From 2e75909f2d267cfd0f9588c74d9a9af491e39e22 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Sun, 21 Jun 2026 07:17:15 -0700 Subject: [PATCH] Add 3D replay view design spec Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-21-3d-replay-view-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-3d-replay-view-design.md diff --git a/docs/superpowers/specs/2026-06-21-3d-replay-view-design.md b/docs/superpowers/specs/2026-06-21-3d-replay-view-design.md new file mode 100644 index 0000000..ce51a59 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-3d-replay-view-design.md @@ -0,0 +1,158 @@ +# 3D Replay View — Design + +**Date:** 2026-06-21 +**App:** `tssbot.web` (React frontend + Node `server.cjs` + Rust/Python upstream) + +## Goal + +Add a **2D/3D toggle** to the game-detail Replay panel. The existing 2D +minimap replayer stays the default; a new 3D view (ported from the standalone +`REPLAY_VIEWER` three.js app) renders the same replay in an orbitable 3D scene. +Both views share one clock, the ticket bar, the battle log, and the team side +panels, so switching between them is seamless in real time. + +## Requirements + +1. Toggle labelled **2D / 3D** next to the "Replay" heading, **2D by default**, + sitting alongside the existing Ground/Air toggle. +2. The 3D renderer is **pure**: only its ``. No name labels, no axis + lines, no map guide lines, no on-canvas controls/legend. +3. Keep the existing **team side panels** (winners/losers legends). +4. **Clicking a side-panel row focuses that unit in 3D** (camera flies to it and + it glows). In 2D, click behaves the same as hover (no new behavior). +5. Keep the **ticket bar on top** and **battle log below**, both synced to the + shared clock regardless of active view. +6. The **Ground/Air** toggle controls both views. +7. 3D uses **real height (Y)** from the replay data. + +## Data flow + +Pipeline today: +`replay_data.json[.gz]` (raw, path rows `[t, x, y, z]`) +→ `python -m BOT.render_replay ` (in `GitHub/BOTS/TSSBOT`) +→ `replay_canvas.json` (2D: path points `{t, x, z}`, plus `levelCoords`, +`tankMapCoords`, `mapCoords`, `fullMapLevel`, players, kills, captureAreas, +captureState, tickets, teamNames, winnerSlot) +→ cached + served at `GET /api/tss/games/:id/replay-canvas` +→ `ReplayCanvasEngine` (in `frontend/src/ReplayCanvas.jsx`). + +The raw rows already carry `y` (confirmed: e.g. `[29031, -10218.59, 69.74, +-2626.99]`); the renderer drops it. The map coords needed by 3D are already in +`replay_canvas.json`. + +### Change + +`render_replay.py`: include `y` in each emitted canvas path point (add a `y` +field, e.g. `{t, x, y, z}`). Backwards compatible — the 2D engine reads only +`t/x/z`. Cached JSONs without `y` regenerate on next `mtime` mismatch; until +then 3D falls back to `y = 0`. + +### Runtime flow + +- `ReplayCanvasPanel.jsx` fetches `/api/tss/games/:id/replay-canvas` once and + builds `ReplayCanvasEngine` with the data object. +- The engine **eagerly** constructs both the 2D canvas and a hidden + `ReplayCanvas3D` view from the same `data`. +- `engine._tick` advances `currentTime` and calls `activeView.setTime(currentTime)`. + Scrubber `input` does the same. Ticket bar + battle log update from + `currentTime` independent of the active view. +- `engine.setViewMode('2d'|'3d')` toggles visibility of the 2D canvas vs the 3D + container, sets `activeView`, calls the new view's `setTime`/`setMode`/ + `resize`, and preserves play state + time → seamless swap. +- `engine.setMode('ground'|'air')` updates 2D coords (unchanged) **and** calls + `view3d.setMode(mode)`. +- `rc-row` click → `engine.focus(playerId)` → routed to `activeView.focus`. 3D + flies the camera to the unit and glows it; 2D `focus` is a no-op. + +## Components + +### 1. `BOT/render_replay.py` (in `GitHub/BOTS/TSSBOT`) +Emit `y` per canvas path point. Single-field additive change at the point where +path points are serialized for the canvas JSON. + +### 2. `frontend/src/ReplayCanvas3D.js` (new) +The ported three.js renderer as a UI-less class. + +- **Constructor** `(container, data)`: builds renderer, scene, camera, + OrbitControls into `container`; resolves map info from `data` + (levelCoords/tankMapCoords/mapCoords/fullMapLevel) the same way the standalone + viewer's `resolveMapInfo` does; builds map plane, tank/marker entities, path + lines, kill lines, capture zones; starts its own `requestAnimationFrame` loop + for camera damping + rendering (it renders continuously but only changes unit + positions when `setTime` is called). +- **Methods:** + - `setTime(t)` — place all entities/markers/kill-lines/zones at game time `t` + (same interpolation/death-freeze logic as the standalone viewer). + - `setMode('ground'|'air')` — swap coords/map image + rebuild scene extents. + - `focus(playerId)` — move camera target to that player's current entity and + apply the path glow / dim-others effect. + - `setHighlight(playerId|null)` — optional, mirror hover highlight (best + effort; may reuse `focus` glow without camera move). *MVP: `focus` only.* + - `resize()` — match container size. + - `dispose()` — stop loop, dispose geometries/materials/renderer, remove + canvas. +- **Stripped vs standalone viewer:** remove name-label sprites, axis lines, map + guide lines/annotations, the on-canvas play/speed/scrub controls, the active + list / hover panel / meta panel DOM, and the replay-select. Keep: map plane + + texture, tank model + sphere fallback, path lines, kill lines, capture zones, + death-freeze, path glow + camera focus, Shift-to-boost camera movement, the + `Plane*`-mesh removal fix, and the capture/heading/freeze fixes already made. +- **Entity identity:** each 3D entity carries its `playerId` (from `data`) so + `focus(playerId)` can find the currently-live entity for that player. +- **Model asset:** the tank `.obj` and map images. 3D loads the tank model from + a static path served by the app (copy `REPLAY_VIEWER/public/models/t34/` into + `tssbot.web/frontend/public/models/t34/` or equivalent served dir). Map images + come from the existing minimap endpoint (`/api/match/minimap/` and + `?type=full`), the same URLs the 2D engine uses. + +### 3. `frontend/src/ReplayCanvas.jsx` — `ReplayCanvasEngine` +- Add a center child container for the 3D canvas next to the 2D ``. +- New state: `this._viewMode = '2d'`, `this.view3d = null`, `this.activeView`. +- In `init()` (after map data is ready), eagerly construct `ReplayCanvas3D`. +- `setViewMode(mode)`: show/hide canvases, set `activeView`, push current + `currentTime` + ground/air mode to the newly active view, `resize()` it. +- In `_tick` and scrubber handler: when `_viewMode === '3d'`, call + `view3d.setTime(currentTime)` instead of (or in addition to) the 2D `render()`. + 2D `render()` is skipped while 3D is active for perf. +- `setMode(mode)`: keep existing 2D behavior, also `view3d?.setMode(mode)`. +- `focus(playerId)`: call `activeView.focus?.(playerId)`. +- `rc-row` click listener → `this.focus(pid)`. +- `destroy()`: also `view3d?.dispose()`. + +### 4. `frontend/src/ReplayCanvas.jsx` — `ReplayCanvasPanel` +- State adds `viewMode: '2d'`. +- Render a 2D/3D toggle (mirroring `rc-mode-toggle` styling) next to the + "Replay" `

