diff --git a/PROGRESS.md b/PROGRESS.md index 32fef4e..0099cce 100644 --- a/PROGRESS.md +++ b/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 +``` diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..afb1feb --- /dev/null +++ b/web/index.html @@ -0,0 +1,316 @@ + + + + + + AI Code Battle - Replay Viewer + + + +
+

AI Code Battle - Replay Viewer

+ +
+
+
+ +
Load a replay file to view
+
+
+ + +
+
+ + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..76b17d6 --- /dev/null +++ b/web/package-lock.json @@ -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 + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b93b10e --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..f462932 --- /dev/null +++ b/web/src/main.ts @@ -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 = '
No events
'; + return; + } + + eventLogDiv.innerHTML = events.map(e => { + const type = e.type.replace(/_/g, ' '); + return `
${type}
`; + }).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 = ''; + 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'); diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts new file mode 100644 index 0000000..e940d90 --- /dev/null +++ b/web/src/replay-viewer.ts @@ -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 { + const visible = new Set(); + 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; + } +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..47976cd --- /dev/null +++ b/web/src/types.ts @@ -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; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..c298359 --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..7e527a4 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: '.', + build: { + outDir: 'dist', + sourcemap: true, + }, + server: { + port: 3000, + }, +})