terrain-patterns.ts

PURPOSE

Boss-terrain runtime that applies, culls, damages, and ticks the destructible pillars and damaging hazard zones defined by a TerrainPatternId. On encounter start applyTerrainPattern() materializes the pattern’s pillars and hazard zones into the live world, tagging each entry with _isBossTerrain = true. On encounter end cullTerrainPattern() removes every tagged entry. tickHazardZones() is the per-frame hook that drains player HP while the ship overlaps a hazard pad. Spec reference is docs/superpowers/specs/2026-04-25-bosses-as-enemies-design.md §3.7.

OWNS

  • BossPillar interface — circular destructible cover with kind: 'pillar', x, y, radius, hp, hpMax, alive, and the _isBossTerrain: true tag.
  • BossHazardZone interface — kind: 'hazard', x, y, radius, damagePerSec, wall-clock phase (pulse-glow render), and the _isBossTerrain: true tag.
  • BossTerrainEntry union of the two.
  • TerrainHostWorld minimal world contract — destructibles: BossTerrainEntry[] | unknown[]. Keeps the module decoupled from core/types.WorldState.
  • The isBossTerrain type-guard helper.
  • The lifecycle functions applyTerrainPattern, cullTerrainPattern, damageBossPillar, tickHazardZones, reapDeadBossPillars.

READS FROM

  • ../boss/arenaBossArena type, passed to each pattern’s positionFn so generators size relative to the live arena.
  • ../../data/terrain-patternsTerrainPatternId type and the TERRAIN_PATTERNS registry. Each pattern definition provides destructibles (array of pillar specs with hp, radius, positionFn) and optional hazardZones (array of hazard specs with radius, damagePerSec, positionFn).
  • Caller-supplied ship position and radius via tickHazardZones parameters (shipX, shipY, shipRadius).
  • Caller-supplied dt (game-dt) and applyDamage callback for hazard ticks.

PUSHES TO

  • world.destructibles — pushes new BossPillar and BossHazardZone records on apply; splice-and-pop removal on cull and on reap.
  • applyDamage callback (caller-provided) — invoked with damagePerSec * dt per overlap per frame.
  • BossPillar.hp / BossPillar.alivedamageBossPillar mutates in place, clamps hp to 0, sets alive = false at or below zero.
  • BossHazardZone.phase — incremented by dt each tick for the warning-glow render.

DOES NOT

  • Does not render anything. Pillars and hazard zones are visual responsibilities of the appropriate render pass (filled circle + outline for pillars, pulsing red halo for hazards).
  • Does not wire bullet, AOE, or contact damage into damageBossPillar — that routing is a Wave 1.5 integration agent task.
  • Does not call tickHazardZones from any update loop itself — the integration agent is responsible for calling it with game-dt from the main update loop.
  • Does not pick which applyDamage path to invoke — the integration agent passes the correct callback.
  • Does not enforce a single active pattern via shared state; idempotency is achieved by applyTerrainPattern calling cullTerrainPattern first.
  • Does not define the patterns themselves — those live in data/terrain-patterns.ts.
  • Does not auto-reap dead pillars during damage; reapDeadBossPillars is a separate cleanup pass.
  • Does not protect against non-BossTerrainEntry entries already in world.destructibles; the isBossTerrain guard keeps them untouched on cull/reap/tick.
  • Does not use wall-dt for hazard damage; combat math uses game-dt per spec §7.

Signals

  • Return value of damageBossPillar — boolean true exactly on the killing blow (pillar transitions from alive to dead), false otherwise including when called on an already-dead pillar.
  • Entity-presence signal — any entry in world.destructibles with _isBossTerrain === true is owned by this module and will be removed by cullTerrainPattern.
  • Per-overlap damage call — applyDamage(damagePerSec * dt) per frame per overlapping hazard zone (multiple overlapping zones stack additively).

Entry points

  • applyTerrainPattern(patternId, arena, world) — culls any existing tagged entries, then for each pillar spec in TERRAIN_PATTERNS[patternId].destructibles runs spec.positionFn(arena) and pushes a BossPillar per position; same loop for hazardZones if present. Empty patterns ('open') are a no-op after the cull.
  • cullTerrainPattern(world) — reverse iteration over world.destructibles; for each entry where isBossTerrain(e) is true, swap with the last element and pop. Safe to call when no pattern is active.
  • damageBossPillar(pillar, amount) — early-returns false if pillar is not alive; otherwise subtracts amount from hp, and on hp <= 0 clamps to zero, sets alive = false, returns true.
  • tickHazardZones(world, shipX, shipY, shipRadius, dt, applyDamage) — forward iteration; skips non-hazard tagged entries via kind check, increments phase by dt on every hazard, computes squared distance against (radius + shipRadius), and calls applyDamage(damagePerSec * dt) on overlap.
  • reapDeadBossPillars(world) — reverse iteration; swap-and-pops any tagged pillar with alive === false. Mid-frame death is fine; this is a cleanup pass intended to run after death effects play.

Pattern notes

  • Tag-based ownership — every spawned entry carries _isBossTerrain: true. This is the sole criterion used by cullTerrainPattern, tickHazardZones, and reapDeadBossPillars to recognize entries they own. Foreign entries already in world.destructibles are untouched.
  • Idempotent apply — applyTerrainPattern always culls before spawning, so calling it twice in a row leaves only one pattern’s worth of entries.
  • Swap-and-pop removal — both cull paths and reap use the index-swap-with-last + pop() idiom in reverse iteration. O(1) per removal, order is not preserved.
  • Position generation runs once per encounter — positionFn(arena) is invoked at applyTerrainPattern time only; positions are fixed for the duration of the pattern’s lifetime in the world.
  • Biome / variant selection lives upstream — this module is biome-agnostic; the variation it exposes is the TerrainPatternId ('open', 'pillar_ring', 'pillar_cross', 'hazard_pads', 'corridor', 'gauntlet') and the pattern definitions in data/terrain-patterns.ts. Boss roster (spec §4) maps each boss to its terrain.
  • Per-pillar HP is set at spawn from the spec — hp and hpMax are both initialized to spec.hp, no scaling done here.
  • Hazard pulse phase is wall-clock — phase accumulates the same dt passed in; pure render data, not gameplay state.
  • Decoupled world contract — TerrainHostWorld exposes only destructibles so tests can pass a stripped-down stand-in instead of a full WorldState. Internal casts (as BossTerrainEntry[]) accept the unknown[] slot in the real world type.
  • Cull does not invoke death effects — it’s a hard removal for end-of-encounter cleanup. Use damageBossPillar + reapDeadBossPillars for the gameplay death path.