PURPOSE
Pre-allocated pool of breakable crate pickups (the box emoji destructible) that spawn off-screen around the ship, drift gently in place, break on player contact, and drop XP orbs plus a three-layer VFX burst. Replaces the legacy tiered-cube destructible system: a single flat sprite type, sector-aware spawn, no on-screen pop-in or cull, zero per-frame allocations. Density is driven by each planet’s destructibles.crates slider.
OWNS
- The
CrateSlotinterface and theCratePoolclass with its fixed-size slot array (POOL_SIZE = 80). - The module-level baked Canvas-2D fallback sprite (
sprite: HTMLCanvasElement | null) and its lazy bake viabakeSprite(). - The singleton
crates = new CratePool()exported instance. - Tunable constants for density, spawn geometry, collision radius, sprite size, bob curve, drop-shadow, XP percentage range, and the three VFX layers (impact sparks, debris chunks, rim flash ring).
- Internal state:
activeCount,tickAccumMs, per-slotactive/x/y/bobPhase/bobAmp.
READS FROM
core/state—W,H(canvas dims),camera(fallback viewport + Canvas-2D draw),game(forgame.uiTimein the bob curve andgame.levelfor XP scaling).rendering/camera—Camera.toS(x, y)for world→screen projection in the Canvas-2D draw path.rendering/sprite-batch—getSpriteBatch()for the primary WebGL instanced draw.rendering/atlas-builder—tryGetAtlasRegion('crate')for the atlas region andbakeStickerEmoji(...)for the Canvas-2D fallback bake.data/planet-config—PLANETS[planetId].destructibles.crates(0–100 slider) viadensityForPlanet().engine/world/leveling—xpForLevel(level)to compute the current XP bar size.- The
shipargument passed intoupdate()— readsship.x,ship.y,ship.vx,ship.vy,ship.alive,ship.outerRadius/ship.radius. - The
worldargument — readsworld.planetIdfor the density slider lookup. - The DOM as a last resort:
document.querySelector('canvas')ifW/Hare zero before first resize.
PUSHES TO
core/signals— firesSig.fire('crate_break', 0, 0, c.x, c.y, '')on each break (consumed by the crate-buster artifact and any other listeners).vfx/juice— firesJuice.fire('crate_break')for the SFX/screen-juice channel.engine/world/xp-orbs— callsxpOrbs.spawn(x, y, vx, vy, amount, lifeSeconds)3–5 times per break.vfx/particles— pushesCRATE_SPARK_COUNT(16) hot-yellow/white spark particles andCRATE_DEBRIS_COUNT(5) saddle-brown debris chunks viaParticles.add(...).vfx/sonar-rings— emits one warm-amber rim flash viaSonarRings.shockwave(x, y, startR, endR, color, lifetime, lineW).- The active WebGL
SpriteBatch— adds two passes (shadow at+SHADOW_Y_OFFSET, sprite with bob) per active crate per frame. - A 2D canvas context in the fallback path — issues
drawImage/fillRectfor the shadow and sprite passes.
DOES NOT
- Allocate at runtime. The slot array, particles, and orbs all come from pre-existing pools.
- Spawn or cull crates while they are inside the camera viewport (with
VIEWPORT_MARGINpadding). On-screen actives are protected even duringtrimToTargetuntil the off-screen pass is exhausted. - Damage the ship, apply knockback, or modify ship state on collision — collision triggers a break only.
- Spawn anything other than the flat box emoji. There are no tiers, no variants, no rare crates.
- Persist across runs or reuse positions between
clear()calls. - Drive its own movement — crates are static in world space and only the visible sprite bobs vertically; the shadow stays anchored.
- React to time-dilation for the bob curve —
game.uiTimeis wall-clock so the bob continues during slow-mo. - Spawn during the first frame if no slot is free or all attempts collide with the viewport / another active crate (it silently skips that tick).
Signals
- Fires
crate_breakwith payload(0, 0, c.x, c.y, '')on every successful collision break, before SFX and VFX. Order is load-bearing: signal first (so the crate-buster artifact hook fires), then juice, then XP orbs, then the three VFX layers.
Entry points
crates.init()— bakes the Canvas-2D fallback sprite. Called once during game boot.crates.clear()— deactivates every slot and resets counters. Called on new-run / arena teardown.crates.update(dt, ship, camera_, world, game)— per-frame: runs collision every frame, throttles spawn/cull/trim on aSPAWN_TICK_MS(200 ms) accumulator. Computes target density fromworld.planetId.crates.draw(ctx)— per-frame draw. Primary path is the WebGL sprite batch (two passes: shadow then sprite-with-bob). Falls back to Canvas-2D with the module-baked sprite if the batch or atlas region is missing.
Pattern notes
- Fixed pool, no allocations.
POOL_SIZE = 80slots are constructed once in theCratePoolconstructor.findFreeSlot()linear-scans for an inactive slot;activeCountis maintained explicitly to avoid scanning during the budget check. - Sector-aware spawn with velocity-cone bias. Spawns happen in a ring
SPAWN_MIN..SPAWN_MAX(400–1400 units) around the ship. If the ship is moving (speed > 1),VELOCITY_CONE_BIAS = 0.7of attempts pick an angle within ±π/2 of the ship’s heading so crates appear ahead of motion.SPAWN_ATTEMPTS_PER_TICK = 8candidate positions are tried per tick; each is rejected if it lands in the viewport or withinMIN_GAP = 120units of another active crate. - Asymmetric spawn/cull radii. Crates spawn out to
SPAWN_MAX = 1400but cull atCULL_RADIUS = 2000, so a moving ship doesn’t immediately re-cull what it just spawned. - Viewport never culls or pops.
runCullskips any active crate inside the (margin-padded) viewport.trimToTargetdoes two passes when the slider drops: off-screen first, then on-screen only if still over target. - Density slider.
densityForPlanet(planetId)readsPLANETS[planetId].destructibles.crates(0–100), clamps, and scales linearly toMAX_DENSITY = 20. Missing planet or missing slider falls back toMAX_DENSITY. - Collision radius is sprite-tracked.
COLLISION_RADIUS = 36is documented as the visible silhouette (emoji + halo) at the current bake size with ~5% buffer; comment includes the derivation formula. - Bob is shared-period, per-slot phase + amplitude.
BOB_PERIOD_SEC = 8.0is global so the field reads as one drift; each slot gets a randombobPhase(0..2π) andbobAmp(BOB_AMP_MIN..BOB_AMP_MAX, 7..18). Sampled viasampleBob(c)usinggame.uiTime. - Drop shadow matches ship/enemy pass.
SHADOW_Y_OFFSET = 25,SHADOW_ALPHA = 0.35mirror the bridge.ts shadow pass for visual coherence; shadows do not bob. - XP drop scales with player level. Each break rolls
CRATE_XP_PCT_MIN..CRATE_XP_PCT_MAX(3–9%) of the current level’s XP bar (xpForLevel(level+1) - xpForLevel(level)), splits across 3–5 orbs ejected at 150 u/s on random angles from a small radial offset (8..24 units). - Three-layer break VFX (all constants named). Layer 1 — 16 spark particles, hot-yellow base (#FFD750) with every 4th switched to hot-white (#FFF5C8), speed 60..190, life 0.12..0.30. Layer 2 — 5 saddle-brown chunks (#8B5A2B), slower (25..80) and larger, life 0.35..0.60. Layer 3 — one
SonarRings.shockwavewarm-amber ring (#ffb840) expanding 8 → 72 over 0.28 s at line width 3.5. - Render path split. WebGL
SpriteBatchpath is preferred and does both passes in instanced draws against the'crate'atlas region. Canvas-2D fallback usessource-incomposite to tint the baked sprite into a black silhouette for the shadow pass, then redraws normally for the sprite pass; both fallback passes off-screen-cull by rect againstW/H. - Viewport fallback to live
<canvas>.viewport()readsW/Hfromcore/statebut if those are zero (pre-first-resize), queriesdocument.querySelector('canvas')and usesclientWidth/clientHeight. - Singleton export.
export const crates = new CratePool()— the rest of the engine imports the instance, not the class.