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