阶段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

+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)