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_RATEconstant (value4) —warpTrise rate per second; reaches1.0from0.0in0.25 s. MatchesCamera.update’s zoom-lerp rate so visuals and gameplay state stay in sync.WarpPuddleCallbacksinterface — optionalonEnter(group)/onExit(group)hooks invoked on group transitions.tickWarpPuddles(ship, groups, dt, cb)— the per-frame update; mutatesship.warpGroupIdandship.warpTand fires the callbacks.getActiveWarpGroup(ship, groups)— lookup helper that returns the group whose id matchesship.warpGroupId, ornull.
READS FROM
ship.x,ship.y— ship position, passed intofindContainingGroupfor 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 theWarpPuddleGroupwhose union of member circles contains the ship, ornull.WarpPuddleGrouptype from./warp-puddles— used for thegroupsparameter and the callback argument type.ShipStatetype from../core/types.dtparameter — delta-time seconds for the tween integration.
PUSHES TO
ship.warpGroupId— set to the containing group’sidon entry, set tonullon exit, and cleared when thegroupsarray is empty.ship.warpT— ramped up byWARP_TWEEN_RATE * dtand clamped at1while inside a group; snapped to0the frame the ship leaves any group, and zeroed whengroupsis empty.cb.onEnter(group)— fired once whenship.warpGroupIdtransitions fromnull/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.warpGroupIdand apply their own effects. - Does not play SFX or spawn particles directly — the bubble-burst VFX, halo rings, and
warp_puddle_enter/warp_puddle_exitmicro-sounds are wired by the caller in the engine bridge. - Does not modify ship stats, thrust, heat, or weapon behavior — those overrides hang off
warpTin their own systems. - Does not own puddle generation, group merging, or the union-find that produces
WarpPuddleGroup— that lives inwarp-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
findContainingGroupreturns; 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 oneonExit.
Signals
ship.warpGroupId(string id ornull) — 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: theWarpPuddleGroupmatchingship.warpGroupId, ornullif the id is unset or no group with that id exists ingroups.
Entry points
tickWarpPuddles(ship, groups, dt, cb)— called once per frame fromengine/bridge.tswithgroups = levelData?.generation?.warpPuddleGroups ?? []. The bridge’scb.onEntertriggersParticles.burstpurple + white sparks, twoExplosionFX.haloRingcalls, andMicroSfx.play('warp_puddle_enter'). The bridge’scb.onExittriggers a smaller burst, one halo ring, andMicroSfx.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 stalewarpGroupId/warpTfrom a previous level so the altered state cannot persist past a teardown. - Entry/exit are asymmetric by design: entry uses a
0.25 sramp so the altered state phases in, but exit snapswarpTto0instantly. 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
prevIdagainstcontaining.id. If both are set and differ,onExitfires for the old group (looked up viagroups.find) beforeonEnterfires for the new one, all in the same frame. - Exit callback also fires when the ship was inside a group last frame but
findContainingGroupnow returnsnull—prevIdis captured, the previous group is resolved viagroups.find, and the callback runs beforewarpGroupIdis nulled. WARP_TWEEN_RATEis intentionally locked to the camera’s zoom-lerp rate so the visual zoom and the gameplaywarpTcannot desync. Changing one without the other will produce a pop.ship.warpTis clamped at1viaMath.min(1, ...)on every tick — once at peak, dt drift cannot push it over.getActiveWarpGroupuses a plainforloop rather thanArray.find;tickWarpPuddlesusesArray.findfor the prev-group lookup. Both are O(n) overgroupsand neither caches.- Module is pure with respect to globals — all state lives on
shipand the passed-ingroupsarray; the module owns no module-scope mutable state, so it is safe to call across multiple worlds or test runs without reset.