阶段11开发

This commit is contained in:
tianmo committed 2026-08-31 21:01:53 +08:00
1 parent 8e9b83f95b
commit 8802bbe25e
21 files changed
+5285 -81

No files matched your search

+178 -30
View File
@@ -11,7 +11,7 @@ export type AttributeMode = 'fixed' | 'random' | 'curve'
/** 初始旋转额外支持跟随发射方向 */
export type RotationMode = AttributeMode | 'direction'
export type ScaleAxisMode = 'uniform' | 'separate'
export interface CurvePoint { x: number; y: number }
export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export interface ColorGradientStop { position: number; color: string }
export type ColliderShape = 'circle' | 'ellipse' | 'rect' | 'polygon' | 'custom'
@@ -91,8 +91,16 @@ export interface TrailImageResource {
alpha: number
alphaCurve: CurvePoint[]
boneCount: number
/** 选中该资源的粒子实际生成拖尾的比例(0-100)。 */
trailRatio: number
lengthMode: 'fixed' | 'random'
length: number
lengthMin: number
lengthMax: number
widthSizeMode: 'fixed' | 'random'
width: number
widthMin: number
widthMax: number
widthMode: 'particle' | 'fixed'
lifeLengthEnabled: boolean
lifeLengthCurve: CurvePoint[]
@@ -279,7 +287,7 @@ export interface EmitterConfig {
trailAlphaMode: 'particle' | 'fixed' | 'curve'
trailAlpha: number
trailAlphaCurve: CurvePoint[]
/** 每个粒子的拖尾骨骼链数量 */
/** 每个粒子的同级拖尾骨骼数量 */
trailBoneCount: number
trailLength: number
trailWidth: number
@@ -317,11 +325,19 @@ export interface EmitterConfig {
export interface ParticleState {
/** 对应骨骼在 root 下的命名 p_<i> */
boneName: string
/** 每次出生唯一且可复现的编号;关闭导出骨骼池时用于区分同一池槽的多次复用。 */
spawnId?: number
x: number
y: number
rotation: number
scaleX: number
scaleY: number
/** 已烘焙的数学世界坐标;导出时不再依赖路径、碰撞体或 Spine。 */
worldX?: number
worldY?: number
worldRotation?: number
worldScaleX?: number
worldScaleY?: number
alpha: number
colorHex: number
resourceId?: number
@@ -329,7 +345,7 @@ export interface ParticleState {
imageFrameIndex?: number
/** 单次播放结束后可只隐藏图片,粒子和拖尾仍继续存在。 */
imageVisible?: boolean
/** 拖尾拥有独立骨骼链和蒙皮状态,录制/回放时一并保存。 */
/** 拖尾拥有一组同级骨骼和蒙皮状态,录制/回放时一并保存。 */
trailState?: TrailState
active: boolean
}
@@ -339,10 +355,15 @@ export interface TrailBoneState {
x: number
y: number
rotation: number
worldX?: number
worldY?: number
worldRotation?: number
}
export interface TrailState {
bones: TrailBoneState[]
/** 兼容旧配置;当前拖尾整组骨骼同时出生和死亡,存在时始终等于骨骼总数。 */
activeBoneCount?: number
width: number
alpha: number
colorHex: number
@@ -357,7 +378,7 @@ export function defaultConfig(): EmitterConfig {
texture: null,
textureName: 'star',
textureFolder: '',
previewUrl: '/particles/star.png',
previewUrl: '/images/star.png',
imageMode: 'fixed',
sequencePlayback: 'loop-forward',
sequenceFps: 30,
@@ -499,7 +520,7 @@ export function defaultConfig(): EmitterConfig {
id: 1,
texture: null,
textureName: 'trail',
previewUrl: '/trails/trail.png',
previewUrl: '/images/trail.png',
weight: 100,
locked: false,
rotation: 0,
@@ -511,18 +532,25 @@ export function defaultConfig(): EmitterConfig {
alpha: 1,
alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
boneCount: 3,
trailRatio: 100,
lengthMode: 'fixed',
length: 100,
lengthMin: 100,
lengthMax: 100,
widthSizeMode: 'fixed',
width: 100,
widthMin: 100,
widthMax: 100,
widthMode: 'particle',
lifeLengthEnabled: false,
lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
shapeEnabled: false,
shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
}],
trailResourceVersion: 5,
trailResourceVersion: 6,
trailTexture: null,
trailTextureName: 'trail',
trailPreviewUrl: '/trails/trail.png',
trailPreviewUrl: '/images/trail.png',
trailColorMode: 'particle',
trailColor: '#ffffff',
trailColorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
@@ -575,10 +603,10 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
const legacyForceStrength = Number(target.forceStrength)
const needsTrailCurveMigration = target.trailConfigVersion !== 3
const needsTrailResourceMigration = !Array.isArray(target.trailResources)
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 5
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 6
const legacyTrailTexture = target.trailTexture as Texture<any> | null | undefined
const legacyTrailTextureName = String(target.trailTextureName || 'trail')
const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/trails/trail.png')
const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/images/trail.png').replace('/trails/trail.png', '/images/trail.png')
for (const [key, value] of Object.entries(defaults)) {
if (target[key] !== undefined && target[key] !== null) continue
target[key] = Array.isArray(value)
@@ -597,7 +625,8 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
for (const resource of config.imageResources) {
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.previewUrl === '/particles/star.png') resource.previewUrl = '/images/star.png'
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/images/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'
@@ -689,8 +718,15 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
alpha: config.trailAlpha,
alphaCurve: config.trailAlphaCurve.map((point) => ({ ...point })),
boneCount: config.trailBoneCount,
trailRatio: 100,
lengthMode: 'fixed',
length: config.trailLength,
lengthMin: config.trailLength,
lengthMax: config.trailLength,
widthSizeMode: 'fixed',
width: config.trailWidth,
widthMin: config.trailWidth,
widthMax: config.trailWidth,
widthMode: config.trailWidthMode,
lifeLengthEnabled: config.trailLifeLengthEnabled,
lifeLengthCurve: config.trailLifeLengthCurve.map((point) => ({ ...point })),
@@ -700,7 +736,8 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
}
for (const resource of config.trailResources) {
if (!resource.textureName) resource.textureName = 'trail'
if (!resource.previewUrl) resource.previewUrl = '/trails/trail.png'
if (resource.previewUrl === '/trails/trail.png') resource.previewUrl = '/images/trail.png'
if (!resource.previewUrl) resource.previewUrl = '/images/trail.png'
if (!Number.isFinite(resource.weight)) resource.weight = 100
if (typeof resource.locked !== 'boolean') resource.locked = false
if (!Number.isFinite(resource.rotation)) resource.rotation = 0
@@ -716,8 +753,16 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
resource.alphaCurve = (config.trailAlphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
if (!Number.isFinite(resource.boneCount)) resource.boneCount = Number.isFinite(config.trailBoneCount) ? config.trailBoneCount : 3
if (!Number.isFinite(resource.trailRatio)) resource.trailRatio = 100
resource.trailRatio = Math.min(100, Math.max(0, resource.trailRatio))
if (!['fixed', 'random'].includes(resource.lengthMode)) resource.lengthMode = 'fixed'
if (!Number.isFinite(resource.length)) resource.length = Number.isFinite(config.trailLength) ? config.trailLength : 100
if (!Number.isFinite(resource.lengthMin)) resource.lengthMin = resource.length
if (!Number.isFinite(resource.lengthMax)) resource.lengthMax = resource.length
if (!['fixed', 'random'].includes(resource.widthSizeMode)) resource.widthSizeMode = 'fixed'
if (!Number.isFinite(resource.width)) resource.width = Number.isFinite(config.trailWidth) ? config.trailWidth : 100
if (!Number.isFinite(resource.widthMin)) resource.widthMin = resource.width
if (!Number.isFinite(resource.widthMax)) resource.widthMax = resource.width
if (!['particle', 'fixed'].includes(resource.widthMode)) resource.widthMode = config.trailWidthMode || 'particle'
if (typeof resource.lifeLengthEnabled !== 'boolean') resource.lifeLengthEnabled = Boolean(config.trailLifeLengthEnabled)
if (!Array.isArray(resource.lifeLengthCurve) || resource.lifeLengthCurve.length < 2) {
@@ -737,7 +782,7 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
resource.alphaCurve = resource.alphaCurve?.length >= 2 ? resource.alphaCurve : [{ x: 0, y: 1 }, { x: 1, y: 1 }]
}
}
config.trailResourceVersion = 5
config.trailResourceVersion = 6
return config
}
@@ -745,6 +790,7 @@ interface Particle {
sprite: Sprite
active: boolean
boneName: string
spawnId: number
life: number
maxLife: number
x: number; y: number
@@ -772,6 +818,9 @@ interface Particle {
trailCurrentAlpha: number
trailCurrentColor: number
trailResourceId: number
trailEnabled: boolean
trailSampledLength: number
trailSampledWidth: number
resourceId: number
imageFrameIndex: number
collisionPaused: boolean
@@ -803,6 +852,7 @@ export class ParticleEmitter extends Container {
private followSpawnX = 0
private followSpawnY = 0
private followSpawnAngle = 0
private followExportCurveMode: '线性' | '贝塞尔' = '线性'
private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
@@ -812,6 +862,11 @@ export class ParticleEmitter extends Container {
this.addChild(this.trailDebugGfx)
}
setFollowExportCurveMode(mode: '线性' | '贝塞尔') { this.followExportCurveMode = mode }
private resolveCurveMode(mode: '跟随导出' | '线性' | '贝塞尔') {
return mode === '跟随导出' ? this.followExportCurveMode : mode
}
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
private reseed() {
const s = this.cfg.seed
@@ -830,6 +885,7 @@ export class ParticleEmitter extends Container {
this.addChild(sprite)
this.pool.push({
sprite, active: false, boneName: 'p_' + poolIndex,
spawnId: -1,
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1,
@@ -851,6 +907,9 @@ export class ParticleEmitter extends Container {
trailCurrentAlpha: 0,
trailCurrentColor: 0xffffff,
trailResourceId: 1,
trailEnabled: false,
trailSampledLength: 100,
trailSampledWidth: 100,
resourceId: 1,
imageFrameIndex: 0,
collisionPaused: false,
@@ -941,7 +1000,9 @@ export class ParticleEmitter extends Container {
this.burstTimer += dt
if (cfg.burstLoop) {
if (this.burstTimer >= 1) { this.burstTimer = 0; this.emitBurst() }
} else if (this.burstTimer <= dt) {
// update(0) 只用于两个固定帧之间刷新画面,不能被识别为首次爆发;
// 否则 0 <= 0 会在第 1 帧前额外生成一批粒子。
} else if (dt > 0 && this.burstTimer <= dt) {
this.emitBurst()
}
}
@@ -1104,13 +1165,13 @@ export class ParticleEmitter extends Container {
}
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
const baseAlpha = cfg.alphaMode === 'curve'
? curveAt(cfg.alphaCurve, t) * cfg.alphaScale
? curveAt(cfg.alphaCurve, t, this.resolveCurveMode(cfg.curveMode)) * cfg.alphaScale
: cfg.alpha * cfg.alphaScale
// 开启卡片独立透明度后,覆盖系统透明度;固定值不会再受系统曲线影响。
s.alpha = finite(resource?.independentAlpha === 'fixed'
? resource.alpha
: resource?.independentAlpha === 'curve'
? curveAt(resource.alphaCurve, t)
? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode))
: baseAlpha, 1)
const colorStart = resource?.colorStart || '#ffffff'
const rgb = resource?.colorMode === 'lifetime'
@@ -1119,7 +1180,20 @@ 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, imageFrameIndex: p.imageFrameIndex, imageVisible: s.visible, trailState, active: true })
const world = localToWorldMath(cfg, renderX, renderY, this.simulationTransform)
const rootRotation = this.simulationTransform?.rotation ?? cfg.rootRotation
const rootScaleX = Math.abs(this.simulationTransform?.scaleX ?? cfg.rootScaleX)
const rootScaleY = Math.abs(this.simulationTransform?.scaleY ?? cfg.rootScaleY)
const worldTrail = trailState
? {
...trailState,
bones: trailState.bones.map((bone) => {
const point = localToWorldMath(cfg, bone.x, bone.y, this.simulationTransform)
return { ...bone, worldX: point.x, worldY: point.y, worldRotation: rootRotation - bone.rotation }
}),
}
: undefined
states.push({ boneName: p.boneName, spawnId: p.spawnId, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, worldX: world.x, worldY: world.y, worldRotation: rootRotation + p.rotation, worldScaleX: s.scale.x * rootScaleX, worldScaleY: s.scale.y * rootScaleY, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: s.visible, trailState: worldTrail, active: true })
}
this.states = states
return states
@@ -1205,14 +1279,21 @@ export class ParticleEmitter extends Container {
if (p.trailHistory.length > keepCount) p.trailHistory.length = keepCount
}
/** 沿历史轨迹等距生成独立骨骼链;第 0 根骨骼位于粒子头部。 */
/** 沿历史轨迹等距生成同级拖尾骨骼;第 0 根骨骼位于粒子头部。 */
private sampleTrailBones(p: Particle, length: number, boneCount: number) {
const count = Math.max(2, Math.min(16, Math.round(boneCount)))
const history = p.trailHistory
const result: TrailBoneState[] = []
if (!history.length) return result
let availableLength = 0
for (let index = 1; index < history.length; index++) {
availableLength += Math.hypot(history[index].x - history[index - 1].x, history[index].y - history[index - 1].y)
}
// 轨迹不足完整长度时,不把后方骨骼堆叠在最旧采样点;所有骨骼始终在
// 当前有效轨迹内等距插值,并随可用长度增加逐渐展开到配置长度。
const sampledLength = Math.min(Math.max(0, length), availableLength)
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
const targetDistance = length * boneIndex / (count - 1)
const targetDistance = sampledLength * boneIndex / (count - 1)
let traversed = 0
let point = history[history.length - 1]
for (let i = 1; i < history.length; i++) {
@@ -1235,6 +1316,16 @@ export class ParticleEmitter extends Container {
const dy = i < result.length - 1 ? next.y - result[i].y : result[i].y - previous.y
result[i].rotation = Math.atan2(dy, dx) * 180 / Math.PI
}
if (sampledLength + 0.0001 < Math.max(0, length) && result.length > 1) {
// 拖尾仍在展开阶段时,位置已经在当前有效轨迹中等距插值;旋转也在
// 头尾切线之间使用最短角度插值,避免同级骨骼驱动的网格发生方向折跳。
const startRotation = result[0].rotation
const endRotation = result[result.length - 1].rotation
const shortestDelta = ((endRotation - startRotation + 540) % 360) - 180
for (let index = 0; index < result.length; index++) {
result[index].rotation = startRotation + shortestDelta * index / (result.length - 1)
}
}
return result
}
@@ -1282,7 +1373,7 @@ export class ParticleEmitter extends Container {
private updateTrail(p: Particle, renderX: number, renderY: number, lifeT: number, sprite: Sprite): TrailState | undefined {
const cfg = this.cfg
if (!cfg.trail) {
if (!cfg.trail || !p.trailEnabled || p.trailSampledLength <= 0 || p.trailSampledWidth <= 0) {
p.trailHistory.length = 0
p.trailBones.length = 0
if (p.trailMesh) p.trailMesh.visible = false
@@ -1291,9 +1382,9 @@ export class ParticleEmitter extends Container {
// 先确认骨骼/网格签名;发生结构变化时会在记录新轨迹前完成清理和重新绑定。
this.ensureTrailMesh(p)
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
this.appendTrailPoint(p, renderX, renderY, trailResource?.length ?? 100)
this.appendTrailPoint(p, renderX, renderY, p.trailSampledLength)
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
const length = Math.max(0, trailResource?.length ?? 100) * lengthMultiplier
const length = Math.max(0, p.trailSampledLength) * lengthMultiplier
p.trailBones = this.sampleTrailBones(p, length, trailResource?.boneCount ?? 3)
// 首骨骼必须与粒子最终渲染位置完全一致,避免采样/重绑时出现亚像素脱节。
if (p.trailBones[0]) {
@@ -1303,7 +1394,7 @@ export class ParticleEmitter extends Container {
const particleWidthScale = trailResource?.widthMode === 'particle'
? (Math.abs(sprite.scale.x) + Math.abs(sprite.scale.y)) * 0.5
: 1
p.trailCurrentWidth = Math.max(0, trailResource?.width ?? 100) * particleWidthScale
p.trailCurrentWidth = Math.max(0, p.trailSampledWidth) * particleWidthScale
p.trailCurrentAlpha = trailResource?.alphaMode === 'particle'
? sprite.alpha
: trailResource?.alphaMode === 'curve'
@@ -1316,6 +1407,9 @@ export class ParticleEmitter extends Container {
: hexToNumber(trailResource?.color || '#ffffff')
const state: TrailState = {
bones: p.trailBones.map((bone) => ({ ...bone })),
// 拖尾是一张固定拓扑的加权 Mesh,所有绑定骨骼必须成组存活。
// 历史长度不足时所有骨骼会在当前有效轨迹内等距插值,而不是延迟出生。
activeBoneCount: p.trailBones.length,
width: p.trailCurrentWidth,
alpha: Math.min(1, Math.max(0, p.trailCurrentAlpha)),
colorHex: p.trailCurrentColor,
@@ -1415,6 +1509,7 @@ export class ParticleEmitter extends Container {
const p = this.pool[idx]
const resource = this.pickImageResource()
const spawnSerial = this._spawnSerial++
p.spawnId = spawnSerial
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
// 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。
@@ -1436,9 +1531,26 @@ export class ParticleEmitter extends Container {
p.trailCurrentAlpha = 0
p.trailCurrentColor = 0xffffff
p.trailResourceId = trailResource.id
const ratio = Math.min(100, Math.max(0, Number(trailResource.trailRatio) || 0))
const estimatedCount = cfg.mode === 'burst'
? Math.max(0, Math.round(cfg.burstCount))
: Math.max(0, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
const forceSingleTrail = ratio > 0 && estimatedCount === 1 && spawnSerial === 0
const ratioSample = seededUnit(cfg.seed, spawnSerial, 0x27d4eb2d) * 100
const sampleRange = (mode: 'fixed' | 'random', fixed: number, min: number, max: number, salt: number) => {
if (mode !== 'random') return Math.max(0, Number(fixed) || 0)
const low = Math.max(0, Math.min(Number(min) || 0, Number(max) || 0))
const high = Math.max(0, Math.max(Number(min) || 0, Number(max) || 0))
return lerp(low, high, seededUnit(cfg.seed, spawnSerial, salt))
}
p.trailSampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1)
p.trailSampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c)
p.trailEnabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trailSampledLength > 0 && p.trailSampledWidth > 0
if (p.trailMesh) p.trailMesh.visible = false
p.active = true; p.life = life; p.maxLife = life
p.x = ox; p.y = oy
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
if (p.trailEnabled) p.trailHistory.push({ x: ox, y: oy })
p.vx = Math.cos(dirAng) * speed
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
@@ -1476,7 +1588,7 @@ export class ParticleEmitter extends Container {
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', imageMode: 'fixed', sequencePlayback: 'loop-forward', sequenceFps: 30, sequenceFrames: [],
previewUrl: '/images/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' }],
@@ -1518,12 +1630,14 @@ export class ParticleEmitter extends Container {
private pickTrailResource(sample: number): TrailImageResource {
const resources = this.cfg.trailResources.filter((resource) => resource.texture)
if (!resources.length) return this.cfg.trailResources[0] || {
id: 1, texture: null, textureName: 'trail', previewUrl: '/trails/trail.png', weight: 100, locked: false,
id: 1, texture: null, textureName: 'trail', previewUrl: '/images/trail.png', weight: 100, locked: false,
rotation: 0, colorMode: 'particle', color: '#ffffff',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
blend: 'normal',
alphaMode: 'particle', alpha: 1, alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
boneCount: 3, length: 100, width: 100, widthMode: 'particle',
boneCount: 3, trailRatio: 100,
lengthMode: 'fixed', length: 100, lengthMin: 100, lengthMax: 100,
widthSizeMode: 'fixed', width: 100, widthMin: 100, widthMax: 100, widthMode: 'particle',
lifeLengthEnabled: false, lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
shapeEnabled: false, shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
}
@@ -1569,12 +1683,17 @@ export class ParticleEmitter extends Container {
if (!p.active) continue
out.push({
// 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。
boneName: p.boneName, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
boneName: p.boneName, spawnId: p.spawnId, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
...this.captureWorldTransform(p),
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 })),
bones: p.trailBones.map((bone) => {
const point = localToWorldMath(this.cfg, bone.x, bone.y, this.simulationTransform)
return { ...bone, worldX: point.x, worldY: point.y, worldRotation: (this.simulationTransform?.rotation ?? this.cfg.rootRotation) - bone.rotation }
}),
activeBoneCount: p.trailBones.length,
width: p.trailCurrentWidth,
alpha: p.trailCurrentAlpha,
colorHex: p.trailCurrentColor,
@@ -1586,6 +1705,18 @@ export class ParticleEmitter extends Container {
return out
}
private captureWorldTransform(p: Particle) {
const point = localToWorldMath(this.cfg, p.sprite.x, p.sprite.y, this.simulationTransform)
const rootRotation = this.simulationTransform?.rotation ?? this.cfg.rootRotation
return {
worldX: point.x,
worldY: point.y,
worldRotation: rootRotation + p.rotation,
worldScaleX: p.sprite.scale.x * Math.abs(this.simulationTransform?.scaleX ?? this.cfg.rootScaleX),
worldScaleY: p.sprite.scale.y * Math.abs(this.simulationTransform?.scaleY ?? this.cfg.rootScaleY),
}
}
/** 回放:把快照还原到 sprite(非活动粒子隐藏) */
apply(states: ParticleState[]) {
const byName = new Map<string, ParticleState>()
@@ -1813,15 +1944,32 @@ function overLifeValue(mode: AttributeMode, min: number, max: number, curve: Cur
return mode === 'random' ? sampled : min
}
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
function curveAt(pts: CurvePoint[] | undefined, x: number): number {
function cubicValue(a: number, b: number, c: number, d: number, t: number) {
const inverse = 1 - t
return inverse * inverse * inverse * a + 3 * inverse * inverse * t * b + 3 * inverse * t * t * c + t * t * t * d
}
function curveAt(pts: CurvePoint[] | undefined, x: number, mode: '线性' | '贝塞尔' = '线性'): number {
if (!Array.isArray(pts) || pts.length === 0) return 1
const p = [...pts].sort((a, b) => a.x - b.x)
if (x <= p[0].x) return p[0].y
if (x >= p[p.length - 1].x) return p[p.length - 1].y
for (let i = 0; i < p.length - 1; i++) {
if (x >= p[i].x && x <= p[i + 1].x) {
const t = (x - p[i].x) / (p[i + 1].x - p[i].x || 1)
return lerp(p[i].y, p[i + 1].y, t)
const start = p[i], end = p[i + 1]
const spanX = end.x - start.x || 1
if (mode !== '贝塞尔') return lerp(start.y, end.y, (x - start.x) / spanX)
const control1X = start.x + (start.outX ?? spanX / 3)
const control1Y = start.y + (start.outY ?? (end.y - start.y) / 3)
const control2X = end.x + (end.inX ?? -spanX / 3)
const control2Y = end.y + (end.inY ?? -(end.y - start.y) / 3)
let low = 0, high = 1
for (let iteration = 0; iteration < 14; iteration++) {
const amount = (low + high) / 2
if (cubicValue(start.x, control1X, control2X, end.x, amount) < x) low = amount
else high = amount
}
return cubicValue(start.y, control1Y, control2Y, end.y, (low + high) / 2)
}
}
return p[p.length - 1].y