`, always visible once `status === 'ready'`. +- `switchView(mode)` → `engine.setViewMode(mode)` + `setState`. +- If WebGL is unavailable, disable the 3D button (dimmed, tooltip) and stay 2D. + +### 5. Styles +- `.rc-view-toggle` / `.rc-view-btn` (reuse `.rc-mode-toggle` look). +- Center stacking so the 2D `` and 3D container occupy the same slot + (one hidden via class). + +## Error handling + +- **Missing `y`** (stale cache): 3D uses `y = 0` → flat scene, no crash. +- **No WebGL**: 3D button disabled; panel stays 2D. +- **Model/texture load failure**: 3D falls back to sphere markers / untextured + map (already handled in the standalone viewer's loaders). + +## Testing / verification + +Manual, since this is interactive visual UI: +- 2D view byte-for-byte unchanged when 3D is never opened. +- Toggle 2D⇄3D mid-playback: time, play/pause, and ticket/battle-log stay in + sync; no jump. +- Ground/Air toggle affects both views (use a game with aircraft). +- Clicking a side row in 3D flies the camera + glows that unit; in 2D it matches + hover. +- `vite build` compiles. +- One game regenerated through `render_replay` includes `y`. + +## Out of scope (YAGNI) + +- Persistent 2D click-focus / 2D recenter (explicitly declined). +- Name labels / legend inside the 3D canvas. +- Lazy-loading three.js (eager chosen). +- Backfilling height into all cached `replay_canvas.json` (regenerate on demand).