engine/boss/arena.ts

PURPOSE

Wrap a live BossRoom in the BossArena spatial-helper interface so affixes, abilities, AI, and spawn profiles can place points and run containment queries without ever touching screen size or world coordinates. The arena is the single spatial vocabulary every boss-side system writes against; the spec reference baked into the file header is docs/superpowers/specs/2026-04-25-bosses-as-enemies-design.md §2.6.

OWNS

  • createBossArena(room) factory that returns a BossArena value object closing over the underlying BossRoom.
  • Live cx / cy accessors that proxy room.cx / room.cy through getters (so a moving room is always reflected).
  • A private targetRadius() helper: for circle rooms returns room.targetR; for rect rooms returns Math.min(room.targetW, room.targetH) / 2. Drives every helper that needs a stable footprint.
  • The DEFAULT_MARGIN constant (20) used by clampPoint and randomPoint when no margin is supplied.
  • contains(x, y) — uses current room dimensions (currentR / currentW / currentH) so leash and cull checks see the boundary as it actually is at this instant, including mid-shrink.
  • clampPoint(x, y, margin?) — projects an outside point inward to the current boundary minus margin. Circle path normalizes the offset and scales by maxDist / dist; rect path clamps to a [minX, maxX] × [minY, maxY] box. Returns the original point untouched if it is already inside (or if the point coincides with center).
  • randomPoint(margin?) — uniform sample inside the target footprint. Circle uses r = (R - margin) * sqrt(uniform) with a uniform angle so density is uniform across the disc; rect samples (2u - 1) * (halfW - margin) and (2u - 1) * (halfH - margin).
  • cardinalPoints(fracOfRadius) — exactly four points at angles 0/90/180/270 from center, at targetRadius() * fracOfRadius, returned in [E, S, W, N] order.
  • ringPoints(count, fracOfRadius)count evenly-spaced points around a ring at targetRadius() * fracOfRadius, starting at angle 0 and stepping by 2π / count. Returns an empty array for count <= 0.
  • edgePoints(count)count points on the perimeter. Circle path is the same uniform-angle ring at targetRadius(). Rect path walks the perimeter parametrically (top → right → bottom → left) using the target dimensions and the sum 2 * (targetW + targetH). Returns an empty array for count <= 0.
  • oppositePlayer(playerX, playerY, dist) — mirrors the player across the room center, projects the resulting direction to dist from center, then funnels through clampPoint so the result is guaranteed inside. If the player is exactly on center, falls back to clampPoint(cx + dist, cy).
  • radius() — public accessor that returns targetRadius().
  • diagonal() — circle: targetRadius() * 2; rect: Math.hypot(targetW, targetH).
  • bounds() — axis-aligned bounding box of the target footprint, returned as { minX, minY, maxX, maxY }.
  • A re-export of the BossArena type from engine/core/types so callers can import type { BossArena } from './arena'.

READS FROM

  • engine/core/types for the BossArena and BossRoom interface shapes.
  • The BossRoom value passed in at construction time. Reads room.shape, room.cx, room.cy, room.targetR, room.targetW, room.targetH, room.currentR, room.currentW, room.currentH. The room reference is kept live; every getter / method re-reads it.

PUSHES TO

  • Nothing. The factory mutates no state, fires no signals, and allocates only the returned arena object plus the small { x, y } tuples its helpers produce. The exposed room field is the same BossRoom reference handed in — mutations to room dimensions happen elsewhere (the shrink-loop in engine/boss/boss-room.ts and the sealed-arena setup in engine/boss/sealed-arena.ts).

DOES NOT

  • Move, shrink, lock, or otherwise drive BossRoom lifecycle — that is owned by engine/boss/boss-room.ts and engine/boss/sealed-arena.ts.
  • Render anything. Walls and glow are drawn by the room module.
  • Clamp the ship, cull entities, or apply physics. contains is a pure predicate; callers wire it into their own per-frame loops.
  • Track the player. oppositePlayer takes the player position as an argument; no module reference is held.
  • Decide which margin a helper uses by inspecting boss state; callers pass margin explicitly or accept DEFAULT_MARGIN.
  • Handle the closing-vs-locked distinction. Spawn-placement helpers always use the target footprint so spawn positions don’t drift while the room is shrinking; contains is the only method that uses the current footprint.
  • Cache anything. Every call re-reads the room.

Signals

  • Fires: none.
  • Watches: none.

Entry points

  • createBossArena(room: BossRoom): BossArena — sole export. Constructed once per encounter by the boss-room layer and stashed on GameState.bossArena; affixes, abilities, AI, and spawn profiles all read it from there.
  • BossArena (re-exported type) — the interface every consumer codes against.

Pattern notes

  • Single spatial vocabulary. The header comment names the only legal sources of a boss spawn point: contains / clampPoint / randomPoint / cardinalPoints / ringPoints / edgePoints / oppositePlayer. Anything that reaches for screen size or raw world coordinates is a bug.
  • Target vs current footprint. contains reads currentR / currentW / currentH so leash and cull checks track the live boundary. Every placement helper (randomPoint, cardinalPoints, ringPoints, edgePoints, bounds, diagonal, radius) reads the target footprint so spawn positions are stable while the room is closing. clampPoint is the exception that uses the current footprint — it is meant for now-clamps, not future placement.
  • Rect radius is the inscribed half-min-side. A rect arena’s radius() is min(W, H) / 2, the largest disc that fits inside, so fracOfRadius-based helpers stay inside the rect even when the rect is non-square.
  • Uniform disc sampling. randomPoint for circles uses the sqrt(u) radius transform so points are uniformly distributed across the disc; a naive u * R would bias toward the center.
  • Edge walk on rects. edgePoints on a rect maps a single [0, perimeter) parameter across four sides using cumulative thresholds (sideTop, sideRight, sideBottom), giving evenly-spaced perimeter points with constant arc-length step.
  • oppositePlayer is mirror-then-project. It does not “place opposite of player on the boundary” — it places at exactly dist from center along the player-to-center direction, then clamps. The degenerate (player on center) case picks a fixed eastward direction.
  • Live getters. cx and cy are property getters, not snapshot copies — if the room moves, the arena moves with it on the next read.
  • No-margin defaults. cardinalPoints, ringPoints, edgePoints, and oppositePlayer apply no margin; only clampPoint and randomPoint accept and default a margin.