Files
SpineParticlesWeb/scripts/verify-particle-events.mjs
T

228 lines
9.3 KiB
JavaScript

import { build } from 'esbuild'
const bundled = await build({
bundle: true,
entryPoints: ['src/core/particleEmitter.ts'],
format: 'esm',
platform: 'node',
write: false,
alias: { 'pixi.js': './scripts/pixi-stub.mjs' },
})
const { ParticleEmitter, defaultConfig } = await import(
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}`
)
const { Texture } = await import('./pixi-stub.mjs')
if (defaultConfig().warmupFrames !== 0) throw new Error('暖场帧数默认值必须为 0')
const normalizedWarmup = makeConfig({ warmupFrames: -2.7 })
const normalizedWarmupEmitter = new ParticleEmitter(normalizedWarmup)
if (normalizedWarmup.warmupFrames !== 0 || normalizedWarmupEmitter.activeCount !== 0) {
throw new Error('暖场帧数必须归一化为不小于 0 的整数')
}
const subBundled = await build({
bundle: true,
entryPoints: ['src/core/subEmitterIds.ts'],
format: 'esm',
platform: 'node',
write: false,
})
const { collectSubEmitterIds, collectEmitterAncestors } = await import(
`data:text/javascript;base64,${Buffer.from(subBundled.outputFiles[0].text).toString('base64')}`
)
function makeConfig(overrides = {}) {
const config = defaultConfig()
config.texture = new Texture()
for (const resource of config.imageResources) resource.texture = new Texture()
for (const resource of config.trailResources) resource.texture = new Texture()
Object.assign(config, {
mode: 'burst',
burstCount: 1,
burstLoop: false,
delay: 0,
duration: 1,
lifeMode: 'fixed',
lifeMin: 0.1,
lifeMax: 0.1,
speedMin: 0,
speedMax: 0,
shape: 'point',
collision: false,
eventEnabled: false,
}, overrides)
return config
}
const step = 1 / 30
const emitter = new ParticleEmitter(makeConfig())
const events = []
emitter.onParticleEvent = (event) => events.push(event)
emitter.update(step, 'force')
if (!events.some((event) => event.kind === 'birth')) throw new Error('未收到出生事件')
for (let i = 0; i < 8; i++) emitter.update(step)
if (!events.some((event) => event.kind === 'death')) throw new Error('未收到死亡事件')
const target = new ParticleEmitter(makeConfig({ lifeMin: 1, lifeMax: 1 }))
if (!target.spawnEventParticle(12, 34, 1)) throw new Error('事件粒子生成失败')
if (target.activeCount !== 1) throw new Error('事件粒子未进入目标粒子池')
if (target.spawnEventParticle(0, 0, 3)) throw new Error('事件链深度上限未生效')
const inheritConfig = makeConfig({
burstCount: 0,
lifeMin: 1,
lifeMax: 1,
scaleMode: 'fixed',
scaleMin: 2,
scaleMax: 2,
initialRotationMode: 'fixed',
initialRotationMin: 0,
initialRotationMax: 0,
})
inheritConfig.imageResources[0].colorMode = 'fixed'
inheritConfig.imageResources[0].colorStart = '#102030'
const inheritTarget = new ParticleEmitter(inheritConfig)
inheritTarget.spawnEventParticle(0, 0, 1, {
attributes: ['color', 'scale', 'rotation', 'life'],
colorMode: 'add',
data: { colorHex: 0x010203, scaleX: 1.5, scaleY: 1.5, rotation: 90, life: 0.5 },
})
inheritTarget.update(0)
const inherited = inheritTarget.capture()[0]
if (!inherited) throw new Error('继承属性的子发射器粒子未生成')
if (inherited.colorHex !== 0x112233) throw new Error(`颜色继承应相加,实际 ${inherited.colorHex.toString(16)}`)
if (Math.abs(inherited.scaleX - 3) > 1e-6 || Math.abs(inherited.scaleY - 3) > 1e-6) throw new Error('大小继承应乘以目标整体缩放')
if (Math.abs(inherited.rotation - 90) > 1e-6) throw new Error('旋转继承应作为整体方向偏移')
for (let i = 0; i < 12; i++) inheritTarget.update(step)
if (inheritTarget.activeCount !== 1) throw new Error('生命周期继承过短')
for (let i = 0; i < 6; i++) inheritTarget.update(step)
if (inheritTarget.activeCount !== 0) throw new Error('生命周期继承未生效')
const replaceColorTarget = new ParticleEmitter(makeConfig({ burstCount: 0, lifeMin: 1, lifeMax: 1 }))
replaceColorTarget.spawnEventParticle(0, 0, 1, {
attributes: ['color'],
colorMode: 'replace',
data: { colorHex: 0x445566, scaleX: 1, scaleY: 1, rotation: 0, life: 1 },
})
replaceColorTarget.update(0)
if (replaceColorTarget.capture()[0]?.colorHex !== 0x445566) throw new Error('颜色覆盖继承失败')
// 子发射器爆发模式:一次事件应按“单次数量”生成多颗粒子。
const burstTarget = new ParticleEmitter(makeConfig({ burstCount: 10 }))
if (!burstTarget.spawnEventParticle(0, 0, 1)) throw new Error('子发射器爆发事件生成失败')
if (burstTarget.activeCount !== 10) throw new Error(`子发射器应按单次数量生成 10 颗,实际 ${burstTarget.activeCount} 颗`)
// 子发射器 delay:事件粒子应延迟发射,而不是立即生成。
const delayedTarget = new ParticleEmitter(makeConfig({ delay: 0.2, burstCount: 2, lifeMin: 1, lifeMax: 1 }))
delayedTarget.setAutoEmission(false)
if (!delayedTarget.spawnEventParticle(0, 0, 1)) throw new Error('延迟子发射器事件未入队')
if (delayedTarget.activeCount !== 0) throw new Error('子发射器 delay 未生效,粒子立即生成了')
delayedTarget.update(0.1)
if (delayedTarget.activeCount !== 0) throw new Error('子发射器延迟未到时间就发射了')
delayedTarget.update(0.1)
if (delayedTarget.activeCount !== 2) throw new Error(`子发射器延迟后应生成 2 颗,实际 ${delayedTarget.activeCount} 颗`)
// 子发射器:被引用后不自动发射,只接收事件粒子;移除引用后恢复普通系统。
const subEmitter = new ParticleEmitter(makeConfig({ burstCount: 5 }))
subEmitter.setAutoEmission(false)
subEmitter.update(step, 'force')
if (subEmitter.activeCount !== 0) throw new Error('子发射器不应自动发射')
if (!subEmitter.spawnEventParticle(0, 0, 1)) throw new Error('子发射器无法接收事件粒子')
subEmitter.setAutoEmission(true)
subEmitter.update(step, 'force')
if (subEmitter.activeCount <= 1) throw new Error('移除引用后应恢复自动发射')
const subIds = collectSubEmitterIds([
{ id: 1, config: { events: [{ targetSystemId: 2 }, { targetSystemId: 1 }] } },
{ id: 2, config: {} },
{ id: 3, config: {} },
])
if (!subIds.has(2) || subIds.has(1) || subIds.has(3)) throw new Error('子发射器引用集合计算错误')
const chainSystems = [
{ id: 1, config: { events: [{ targetSystemId: 2 }] } },
{ id: 2, config: { events: [{ targetSystemId: 3 }] } },
{ id: 3, config: {} },
]
const ancestors = collectEmitterAncestors(chainSystems, 3)
if (!ancestors.has(1) || !ancestors.has(2) || ancestors.has(3)) {
throw new Error('子发射器上游父系统计算错误,无法阻止循环依赖')
}
const collider = {
id: 1, shape: 'circle', enabled: true,
x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
radius: 50, radiusX: 50, radiusY: 50, width: 100, height: 100, sides: 6,
response: 'physics', elasticity: 1, friction: 0,
}
const collisionEmitter = new ParticleEmitter(makeConfig({ collision: true, lifeMin: 1, lifeMax: 1 }))
collisionEmitter.setColliders(Array.from({ length: 4 }, (_, index) => ({
...collider,
id: index + 1,
radius: 50 + index * 10,
})))
collisionEmitter.setAutoEmission(false)
collisionEmitter.spawnEventParticle(0, 0, 1)
const collisionEvents = []
collisionEmitter.onParticleEvent = (event) => collisionEvents.push(event)
collisionEmitter.update(step, 'force')
if (!collisionEvents.some((event) => event.kind === 'collision')) throw new Error('未收到碰撞事件')
const collisionCountsBySpawn = new Map()
for (const event of collisionEvents) {
if (event.kind !== 'collision') continue
const counts = collisionCountsBySpawn.get(event.spawnId) || []
counts.push(event.collisionCount)
collisionCountsBySpawn.set(event.spawnId, counts)
}
if (![...collisionCountsBySpawn.values()].some((counts) => counts[0] === 1 && counts.at(-1) === 4)) {
throw new Error('同一粒子的碰撞次数未正确累计')
}
const warmupEmitter = new ParticleEmitter(makeConfig({
mode: 'stream',
streamBehavior: 'normal',
rate: 30,
duration: 10,
lifeMin: 10,
lifeMax: 10,
speedMin: 0,
speedMax: 0,
}))
warmupEmitter.reset()
warmupEmitter.warmup(3, step)
if (warmupEmitter.activeCount !== 3) {
throw new Error(`暖场 3 帧后应有 3 颗粒子,实际 ${warmupEmitter.activeCount} 颗`)
}
// 非等比缩放下,应按世界距离选择最近面,不能让宽扁矩形的顶面碰撞跳到侧面。
const scaledRectEmitter = new ParticleEmitter(makeConfig({
collision: true,
lifeMin: 1,
lifeMax: 1,
speedMin: 0,
speedMax: 0,
useGravity: false,
}))
scaledRectEmitter.setColliders([{
...collider,
shape: 'rect',
x: 0,
y: -60,
scaleX: 4,
scaleY: 0.5,
width: 120,
height: 80,
}])
scaledRectEmitter.setAutoEmission(false)
scaledRectEmitter.spawnEventParticle(-125.02, -56.58, 1)
const scaledRectEvents = []
scaledRectEmitter.onParticleEvent = (event) => scaledRectEvents.push(event)
scaledRectEmitter.update(step, 'force')
const scaledRectHit = scaledRectEvents.find((event) => event.kind === 'collision')
if (!scaledRectHit) throw new Error('非等比矩形未产生碰撞事件')
if (Math.abs(scaledRectHit.worldX + 125.02) > 0.1 || Math.abs(scaledRectHit.worldY + 40) > 0.1) {
throw new Error(`非等比矩形吸附面错误:实际命中 (${scaledRectHit.worldX}, ${scaledRectHit.worldY})`)
}
console.log('粒子事件校验通过:暖场 / 出生 / 死亡 / 碰撞 / 非等比矩形碰撞面 / 子发射器生成与禁用自动发射 / 属性继承 / 深度上限')