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 _components map keyed by componentId, storing per-component metadata: category, atlas region, tile size, grid dimensions, frame count, fps, loop mode, pivot.
  • Internal _loadedOnce flag guarding idempotent boot init.
  • ComponentInfo interface (private) describing a registered baked component.
  • VfxManifest interface (private) matching the dev plugin’s manifest shape from vite.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.json for 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 via Image().
  • tryGetAtlasRegion from ./atlas-builder to reuse a region if one already exists for vfx_<componentId>.

PUSHES TO

  • reserveAtlasRegion(regionName, gridW, gridH) on ./atlas-builder — packs each baked PNG into the main atlas under name vfx_<componentId>.
  • patchAtlasRegion(regionName, img) on ./atlas-builder — uploads the PNG pixels into the reserved region.
  • console.warn on per-component load failure (bad PNG, fetch error).

DOES NOT

  • Does not call reuploadAtlas itself — the boot caller is responsible for triggering the single upload after all components register.
  • Does not draw to the canvas, hold a SpriteBatch reference, 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 t to 0..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 drawBullet on their own when getVfxComponent returns 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.size after the first run. Iterates CATEGORIES, fetches each manifest, loads each layer’s PNG, reserves + patches the atlas region, and stores ComponentInfo in _components.
  • getVfxComponent(componentId) — synchronous lookup of registered metadata.
  • vfxFrameRegion(componentId, tSeconds) — synchronous per-frame UV resolver. Looks up the component, picks the flipbook frame via pickFrame, and computes the sub-region via frameRegion.
  • 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 the CATEGORIES constant; 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.
  • tryGetAtlasRegion is checked before reserveAtlasRegion so re-init paths (despite the _loadedOnce guard) 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 same vfx_<componentId> name.
  • pickFrame uses ((rawFrame % frameCount) + frameCount) % frameCount for loop mode so negative tSeconds wraps correctly instead of producing a negative frame index.
  • loopMode: 'parameterized' is treated identically to 'loop' inside pickFrame — the parameterization is expected to happen upstream in how callers compute tSeconds.
  • Half-texel inset in frameRegion is 0.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 listed ComponentInfo fields are retained.
  • Integration order matters: callers must finish initVfxComponents() for every category before triggering reuploadAtlas once, since the runtime never uploads.