pickups.ts
PURPOSE
Thin adapter that exposes the PickupSystem namespace expected by older call sites (bridge.ts, dev tooling, tests, boss-death magnet events). Every method delegates straight through to the SoA module ./xp-orbs. The file exists only so external code can keep calling PickupSystem.update(...), PickupSystem.triggerGlobalMagnet(...), PickupSystem.reset(), etc. without knowing about the columnar typed-array refactor. No state is owned here; no allocation happens here; no draw happens here.
OWNS
PickupSystem— exported singleton object with the methods listed under Entry points. No instance fields, no module-level state.- The
xp_pickupJuice.firecall insidecollectXP(one of two paths that fires this juice event; the other isxp-orbs.ts → _collectSlot). - The default magnet-range fallback for
shouldCollect(magnetRange = 50). This default is independent of the SoAupdatepath, which uses its own100fallback. - The default XP amount for
collectXP(xp?.amount || 10) andspawnPickup(amount = 1), plus the random±100 u/slaunch velocity ((Math.random() − 0.5) * 200) and the default orb radius5used byspawnPickup.
READS FROM
../core/types—WorldState,ShipState,GameStateshapes only (no field mutation beyond what’s listed under PUSHES TO)../xp-orbs—spawn,update,triggerGlobalMagnet,clearare all re-exposed through the adapter.../vfx/particles—XpAccumfor the cumulative XP popup overlay fed bycollectXP.../vfx/juice—Juicefor thexp_pickupcue fired bycollectXP.ship.x,ship.y,ship.radius,ship.magnetRange,ship.xpGainMultfromShipState.game.xpfromGameState.
PUSHES TO
game.xp—collectXPincrements byround((xp.amount || 10) * (ship.xpGainMult || 1.0)).XpAccum.collect(amount)—collectXPcalls this so the HUD popup overlay reflects the same delta that hitgame.xp.Juice.fire('xp_pickup')—collectXPfires this once per call.xp-orbspool (indirect) —spawnPickupclaims a slot viaxpOrbs.spawn;updateadvances the per-frame tick;triggerGlobalMagnetflips every live orb into exponential lock-on;resetwipes the pool viaxpOrbs.clear.
DOES NOT
- Handle weapon-box, artifact-box, or regen-station pickups. Those live in sibling modules:
- Weapon / artifact / scrap / chest crates →
engine/world/crates.ts(cratePool). - Artifact pedestals and pickup logic →
engine/world/artifacts.ts. - Regen station, scrap pile, and other interactable props →
engine/world/props.ts(via thegetPropPool().updatepath inbridge.ts).
- Weapon / artifact / scrap / chest crates →
- Store any orb state. All columnar arrays, magnet layers, merge passes, soft caps, draw, and offscreen cull live in
./xp-orbs. This file owns zero typed arrays and zero counters. - Compute or apply
XP_NERF_MULT. The 0.45 global throttle is baked into the integeramountat spawn time inengine/combat/damage.ts → spawnXPOrbs. The adapter only forwards the already-discounted value. - Define magnet range.
shouldCollectreadsship.magnetRangeand falls back to 50; the activeupdatepath defers entirely to./xp-orbs, which has its own 100 fallback and readsship.magnetRangeitself. - Render anything. The draw path lives in
./xp-orbs → drawand is invoked directly frombridge.ts, not through this adapter. - Emit any
Sigsignals. Collection is fully synchronous and only touchesgame.xp,XpAccum, andJuice. - Persist across runs.
resetdelegates toxpOrbs.clear, which wipes the pool and merge accumulators. - Despawn pickups by age. The adapter exposes no time-out path; orb removal happens inside
./xp-orbs(direct contact, magnet snap-on, merge absorption,clear).
Signals
Fires: none on the typed Sig bus.
- Indirect VFX/audio cue:
Juice.fire('xp_pickup')fromcollectXP. - Indirect HUD feed:
XpAccum.collect(amount)fromcollectXP.
Does not subscribe to any signals.
Entry points
shouldCollect(ship, pickup)— radius check. Returnstrueiff(pickup.x − ship.x)² + (pickup.y − ship.y)² < (magnetRange + ship.radius)², withmagnetRangedefaulting to 50 when unset on the ship. Used as a gating helper in a few places that need to know whether a pickup is inside the collect band without actually collecting it.collectXP(ship, game, xp)— apply an XP grant directly without going through an orb entity. Readsxp.amount(default 10) andship.xpGainMult(default 1.0), rounds the product, adds it togame.xp, callsXpAccum.collect, and firesJuice.fire('xp_pickup'). Test and event helper path.spawnPickup(world, x, y, amount = 1)— dev/test helper. Spawns a single XP orb at(x, y)with a random±100 u/sinitial velocity on each axis and radius 5 viaxpOrbs.spawn. Returns a read-only{ x, y, amount, type: 'xp', radius: 5 }snapshot for legacy callers that wanted the ref. Theworldarg is unused.update(ship, world, game, dt)— per-frame XP-orb tick. Delegates straight toxpOrbs.update(ship, game, dt). Called once per frame fromengine/bridge.tsinside the world update pass. Theworldarg is unused.triggerGlobalMagnet(world)— flips every live orb into the exponential lock-on phase by callingxpOrbs.triggerGlobalMagnet(). Fired on magnet shooting-star events and boss death. Theworldarg is unused.reset()— wipes the orb pool by callingxpOrbs.clear(). Invoked on run reset / death.
Pattern notes
- Pure delegation layer. Every active method body is either a one-line call into
./xp-orbsor a small arithmetic helper. Adding behavior here would split ownership with./xp-orbs; new pickup-collection logic should go in the SoA module and (if necessary) get a one-line wrapper added here. - Three independent
magnetRangedefaults exist in the codebase:shouldCollectuses 50,xp-orbs → updateuses 100, andcore/state.tsinitializes shipmagnetRange = 100. The 50 default inshouldCollectis intentionally conservative — that helper is used as a quick yes/no gate, so its fallback only fires when a ship somehow has nomagnetRangefield at all. collectXPandxp-orbs → _collectSlotare two separate code paths that both fireJuice.fire('xp_pickup')and both callXpAccum.collect(amount). The orb pool path is the production path (called from the SoA tick on direct contact, layer-1 snap, or merge);collectXPis the synthetic / test path that skips the orb entity entirely.spawnPickupis a thin convenience wrapper. Theworldparameter is preserved for signature compatibility with legacy callers but is ignored; the orb pool is process-global. Returns a snapshot object purely so old call sites that didconst orb = spawnPickup(...); orb.foo = bardon’t crash, but the snapshot is decoupled from the actual slot inside./xp-orbs.damage.tsstill has animport { PickupSystem }line but no call site uses it; the live call into the pickup tick lives inbridge.ts. Removing the dead import is safe but not done here.WorldStateis in the signature ofupdate,spawnPickup, andtriggerGlobalMagnetpurely for legacy compatibility. The XP-orb pool is a process-global SoA, not a per-world array, so the parameter is dead in every adapter method that takes it.triggerGlobalMagnetis called fromengine/world/pickups.tsitself (this adapter), fromengine/world/props.ts(scrap-magnet pulse on destructible break), and fromengine/bridge.ts(magnet shooting-star events + boss death). Each of those call sites can also callxpOrbs.triggerGlobalMagnet()directly; the adapter wrapper exists only for symmetry with the rest ofPickupSystem.- The
pickupargument ofshouldCollectis typedanybecause the helper predates the typed pickup model — historically, anything withxandycould be tested for proximity. Callers should not infer thatpickuphas any other field. - File is intentionally short. The XP-orb refactor moved everything load-bearing into
./xp-orbs; this adapter is the seam that lets external code stay unchanged. IfPickupSystem.*callers ever migrate to callxpOrbs.*directly, this file can be deleted outright.