feat(replay): add fog-of-war perspective toggle and minimap per §7.3

Add perspective dropdown (Omniscient + per-player) that filters the
replay view to a single player's fog of war, hiding cells/bots outside
their vision radius. Add minimap canvas in the corner showing the full
grid with walls, energy, cores, bots, fog overlay, and a viewport
rectangle. Clicking the minimap pans the main canvas and zooms in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-23 00:30:46 -04:00
parent 59fb673edb
commit 87f68044b4
4 changed files with 183 additions and 4 deletions

View file

@ -214,6 +214,27 @@
font-family: monospace;
}
/* Minimap (§7.3) */
.minimap-container {
position: absolute;
bottom: 16px;
right: 16px;
border: 2px solid #475569;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
opacity: 0.85;
transition: opacity 0.2s;
z-index: 10;
}
.minimap-container:hover {
opacity: 1;
}
#minimap-canvas {
display: block;
cursor: crosshair;
}
/* Transcript panel styles (§15.3) */
.transcript-panel {
position: fixed;
@ -360,9 +381,12 @@
<div class="main-layout">
<div class="viewer-container">
<div class="canvas-wrapper">
<div class="canvas-wrapper" style="position:relative">
<canvas id="replay-canvas"></canvas>
<div id="no-replay" class="no-replay">Load a replay file to view</div>
<div class="minimap-container" id="minimap-container">
<canvas id="minimap-canvas" width="150" height="150"></canvas>
</div>
</div>
</div>

View file

@ -40,6 +40,12 @@ let transcriptViewModeValue: 'all' | 'window' | 'recent' = 'all';
// Initialize viewer
let viewer = new ReplayViewer(canvas, { cellSize: 16 });
// Wire up minimap canvas (§7.3)
const minimapCanvas = document.getElementById('minimap-canvas') as HTMLCanvasElement;
if (minimapCanvas) {
viewer.setMinimapCanvas(minimapCanvas);
}
// Enable controls when replay is loaded
function enableControls(): void {
playBtn.disabled = false;
@ -92,7 +98,7 @@ function updateMatchInfo(replay: Replay): void {
}
// Update fog of war options
fogSelect.innerHTML = '<option value="">Disabled (full view)</option>';
fogSelect.innerHTML = '<option value="">Omniscient</option>';
replay.players.forEach((player, idx) => {
const option = document.createElement('option');
option.value = String(idx);
@ -196,6 +202,7 @@ cellSizeSelect.addEventListener('change', () => {
viewer = new ReplayViewer(canvas, { cellSize: size });
viewer.onTurnChange = () => { updateUI(); updateEventLog(); updateTranscriptHighlight(); };
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
if (minimapCanvas) viewer.setMinimapCanvas(minimapCanvas);
loadReplay(replay);
}
});

View file

@ -64,6 +64,9 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
<button id="theater-btn" class="theater-btn" aria-label="Toggle theater mode" title="Theater mode (F)" style="position:absolute;top:8px;right:8px">&#x26F6;</button>
<div id="follow-indicator" class="follow-indicator" style="display:none;position:absolute;top:8px;left:8px;background:rgba(0,0,0,0.75);color:#e5e7eb;font:12px monospace;padding:4px 10px;border-radius:4px;pointer-events:auto;z-index:10;cursor:pointer" role="status" aria-live="polite"></div>
<div id="minimap-container" style="position:absolute;bottom:16px;right:16px;border:2px solid var(--border,#475569);border-radius:6px;overflow:hidden;box-shadow:0 4px 12px rgba(0,0,0,0.5);opacity:0.85;transition:opacity 0.2s;z-index:10">
<canvas id="minimap-canvas" width="150" height="150" style="display:block;cursor:crosshair"></canvas>
</div>
</div>
<!-- Mobile compact controls bar — CSS hides on tablet+ -->
@ -177,7 +180,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
</select>
<label for="fog-select" style="margin-top: 10px;">Fog of War:</label>
<select id="fog-select">
<option value="">Disabled (full view)</option>
<option value="">Omniscient</option>
</select>
<label for="cell-size-select" style="margin-top: 10px;">Cell Size:</label>
<select id="cell-size-select">
@ -585,6 +588,12 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
viewer = new ReplayViewerClass(actualCanvas, { cellSize: 10 });
}
// Wire up minimap canvas (§7.3)
const minimapCanvas = document.getElementById('minimap-canvas') as HTMLCanvasElement;
if (minimapCanvas) {
viewer.setMinimapCanvas(minimapCanvas);
}
let criticalMoments: Array<{turn: number; delta: number; description: string}> = [];
let commentaryEnabled = true;
let debugPanelExpanded = false;
@ -750,7 +759,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
infoWinner.textContent = 'Player ' + replay.result.winner;
}
fogSelect.innerHTML = '<option value="">Disabled (full view)</option>';
fogSelect.innerHTML = '<option value="">Omniscient</option>';
replay.players.forEach((player, idx) => {
const option = document.createElement('option');
option.value = String(idx);
@ -1430,6 +1439,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
const prevTurn = viewer.getTurn();
viewer.destroy();
viewer = new ReplayViewerClass(canvas, { cellSize: size });
if (minimapCanvas) viewer.setMinimapCanvas(minimapCanvas);
loadReplay(replay);
viewer.setTurn(prevTurn);
updateUI();

View file

@ -473,6 +473,9 @@ export class ReplayViewer {
// Annotation overlay state (§16.8)
private annotations: Array<{ turn: number; type: string; position?: Position }> = [];
// Minimap state (§7.3)
private minimapCanvas: HTMLCanvasElement | null = null;
constructor(canvas: HTMLCanvasElement, options: ViewerOptions = {}) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
@ -642,6 +645,118 @@ export class ReplayViewer {
return this.fogOfWarPlayer;
}
// ── Minimap Controls (§7.3) ──────────────────────────────────────────────────
setMinimapCanvas(canvas: HTMLCanvasElement): void {
this.minimapCanvas = canvas;
canvas.addEventListener('click', (e: MouseEvent) => this.handleMinimapClick(e));
canvas.style.cursor = 'crosshair';
this.renderMinimap();
}
private handleMinimapClick(e: MouseEvent): void {
if (!this.replay) return;
const canvas = this.minimapCanvas!;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const { rows, cols } = this.replay.map;
const mapW = cols * this.cellSize;
const mapH = rows * this.cellSize;
// Convert click position to map coordinates
const mapX = (x / canvas.width) * mapW;
const mapY = (y / canvas.height) * mapH;
// Pan camera to clicked position (exit follow mode)
this.followPlayer = null;
this.cameraTargetCenterX = mapX;
this.cameraTargetCenterY = mapY;
// Zoom in if at 1x
if (this.cameraZoom <= 1.1) {
this.cameraTargetZoom = 2;
}
this.render();
if (this.onFollowChange) this.onFollowChange(null);
}
renderMinimap(): void {
if (!this.minimapCanvas || !this.replay) return;
const mCtx = this.minimapCanvas.getContext('2d');
if (!mCtx) return;
const { rows, cols } = this.replay.map;
const turnData = this.replay.turns[this.currentTurn];
if (!turnData) return;
const w = this.minimapCanvas.width;
const h = this.minimapCanvas.height;
const cellW = w / cols;
const cellH = h / rows;
const colors = this.getPlayerColors();
// Background
mCtx.fillStyle = '#0a0a1e';
mCtx.fillRect(0, 0, w, h);
// Walls
mCtx.fillStyle = '#374151';
for (const wall of this.replay.map.walls) {
mCtx.fillRect(wall.col * cellW, wall.row * cellH, cellW + 0.5, cellH + 0.5);
}
// Energy
for (const e of turnData.energy) {
mCtx.fillStyle = '#facc15';
mCtx.fillRect(e.col * cellW, e.row * cellH, Math.max(cellW, 2), Math.max(cellH, 2));
}
// Cores
for (const core of turnData.cores) {
mCtx.fillStyle = core.active ? colors[core.owner] : '#4b5563';
mCtx.fillRect(core.position.col * cellW - 1, core.position.row * cellH - 1,
Math.max(cellW, 3) + 2, Math.max(cellH, 3) + 2);
}
// Bots
for (const bot of turnData.bots) {
if (!bot.alive) continue;
mCtx.fillStyle = colors[bot.owner];
const bx = bot.position.col * cellW;
const by = bot.position.row * cellH;
const bs = Math.max(cellW, 2);
mCtx.fillRect(bx, by, bs, bs);
}
// Fog overlay on minimap
if (this.fogOfWarPlayer !== null) {
const visible = this.computeVisibility(turnData, this.fogOfWarPlayer);
mCtx.fillStyle = 'rgba(10,10,30,0.7)';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!visible.has(`${r},${c}`)) {
mCtx.fillRect(c * cellW, r * cellH, cellW + 0.5, cellH + 0.5);
}
}
}
}
// Viewport rectangle
const mapW = cols * this.cellSize;
const mapH = rows * this.cellSize;
const canvasW = this.canvas.width;
const viewW = canvasW / this.cameraZoom;
const viewH = mapH / this.cameraZoom;
// Camera center is in world coordinates
const vx = this.cameraCenterX - viewW / 2;
const vy = this.cameraCenterY - viewH / 2;
// Scale to minimap coordinates
const sx = vx / mapW * w;
const sy = vy / mapH * h;
const sw = viewW / mapW * w;
const sh = viewH / mapH * h;
mCtx.strokeStyle = '#ffffff';
mCtx.lineWidth = 1.5;
mCtx.strokeRect(sx, sy, sw, sh);
}
// ── Accessibility Controls ─────────────────────────────────────────────────────
setAccessibility(settings: Partial<AccessibilitySettings>): void {
@ -1651,6 +1766,11 @@ export class ReplayViewer {
break;
}
// Draw fog-of-war overlay on non-visible tiles (§7.3)
if (visible) {
this.renderFogOverlay(visible);
}
// Draw animated particles and effects (if not reduced motion)
if (!this.accessibility.reducedMotion) {
drawEffects(ctx);
@ -1681,6 +1801,9 @@ export class ReplayViewer {
if (this.winProbCanvas && this.winProbData) {
this.renderWinProbSparkline();
}
// Update minimap each frame (§7.3)
this.renderMinimap();
}
// Standard view with grid
@ -2234,6 +2357,21 @@ export class ReplayViewer {
return visible;
}
private renderFogOverlay(visible: Set<string>): void {
const { ctx, cellSize, replay } = this;
if (!replay) return;
const { rows, cols } = replay.map;
const fogColor = this.accessibility.highContrast ? 'rgba(0,0,0,0.85)' : 'rgba(10,10,30,0.7)';
ctx.fillStyle = fogColor;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!visible.has(`${r},${c}`)) {
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
}
}
}
}
private drawCell(row: number, col: number, color: string): void {
const { ctx, cellSize } = this;
ctx.fillStyle = color;