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:
parent
6f1b50384c
commit
4f77980398
10 changed files with 2089 additions and 7 deletions
65
PROGRESS.md
65
PROGRESS.md
|
|
@ -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
2
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
316
web/index.html
Normal file
316
web/index.html
Normal 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
944
web/package-lock.json
generated
Normal 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
14
web/package.json
Normal 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
235
web/src/main.ts
Normal 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
367
web/src/replay-viewer.ts
Normal 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
121
web/src/types.ts
Normal 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
20
web/tsconfig.json
Normal 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
12
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
root: '.',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue