ai-code-battle/web/src/main.ts
jedarden 87f68044b4 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>
2026-04-23 00:30:46 -04:00

380 lines
12 KiB
TypeScript

import { ReplayViewer } from './replay-viewer';
import type { Replay, TranscriptEntry } from './types';
// DOM elements
const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
const noReplayDiv = document.getElementById('no-replay') as HTMLDivElement;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const urlInput = document.getElementById('url-input') as HTMLInputElement;
const loadUrlBtn = document.getElementById('load-url-btn') as HTMLButtonElement;
const playBtn = document.getElementById('play-btn') as HTMLButtonElement;
const prevBtn = document.getElementById('prev-btn') as HTMLButtonElement;
const nextBtn = document.getElementById('next-btn') as HTMLButtonElement;
const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
const turnDisplay = document.getElementById('turn-display') as HTMLSpanElement;
const totalTurnsSpan = document.getElementById('total-turns') as HTMLSpanElement;
const turnSlider = document.getElementById('turn-slider') as HTMLInputElement;
const speedDisplay = document.getElementById('speed-display') as HTMLSpanElement;
const speedSlider = document.getElementById('speed-slider') as HTMLInputElement;
const fogSelect = document.getElementById('fog-select') as HTMLSelectElement;
const cellSizeSelect = document.getElementById('cell-size-select') as HTMLSelectElement;
const eventLogDiv = document.getElementById('event-log') as HTMLDivElement;
// Info elements
const infoMatchId = document.getElementById('info-match-id') as HTMLElement;
const infoWinner = document.getElementById('info-winner') as HTMLElement;
const infoTurns = document.getElementById('info-turns') as HTMLElement;
const infoReason = document.getElementById('info-reason') as HTMLElement;
// Transcript panel elements (§15.3)
const transcriptPanel = document.getElementById('transcript-panel') as HTMLDivElement;
const transcriptToggleBtn = document.getElementById('transcript-toggle') as HTMLButtonElement;
const transcriptCloseBtn = document.getElementById('transcript-close') as HTMLButtonElement;
const transcriptEntriesDiv = document.getElementById('transcript-entries') as HTMLDivElement;
const transcriptViewMode = document.getElementById('transcript-view-mode') as HTMLSelectElement;
// Transcript state
let transcriptEntries: TranscriptEntry[] = [];
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;
prevBtn.disabled = false;
nextBtn.disabled = false;
resetBtn.disabled = false;
turnSlider.disabled = false;
noReplayDiv.style.display = 'none';
}
// Update UI state
function updateUI(): void {
turnDisplay.textContent = String(viewer.getTurn());
totalTurnsSpan.textContent = String(viewer.getTotalTurns());
turnSlider.value = String(viewer.getTurn());
// Update play button text
playBtn.textContent = 'Pause';
if (!viewer.getReplay() || viewer.isAtEnd()) {
playBtn.textContent = 'Play';
}
}
// Update event log
function updateEventLog(): void {
const events = viewer.getTurnEvents();
if (events.length === 0) {
eventLogDiv.innerHTML = '<div class="no-replay">No events</div>';
return;
}
eventLogDiv.innerHTML = events.map(e => {
const type = e.type.replace(/_/g, ' ');
return `<div class="event"><span class="event-type">${type}</span></div>`;
}).join('');
}
// Update match info panel
function updateMatchInfo(replay: Replay): void {
infoMatchId.textContent = replay.match_id;
infoTurns.textContent = String(replay.result.turns);
infoReason.textContent = replay.result.reason;
if (replay.result.winner >= 0 && replay.result.winner < replay.players.length) {
infoWinner.textContent = replay.players[replay.result.winner].name;
} else if (replay.result.winner === -1) {
infoWinner.textContent = 'Draw';
} else {
infoWinner.textContent = 'Player ' + replay.result.winner;
}
// Update fog of war options
fogSelect.innerHTML = '<option value="">Omniscient</option>';
replay.players.forEach((player, idx) => {
const option = document.createElement('option');
option.value = String(idx);
option.textContent = player.name;
fogSelect.appendChild(option);
});
}
// Load replay from JSON
function loadReplay(replay: Replay): void {
viewer.loadReplay(replay);
enableControls();
updateMatchInfo(replay);
// Update slider max
turnSlider.max = String(viewer.getTotalTurns() - 1);
updateUI();
updateEventLog();
renderTranscript(); // Generate and render transcript (§15.3)
}
// File input handler
fileInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const replay = JSON.parse(text) as Replay;
loadReplay(replay);
} catch (err) {
alert('Failed to load replay: ' + err);
}
});
// URL load handler
loadUrlBtn.addEventListener('click', async () => {
const url = urlInput.value.trim();
if (!url) return;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const replay = await response.json() as Replay;
loadReplay(replay);
} catch (err) {
alert('Failed to load replay from URL: ' + err);
}
});
// Playback controls
playBtn.addEventListener('click', () => {
viewer.togglePlay();
});
prevBtn.addEventListener('click', () => {
viewer.setTurn(viewer.getTurn() - 1);
updateUI();
updateEventLog();
});
nextBtn.addEventListener('click', () => {
viewer.setTurn(viewer.getTurn() + 1);
updateUI();
updateEventLog();
});
resetBtn.addEventListener('click', () => {
viewer.pause();
viewer.setTurn(0);
updateUI();
updateEventLog();
});
// Turn slider
turnSlider.addEventListener('input', () => {
viewer.setTurn(parseInt(turnSlider.value, 10));
updateUI();
updateEventLog();
});
// Speed slider
speedSlider.addEventListener('input', () => {
const speed = parseInt(speedSlider.value, 10);
viewer.setSpeed(speed);
speedDisplay.textContent = String(speed);
});
// Fog of war select
fogSelect.addEventListener('change', () => {
const value = fogSelect.value;
viewer.setFogOfWar(value === '' ? null : parseInt(value, 10));
});
// Cell size select
cellSizeSelect.addEventListener('change', () => {
const size = parseInt(cellSizeSelect.value, 10);
const replay = viewer.getReplay();
if (replay) {
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);
}
});
// Viewer callbacks
viewer.onTurnChange = () => {
updateUI();
updateEventLog();
updateTranscriptHighlight();
};
viewer.onPlayStateChange = (playing) => {
playBtn.textContent = playing ? 'Pause' : 'Play';
};
// ── Transcript Panel Functions (§15.3) ────────────────────────────────────────
function toggleTranscriptPanel(): void {
transcriptPanel.classList.toggle('open');
// Update button visibility based on panel state
transcriptToggleBtn.style.display = transcriptPanel.classList.contains('open') ? 'none' : 'block';
}
function closeTranscriptPanel(): void {
transcriptPanel.classList.remove('open');
transcriptToggleBtn.style.display = 'block';
}
function renderTranscript(): void {
if (!viewer.getReplay()) {
transcriptEntriesDiv.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">Load a replay to view the transcript.</p>';
return;
}
// Generate transcript from viewer
transcriptEntries = viewer.generateTranscript();
// Filter entries based on view mode
const filteredEntries = filterTranscriptEntries(transcriptEntries);
if (filteredEntries.length === 0) {
transcriptEntriesDiv.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">No transcript entries available.</p>';
return;
}
// Render entries
transcriptEntriesDiv.innerHTML = filteredEntries.map(entry => {
const isCurrent = entry.turn === viewer.getTurn();
return `
<div class="transcript-entry${isCurrent ? ' current' : ''}" data-turn="${entry.turn}">
<div class="turn-number">Turn ${entry.turn}</div>
<div class="text">${entry.text}</div>
</div>
`;
}).join('');
// Add click handlers for jump-to-turn
transcriptEntriesDiv.querySelectorAll('.transcript-entry').forEach(el => {
el.addEventListener('click', () => {
const turn = parseInt(el.getAttribute('data-turn') || '0', 10);
viewer.setTurn(turn);
updateUI();
updateEventLog();
updateTranscriptHighlight();
});
});
updateTranscriptHighlight();
}
function filterTranscriptEntries(entries: TranscriptEntry[]): TranscriptEntry[] {
const currentTurn = viewer.getTurn();
const totalTurns = viewer.getTotalTurns();
switch (transcriptViewModeValue) {
case 'window':
// Show ±10 turns from current turn
return entries.filter(e => e.turn >= currentTurn - 10 && e.turn <= currentTurn + 10);
case 'recent':
// Show last 20 turns
return entries.filter(e => e.turn >= totalTurns - 20);
case 'all':
default:
// Show all turns
return entries;
}
}
function updateTranscriptHighlight(): void {
// Update current turn highlighting in transcript
const currentTurn = viewer.getTurn();
transcriptEntriesDiv.querySelectorAll('.transcript-entry').forEach(el => {
const turn = parseInt(el.getAttribute('data-turn') || '-1', 10);
if (turn === currentTurn) {
el.classList.add('current');
// Scroll the current entry into view if panel is open
if (transcriptPanel.classList.contains('open')) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} else {
el.classList.remove('current');
}
});
}
// Transcript panel event listeners
transcriptToggleBtn.addEventListener('click', toggleTranscriptPanel);
transcriptCloseBtn.addEventListener('click', closeTranscriptPanel);
transcriptViewMode.addEventListener('change', () => {
const value = transcriptViewMode.value;
if (value === 'all' || value === 'window' || value === 'recent') {
transcriptViewModeValue = value;
renderTranscript();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (!viewer.getReplay()) return;
switch (e.code) {
case 'Space':
e.preventDefault();
viewer.togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
viewer.setTurn(viewer.getTurn() - 1);
updateUI();
updateEventLog();
break;
case 'ArrowRight':
e.preventDefault();
viewer.setTurn(viewer.getTurn() + 1);
updateUI();
updateEventLog();
break;
case 'Home':
e.preventDefault();
viewer.setTurn(0);
updateUI();
updateEventLog();
break;
case 'End':
e.preventDefault();
viewer.setTurn(viewer.getTotalTurns() - 1);
updateUI();
updateEventLog();
break;
case 'KeyT':
// Toggle transcript panel (§15.3)
e.preventDefault();
toggleTranscriptPanel();
break;
}
});
console.log('AI Code Battle Replay Viewer initialized');
// Auto-load demo replay
(async () => {
try {
const response = await fetch('/data/demo-replay-v2.json');
if (response.ok) {
const replay = await response.json() as Replay;
loadReplay(replay);
urlInput.value = '/data/demo-replay-v2.json';
}
} catch (e) {
// silently fail - user can load manually
}
})();