Add 3D replay view design spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
FURRO404
2026-06-21 07:17:15 -07:00
parent a24decbbeb
commit 2e75909f2d
@@ -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 `<canvas>`. 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 <replay> <out.json>` (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/<level>` 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 `<canvas>`.
- 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" `<h2>`, 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 `<canvas>` 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).