Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.8 KiB
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
- Toggle labelled 2D / 3D next to the "Replay" heading, 2D by default, sitting alongside the existing Ground/Air toggle.
- The 3D renderer is pure: only its
<canvas>. No name labels, no axis lines, no map guide lines, no on-canvas controls/legend. - Keep the existing team side panels (winners/losers legends).
- 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).
- Keep the ticket bar on top and battle log below, both synced to the shared clock regardless of active view.
- The Ground/Air toggle controls both views.
- 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.jsxfetches/api/tss/games/:id/replay-canvasonce and buildsReplayCanvasEnginewith the data object.- The engine eagerly constructs both the 2D canvas and a hidden
ReplayCanvas3Dview from the samedata. engine._tickadvancescurrentTimeand callsactiveView.setTime(currentTime). Scrubberinputdoes the same. Ticket bar + battle log update fromcurrentTimeindependent of the active view.engine.setViewMode('2d'|'3d')toggles visibility of the 2D canvas vs the 3D container, setsactiveView, calls the new view'ssetTime/setMode/resize, and preserves play state + time → seamless swap.engine.setMode('ground'|'air')updates 2D coords (unchanged) and callsview3d.setMode(mode).rc-rowclick →engine.focus(playerId)→ routed toactiveView.focus. 3D flies the camera to the unit and glows it; 2Dfocusis 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 intocontainer; resolves map info fromdata(levelCoords/tankMapCoords/mapCoords/fullMapLevel) the same way the standalone viewer'sresolveMapInfodoes; builds map plane, tank/marker entities, path lines, kill lines, capture zones; starts its ownrequestAnimationFrameloop for camera damping + rendering (it renders continuously but only changes unit positions whensetTimeis called). - Methods:
setTime(t)— place all entities/markers/kill-lines/zones at game timet(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 reusefocusglow without camera move). MVP:focusonly.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(fromdata) sofocus(playerId)can find the currently-live entity for that player. - Model asset: the tank
.objand map images. 3D loads the tank model from a static path served by the app (copyREPLAY_VIEWER/public/models/t34/intotssbot.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 constructReplayCanvas3D. setViewMode(mode): show/hide canvases, setactiveView, push currentcurrentTime+ ground/air mode to the newly active view,resize()it.- In
_tickand scrubber handler: when_viewMode === '3d', callview3d.setTime(currentTime)instead of (or in addition to) the 2Drender(). 2Drender()is skipped while 3D is active for perf. setMode(mode): keep existing 2D behavior, alsoview3d?.setMode(mode).focus(playerId): callactiveView.focus?.(playerId).rc-rowclick listener →this.focus(pid).destroy(): alsoview3d?.dispose().
4. frontend/src/ReplayCanvas.jsx — ReplayCanvasPanel
- State adds
viewMode: '2d'. - Render a 2D/3D toggle (mirroring
rc-mode-togglestyling) next to the "Replay"<h2>, always visible oncestatus === '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-togglelook).- Center stacking so the 2D
<canvas>and 3D container occupy the same slot (one hidden via class).
Error handling
- Missing
y(stale cache): 3D usesy = 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 buildcompiles.- One game regenerated through
render_replayincludesy.
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).