Complete Phase 3: TypeScript Canvas replay viewer

- Add web/ directory with TypeScript + Vite build tooling
- Implement ReplayViewer class with Canvas-based grid rendering
- Add play/pause, scrub, and speed controls with keyboard shortcuts
- Implement fog of war perspective toggle per player
- Add score overlay with player names, scores, and energy
- Support loading replays from local file or URL
- Add match info panel and event log display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-24 07:30:04 -04:00
parent 6f1b50384c
commit 4f77980398
10 changed files with 2089 additions and 7 deletions

View file

@ -1,6 +1,6 @@
# AI Code Battle - Implementation Progress
## Current Phase: Phase 2 - HTTP Protocol & Strategy Bots
## Current Phase: Phase 3 - Replay Viewer
**Status: ✅ COMPLETE**
@ -42,17 +42,47 @@
- **SwarmBot** (TypeScript) - Formation-based combat
- **HunterBot** (Java) - Target isolation and hunting
### Phase 3 Completed
- [x] Web project setup (`web/`)
- TypeScript + Vite build tooling
- Type definitions matching Go replay format
- [x] ReplayViewer class (`web/src/replay-viewer.ts`)
- Canvas-based grid rendering
- Bot, core, energy, wall visualization
- Player color coding (6 distinct colors)
- [x] Playback controls
- Play/pause toggle
- Turn-by-step navigation (prev/next)
- Turn scrubber slider
- Speed control (20ms - 1000ms per turn)
- Keyboard shortcuts (Space, arrows, Home/End)
- [x] Fog of War perspective toggle
- Per-player visibility calculation
- Vision radius from game config
- [x] Score overlay
- Real-time scores per player
- Energy held display
- Player name with color indicator
- [x] Match info panel
- Match ID, winner, turns, reason
- [x] Event log
- Turn-by-turn event display
- [x] File/URL loading
- Local file upload
- Remote URL fetch
### Exit Criteria Progress
| Criterion | Status |
|-----------|--------|
| HMAC auth implementation | ✅ Complete |
| HTTP bot client with timeout | ✅ Complete |
| 6 strategy bots in 6 languages | ✅ Complete |
| All bots have Dockerfile | ✅ Complete |
| Integration tests passing | ✅ Complete |
| TypeScript Canvas-based replay viewer | ✅ Complete |
| Play/pause, scrub, speed control | ✅ Complete |
| Fog of war perspective toggle | ✅ Complete |
| Score overlay | ✅ Complete |
| Loads replay JSON from file or URL | ✅ Complete |
## Next Phase: Phase 3 - Replay Viewer
## Next Phase: Phase 4 - Match Orchestration
**Status: Ready to start**
@ -75,6 +105,15 @@ ai-code-battle/
├── cmd/
│ ├── acb-local/ # CLI match runner
│ └── acb-mapgen/ # Map generator
├── web/
│ ├── package.json # npm dependencies
│ ├── tsconfig.json # TypeScript config
│ ├── vite.config.ts # Vite bundler config
│ ├── index.html # Replay viewer page
│ └── src/
│ ├── types.ts # Replay type definitions
│ ├── replay-viewer.ts # Canvas viewer class
│ └── main.ts # UI controller
├── bots/
│ ├── random/ # Python - RandomBot
│ ├── gatherer/ # Go - GathererBot
@ -101,7 +140,11 @@ ai-code-battle/
## Running Tests
```bash
# Go engine tests
go test ./engine/... -v
# Web build verification
cd web && npm run build
```
## Building CLI Tools
@ -116,3 +159,11 @@ go build ./cmd/acb-mapgen
```bash
./acb-local -seed 42 -max-turns 100 -output replay.json -verbose
```
## Viewing a Replay
```bash
cd web
npm run dev
# Open http://localhost:3000 and load replay.json
```

2
web/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

316
web/index.html Normal file
View file

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Code Battle - Replay Viewer</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
margin-bottom: 20px;
color: #f8fafc;
}
.main-layout {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.viewer-container {
flex: 1;
min-width: 300px;
}
.canvas-wrapper {
background-color: #1e293b;
border-radius: 8px;
padding: 10px;
overflow: auto;
max-height: 80vh;
}
#replay-canvas {
display: block;
}
.sidebar {
width: 300px;
display: flex;
flex-direction: column;
gap: 15px;
}
.panel {
background-color: #1e293b;
border-radius: 8px;
padding: 15px;
}
.panel h2 {
font-size: 1rem;
margin-bottom: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
background-color: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
button:hover {
background-color: #2563eb;
}
button:disabled {
background-color: #475569;
cursor: not-allowed;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.slider-group label {
font-size: 12px;
color: #94a3b8;
}
input[type="range"] {
width: 100%;
}
.file-input-wrapper {
position: relative;
}
input[type="file"] {
display: none;
}
.file-label {
display: inline-block;
background-color: #475569;
color: white;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.file-label:hover {
background-color: #64748b;
}
.url-input-group {
display: flex;
gap: 5px;
}
input[type="text"] {
flex: 1;
background-color: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px;
border-radius: 6px;
font-size: 14px;
}
select {
background-color: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px;
border-radius: 6px;
font-size: 14px;
}
.match-info {
font-size: 13px;
}
.match-info dt {
color: #94a3b8;
font-size: 11px;
text-transform: uppercase;
margin-top: 8px;
}
.match-info dd {
color: #f1f5f9;
}
.event-log {
max-height: 200px;
overflow-y: auto;
font-size: 12px;
font-family: monospace;
}
.event-log .event {
padding: 4px 0;
border-bottom: 1px solid #334155;
}
.event-log .event:last-child {
border-bottom: none;
}
.event-type {
color: #fbbf24;
}
.no-replay {
color: #64748b;
text-align: center;
padding: 20px;
}
.keyboard-shortcuts {
font-size: 12px;
color: #64748b;
margin-top: 10px;
}
.keyboard-shortcuts kbd {
background-color: #334155;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>AI Code Battle - Replay Viewer</h1>
<div class="main-layout">
<div class="viewer-container">
<div class="canvas-wrapper">
<canvas id="replay-canvas"></canvas>
<div id="no-replay" class="no-replay">Load a replay file to view</div>
</div>
</div>
<div class="sidebar">
<div class="panel">
<h2>Load Replay</h2>
<div class="controls" style="margin-bottom: 10px;">
<div class="file-input-wrapper">
<label class="file-label" for="file-input">Choose File</label>
<input type="file" id="file-input" accept=".json">
</div>
</div>
<div class="url-input-group">
<input type="text" id="url-input" placeholder="Or enter URL...">
<button id="load-url-btn">Load</button>
</div>
</div>
<div class="panel">
<h2>Playback</h2>
<div class="controls">
<button id="play-btn" disabled>Play</button>
<button id="prev-btn" disabled>Prev</button>
<button id="next-btn" disabled>Next</button>
<button id="reset-btn" disabled>Reset</button>
</div>
<div class="slider-group" style="margin-top: 10px;">
<label>Turn: <span id="turn-display">0</span> / <span id="total-turns">0</span></label>
<input type="range" id="turn-slider" min="0" max="0" value="0" disabled>
</div>
<div class="slider-group" style="margin-top: 10px;">
<label>Speed: <span id="speed-display">100</span>ms/turn</label>
<input type="range" id="speed-slider" min="20" max="1000" value="100">
</div>
</div>
<div class="panel">
<h2>View Options</h2>
<div class="slider-group">
<label for="fog-select">Fog of War:</label>
<select id="fog-select">
<option value="">Disabled (full view)</option>
</select>
</div>
<div class="slider-group" style="margin-top: 10px;">
<label for="cell-size-select">Cell Size:</label>
<select id="cell-size-select">
<option value="6">Small (6px)</option>
<option value="8">Medium (8px)</option>
<option value="10" selected>Large (10px)</option>
<option value="12">X-Large (12px)</option>
</select>
</div>
</div>
<div class="panel">
<h2>Match Info</h2>
<dl class="match-info" id="match-info">
<dt>Match ID</dt>
<dd id="info-match-id">-</dd>
<dt>Winner</dt>
<dd id="info-winner">-</dd>
<dt>Turns</dt>
<dd id="info-turns">-</dd>
<dt>Reason</dt>
<dd id="info-reason">-</dd>
</dl>
</div>
<div class="panel">
<h2>Events This Turn</h2>
<div class="event-log" id="event-log">
<div class="no-replay">No events</div>
</div>
</div>
<div class="keyboard-shortcuts">
<kbd>Space</kbd> Play/Pause
<kbd></kbd><kbd></kbd> Step
<kbd>Home</kbd><kbd>End</kbd> First/Last
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

944
web/package-lock.json generated Normal file
View file

@ -0,0 +1,944 @@
{
"name": "acb-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "acb-web",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

14
web/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "acb-web",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}

235
web/src/main.ts Normal file
View file

@ -0,0 +1,235 @@
import { ReplayViewer } from './replay-viewer';
import type { Replay } 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;
// Initialize viewer
let viewer = new ReplayViewer(canvas, { cellSize: 10 });
// 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="">Disabled (full view)</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();
}
// 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 });
loadReplay(replay);
}
});
// Viewer callbacks
viewer.onTurnChange = () => {
updateUI();
updateEventLog();
};
viewer.onPlayStateChange = (playing) => {
playBtn.textContent = playing ? 'Pause' : 'Play';
};
// 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;
}
});
console.log('AI Code Battle Replay Viewer initialized');

