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_pickup Juice.fire call inside collectXP (one of two paths that fires this juice event; the other is xp-orbs.ts → _collectSlot).
  • The default magnet-range fallback for shouldCollect (magnetRange = 50). This default is independent of the SoA update path, which uses its own 100 fallback.
  • The default XP amount for collectXP (xp?.amount || 10) and spawnPickup (amount = 1), plus the random ±100 u/s launch velocity ((Math.random() − 0.5) * 200) and the default orb radius 5 used by spawnPickup.

READS FROM

  • ../core/typesWorldState, ShipState, GameState shapes only (no field mutation beyond what’s listed under PUSHES TO).
  • ./xp-orbsspawn, update, triggerGlobalMagnet, clear are all re-exposed through the adapter.
  • ../vfx/particlesXpAccum for the cumulative XP popup overlay fed by collectXP.
  • ../vfx/juiceJuice for the xp_pickup cue fired by collectXP.
  • ship.x, ship.y, ship.radius, ship.magnetRange, ship.xpGainMult from ShipState.
  • game.xp from GameState.

PUSHES TO

  • game.xpcollectXP increments by round((xp.amount || 10) * (ship.xpGainMult || 1.0)).
  • XpAccum.collect(amount)collectXP calls this so the HUD popup overlay reflects the same delta that hit game.xp.
  • Juice.fire('xp_pickup')collectXP fires this once per call.
  • xp-orbs pool (indirect) — spawnPickup claims a slot via xpOrbs.spawn; update advances the per-frame tick; triggerGlobalMagnet flips every live orb into exponential lock-on; reset wipes the pool via xpOrbs.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 the getPropPool().update path in bridge.ts).
  • 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 integer amount at spawn time in engine/combat/damage.ts → spawnXPOrbs. The adapter only forwards the already-discounted value.
  • Define magnet range. shouldCollect reads ship.magnetRange and falls back to 50; the active update path defers entirely to ./xp-orbs, which has its own 100 fallback and reads ship.magnetRange itself.
  • Render anything. The draw path lives in ./xp-orbs → draw and is invoked directly from bridge.ts, not through this adapter.
  • Emit any Sig signals. Collection is fully synchronous and only touches game.xp, XpAccum, and Juice.
  • Persist across runs. reset delegates to xpOrbs.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') from collectXP.
  • Indirect HUD feed: XpAccum.collect(amount) from collectXP.

Does not subscribe to any signals.

Entry points

  • shouldCollect(ship, pickup) — radius check. Returns true iff (pickup.x − ship.x)² + (pickup.y − ship.y)² < (magnetRange + ship.radius)², with magnetRange defaulting 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. Reads xp.amount (default 10) and ship.xpGainMult (default 1.0), rounds the product, adds it to game.xp, calls XpAccum.collect, and fires Juice.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/s initial velocity on each axis and radius 5 via xpOrbs.spawn. Returns a read-only { x, y, amount, type: 'xp', radius: 5 } snapshot for legacy callers that wanted the ref. The world arg is unused.
  • update(ship, world, game, dt) — per-frame XP-orb tick. Delegates straight to xpOrbs.update(ship, game, dt). Called once per frame from engine/bridge.ts inside the world update pass. The world arg is unused.
  • triggerGlobalMagnet(world) — flips every live orb into the exponential lock-on phase by calling xpOrbs.triggerGlobalMagnet(). Fired on magnet shooting-star events and boss death. The world arg is unused.
  • reset() — wipes the orb pool by calling xpOrbs.clear(). Invoked on run reset / death.

Pattern notes

  • Pure delegation layer. Every active method body is either a one-line call into ./xp-orbs or 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 magnetRange defaults exist in the codebase: shouldCollect uses 50, xp-orbs → update uses 100, and core/state.ts initializes ship magnetRange = 100. The 50 default in shouldCollect is intentionally conservative — that helper is used as a quick yes/no gate, so its fallback only fires when a ship somehow has no magnetRange field at all.
  • collectXP and xp-orbs → _collectSlot are two separate code paths that both fire Juice.fire('xp_pickup') and both call XpAccum.collect(amount). The orb pool path is the production path (called from the SoA tick on direct contact, layer-1 snap, or merge); collectXP is the synthetic / test path that skips the orb entity entirely.
  • spawnPickup is a thin convenience wrapper. The world parameter 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 did const orb = spawnPickup(...); orb.foo = bar don’t crash, but the snapshot is decoupled from the actual slot inside ./xp-orbs.
  • damage.ts still has an import { PickupSystem } line but no call site uses it; the live call into the pickup tick lives in bridge.ts. Removing the dead import is safe but not done here.
  • WorldState is in the signature of update, spawnPickup, and triggerGlobalMagnet purely 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.
  • triggerGlobalMagnet is called from engine/world/pickups.ts itself (this adapter), from engine/world/props.ts (scrap-magnet pulse on destructible break), and from engine/bridge.ts (magnet shooting-star events + boss death). Each of those call sites can also call xpOrbs.triggerGlobalMagnet() directly; the adapter wrapper exists only for symmetry with the rest of PickupSystem.
  • The pickup argument of shouldCollect is typed any because the helper predates the typed pickup model — historically, anything with x and y could be tested for proximity. Callers should not infer that pickup has 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. If PickupSystem.* callers ever migrate to call xpOrbs.* directly, this file can be deleted outright.