阶段10开发完成
This commit is contained in:
1 parent
cffe02c1ec
commit
c239171cc4
21 files changed
+997
-36
No files matched your search
+125
-7
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user