367
web/src/replay-viewer.ts Normal file
View file

@ -0,0 +1,367 @@
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent } from './types';
// Player colors - accessible and distinct
const PLAYER_COLORS = [
'#3b82f6', // Blue (player 0)
'#ef4444', // Red (player 1)
'#22c55e', // Green (player 2)
'#f59e0b', // Amber (player 3)
'#8b5cf6', // Purple (player 4)
'#06b6d4', // Cyan (player 5)
];
const NEUTRAL_COLOR = '#6b7280'; // Gray
const WALL_COLOR = '#1f2937'; // Dark gray
const ENERGY_COLOR = '#fbbf24'; // Yellow
const BACKGROUND_COLOR = '#111827'; // Very dark gray
const GRID_COLOR = '#374151'; // Medium gray
export interface ViewerOptions {
cellSize?: number;
showGrid?: boolean;
fogOfWarPlayer?: number | null; // null = disabled, number = player perspective
animationSpeed?: number; // ms per frame
}
export class ReplayViewer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private replay: Replay | null = null;
private currentTurn: number = 0;
private isPlaying: boolean = false;
private animationFrame: number | null = null;
private lastFrameTime: number = 0;
private cellSize: number;
private showGrid: boolean;
private fogOfWarPlayer: number | null;
private animationSpeed: number;
// Event callbacks
public onTurnChange?: (turn: number) => void;
public onPlayStateChange?: (playing: boolean) => void;
public onReplayLoad?: (replay: Replay) => void;
constructor(canvas: HTMLCanvasElement, options: ViewerOptions = {}) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not get 2D context');
this.ctx = ctx;
this.cellSize = options.cellSize ?? 10;
this.showGrid = options.showGrid ?? true;
this.fogOfWarPlayer = options.fogOfWarPlayer ?? null;
this.animationSpeed = options.animationSpeed ?? 100;
this.render = this.render.bind(this);
}
loadReplay(replay: Replay): void {
this.replay = replay;
this.currentTurn = 0;
// Resize canvas to fit the grid
this.resizeCanvas();
// Render initial state
this.render();
if (this.onReplayLoad) this.onReplayLoad(replay);
}
private resizeCanvas(): void {
if (!this.replay) return;
const { rows, cols } = this.replay.map;
this.canvas.width = cols * this.cellSize;
this.canvas.height = rows * this.cellSize;
}
private posKey(pos: Position): string {
return `${pos.row},${pos.col}`;
}
setTurn(turn: number): void {
if (!this.replay) return;
this.currentTurn = Math.max(0, Math.min(turn, this.replay.turns.length - 1));
this.render();
if (this.onTurnChange) this.onTurnChange(this.currentTurn);
}
getTurn(): number {
return this.currentTurn;
}
getTotalTurns(): number {
return this.replay?.turns.length ?? 0;
}
play(): void {
if (this.isPlaying || !this.replay) return;
this.isPlaying = true;
this.lastFrameTime = performance.now();
this.animationFrame = requestAnimationFrame(this.animate.bind(this));
if (this.onPlayStateChange) this.onPlayStateChange(true);
}
pause(): void {
this.isPlaying = false;
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
if (this.onPlayStateChange) this.onPlayStateChange(false);
}
togglePlay(): void {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
setSpeed(msPerFrame: number): void {
this.animationSpeed = Math.max(10, Math.min(2000, msPerFrame));
}
getSpeed(): number {
return this.animationSpeed;
}
setFogOfWar(player: number | null): void {
this.fogOfWarPlayer = player;
this.render();
}
getFogOfWar(): number | null {
return this.fogOfWarPlayer;
}
private animate(timestamp: number): void {
if (!this.isPlaying || !this.replay) return;
const elapsed = timestamp - this.lastFrameTime;
if (elapsed >= this.animationSpeed) {
this.lastFrameTime = timestamp;
// Advance to next turn
if (this.currentTurn < this.replay.turns.length - 1) {
this.currentTurn++;
this.render();
if (this.onTurnChange) this.onTurnChange(this.currentTurn);
} else {
// End of replay
this.pause();
return;
}
}
this.animationFrame = requestAnimationFrame(this.animate.bind(this));
}
private render(): void {
if (!this.replay) return;
const { ctx, cellSize, canvas } = this;
const { rows, cols } = this.replay.map;
// Clear canvas
ctx.fillStyle = BACKGROUND_COLOR;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid lines
if (this.showGrid) {
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 0.5;
for (let r = 0; r <= rows; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellSize);
ctx.lineTo(cols * cellSize, r * cellSize);
ctx.stroke();
}
for (let c = 0; c <= cols; c++) {
ctx.beginPath();
ctx.moveTo(c * cellSize, 0);
ctx.lineTo(c * cellSize, rows * cellSize);
ctx.stroke();
}
}
// Get current turn data
const turnData = this.replay.turns[this.currentTurn];
if (!turnData) return;
// Determine visibility for fog of war
const visible = this.fogOfWarPlayer !== null
? this.computeVisibility(turnData, this.fogOfWarPlayer)
: null;
// Draw walls (always visible)
for (const wall of this.replay.map.walls) {
this.drawCell(wall.row, wall.col, WALL_COLOR);
}
// Draw cores
for (const core of turnData.cores) {
if (visible && !visible.has(this.posKey(core.position))) continue;
const color = core.active ? PLAYER_COLORS[core.owner] : NEUTRAL_COLOR;
this.drawCore(core.position.row, core.position.col, color, core.active);
}
// Draw energy
for (const energy of turnData.energy) {
if (visible && !visible.has(this.posKey(energy))) continue;
this.drawEnergy(energy.row, energy.col);
}
// Draw bots
for (const bot of turnData.bots) {
if (!bot.alive) continue;
if (visible && !visible.has(this.posKey(bot.position))) continue;
const color = PLAYER_COLORS[bot.owner];
this.drawBot(bot, color);
}
// Draw score overlay
this.drawScoreOverlay(turnData);
}
private computeVisibility(turnData: ReplayTurn, player: number): Set<string> {
const visible = new Set<string>();
const config = this.replay!.config;
const visionRadius2 = config.vision_radius2;
// Add all positions visible from this player's bots
for (const bot of turnData.bots) {
if (bot.owner !== player || !bot.alive) continue;
// Add all cells within vision radius
const vr = Math.ceil(Math.sqrt(visionRadius2));
for (let dr = -vr; dr <= vr; dr++) {
for (let dc = -vr; dc <= vr; dc++) {
const dist2 = dr * dr + dc * dc;
if (dist2 <= visionRadius2) {
const r = (bot.position.row + dr + this.replay!.map.rows) % this.replay!.map.rows;
const c = (bot.position.col + dc + this.replay!.map.cols) % this.replay!.map.cols;
visible.add(`${r},${c}`);
}
}
}
}
// Also add this player's cores (always visible)
for (const core of this.replay!.map.cores) {
if (core.owner === player) {
visible.add(this.posKey(core.position));
}
}
return visible;
}
private drawCell(row: number, col: number, color: string): void {
const { ctx, cellSize } = this;
ctx.fillStyle = color;
ctx.fillRect(col * cellSize, row * cellSize, cellSize, cellSize);
}
private drawCore(row: number, col: number, color: string, active: boolean): void {
const { ctx, cellSize } = this;
const x = col * cellSize + cellSize / 2;
const y = row * cellSize + cellSize / 2;
const radius = (cellSize / 2) - 1;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
// Draw inactive marker
if (!active) {
ctx.strokeStyle = BACKGROUND_COLOR;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - radius / 2, y - radius / 2);
ctx.lineTo(x + radius / 2, y + radius / 2);
ctx.stroke();
}
}
private drawEnergy(row: number, col: number): void {
const { ctx, cellSize } = this;
const x = col * cellSize + cellSize / 2;
const y = row * cellSize + cellSize / 2;
const radius = (cellSize / 3);
ctx.fillStyle = ENERGY_COLOR;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
private drawBot(bot: ReplayBot, color: string): void {
const { ctx, cellSize } = this;
const x = bot.position.col * cellSize + cellSize / 2;
const y = bot.position.row * cellSize + cellSize / 2;
const radius = (cellSize / 2) - 1;
// Draw bot as filled circle
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
// Draw border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.stroke();
}
private drawScoreOverlay(turnData: ReplayTurn): void {
if (!this.replay) return;
const { ctx } = this;
const padding = 10;
const lineHeight = 20;
// Draw semi-transparent background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, 150, padding * 2 + lineHeight * this.replay.players.length);
// Draw scores for each player
ctx.font = '14px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
this.replay.players.forEach((player, idx) => {
const score = turnData.scores[idx] ?? 0;
const energy = turnData.energy_held[idx] ?? 0;
const color = PLAYER_COLORS[idx];
// Draw color indicator
ctx.fillStyle = color;
ctx.fillRect(padding, padding + idx * lineHeight, 12, 12);
// Draw text
ctx.fillStyle = '#ffffff';
ctx.fillText(`${player.name}: ${score} (E:${energy})`, padding + 18, padding + idx * lineHeight);
});
}
// Utility to get current turn events
getTurnEvents(): GameEvent[] {
if (!this.replay) return [];
const turnData = this.replay.turns[this.currentTurn];
return turnData?.events ?? [];
}
// Get replay info
getReplay(): Replay | null {
return this.replay;
}
// Check if at end of replay
isAtEnd(): boolean {
if (!this.replay) return true;
return this.currentTurn >= this.replay.turns.length - 1;
}
}

