frame-select.ts

PURPOSE

Pure function that maps an animated VFX instance’s runtime state (age, fps, loop mode, optional driver parameter) to a single frame index inside a fixed-length frame array. Used by the VFX workbench preview/playback path to drive sprite-strip animation.

OWNS

  • FrameSelectArgs interface — input shape: loopMode ('oneshot' | 'loop' | 'parameterized'), frameCount: number, fps: number, ageMs: number, optional param?: number.
  • pickFrameIndex(a: FrameSelectArgs): number — pure frame-index resolver. No state, no allocations, no side effects.

READS FROM

  • Only its own argument struct. No imports.

PUSHES TO

  • Nothing. Returns an integer in [0, frameCount - 1].

DOES NOT

  • Does not load, decode, or composite frame textures.
  • Does not advance time, schedule ticks, or read performance.now().
  • Does not handle frameCount === 0 as an error — returns 0 defensively (the one boundary fallback in the file).
  • Does not interpolate between frames (integer index only).
  • Does not respect playback rate other than via fps and ageMs.
  • Does not emit telemetry.

Signals

  • loopMode === 'parameterized'param is clamped to [0, 1], then mapped to floor(p * frameCount) and clamped to [0, frameCount - 1]. ageMs and fps are ignored on this branch. param ?? 0 means a missing param plays frame 0.
  • loopMode === 'oneshot'elapsedFrames = ageMs * fps / 1000, then floor and clamp to last frame. Holds on the final frame after the strip completes.
  • loopMode === 'loop' — same elapsedFrames, then floor(elapsedFrames) % frameCount; the m < 0 ? m + frameCount : m branch handles negative ageMs so the function stays total even on unexpected inputs.
  • frameCount <= 0 short-circuits to 0 before any branch runs.

Entry points

  • Called by VFX workbench render code that picks which frame of a sprite strip to draw for a given particle/effect instance each tick. (No imports inside this file reveal the caller; it is a leaf utility.)

Pattern notes

  • Pure function, no this, no closures, no module-level state — trivially testable and safe to call inside hot render loops.
  • Branch order is intentional: the parameterized branch returns before the elapsedFrames math runs, so it never pays the multiply/divide cost.
  • All three return paths clamp to [0, frameCount - 1] so the caller can index directly into a frames array without bounds checks.
  • The negative-ageMs guard on the loop branch is the only place this file tolerates “bad” input rather than relying on caller invariants — matches a single-frame paranoia at frameCount <= 0.
  • loopMode is a string union rather than an enum to keep this file dependency-free.

EXTRACT-CANDIDATE

Frame-index resolution from (ageMs, fps, frameCount, loopMode, param) is a generic primitive that applies to any sprite-strip animation in the engine, not just the VFX workbench. If a second consumer ever needs the same mapping (e.g. enemy idle-loop frames, UI spinners), promote this file from engine/vfx-workbench/ to a shared engine/animation/ or engine/rendering/ module without changing its surface. The parameterized mode (drive frame by a 0..1 scalar instead of time) is the part most likely to be reused — it is effectively a “lookup table sampler” and could be named as such.