export type FxKind = 'spark' | 'mist' | 'ripple' | 'blade' | 'pulse' | 'seasonShift' | 'drift' export type FxMode = 'soft' | 'std' | 'full' export interface FxEmit { kind: FxKind payload?: Record at: number } export interface FxBus { emit(e: FxEmit): void } /** 竞态安全发射闸:单循环、队列不覆盖、档位过滤、reduced-motion 降级 */ export class FxGate { private queue: FxEmit[] = [] private draining = false mode: FxMode = 'std' preferReduced = false constructor(private bus: FxBus) {} setMode(m: FxMode): void { this.mode = m } setReduced(r: boolean): void { this.preferReduced = r } allowed(kind: FxKind): boolean { if (this.mode === 'soft') return kind === 'ripple' || kind === 'pulse' || kind === 'drift' if (this.mode === 'full') return true return kind !== 'blade' // std:火花/雾/涟漪/脉动,刀光仅 full } emit(kind: FxKind, payload?: FxEmit['payload']): void { if (!this.allowed(kind) || this.preferReduced || this.draining) return this.queue.push({ kind, payload, at: Date.now() }) this.drain() } private drain(): void { if (this.draining) return this.draining = true // 单帧集中派发(不逐个 setTimeout,防止栈深+竞态覆盖) const batch = this.queue this.queue = [] try { for (const e of batch) this.bus.emit(e) } finally { this.draining = false } } pending(): number { return this.queue.length + (this.draining ? 1 : 0) } /** 纯逻辑:资源差量(防负值与 NaN,供漂字用) */ static deltas(prev: Record, next: Record): Record { const out: Record = {} const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) for (const k of keys) { const a = prev[k] ?? 0 const b = next[k] ?? 0 if (Number.isFinite(a) && Number.isFinite(b) && b !== a) out[k] = b - a } return out } }