121
web/src/types.ts Normal file
View file

@ -0,0 +1,121 @@
// Replay format types matching the Go engine
export interface Position {
row: number;
col: number;
}
export interface Config {
rows: number;
cols: number;
max_turns: number;
vision_radius2: number;
attack_radius2: number;
spawn_cost: number;
energy_interval: number;
}
export interface MatchResult {
winner: number;
reason: string;
turns: number;
scores: number[];
energy: number[];
bots_alive: number[];
}
export interface ReplayPlayer {
id: number;
name: string;
}
export interface ReplayCore {
position: Position;
owner: number;
}
export interface ReplayMap {
rows: number;
cols: number;
walls: Position[];
cores: ReplayCore[];
energy_nodes: Position[];
}
export interface ReplayBot {
id: number;
owner: number;
position: Position;
alive: boolean;
}
export interface ReplayCoreState {
position: Position;
owner: number;
active: boolean;
}
export interface GameEvent {
type: string;
turn: number;
details: unknown;
}
export interface ReplayTurn {
turn: number;
bots: ReplayBot[];
cores: ReplayCoreState[];
energy: Position[];
scores: number[];
energy_held: number[];
events?: GameEvent[];
}
export interface Replay {
match_id: string;
config: Config;
start_time: string;
end_time: string;
result: MatchResult;
players: ReplayPlayer[];
map: ReplayMap;
turns: ReplayTurn[];
}
// Event detail types
export interface BotSpawnedDetails {
bot_id: number;
owner: number;
position: Position;
}
export interface BotDiedDetails {
bot_id: number;
owner: number;
position: Position;
}
export interface EnergyCollectedDetails {
bot_id: number;
owner: number;
position: Position;
}
export interface CoreCapturedDetails {
position: Position;
old_owner: number;
new_owner: number;
}
export interface CombatDeathDetails {
attacker_id: number;
attacker_owner: number;
defender_id: number;
defender_owner: number;
position: Position;
}
export interface CollisionDeathDetails {
bot_ids: number[];
position: Position;
}

20
web/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist"
},
"include": ["src"]
}

12
web/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
export default defineConfig({
root: '.',
build: {
outDir: 'dist',
sourcemap: true,
},
server: {
port: 3000,
},
})