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 aBossArenavalue object closing over the underlyingBossRoom.- Live
cx/cyaccessors that proxyroom.cx/room.cythrough getters (so a moving room is always reflected). - A private
targetRadius()helper: forcirclerooms returnsroom.targetR; forrectrooms returnsMath.min(room.targetW, room.targetH) / 2. Drives every helper that needs a stable footprint. - The
DEFAULT_MARGINconstant (20) used byclampPointandrandomPointwhen 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 minusmargin. Circle path normalizes the offset and scales bymaxDist / 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 usesr = (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, attargetRadius() * fracOfRadius, returned in[E, S, W, N]order.ringPoints(count, fracOfRadius)—countevenly-spaced points around a ring attargetRadius() * fracOfRadius, starting at angle 0 and stepping by2π / count. Returns an empty array forcount <= 0.edgePoints(count)—countpoints on the perimeter. Circle path is the same uniform-angle ring attargetRadius(). Rect path walks the perimeter parametrically (top → right → bottom → left) using the target dimensions and the sum2 * (targetW + targetH). Returns an empty array forcount <= 0.oppositePlayer(playerX, playerY, dist)— mirrors the player across the room center, projects the resulting direction todistfrom center, then funnels throughclampPointso the result is guaranteed inside. If the player is exactly on center, falls back toclampPoint(cx + dist, cy).radius()— public accessor that returnstargetRadius().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
BossArenatype fromengine/core/typesso callers canimport type { BossArena } from './arena'.
READS FROM
engine/core/typesfor theBossArenaandBossRoominterface shapes.- The
BossRoomvalue passed in at construction time. Readsroom.shape,room.cx,room.cy,room.targetR,room.targetW,room.targetH,room.currentR,room.currentW,room.currentH. Theroomreference 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 exposedroomfield is the sameBossRoomreference handed in — mutations to room dimensions happen elsewhere (the shrink-loop inengine/boss/boss-room.tsand the sealed-arena setup inengine/boss/sealed-arena.ts).
DOES NOT
- Move, shrink, lock, or otherwise drive
BossRoomlifecycle — that is owned byengine/boss/boss-room.tsandengine/boss/sealed-arena.ts. - Render anything. Walls and glow are drawn by the room module.
- Clamp the ship, cull entities, or apply physics.
containsis a pure predicate; callers wire it into their own per-frame loops. - Track the player.
oppositePlayertakes the player position as an argument; no module reference is held. - Decide which margin a helper uses by inspecting boss state; callers pass
marginexplicitly or acceptDEFAULT_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;
containsis 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 onGameState.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.
containsreadscurrentR/currentW/currentHso 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.clampPointis 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()ismin(W, H) / 2, the largest disc that fits inside, sofracOfRadius-based helpers stay inside the rect even when the rect is non-square. - Uniform disc sampling.
randomPointfor circles uses thesqrt(u)radius transform so points are uniformly distributed across the disc; a naiveu * Rwould bias toward the center. - Edge walk on rects.
edgePointson 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. oppositePlayeris mirror-then-project. It does not “place opposite of player on the boundary” — it places at exactlydistfrom center along the player-to-center direction, then clamps. The degenerate (player on center) case picks a fixed eastward direction.- Live getters.
cxandcyare property getters, not snapshot copies — if the room moves, the arena moves with it on the next read. - No-margin defaults.
cardinalPoints,ringPoints,edgePoints, andoppositePlayerapply no margin; onlyclampPointandrandomPointaccept and default a margin.