阶段10开发完成
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -42,6 +42,13 @@ export interface ParticleImageResource {
|
||||
textureName: string
|
||||
textureFolder: string
|
||||
previewUrl: string
|
||||
/** 图片模式:固定图片或按粒子出生时间播放的序列帧。 */
|
||||
imageMode: 'fixed' | 'sequence'
|
||||
/** 序列帧播放方式:单次隐藏、单次定格、正向循环或反向循环。 */
|
||||
sequencePlayback: 'once-hide' | 'once-hold' | 'loop-forward' | 'loop-reverse'
|
||||
/** 序列帧每秒播放帧数。 */
|
||||
sequenceFps: number
|
||||
sequenceFrames: ParticleSequenceFrame[]
|
||||
/** 发射权重,多个资源按权重随机选择 */
|
||||
weight: number
|
||||
/** 锁定后,调整其他资源时保持当前占比 */
|
||||
@@ -61,6 +68,12 @@ export interface ParticleImageResource {
|
||||
alphaCurve: CurvePoint[]
|
||||
}
|
||||
|
||||
export interface ParticleSequenceFrame {
|
||||
name: string
|
||||
previewUrl: string
|
||||
texture: Texture<any> | null
|
||||
}
|
||||
|
||||
export interface TrailImageResource {
|
||||
id: number
|
||||
texture: Texture<any> | null
|
||||
@@ -96,6 +109,14 @@ export interface EmitterConfig {
|
||||
// 发射
|
||||
mode: EmitMode
|
||||
rate: number // 持续:每秒发射数
|
||||
/** 持续发射表现:常规持续或可无缝衔接的循环段。 */
|
||||
streamBehavior: 'normal' | 'loop'
|
||||
/** 循环持续的独立时长(帧),不覆盖常规持续的 duration 秒数。 */
|
||||
loopDurationFrames: number
|
||||
/** 循环持续模式下是否生成循环开始动画。 */
|
||||
generateLoopStartAnimation: boolean
|
||||
/** 循环持续模式下是否生成循环结束动画。 */
|
||||
generateLoopEndAnimation: boolean
|
||||
burstCount: number // 爆发:单次数量
|
||||
burstLoop: boolean // 爆发是否每秒重复
|
||||
/** 延迟发射(秒)= 粒子条左端位置 */
|
||||
@@ -304,6 +325,10 @@ export interface ParticleState {
|
||||
alpha: number
|
||||
colorHex: number
|
||||
resourceId?: number
|
||||
/** 当前图片资源的序列帧索引,固定图片为 0。 */
|
||||
imageFrameIndex?: number
|
||||
/** 单次播放结束后可只隐藏图片,粒子和拖尾仍继续存在。 */
|
||||
imageVisible?: boolean
|
||||
/** 拖尾拥有独立骨骼链和蒙皮状态,录制/回放时一并保存。 */
|
||||
trailState?: TrailState
|
||||
active: boolean
|
||||
@@ -333,6 +358,10 @@ export function defaultConfig(): EmitterConfig {
|
||||
textureName: 'star',
|
||||
textureFolder: '',
|
||||
previewUrl: '/particles/star.png',
|
||||
imageMode: 'fixed',
|
||||
sequencePlayback: 'loop-forward',
|
||||
sequenceFps: 30,
|
||||
sequenceFrames: [],
|
||||
weight: 100,
|
||||
locked: false,
|
||||
scale: 1,
|
||||
@@ -352,6 +381,10 @@ export function defaultConfig(): EmitterConfig {
|
||||
name: 'ParticleSystem1',
|
||||
mode: 'stream',
|
||||
rate: 20,
|
||||
streamBehavior: 'normal',
|
||||
loopDurationFrames: 30,
|
||||
generateLoopStartAnimation: false,
|
||||
generateLoopEndAnimation: false,
|
||||
burstCount: 50,
|
||||
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
|
||||
delay: 0,
|
||||
@@ -532,6 +565,7 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
const defaults = defaultConfig() as unknown as Record<string, unknown>
|
||||
const target = config as unknown as Record<string, unknown>
|
||||
const needsImageWeightMigration = target.imageWeightVersion !== 1
|
||||
const needsLoopDurationMigration = target.loopDurationFrames == null
|
||||
const needsGravityMigration = target.gravityMode == null
|
||||
const legacyGravity = Number(target.gravity)
|
||||
const needsWindMigration = target.windMode == null
|
||||
@@ -551,6 +585,12 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item)
|
||||
: value
|
||||
}
|
||||
if (config.streamBehavior !== 'normal' && config.streamBehavior !== 'loop') config.streamBehavior = 'normal'
|
||||
if (needsLoopDurationMigration || !Number.isFinite(config.loopDurationFrames)) config.loopDurationFrames = Math.ceil(Math.max(1 / 30, Number(config.duration) || 1) * 30)
|
||||
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
|
||||
if (typeof config.generateLoopStartAnimation !== 'boolean') config.generateLoopStartAnimation = false
|
||||
if (typeof config.generateLoopEndAnimation !== 'boolean') config.generateLoopEndAnimation = false
|
||||
if (config.generateLoopStartAnimation && config.generateLoopEndAnimation) config.generateLoopEndAnimation = false
|
||||
if (!Array.isArray(config.imageResources) || config.imageResources.length === 0) {
|
||||
config.imageResources = defaultConfig().imageResources
|
||||
}
|
||||
@@ -558,6 +598,20 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
if (!resource.texture && config.texture) resource.texture = config.texture
|
||||
if (!resource.textureName) resource.textureName = 'star'
|
||||
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/particles/star.png' : ''
|
||||
if (resource.imageMode !== 'fixed' && resource.imageMode !== 'sequence') resource.imageMode = 'fixed'
|
||||
if ((resource.sequencePlayback as string) === 'once') resource.sequencePlayback = 'once-hold'
|
||||
else if ((resource.sequencePlayback as string) === 'loop') resource.sequencePlayback = 'loop-forward'
|
||||
else if (!['once-hide', 'once-hold', 'loop-forward', 'loop-reverse'].includes(resource.sequencePlayback)) {
|
||||
resource.sequencePlayback = 'loop-forward'
|
||||
}
|
||||
if (!Number.isFinite(resource.sequenceFps)) resource.sequenceFps = 30
|
||||
resource.sequenceFps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
|
||||
if (!Array.isArray(resource.sequenceFrames)) resource.sequenceFrames = []
|
||||
for (const frame of resource.sequenceFrames) {
|
||||
if (typeof frame.name !== 'string') frame.name = ''
|
||||
if (typeof frame.previewUrl !== 'string') frame.previewUrl = ''
|
||||
if (frame.texture === undefined) frame.texture = null
|
||||
}
|
||||
if (!Number.isFinite(resource.weight)) resource.weight = 100
|
||||
if (typeof resource.locked !== 'boolean') resource.locked = false
|
||||
if (!Number.isFinite(resource.scale)) resource.scale = 1
|
||||
@@ -719,6 +773,7 @@ interface Particle {
|
||||
trailCurrentColor: number
|
||||
trailResourceId: number
|
||||
resourceId: number
|
||||
imageFrameIndex: number
|
||||
collisionPaused: boolean
|
||||
}
|
||||
|
||||
@@ -797,6 +852,7 @@ export class ParticleEmitter extends Container {
|
||||
trailCurrentColor: 0xffffff,
|
||||
trailResourceId: 1,
|
||||
resourceId: 1,
|
||||
imageFrameIndex: 0,
|
||||
collisionPaused: false,
|
||||
})
|
||||
}
|
||||
@@ -850,8 +906,8 @@ export class ParticleEmitter extends Container {
|
||||
return false
|
||||
}
|
||||
|
||||
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
|
||||
update(dt: number): ParticleState[] {
|
||||
/** 每帧更新;循环段录制时可强制发射或停止发射,以生成稳定衔接帧。 */
|
||||
update(dt: number, emissionControl: 'normal' | 'force' | 'off' = 'normal'): ParticleState[] {
|
||||
// HMR 会保留已创建的系统对象;旧对象缺少新增字段时在这里即时补齐。
|
||||
const cfg = this.cfg
|
||||
if (
|
||||
@@ -873,9 +929,9 @@ export class ParticleEmitter extends Container {
|
||||
for (const particle of this.pool) particle.attractionActive = false
|
||||
}
|
||||
this._attractionWasEnabled = cfg.attraction
|
||||
const inWindow = cfg.delay <= 0 || this._elapsed >= cfg.delay
|
||||
const inWindow = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.delay <= 0 || this._elapsed >= cfg.delay))
|
||||
// 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。
|
||||
const withinDuration = cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)
|
||||
const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)))
|
||||
// 发射(仅在窗口内且未超时长)
|
||||
if (inWindow && withinDuration) {
|
||||
if (cfg.mode === 'stream') {
|
||||
@@ -1011,6 +1067,12 @@ export class ParticleEmitter extends Container {
|
||||
const t = lifeT
|
||||
const s = p.sprite
|
||||
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
|
||||
if (resource) {
|
||||
const frameState = this.imageFrameState(resource, p.maxLife - p.life)
|
||||
p.imageFrameIndex = frameState.index
|
||||
s.texture = this.imageFrameTexture(resource, p.imageFrameIndex)
|
||||
s.visible = frameState.visible
|
||||
}
|
||||
|
||||
if (cfg.speedOverLifeEnabled) {
|
||||
const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1)
|
||||
@@ -1057,7 +1119,7 @@ export class ParticleEmitter extends Container {
|
||||
s.tint = rgb
|
||||
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
||||
const trailState = this.updateTrail(p, renderX, renderY, t, s)
|
||||
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, trailState, active: true })
|
||||
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: s.visible, trailState, active: true })
|
||||
}
|
||||
this.states = states
|
||||
return states
|
||||
@@ -1352,11 +1414,12 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
const p = this.pool[idx]
|
||||
const resource = this.pickImageResource()
|
||||
const spawnSerial = this._spawnSerial++
|
||||
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
||||
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, this._spawnSerial++, 0x5bd1e995))
|
||||
// 噪声相位由全局随机种子和粒子池编号确定,同一配置重复录制得到相同轨迹。
|
||||
p.noisePhaseX = seededUnit(cfg.seed, idx, 0x68bc21eb) * Math.PI * 2
|
||||
p.noisePhaseY = seededUnit(cfg.seed, idx, 0x02e5be93) * Math.PI * 2
|
||||
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
|
||||
// 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。
|
||||
p.noisePhaseX = seededUnit(cfg.seed, spawnSerial, 0x68bc21eb) * Math.PI * 2
|
||||
p.noisePhaseY = seededUnit(cfg.seed, spawnSerial, 0x02e5be93) * Math.PI * 2
|
||||
p.gravityStrength = cfg.useGravity && cfg.gravityMode === 'random'
|
||||
? lerp(cfg.gravityMin, cfg.gravityMax, this.rng())
|
||||
: cfg.gravityMin
|
||||
@@ -1366,7 +1429,7 @@ export class ParticleEmitter extends Container {
|
||||
p.attractionActive = false
|
||||
p.attractionElapsed = 0
|
||||
p.collisionPaused = false
|
||||
p.attractionCurveSide = seededUnit(cfg.seed, idx, 0xa341316c) < 0.5 ? -1 : 1
|
||||
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
|
||||
p.trailHistory.length = 0
|
||||
p.trailBones.length = 0
|
||||
p.trailCurrentWidth = 0
|
||||
@@ -1400,7 +1463,9 @@ export class ParticleEmitter extends Container {
|
||||
? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng())
|
||||
: cfg.scaleYOverLifeMin
|
||||
p.resourceId = resource.id
|
||||
p.sprite.texture = resource.texture || cfg.texture
|
||||
const initialFrame = this.imageFrameState(resource, 0)
|
||||
p.imageFrameIndex = initialFrame.index
|
||||
p.sprite.texture = this.imageFrameTexture(resource, initialFrame.index)
|
||||
p.sprite.anchor.set(resource.anchorX, resource.anchorY)
|
||||
p.sprite.blendMode = BLEND_MAP[resource.blend] as any
|
||||
p.sprite.tint = hexToNumber(resource.colorStart)
|
||||
@@ -1408,10 +1473,11 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
|
||||
private pickImageResource(): ParticleImageResource {
|
||||
const resources = this.cfg.imageResources.filter((resource) => resource.texture)
|
||||
const resources = this.cfg.imageResources.filter((resource) => resource.texture || resource.sequenceFrames.some((frame) => frame.texture))
|
||||
if (!resources.length) return {
|
||||
id: 1, texture: this.cfg.texture, textureName: 'star', textureFolder: '',
|
||||
previewUrl: '/particles/star.png', weight: 100, locked: false, scale: 1, anchorX: 0.5, anchorY: 0.5,
|
||||
previewUrl: '/particles/star.png', imageMode: 'fixed', sequencePlayback: 'loop-forward', sequenceFps: 30, sequenceFrames: [],
|
||||
weight: 100, locked: false, scale: 1, anchorX: 0.5, anchorY: 0.5,
|
||||
colorMode: 'fixed', colorStart: '#ffffff', colorEnd: '#ff4d4d', blend: 'normal',
|
||||
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
|
||||
independentAlpha: 'off', alpha: 1, alphaCurveMode: '跟随导出',
|
||||
@@ -1427,6 +1493,28 @@ export class ParticleEmitter extends Container {
|
||||
return resources[resources.length - 1]
|
||||
}
|
||||
|
||||
private imageFrameState(resource: ParticleImageResource, age: number) {
|
||||
if (resource.imageMode !== 'sequence' || resource.sequenceFrames.length <= 1) return { index: 0, visible: true }
|
||||
const fps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
|
||||
const rawIndex = Math.max(0, Math.floor(Math.max(0, age) * fps))
|
||||
const lastIndex = resource.sequenceFrames.length - 1
|
||||
if (resource.sequencePlayback === 'once-hide') {
|
||||
return { index: Math.min(lastIndex, rawIndex), visible: rawIndex <= lastIndex }
|
||||
}
|
||||
if (resource.sequencePlayback === 'once-hold') return { index: Math.min(lastIndex, rawIndex), visible: true }
|
||||
if (resource.sequencePlayback === 'loop-reverse') return { index: lastIndex - rawIndex % resource.sequenceFrames.length, visible: true }
|
||||
return { index: rawIndex % resource.sequenceFrames.length, visible: true }
|
||||
}
|
||||
|
||||
private imageFrameTexture(resource: ParticleImageResource, frameIndex: number) {
|
||||
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
|
||||
return resource.sequenceFrames[Math.max(0, Math.min(resource.sequenceFrames.length - 1, frameIndex))]?.texture
|
||||
|| resource.texture
|
||||
|| this.cfg.texture
|
||||
}
|
||||
return resource.texture || this.cfg.texture
|
||||
}
|
||||
|
||||
private pickTrailResource(sample: number): TrailImageResource {
|
||||
const resources = this.cfg.trailResources.filter((resource) => resource.texture)
|
||||
if (!resources.length) return this.cfg.trailResources[0] || {
|
||||
@@ -1483,7 +1571,7 @@ export class ParticleEmitter extends Container {
|
||||
// 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。
|
||||
boneName: p.boneName, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
|
||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, active: true,
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: p.sprite.visible, active: true,
|
||||
trailState: this.cfg.trail && p.trailBones.length
|
||||
? {
|
||||
bones: p.trailBones.map((bone) => ({ ...bone })),
|
||||
@@ -1512,7 +1600,7 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
p.active = st.active
|
||||
const s = p.sprite
|
||||
s.visible = st.active
|
||||
s.visible = st.active && st.imageVisible !== false
|
||||
s.x = st.x; s.y = st.y
|
||||
s.rotation = -st.rotation * Math.PI / 180
|
||||
s.scale.set(st.scaleX, st.scaleY)
|
||||
@@ -1520,8 +1608,9 @@ export class ParticleEmitter extends Container {
|
||||
s.tint = st.colorHex
|
||||
if (st.resourceId != null) {
|
||||
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
|
||||
if (resource?.texture) {
|
||||
s.texture = resource.texture
|
||||
if (resource) {
|
||||
p.imageFrameIndex = st.imageFrameIndex ?? 0
|
||||
s.texture = this.imageFrameTexture(resource, p.imageFrameIndex)
|
||||
s.anchor.set(resource.anchorX, resource.anchorY)
|
||||
s.blendMode = BLEND_MAP[resource.blend] as any
|
||||
}
|
||||
@@ -1555,6 +1644,21 @@ export class ParticleEmitter extends Container {
|
||||
this._lastSeed = this.cfg.seed
|
||||
this.rng = mulberry32(this.cfg.seed >>> 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始下一个循环周期,但保留仍存活粒子的物理状态。
|
||||
* 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。
|
||||
*/
|
||||
restartEmissionCycle() {
|
||||
this.accumulator = 0
|
||||
this.burstTimer = 0
|
||||
this._spawnSerial = 0
|
||||
this._elapsed = 0
|
||||
this._attractionEnabledAt = 0
|
||||
this._attractionWasEnabled = this.cfg.attraction
|
||||
this._lastSeed = this.cfg.seed
|
||||
this.rng = mulberry32(this.cfg.seed >>> 0)
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
|
||||
|
||||
@@ -129,6 +129,9 @@ async function hydrateParticleResources(systems: ParticleSystem[]) {
|
||||
config.texture = defaultTexture
|
||||
await Promise.all(config.imageResources.map(async (resource) => {
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/particles/star.png')
|
||||
await Promise.all(resource.sequenceFrames.map(async (frame) => {
|
||||
frame.texture = await loadTexture(frame.previewUrl || resource.previewUrl || '/particles/star.png')
|
||||
}))
|
||||
}))
|
||||
await Promise.all(config.trailResources.map(async (resource) => {
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/trails/trail.png')
|
||||
|
||||
@@ -465,8 +465,25 @@ export const useParticleStore = defineStore('particle', {
|
||||
const particleLife = c.lifeMode === 'fixed'
|
||||
? lifeMin
|
||||
: Math.max(lifeMin, lifeMax)
|
||||
const emissionDuration = c.mode === 'stream' ? Math.max(1 / fps, Number(c.duration) || 0) : 0
|
||||
const end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife
|
||||
const emissionDuration = c.mode === 'stream'
|
||||
? c.streamBehavior === 'loop'
|
||||
? Math.max(1 / fps, Math.ceil(Math.max(1, Number(c.loopDurationFrames) || 1)) / fps)
|
||||
: Math.max(1 / fps, Number(c.duration) || 0)
|
||||
: 0
|
||||
let end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife
|
||||
if (c.mode === 'stream' && c.streamBehavior === 'loop') {
|
||||
if (c.generateLoopEndAnimation) {
|
||||
// 结束段从循环衔接帧开始,立即停止生成;额外保留一帧用于明确记录“全部死亡”。
|
||||
end = Math.max(0, Number(c.delay) || 0) + particleLife + 1 / fps
|
||||
} else if (c.generateLoopStartAnimation) {
|
||||
// 启动段从空状态自然预热到稳定循环衔接帧;寿命跨越多个周期时自动增加预热周期。
|
||||
const warmupCycles = Math.max(1, Math.ceil(particleLife / emissionDuration))
|
||||
end = Math.max(0, Number(c.delay) || 0) + emissionDuration * warmupCycles
|
||||
} else {
|
||||
// 纯循环段延迟期间保持衔接帧,之后播放一个完整周期,不追加常规生命周期尾段。
|
||||
end = Math.max(0, Number(c.delay) || 0) + emissionDuration
|
||||
}
|
||||
}
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
for (const spine of this.spines) {
|
||||
@@ -480,7 +497,8 @@ export const useParticleStore = defineStore('particle', {
|
||||
const end = Math.max(0, Number(collider.followDuration) || 0)
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
const frames = Math.max(1, Math.ceil(maxSec * fps))
|
||||
// totalFrames 表示快照数量;0 秒和末端点都可选,因此比末端帧编号多 1。
|
||||
const frames = Math.max(1, Math.ceil(maxSec * fps) + 1)
|
||||
this.timeline.totalFrames = frames
|
||||
// clamp 当前帧
|
||||
if (this.timeline.frame >= frames) this.timeline.frame = frames - 1
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
# 网页粒子系统 · 阶段十
|
||||
|
||||
> 阶段定位:**粒子图片序列帧与循环持续三段动画体系定版**
|
||||
> 完成日期:2026-08-31
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段结论
|
||||
|
||||
阶段十在阶段九配置持久化、撤回和预设体系的基础上,扩展了粒子图片资源的序列帧能力,并完成了持续发射模式下“启动—循环—结束”三段动画的编辑和预览模型。
|
||||
|
||||
图片资源现在可以在固定图片和序列帧之间切换。每个序列帧资源仍属于同一个粒子和同一根粒子骨骼,只在播放过程中替换纹理,不会因为一组序列图而生成多根粒子骨骼。
|
||||
|
||||
持续发射模式新增“常规持续”和“循环持续”。循环持续通过稳定周期预演、确定性随机序列、完整帧缓存和假死粒子状态解决首尾衔接问题,并支持分别生成启动段、纯循环段和结束段。
|
||||
|
||||
本阶段主要完成:
|
||||
|
||||
1. 图片资源增加固定图片与序列帧两种模式;
|
||||
2. 支持多选图片并按自然文件名顺序组成序列;
|
||||
3. 支持单次尾帧消失、单次尾帧固定、正向循环和反向循环;
|
||||
4. 支持独立序列帧播放帧率;
|
||||
5. 序列帧状态纳入粒子录制、回放、保存、加载和撤回;
|
||||
6. 持续发射增加常规持续与循环持续两种表现;
|
||||
7. 循环时长使用独立整数帧参数,不覆盖常规持续秒数;
|
||||
8. 实现启动段、纯循环段、结束段三种互斥表现;
|
||||
9. 建立统一的循环衔接帧;
|
||||
10. 使用周期预演避免末帧硬复制造成的运动突变;
|
||||
11. 使用透明度为 0 的假死粒子保持纯循环段骨骼集合稳定;
|
||||
12. 支持循环模式下的延迟时间与首帧保持;
|
||||
13. 循环模式自动禁用并重置 Spine 骨骼跟随和路径跟随;
|
||||
14. 修复循环重新计算期间缓存帧与实时状态交替造成的闪烁;
|
||||
15. 时间轴支持选择包含结束端点的最后一帧;
|
||||
16. 多粒子系统独立计算,整体时间轴继续取粒子和 Spine 中最长者;
|
||||
17. 完成 TypeScript 与 Vite 生产构建验证。
|
||||
|
||||
---
|
||||
|
||||
## 二、图片序列帧资源
|
||||
|
||||
### 2.1 图片模式
|
||||
|
||||
每张粒子图片资源卡片增加“图片模式”:
|
||||
|
||||
| 模式 | 说明 |
|
||||
|---|---|
|
||||
| 固定 | 粒子生命周期内始终使用同一张图片 |
|
||||
| 序列帧 | 按粒子出生后的时间依次切换多张图片 |
|
||||
|
||||
模式属于单个图片资源卡片。一个粒子系统可以同时存在多个图片资源,每个资源可以分别选择固定图片或序列帧,并继续使用原有权重随机选择逻辑。
|
||||
|
||||
资源卡片原有参数保持不变,包括:
|
||||
|
||||
- 权重与权重锁定;
|
||||
- 缩放;
|
||||
- 锚点;
|
||||
- 颜色模式与颜色渐变;
|
||||
- 混合模式;
|
||||
- 独立透明度与透明度曲线。
|
||||
|
||||
### 2.2 序列图片选择
|
||||
|
||||
选择序列帧时允许一次多选图片。编辑器会按文件名进行自然排序,而不是简单字符串排序。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
diwen_1.png
|
||||
diwen_2.png
|
||||
diwen_10.png
|
||||
```
|
||||
|
||||
会按照 `1 → 2 → 10` 排列。
|
||||
|
||||
界面明确显示实际播放顺序:
|
||||
|
||||
```text
|
||||
已选择 13 帧,按 diwen_000.png-diwen_012.png 顺序播放
|
||||
```
|
||||
|
||||
阶段测试资源位于:
|
||||
|
||||
```text
|
||||
public/序列帧测试/sg/
|
||||
```
|
||||
|
||||
### 2.3 播放模式
|
||||
|
||||
序列帧支持四种播放模式:
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| 单次-尾帧消失 | 正向播放一次,超过最后一张后隐藏粒子图片 |
|
||||
| 单次-尾帧固定 | 正向播放一次,之后保持最后一张图片 |
|
||||
| 正向循环 | 第一张播放到最后一张,再回到第一张 |
|
||||
| 反向循环 | 最后一张播放到第一张,再回到最后一张 |
|
||||
|
||||
“尾帧消失”只隐藏粒子图片。粒子逻辑、粒子骨骼和拖尾仍可继续存在,直到粒子生命周期结束。
|
||||
|
||||
### 2.4 播放帧率
|
||||
|
||||
每个序列帧资源拥有独立播放帧率:
|
||||
|
||||
```text
|
||||
sequenceFps
|
||||
```
|
||||
|
||||
有效范围为 1~120 FPS,默认 30 FPS。序列帧索引根据粒子实际出生时间计算,不依赖浏览器刷新次数。
|
||||
|
||||
### 2.5 单粒子单骨骼原则
|
||||
|
||||
序列帧不会为每张图片创建独立骨骼。
|
||||
|
||||
运行规则为:
|
||||
|
||||
```text
|
||||
一个粒子实例 = 一个粒子骨骼 = 一个 Sprite
|
||||
多张序列图片 = 同一个 Sprite 在不同时间替换 Texture
|
||||
```
|
||||
|
||||
因此 13 张序列图片仍只使用一根粒子骨骼,避免序列帧数量直接放大骨骼数量。
|
||||
|
||||
### 2.6 录制和持久化
|
||||
|
||||
粒子逐帧状态增加:
|
||||
|
||||
```text
|
||||
imageFrameIndex
|
||||
imageVisible
|
||||
```
|
||||
|
||||
录制和回放会恢复:
|
||||
|
||||
- 当前序列帧索引;
|
||||
- 当前纹理;
|
||||
- 当前图片显隐;
|
||||
- 资源锚点;
|
||||
- 资源混合模式。
|
||||
|
||||
配置保存会保存序列帧文件名、预览数据和播放参数;加载时重新创建纹理。撤回快照也包含序列帧资源配置,因此选择图片、切换模式和调整播放方式均可撤回。
|
||||
|
||||
---
|
||||
|
||||
## 三、持续发射表现模式
|
||||
|
||||
### 3.1 两种表现
|
||||
|
||||
持续发射模式下增加:
|
||||
|
||||
| 表现 | 说明 |
|
||||
|---|---|
|
||||
| 常规持续 | 保持原持续发射逻辑,结束发射后等待存活粒子死亡 |
|
||||
| 循环持续 | 生成可以拆分为启动、循环和结束的稳定周期动画 |
|
||||
|
||||
发射速率等已有参数继续共用。常规持续时长和循环持续时长分别保存,不会互相覆盖。
|
||||
|
||||
### 3.2 独立时长数据
|
||||
|
||||
常规持续继续使用:
|
||||
|
||||
```text
|
||||
duration: number // 秒
|
||||
```
|
||||
|
||||
循环持续新增:
|
||||
|
||||
```text
|
||||
loopDurationFrames: number // 整数帧
|
||||
```
|
||||
|
||||
默认循环时长为 30f,可编辑范围为 1~1800f。播放仍固定按照:
|
||||
|
||||
```text
|
||||
30f/s
|
||||
```
|
||||
|
||||
旧配置没有 `loopDurationFrames` 时,会把原持续秒数乘以 30 并向上取整作为迁移初值。此后两种时长完全独立。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
常规持续 = 1.00s
|
||||
循环持续 = 54f
|
||||
```
|
||||
|
||||
在两种表现之间切换不会改变对方的数值。
|
||||
|
||||
---
|
||||
|
||||
## 四、循环持续三段动画
|
||||
|
||||
### 4.1 三种状态
|
||||
|
||||
循环持续面板包含两个附加开关:
|
||||
|
||||
- 生成开始循环动画;
|
||||
- 生成结束循环动画。
|
||||
|
||||
两个开关互斥,不能同时开启。
|
||||
|
||||
| 开始开关 | 结束开关 | 当前生成内容 |
|
||||
|---|---|---|
|
||||
| 关 | 关 | 纯循环段 |
|
||||
| 开 | 关 | 启动—循环段 |
|
||||
| 关 | 开 | 循环—结束段 |
|
||||
|
||||
### 4.2 循环衔接帧
|
||||
|
||||
稳定循环状态的周期边界称为“循环衔接帧”。它包含:
|
||||
|
||||
- 粒子骨骼集合;
|
||||
- 粒子位置、旋转和缩放;
|
||||
- 颜色与透明度;
|
||||
- 图片资源与序列帧索引;
|
||||
- 图片显隐;
|
||||
- 拖尾骨骼、宽度、颜色和透明度;
|
||||
- 修改器计算后的最终结果。
|
||||
|
||||
启动段末端、纯循环段周期边界和结束段起点使用同一套确定性计算规则。
|
||||
|
||||
### 4.3 启动段
|
||||
|
||||
启动段规则:
|
||||
|
||||
1. 第一帧完全为空;
|
||||
2. 粒子骨骼数量为 0;
|
||||
3. 延迟期间保持空帧;
|
||||
4. 延迟结束后开始正常生成粒子;
|
||||
5. 粒子使用正常生命周期,可以在启动过程中死亡;
|
||||
6. 系统自然预热到稳定循环状态;
|
||||
7. 最后一帧到达循环衔接状态。
|
||||
|
||||
当最大粒子寿命跨越多个循环周期时,启动段会自动使用足够的预热周期,保证末端确实进入稳定循环分布。
|
||||
|
||||
### 4.4 纯循环段
|
||||
|
||||
纯循环段规则:
|
||||
|
||||
1. 延迟期间保持循环衔接帧;
|
||||
2. 延迟结束后播放一个完整循环周期;
|
||||
3. 周期长度由 `loopDurationFrames` 决定;
|
||||
4. 发射事件、随机参数和资源选择在每个周期使用相同确定性序列;
|
||||
5. 周期末端由真实模拟自然到达下一周期起点;
|
||||
6. 不再把末帧强制替换为首帧。
|
||||
|
||||
这一实现保证的不只是边界两个快照一致,还保证倒数帧到结束端点、结束端点到下一周期的运动连续,不会在最后两帧突然跳变或卡顿。
|
||||
|
||||
### 4.5 结束段
|
||||
|
||||
结束段规则:
|
||||
|
||||
1. 第一帧为循环衔接状态;
|
||||
2. 延迟期间保持该衔接状态;
|
||||
3. 延迟结束后立即停止生成新粒子;
|
||||
4. 已存在粒子继续按正常生命周期运行;
|
||||
5. 拖尾随粒子继续更新;
|
||||
6. 最后一帧所有粒子和拖尾消失。
|
||||
|
||||
结束段不使用发射时长作为消亡长度。长度根据衔接帧中粒子的最大剩余生命和拖尾状态自动计算。
|
||||
|
||||
---
|
||||
|
||||
## 五、稳定循环计算
|
||||
|
||||
### 5.1 完整周期预演
|
||||
|
||||
早期实现只把纯循环末帧复制为首帧。该方式虽然能让两个快照数值相同,却会使倒数帧直接跳到衔接状态,造成明显卡顿。
|
||||
|
||||
当前流程改为:
|
||||
|
||||
1. 根据循环时长计算每周期帧数;
|
||||
2. 根据最大粒子寿命计算需要预演的周期数;
|
||||
3. 从确定性随机种子开始模拟;
|
||||
4. 每个新周期重置发射计时和随机序列;
|
||||
5. 保留跨周期仍存活粒子的物理状态;
|
||||
6. 预演到粒子分布稳定;
|
||||
7. 记录下一完整周期及结束端点;
|
||||
8. 时间轴循环时直接回放缓存结果。
|
||||
|
||||
### 5.2 周期随机序列
|
||||
|
||||
每个周期重新开始:
|
||||
|
||||
- 发射累计器;
|
||||
- 发射计时;
|
||||
- 粒子生成序号;
|
||||
- 全局随机源;
|
||||
- 与发射周期相关的吸附计时。
|
||||
|
||||
仍存活粒子不会在周期边界被重置,其运动会继续自然推进。
|
||||
|
||||
噪声相位、拖尾资源采样和吸附曲线方向改为使用周期内粒子生成序号,不再依赖粒子池槽位。即使粒子池复用了不同编号,也不会改变下一周期相同发射事件的视觉属性。
|
||||
|
||||
### 5.3 假死粒子
|
||||
|
||||
纯循环段需要保持粒子骨骼集合稳定。某粒子在某些帧已按正常生命周期死亡时,录制缓存不会直接删除对应骨骼,而是写入假死状态:
|
||||
|
||||
```text
|
||||
active = true
|
||||
alpha = 0
|
||||
imageVisible = false
|
||||
trailState = undefined
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- 视觉上粒子已经死亡;
|
||||
- 图片和拖尾均不可见;
|
||||
- 逻辑上粒子骨骼仍存在;
|
||||
- 周期内不会因为骨骼突然增删破坏衔接;
|
||||
- 后续正常出现时仍可继续使用同一骨骼轨道。
|
||||
|
||||
假死只用于纯循环段。启动段和结束段仍使用正常粒子死亡逻辑。
|
||||
|
||||
### 5.4 缓存帧闪烁修复
|
||||
|
||||
循环段会在录制首帧一次性生成完整缓存。此前在全局时间轴第一次录制结束前,固定帧之间仍会调用 `update(0)` 显示粒子内部实时状态,导致画面在以下两套状态之间交替:
|
||||
|
||||
- 已计算好的循环缓存帧;
|
||||
- 周期预演结束后的内部状态。
|
||||
|
||||
表现为重新计算后粒子高频闪烁,直到时间轴完整运行一遍才恢复。
|
||||
|
||||
当前逻辑在循环缓存存在后立即全程读取缓存,不再夹杂实时状态。
|
||||
|
||||
---
|
||||
|
||||
## 六、延迟时间
|
||||
|
||||
循环持续继续使用原“延迟时间”参数,三种状态均支持。
|
||||
|
||||
| 状态 | 延迟期间保持内容 |
|
||||
|---|---|
|
||||
| 启动段 | 空的第一帧 |
|
||||
| 纯循环段 | 循环衔接帧 |
|
||||
| 结束段 | 循环衔接帧 |
|
||||
|
||||
延迟结束后才开始推进对应动画。
|
||||
|
||||
时间轴中:
|
||||
|
||||
- 粒子条左侧位置继续表示延迟;
|
||||
- 可以拖动粒子条调整延迟;
|
||||
- 整体播放长度包含延迟帧;
|
||||
- 延迟期间画布不是重复重新计算,而是保持第一帧缓存。
|
||||
|
||||
---
|
||||
|
||||
## 七、跟随功能限制
|
||||
|
||||
循环持续不与 Spine 骨骼跟随或路径跟随联动。原因是外部目标动画或路径进度可能不具备相同周期,无法保证粒子周期边界稳定。
|
||||
|
||||
进入或退出循环持续时均弹出提示:
|
||||
|
||||
```text
|
||||
粒子跟随设置会失效并恢复默认值
|
||||
```
|
||||
|
||||
只有用户确认后才切换。
|
||||
|
||||
确认后重置:
|
||||
|
||||
- 发射器跟随总开关;
|
||||
- 跟随模式;
|
||||
- 路径 ID;
|
||||
- Spine ID 与骨骼名;
|
||||
- 跟随空间和方向;
|
||||
- 路径进度曲线;
|
||||
- 位移、旋转和缩放偏移。
|
||||
|
||||
循环持续期间跟随控件不可重新开启。切回常规持续后控件恢复可用,但不会自动恢复进入循环前的旧跟随设置。
|
||||
|
||||
---
|
||||
|
||||
## 八、时间轴规则
|
||||
|
||||
### 8.1 结束端点
|
||||
|
||||
时间轴内部保存的是快照数量,界面显示的是最后一个可选帧编号。
|
||||
|
||||
例如动画长度为 54f:
|
||||
|
||||
```text
|
||||
可选帧:0f~54f
|
||||
快照数量:55
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- 可以拖动到第 54 帧;
|
||||
- “移动到最后一帧”会到达第 54 帧;
|
||||
- 后一帧按钮会正确限制在第 54 帧;
|
||||
- 非循环播放会停在第 54 帧。
|
||||
|
||||
### 8.2 轨道显示
|
||||
|
||||
| 状态 | 时间轴表现 |
|
||||
|---|---|
|
||||
| 常规持续 | 绿色发射段 + 红色粒子存活尾段 |
|
||||
| 启动段 | 延迟偏移 + 启动预热发射段 |
|
||||
| 纯循环段 | 延迟偏移 + 一个完整循环周期 |
|
||||
| 结束段 | 延迟偏移 + 只有粒子存在/消亡段 |
|
||||
|
||||
结束段不显示可调整的绿色发射时长,因为该段不会产生新粒子。
|
||||
|
||||
### 8.3 多系统与 Spine
|
||||
|
||||
每个粒子系统独立计算自己的:
|
||||
|
||||
- 循环时长;
|
||||
- 预热周期;
|
||||
- 循环衔接状态;
|
||||
- 启动或结束长度;
|
||||
- 延迟帧。
|
||||
|
||||
整体时间轴仍取以下对象的最长结束时间:
|
||||
|
||||
- 全部粒子系统;
|
||||
- 当前加载并选择动画的 Spine 对象;
|
||||
- 已存在的其他时间轴场景动画数据。
|
||||
|
||||
较短粒子系统到达末端后保持末帧,直到整体时间轴结束。
|
||||
|
||||
---
|
||||
|
||||
## 九、配置、预设与撤回联动
|
||||
|
||||
阶段十新增字段均属于粒子系统配置:
|
||||
|
||||
```text
|
||||
streamBehavior
|
||||
loopDurationFrames
|
||||
generateLoopStartAnimation
|
||||
generateLoopEndAnimation
|
||||
imageMode
|
||||
sequencePlayback
|
||||
sequenceFps
|
||||
sequenceFrames
|
||||
```
|
||||
|
||||
因此它们自动纳入:
|
||||
|
||||
- JSON 配置保存;
|
||||
- JSON 配置加载;
|
||||
- `public/Preset/` 预设加载;
|
||||
- 对象复制;
|
||||
- 编辑器撤回快照;
|
||||
- 模块重置。
|
||||
|
||||
加载旧配置时会补齐默认值,并处理:
|
||||
|
||||
- 旧 `once` 序列模式迁移为 `once-hold`;
|
||||
- 旧 `loop` 序列模式迁移为 `loop-forward`;
|
||||
- 缺少循环时长时从旧持续秒数换算初值;
|
||||
- 启动和结束开关同时为真时自动保留启动段、关闭结束段;
|
||||
- 缺少序列帧字段时恢复固定图片模式。
|
||||
|
||||
粒子逐帧缓存不写入配置。配置加载、撤回或参数修改后会根据当前确定性配置重新计算。
|
||||
|
||||
---
|
||||
|
||||
## 十、主要代码落点
|
||||
|
||||
| 文件 | 阶段十职责 |
|
||||
|---|---|
|
||||
| `src/core/particleEmitter.ts` | 序列帧纹理切换、循环配置字段、强制/停止发射控制、周期随机源重启 |
|
||||
| `src/views/ImageResourceCard.vue` | 图片模式、播放模式、播放帧率、序列文件和顺序提示 |
|
||||
| `src/views/ParticlePanel.vue` | 序列帧选择、循环持续面板、独立帧时长、互斥开关、跟随重置确认 |
|
||||
| `src/views/Stage.vue` | 三段动画烘焙、周期预演、衔接状态、假死粒子、缓存回放与闪烁修复 |
|
||||
| `src/views/Timeline.vue` | 循环轨道宽度、延迟偏移、结束段显示和末端帧选择 |
|
||||
| `src/store/particleStore.ts` | 三种循环状态总帧数、多系统和 Spine 最长时间计算 |
|
||||
| `src/editor/editorConfig.ts` | 序列图片数据保存、纹理重建和旧配置恢复 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、验证结果
|
||||
|
||||
本阶段完成以下静态与构建验证:
|
||||
|
||||
```text
|
||||
npm run build
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `vue-tsc -b` 通过;
|
||||
- Vite 生产构建通过;
|
||||
- 548 个模块完成转换;
|
||||
- 无 TypeScript 错误;
|
||||
- 无构建错误;
|
||||
- 仅保留既有的大包体积提示。
|
||||
|
||||
交互修复过程中已覆盖:
|
||||
|
||||
- 循环时长与常规持续时长独立;
|
||||
- 纯循环末端不再硬复制首帧;
|
||||
- 延迟期间保持第一帧;
|
||||
- 时间轴可以到达结束端点;
|
||||
- 重新计算期间不再交替显示内部实时状态;
|
||||
- 配置字段具备旧数据迁移默认值。
|
||||
|
||||
---
|
||||
|
||||
## 十二、阶段十最终状态
|
||||
|
||||
阶段十完成后,编辑器已经具备:
|
||||
|
||||
- 单图和序列帧混合资源;
|
||||
- 多种序列播放方式;
|
||||
- 确定性粒子序列帧录制;
|
||||
- 常规持续与独立循环持续;
|
||||
- 启动、循环、结束三段动画生成;
|
||||
- 稳定周期预演;
|
||||
- 假死粒子骨骼保持;
|
||||
- 延迟首帧保持;
|
||||
- 多系统时间轴对齐;
|
||||
- 配置、预设、复制和撤回联动。
|
||||
|
||||
至此,阶段十“序列帧资源与循环持续动画体系”完成落盘。
|
||||