tss web 3d viewer
This commit is contained in:
@@ -36,16 +36,12 @@ captureState, tickets, teamNames, winnerSlot)
|
|||||||
→ cached + served at `GET /api/tss/games/:id/replay-canvas`
|
→ cached + served at `GET /api/tss/games/:id/replay-canvas`
|
||||||
→ `ReplayCanvasEngine` (in `frontend/src/ReplayCanvas.jsx`).
|
→ `ReplayCanvasEngine` (in `frontend/src/ReplayCanvas.jsx`).
|
||||||
|
|
||||||
The raw rows already carry `y` (confirmed: e.g. `[29031, -10218.59, 69.74,
|
**Update (verified during implementation):** `render_replay.py` *already* emits
|
||||||
-2626.99]`); the renderer drops it. The map coords needed by 3D are already in
|
`y` per canvas path point (`render_replay.py:4237` → `{t, x, z, y}`), and
|
||||||
`replay_canvas.json`.
|
`replay_canvas.json` already includes `captureAreas` (with `x/z/radius/center`),
|
||||||
|
`captureState` (cap timelines keyed `A/B/…`), `kills` (with `killerPos`/
|
||||||
### Change
|
`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.
|
||||||
`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
|
### Runtime flow
|
||||||
|
|
||||||
@@ -66,9 +62,8 @@ then 3D falls back to `y = 0`.
|
|||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### 1. `BOT/render_replay.py` (in `GitHub/BOTS/TSSBOT`)
|
### 1. Backend / `render_replay.py`
|
||||||
Emit `y` per canvas path point. Single-field additive change at the point where
|
No change — `y`, capture data, and kill positions are already emitted.
|
||||||
path points are serialized for the canvas JSON.
|
|
||||||
|
|
||||||
### 2. `frontend/src/ReplayCanvas3D.js` (new)
|
### 2. `frontend/src/ReplayCanvas3D.js` (new)
|
||||||
The ported three.js renderer as a UI-less class.
|
The ported three.js renderer as a UI-less class.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 3ds Max Wavefront OBJ Exporter v0.97b - (c)2007 guruware
|
||||||
|
# File Created: 27.04.2017 18:23:39
|
||||||
|
|
||||||
|
newmtl wire_022022022
|
||||||
|
Ns 32
|
||||||
|
d 1
|
||||||
|
Tr 0
|
||||||
|
Tf 1 1 1
|
||||||
|
illum 2
|
||||||
|
Ka 0.0863 0.0863 0.0863
|
||||||
|
Kd 0.0863 0.0863 0.0863
|
||||||
|
Ks 0.3500 0.3500 0.3500
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import ReplayCanvas3D from './ReplayCanvas3D.js'
|
||||||
|
|
||||||
const RC = {
|
const RC = {
|
||||||
TRAIL_MS: 18000,
|
TRAIL_MS: 18000,
|
||||||
@@ -60,11 +61,15 @@ class ReplayCanvasEngine {
|
|||||||
this._tickets = data.tickets && Object.keys(data.tickets).length ? data.tickets : null
|
this._tickets = data.tickets && Object.keys(data.tickets).length ? data.tickets : null
|
||||||
this._winnerSlot = Number(data.winnerSlot) || 0
|
this._winnerSlot = Number(data.winnerSlot) || 0
|
||||||
this._mode = 'ground'
|
this._mode = 'ground'
|
||||||
|
this._viewMode = '2d'
|
||||||
|
this.view3d = null
|
||||||
|
this.supports3d = false
|
||||||
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }
|
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }
|
||||||
this.players = {}
|
this.players = {}
|
||||||
for (const p of data.players || []) this.players[p.id] = p
|
for (const p of data.players || []) this.players[p.id] = p
|
||||||
|
|
||||||
const hasAircraft = (data.entities || []).some((e) => e.type === 'aircraft')
|
const hasAircraft = (data.entities || []).some((e) => e.type === 'aircraft')
|
||||||
|
this.hasGroundEntities = this._hasGroundEntities(data.entities)
|
||||||
this.hasAirMode = Boolean(this._airCoords && this._fullMapLevel && hasAircraft)
|
this.hasAirMode = Boolean(this._airCoords && this._fullMapLevel && hasAircraft)
|
||||||
if (!this._groundCoords && this._airCoords) this._groundCoords = this._airCoords
|
if (!this._groundCoords && this._airCoords) this._groundCoords = this._airCoords
|
||||||
if (!this._tankMapCoords && this._groundCoords) this._tankMapCoords = this._groundCoords
|
if (!this._tankMapCoords && this._groundCoords) this._tankMapCoords = this._groundCoords
|
||||||
@@ -123,6 +128,9 @@ class ReplayCanvasEngine {
|
|||||||
async init() {
|
async init() {
|
||||||
this._buildDOM()
|
this._buildDOM()
|
||||||
await Promise.all([this._loadMap(), this._loadEntityIcons()])
|
await Promise.all([this._loadMap(), this._loadEntityIcons()])
|
||||||
|
this._initView3d()
|
||||||
|
this._onResize = () => { if (this._viewMode === '3d') this.view3d?.resize() }
|
||||||
|
window.addEventListener('resize', this._onResize)
|
||||||
this.playing = true
|
this.playing = true
|
||||||
this.playBtn.textContent = 'Pause'
|
this.playBtn.textContent = 'Pause'
|
||||||
this.lastFrameTime = performance.now()
|
this.lastFrameTime = performance.now()
|
||||||
@@ -130,6 +138,41 @@ class ReplayCanvasEngine {
|
|||||||
this.animFrameId = requestAnimationFrame(this._tick)
|
this.animFrameId = requestAnimationFrame(this._tick)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_initView3d() {
|
||||||
|
try {
|
||||||
|
this.view3d = new ReplayCanvas3D(this.view3dContainer, this.data)
|
||||||
|
this.supports3d = true
|
||||||
|
} catch {
|
||||||
|
this.view3d = null
|
||||||
|
this.supports3d = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderActive() {
|
||||||
|
if (this._viewMode === '3d') this.view3d?.setTime(this.currentTime)
|
||||||
|
else this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewMode(mode) {
|
||||||
|
const next = mode === '3d' && this.view3d ? '3d' : '2d'
|
||||||
|
if (next === this._viewMode) return
|
||||||
|
this._viewMode = next
|
||||||
|
const is3d = next === '3d'
|
||||||
|
this.canvas.classList.toggle('rc-hidden', is3d)
|
||||||
|
this.view3dContainer.classList.toggle('rc-hidden', !is3d)
|
||||||
|
if (is3d) {
|
||||||
|
this.view3d.setMode(this._mode)
|
||||||
|
this.view3d.resize()
|
||||||
|
this.view3d.setTime(this.currentTime)
|
||||||
|
} else {
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(playerId) {
|
||||||
|
if (this._viewMode === '3d') this.view3d?.focus(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
_buildDOM() {
|
_buildDOM() {
|
||||||
this.container.innerHTML = ''
|
this.container.innerHTML = ''
|
||||||
this._panelRows = []
|
this._panelRows = []
|
||||||
@@ -144,12 +187,18 @@ class ReplayCanvasEngine {
|
|||||||
center.className = 'rc-center'
|
center.className = 'rc-center'
|
||||||
this._buildTicketsBar(center)
|
this._buildTicketsBar(center)
|
||||||
|
|
||||||
|
const stage = document.createElement('div')
|
||||||
|
stage.className = 'rc-stage'
|
||||||
this.canvas = document.createElement('canvas')
|
this.canvas = document.createElement('canvas')
|
||||||
this.canvas.width = this.canvasSize
|
this.canvas.width = this.canvasSize
|
||||||
this.canvas.height = this.canvasSize
|
this.canvas.height = this.canvasSize
|
||||||
this.canvas.className = 'rc-canvas'
|
this.canvas.className = 'rc-canvas'
|
||||||
this.ctx = this.canvas.getContext('2d')
|
this.ctx = this.canvas.getContext('2d')
|
||||||
center.appendChild(this.canvas)
|
stage.appendChild(this.canvas)
|
||||||
|
this.view3dContainer = document.createElement('div')
|
||||||
|
this.view3dContainer.className = 'rc-3d-container rc-hidden'
|
||||||
|
stage.appendChild(this.view3dContainer)
|
||||||
|
center.appendChild(stage)
|
||||||
|
|
||||||
const controls = document.createElement('div')
|
const controls = document.createElement('div')
|
||||||
controls.className = 'rc-controls'
|
controls.className = 'rc-controls'
|
||||||
@@ -191,7 +240,7 @@ class ReplayCanvasEngine {
|
|||||||
this._updatePanelDeathStates()
|
this._updatePanelDeathStates()
|
||||||
this._updateBattleLog()
|
this._updateBattleLog()
|
||||||
this._updateTicketsBar(this.currentTime)
|
this._updateTicketsBar(this.currentTime)
|
||||||
this.render()
|
this._renderActive()
|
||||||
})
|
})
|
||||||
controls.querySelectorAll('.rc-sp').forEach((btn) => {
|
controls.querySelectorAll('.rc-sp').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -279,6 +328,7 @@ class ReplayCanvasEngine {
|
|||||||
if (ent && !this._isEntityGone(ent, this.currentTime)) this._setHighlight(pid)
|
if (ent && !this._isEntityGone(ent, this.currentTime)) this._setHighlight(pid)
|
||||||
})
|
})
|
||||||
row.addEventListener('mouseleave', () => this._setHighlight(null))
|
row.addEventListener('mouseleave', () => this._setHighlight(null))
|
||||||
|
row.addEventListener('click', () => this.focus(pid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,11 +789,13 @@ class ReplayCanvasEngine {
|
|||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
if (mode === this._mode) return
|
if (mode === this._mode) return
|
||||||
if (mode === 'air' && !this.hasAirMode) return
|
if (mode === 'air' && !this.hasAirMode) return
|
||||||
|
if (mode === 'ground' && !this.hasGroundEntities) return
|
||||||
this._applyCoords(mode)
|
this._applyCoords(mode)
|
||||||
for (const ent of this.entities) ent._lastHeading = null
|
for (const ent of this.entities) ent._lastHeading = null
|
||||||
this._drawMapToCanvas()
|
this._drawMapToCanvas()
|
||||||
this._computeDeaths()
|
this._computeDeaths()
|
||||||
this.render()
|
this.view3d?.setMode(mode)
|
||||||
|
this._renderActive()
|
||||||
}
|
}
|
||||||
|
|
||||||
_togglePlay() {
|
_togglePlay() {
|
||||||
@@ -766,7 +818,7 @@ class ReplayCanvasEngine {
|
|||||||
this.playBtn.textContent = 'Play'
|
this.playBtn.textContent = 'Play'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.render()
|
this._renderActive()
|
||||||
this._updateTicketsBar(this.currentTime)
|
this._updateTicketsBar(this.currentTime)
|
||||||
this._updateControls()
|
this._updateControls()
|
||||||
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
|
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
|
||||||
@@ -1037,6 +1089,9 @@ class ReplayCanvasEngine {
|
|||||||
destroy() {
|
destroy() {
|
||||||
if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
|
if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
|
||||||
this.animFrameId = null
|
this.animFrameId = null
|
||||||
|
if (this._onResize) window.removeEventListener('resize', this._onResize)
|
||||||
|
this.view3d?.dispose()
|
||||||
|
this.view3d = null
|
||||||
this.container.innerHTML = ''
|
this.container.innerHTML = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1044,13 +1099,13 @@ class ReplayCanvasEngine {
|
|||||||
export default function ReplayCanvasPanel({ gameId }) {
|
export default function ReplayCanvasPanel({ gameId }) {
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const engineRef = useRef(null)
|
const engineRef = useRef(null)
|
||||||
const [state, setState] = useState({ status: 'loading', error: '', hasAirMode: false, mode: 'ground' })
|
const [state, setState] = useState({ status: 'loading', error: '', hasAirMode: false, hasGroundEntities: true, mode: 'ground', viewMode: '2d', supports3d: false })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameId) return undefined
|
if (!gameId) return undefined
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
let disposed = false
|
let disposed = false
|
||||||
setState({ status: 'loading', error: '', hasAirMode: false, mode: 'ground' })
|
setState({ status: 'loading', error: '', hasAirMode: false, hasGroundEntities: true, mode: 'ground' })
|
||||||
if (engineRef.current) {
|
if (engineRef.current) {
|
||||||
engineRef.current.destroy()
|
engineRef.current.destroy()
|
||||||
engineRef.current = null
|
engineRef.current = null
|
||||||
@@ -1080,11 +1135,14 @@ export default function ReplayCanvasPanel({ gameId }) {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: '',
|
error: '',
|
||||||
hasAirMode: engine.hasAirMode,
|
hasAirMode: engine.hasAirMode,
|
||||||
|
hasGroundEntities: engine.hasGroundEntities,
|
||||||
mode: engine._mode,
|
mode: engine._mode,
|
||||||
|
viewMode: '2d',
|
||||||
|
supports3d: engine.supports3d,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted || disposed) return
|
if (controller.signal.aborted || disposed) return
|
||||||
setState({ status: 'error', error: error.message || 'Replay unavailable', hasAirMode: false, mode: 'ground' })
|
setState({ status: 'error', error: error.message || 'Replay unavailable', hasAirMode: false, hasGroundEntities: true, mode: 'ground', viewMode: '2d', supports3d: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,16 +1164,47 @@ export default function ReplayCanvasPanel({ gameId }) {
|
|||||||
setState((current) => ({ ...current, mode: engine._mode }))
|
setState((current) => ({ ...current, mode: engine._mode }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchView(viewMode) {
|
||||||
|
const engine = engineRef.current
|
||||||
|
if (!engine) return
|
||||||
|
engine.setViewMode(viewMode)
|
||||||
|
setState((current) => ({ ...current, viewMode: engine._viewMode }))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="replay-card rounded-lg border border-border bg-fury-white p-5 shadow-sm">
|
<section className="replay-card rounded-lg border border-border bg-fury-white p-5 shadow-sm">
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-lg font-bold text-text">Replay</h2>
|
<h2 className="text-lg font-bold text-text">Replay</h2>
|
||||||
|
{state.status === 'ready' ? (
|
||||||
|
<div className="rc-mode-toggle visible">
|
||||||
|
<button
|
||||||
|
className={`rc-mode-btn ${state.viewMode === '2d' ? 'active' : ''}`}
|
||||||
|
onClick={() => switchView('2d')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
2D
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rc-mode-btn ${state.viewMode === '3d' ? 'active' : ''} ${!state.supports3d ? 'rc-mode-btn-disabled' : ''}`}
|
||||||
|
onClick={() => switchView('3d')}
|
||||||
|
type="button"
|
||||||
|
disabled={!state.supports3d}
|
||||||
|
title={!state.supports3d ? '3D view not supported on this device' : undefined}
|
||||||
|
>
|
||||||
|
3D
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{state.status === 'ready' && state.hasAirMode ? (
|
{state.status === 'ready' && state.hasAirMode ? (
|
||||||
<div className="rc-mode-toggle visible">
|
<div className="rc-mode-toggle visible">
|
||||||
<button
|
<button
|
||||||
className={`rc-mode-btn ${state.mode === 'ground' ? 'active' : ''}`}
|
className={`rc-mode-btn ${state.mode === 'ground' ? 'active' : ''} ${!state.hasGroundEntities ? 'rc-mode-btn-disabled' : ''}`}
|
||||||
onClick={() => switchMode('ground')}
|
onClick={() => switchMode('ground')}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!state.hasGroundEntities}
|
||||||
|
title={!state.hasGroundEntities ? 'No ground units in this replay' : undefined}
|
||||||
>
|
>
|
||||||
Ground
|
Ground
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
// Pure 3D replay view. No DOM beyond its own <canvas>; driven entirely by an
|
||||||
|
// external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine.
|
||||||
|
// Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of
|
||||||
|
// its own UI (labels, axes, guide lines, controls, hover/active-list panels).
|
||||||
|
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||||
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
|
||||||
|
|
||||||
|
const MODEL_PATH = '/models/t34/'
|
||||||
|
const MINIMAP_URL = (level) => `/api/match/minimap/${level}`
|
||||||
|
const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full`
|
||||||
|
|
||||||
|
const WIN_COLOR = '#00c800'
|
||||||
|
const LOSE_COLOR = '#dc1e1e'
|
||||||
|
const DRONE_COLOR = '#cbd5e1'
|
||||||
|
|
||||||
|
const DIRECTION_SAMPLE_MS = 700
|
||||||
|
const DIRECTION_BLEND = 0.22
|
||||||
|
const KILL_LINE_WINDOW_MS = 3000
|
||||||
|
const CAP_INTERP_MAX_GAP_MS = 4000
|
||||||
|
const FLIP_MAP_TEXTURE_Z = true
|
||||||
|
|
||||||
|
function coordsAreValid(c) {
|
||||||
|
return c &&
|
||||||
|
Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) &&
|
||||||
|
Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) &&
|
||||||
|
Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCoords(c) {
|
||||||
|
if (!coordsAreValid(c)) return null
|
||||||
|
return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSourceRect(baseCoords, renderCoords) {
|
||||||
|
const base = normalizeCoords(baseCoords)
|
||||||
|
const render = normalizeCoords(renderCoords)
|
||||||
|
if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 }
|
||||||
|
const dx = base.x1 - base.x0
|
||||||
|
const dz = base.z1 - base.z0
|
||||||
|
const xLo = Math.min(render.x0, render.x1)
|
||||||
|
const xHi = Math.max(render.x0, render.x1)
|
||||||
|
const zLo = Math.min(render.z0, render.z1)
|
||||||
|
const zHi = Math.max(render.z0, render.z1)
|
||||||
|
const u0 = (xLo - base.x0) / dx
|
||||||
|
const u1 = (xHi - base.x0) / dx
|
||||||
|
const v0 = (zLo - base.z0) / dz
|
||||||
|
const v1 = (zHi - base.z0) / dz
|
||||||
|
const uMin = Math.max(0, Math.min(1, Math.min(u0, u1)))
|
||||||
|
const uMax = Math.max(0, Math.min(1, Math.max(u0, u1)))
|
||||||
|
const vMin = Math.max(0, Math.min(1, Math.min(v0, v1)))
|
||||||
|
const vMax = Math.max(0, Math.min(1, Math.max(v0, v1)))
|
||||||
|
const w = uMax - uMin
|
||||||
|
const h = vMax - vMin
|
||||||
|
if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 }
|
||||||
|
return { u: uMin, v: 1 - vMax, w, h }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackBoundsCoords(bounds) {
|
||||||
|
const pad = Math.max(bounds.planarSpan * 0.15, 250)
|
||||||
|
return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad }
|
||||||
|
}
|
||||||
|
|
||||||
|
function catmullRom(a, b, c, d, t) {
|
||||||
|
const t2 = t * t
|
||||||
|
const t3 = t2 * t
|
||||||
|
return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ReplayCanvas3D {
|
||||||
|
constructor(container, data) {
|
||||||
|
this.container = container
|
||||||
|
this.data = data
|
||||||
|
this.disposed = false
|
||||||
|
this.currentT = 0
|
||||||
|
this.selectedPlayerId = null
|
||||||
|
this.smoothByEntity = new Map()
|
||||||
|
|
||||||
|
this.players = {}
|
||||||
|
for (const p of data.players || []) this.players[p.id] = p
|
||||||
|
this.teamWon = data.teamWon
|
||||||
|
|
||||||
|
this._buildEntities()
|
||||||
|
this._buildCaptureModel()
|
||||||
|
this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos)
|
||||||
|
|
||||||
|
this.bounds = this._computeBounds()
|
||||||
|
this.scale = 1200 / this.bounds.planarSpan
|
||||||
|
|
||||||
|
this._initThree()
|
||||||
|
|
||||||
|
this._mode = 'ground'
|
||||||
|
this.mapInfo = this._resolveMapInfo('ground')
|
||||||
|
|
||||||
|
this._buildScene()
|
||||||
|
this._loadTankTemplate()
|
||||||
|
this._loadMapTexture()
|
||||||
|
|
||||||
|
this._animate = this._animate.bind(this)
|
||||||
|
this._raf = requestAnimationFrame(this._animate)
|
||||||
|
this.setTime(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- data adaptation -----------------------------------------------------
|
||||||
|
|
||||||
|
_isWinner(entity) {
|
||||||
|
if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon
|
||||||
|
return entity.droneTeam === this.teamWon
|
||||||
|
}
|
||||||
|
|
||||||
|
_colorFor(entity) {
|
||||||
|
if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR
|
||||||
|
return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildEntities() {
|
||||||
|
this.entities = []
|
||||||
|
for (const e of this.data.entities || []) {
|
||||||
|
if (!Array.isArray(e.path) || !e.path.length) continue
|
||||||
|
const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) }))
|
||||||
|
this.entities.push({
|
||||||
|
playerId: e.playerId,
|
||||||
|
entityIndex: e.entityIndex,
|
||||||
|
type: e.type,
|
||||||
|
droneTeam: e.droneTeam,
|
||||||
|
path,
|
||||||
|
startT: path[0].t,
|
||||||
|
endT: path[path.length - 1].t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCaptureModel() {
|
||||||
|
const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : []
|
||||||
|
const state = this.data.captureState || {}
|
||||||
|
this.captureZones = areas.map((area, index) => {
|
||||||
|
const letter = String.fromCharCode(65 + index)
|
||||||
|
const cap = Array.isArray(state[letter])
|
||||||
|
? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v))
|
||||||
|
: []
|
||||||
|
const center = area.tm?.center || [area.x, 0, area.z]
|
||||||
|
return {
|
||||||
|
key: letter,
|
||||||
|
center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) },
|
||||||
|
radius: Math.max(1, Number(area.radius) || 1),
|
||||||
|
cap,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeBounds() {
|
||||||
|
const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
|
||||||
|
for (const e of this.entities) {
|
||||||
|
for (const p of e.path) {
|
||||||
|
b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x)
|
||||||
|
b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y)
|
||||||
|
b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 }
|
||||||
|
b.centerX = (b.minX + b.maxX) / 2
|
||||||
|
b.centerZ = (b.minZ + b.maxZ) / 2
|
||||||
|
b.spanX = Math.max(1, b.maxX - b.minX)
|
||||||
|
b.spanZ = Math.max(1, b.maxZ - b.minZ)
|
||||||
|
b.planarSpan = Math.max(b.spanX, b.spanZ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveMapInfo(mode) {
|
||||||
|
const level = this.data.mission?.level
|
||||||
|
const full = this.data.fullMapLevel
|
||||||
|
if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) {
|
||||||
|
const coords = normalizeCoords(this.data.mapCoords)
|
||||||
|
return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } }
|
||||||
|
}
|
||||||
|
const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds)
|
||||||
|
const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords
|
||||||
|
return {
|
||||||
|
image: level ? MINIMAP_URL(level) : null,
|
||||||
|
coords,
|
||||||
|
baseCoords,
|
||||||
|
sourceRect: mapSourceRect(baseCoords, coords),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- coordinate transforms ----------------------------------------------
|
||||||
|
|
||||||
|
_toWorld(p) {
|
||||||
|
const b = this.bounds
|
||||||
|
return new THREE.Vector3(
|
||||||
|
(p.x - b.centerX) * this.scale,
|
||||||
|
(p.y - b.minY) * this.scale,
|
||||||
|
(p.z - b.centerZ) * this.scale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderPoint(p) {
|
||||||
|
if (!FLIP_MAP_TEXTURE_Z) return p
|
||||||
|
const coords = normalizeCoords(this.mapInfo?.coords)
|
||||||
|
if (!coords) return p
|
||||||
|
return { ...p, z: coords.z0 + coords.z1 - p.z }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- three.js setup ------------------------------------------------------
|
||||||
|
|
||||||
|
_initThree() {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.className = 'rc3d-canvas'
|
||||||
|
this.canvasEl = canvas
|
||||||
|
this.container.appendChild(canvas)
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||||
|
this.renderer.setClearColor(0x0b0d10, 1)
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene()
|
||||||
|
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000)
|
||||||
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||||
|
this.controls.enableDamping = true
|
||||||
|
this.controls.dampingFactor = 0.08
|
||||||
|
this.controls.screenSpacePanning = false
|
||||||
|
this.controls.maxPolarAngle = Math.PI * 0.49
|
||||||
|
this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE
|
||||||
|
this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
|
||||||
|
this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN
|
||||||
|
|
||||||
|
this.root = new THREE.Group()
|
||||||
|
this.mapGroup = new THREE.Group()
|
||||||
|
this.pathGroup = new THREE.Group()
|
||||||
|
this.zoneGroup = new THREE.Group()
|
||||||
|
this.killLineGroup = new THREE.Group()
|
||||||
|
this.markerGroup = new THREE.Group()
|
||||||
|
this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup)
|
||||||
|
this.scene.add(this.root)
|
||||||
|
|
||||||
|
this.scene.add(new THREE.AmbientLight(0xffffff, 0.72))
|
||||||
|
const sun = new THREE.DirectionalLight(0xffffff, 1.1)
|
||||||
|
sun.position.set(240, 500, 180)
|
||||||
|
this.scene.add(sun)
|
||||||
|
|
||||||
|
this.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldMapExtents(coords = this.mapInfo?.coords) {
|
||||||
|
const c = normalizeCoords(coords)
|
||||||
|
if (!c) return null
|
||||||
|
const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 })
|
||||||
|
const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 })
|
||||||
|
return {
|
||||||
|
minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x),
|
||||||
|
minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z),
|
||||||
|
width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetCamera() {
|
||||||
|
const ext = this._worldMapExtents()
|
||||||
|
const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100)
|
||||||
|
this.camera.position.set(span * 0.45, span * 0.55, span * 0.72)
|
||||||
|
this.controls.target.set(0, 0, 0)
|
||||||
|
this.controls.minDistance = Math.max(18, span * 0.025)
|
||||||
|
this.controls.maxDistance = Math.max(320, span * 1.8)
|
||||||
|
this.camera.far = Math.max(9000, span * 8)
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
this.controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- scene construction --------------------------------------------------
|
||||||
|
|
||||||
|
_buildScene() {
|
||||||
|
this._clearGroup(this.mapGroup)
|
||||||
|
this._clearGroup(this.pathGroup)
|
||||||
|
this._clearGroup(this.markerGroup)
|
||||||
|
this._clearGroup(this.zoneGroup)
|
||||||
|
this._clearGroup(this.killLineGroup)
|
||||||
|
this._buildMapPlane()
|
||||||
|
this._buildCaptureZones()
|
||||||
|
this._buildKillLines()
|
||||||
|
this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e))
|
||||||
|
this._applyHighlight()
|
||||||
|
this._resetCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearGroup(group) {
|
||||||
|
for (const child of [...group.children]) {
|
||||||
|
group.remove(child)
|
||||||
|
child.traverse?.((c) => {
|
||||||
|
if (c.geometry) c.geometry.dispose()
|
||||||
|
if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildMapPlane() {
|
||||||
|
const info = this.mapInfo
|
||||||
|
if (!info || !coordsAreValid(info.coords)) return
|
||||||
|
const x0 = Math.min(info.coords.x0, info.coords.x1)
|
||||||
|
const x1 = Math.max(info.coords.x0, info.coords.x1)
|
||||||
|
const z0 = Math.min(info.coords.z0, info.coords.z1)
|
||||||
|
const z1 = Math.max(info.coords.z0, info.coords.z1)
|
||||||
|
const width = Math.max(1, (x1 - x0) * this.scale)
|
||||||
|
const depth = Math.max(1, (z1 - z0) * this.scale)
|
||||||
|
const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 })
|
||||||
|
|
||||||
|
const geometry = new THREE.PlaneGeometry(width, depth, 1, 1)
|
||||||
|
this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 })
|
||||||
|
geometry.rotateX(-Math.PI / 2)
|
||||||
|
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
})
|
||||||
|
const plane = new THREE.Mesh(geometry, material)
|
||||||
|
plane.position.set(center.x, -0.12, center.z)
|
||||||
|
plane.renderOrder = -10
|
||||||
|
this.mapGroup.add(plane)
|
||||||
|
this._mapMaterial = material
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyPlaneUvs(geometry, rect) {
|
||||||
|
const u0 = rect.u
|
||||||
|
const u1 = rect.u + rect.w
|
||||||
|
const vTop = rect.v
|
||||||
|
const vBottom = rect.v + rect.h
|
||||||
|
const uv = geometry.attributes.uv
|
||||||
|
if (FLIP_MAP_TEXTURE_Z) {
|
||||||
|
uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop)
|
||||||
|
uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom)
|
||||||
|
} else {
|
||||||
|
uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom)
|
||||||
|
uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop)
|
||||||
|
}
|
||||||
|
uv.needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadMapTexture() {
|
||||||
|
if (!this.mapInfo.image || !this._mapMaterial) return
|
||||||
|
const material = this._mapMaterial
|
||||||
|
new THREE.TextureLoader().load(
|
||||||
|
this.mapInfo.image,
|
||||||
|
(texture) => {
|
||||||
|
if (this.disposed) { texture.dispose(); return }
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace
|
||||||
|
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
|
||||||
|
material.map = texture
|
||||||
|
material.color.setHex(0xffffff)
|
||||||
|
material.needsUpdate = true
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => { material.color.setHex(0x1f2933); material.opacity = 0.6 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeVisualEntity(entity) {
|
||||||
|
const color = this._colorFor(entity)
|
||||||
|
const points = entity.path.map((p) => this._toWorld(this._renderPoint(p)))
|
||||||
|
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||||
|
const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 })
|
||||||
|
const line = new THREE.Line(lineGeometry, lineMaterial)
|
||||||
|
this.pathGroup.add(line)
|
||||||
|
|
||||||
|
const radius = 4.4
|
||||||
|
let model
|
||||||
|
if (this.unitTemplate && entity.type === 'ground') {
|
||||||
|
model = this.unitTemplate.clone(true)
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (!child.isMesh) return
|
||||||
|
child.material = new THREE.MeshStandardMaterial({
|
||||||
|
color, roughness: 0.72, metalness: 0.06, flatShading: true,
|
||||||
|
emissive: new THREE.Color(color).multiplyScalar(0.22),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const geo = new THREE.SphereGeometry(radius, 18, 12)
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08,
|
||||||
|
})
|
||||||
|
model = new THREE.Mesh(geo, mat)
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = new THREE.ArrowHelper(
|
||||||
|
new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0),
|
||||||
|
radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9,
|
||||||
|
)
|
||||||
|
arrow.line.material.transparent = true
|
||||||
|
arrow.line.material.opacity = 0.78
|
||||||
|
arrow.cone.material.transparent = true
|
||||||
|
arrow.cone.material.opacity = 0.95
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.add(model, arrow)
|
||||||
|
this.markerGroup.add(group)
|
||||||
|
|
||||||
|
return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadTankTemplate() {
|
||||||
|
try {
|
||||||
|
const object = await new Promise((resolve, reject) => {
|
||||||
|
const loader = new OBJLoader()
|
||||||
|
loader.setPath(MODEL_PATH)
|
||||||
|
loader.load('t_34_obj.obj', resolve, undefined, reject)
|
||||||
|
})
|
||||||
|
if (this.disposed) return
|
||||||
|
|
||||||
|
const planes = []
|
||||||
|
object.traverse((child) => {
|
||||||
|
if (!child.isMesh) return
|
||||||
|
child.castShadow = false
|
||||||
|
child.receiveShadow = false
|
||||||
|
if (/^Plane/i.test(child.name || '')) { planes.push(child); return }
|
||||||
|
})
|
||||||
|
for (const plane of planes) plane.removeFromParent()
|
||||||
|
|
||||||
|
const box = new THREE.Box3()
|
||||||
|
object.traverse((child) => { if (child.isMesh) box.expandByObject(child) })
|
||||||
|
if (box.isEmpty()) box.setFromObject(object)
|
||||||
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||||
|
const scale = 70 / maxDim
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
|
||||||
|
const pivot = new THREE.Group()
|
||||||
|
object.position.x -= center.x
|
||||||
|
object.position.z -= center.z
|
||||||
|
object.position.y -= box.min.y
|
||||||
|
pivot.add(object)
|
||||||
|
pivot.scale.setScalar(scale)
|
||||||
|
this.unitTemplate = pivot
|
||||||
|
|
||||||
|
// Rebuild ground entities now that the model is available.
|
||||||
|
this._buildScene()
|
||||||
|
this.setTime(this.currentT)
|
||||||
|
} catch {
|
||||||
|
// No model -> spheres are used; nothing else to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capture zones -------------------------------------------------------
|
||||||
|
|
||||||
|
_buildCaptureZones() {
|
||||||
|
for (const zone of this.captureZones) {
|
||||||
|
const center = this._toWorld(this._renderPoint(zone.center))
|
||||||
|
const radius = Math.max(8, zone.radius * this.scale)
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.position.set(center.x, 1.05, center.z)
|
||||||
|
|
||||||
|
const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96)
|
||||||
|
ringGeo.rotateX(-Math.PI / 2)
|
||||||
|
const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
}))
|
||||||
|
ring.renderOrder = 8
|
||||||
|
|
||||||
|
const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96)
|
||||||
|
fillGeo.rotateX(-Math.PI / 2)
|
||||||
|
const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
}))
|
||||||
|
fill.position.y = 0.02
|
||||||
|
fill.renderOrder = 7
|
||||||
|
|
||||||
|
group.add(fill, ring)
|
||||||
|
group.userData = { zone, fill }
|
||||||
|
this.zoneGroup.add(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_captureValueAt(zone, t) {
|
||||||
|
if (!zone.cap.length) return 0
|
||||||
|
if (t <= zone.cap[0][0]) return zone.cap[0][1]
|
||||||
|
for (let i = 1; i < zone.cap.length; i++) {
|
||||||
|
const prev = zone.cap[i - 1]
|
||||||
|
const cur = zone.cap[i]
|
||||||
|
if (t <= cur[0]) {
|
||||||
|
const gap = cur[0] - prev[0]
|
||||||
|
if (gap > CAP_INTERP_MAX_GAP_MS) return prev[1]
|
||||||
|
const alpha = (t - prev[0]) / Math.max(1, gap)
|
||||||
|
return THREE.MathUtils.lerp(prev[1], cur[1], THREE.MathUtils.clamp(alpha, 0, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zone.cap[zone.cap.length - 1][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateCaptureZones() {
|
||||||
|
for (const group of this.zoneGroup.children) {
|
||||||
|
const { zone, fill } = group.userData
|
||||||
|
if (!zone || !fill) continue
|
||||||
|
const value = THREE.MathUtils.clamp(this._captureValueAt(zone, this.currentT), -100, 100)
|
||||||
|
const amount = Math.abs(value) / 100
|
||||||
|
fill.visible = value !== 0
|
||||||
|
fill.material.color.set(value > 0 ? WIN_COLOR : value < 0 ? LOSE_COLOR : 0xd8dee9)
|
||||||
|
fill.material.opacity = value === 0 ? 0 : 0.18 + amount * 0.34
|
||||||
|
const s = value === 0 ? 0 : Math.sqrt(amount)
|
||||||
|
fill.scale.set(s, s, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- kill lines ----------------------------------------------------------
|
||||||
|
|
||||||
|
_buildKillLines() {
|
||||||
|
this.killLines = []
|
||||||
|
for (const k of this.kills) {
|
||||||
|
const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z }))
|
||||||
|
const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z }))
|
||||||
|
start.y += 12; end.y += 12
|
||||||
|
const vec = end.clone().sub(start)
|
||||||
|
const length = vec.length()
|
||||||
|
if (length < 0.5) continue
|
||||||
|
const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR)
|
||||||
|
const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color,
|
||||||
|
Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055)))
|
||||||
|
for (const m of [arrow.line.material, arrow.cone.material]) {
|
||||||
|
m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false
|
||||||
|
}
|
||||||
|
arrow.renderOrder = 18
|
||||||
|
arrow.visible = false
|
||||||
|
this.killLineGroup.add(arrow)
|
||||||
|
this.killLines.push({ time: k.time, arrow })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateKillLines() {
|
||||||
|
for (const { time, arrow } of this.killLines) {
|
||||||
|
const age = this.currentT - time
|
||||||
|
const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS
|
||||||
|
arrow.visible = visible
|
||||||
|
if (!visible) continue
|
||||||
|
const opacity = 1 - age / KILL_LINE_WINDOW_MS
|
||||||
|
arrow.line.material.opacity = 0.18 + opacity * 0.62
|
||||||
|
arrow.cone.material.opacity = 0.24 + opacity * 0.72
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interpolation -------------------------------------------------------
|
||||||
|
|
||||||
|
_findSegment(entity, t) {
|
||||||
|
const path = entity.path
|
||||||
|
if (t < entity.startT || t > entity.endT) return null
|
||||||
|
let lo = 0, hi = path.length - 1
|
||||||
|
while (lo < hi - 1) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (path[mid].t <= t) lo = mid; else hi = mid
|
||||||
|
}
|
||||||
|
return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
_interp(entity, t) {
|
||||||
|
const seg = this._findSegment(entity, t)
|
||||||
|
if (!seg) return null
|
||||||
|
const { index, a, b } = seg
|
||||||
|
const path = entity.path
|
||||||
|
const span = Math.max(1, b.t - a.t)
|
||||||
|
const alpha = Math.min(1, Math.max(0, (t - a.t) / span))
|
||||||
|
const before = path[Math.max(0, index - 1)]
|
||||||
|
const after = path[Math.min(path.length - 1, index + 2)]
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
x: catmullRom(before.x, a.x, b.x, after.x, alpha),
|
||||||
|
y: catmullRom(before.y, a.y, b.y, after.y, alpha),
|
||||||
|
z: catmullRom(before.z, a.z, b.z, after.z, alpha),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_directionAt(entity, t) {
|
||||||
|
const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS)
|
||||||
|
const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS)
|
||||||
|
if (afterT <= beforeT) return null
|
||||||
|
const before = this._interp(entity, beforeT)
|
||||||
|
const after = this._interp(entity, afterT)
|
||||||
|
if (!before || !after) return null
|
||||||
|
const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before)))
|
||||||
|
if (dir.lengthSq() < 0.0001) return null
|
||||||
|
return dir.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
_blendedDirection(visual, t) {
|
||||||
|
const raw = this._directionAt(visual.entity, t)
|
||||||
|
if (!raw) return visual.smoothedDirection
|
||||||
|
const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800
|
||||||
|
if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone()
|
||||||
|
else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize()
|
||||||
|
visual.lastDirectionT = t
|
||||||
|
return visual.smoothedDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- highlight / focus ---------------------------------------------------
|
||||||
|
|
||||||
|
_applyHighlight() {
|
||||||
|
const sel = this.selectedPlayerId
|
||||||
|
if (!this.visualEntities) return
|
||||||
|
for (const visual of this.visualEntities) {
|
||||||
|
const isSel = sel != null && visual.entity.playerId === sel
|
||||||
|
const teamColor = this._colorFor(visual.entity)
|
||||||
|
const mat = visual.line.material
|
||||||
|
if (sel == null) {
|
||||||
|
mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||||
|
} else if (isSel) {
|
||||||
|
mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30
|
||||||
|
} else {
|
||||||
|
mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||||
|
}
|
||||||
|
if (visual.model?.isGroup) {
|
||||||
|
visual.model.traverse((child) => {
|
||||||
|
if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(playerId) {
|
||||||
|
this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId
|
||||||
|
this._applyHighlight()
|
||||||
|
if (this.selectedPlayerId == null) return
|
||||||
|
const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible)
|
||||||
|
|| (this.visualEntities || []).find((v) => v.entity.playerId === playerId)
|
||||||
|
if (!visual) return
|
||||||
|
const p = this._interp(visual.entity, this.currentT)
|
||||||
|
if (!p) return
|
||||||
|
const world = this._toWorld(this._renderPoint(p))
|
||||||
|
const offset = this.camera.position.clone().sub(this.controls.target)
|
||||||
|
this.controls.target.copy(world)
|
||||||
|
this.camera.position.copy(world.clone().add(offset))
|
||||||
|
this.controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- public API ----------------------------------------------------------
|
||||||
|
|
||||||
|
setTime(t) {
|
||||||
|
this.currentT = t
|
||||||
|
if (!this.visualEntities) return
|
||||||
|
for (const visual of this.visualEntities) {
|
||||||
|
const p = this._interp(visual.entity, t)
|
||||||
|
visual.group.visible = Boolean(p)
|
||||||
|
if (!p) continue
|
||||||
|
visual.group.position.copy(this._toWorld(this._renderPoint(p)))
|
||||||
|
const dir = this._blendedDirection(visual, t)
|
||||||
|
visual.arrow.visible = Boolean(dir)
|
||||||
|
if (dir) {
|
||||||
|
visual.arrow.setDirection(dir)
|
||||||
|
if (visual.model?.isGroup) {
|
||||||
|
const flat = dir.clone(); flat.y = 0
|
||||||
|
if (flat.lengthSq() > 0) {
|
||||||
|
flat.normalize()
|
||||||
|
visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat)
|
||||||
|
visual.model.rotateY(Math.PI)
|
||||||
|
visual.model.rotateY(-Math.PI / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._updateCaptureZones()
|
||||||
|
this._updateKillLines()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode) {
|
||||||
|
const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground'
|
||||||
|
if (next === this._mode) return
|
||||||
|
this._mode = next
|
||||||
|
this.mapInfo = this._resolveMapInfo(next)
|
||||||
|
this.smoothByEntity.clear()
|
||||||
|
this._buildScene()
|
||||||
|
this._loadMapTexture()
|
||||||
|
this.setTime(this.currentT)
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const w = this.container.clientWidth || this.container.offsetWidth || 720
|
||||||
|
const h = this.container.clientHeight || w
|
||||||
|
this.renderer.setSize(w, h, false)
|
||||||
|
this.camera.aspect = w / Math.max(1, h)
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
}
|
||||||
|
|
||||||
|
_animate() {
|
||||||
|
if (this.disposed) return
|
||||||
|
this.controls.update()
|
||||||
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
this._raf = requestAnimationFrame(this._animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true
|
||||||
|
if (this._raf) cancelAnimationFrame(this._raf)
|
||||||
|
this.controls?.dispose()
|
||||||
|
this._clearGroup(this.mapGroup)
|
||||||
|
this._clearGroup(this.pathGroup)
|
||||||
|
this._clearGroup(this.markerGroup)
|
||||||
|
this._clearGroup(this.zoneGroup)
|
||||||
|
this._clearGroup(this.killLineGroup)
|
||||||
|
this.renderer?.dispose()
|
||||||
|
if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,6 +450,17 @@ h3 {
|
|||||||
color: var(--color-fury-white);
|
color: var(--color-fury-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rc-mode-btn-disabled,
|
||||||
|
.rc-mode-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-mode-btn-disabled:hover,
|
||||||
|
.rc-mode-btn:disabled:hover {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.rc-layout {
|
.rc-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(160px, 200px) min(720px, 60vh, 92vw) minmax(160px, 200px);
|
grid-template-columns: minmax(160px, 200px) min(720px, 60vh, 92vw) minmax(160px, 200px);
|
||||||
@@ -591,6 +602,20 @@ h3 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rc-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-stage .rc-canvas,
|
||||||
|
.rc-stage .rc-3d-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.rc-canvas {
|
.rc-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -600,6 +625,24 @@ h3 {
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rc-3d-container {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0d10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-3d-container .rc3d-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.rc-tickets {
|
.rc-tickets {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Generated
+7
@@ -15,6 +15,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3670,6 +3671,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user