2e75909f2d
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
7.8 KiB
Markdown
159 lines
7.8 KiB
Markdown
# 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).
|