872 lines
39 KiB
TypeScript
872 lines
39 KiB
TypeScript
import { Container, Sprite, Graphics } from 'pixi.js'
|
|
import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmitterShapeSample } from './emitterShape'
|
|
import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail'
|
|
import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources'
|
|
|
|
import type { SceneCollider, EmitterConfig, ParticleState, ParticleInheritAttribute } from './particleTypes'
|
|
import { defaultConfig, ensureEmitterConfig } from './particleConfig'
|
|
import { BLEND_MAP, MAX_PARTICLES } from './particleConstants'
|
|
import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration, addRgbColors } from './particleMath'
|
|
|
|
// 保留原有入口,现有调用方无需随内部拆分修改。
|
|
export type * from './particleTypes'
|
|
export { defaultConfig, ensureEmitterConfig } from './particleConfig'
|
|
|
|
interface Particle {
|
|
sprite: Sprite
|
|
active: boolean
|
|
boneName: string
|
|
spawnId: number
|
|
life: number
|
|
maxLife: number
|
|
x: number; y: number
|
|
vx: number; vy: number
|
|
scaleStartX: number; scaleStartY: number
|
|
alphaStart: number; alphaEnd: number
|
|
rotation: number; rotationSpeed: number
|
|
speedScale: number
|
|
speedScaleTarget: number
|
|
scaleOverLifeX: number; scaleOverLifeY: number
|
|
noisePhaseX: number; noisePhaseY: number
|
|
gravityStrength: number
|
|
windStrength: number
|
|
attractionActive: boolean
|
|
attractionElapsed: number
|
|
attractionStartWorldX: number
|
|
attractionStartWorldY: number
|
|
attractionCurveSide: number
|
|
/** 拖尾运行状态(实现见 particleTrail.ts) */
|
|
trail: ParticleTrailState
|
|
resourceId: number
|
|
/** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */
|
|
colorSample: number
|
|
/** 父粒子颜色,逐通道相加到粒子自身颜色。 */
|
|
colorAdd: number
|
|
/** 父粒子颜色覆盖;null 表示不覆盖。 */
|
|
colorOverride: number | null
|
|
/** 当前渲染缩放,用于事件继承。 */
|
|
currentScaleX: number
|
|
currentScaleY: number
|
|
imageFrameIndex: number
|
|
collisionPaused: boolean
|
|
/** 当前粒子累计发生的碰撞次数。 */
|
|
collisionCount: number
|
|
/** 由事件链生成的层级;0 = 正常发射。 */
|
|
eventDepth: number
|
|
}
|
|
|
|
export type ParticleEventKind = 'birth' | 'death' | 'collision'
|
|
|
|
export interface ParticleEventInfo {
|
|
kind: ParticleEventKind
|
|
/** 事件位置的数学世界坐标。 */
|
|
worldX: number
|
|
worldY: number
|
|
rotation: number
|
|
spawnId: number
|
|
/** 0 = 正常发射;数值越大表示事件链越深。 */
|
|
depth: number
|
|
colorHex: number
|
|
scaleX: number
|
|
scaleY: number
|
|
maxLife: number
|
|
collisionCount: number
|
|
normalX?: number
|
|
normalY?: number
|
|
}
|
|
|
|
/** 子发射器从父粒子继承的数据。 */
|
|
export interface ParticleInheritData {
|
|
colorHex: number
|
|
scaleX: number
|
|
scaleY: number
|
|
rotation: number
|
|
life: number
|
|
}
|
|
|
|
export interface ParticleInheritOptions {
|
|
attributes: ParticleInheritAttribute[]
|
|
colorMode: 'add' | 'replace'
|
|
data: ParticleInheritData
|
|
}
|
|
|
|
export class ParticleEmitter extends Container {
|
|
private pool: Particle[] = []
|
|
private cfg: EmitterConfig
|
|
private accumulator = 0
|
|
private burstTimer = 0
|
|
private _spawnSerial = 0
|
|
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
|
|
private _elapsed = 0
|
|
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
|
|
private _attractionEnabledAt = 0
|
|
private _attractionWasEnabled = false
|
|
/** 子发射器关闭自动发射,只接收父粒子事件生成的粒子。 */
|
|
private autoEmission = true
|
|
/** 事件粒子延迟队列:子发射器 delay 表示在父事件之后再延迟多久发射。 */
|
|
private pendingEventSpawns: Array<{
|
|
worldX: number
|
|
worldY: number
|
|
depth: number
|
|
inherit?: ParticleInheritOptions
|
|
count: number
|
|
remaining: number
|
|
}> = []
|
|
/** 活动粒子状态快照(供骨架 / 导出) */
|
|
states: ParticleState[] = []
|
|
/** 粒子出生/死亡/碰撞事件回调(由 EmitterSync 接入跨系统事件)。 */
|
|
onParticleEvent: ((event: ParticleEventInfo) => void) | null = null
|
|
/** 确定性随机(种子>0 时可复现) */
|
|
private rng: () => number = Math.random
|
|
private _lastSeed = -1
|
|
private trailDebugGfx = new Graphics()
|
|
private trail: TrailController
|
|
private colliders: SceneCollider[] = []
|
|
private followSpawnX = 0
|
|
private followSpawnY = 0
|
|
private followSpawnAngle = 0
|
|
private spawnPathSampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null = null
|
|
private spawnPathClosed = false
|
|
private followExportCurveMode: '线性' | '贝塞尔' = '线性'
|
|
private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null
|
|
|
|
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
|
super()
|
|
this.cfg = ensureEmitterConfig(config)
|
|
this.trail = new TrailController(this)
|
|
this._ensurePool(maxParticles)
|
|
this.addChild(this.trailDebugGfx)
|
|
}
|
|
|
|
setFollowExportCurveMode(mode: '线性' | '贝塞尔') { this.followExportCurveMode = mode }
|
|
private resolveCurveMode(mode: '跟随导出' | '线性' | '贝塞尔') {
|
|
return mode === '跟随导出' ? this.followExportCurveMode : mode
|
|
}
|
|
|
|
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
|
|
private reseed() {
|
|
const s = this.cfg.seed
|
|
if (s === this._lastSeed) return
|
|
this._lastSeed = s
|
|
this.rng = mulberry32(s >>> 0)
|
|
}
|
|
|
|
private _ensurePool(n: number) {
|
|
while (this.pool.length < n) {
|
|
const poolIndex = this.pool.length
|
|
const sprite = new Sprite(this.cfg.texture)
|
|
sprite.blendMode = BLEND_MAP[this.cfg.blend] as any
|
|
sprite.anchor.set(0.5)
|
|
sprite.visible = false
|
|
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,
|
|
noisePhaseX: 0,
|
|
noisePhaseY: 0,
|
|
gravityStrength: 980,
|
|
windStrength: 100,
|
|
attractionActive: false,
|
|
attractionElapsed: 0,
|
|
attractionStartWorldX: 0,
|
|
attractionStartWorldY: 0,
|
|
attractionCurveSide: 1,
|
|
trail: createTrailState(),
|
|
resourceId: 1,
|
|
colorSample: 0xffffff,
|
|
colorAdd: 0,
|
|
colorOverride: null,
|
|
currentScaleX: 1,
|
|
currentScaleY: 1,
|
|
imageFrameIndex: 0,
|
|
collisionPaused: false,
|
|
collisionCount: 0,
|
|
eventDepth: 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) {
|
|
this.spawnPathSampler = sampler
|
|
this.spawnPathClosed = closed
|
|
}
|
|
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
|
|
setAutoEmission(enabled: boolean) { this.autoEmission = enabled }
|
|
setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) {
|
|
this.followSpawnX = x
|
|
this.followSpawnY = y
|
|
this.followSpawnAngle = angleDegrees
|
|
}
|
|
setSimulationTransform(centerX: number, centerY: number, rotation: number, scaleX?: number, scaleY?: number) {
|
|
this.simulationTransform = { centerX, centerY, rotation, scaleX, scaleY }
|
|
}
|
|
|
|
private applyColliders(p: Particle, particleIndex: number) {
|
|
if (!this.cfg.collision || !this.colliders.length) return false
|
|
const cfg = this.cfg
|
|
let world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform)
|
|
let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy, this.simulationTransform)
|
|
for (const collider of this.colliders) {
|
|
if (!collider.enabled) continue
|
|
const hit = resolveColliderPoint(collider, world.x, world.y)
|
|
if (!hit) continue
|
|
p.collisionCount++
|
|
this.emitWorldEvent('collision', p, hit.x, hit.y, { normalX: hit.nx, normalY: hit.ny })
|
|
if (collider.response === 'destroy') {
|
|
this.kill(particleIndex)
|
|
return true
|
|
}
|
|
world = { x: hit.x, y: hit.y }
|
|
if (collider.response === 'pause') {
|
|
p.collisionPaused = true
|
|
velocity = { x: 0, y: 0 }
|
|
} else {
|
|
const normalSpeed = velocity.x * hit.nx + velocity.y * hit.ny
|
|
const tangentX = velocity.x - normalSpeed * hit.nx
|
|
const tangentY = velocity.y - normalSpeed * hit.ny
|
|
const elasticity = Math.min(1, Math.max(0, collider.elasticity))
|
|
const tangentScale = 1 - Math.min(1, Math.max(0, collider.friction))
|
|
const reflectedNormal = normalSpeed < 0 ? -normalSpeed * elasticity : normalSpeed
|
|
velocity.x = tangentX * tangentScale + reflectedNormal * hit.nx
|
|
velocity.y = tangentY * tangentScale + reflectedNormal * hit.ny
|
|
}
|
|
const local = worldMathToLocal(cfg, world.x, world.y, this.simulationTransform)
|
|
const localVelocity = worldVelocityToLocalMath(cfg, velocity.x, velocity.y, this.simulationTransform)
|
|
p.x = local.x
|
|
p.y = local.y
|
|
p.vx = localVelocity.x
|
|
p.vy = localVelocity.y
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** 每帧更新;循环段录制时可强制发射或停止发射,以生成稳定衔接帧。 */
|
|
update(dt: number, emissionControl: 'normal' | 'force' | 'off' = 'normal'): ParticleState[] {
|
|
// HMR 会保留已创建的系统对象;旧对象缺少新增字段时在这里即时补齐。
|
|
const cfg = this.cfg
|
|
if (
|
|
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
|
|
cfg.shapeRangeVersion !== 1 ||
|
|
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
|
|
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
|
|
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null
|
|
) ensureEmitterConfig(cfg)
|
|
this.reseed() // 种子变化即重建随机源
|
|
// 累计发射时间
|
|
this._elapsed += dt
|
|
if (cfg.attraction && !this._attractionWasEnabled) {
|
|
this._attractionEnabledAt = this._elapsed
|
|
for (const particle of this.pool) {
|
|
particle.attractionActive = false
|
|
particle.attractionElapsed = 0
|
|
}
|
|
} else if (!cfg.attraction && this._attractionWasEnabled) {
|
|
for (const particle of this.pool) particle.attractionActive = false
|
|
}
|
|
this._attractionWasEnabled = cfg.attraction
|
|
const inWindow = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.delay <= 0 || this._elapsed >= cfg.delay))
|
|
// 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。
|
|
const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)))
|
|
// 发射(仅在窗口内且未超时长)
|
|
if (this.autoEmission && inWindow && withinDuration) {
|
|
if (cfg.mode === 'stream') {
|
|
this.accumulator += dt * cfg.rate
|
|
while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() }
|
|
} else {
|
|
this.burstTimer += dt
|
|
if (cfg.burstLoop) {
|
|
if (this.burstTimer >= 1) { this.burstTimer = 0; this.emitBurst() }
|
|
// update(0) 只用于两个固定帧之间刷新画面,不能被识别为首次爆发;
|
|
// 否则 0 <= 0 会在第 1 帧前额外生成一批粒子。
|
|
} else if (dt > 0 && this.burstTimer <= dt) {
|
|
this.emitBurst()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 处理子发射器的延迟发射队列;新粒子在本帧就会参与后续更新与录制。
|
|
if (this.pendingEventSpawns.length && dt > 0) {
|
|
const ready: typeof this.pendingEventSpawns = []
|
|
const waiting: typeof this.pendingEventSpawns = []
|
|
for (const pending of this.pendingEventSpawns) {
|
|
pending.remaining -= dt
|
|
if (pending.remaining <= 0) ready.push(pending)
|
|
else waiting.push(pending)
|
|
}
|
|
this.pendingEventSpawns = waiting
|
|
for (const pending of ready) {
|
|
const local = worldMathToLocal(this.cfg, pending.worldX, pending.worldY, this.simulationTransform)
|
|
for (let index = 0; index < pending.count; index++) {
|
|
if (!this.spawnOne({ x: local.x, y: local.y }, pending.depth, pending.inherit)) break
|
|
}
|
|
}
|
|
}
|
|
|
|
const states: ParticleState[] = []
|
|
for (let i = 0; i < this.pool.length; i++) {
|
|
const p = this.pool[i]
|
|
if (!p.active) continue
|
|
p.life -= dt
|
|
// 粒子自身生命周期优先:即使正在吸附途中,生命耗尽也立即死亡,不因吸附时长延寿。
|
|
if (p.life <= 0) { this.kill(i); continue }
|
|
const lifeT = tFromLife(p)
|
|
if (!p.collisionPaused && cfg.useGravity) {
|
|
const gravityMultiplier = Math.min(1, Math.max(0, curveAt(cfg.gravityCurve, lifeT)))
|
|
const gravityForce = (cfg.gravityMode === 'random' ? p.gravityStrength : cfg.gravityMin) * gravityMultiplier
|
|
const gravityAngle = cfg.gravityDirection * Math.PI / 180
|
|
p.vx += Math.cos(gravityAngle) * gravityForce * dt
|
|
p.vy += -Math.sin(gravityAngle) * gravityForce * dt
|
|
}
|
|
// 风力
|
|
if (!p.collisionPaused && cfg.wind) {
|
|
const windMultiplier = Math.min(1, Math.max(0, curveAt(cfg.windCurve, lifeT)))
|
|
const windForce = (cfg.windMode === 'random' ? p.windStrength : cfg.windMin) * windMultiplier
|
|
const windAngle = cfg.windDirection * Math.PI / 180
|
|
p.vx += Math.cos(windAngle) * windForce * dt
|
|
p.vy += -Math.sin(windAngle) * windForce * dt
|
|
}
|
|
// 全局力场:先把粒子局部坐标转换到场景数学坐标,计算力后再转回发射器局部坐标。
|
|
if (!p.collisionPaused && cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) {
|
|
const rootScaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
|
const rootScaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
|
const rootAngle = -(this.simulationTransform?.rotation ?? cfg.rootRotation) * Math.PI / 180
|
|
const cosRoot = Math.cos(rootAngle), sinRoot = Math.sin(rootAngle)
|
|
const centerX = this.simulationTransform?.centerX ?? cfg.centerX
|
|
const centerY = this.simulationTransform?.centerY ?? cfg.centerY
|
|
const particleScreenX = centerX + cosRoot * rootScaleX * p.x - sinRoot * rootScaleY * p.y
|
|
const particleScreenY = -centerY + sinRoot * rootScaleX * p.x + cosRoot * rootScaleY * p.y
|
|
const particleWorldX = particleScreenX
|
|
const particleWorldY = -particleScreenY
|
|
const dx = cfg.forceCenterX - particleWorldX
|
|
const dy = cfg.forceCenterY - particleWorldY
|
|
const distance = Math.hypot(dx, dy)
|
|
if (distance > 0.0001 && distance <= cfg.forceRadius) {
|
|
const falloff = fieldFalloff(cfg.forceFalloff, distance, cfg.forceRadius)
|
|
const force = cfg.forceStrength * falloff * 10
|
|
const accelWorldX = dx / distance * force
|
|
const accelWorldY = dy / distance * force
|
|
const accelScreenX = accelWorldX
|
|
const accelScreenY = -accelWorldY
|
|
p.vx += (cosRoot * accelScreenX + sinRoot * accelScreenY) / rootScaleX * dt
|
|
p.vy += (-sinRoot * accelScreenX + cosRoot * accelScreenY) / rootScaleY * dt
|
|
}
|
|
}
|
|
// 漩涡力在全局数学坐标中沿半径切线方向作用。
|
|
if (!p.collisionPaused && cfg.vortex && cfg.vortexStrength !== 0 && cfg.vortexRadius > 0) {
|
|
const world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform)
|
|
const acceleration = vortexAcceleration(
|
|
world.x, world.y,
|
|
cfg.vortexCenterX, cfg.vortexCenterY,
|
|
cfg.vortexStrength, cfg.vortexRadius, cfg.vortexFalloff,
|
|
)
|
|
const localAcceleration = worldVelocityToLocalMath(cfg, acceleration.x, acceleration.y, this.simulationTransform)
|
|
p.vx += localAcceleration.x * dt
|
|
p.vy += localAcceleration.y * dt
|
|
}
|
|
// 阻尼:速度按衰减系数逐渐减小
|
|
if (!p.collisionPaused && cfg.damping > 0) {
|
|
const f = Math.max(0, 1 - cfg.damping * dt)
|
|
p.vx *= f; p.vy *= f
|
|
}
|
|
if (!p.collisionPaused) {
|
|
p.x += p.vx * dt
|
|
p.y += p.vy * dt
|
|
if (this.applyColliders(p, i)) continue
|
|
} else {
|
|
p.vx = 0
|
|
p.vy = 0
|
|
}
|
|
|
|
// 吸附使用场景全局数学坐标。延迟结束时锁定当前位置,随后沿直线或确定性弧线到达目标。
|
|
if (cfg.attraction && !p.collisionPaused) {
|
|
const age = p.maxLife - p.life
|
|
const delayReached = cfg.attractionDelayMode === 'particle'
|
|
? age >= Math.max(0, cfg.attractionDelay)
|
|
: this._elapsed - this._attractionEnabledAt >= Math.max(0, cfg.attractionDelay)
|
|
if (delayReached) {
|
|
const currentWorld = localToWorldMath(cfg, p.x, p.y, this.simulationTransform)
|
|
const targetX = cfg.attractionCenterX
|
|
const targetY = cfg.attractionCenterY
|
|
const radius = Math.max(0, cfg.attractionRadius)
|
|
if (!p.attractionActive) {
|
|
if (Math.hypot(targetX - currentWorld.x, targetY - currentWorld.y) <= radius) {
|
|
this.kill(i)
|
|
continue
|
|
}
|
|
p.attractionActive = true
|
|
p.attractionElapsed = 0
|
|
p.attractionStartWorldX = currentWorld.x
|
|
p.attractionStartWorldY = currentWorld.y
|
|
}
|
|
p.attractionElapsed += dt
|
|
const duration = Math.max(0.0001, cfg.attractionDuration)
|
|
const progress = Math.min(1, p.attractionElapsed / duration)
|
|
let worldX = lerp(p.attractionStartWorldX, targetX, progress)
|
|
let worldY = lerp(p.attractionStartWorldY, targetY, progress)
|
|
if (cfg.attractionPath === 'curve') {
|
|
const dx = targetX - p.attractionStartWorldX
|
|
const dy = targetY - p.attractionStartWorldY
|
|
const distance = Math.hypot(dx, dy)
|
|
if (distance > 0.0001) {
|
|
const curveOffset = distance * 0.25 * p.attractionCurveSide
|
|
const controlX = (p.attractionStartWorldX + targetX) * 0.5 - dy / distance * curveOffset
|
|
const controlY = (p.attractionStartWorldY + targetY) * 0.5 + dx / distance * curveOffset
|
|
const oneMinusT = 1 - progress
|
|
worldX = oneMinusT * oneMinusT * p.attractionStartWorldX + 2 * oneMinusT * progress * controlX + progress * progress * targetX
|
|
worldY = oneMinusT * oneMinusT * p.attractionStartWorldY + 2 * oneMinusT * progress * controlY + progress * progress * targetY
|
|
}
|
|
}
|
|
const remainingDistance = Math.hypot(targetX - worldX, targetY - worldY)
|
|
if (progress >= 1 || remainingDistance <= radius) {
|
|
this.kill(i)
|
|
continue
|
|
}
|
|
const local = worldMathToLocal(cfg, worldX, worldY, this.simulationTransform)
|
|
p.x = local.x
|
|
p.y = local.y
|
|
// 吸附期间位置由路径控制,清除速度以免抵达后发生额外积分偏移。
|
|
p.vx = 0
|
|
p.vy = 0
|
|
}
|
|
}
|
|
const t = lifeT
|
|
const s = p.sprite
|
|
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
|
|
if (resource) {
|
|
const frameState = imageFrameState(resource, p.maxLife - p.life)
|
|
p.imageFrameIndex = frameState.index
|
|
s.texture = imageFrameTexture(resource, cfg.texture, p.imageFrameIndex)
|
|
s.visible = frameState.visible
|
|
}
|
|
|
|
if (cfg.speedOverLifeEnabled) {
|
|
const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1)
|
|
const ratio = p.speedScale === 0 ? target : target / p.speedScale
|
|
p.vx *= ratio; p.vy *= ratio; p.speedScale = target
|
|
}
|
|
|
|
let rotationSpeed = p.rotationSpeed
|
|
if (cfg.rotationOverLifeEnabled && cfg.rotationOverLifeMode === 'curve') {
|
|
rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, curveAt(cfg.rotationOverLifeCurve, t))
|
|
}
|
|
p.rotation += rotationSpeed * dt
|
|
let renderX = p.x, renderY = p.y
|
|
if (cfg.noise && cfg.noiseAmt > 0 && cfg.noiseRange > 0 && cfg.noiseSpeed > 0) {
|
|
const age = p.maxLife - p.life
|
|
const amplitude = Math.max(0, cfg.noiseRange) * Math.min(1, Math.max(0, cfg.noiseAmt) / 100) * 0.5
|
|
const phase = age * Math.max(0, cfg.noiseSpeed) * Math.PI * 2
|
|
// 减去出生时的初始采样,确保粒子仍从发射点开始;差值最大不超过设定波动范围。
|
|
renderX += (Math.sin(p.noisePhaseX + phase) - Math.sin(p.noisePhaseX)) * amplitude
|
|
renderY += (Math.sin(p.noisePhaseY + phase * 0.83) - Math.sin(p.noisePhaseY)) * amplitude
|
|
}
|
|
s.x = renderX; s.y = renderY; s.rotation = -p.rotation * Math.PI / 180
|
|
let scaleMulX = 1, scaleMulY = 1
|
|
if (cfg.scaleOverLifeEnabled) {
|
|
scaleMulX = overLifeValue(cfg.scaleOverLifeMode, cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, cfg.scaleOverLifeCurve, t, p.scaleOverLifeX)
|
|
scaleMulY = cfg.scaleOverLifeAxisMode === 'separate'
|
|
? overLifeValue(cfg.scaleOverLifeMode, cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, cfg.scaleYOverLifeCurve, t, p.scaleOverLifeY)
|
|
: scaleMulX
|
|
}
|
|
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
|
|
p.currentScaleX = s.scale.x
|
|
p.currentScaleY = s.scale.y
|
|
const baseAlpha = cfg.alphaMode === 'curve'
|
|
? 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, this.resolveCurveMode(resource.alphaCurveMode))
|
|
: baseAlpha, 1)
|
|
const colorStart = resource?.colorStart || '#ffffff'
|
|
const baseRgb = resource?.colorMode === 'lifetime'
|
|
? gradientColorAt(resource.colorGradient, t)
|
|
: resource?.colorMode === 'random'
|
|
? p.colorSample
|
|
: hexToNumber(colorStart)
|
|
// 继承颜色:按配置逐通道相加,或直接覆盖目标系统颜色。
|
|
const rgb = p.colorOverride ?? addRgbColors(baseRgb, p.colorAdd)
|
|
s.tint = rgb
|
|
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
|
const trailState = this.trail.update(p.trail, cfg, p.boneName, renderX, renderY, t, s)
|
|
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
|
|
}
|
|
|
|
/** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */
|
|
drawTrailMeshDebug(visible: boolean) {
|
|
this.trail.drawDebug(this.trailDebugGfx, visible, this.cfg, this.pool.map((p) => p.trail))
|
|
}
|
|
|
|
private emitBurst() {
|
|
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
|
|
}
|
|
|
|
private spawnOrigin(): EmitterShapeSample {
|
|
const cfg = this.cfg
|
|
// 局部原点 = 0(root 中心,centerX 由发射器容器 position 补回);粒子相对 root 中心 = 偏移X/Y
|
|
let ox = cfg.offsetX, oy = cfg.offsetY
|
|
// 采用数学坐标系(Y 向上为正):world 为屏幕坐标(向下为正),故 y 取负
|
|
oy = -oy
|
|
if (cfg.shape === 'path' && cfg.spawnPathId > 0 && this.spawnPathSampler) {
|
|
const count = this.estimatedEmissionCount()
|
|
const progress = pathDistributionProgress(cfg.spawnPathDistribution, this._spawnSerial, count, this.spawnPathClosed, this.rng)
|
|
const pathSample = this.spawnPathSampler(progress)
|
|
if (pathSample) {
|
|
const origin = this.applyFollowToOrigin(pathSample.x + ox, pathSample.y + oy)
|
|
return { ...origin, radialAngle: 0, pathAngle: pathSample.angle }
|
|
}
|
|
}
|
|
const sample = sampleEmitterShape(cfg, this.rng)
|
|
const origin = this.applyFollowToOrigin(ox + sample.x, oy + sample.y)
|
|
return { ...origin, radialAngle: sample.radialAngle }
|
|
}
|
|
|
|
private estimatedEmissionCount() {
|
|
const cfg = this.cfg
|
|
return cfg.mode === 'burst'
|
|
? Math.max(1, Math.round(cfg.burstCount))
|
|
: Math.max(1, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
|
|
}
|
|
|
|
private applyFollowToOrigin(x: number, y: number) {
|
|
const angle = this.followSpawnAngle * Math.PI / 180
|
|
const cos = Math.cos(angle), sin = Math.sin(angle)
|
|
return {
|
|
x: this.followSpawnX + cos * x - sin * y,
|
|
y: this.followSpawnY + sin * x + cos * y,
|
|
}
|
|
}
|
|
|
|
/** 由跨系统事件触发:在给定的数学世界坐标处生成一颗目标系统的粒子。 */
|
|
spawnEventParticle(worldX: number, worldY: number, depth = 1, inherit?: ParticleInheritOptions, count?: number): boolean {
|
|
if (depth > 2) return false
|
|
// 子发射器按自己的发射模式决定每次事件生成多少颗:爆发=单次数量,持续=1。
|
|
const requested = count ?? (this.cfg.mode === 'burst' ? this.cfg.burstCount : 1)
|
|
const total = Math.max(1, Math.min(MAX_PARTICLES, Math.round(Number(requested) || 1)))
|
|
// 子发射器 delay 表示在父事件之后再延迟多久发射。
|
|
const delay = Math.max(0, Number(this.cfg.delay) || 0)
|
|
if (delay > 0) {
|
|
this.pendingEventSpawns.push({ worldX, worldY, depth, inherit, count: total, remaining: delay })
|
|
return true
|
|
}
|
|
const local = worldMathToLocal(this.cfg, worldX, worldY, this.simulationTransform)
|
|
let spawned = false
|
|
for (let index = 0; index < total; index++) {
|
|
if (!this.spawnOne({ x: local.x, y: local.y }, depth, inherit)) break
|
|
spawned = true
|
|
}
|
|
return spawned
|
|
}
|
|
|
|
private emitWorldEvent(
|
|
kind: ParticleEventKind,
|
|
particle: Particle,
|
|
worldX: number,
|
|
worldY: number,
|
|
extra?: { normalX?: number; normalY?: number },
|
|
) {
|
|
if (!this.onParticleEvent) return
|
|
this.onParticleEvent({
|
|
kind,
|
|
worldX,
|
|
worldY,
|
|
rotation: particle.rotation,
|
|
spawnId: particle.spawnId,
|
|
depth: particle.eventDepth,
|
|
colorHex: particle.sprite.tint as number,
|
|
scaleX: particle.currentScaleX,
|
|
scaleY: particle.currentScaleY,
|
|
maxLife: particle.maxLife,
|
|
collisionCount: particle.collisionCount,
|
|
...extra,
|
|
})
|
|
}
|
|
|
|
private emitLocalEvent(kind: ParticleEventKind, particle: Particle, x: number, y: number) {
|
|
if (!this.onParticleEvent) return
|
|
const world = localToWorldMath(this.cfg, x, y, this.simulationTransform)
|
|
this.emitWorldEvent(kind, particle, world.x, world.y)
|
|
}
|
|
|
|
private spawnOne(originOverride?: { x: number; y: number }, eventDepth = 0, inherit?: ParticleInheritOptions): boolean {
|
|
const cfg = this.cfg
|
|
let idx = this.pool.findIndex((p) => !p.active)
|
|
if (idx === -1) return false // 池满
|
|
const origin = originOverride
|
|
? { x: originOverride.x, y: originOverride.y, radialAngle: 0 }
|
|
: this.spawnOrigin()
|
|
const { x: ox, y: oy } = origin
|
|
const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng())
|
|
const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
|
|
// 继承旋转作为整个子发射系统的方向偏移:旋转发射方向与粒子初始朝向。
|
|
const rotationOffset = inherit?.attributes.includes('rotation') ? inherit.data.rotation : 0
|
|
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng) + rotationOffset * Math.PI / 180
|
|
const p = this.pool[idx]
|
|
const resource = pickImageResource(cfg, this.rng)
|
|
const spawnSerial = this._spawnSerial++
|
|
p.spawnId = spawnSerial
|
|
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
|
const trailResource = pickTrailResource(cfg, seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
|
|
// 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。
|
|
p.noisePhaseX = seededUnit(cfg.seed, spawnSerial, 0x68bc21eb) * Math.PI * 2
|
|
p.noisePhaseY = seededUnit(cfg.seed, spawnSerial, 0x02e5be93) * Math.PI * 2
|
|
p.gravityStrength = cfg.useGravity && cfg.gravityMode === 'random'
|
|
? lerp(cfg.gravityMin, cfg.gravityMax, this.rng())
|
|
: cfg.gravityMin
|
|
p.windStrength = cfg.wind && cfg.windMode === 'random'
|
|
? lerp(cfg.windMin, cfg.windMax, this.rng())
|
|
: cfg.windMin
|
|
p.attractionActive = false
|
|
p.attractionElapsed = 0
|
|
p.collisionPaused = false
|
|
p.collisionCount = 0
|
|
p.eventDepth = eventDepth
|
|
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
|
|
p.trail.history.length = 0
|
|
p.trail.bones.length = 0
|
|
p.trail.currentWidth = 0
|
|
p.trail.currentAlpha = 0
|
|
p.trail.currentColor = 0xffffff
|
|
p.trail.resourceId = 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.trail.sampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1)
|
|
p.trail.sampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c)
|
|
p.trail.enabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trail.sampledLength > 0 && p.trail.sampledWidth > 0
|
|
if (p.trail.mesh) p.trail.mesh.visible = false
|
|
p.active = true; p.life = life; p.maxLife = life
|
|
p.x = ox; p.y = oy
|
|
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
|
|
if (p.trail.enabled) p.trail.history.push({ x: ox, y: oy })
|
|
p.vx = Math.cos(dirAng) * speed
|
|
p.vy = Math.sin(dirAng) * speed
|
|
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
|
|
p.scaleStartY = cfg.scaleAxisMode === 'separate'
|
|
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng()) * resource.scale
|
|
: p.scaleStartX
|
|
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
|
|
p.rotation = cfg.initialRotationMode === 'direction'
|
|
? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin
|
|
: initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng()) + rotationOffset
|
|
p.rotationSpeed = cfg.rotationOverLifeEnabled
|
|
? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng())
|
|
: 0
|
|
p.speedScale = 1
|
|
p.speedScaleTarget = cfg.speedOverLifeMode === 'random'
|
|
? lerp(cfg.speedOverLifeMin, cfg.speedOverLifeMax, this.rng())
|
|
: cfg.speedOverLifeMin
|
|
p.scaleOverLifeX = cfg.scaleOverLifeMode === 'random'
|
|
? lerp(cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, this.rng())
|
|
: cfg.scaleOverLifeMin
|
|
p.scaleOverLifeY = cfg.scaleOverLifeMode === 'random'
|
|
? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng())
|
|
: cfg.scaleYOverLifeMin
|
|
p.resourceId = resource.id
|
|
p.colorSample = resource.colorMode === 'random'
|
|
? gradientColorAt(resource.colorGradient, seededUnit(cfg.seed, spawnSerial, 0x9e3779b9))
|
|
: hexToNumber(resource.colorStart)
|
|
p.colorAdd = 0
|
|
p.colorOverride = null
|
|
// 子发射器继承父粒子属性:颜色 / 大小 / 旋转 / 生命周期。
|
|
if (inherit) {
|
|
const attributes = inherit.attributes
|
|
if (attributes.includes('color')) {
|
|
if (inherit.colorMode === 'replace') p.colorOverride = inherit.data.colorHex
|
|
else p.colorAdd = inherit.data.colorHex
|
|
}
|
|
if (attributes.includes('scale')) {
|
|
// 大小作为整个子发射系统的缩放:目标粒子自身缩放 × 父粒子当前缩放。
|
|
p.scaleStartX *= inherit.data.scaleX
|
|
p.scaleStartY *= inherit.data.scaleY
|
|
}
|
|
// 旋转已在上面作为整体方向偏移应用,不再覆盖粒子自身旋转。
|
|
if (attributes.includes('life')) {
|
|
p.life = Math.max(0.0001, inherit.data.life)
|
|
p.maxLife = p.life
|
|
}
|
|
}
|
|
p.currentScaleX = p.scaleStartX
|
|
p.currentScaleY = p.scaleStartY
|
|
const initialFrame = imageFrameState(resource, 0)
|
|
p.imageFrameIndex = initialFrame.index
|
|
p.sprite.texture = imageFrameTexture(resource, cfg.texture, initialFrame.index)
|
|
p.sprite.anchor.set(resource.anchorX, resource.anchorY)
|
|
p.sprite.blendMode = BLEND_MAP[resource.blend] as any
|
|
p.sprite.tint = p.colorOverride ?? addRgbColors(p.colorSample, p.colorAdd)
|
|
p.sprite.visible = true
|
|
this.emitLocalEvent('birth', p, p.x, p.y)
|
|
return true
|
|
}
|
|
|
|
private kill(i: number) {
|
|
const p = this.pool[i]
|
|
if (p.active) this.emitLocalEvent('death', p, p.x, p.y)
|
|
p.active = false
|
|
p.sprite.visible = false
|
|
resetTrailState(p.trail)
|
|
}
|
|
|
|
clear() {
|
|
this.pendingEventSpawns.length = 0
|
|
for (const p of this.pool) {
|
|
p.active = false
|
|
p.sprite.visible = false
|
|
resetTrailState(p.trail)
|
|
}
|
|
}
|
|
|
|
get activeCount() {
|
|
let n = 0
|
|
for (const p of this.pool) if (p.active) n++
|
|
return n
|
|
}
|
|
|
|
/** 录制:返回当前活动粒子的深拷贝快照 */
|
|
capture(): ParticleState[] {
|
|
const out: ParticleState[] = []
|
|
for (const p of this.pool) {
|
|
if (!p.active) continue
|
|
out.push({
|
|
// 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。
|
|
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.trail.bones.length
|
|
? {
|
|
bones: p.trail.bones.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.trail.bones.length,
|
|
width: p.trail.currentWidth,
|
|
alpha: p.trail.currentAlpha,
|
|
colorHex: p.trail.currentColor,
|
|
resourceId: p.trail.resourceId,
|
|
}
|
|
: undefined,
|
|
})
|
|
}
|
|
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>()
|
|
for (const s of states) byName.set(s.boneName, s)
|
|
for (const p of this.pool) {
|
|
const st = byName.get(p.boneName)
|
|
if (!st) {
|
|
p.active = false
|
|
p.sprite.visible = false
|
|
if (p.trail.mesh) p.trail.mesh.visible = false
|
|
continue
|
|
}
|
|
p.active = st.active
|
|
const s = p.sprite
|
|
s.visible = st.active && st.imageVisible !== false
|
|
s.x = st.x; s.y = st.y
|
|
s.rotation = -st.rotation * Math.PI / 180
|
|
s.scale.set(st.scaleX, st.scaleY)
|
|
s.alpha = st.alpha
|
|
s.tint = st.colorHex
|
|
if (st.resourceId != null) {
|
|
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
|
|
if (resource) {
|
|
p.imageFrameIndex = st.imageFrameIndex ?? 0
|
|
s.texture = imageFrameTexture(resource, this.cfg.texture, p.imageFrameIndex)
|
|
s.anchor.set(resource.anchorX, resource.anchorY)
|
|
s.blendMode = BLEND_MAP[resource.blend] as any
|
|
}
|
|
}
|
|
if (this.cfg.trail && st.trailState) {
|
|
this.trail.applyState(p.trail, this.cfg, p.sprite, st.trailState)
|
|
} else if (p.trail.mesh) p.trail.mesh.visible = false
|
|
}
|
|
}
|
|
|
|
/** 清空所有粒子并重置发射计时 */
|
|
reset() {
|
|
this.pendingEventSpawns.length = 0
|
|
for (const p of this.pool) {
|
|
p.active = false
|
|
p.sprite.visible = false
|
|
resetTrailState(p.trail)
|
|
}
|
|
this.accumulator = 0
|
|
this.burstTimer = 0
|
|
this._spawnSerial = 0
|
|
this._elapsed = 0
|
|
this._attractionEnabledAt = 0
|
|
this._attractionWasEnabled = false
|
|
// 从头重新模拟:任何整数种子(包括 0)都会回到同一随机序列。
|
|
this._lastSeed = this.cfg.seed
|
|
this.rng = mulberry32(this.cfg.seed >>> 0)
|
|
}
|
|
|
|
/**
|
|
* 开始下一个循环周期,但保留仍存活粒子的物理状态。
|
|
* 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。
|
|
*/
|
|
restartEmissionCycle() {
|
|
this.pendingEventSpawns.length = 0
|
|
this.accumulator = 0
|
|
this.burstTimer = 0
|
|
this._spawnSerial = 0
|
|
this._elapsed = 0
|
|
this._attractionEnabledAt = 0
|
|
this._attractionWasEnabled = this.cfg.attraction
|
|
this._lastSeed = this.cfg.seed
|
|
this.rng = mulberry32(this.cfg.seed >>> 0)
|
|
}
|
|
|
|
/** 从当前重置状态向前模拟,用于时间轴第 0 帧的暖场。 */
|
|
warmup(frames: number, frameDuration: number) {
|
|
const count = Math.max(0, Math.floor(Number(frames) || 0))
|
|
for (let frame = 0; frame < count; frame++) this.update(frameDuration)
|
|
}
|
|
}
|