PURPOSE
Bridge from workbench-baked sprite atlases to the shipping SpriteBatch. Loaded once at boot; consumed by per-weapon draw code. Lets weapons resolve a baked VFX component ID + elapsed time into a UV region for the correct flipbook frame in the main atlas.
OWNS
- Internal
_componentsmap keyed bycomponentId, storing per-component metadata: category, atlas region, tile size, grid dimensions, frame count, fps, loop mode, pivot. - Internal
_loadedOnceflag guarding idempotent boot init. ComponentInfointerface (private) describing a registered baked component.VfxManifestinterface (private) matching the dev plugin’s manifest shape fromvite.config.ts.- The half-texel UV inset math used to avoid bleed between neighboring grid tiles.
READS FROM
/src/starship-survivors/data/vfx/atlas/<category>.manifest.jsonfor each of the four categories (muzzle,body,impact,persistent) — fetched via HTTP./src/starship-survivors/data/vfx/atlas/<category>/<componentId>.png— each baked flipbook PNG, loaded viaImage().tryGetAtlasRegionfrom./atlas-builderto reuse a region if one already exists forvfx_<componentId>.
PUSHES TO
reserveAtlasRegion(regionName, gridW, gridH)on./atlas-builder— packs each baked PNG into the main atlas under namevfx_<componentId>.patchAtlasRegion(regionName, img)on./atlas-builder— uploads the PNG pixels into the reserved region.console.warnon per-component load failure (bad PNG, fetch error).
DOES NOT
- Does not call
reuploadAtlasitself — the boot caller is responsible for triggering the single upload after all components register. - Does not draw to the canvas, hold a
SpriteBatchreference, or issue any GL calls. - Does not throw on missing manifests or PNGs — missing assets log a warning and the component is simply unavailable.
- Does not retry, hot-reload, or watch the filesystem; init is one-shot per process.
- Does not normalize
tto0..1— callers must pass live elapsed game time in seconds. - Does not provide a way to unregister or replace a component once loaded.
- Does not handle the fallback render path — weapons fall back to Canvas 2D
drawBulleton their own whengetVfxComponentreturns null.
Signals
initVfxComponents(): Promise<number>resolves with the number of components registered (may be 0 in dev before any bakes exist).getVfxComponent(componentId)returns null when the component is not loaded — the documented signal for weapons to fall back to Canvas 2D.vfxFrameRegion(componentId, tSeconds)returns null when the component is not loaded.- Per-component load failures emit
console.warn('[vfx-runtime] failed to register <componentId>:', err). getVfxComponentCount()exposes the registered-component count for telemetry.
Entry points
initVfxComponents()— async boot routine. Idempotent: short-circuits with_components.sizeafter the first run. IteratesCATEGORIES, fetches each manifest, loads each layer’s PNG, reserves + patches the atlas region, and storesComponentInfoin_components.getVfxComponent(componentId)— synchronous lookup of registered metadata.vfxFrameRegion(componentId, tSeconds)— synchronous per-frame UV resolver. Looks up the component, picks the flipbook frame viapickFrame, and computes the sub-region viaframeRegion.getVfxComponentCount()— synchronous count accessor.
Private helpers: pickFrame (oneshot clamps to last frame, loop/parameterized wraps via modulo with negative-safe math), frameRegion (col/row → UV sub-region with half-texel inset), loadImage (Promise wrapper around Image() with onload/onerror).
Pattern notes
- Atlas region naming convention: every baked component is stored under
vfx_<componentId>in the main atlas to namespace away from other sprite categories. - The four categories (
muzzle,body,impact,persistent) are hardcoded in theCATEGORIESconstant; adding a new category requires editing this file. - Missing-manifest path uses
continue(not throw): an empty category is a normal dev state, not an error. tryGetAtlasRegionis checked beforereserveAtlasRegionso re-init paths (despite the_loadedOnceguard) and shared region names with other systems would not double-allocate. In practice the guard means this branch is only hit if another system pre-registered the samevfx_<componentId>name.pickFrameuses((rawFrame % frameCount) + frameCount) % frameCountfor loop mode so negativetSecondswraps correctly instead of producing a negative frame index.loopMode: 'parameterized'is treated identically to'loop'insidepickFrame— the parameterization is expected to happen upstream in how callers computetSeconds.- Half-texel inset in
frameRegionis0.5 / (tileSize * gridCols), derived from atlas-pixel width per UV unit, to keep edge tiles from bleeding neighbors when the sampler filters. - The
VfxManifest.layers[]shape carries extra metadata (anchors,palette,shaderTemplate,shaderParams,bakedAt,atlasPath,name,kind,layerIndex) that this runtime ignores — only the listedComponentInfofields are retained. - Integration order matters: callers must finish
initVfxComponents()for every category before triggeringreuploadAtlasonce, since the runtime never uploads.