子发射器
This commit is contained in:
1 parent
d4ebb18777
commit
d224939efd
33 files changed
+5690
-1355
No files matched your search
@@ -0,0 +1,21 @@
|
||||
class Vec { constructor(){ this.x=0; this.y=0 } set(x,y){ this.x=x; this.y=(y===undefined?x:y); return this } }
|
||||
export class Texture { constructor(){ this.width=1; this.height=1 } }
|
||||
Texture.EMPTY = new Texture()
|
||||
export class Container {
|
||||
constructor(){ this.children=[]; this.parent=null; this.position=new Vec(); this.scale=new Vec(); this.scale.x=1; this.scale.y=1; this.anchor=new Vec(); this.rotation=0; this.visible=true; this.renderable=true; this.alpha=1 }
|
||||
addChild(child){ this.children.push(child); child.parent=this; return child }
|
||||
addChildAt(child,index){ this.children.splice(index,0,child); child.parent=this; return child }
|
||||
removeChild(child){ const index=this.children.indexOf(child); if(index>=0) this.children.splice(index,1); return child }
|
||||
removeFromParent(){ if(this.parent) this.parent.removeChild(this) }
|
||||
getChildIndex(child){ return this.children.indexOf(child) }
|
||||
destroy(){}
|
||||
}
|
||||
export class Sprite extends Container {
|
||||
constructor(texture){ super(); this.texture=texture; this.blendMode=0; this.tint=0xffffff; this.x=0; this.y=0 }
|
||||
}
|
||||
export class Graphics extends Container {
|
||||
clear(){} lineStyle(){} moveTo(){} lineTo(){} beginFill(){} endFill(){} drawCircle(){} drawPolygon(){} drawRect(){} bezierCurveTo(){}
|
||||
}
|
||||
export class SimpleMesh extends Container {
|
||||
constructor(texture,vertices,uvs,indices){ super(); this.texture=texture; this.vertices=vertices; this.uvs=uvs; this.indices=indices; this.blendMode=0; this.tint=0xffffff }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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')
|
||||
|
||||
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('同一粒子的碰撞次数未正确累计')
|
||||
}
|
||||
|
||||
console.log('粒子事件校验通过:出生 / 死亡 / 碰撞 / 子发射器生成与禁用自动发射 / 属性继承 / 深度上限')
|
||||
@@ -0,0 +1,87 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const source = `
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useParticleStore } from './src/store/particleStore.ts'
|
||||
import { subEmitterTriggerTimings } from './src/core/subEmitterTiming.ts'
|
||||
import { collectSubEmitterIds } from './src/core/subEmitterIds.ts'
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const store = useParticleStore()
|
||||
const parent = store.addSystem()
|
||||
const child = store.addSystem()
|
||||
const assert = (condition, message) => { if (!condition) throw new Error(message) }
|
||||
const setLife = (system, life) => {
|
||||
system.config.lifeMode = 'fixed'
|
||||
system.config.lifeMin = life
|
||||
system.config.lifeMax = life
|
||||
}
|
||||
|
||||
parent.config.mode = 'stream'
|
||||
parent.config.streamBehavior = 'normal'
|
||||
parent.config.delay = 0
|
||||
parent.config.duration = 1
|
||||
setLife(parent, 2)
|
||||
parent.config.eventEnabled = true
|
||||
parent.config.events = [{
|
||||
id: 1, event: 'death', targetSystemId: child.id,
|
||||
probability: 1, collisionLimit: 1, inherit: [], colorInheritMode: 'add',
|
||||
}]
|
||||
|
||||
// 子发射器自身的 10s 发射时长不应算入时间轴。
|
||||
child.config.mode = 'stream'
|
||||
child.config.streamBehavior = 'normal'
|
||||
child.config.delay = 0
|
||||
child.config.duration = 10
|
||||
setLife(child, 1.5)
|
||||
|
||||
// 死亡触发:父粒子最晚 3s 死亡,子发射器再存活 1.5s → 4.5s。
|
||||
let frames = store.recalcTotalFrames()
|
||||
assert(frames === Math.ceil(4.5 * 30) + 1, '死亡子发射器时间轴错误:' + frames)
|
||||
const deathTiming = subEmitterTriggerTimings(store.systems, 30).get(child.id)
|
||||
assert(deathTiming && Math.abs(deathTiming.firstTrigger - 2) < 1e-6, '死亡子发射器起点应为第一次死亡 2s')
|
||||
assert(deathTiming && Math.abs(deathTiming.lastTrigger - 3) < 1e-6, '死亡子发射器最后触发应为 3s')
|
||||
assert(deathTiming && Math.abs(deathTiming.end - 4.5) < 1e-6, '死亡子发射器终点应为 4.5s')
|
||||
|
||||
// 子发射器自身 delay 在父事件之后继续延后发射。
|
||||
child.config.delay = 0.2
|
||||
frames = store.recalcTotalFrames()
|
||||
assert(frames === Math.ceil(4.7 * 30) + 1, '子发射器延迟未计入时间轴:' + frames)
|
||||
child.config.delay = 0
|
||||
|
||||
// 出生触发:子发射器与父粒子同时开始,取较长者 2s → 3s。
|
||||
parent.config.events[0].event = 'birth'
|
||||
frames = store.recalcTotalFrames()
|
||||
assert(frames === Math.ceil(3 * 30) + 1, '出生子发射器时间轴错误:' + frames)
|
||||
const birthTiming = subEmitterTriggerTimings(store.systems, 30).get(child.id)
|
||||
assert(birthTiming && Math.abs(birthTiming.firstTrigger - 0) < 1e-6, '出生子发射器应从父粒子首次出生开始')
|
||||
assert(birthTiming && Math.abs(birthTiming.lastTrigger - 1) < 1e-6, '出生子发射器最后触发应为发射结束 1s')
|
||||
assert(birthTiming && Math.abs(birthTiming.end - 2.5) < 1e-6, '出生子发射器终点应为 2.5s')
|
||||
|
||||
// 关闭“开启子发射器”后,保留的规则不应继续把目标标记为子发射器。
|
||||
parent.config.eventEnabled = false
|
||||
assert(!collectSubEmitterIds(store.systems).has(child.id), '关闭子发射器后目标仍被当作子发射器')
|
||||
assert(!subEmitterTriggerTimings(store.systems, 30).has(child.id), '关闭子发射器后仍有时间轴触发区间')
|
||||
frames = store.recalcTotalFrames()
|
||||
assert(frames === Math.ceil(11.5 * 30) + 1, '关闭子发射器后目标未恢复独立时长')
|
||||
|
||||
// 移除引用后子发射器恢复正常,自身的 10s 发射时长重新计入。
|
||||
parent.config.eventEnabled = true
|
||||
parent.config.events = []
|
||||
frames = store.recalcTotalFrames()
|
||||
assert(frames === Math.ceil(11.5 * 30) + 1, '移除引用后时间轴错误:' + frames)
|
||||
|
||||
console.log('子发射器时间轴校验通过:死亡/出生触发与移除引用后的总长度')
|
||||
`
|
||||
|
||||
const result = await build({
|
||||
stdin: { contents: source, resolveDir: process.cwd(), sourcefile: 'verify-sub-emitter-timeline.ts', loader: 'ts' },
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
})
|
||||
|
||||
const code = Buffer.from(result.outputFiles[0].contents).toString('base64')
|
||||
await import(`data:text/javascript;base64,${code}`)
|
||||
@@ -6,7 +6,7 @@ const bundled = await build({
|
||||
contents: [
|
||||
"export { bakeLoopFrames, closeLoopFrameSeam } from './src/core/loopFrameBaker.ts'",
|
||||
"export { loopPlaybackRange, loopSegmentTiming } from './src/core/loopSegmentTiming.ts'",
|
||||
"export { normalizePlaybackRange, resizePlaybackRange } from './src/timeline/playbackRange.ts'",
|
||||
"export { normalizePlaybackRange, resizePlaybackRange, resolvePlaybackRange } from './src/timeline/playbackRange.ts'",
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: 'timeline-test-entry.ts',
|
||||
@@ -52,6 +52,10 @@ const normalized = api.normalizePlaybackRange(10, 10, 100)
|
||||
if (normalized.start !== 10 || normalized.end !== 11) throw new Error('播放范围必须至少包含两个帧位置')
|
||||
const resized = api.resizePlaybackRange({ start: 5, end: 59 }, 60, 90)
|
||||
if (resized.start !== 5 || resized.end !== 89) throw new Error('全范围播放没有随总时长扩展')
|
||||
const abOff = api.resolvePlaybackRange(false, 5, 54, 139)
|
||||
if (abOff.start !== 0 || abOff.end !== 138) throw new Error('AB 关闭时应播放完整时间轴')
|
||||
const abOn = api.resolvePlaybackRange(true, 5, 54, 139)
|
||||
if (abOn.start !== 5 || abOn.end !== 54) throw new Error('AB 开启时应播放用户输入区间')
|
||||
|
||||
function state(x) {
|
||||
return [{
|
||||
|
||||
Reference in new issue
Block a user