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:
parent
59fb673edb
commit
87f68044b4
4 changed files with 183 additions and 4 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">⛶</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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue