From 16fce127ff002adae7d39b8a46c697f245462b3f Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 24 May 2026 10:24:44 -0400 Subject: [PATCH] feat(viewer): render shrinking play-zone in replay viewer Draws the active zone boundary and out-of-zone danger area using per-turn zone_bounds data from the replay. The zone renders as: - Red semi-transparent overlay outside the safe zone - Solid red boundary circle with dashed inner ring - Center cross marker - Inactive zones show as subtle dashed outline Changes: - Add ZoneBounds type to types.ts - Add zone_bounds field to ReplayTurn - Implement drawZone() method in replay-viewer.ts - Call drawZone() in renderStandardView() - Update replay-schema-v1.json with ZoneBounds definition Accepts: bf-k1oy Closes: bf-k1oy --- web/public/replay-schema-v1.json | 23 +++++++++++ web/src/replay-viewer.ts | 69 ++++++++++++++++++++++++++++++++ web/src/types.ts | 7 ++++ 3 files changed, 99 insertions(+) diff --git a/web/public/replay-schema-v1.json b/web/public/replay-schema-v1.json index 95c07f6..458747a 100644 --- a/web/public/replay-schema-v1.json +++ b/web/public/replay-schema-v1.json @@ -609,6 +609,29 @@ "additionalProperties": { "$ref": "#/$defs/DebugInfo" } + }, + "zone_bounds": { + "description": "Active shrinking zone bounds at this turn. Only present when zone is enabled in config.", + "$ref": "#/$defs/ZoneBounds" + } + } + }, + "ZoneBounds": { + "type": "object", + "description": "Bounds of the shrinking play-zone (storm) at a turn.", + "required": ["center", "radius", "active"], + "properties": { + "center": { + "$ref": "#/$defs/Position" + }, + "radius": { + "type": "integer", + "description": "Radius of the safe zone in tiles. Bots outside this radius are killed.", + "minimum": 0 + }, + "active": { + "type": "boolean", + "description": "Whether the zone is currently shrinking. False before zone_start_turn." } } }, diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 7690e26..f655b97 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -1842,6 +1842,9 @@ export class ReplayViewer { this.drawCell(wall.row, wall.col, wallColor); } + // Draw shrinking zone (if active) + this.drawZone(); + // Draw cores for (const core of turnData.cores) { if (visible && !visible.has(this.posKey(core.position))) continue; @@ -2572,6 +2575,72 @@ export class ReplayViewer { } } + private drawZone(): void { + const turnData = this.replay?.turns[this.currentTurn]; + if (!turnData?.zone_bounds) return; + + const { ctx, cellSize } = this; + const { center, radius, active } = turnData.zone_bounds; + + // Zone center in world coordinates + const cx = center.col * cellSize + cellSize / 2; + const cy = center.row * cellSize + cellSize / 2; + const cr = radius * cellSize; + + if (!active) { + // Zone not active yet — draw subtle outline + ctx.strokeStyle = 'rgba(239, 68, 68, 0.3)'; + ctx.lineWidth = 2; + ctx.setLineDash([8, 8]); + ctx.beginPath(); + ctx.arc(cx, cy, cr, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + return; + } + + // Draw danger area (outside zone) — semi-transparent red overlay + const mapW = this.replay!.map.cols * cellSize; + const mapH = this.replay!.map.rows * cellSize; + + ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'; + ctx.fillRect(0, 0, mapW, mapH); + + // Use destination-out to "cut out" the safe zone circle + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.arc(cx, cy, cr, 0, Math.PI * 2); + ctx.fill(); + ctx.globalCompositeOperation = 'source-over'; + + // Draw zone boundary circle + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, cr, 0, Math.PI * 2); + ctx.stroke(); + + // Draw inner dashed ring for visibility + ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.arc(cx, cy, cr - 3, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + + // Draw zone center marker (small cross) + const crossSize = 4; + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx - crossSize, cy); + ctx.lineTo(cx + crossSize, cy); + ctx.moveTo(cx, cy - crossSize); + ctx.lineTo(cx, cy + crossSize); + ctx.stroke(); + } + private drawBot(bot: ReplayBot, color: string): void { const { cellSize } = this; const targetX = bot.position.col * cellSize + cellSize / 2; diff --git a/web/src/types.ts b/web/src/types.ts index 1477887..3058bfc 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -64,6 +64,12 @@ export interface GameEvent { details: unknown; } +export interface ZoneBounds { + center: Position; + radius: number; + active: boolean; +} + export interface ReplayTurn { turn: number; bots: ReplayBot[]; @@ -73,6 +79,7 @@ export interface ReplayTurn { energy_held: number[]; events?: GameEvent[]; debug?: Record; + zone_bounds?: ZoneBounds; } export interface ReplayCriticalMoment {