阶段10开发完成

This commit is contained in:
tianmo committed 2026-08-31 02:15:38 +08:00
1 parent cffe02c1ec
commit c239171cc4
21 files changed
+997 -36

No files matched your search

+56 -3
View File
@@ -1,8 +1,8 @@
<template>
<div class="image-card">
<div class="imgrow">
<button class="img-thumb" title="选择本地图片" @click="$emit('pick', resource.id)">
<img v-if="resource.previewUrl" :src="resource.previewUrl" :alt="resource.textureName" />
<button class="img-thumb" :title="supportsSequence && resource.imageMode === 'sequence' ? '选择序列帧图片' : '选择本地图片'" @click="pickImage">
<img v-if="displayPreviewUrl" :src="displayPreviewUrl" :alt="resource.textureName" />
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
</button>
<div class="img-fields">
@@ -15,6 +15,25 @@
</div>
</div>
<div class="thumb-note">{{ displayFileName }}</div>
<label v-if="supportsSequence" class="row"><span>图片模式</span>
<select v-model="resource.imageMode" class="inp">
<option value="fixed">固定</option>
<option value="sequence">序列帧</option>
</select>
</label>
<template v-if="supportsSequence && resource.imageMode === 'sequence'">
<label class="row"><span>播放模式</span>
<select v-model="resource.sequencePlayback" class="inp">
<option value="once-hide">单次-尾帧消失</option>
<option value="once-hold">单次-尾帧固定</option>
<option value="loop-forward">正向循环</option>
<option value="loop-reverse">反向循环</option>
</select>
</label>
<label class="row"><span>播放帧率</span><input v-model.number="resource.sequenceFps" type="number" min="1" max="120" step="1" class="inp" /></label>
<button class="sequence-pick" @click="$emit('pickSequence', resource.id)">选择序列帧(多选)</button>
<div class="sequence-note">{{ sequenceOrderText }}</div>
</template>
<div class="resource-weight-row">
<NumSlider
class="resource-weight-control"
@@ -48,6 +67,10 @@ import NumSlider from './NumSlider.vue'
type CommonImageResource = {
id: number
previewUrl: string
imageMode?: 'fixed' | 'sequence'
sequencePlayback?: 'once-hide' | 'once-hold' | 'loop-forward' | 'loop-reverse'
sequenceFps?: number
sequenceFrames?: Array<{ name: string; previewUrl: string }>
textureName: string
textureFolder?: string
weight: number
@@ -60,13 +83,16 @@ const props = withDefaults(defineProps<{
defaultFile: string
namePlaceholder?: string
showFolder?: boolean
supportsSequence?: boolean
}>(), {
namePlaceholder: 'particle',
showFolder: true,
supportsSequence: false,
})
defineEmits<{
const emit = defineEmits<{
pick: [id: number]
pickSequence: [id: number]
reset: [id: number]
remove: [id: number]
updateWeight: [id: number, value: number]
@@ -74,9 +100,33 @@ defineEmits<{
}>()
const displayFileName = computed(() => {
if (props.resource.imageMode === 'sequence') {
const frames = props.resource.sequenceFrames || []
if (!frames.length) return '未选择序列帧'
const first = frames[0]?.name || '第 1 帧'
const last = frames[frames.length - 1]?.name || first
return frames.length === 1 ? `1 帧 · ${first}` : `${frames.length} 帧 · ${first} → ${last}`
}
const name = props.resource.textureName || props.defaultFile.replace(/\.[^.]+$/, '')
return name.includes('.') ? name : `${name}.png`
})
const displayPreviewUrl = computed(() => props.resource.imageMode === 'sequence'
? props.resource.sequenceFrames?.[0]?.previewUrl || ''
: props.resource.previewUrl)
const sequenceOrderText = computed(() => {
const frames = props.resource.sequenceFrames || []
if (!frames.length) return '未选择序列帧'
const first = frames[0]?.name || '第一张图片'
const last = frames[frames.length - 1]?.name || '最后一张图片'
return `已选择 ${frames.length} 帧,按 ${first}-${last} 顺序播放`
})
function pickImage() {
if (props.supportsSequence && props.resource.imageMode === 'sequence') emit('pickSequence', props.resource.id)
else emit('pick', props.resource.id)
}
</script>
<style scoped>
@@ -94,6 +144,9 @@ const displayFileName = computed(() => {
.mini-btn2:hover { background: #2b3a5c; }
.mini-btn2:disabled { opacity: 0.35; cursor: not-allowed; }
.thumb-note { margin: 4px 0 2px; color: #667; font-size: 11px; }
.sequence-pick { width: 100%; margin: 2px 0 4px; padding: 6px 8px; border: 1px solid #4a4a76; border-radius: 5px; background: #27264b; color: #d8d7ff; font-size: 12px; cursor: pointer; }
.sequence-pick:hover { border-color: #7774ff; background: #35336a; }
.sequence-note { margin: 0 0 6px; color: #727b9b; font-size: 11px; line-height: 1.4; }
.resource-weight-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; gap: 7px; }
.resource-weight-control { width: 100%; min-width: 0; }
.resource-weight-control :deep(.ns-range) { min-width: 0; }
+127 -4
View File
@@ -216,8 +216,24 @@
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="setEmitMode('burst')">爆发</button>
</div>
<template v-if="sys.config.mode === 'stream'">
<div class="segs stream-behavior-segs">
<button class="seg" :class="{ on: sys.config.streamBehavior === 'normal' }" @click="setStreamBehavior('normal')">常规持续</button>
<button class="seg" :class="{ on: sys.config.streamBehavior === 'loop' }" @click="setStreamBehavior('loop')">循环持续</button>
</div>
<NumSlider label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
<NumSlider label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
<NumSlider v-if="sys.config.streamBehavior === 'normal'" label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
<NumSlider v-else label="发射时长 (f)" :min="1" :max="1800" :step="1" :model-value="loopDurationFrames" @update:model-value="setLoopDurationFrames" />
<div v-if="sys.config.streamBehavior === 'loop'" class="stream-loop-options">
<label class="switch-row">
<input type="checkbox" :checked="sys.config.generateLoopStartAnimation" @change="setLoopSection('start', $event)" />
<span class="switch-ui"></span><span>生成开始循环动画</span>
</label>
<label class="switch-row">
<input type="checkbox" :checked="sys.config.generateLoopEndAnimation" @change="setLoopSection('end', $event)" />
<span class="switch-ui"></span><span>生成结束循环动画</span>
</label>
</div>
<div v-if="sys.config.streamBehavior === 'loop'" class="modifier-hint stream-loop-hint">循环持续模式不支持骨骼跟随和路径跟随;进入或退出时会将粒子跟随设置恢复默认值。</div>
</template>
<template v-else>
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
@@ -446,8 +462,10 @@
:key="resource.id"
:resource="resource"
:resource-count="sys.config.imageResources.length"
supports-sequence
default-file="star.png"
@pick="openTexturePicker"
@pick-sequence="openSequencePicker"
@reset="resetTexture"
@remove="removeImageResource"
@update-weight="setResourceWeight"
@@ -497,6 +515,7 @@
</template>
</ImageResourceCard>
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
<input ref="sequenceFileInput" class="file-input" type="file" multiple accept="image/png,image/jpeg,image/webp,image/gif" @change="onSequenceFiles" />
</div>
</div>
@@ -699,10 +718,10 @@
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.emitterFollow" />
<input type="checkbox" v-model="sys.config.emitterFollow" :disabled="sys.config.streamBehavior === 'loop'" />
<span class="switch-ui"></span><span>开启发射器跟随</span>
</label>
<div v-if="sys.config.emitterFollow" class="modifier-params emitter-follow-params">
<div v-if="sys.config.emitterFollow && sys.config.streamBehavior !== 'loop'" class="modifier-params emitter-follow-params">
<label class="field-block"><span>跟随模式</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
<option value="none">无</option>
<option value="path">跟随路径</option>
@@ -865,6 +884,11 @@ const activeIdx = computed(() => {
return i >= 0 ? i : 0
})
const sys = computed(() => store.systems[activeIdx.value] || null)
const loopDurationFrames = computed(() => {
const config = sys.value?.config
if (!config) return 1
return Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
})
const activeObjectType = computed(() => store.activeObjectType)
const activeCollider = computed(() => store.activeObjectType === 'collision'
? store.colliders.find((item) => item.id === store.activeObjectId) || null
@@ -911,6 +935,9 @@ watchEffect(() => {
if (config.scaleMode === 'curve') config.scaleMode = 'random'
if (config.initialRotationMode === 'curve') config.initialRotationMode = 'random'
if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1
if (config.streamBehavior === 'loop') {
if (config.emitterFollow || config.emitterFollowMode !== 'none') resetParticleFollow(config)
}
})
// 每个粒子系统永久保存自己的 Spine ID 与骨骼名;层级树当前选择不会覆盖既有绑定。
@@ -965,6 +992,62 @@ function setEmitMode(mode: 'stream' | 'burst') {
if (mode === 'stream' && (!(config.duration > 0))) config.duration = 1
}
function setLoopDurationFrames(value: number) {
const config = sys.value?.config
if (!config) return
const frames = Math.min(1800, Math.max(1, Math.ceil(Number(value) || 1)))
config.loopDurationFrames = frames
}
function resetParticleFollow(config: any) {
const defaults = defaultConfig()
config.emitterFollow = false
config.emitterFollowMode = 'none'
config.emitterFollowPathId = defaults.emitterFollowPathId
config.emitterFollowSpineId = defaults.emitterFollowSpineId
config.emitterFollowBoneName = defaults.emitterFollowBoneName
config.emitterFollowBoneOffsetX = defaults.emitterFollowBoneOffsetX
config.emitterFollowBoneOffsetY = defaults.emitterFollowBoneOffsetY
config.emitterFollowBoneRotation = defaults.emitterFollowBoneRotation
config.emitterFollowBoneScaleX = defaults.emitterFollowBoneScaleX
config.emitterFollowBoneScaleY = defaults.emitterFollowBoneScaleY
config.emitterAngleMode = defaults.emitterAngleMode
config.emitterFollowCurve = defaults.emitterFollowCurve.map((point) => ({ ...point }))
config.emitterFollowDuration = defaults.emitterFollowDuration
config.emitterFollowSpace = defaults.emitterFollowSpace
config.emitterFollowDirection = defaults.emitterFollowDirection
config.emitterFollowOffset = defaults.emitterFollowOffset
}
function setStreamBehavior(behavior: 'normal' | 'loop') {
const config = sys.value?.config
if (!config || config.streamBehavior === behavior) return
const entering = behavior === 'loop'
const message = entering
? '进入循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?'
: '退出循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?'
if (!window.confirm(message)) return
resetParticleFollow(config)
config.streamBehavior = behavior
if (!entering) {
config.generateLoopStartAnimation = false
config.generateLoopEndAnimation = false
}
}
function setLoopSection(section: 'start' | 'end', event: Event) {
const config = sys.value?.config
if (!config) return
const checked = (event.target as HTMLInputElement).checked
if (section === 'start') {
config.generateLoopStartAnimation = checked
if (checked) config.generateLoopEndAnimation = false
} else {
config.generateLoopEndAnimation = checked
if (checked) config.generateLoopStartAnimation = false
}
}
function onEmitterFollowModeChange(event: Event, config: {
emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowSpineId: number
@@ -1000,7 +1083,10 @@ function bindSelectedBone(config: {
type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export'
const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
emitMode: ['mode', 'rate', 'burstCount', 'burstLoop', 'delay', 'duration'],
emitMode: [
'mode', 'rate', 'streamBehavior', 'loopDurationFrames', 'generateLoopStartAnimation', 'generateLoopEndAnimation',
'burstCount', 'burstLoop', 'delay', 'duration',
],
shape: ['shape', 'radius', 'rectW', 'rectH', 'direction', 'spread', 'coneAngle'],
attr: [
'lifeMin', 'lifeMax', 'lifeMode', 'lifeCurve',
@@ -1149,6 +1235,7 @@ function onShapeChange() {
}
// 重置/删除图片 → 回到默认 star.png
const textureFileInput = ref<HTMLInputElement | null>(null)
const sequenceFileInput = ref<HTMLInputElement | null>(null)
const pendingResourceId = ref<number | null>(null)
const trailTextureFileInput = ref<HTMLInputElement | null>(null)
const pendingTrailResourceId = ref<number | null>(null)
@@ -1331,6 +1418,10 @@ function addImageResource() {
textureName: 'star',
textureFolder: '',
previewUrl: '/particles/star.png',
imageMode: 'fixed',
sequencePlayback: 'loop-forward',
sequenceFps: 30,
sequenceFrames: [],
weight: 100,
locked: false,
scale: 1,
@@ -1354,6 +1445,11 @@ function openTexturePicker(resourceId: number) {
textureFileInput.value?.click()
}
function openSequencePicker(resourceId: number) {
pendingResourceId.value = resourceId
sequenceFileInput.value?.click()
}
async function onTextureFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -1370,6 +1466,25 @@ async function onTextureFile(event: Event) {
pendingResourceId.value = null
}
async function onSequenceFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || []).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
const config = sys.value?.config
const resource = config?.imageResources.find((item) => item.id === pendingResourceId.value)
if (!files.length || !resource) { input.value = ''; return }
resource.sequenceFrames = await Promise.all(files.map(async (file) => {
const previewUrl = await readFileAsDataUrl(file)
return {
name: file.name,
previewUrl,
texture: markRaw(await Texture.fromURL(previewUrl)) as any,
}
}))
resource.imageMode = 'sequence'
input.value = ''
pendingResourceId.value = null
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
@@ -1387,6 +1502,10 @@ function resetTexture(resourceId: number) {
resource.textureName = 'star'
resource.textureFolder = ''
resource.previewUrl = '/particles/star.png'
resource.imageMode = 'fixed'
resource.sequencePlayback = 'loop-forward'
resource.sequenceFps = 30
resource.sequenceFrames = []
resource.scale = 1
resource.anchorX = 0.5
resource.anchorY = 0.5
@@ -1605,6 +1724,8 @@ function toggleResourceLock(resourceId: number) {
.pair { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.colr { width: 28px; height: 22px; border: none; background: none; cursor: pointer; }
.segs { display: flex; gap: 6px; margin: 6px 0; }
.stream-behavior-segs { margin-top: 10px; }
.stream-loop-options { display: grid; gap: 8px; margin: 8px 0 4px; padding: 9px 10px; border: 1px solid #32405a; border-radius: 6px; background: #171f2e; }
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.mini-btn { background: #2e3a55; border: 1px solid #3a4a6a; color: #cdf; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
@@ -1685,6 +1806,8 @@ function toggleResourceLock(resourceId: number) {
.modifier-switch { font-weight: 600; }
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
.stream-loop-hint { margin: 9px 2px 3px; padding: 7px 8px; border: 1px solid #4a436e; border-radius: 5px; background: #211f35; color: #aaa4d7; }
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
.bone-follow-target { margin: 7px 0 9px; padding: 7px 8px; overflow: hidden; color: #7f8ba3; background: #121a29; border: 1px solid #2f3c53; border-radius: 5px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.bone-follow-target strong { color: #c8d0e4; font-weight: 600; }
.bone-follow-missing { margin: 7px 0 9px; padding: 7px 8px; color: #d49aa3; background: #2a1e26; border: 1px solid #513540; border-radius: 5px; font-size: 10px; line-height: 1.5; }
+125 -7
View File
@@ -110,7 +110,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
import { useParticleStore, type CollisionBody, type ParticleSystem, type ScenePath } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { editorHistoryState, installEditorHistory, setUndoLimit, undoLastOperation } from '../editor/editorHistory'
import { useEditorShortcuts } from '../editor/useEditorShortcuts'
@@ -1073,6 +1073,106 @@ function appendStateBones(target: BonePreview[], state: ParticleState) {
})
}
function cloneParticleFrame(frame: ParticleState[]): ParticleState[] {
return frame.map((state) => ({
...state,
trailState: state.trailState
? { ...state.trailState, bones: state.trailState.bones.map((bone) => ({ ...bone })) }
: undefined,
}))
}
/**
* 纯循环段保持同一组粒子骨骼贯穿整个片段。
* 某一粒子在正常生命周期之外不删除骨骼,而是生成 alpha=0 的“假死”快照;
* 因此视觉上仍按正常时机消失,结构上却不会因骨骼增删破坏首尾衔接。
*/
function stabilizeLoopParticleBones(frames: ParticleState[][]) {
const templates = new Map<string, ParticleState>()
for (const frame of frames) {
for (const state of frame) if (!templates.has(state.boneName)) templates.set(state.boneName, cloneParticleFrame([state])[0])
}
const boneNames = [...templates.keys()].sort((a, b) => {
const ai = Number(a.replace(/^p_/, ''))
const bi = Number(b.replace(/^p_/, ''))
return Number.isFinite(ai) && Number.isFinite(bi) ? ai - bi : a.localeCompare(b)
})
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const byName = new Map(frames[frameIndex].map((state) => [state.boneName, state]))
frames[frameIndex] = boneNames.map((boneName) => {
const current = byName.get(boneName)
if (current) return current
const template = cloneParticleFrame([templates.get(boneName)!])[0]
return { ...template, alpha: 0, imageVisible: false, trailState: undefined, active: true }
})
}
}
/**
* 预先生成循环持续的独立片段。
* 三种片段使用相同种子、相同固定步长和相同预热步数,因此启动末帧、纯循环首尾帧、结束首帧完全一致。
*/
function bakeLoopFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number) {
const config = system.config
const frameDuration = 1 / Math.max(1, fps)
const maxLife = config.lifeMode === 'fixed'
? Math.max(0, Number(config.lifeMin) || 0)
: Math.max(0, Number(config.lifeMin) || 0, Number(config.lifeMax) || 0)
const durationFrames = Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
const maxLifeFrames = Math.max(1, Math.ceil(maxLife * fps))
const warmupCycles = Math.max(1, Math.ceil(maxLifeFrames / durationFrames))
const delayFrames = Math.max(0, Math.ceil(Math.max(0, Number(config.delay) || 0) * fps))
const frames: ParticleState[][] = []
emitter.reset()
if (config.generateLoopStartAnimation) {
for (let index = 0; index < delayFrames; index++) frames.push([])
// 启动段的第一帧严格为空;随后自然预热到和纯循环段完全相同的稳定衔接状态。
frames.push([])
for (let cycle = 0; cycle < warmupCycles; cycle++) {
if (cycle > 0) emitter.restartEmissionCycle()
for (let index = 0; index < durationFrames; index++) {
emitter.update(frameDuration, 'force')
frames.push(emitter.capture())
}
}
} else {
// 预跑足够多的完整周期,让跨周期存活的粒子进入稳定分布。
for (let cycle = 0; cycle < warmupCycles; cycle++) {
if (cycle > 0) emitter.restartEmissionCycle()
for (let index = 0; index < durationFrames; index++) emitter.update(frameDuration, 'force')
}
const seam = emitter.capture()
// 纯循环和结束段在延迟期间保持循环衔接帧;延迟结束后才开始推进周期或消亡。
for (let index = 0; index < delayFrames; index++) frames.push(cloneParticleFrame(seam))
frames.push(cloneParticleFrame(seam))
if (config.generateLoopEndAnimation) {
// 结束段从衔接帧开始,停止产生新粒子,直到粒子和其拖尾全部消失。
const safetyFrames = Math.max(1, Math.ceil(maxLife * fps) + 2)
for (let index = 0; index < safetyFrames && emitter.activeCount > 0; index++) {
emitter.update(frameDuration, 'off')
frames.push(emitter.capture())
}
if (frames[frames.length - 1]?.length) frames.push([])
} else {
// 重新开始相同的发射周期,并保留跨周期粒子;末端点由真实演算产生,不再硬复制首帧。
emitter.restartEmissionCycle()
for (let index = 0; index < durationFrames; index++) {
emitter.update(frameDuration, 'force')
frames.push(emitter.capture())
}
stabilizeLoopParticleBones(frames)
}
}
const finalFrame = cloneParticleFrame(frames[frames.length - 1] || [])
if (frames.length > totalFrames) frames.length = totalFrames
while (frames.length < totalFrames) frames.push(cloneParticleFrame(finalFrame))
system.frames = frames
emitter.apply(frames[0] || [])
}
function drawBones(bones: BonePreview[]) {
if (!boneGfx) return
const g = boneGfx
@@ -1407,18 +1507,33 @@ function loop() {
total = 0
if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
if (tl.frame === 0) {
for (const sys of store.systems) {
const emitter = emitterMap.get(sys.id)
if (!emitter) continue
if (sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop') {
bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
} else {
emitter.reset()
}
}
}
for (const [systemId, em] of emitterMap) {
const states = em.update(frameDuration)
const systemVisible = store.systems.find((item) => item.id === systemId)?.visible !== false
const system = store.systems.find((item) => item.id === systemId)
const loopFrame = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
? system.frames?.[tl.frame]
: undefined
const states = loopFrame || em.update(frameDuration)
if (loopFrame) em.apply(loopFrame)
const systemVisible = system?.visible !== false
if (systemVisible) for (const state of states) if (state.active) appendStateBones(collected, state)
total += em.activeCount
total += states.length
}
for (const sys of store.systems) {
if (!sys.config.texture) continue
sys.frames = sys.frames || []
const emitter = emitterMap.get(sys.id)
if (emitter) sys.frames[tl.frame] = emitter.capture()
if (emitter && !(sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop')) sys.frames[tl.frame] = emitter.capture()
}
tl.frame++
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true }
@@ -1451,7 +1566,10 @@ function loop() {
// 回放缓存存在时不再调用 update(0),避免用首次模拟结束后的旧活动状态覆盖数量。
const replayFrameIndex = tl.playing ? timelineDisplayFrame : tl.frame
const frame = sys.frames?.[replayFrameIndex]
if (tl.recorded && emitter && frame) {
const hasPreparedLoopFrames = sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop' && !!frame
// 循环持续会在录制首帧一次性准备完整缓存。即使全局录制尚未结束,帧间也必须继续读取缓存,
// 不能调用 update(0) 回到预演结束时的内部粒子状态,否则画面会在两套状态之间高频闪烁。
if ((tl.recorded || hasPreparedLoopFrames) && emitter && frame) {
emitter.apply(frame)
for (const state of frame) if (state.active) {
if (sys.visible !== false) appendStateBones(collected, state)
+24 -2
View File
@@ -10,7 +10,7 @@
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环">⟳</button>
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ store.timeline.totalFrames }}</b></span>
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ Math.max(0, store.timeline.totalFrames - 1) }}</b></span>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
<span>所属动画</span>
@@ -91,7 +91,7 @@
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
<span class="bar-life" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<span
v-if="sys.config.mode === 'stream'"
v-if="sys.config.mode === 'stream' && !isLoopEnd(sys)"
class="bar-emission"
:style="{ width: emissionWidthPx(sys) + 'px' }"
>
@@ -262,16 +262,37 @@ function barWidthPx(sys: SysLike) {
function emissionWidthPx(sys: SysLike) {
if ((sys.config as any).mode !== 'stream') return 0
if (isLoopEnd(sys)) return 0
if ((sys.config as any).streamBehavior === 'loop') {
const config = sys.config as any
const durationFrames = Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
if (config.generateLoopStartAnimation) {
const warmupCycles = Math.max(1, Math.ceil((maxParticleLife(sys) * fps.value) / durationFrames))
return (durationFrames * warmupCycles) / pxPerF()
}
return durationFrames / pxPerF()
}
const duration = draftDurations.value[sys.id] ?? Math.max(1 / fps.value, Number((sys.config as any).duration) || 1)
return (duration * fps.value) / pxPerF()
}
function lifeWidthPx(sys: SysLike) {
const config = sys.config as any
if (config.mode === 'stream' && config.streamBehavior === 'loop') {
// 启动段和纯循环段的存在时长与发射段完全重合;结束段只显示剩余生命/拖尾。
if (!config.generateLoopEndAnimation) return emissionWidthPx(sys)
return ((maxParticleLife(sys) * fps.value) + 1) / pxPerF()
}
// 红色底条表示整个系统“仍有粒子存在”的时长:持续发射窗口 + 最长粒子生命。
// 爆发模式的发射窗口为 0,因此只保留最长粒子生命。
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
}
function isLoopEnd(sys: SysLike) {
const config = sys.config as any
return config.mode === 'stream' && config.streamBehavior === 'loop' && config.generateLoopEndAnimation
}
function selectedSpineDuration(spine: SpineSceneObject) {
return spine.animations.find((animation) => animation.name === spine.selectedAnimation)?.duration || 0
}
@@ -337,6 +358,7 @@ function onBarDown(e: PointerEvent, sys: SysLike) {
// 拖动粒子条右缘:调整发射时长 duration
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
if (isLoopEnd(sys)) return
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
const secPerPx = pxPerF() / fps.value
const barLeft = (e.currentTarget as HTMLElement).closest('.lane-bar')?.getBoundingClientRect().left ?? e.clientX