warp-puddle-system.ts

PURPOSE

Per-frame enter/exit detection and warpT tween for warp puddles. Operates on merged puddle groups, not on individual puddles — two overlapping circles are treated as one continuous warp zone. Owns the contract that entry ramps up over a short tween while exit snaps back instantly, and fires enter/exit callbacks exactly once per group transition.

OWNS

  • WARP_TWEEN_RATE constant (value 4) — warpT rise rate per second; reaches 1.0 from 0.0 in 0.25 s. Matches Camera.update’s zoom-lerp rate so visuals and gameplay state stay in sync.
  • WarpPuddleCallbacks interface — optional onEnter(group) / onExit(group) hooks invoked on group transitions.
  • tickWarpPuddles(ship, groups, dt, cb) — the per-frame update; mutates ship.warpGroupId and ship.warpT and fires the callbacks.
  • getActiveWarpGroup(ship, groups) — lookup helper that returns the group whose id matches ship.warpGroupId, or null.

READS FROM

  • ship.x, ship.y — ship position, passed into findContainingGroup for the point-in-group test.
  • ship.warpGroupId — previous-frame group id used to detect transitions.
  • ship.warpT — previous-frame tween value used as the input to the ramp-in clamp.
  • findContainingGroup(groups, x, y) from ./warp-puddles — returns the WarpPuddleGroup whose union of member circles contains the ship, or null.
  • WarpPuddleGroup type from ./warp-puddles — used for the groups parameter and the callback argument type.
  • ShipState type from ../core/types.
  • dt parameter — delta-time seconds for the tween integration.

PUSHES TO

  • ship.warpGroupId — set to the containing group’s id on entry, set to null on exit, and cleared when the groups array is empty.
  • ship.warpT — ramped up by WARP_TWEEN_RATE * dt and clamped at 1 while inside a group; snapped to 0 the frame the ship leaves any group, and zeroed when groups is empty.
  • cb.onEnter(group) — fired once when ship.warpGroupId transitions from null/different-id to the new containing group’s id.
  • cb.onExit(group) — fired once with the previously-active group when the ship leaves it (either to no group, or to a different group on the same frame).

DOES NOT

  • Does not render the puddles, draw the warp post-fx, or set the camera zoom — downstream consumers read ship.warpT / ship.warpGroupId and apply their own effects.
  • Does not play SFX or spawn particles directly — the bubble-burst VFX, halo rings, and warp_puddle_enter / warp_puddle_exit micro-sounds are wired by the caller in the engine bridge.
  • Does not modify ship stats, thrust, heat, or weapon behavior — those overrides hang off warpT in their own systems.
  • Does not own puddle generation, group merging, or the union-find that produces WarpPuddleGroup — that lives in warp-puddles.ts.
  • Does not push to the music bus — MusicPlayer.setWarpIntensity(ship.warpT) is called by the bridge each frame, not by this module.
  • Does not ease the exit tween — exit is hard-coded as an instant snap to 0.
  • Does not enforce a single group at a time beyond what findContainingGroup returns; if the ship straddles two non-merged groups the lookup helper picks one and treats the other as exited.
  • Does not de-duplicate callback fires across the same group id — a single entry produces exactly one onEnter, a single exit produces exactly one onExit.

Signals

  • ship.warpGroupId (string id or null) — read by any system that needs to know which group the ship is inside.
  • ship.warpT (0..1) — the canonical altered-state intensity consumed by thrust / heat / camera zoom / music filter / rendering alpha downstream.
  • WarpPuddleCallbacks.onEnter / onExit — the only push notification this module emits; everything else is state-mutation pull.
  • Return value of getActiveWarpGroup: the WarpPuddleGroup matching ship.warpGroupId, or null if the id is unset or no group with that id exists in groups.

Entry points

  • tickWarpPuddles(ship, groups, dt, cb) — called once per frame from engine/bridge.ts with groups = levelData?.generation?.warpPuddleGroups ?? []. The bridge’s cb.onEnter triggers Particles.burst purple + white sparks, two ExplosionFX.haloRing calls, and MicroSfx.play('warp_puddle_enter'). The bridge’s cb.onExit triggers a smaller burst, one halo ring, and MicroSfx.play('warp_puddle_exit').
  • getActiveWarpGroup(ship, groups) — exported lookup for callers that need the full group payload rather than just the id.

Pattern notes

  • The empty-groups early-out (groups.length === 0) covers level transitions and levels without puddles — it clears any stale warpGroupId / warpT from a previous level so the altered state cannot persist past a teardown.
  • Entry/exit are asymmetric by design: entry uses a 0.25 s ramp so the altered state phases in, but exit snaps warpT to 0 instantly. The in-source rationale is that the moment the ship leaves the boundary every downstream effect — thrust, heat, camera zoom, music filter, rendering alpha — must be back to normal without lag.
  • Group transitions are handled by comparing prevId against containing.id. If both are set and differ, onExit fires for the old group (looked up via groups.find) before onEnter fires for the new one, all in the same frame.
  • Exit callback also fires when the ship was inside a group last frame but findContainingGroup now returns nullprevId is captured, the previous group is resolved via groups.find, and the callback runs before warpGroupId is nulled.
  • WARP_TWEEN_RATE is intentionally locked to the camera’s zoom-lerp rate so the visual zoom and the gameplay warpT cannot desync. Changing one without the other will produce a pop.
  • ship.warpT is clamped at 1 via Math.min(1, ...) on every tick — once at peak, dt drift cannot push it over.
  • getActiveWarpGroup uses a plain for loop rather than Array.find; tickWarpPuddles uses Array.find for the prev-group lookup. Both are O(n) over groups and neither caches.
  • Module is pure with respect to globals — all state lives on ship and the passed-in groups array; the module owns no module-scope mutable state, so it is safe to call across multiple worlds or test runs without reset.