/** * Generic object pool used by damage effects, bullets, enemies and VFX * (requirement 18.5). Pure-TS, platform-agnostic, Jest-testable. * * Design notes: * - `factory` creates a brand-new instance when the free list is empty. * - `resetter` is invoked on every released object, letting the caller wipe * transient state (position, timers, listeners) before it goes back to * the pool. * - `maxSize` caps the retained instances; objects released beyond the cap * are dropped (letting the GC collect them) to bound memory usage * (requirement 18.4: memory peak ≤ 200MB). * - Double-release is silently ignored but reported through `onDoubleRelease` * so tests / Logger can assert correctness. */ export type ObjectFactory = () => T; export type ObjectResetter = (obj: T) => void; export interface ObjectPoolOptions { /** Required creator invoked when the pool is empty. */ factory: ObjectFactory; /** Optional cleaner invoked on every `release`. */ resetter?: ObjectResetter; /** Max retained objects; excess releases are discarded. Default 128. */ maxSize?: number; /** Optional pre-warm count (creates this many objects upfront). Default 0. */ preAlloc?: number; /** Optional diagnostic hook. */ onDoubleRelease?: (obj: T) => void; } export class ObjectPool { private readonly free: T[] = []; private readonly borrowed = new Set(); private readonly factory: ObjectFactory; private readonly resetter?: ObjectResetter; private readonly maxSize: number; private readonly onDoubleRelease?: (obj: T) => void; // Diagnostics private _acquiredTotal = 0; private _recycledTotal = 0; private _createdTotal = 0; constructor(options: ObjectPoolOptions) { this.factory = options.factory; this.resetter = options.resetter; this.maxSize = options.maxSize ?? 128; this.onDoubleRelease = options.onDoubleRelease; const preAlloc = options.preAlloc ?? 0; for (let i = 0; i < preAlloc; i++) { const inst = this.factory(); this._createdTotal++; this.free.push(inst); } } /** Acquire an object from the pool (creates one if empty). */ public acquire(): T { this._acquiredTotal++; const inst = this.free.pop(); if (inst !== undefined) { this.borrowed.add(inst); return inst; } const created = this.factory(); this._createdTotal++; this.borrowed.add(created); return created; } /** Release an object back to the pool. Double-releases are ignored. */ public release(obj: T): void { if (!this.borrowed.has(obj)) { this.onDoubleRelease?.(obj); return; } this.borrowed.delete(obj); this.resetter?.(obj); if (this.free.length < this.maxSize) { this.free.push(obj); this._recycledTotal++; } // else: drop the object so the GC can reclaim it. } /** Number of objects currently held in the free list. */ public get freeCount(): number { return this.free.length; } /** Number of objects that are currently out on loan. */ public get borrowedCount(): number { return this.borrowed.size; } /** Drop everything. Used on scene unload. */ public drain(): void { this.free.length = 0; this.borrowed.clear(); } /** Diagnostic stats (used by Logger / perf BI埋点). */ public stats() { return { free: this.freeCount, borrowed: this.borrowedCount, acquired: this._acquiredTotal, recycled: this._recycledTotal, created: this._createdTotal, }; } }