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('粒子事件校验通过:暖场 / 出生 / 死亡 / 碰撞 / 非等比矩形碰撞面 / 子发射器生成与禁用自动发射 / 属性继承 / 深度上限')