阶段11开发
This commit is contained in:
1 parent
8e9b83f95b
commit
8802bbe25e
21 files changed
+5285
-81
No files matched your search
+178
-30
@@ -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
|
||||
|
||||
Reference in new issue
Block a user