Initial commit: TSS Bot Web Frontend (React/Vite + production server)
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
# 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`).
|
||||
|
||||
**Update (verified during implementation):** `render_replay.py` *already* emits
|
||||
`y` per canvas path point (`render_replay.py:4237` → `{t, x, z, y}`), and
|
||||
`replay_canvas.json` already includes `captureAreas` (with `x/z/radius/center`),
|
||||
`captureState` (cap timelines keyed `A/B/…`), `kills` (with `killerPos`/
|
||||
`victimPos` in world coords), and all map coords. **No backend change is
|
||||
needed.** 3D still falls back to `y = 0` if a point is missing.
|
||||
|
||||
### 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. Backend / `render_replay.py`
|
||||
No change — `y`, capture data, and kill positions are already emitted.
|
||||
|
||||
### 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).
|
||||
Reference in New Issue
Block a user