diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c748aec..277b824 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves'
+import ReplayCanvasPanel from './ReplayCanvas'
const numberFormat = new Intl.NumberFormat('en-GB')
const dateFormat = new Intl.DateTimeFormat('en-GB', {
@@ -152,13 +153,36 @@ function displayTeamName(value) {
return String(value || '').trim()
}
-function ParticipantNames({ participants }) {
+function ParticipantNames({ participants, spread = false }) {
if (!participants.length) {
return
Participants unknown
}
+ // On wide rows (battle logs), spread the two teams to opposite ends with a
+ // centered "vs" so each side gets an equal share of the available width.
+ if (spread && participants.length === 2) {
+ const [first, second] = participants
+ return (
+
+
+ {first.name}
+
+
+ vs
+
+
+ {second.name}
+
+
+ )
+ }
+
return (
-
+
{participants.map((participant) => (
+
+
{battleEvents.length || logs.battle_log.length ? (
Battle Log
@@ -3275,7 +3301,7 @@ function BattleLogsPage({ live, matches, navigate }) {
{matches.map((match) => (
navigate(gamePath(match.session_id))}
type="button"
@@ -3286,7 +3312,7 @@ function BattleLogsPage({ live, matches, navigate }) {
{formatDate(match.timestamp)} ยท {match.session_id}
-
+
{formatMatchSize(match.player_count)}
))}
diff --git a/frontend/src/ReplayCanvas.jsx b/frontend/src/ReplayCanvas.jsx
new file mode 100644
index 0000000..50a2cdf
--- /dev/null
+++ b/frontend/src/ReplayCanvas.jsx
@@ -0,0 +1,1098 @@
+import { useEffect, useRef, useState } from 'react'
+
+const RC = {
+ TRAIL_MS: 18000,
+ AIR_TRAIL_MS: 4000,
+ DRONE_TRAIL_MS: 2000,
+ KILL_TTL: 8000,
+ DMG_TTL: 4000,
+ GHOST_TTL: 3000,
+ DEFAULT_SPEED: 4,
+ WIN: '#00c800',
+ LOSE: '#dc1e1e',
+ WIN_TRAIL: 'rgba(0,120,0,',
+ LOSE_TRAIL: 'rgba(132,18,18,',
+ DOT_R: 5,
+ AIR_R: 4,
+ DRONE_R: 3,
+}
+
+const CAP_STROKE_PX = 3
+const CAP_ICON_ALPHA = 0.35
+const CAP_ICON_MIN_SIZE = 10
+const CAP_FILL_ALPHA = 0.5
+
+function loadImage(src) {
+ return new Promise((resolve) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => resolve(img)
+ img.onerror = () => resolve(null)
+ img.src = src
+ })
+}
+
+function esc(value) {
+ const div = document.createElement('div')
+ div.textContent = String(value || '')
+ return div.innerHTML
+}
+
+class ReplayCanvasEngine {
+ constructor(containerEl, data) {
+ this.container = containerEl
+ this.data = data
+ this.playing = false
+ this.speed = RC.DEFAULT_SPEED
+ this.currentTime = 0
+ this.tStart = Infinity
+ this.tEnd = -Infinity
+ this.lastFrameTime = 0
+ this.highlightedPlayerId = null
+ this.animFrameId = null
+ this.canvasSize = 720
+ this._groundCoords = data.levelCoords
+ this._tankMapCoords = data.tankMapCoords || data.levelCoords
+ this._airCoords = data.mapCoords || null
+ this._fullMapLevel = data.fullMapLevel || null
+ this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : []
+ this._captureState = data.captureState && Object.keys(data.captureState).length ? data.captureState : null
+ this._tickets = data.tickets && Object.keys(data.tickets).length ? data.tickets : null
+ this._winnerSlot = Number(data.winnerSlot) || 0
+ this._mode = 'ground'
+ this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }
+ this.players = {}
+ for (const p of data.players || []) this.players[p.id] = p
+
+ const hasAircraft = (data.entities || []).some((e) => e.type === 'aircraft')
+ this.hasAirMode = Boolean(this._airCoords && this._fullMapLevel && hasAircraft)
+ if (!this._groundCoords && this._airCoords) this._groundCoords = this._airCoords
+ if (!this._tankMapCoords && this._groundCoords) this._tankMapCoords = this._groundCoords
+ this._applyCoords(this.hasAirMode && !this._hasGroundEntities(data.entities) ? 'air' : 'ground')
+
+ this.entities = []
+ for (const e of data.entities || []) {
+ if (!e.path || !e.path.length) continue
+ const times = new Float64Array(e.path.length)
+ const positions = new Float32Array(e.path.length * 2)
+ for (let i = 0; i < e.path.length; i++) {
+ times[i] = Number(e.path[i].t)
+ positions[i * 2] = Number(e.path[i].x)
+ positions[i * 2 + 1] = Number(e.path[i].z)
+ }
+ if (times[0] < this.tStart) this.tStart = times[0]
+ if (times[times.length - 1] > this.tEnd) this.tEnd = times[times.length - 1]
+ const isWinner = e.playerId > 0
+ ? this.players[e.playerId]?.team === data.teamWon
+ : e.droneTeam === data.teamWon
+ this.entities.push({
+ ...e,
+ times,
+ positions,
+ isWinner,
+ deathTime: null,
+ ghostEndTime: null,
+ deathPos: null,
+ _lastHeading: null,
+ })
+ }
+ if (!Number.isFinite(this.tStart) || !Number.isFinite(this.tEnd)) {
+ this.tStart = 0
+ this.tEnd = 1
+ }
+ this._computeDeaths()
+ this.currentTime = this.tStart
+ }
+
+ _hasGroundEntities(entities) {
+ return Array.isArray(entities) && entities.some((e) => e.type === 'ground')
+ }
+
+ _applyCoords(mode) {
+ this._mode = mode === 'air' && this._airCoords ? 'air' : 'ground'
+ const coords = this._mode === 'air' ? this._airCoords : this._groundCoords
+ this.x0 = Number(coords?.x0 || 0)
+ this.z0 = Number(coords?.z0 || 0)
+ this.xRange = Number(coords?.x1 || 1) - this.x0
+ this.zRange = Number(coords?.z1 || 1) - this.z0
+ if (!this.xRange) this.xRange = 1
+ if (!this.zRange) this.zRange = 1
+ this._updateMapSourceRect()
+ }
+
+ async init() {
+ this._buildDOM()
+ await Promise.all([this._loadMap(), this._loadEntityIcons()])
+ this.playing = true
+ this.playBtn.textContent = 'Pause'
+ this.lastFrameTime = performance.now()
+ this._tick = this._tick.bind(this)
+ this.animFrameId = requestAnimationFrame(this._tick)
+ }
+
+ _buildDOM() {
+ this.container.innerHTML = ''
+ const layout = document.createElement('div')
+ layout.className = 'rc-layout'
+
+ this.leftPanel = document.createElement('div')
+ this.leftPanel.className = 'rc-panel rc-panel-win'
+ this._buildTeamPanel(this.leftPanel, true)
+
+ const center = document.createElement('div')
+ center.className = 'rc-center'
+ this._buildTicketsBar(center)
+
+ this.canvas = document.createElement('canvas')
+ this.canvas.width = this.canvasSize
+ this.canvas.height = this.canvasSize
+ this.canvas.className = 'rc-canvas'
+ this.ctx = this.canvas.getContext('2d')
+ center.appendChild(this.canvas)
+
+ const controls = document.createElement('div')
+ controls.className = 'rc-controls'
+ controls.innerHTML = `
+ Play
+
+ 1x
+ 2x
+ 4x
+ 8x
+
+
+ 0:00 / 0:00
+ `
+ center.appendChild(controls)
+
+ const logWrap = document.createElement('div')
+ logWrap.className = 'rc-log-wrap'
+ logWrap.innerHTML = '
'
+ center.appendChild(logWrap)
+ this.battleLog = logWrap.querySelector('.rc-log')
+ this._buildEventList()
+
+ this.rightPanel = document.createElement('div')
+ this.rightPanel.className = 'rc-panel rc-panel-lose'
+ this._buildTeamPanel(this.rightPanel, false)
+
+ layout.appendChild(this.leftPanel)
+ layout.appendChild(center)
+ layout.appendChild(this.rightPanel)
+ this.container.appendChild(layout)
+
+ this.playBtn = controls.querySelector('.rc-play')
+ this.scrubber = controls.querySelector('.rc-scrub')
+ this.timeDisplay = controls.querySelector('.rc-time')
+ this.playBtn.addEventListener('click', () => this._togglePlay())
+ this.scrubber.addEventListener('input', () => {
+ this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart)
+ this._updatePanelDeathStates()
+ this._updateBattleLog()
+ this._updateTicketsBar(this.currentTime)
+ this.render()
+ })
+ controls.querySelectorAll('.rc-sp').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ controls.querySelectorAll('.rc-sp').forEach((b) => b.classList.remove('active'))
+ btn.classList.add('active')
+ this.speed = Number(btn.dataset.speed) || RC.DEFAULT_SPEED
+ })
+ })
+
+ this._mouseOnCanvas = false
+ this._mouseX = 0
+ this._mouseY = 0
+ this.canvas.addEventListener('mousemove', (ev) => {
+ this._mouseOnCanvas = true
+ const rect = this.canvas.getBoundingClientRect()
+ this._mouseX = (ev.clientX - rect.left) * (this.canvasSize / rect.width)
+ this._mouseY = (ev.clientY - rect.top) * (this.canvasSize / rect.height)
+ })
+ this.canvas.addEventListener('mouseleave', () => {
+ this._mouseOnCanvas = false
+ this._setHighlight(null)
+ })
+
+ this.mapCanvas = document.createElement('canvas')
+ this.mapCanvas.width = this.canvasSize
+ this.mapCanvas.height = this.canvasSize
+ this.mapCtx = this.mapCanvas.getContext('2d')
+ }
+
+ _buildTeamPanel(panel, isWinner) {
+ const teamEntities = this.entities.filter((e) => e.playerId > 0 && e.isWinner === isWinner)
+ const seen = new Set()
+ const unique = []
+ for (const e of teamEntities) {
+ if (e.type === 'ground' && !seen.has(e.playerId)) {
+ seen.add(e.playerId)
+ unique.push(e)
+ }
+ }
+ for (const e of teamEntities) {
+ if (!seen.has(e.playerId)) {
+ seen.add(e.playerId)
+ unique.push(e)
+ }
+ }
+
+ const color = isWinner ? RC.WIN : RC.LOSE
+ const firstPlayer = unique.length ? this.players[unique[0].playerId] : null
+ const clanTag = firstPlayer?.clan || ''
+ const label = clanTag ? `${esc(clanTag)} ` : (isWinner ? 'Winners' : 'Losers')
+ let html = `${label}
`
+ for (const ent of unique) {
+ const p = this.players[ent.playerId]
+ const name = p ? esc(p.name) : '?'
+ const veh = esc(ent.vehicleName)
+ const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium')
+ html += `
+
+
+ ${name}
+ ${veh}
+
+
+
`
+ }
+ html += '
'
+ panel.innerHTML = html
+ panel.querySelectorAll('.rc-row').forEach((row) => {
+ row.addEventListener('mouseenter', () => {
+ const ent = this.entities.find((e) => e.entityIndex === Number(row.dataset.entityIndex))
+ if (ent && !this._isEntityGone(ent, this.currentTime)) this._setHighlight(Number(row.dataset.playerId))
+ })
+ row.addEventListener('mouseleave', () => this._setHighlight(null))
+ })
+ }
+
+ _buildEventList() {
+ this._events = []
+ for (const k of this.data.kills || []) {
+ const killer = this.players[k.killerId]
+ let victimName = '?'
+ let victimTeam = -1
+ if (k.victimId && this.players[k.victimId]) {
+ victimName = this.players[k.victimId].name
+ victimTeam = this.players[k.victimId].team
+ } else if (k.victimVehicle) {
+ victimName = k.victimVehicle
+ }
+ let html
+ if (!killer) {
+ const victimIsWin = victimTeam === this.data.teamWon
+ html = `${esc(victimName)} crashed `
+ } else {
+ const killerIsWin = killer.team === this.data.teamWon
+ html = `${esc(killer.name)} destroyed ${esc(victimName)} ${k.weapon ? `[${esc(k.weapon)}] ` : ''}`
+ }
+ this._events.push({ time: k.time, type: 'kill', html })
+ }
+ for (const dm of this.data.damages || []) {
+ const atk = this.players[dm.offenderId]
+ const vic = this.players[dm.offendedId]
+ if (!atk || !vic) continue
+ const atkIsWin = atk.team === this.data.teamWon
+ this._events.push({
+ time: dm.time,
+ type: 'damage',
+ html: `${esc(atk.name)} hit ${esc(vic.name)} `,
+ })
+ }
+ this._events.sort((a, b) => a.time - b.time)
+ this._lastLogIndex = -1
+ }
+
+ worldToPixel(x, z) {
+ return [
+ ((x - this.x0) / this.xRange) * this.canvasSize,
+ ((this.z0 + this.zRange - z) / this.zRange) * this.canvasSize,
+ ]
+ }
+
+ getPositionAtTime(entity, time) {
+ const { times, positions } = entity
+ if (time < times[0] || time > times[times.length - 1]) return null
+ let lo = 0
+ let hi = times.length - 1
+ while (lo < hi - 1) {
+ const mid = (lo + hi) >> 1
+ if (times[mid] <= time) lo = mid
+ else hi = mid
+ }
+ const t0 = times[lo]
+ const t1 = times[hi]
+ const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0
+ const i0 = lo * 2
+ const i1 = hi * 2
+ return this.worldToPixel(
+ positions[i0] + (positions[i1] - positions[i0]) * frac,
+ positions[i0 + 1] + (positions[i1 + 1] - positions[i0 + 1]) * frac,
+ )
+ }
+
+ getHeadingAtTime(entity, time) {
+ const windows = this._mode === 'air' ? [1800, 1400, 1000, 650, 350] : [700, 500, 300]
+ for (const dt of windows) {
+ const p0 = this.getPositionAtTime(entity, time - dt) || this.getPositionAtTime(entity, time)
+ const p1 = this.getPositionAtTime(entity, time + dt) || this.getPositionAtTime(entity, time)
+ if (!p0 || !p1) continue
+ const dx = p1[0] - p0[0]
+ const dy = p1[1] - p0[1]
+ const minDist = this._mode === 'air' ? 2 : 0.4
+ if (Math.hypot(dx, dy) < minDist) continue
+ const raw = Math.atan2(dx, -dy)
+ if (entity._lastHeading === null || entity._lastHeading === undefined) {
+ entity._lastHeading = raw
+ } else {
+ let delta = raw - entity._lastHeading
+ while (delta > Math.PI) delta -= Math.PI * 2
+ while (delta < -Math.PI) delta += Math.PI * 2
+ const maxTurn = this._mode === 'air' ? 0.45 : 0.8
+ delta = Math.max(-maxTurn, Math.min(maxTurn, delta))
+ entity._lastHeading += delta
+ }
+ return entity._lastHeading
+ }
+ return entity._lastHeading ?? null
+ }
+
+ _computeDeaths() {
+ for (const ent of this.entities) {
+ ent.deathTime = null
+ ent.ghostEndTime = null
+ ent.deathPos = null
+ }
+ for (const k of this.data.kills || []) {
+ for (const ent of this.entities) {
+ const matched = (k.victimEntityIndex && ent.entityIndex === k.victimEntityIndex)
+ || (k.victimId && ent.playerId === k.victimId && ent.playerId !== 0)
+ if (matched && ent.deathTime === null) {
+ ent.deathTime = k.time
+ ent.ghostEndTime = k.time + RC.GHOST_TTL
+ if (k.victimPos) ent.deathPos = this.worldToPixel(k.victimPos.x, k.victimPos.z)
+ break
+ }
+ }
+ }
+ }
+
+ _entityScreenPos(entity, time) {
+ if (entity.deathTime !== null && time >= entity.deathTime) return entity.deathPos
+ return this.getPositionAtTime(entity, time)
+ }
+
+ _isEntityDead(entity, time) {
+ return entity.deathTime !== null && time >= entity.deathTime
+ }
+
+ _isEntityGone(entity, time) {
+ return entity.ghostEndTime !== null && time >= entity.ghostEndTime
+ }
+
+ _setHighlight(playerId) {
+ if (this.highlightedPlayerId === playerId) return
+ this.highlightedPlayerId = playerId
+ this.container.querySelectorAll('.rc-row').forEach((row) => {
+ row.classList.toggle('rc-hl', Number(row.dataset.playerId) === playerId)
+ })
+ if (!this.playing) this.render()
+ }
+
+ _updatePanelDeathStates() {
+ const t = this.currentTime
+ this.container.querySelectorAll('.rc-row').forEach((row) => {
+ const ent = this.entities.find((e) => e.entityIndex === Number(row.dataset.entityIndex))
+ if (!ent) return
+ const dead = this._isEntityDead(ent, t)
+ const gone = this._isEntityGone(ent, t)
+ row.classList.toggle('rc-dead', dead)
+ row.classList.toggle('rc-gone', gone)
+ const status = row.querySelector('.rc-row-status')
+ status.textContent = dead || gone ? 'x' : ''
+ })
+ }
+
+ _updateBattleLog() {
+ const t = this.currentTime
+ let idx = -1
+ for (let i = 0; i < this._events.length; i++) {
+ if (this._events[i].time <= t) idx = i
+ else break
+ }
+ if (idx === this._lastLogIndex) return
+ this._lastLogIndex = idx
+ this.battleLog.innerHTML = ''
+ for (let i = 0; i <= idx; i++) {
+ const ev = this._events[i]
+ const el = document.createElement('div')
+ el.className = `rc-ev rc-ev-${ev.type}`
+ const elapsed = Math.max(0, (ev.time - this.tStart) / 1000)
+ const mm = Math.floor(elapsed / 60)
+ const ss = Math.floor(elapsed % 60)
+ el.innerHTML = `${mm}:${String(ss).padStart(2, '0')} ${ev.html}`
+ this.battleLog.appendChild(el)
+ }
+ this.battleLog.scrollTop = this.battleLog.scrollHeight
+ }
+
+ async _loadEntityIcons() {
+ this._iconCache = {}
+ const keysToLoad = new Set()
+ for (const ent of this.entities) {
+ if (ent.miniIcon) {
+ const miniKey = ent.miniIcon.replace('mini:', '')
+ keysToLoad.add(miniKey)
+ ent._canvasIconKey = miniKey
+ } else if (ent.iconKey) {
+ keysToLoad.add(ent.iconKey)
+ ent._canvasIconKey = ent.iconKey
+ }
+ }
+ await Promise.all([...keysToLoad].map(async (key) => {
+ const img = await loadImage(`/api/icons/type/${key}`)
+ if (img) this._iconCache[key] = img
+ }))
+ }
+
+ async _loadMap() {
+ const level = this.data.mission?.level
+ this._groundMapImg = level ? await loadImage(`/api/match/minimap/${level}`) : null
+ this._airMapImg = this._fullMapLevel ? await loadImage(`/api/match/minimap/${this._fullMapLevel}?type=full`) : null
+ this._capIconCache = { cap_icon: await loadImage('/api/icons/type/cap_icon') }
+ const capLetters = new Set()
+ for (let i = 0; i < this.captureAreas.length && i < 26; i++) capLetters.add(String.fromCharCode(97 + i))
+ await Promise.all([...capLetters].map(async (letter) => {
+ this._capIconCache[`capture_${letter}`] = await loadImage(`/api/icons/type/capture_${letter}`)
+ }))
+ this._drawMapToCanvas()
+ }
+
+ _updateMapSourceRect() {
+ if (this._mode !== 'ground') {
+ this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }
+ return
+ }
+ const base = this._tankMapCoords || this._groundCoords
+ const render = this._groundCoords
+ const bx0 = Number(base?.x0)
+ const bz0 = Number(base?.z0)
+ const bx1 = Number(base?.x1)
+ const bz1 = Number(base?.z1)
+ const rx0 = Number(render?.x0)
+ const rz0 = Number(render?.z0)
+ const rx1 = Number(render?.x1)
+ const rz1 = Number(render?.z1)
+ const dx = bx1 - bx0
+ const dz = bz1 - bz0
+ if (!Number.isFinite(dx) || !Number.isFinite(dz) || dx === 0 || dz === 0) {
+ this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }
+ return
+ }
+ const u0 = (Math.min(rx0, rx1) - bx0) / dx
+ const u1 = (Math.max(rx0, rx1) - bx0) / dx
+ const v0 = (Math.min(rz0, rz1) - bz0) / dz
+ const v1 = (Math.max(rz0, rz1) - bz0) / 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
+ this._mapSrc = w > 0 && h > 0 ? { u: uMin, v: 1 - vMax, w, h } : { u: 0, v: 0, w: 1, h: 1 }
+ }
+
+ _drawMapToCanvas() {
+ const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg
+ this.mapCtx.clearRect(0, 0, this.canvasSize, this.canvasSize)
+ if (!img) {
+ this.mapCtx.fillStyle = '#111'
+ this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize)
+ return
+ }
+ const { u, v, w, h } = this._mapSrc
+ this.mapCtx.drawImage(
+ img,
+ u * img.naturalWidth,
+ v * img.naturalHeight,
+ w * img.naturalWidth,
+ h * img.naturalHeight,
+ 0,
+ 0,
+ this.canvasSize,
+ this.canvasSize,
+ )
+ this._drawCaptureAreasOnMap()
+ }
+
+ _capOutlinePoints(cap) {
+ const tm = cap?.tm
+ if (!tm || !Array.isArray(tm.a0) || !Array.isArray(tm.a2) || !Array.isArray(tm.center)) return []
+ const ax0 = Number(tm.a0[0])
+ const az0 = Number(tm.a0[2])
+ const ax2 = Number(tm.a2[0])
+ const az2 = Number(tm.a2[2])
+ const cx = Number(tm.center[0])
+ const cz = Number(tm.center[2])
+ if (![ax0, az0, ax2, az2, cx, cz].every(Number.isFinite)) return []
+ const capType = String(cap?.type || '').toLowerCase()
+ const points = []
+ if (capType === 'sphere' || capType === 'cylinder') {
+ for (let i = 0; i < 64; i++) {
+ const t = (2 * Math.PI * i) / 64
+ points.push(this.worldToPixel(cx + Math.cos(t) * ax0 + Math.sin(t) * ax2, cz + Math.cos(t) * az0 + Math.sin(t) * az2))
+ }
+ } else if (capType === 'box') {
+ for (const [sx, sz] of [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]) {
+ points.push(this.worldToPixel(cx + sx * ax0 + sz * ax2, cz + sx * az0 + sz * az2))
+ }
+ }
+ return points
+ }
+
+ _capCircleRadiusPx(cap) {
+ const rr = Math.max(0, Number(cap?.radius || 0))
+ return Math.max(8, Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5)))
+ }
+
+ _capIconForLabel(label) {
+ const key = String(label || '').toLowerCase()
+ return this._capIconCache?.[`capture_${key}`] || this._capIconCache?.cap_icon || null
+ }
+
+ _drawCaptureAreasOnMap() {
+ if (!this.captureAreas.length) return
+ const labels = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+ for (let i = 0; i < this.captureAreas.length; i++) {
+ const cap = this.captureAreas[i]
+ const label = labels[i] || String(i + 1)
+ const [px, py] = this.worldToPixel(Number(cap?.x || 0), Number(cap?.z || 0))
+ const outline = this._capOutlinePoints(cap)
+ let iconSize = 18
+ this.mapCtx.strokeStyle = '#fff'
+ this.mapCtx.lineWidth = CAP_STROKE_PX
+ this.mapCtx.beginPath()
+ if (outline.length >= 3) {
+ this.mapCtx.moveTo(outline[0][0], outline[0][1])
+ for (let p = 1; p < outline.length; p++) this.mapCtx.lineTo(outline[p][0], outline[p][1])
+ this.mapCtx.closePath()
+ let area2 = 0
+ for (let p = 0; p < outline.length; p++) {
+ const a = outline[p]
+ const b = outline[(p + 1) % outline.length]
+ area2 += a[0] * b[1] - b[0] * a[1]
+ }
+ iconSize = Math.max(CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(Math.abs(area2) * 0.25))))
+ } else {
+ const rp = this._capCircleRadiusPx(cap)
+ if (px < -rp || py < -rp || px > this.canvasSize + rp || py > this.canvasSize + rp) continue
+ this.mapCtx.arc(px, py, rp, 0, Math.PI * 2)
+ iconSize = Math.max(CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(Math.PI * rp * rp / 2))))
+ }
+ this.mapCtx.stroke()
+ const img = this._capIconForLabel(label)
+ if (img?.naturalWidth) {
+ this.mapCtx.save()
+ this.mapCtx.globalAlpha = CAP_ICON_ALPHA
+ this.mapCtx.drawImage(img, px - iconSize / 2, py - iconSize / 2, iconSize, iconSize)
+ this.mapCtx.restore()
+ }
+ }
+ }
+
+ _capSeriesForIndex(i) {
+ if (!this._captureState) return null
+ const letter = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i]
+ return letter ? this._captureState[letter] : null
+ }
+
+ _interpSeries(series, t, step = false) {
+ if (!series || !series.length) return null
+ if (t <= series[0][0]) return series[0][1]
+ const last = series[series.length - 1]
+ if (t >= last[0]) return last[1]
+ for (let i = 1; i < series.length; i++) {
+ if (series[i][0] >= t) {
+ if (step) return series[i - 1][1]
+ const t0 = series[i - 1][0]
+ const v0 = series[i - 1][1]
+ const t1 = series[i][0]
+ const v1 = series[i][1]
+ const f = t1 === t0 ? 0 : (t - t0) / (t1 - t0)
+ return v0 + (v1 - v0) * f
+ }
+ }
+ return last[1]
+ }
+
+ _drawCaptureState(ctx, t) {
+ if (!this._captureState) return
+ for (let i = 0; i < this.captureAreas.length; i++) {
+ const series = this._capSeriesForIndex(i)
+ if (!series) continue
+ const val = this._interpSeries(series, t, true)
+ if (val === null) continue
+ const frac = Math.min(1, Math.abs(val) / 100)
+ if (frac <= 0.01) continue
+ const ownerSlot = val > 0 ? 2 : 1
+ const color = ownerSlot === this._winnerSlot ? RC.WIN : RC.LOSE
+ const cap = this.captureAreas[i]
+ const [cx, cy] = this.worldToPixel(Number(cap?.x || 0), Number(cap?.z || 0))
+ const outline = this._capOutlinePoints(cap)
+ ctx.save()
+ ctx.beginPath()
+ if (outline.length >= 3) {
+ ctx.moveTo(outline[0][0], outline[0][1])
+ for (let p = 1; p < outline.length; p++) ctx.lineTo(outline[p][0], outline[p][1])
+ ctx.closePath()
+ } else {
+ ctx.arc(cx, cy, this._capCircleRadiusPx(cap), 0, Math.PI * 2)
+ }
+ ctx.clip()
+ const start = -Math.PI / 2
+ const end = start + 2 * Math.PI * frac
+ ctx.globalAlpha = CAP_FILL_ALPHA
+ ctx.fillStyle = color
+ ctx.beginPath()
+ ctx.moveTo(cx, cy)
+ ctx.arc(cx, cy, this.canvasSize, start, end)
+ ctx.closePath()
+ ctx.fill()
+ ctx.restore()
+ }
+ }
+
+ _buildTicketsBar(center) {
+ if (!this._tickets) return
+ const winSlot = this._winnerSlot || 1
+ this._tkWinSlot = winSlot
+ this._tkLoseSlot = winSlot === 1 ? 2 : 1
+ const bar = document.createElement('div')
+ bar.className = 'rc-tickets'
+ bar.innerHTML = '0 0 '
+ center.appendChild(bar)
+ this.ticketsBar = bar
+ this._tkWinFill = bar.querySelector('.rc-tk-fill-win')
+ this._tkLoseFill = bar.querySelector('.rc-tk-fill-lose')
+ this._tkWinVal = bar.querySelector('.rc-tk-val-win')
+ this._tkLoseVal = bar.querySelector('.rc-tk-val-lose')
+ }
+
+ _updateTicketsBar(t) {
+ if (!this._tickets || !this.ticketsBar) return
+ const w = Math.max(0, Math.round(this._interpSeries(this._tickets[String(this._tkWinSlot)], t) ?? 0))
+ const l = Math.max(0, Math.round(this._interpSeries(this._tickets[String(this._tkLoseSlot)], t) ?? 0))
+ const total = w + l
+ const wPct = total > 0 ? (w / total) * 100 : 50
+ this._tkWinFill.style.width = `${wPct.toFixed(1)}%`
+ this._tkLoseFill.style.width = `${(100 - wPct).toFixed(1)}%`
+ this._tkWinVal.textContent = String(w)
+ this._tkLoseVal.textContent = String(l)
+ this.container.classList.toggle('rc-game-over', l <= 0)
+ }
+
+ setMode(mode) {
+ if (mode === this._mode) return
+ if (mode === 'air' && !this.hasAirMode) return
+ this._applyCoords(mode)
+ for (const ent of this.entities) ent._lastHeading = null
+ this._drawMapToCanvas()
+ this._computeDeaths()
+ this.render()
+ }
+
+ _togglePlay() {
+ this.playing = !this.playing
+ this.playBtn.textContent = this.playing ? 'Pause' : 'Play'
+ if (this.playing) {
+ if (this.currentTime >= this.tEnd) this.currentTime = this.tStart
+ this.lastFrameTime = performance.now()
+ }
+ }
+
+ _tick(now) {
+ if (this.playing) {
+ const dt = now - this.lastFrameTime
+ this.lastFrameTime = now
+ this.currentTime += dt * this.speed
+ if (this.currentTime >= this.tEnd) {
+ this.currentTime = this.tEnd
+ this.playing = false
+ this.playBtn.textContent = 'Play'
+ }
+ }
+ this.render()
+ this._updateTicketsBar(this.currentTime)
+ this._updateControls()
+ if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
+ this._updatePanelDeathStates()
+ this._updateBattleLog()
+ this._lastPanelUpdate = now
+ }
+ this.animFrameId = requestAnimationFrame(this._tick)
+ }
+
+ _updateControls() {
+ const frac = this.tEnd > this.tStart ? (this.currentTime - this.tStart) / (this.tEnd - this.tStart) : 0
+ const clamped = Math.max(0, Math.min(1, frac))
+ this.scrubber.value = Math.round(clamped * 1000)
+ this.scrubber.style.setProperty('--rc-progress', `${(clamped * 100).toFixed(1)}%`)
+ const cur = Math.max(0, (this.currentTime - this.tStart) / 1000)
+ const total = Math.max(0, (this.tEnd - this.tStart) / 1000)
+ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
+ this.timeDisplay.textContent = `${fmt(cur)} / ${fmt(total)}`
+ }
+
+ _updateCanvasHighlight() {
+ if (!this._mouseOnCanvas) return
+ let bestId = null
+ let bestDist = 400
+ for (const ent of this.entities) {
+ if (ent.playerId === 0 || this._isEntityGone(ent, this.currentTime)) continue
+ const pos = this._entityScreenPos(ent, this.currentTime)
+ if (!pos) continue
+ const dx = pos[0] - this._mouseX
+ const dy = pos[1] - this._mouseY
+ const dist = dx * dx + dy * dy
+ if (dist < bestDist) {
+ bestDist = dist
+ bestId = ent.playerId
+ }
+ }
+ this._setHighlight(bestId)
+ }
+
+ render() {
+ const ctx = this.ctx
+ if (!ctx || !this.mapCanvas) return
+ const t = this.currentTime
+ this._updateCanvasHighlight()
+ ctx.drawImage(this.mapCanvas, 0, 0)
+ this._drawCaptureState(ctx, t)
+ this._drawTrails(ctx, t)
+ this._drawDamageLines(ctx, t)
+ this._drawKillLines(ctx, t)
+ this._drawEntities(ctx, t)
+ }
+
+ _drawTrails(ctx, time) {
+ for (const ent of this.entities) {
+ if (this._isEntityGone(ent, time)) continue
+ const endT = ent.deathTime !== null ? Math.min(time, ent.deathTime) : time
+ const trailLen = ent.type === 'ground' ? RC.TRAIL_MS : ent.type === 'aircraft' ? (this._mode === 'air' ? RC.TRAIL_MS : RC.AIR_TRAIL_MS) : RC.DRONE_TRAIL_MS
+ const tMin = endT - trailLen
+ const baseColor = ent.isWinner ? RC.WIN_TRAIL : RC.LOSE_TRAIL
+ ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5)
+ ctx.lineCap = 'round'
+ if (this._mode === 'air' && ent.type === 'aircraft') {
+ let prev = null
+ for (let tt = Math.max(tMin, ent.times[0]); tt <= Math.min(endT, ent.times[ent.times.length - 1]); tt += 200) {
+ const pos = this.getPositionAtTime(ent, tt)
+ if (!pos) continue
+ if (prev) {
+ const age = time - tt
+ const alpha = Math.max(0.08, 1 - age / trailLen)
+ ctx.strokeStyle = `${baseColor}${alpha.toFixed(2)})`
+ ctx.beginPath()
+ ctx.moveTo(prev[0], prev[1])
+ ctx.lineTo(pos[0], pos[1])
+ ctx.stroke()
+ }
+ prev = pos
+ }
+ continue
+ }
+ let prev = null
+ for (let i = 0; i < ent.times.length; i++) {
+ if (ent.times[i] < tMin) continue
+ if (ent.times[i] > endT) break
+ const pos = this.worldToPixel(ent.positions[i * 2], ent.positions[i * 2 + 1])
+ if (prev) {
+ const age = time - ent.times[i]
+ const alpha = Math.max(0.08, 1 - age / trailLen)
+ ctx.strokeStyle = `${baseColor}${alpha.toFixed(2)})`
+ ctx.beginPath()
+ ctx.moveTo(prev[0], prev[1])
+ ctx.lineTo(pos[0], pos[1])
+ ctx.stroke()
+ }
+ prev = pos
+ }
+ }
+ }
+
+ _drawDamageLines(ctx, time) {
+ for (const dm of this.data.damages || []) {
+ const age = time - dm.time
+ if (age < 0 || age > RC.DMG_TTL) continue
+ const attacker = this.entities.find((e) => e.playerId === dm.offenderId)
+ const victim = this.entities.find((e) => e.playerId === dm.offendedId)
+ if (!attacker || !victim) continue
+ const aPos = this.getPositionAtTime(attacker, dm.time)
+ const vPos = this.getPositionAtTime(victim, dm.time)
+ if (!aPos || !vPos) continue
+ const alpha = Math.max(0, 1 - age / RC.DMG_TTL)
+ ctx.globalAlpha = alpha * 0.4
+ ctx.strokeStyle = '#ffcc44'
+ ctx.lineWidth = 1
+ ctx.setLineDash([3, 4])
+ ctx.beginPath()
+ ctx.moveTo(aPos[0], aPos[1])
+ ctx.lineTo(vPos[0], vPos[1])
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.globalAlpha = 1
+ }
+ }
+
+ _drawKillLines(ctx, time) {
+ for (const k of this.data.kills || []) {
+ const age = time - k.time
+ if (age < 0 || age > RC.KILL_TTL || !k.killerPos || !k.victimPos) continue
+ const alpha = Math.max(0, 1 - age / RC.KILL_TTL)
+ const [kx, ky] = this.worldToPixel(k.killerPos.x, k.killerPos.z)
+ const [vx, vy] = this.worldToPixel(k.victimPos.x, k.victimPos.z)
+ ctx.globalAlpha = alpha * 0.6
+ ctx.strokeStyle = '#ff3333'
+ ctx.lineWidth = 1.5
+ ctx.setLineDash([4, 3])
+ ctx.beginPath()
+ ctx.moveTo(kx, ky)
+ ctx.lineTo(vx, vy)
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.globalAlpha = alpha * 0.9
+ ctx.strokeStyle = '#ff3333'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(vx - 5, vy - 5)
+ ctx.lineTo(vx + 5, vy + 5)
+ ctx.moveTo(vx + 5, vy - 5)
+ ctx.lineTo(vx - 5, vy + 5)
+ ctx.stroke()
+ if (k.weapon && alpha > 0.4) {
+ ctx.font = '600 9px system-ui, sans-serif'
+ ctx.fillStyle = `rgba(255,230,100,${(alpha * 0.85).toFixed(2)})`
+ ctx.fillText(k.weapon, (kx + vx) / 2 + 6, (ky + vy) / 2 - 6)
+ }
+ ctx.globalAlpha = 1
+ }
+ }
+
+ _getTintedIcon(iconKey, color, size) {
+ const cacheKey = `${iconKey}_${color}_${size}`
+ if (!this._tintCache) this._tintCache = {}
+ if (this._tintCache[cacheKey]) return this._tintCache[cacheKey]
+ const img = this._iconCache?.[iconKey]
+ if (!img?.naturalWidth) return null
+ const c = document.createElement('canvas')
+ c.width = size
+ c.height = size
+ const cx = c.getContext('2d')
+ const [dx, dy, dw, dh] = this._containedImageRect(img, size)
+ cx.drawImage(img, dx, dy, dw, dh)
+ cx.globalCompositeOperation = 'source-atop'
+ cx.fillStyle = color
+ cx.fillRect(0, 0, size, size)
+ cx.globalCompositeOperation = 'source-over'
+ this._tintCache[cacheKey] = c
+ return c
+ }
+
+ _containedImageRect(img, size) {
+ const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1
+ let w = size
+ let h = size
+ if (ratio > 1) h = size / ratio
+ else w = size * ratio
+ return [(size - w) / 2, (size - h) / 2, w, h]
+ }
+
+ _drawContainedIcon(ctx, img, x, y, size) {
+ const [dx, dy, dw, dh] = this._containedImageRect(img, size)
+ ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh)
+ }
+
+ _drawEntities(ctx, time) {
+ const hl = this.highlightedPlayerId
+ for (const ent of this.entities) {
+ if (!this._isEntityDead(ent, time) || this._isEntityGone(ent, time)) continue
+ const pos = ent.deathPos
+ if (!pos) continue
+ const fade = 1 - (time - ent.deathTime) / RC.GHOST_TTL
+ ctx.globalAlpha = Math.max(0, fade * 0.5)
+ ctx.fillStyle = '#333'
+ const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R
+ ctx.beginPath()
+ ctx.arc(pos[0], pos[1], r, 0, Math.PI * 2)
+ ctx.fill()
+ ctx.globalAlpha = 1
+ }
+ for (const ent of this.entities) {
+ if (this._isEntityDead(ent, time)) continue
+ const pos = this.getPositionAtTime(ent, time)
+ if (!pos) continue
+ const [px, py] = pos
+ if (px < -20 || py < -20 || px > this.canvasSize + 20 || py > this.canvasSize + 20) continue
+ let alpha = 1
+ if (hl !== null && ent.playerId !== hl && ent.playerId !== 0) alpha = 0.25
+ const color = ent.isWinner ? RC.WIN : RC.LOSE
+ const iconSize = ent.type === 'ground' ? 12 : ent.type === 'aircraft' ? 20 : 14
+ const iconImg = this._iconCache?.[ent._canvasIconKey]
+ ctx.globalAlpha = alpha
+ if (hl === ent.playerId && ent.playerId !== 0) {
+ const hr = iconSize / 2 + 5
+ ctx.strokeStyle = '#fff'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.arc(px, py, hr, 0, Math.PI * 2)
+ ctx.stroke()
+ ctx.strokeStyle = color
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.arc(px, py, hr - 2, 0, Math.PI * 2)
+ ctx.stroke()
+ }
+ if (iconImg?.naturalWidth) {
+ const tinted = this._getTintedIcon(ent._canvasIconKey, color, iconSize)
+ const drawSrc = tinted || iconImg
+ if (ent.type === 'aircraft' || ent.type === 'drone') {
+ const heading = this.getHeadingAtTime(ent, time)
+ if (heading !== null) {
+ ctx.save()
+ ctx.translate(px, py)
+ ctx.rotate(heading)
+ if (tinted) ctx.drawImage(drawSrc, -iconSize / 2, -iconSize / 2)
+ else this._drawContainedIcon(ctx, drawSrc, 0, 0, iconSize)
+ ctx.restore()
+ } else if (tinted) {
+ ctx.drawImage(drawSrc, px - iconSize / 2, py - iconSize / 2)
+ } else {
+ this._drawContainedIcon(ctx, drawSrc, px, py, iconSize)
+ }
+ } else if (tinted) {
+ ctx.drawImage(drawSrc, px - iconSize / 2, py - iconSize / 2)
+ } else {
+ this._drawContainedIcon(ctx, drawSrc, px, py, iconSize)
+ }
+ } else {
+ const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R
+ ctx.fillStyle = color
+ ctx.beginPath()
+ ctx.arc(px, py, r, 0, Math.PI * 2)
+ ctx.fill()
+ ctx.strokeStyle = 'rgba(0,0,0,0.5)'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+ }
+ }
+
+ destroy() {
+ if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
+ this.animFrameId = null
+ this.container.innerHTML = ''
+ }
+}
+
+export default function ReplayCanvasPanel({ gameId }) {
+ const containerRef = useRef(null)
+ const engineRef = useRef(null)
+ const [state, setState] = useState({ status: 'loading', error: '', hasAirMode: false, mode: 'ground' })
+
+ useEffect(() => {
+ if (!gameId) return undefined
+ const controller = new AbortController()
+ let disposed = false
+ setState({ status: 'loading', error: '', hasAirMode: false, mode: 'ground' })
+ if (engineRef.current) {
+ engineRef.current.destroy()
+ engineRef.current = null
+ }
+
+ async function loadReplay() {
+ try {
+ const response = await fetch(`/api/tss/games/${encodeURIComponent(gameId)}/replay-canvas`, {
+ signal: controller.signal,
+ headers: { Accept: 'application/json' },
+ })
+ const body = await response.json().catch(() => null)
+ if (!response.ok) {
+ const reason = body?.reason || body?.error || `Replay request failed with ${response.status}`
+ throw new Error(reason)
+ }
+ if (!body?.entities || !body?.players) throw new Error('Invalid replay data')
+ if (disposed || !containerRef.current) return
+ const engine = new ReplayCanvasEngine(containerRef.current, body)
+ engineRef.current = engine
+ await engine.init()
+ if (disposed) {
+ engine.destroy()
+ return
+ }
+ setState({
+ status: 'ready',
+ error: '',
+ hasAirMode: engine.hasAirMode,
+ mode: engine._mode,
+ })
+ } catch (error) {
+ if (controller.signal.aborted || disposed) return
+ setState({ status: 'error', error: error.message || 'Replay unavailable', hasAirMode: false, mode: 'ground' })
+ }
+ }
+
+ loadReplay()
+ return () => {
+ disposed = true
+ controller.abort()
+ if (engineRef.current) {
+ engineRef.current.destroy()
+ engineRef.current = null
+ }
+ }
+ }, [gameId])
+
+ function switchMode(mode) {
+ const engine = engineRef.current
+ if (!engine) return
+ engine.setMode(mode)
+ setState((current) => ({ ...current, mode: engine._mode }))
+ }
+
+ return (
+
+
+
Replay
+ {state.status === 'ready' && state.hasAirMode ? (
+
+ switchMode('ground')}
+ type="button"
+ >
+ Ground
+
+ switchMode('air')}
+ type="button"
+ >
+ Air
+
+
+ ) : null}
+
+
+ {state.status === 'loading' ? (
+ Loading replay
+ ) : null}
+ {state.status === 'error' ? (
+ {state.error}
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 74682ed..a23c41c 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -466,6 +466,444 @@ h3 {
z-index: 1;
}
+.replay-card {
+ overflow: hidden;
+}
+
+.replay-status {
+ display: flex;
+ min-height: 120px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-surface);
+ color: var(--color-text-soft);
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.replay-status-error {
+ color: var(--color-danger);
+}
+
+.rc-mode-toggle {
+ display: none;
+ gap: 2px;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--color-surface);
+ padding: 2px;
+}
+
+.rc-mode-toggle.visible {
+ display: inline-flex;
+}
+
+.rc-mode-btn {
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--color-text-soft);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.78rem;
+ font-weight: 700;
+ padding: 4px 14px;
+ transition: background-color 120ms ease, color 120ms ease;
+}
+
+.rc-mode-btn:hover {
+ color: var(--color-text);
+}
+
+.rc-mode-btn.active {
+ background: var(--color-fury-cyan);
+ color: var(--color-fury-white);
+}
+
+.rc-layout {
+ display: grid;
+ grid-template-columns: minmax(190px, 230px) minmax(560px, 720px) minmax(190px, 230px);
+ width: min(100%, 1210px);
+ gap: 0.5rem;
+ align-items: start;
+ justify-content: center;
+ margin: 0 auto;
+}
+
+@media (max-width: 1120px) {
+ .rc-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .rc-panel {
+ max-height: 200px;
+ }
+}
+
+.rc-panel {
+ overflow-y: auto;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 8px;
+ background: #15100b;
+ color: #fff2e6;
+ font-size: 0.78rem;
+}
+
+.rc-panel-head {
+ position: sticky;
+ z-index: 1;
+ top: 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(21, 16, 11, 0.95);
+ padding: 0.5rem 0.6rem 0.4rem;
+ text-align: center;
+}
+
+.rc-panel-label {
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.rc-clan-tag {
+ font-family: "skyquakesymbols", monospace;
+ font-size: 0.85rem;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.rc-panel-list {
+ padding: 0.25rem 0;
+}
+
+.rc-row {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ border-left: 2px solid transparent;
+ cursor: pointer;
+ padding: 0.35rem 0.6rem;
+ transition: background-color 120ms ease, opacity 300ms ease;
+}
+
+.rc-row:hover,
+.rc-row.rc-hl {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.rc-panel-win .rc-row.rc-hl {
+ border-left-color: rgba(0, 200, 0, 0.5);
+}
+
+.rc-panel-lose .rc-row.rc-hl {
+ border-left-color: rgba(220, 30, 30, 0.5);
+}
+
+.rc-row.rc-dead {
+ opacity: 0.4;
+}
+
+.rc-row.rc-gone {
+ cursor: default;
+ opacity: 0.2;
+}
+
+.rc-row.rc-dead:hover,
+.rc-row.rc-gone:hover {
+ background: transparent;
+}
+
+.rc-type-icon {
+ width: 28px;
+ height: 22px;
+ flex-shrink: 0;
+ object-fit: contain;
+ opacity: 0.6;
+}
+
+.rc-row-info {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+}
+
+.rc-row-name {
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.86);
+ font-size: 0.76rem;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rc-row-veh {
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.42);
+ font-size: 0.65rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rc-row-status {
+ width: 16px;
+ flex-shrink: 0;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.7rem;
+ font-weight: 800;
+ text-align: center;
+}
+
+.rc-center {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ align-items: center;
+}
+
+.rc-canvas {
+ width: 100%;
+ height: auto;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: #111;
+ cursor: crosshair;
+}
+
+.rc-tickets {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 0.7rem;
+}
+
+.rc-tk-val {
+ min-width: 2.6rem;
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+}
+
+.rc-tk-val-win {
+ color: #5cdf5c;
+ text-align: right;
+}
+
+.rc-tk-val-lose {
+ color: #e85555;
+ text-align: left;
+}
+
+.rc-tk-track {
+ display: flex;
+ height: 10px;
+ flex: 1 1 auto;
+ overflow: hidden;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.rc-tk-fill {
+ height: 100%;
+ transition: width 100ms linear;
+}
+
+.rc-tk-fill-win {
+ background: #2a8f2a;
+}
+
+.rc-tk-fill-lose {
+ background: #b22020;
+}
+
+.rc-game-over .rc-tk-track {
+ animation: rcTkGlow 1.4s ease-in-out infinite;
+}
+
+.rc-game-over .rc-tk-val-win,
+.rc-game-over .rc-panel-win .rc-panel-label {
+ animation: rcTkTextGlow 1.4s ease-in-out infinite;
+}
+
+@keyframes rcTkGlow {
+ 0%,
+ 100% {
+ box-shadow: 0 0 2px 0 rgba(92, 223, 92, 0.25);
+ }
+
+ 50% {
+ box-shadow: 0 0 7px 1px rgba(92, 223, 92, 0.55);
+ }
+}
+
+@keyframes rcTkTextGlow {
+ 0%,
+ 100% {
+ text-shadow: 0 0 3px rgba(92, 223, 92, 0.35);
+ }
+
+ 50% {
+ text-shadow: 0 0 9px rgba(92, 223, 92, 0.85);
+ }
+}
+
+.rc-controls {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.45rem 0;
+}
+
+.rc-btn {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.7);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.72rem;
+ font-weight: 700;
+ padding: 0.25rem 0.45rem;
+ transition: background-color 120ms ease, color 120ms ease;
+}
+
+.rc-btn:hover {
+ background: rgba(255, 255, 255, 0.14);
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.rc-play {
+ min-width: 54px;
+}
+
+.rc-speeds {
+ display: flex;
+ gap: 1px;
+}
+
+.rc-sp.active {
+ border-color: rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.16);
+ color: #fff2e6;
+}
+
+.rc-scrub {
+ box-sizing: content-box;
+ height: 6px;
+ flex: 1;
+ padding: 6px 0;
+ border-radius: 3px;
+ appearance: none;
+ background: linear-gradient(to right, #2a6e2a var(--rc-progress, 0%), rgba(255, 255, 255, 0.14) var(--rc-progress, 0%));
+ background-clip: content-box;
+ cursor: pointer;
+ outline: none;
+}
+
+.rc-scrub::-webkit-slider-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ -webkit-appearance: none;
+ appearance: none;
+ background: #90ee90;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
+ cursor: pointer;
+}
+
+.rc-scrub::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border: 0;
+ border-radius: 50%;
+ background: #90ee90;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
+ cursor: pointer;
+}
+
+.rc-time {
+ min-width: 65px;
+ color: rgba(255, 255, 255, 0.48);
+ font-size: 0.65rem;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.rc-log-wrap {
+ width: 100%;
+ margin-top: 0.4rem;
+}
+
+.rc-log {
+ overflow-y: auto;
+ max-height: 130px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ padding: 0.6rem 0.8rem;
+ scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
+ scrollbar-width: thin;
+}
+
+.rc-log:empty::after {
+ color: rgba(255, 255, 255, 0.22);
+ content: "Waiting for events...";
+ font-size: 0.7rem;
+ font-style: italic;
+}
+
+.rc-ev {
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.74);
+ font-size: 0.72rem;
+ padding: 0.2rem 0;
+}
+
+.rc-ev:last-child {
+ border-bottom: 0;
+}
+
+.rc-ev-damage {
+ opacity: 0.6;
+}
+
+.rc-ev-time {
+ display: inline-block;
+ width: 32px;
+ margin-right: 0.4rem;
+ color: rgba(255, 255, 255, 0.34);
+ font-size: 0.65rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.rc-ev-win {
+ color: #5cdf5c;
+ font-weight: 700;
+}
+
+.rc-ev-lose {
+ color: #e85555;
+ font-weight: 700;
+}
+
+.rc-ev-action {
+ color: rgba(255, 255, 255, 0.48);
+ font-size: 0.68rem;
+}
+
+.rc-ev-weapon {
+ margin-left: 0.3rem;
+ color: rgba(255, 255, 255, 0.34);
+ font-size: 0.62rem;
+}
+
@keyframes scrollPulse {
0% {
transform: translateY(-100%);
diff --git a/server.cjs b/server.cjs
index c628418..014b401 100644
--- a/server.cjs
+++ b/server.cjs
@@ -1,4 +1,5 @@
const fs = require('node:fs')
+const { execFile } = require('node:child_process')
const crypto = require('node:crypto')
const http = require('node:http')
const https = require('node:https')
@@ -60,6 +61,24 @@ const VEHICLE_ICONS_DIR = path.resolve(
__dirname,
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
)
+const BOTS_REPO_DIR = path.resolve(
+ expandHome(process.env.BOTS_REPO_DIR || path.join(__dirname, '..', 'BOTS')),
+)
+const TSSBOT_REPO_DIR = path.resolve(process.env.TSSBOT_REPO_DIR || path.join(BOTS_REPO_DIR, 'TSSBOT'))
+const TSS_REPLAY_SAMPLE_DIR = path.join(TSSBOT_REPO_DIR, 'replays_sample')
+const TSS_REPLAYS_DIR = path.resolve(
+ expandHome(
+ process.env.TSS_REPLAYS_DIR ||
+ (process.env.STORAGE_VOL_PATH
+ ? path.join(expandHome(process.env.STORAGE_VOL_PATH), 'REPLAYS', 'TSS')
+ : TSS_REPLAY_SAMPLE_DIR),
+ ),
+)
+const SHARED_DIR = path.resolve(process.env.SHARED_DIR || path.join(BOTS_REPO_DIR, 'SHARED'))
+const TSS_REPLAY_PYTHON = path.resolve(
+ expandHome(process.env.TSS_REPLAY_PYTHON || path.join(SHARED_DIR, '.venv', 'bin', 'python')),
+)
+const TSS_REPLAY_RENDER_TIMEOUT_MS = Number(process.env.TSS_REPLAY_RENDER_TIMEOUT_MS || 30000)
const MAX_TEAM_NAME_LENGTH = 80
const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000
@@ -2064,6 +2083,219 @@ function serveVehicleIcon(req, res) {
})
}
+function safeUniquePaths(paths) {
+ return [...new Set(paths.filter(Boolean).map((value) => path.resolve(value)))]
+}
+
+function resolveTssReplaySessionDir(sessionId) {
+ const sid = String(sessionId || '').toLowerCase()
+ const candidates = safeUniquePaths([
+ path.join(TSS_REPLAYS_DIR, sid),
+ path.join(TSS_REPLAYS_DIR, `0${sid}`),
+ path.join(TSS_REPLAY_SAMPLE_DIR, sid),
+ path.join(TSS_REPLAY_SAMPLE_DIR, `0${sid}`),
+ ])
+
+ for (const dir of candidates) {
+ try {
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir
+ } catch {
+ // Keep trying the remaining replay roots.
+ }
+ }
+
+ return path.join(TSS_REPLAYS_DIR, sid)
+}
+
+function findTssReplayDataPath(sessionDir) {
+ const candidates = [
+ path.join(sessionDir, 'replay_data.json.gz'),
+ path.join(sessionDir, 'replay_data.json'),
+ ]
+ for (const candidate of candidates) {
+ try {
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
+ } catch {
+ // Ignore unreadable candidates and let the caller return 404.
+ }
+ }
+ return null
+}
+
+function readFileResponse(req, res, filePath, headers = {}) {
+ fs.readFile(filePath, (error, data) => {
+ if (error) {
+ sendJson(res, 404, { error: 'File not found' })
+ return
+ }
+ send(res, 200, data, headers)
+ })
+}
+
+function runReplayCanvasRenderer(replayPath, jsonPath) {
+ const pythonBin = fs.existsSync(TSS_REPLAY_PYTHON) ? TSS_REPLAY_PYTHON : 'python3'
+ return new Promise((resolve, reject) => {
+ execFile(
+ pythonBin,
+ ['-m', 'BOT.render_replay', replayPath, jsonPath],
+ {
+ cwd: TSSBOT_REPO_DIR,
+ timeout: TSS_REPLAY_RENDER_TIMEOUT_MS,
+ env: process.env,
+ },
+ (error, stdout, stderr) => {
+ if (error) {
+ reject(new Error(String(stderr || stdout || error.message || 'Replay renderer failed').trim()))
+ return
+ }
+ resolve()
+ },
+ )
+ })
+}
+
+let tssCanvasRenderCount = 0
+const TSS_CANVAS_RENDER_MAX = 3
+
+async function serveTssReplayCanvas(req, res, sessionId) {
+ if (!sessionId || !/^[A-Za-z0-9_-]{1,96}$/.test(sessionId)) {
+ sendJson(res, 400, { error: 'Invalid game ID' })
+ return
+ }
+ if (!isSameOriginRequest(req)) {
+ sendJson(res, 403, { error: 'API access is restricted to this site' })
+ return
+ }
+ if (isRateLimited(req)) {
+ sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
+ return
+ }
+
+ const sessionDir = resolveTssReplaySessionDir(sessionId)
+ const replayPath = findTssReplayDataPath(sessionDir)
+ const jsonPath = path.join(sessionDir, 'replay_canvas.json')
+
+ if (!replayPath) {
+ sendJson(res, 404, { available: false, reason: 'No replay data available' })
+ return
+ }
+
+ try {
+ const jsonStat = fs.existsSync(jsonPath) ? fs.statSync(jsonPath) : null
+ const replayStat = fs.statSync(replayPath)
+ if (jsonStat && jsonStat.size > 0 && jsonStat.mtimeMs >= replayStat.mtimeMs) {
+ readFileResponse(req, res, jsonPath, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'public, max-age=86400',
+ })
+ return
+ }
+ } catch {
+ // Fall through and attempt regeneration.
+ }
+
+ if (tssCanvasRenderCount >= TSS_CANVAS_RENDER_MAX) {
+ sendJson(res, 503, { available: false, reason: 'Too many replays processing; try again shortly' })
+ return
+ }
+
+ tssCanvasRenderCount += 1
+ try {
+ await runReplayCanvasRenderer(replayPath, jsonPath)
+ if (!fs.existsSync(jsonPath)) {
+ sendJson(res, 500, { available: false, reason: 'Replay JSON generation produced no output' })
+ return
+ }
+ readFileResponse(req, res, jsonPath, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'public, max-age=86400',
+ })
+ } catch (error) {
+ try {
+ if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath)
+ } catch {
+ // Ignore cache cleanup errors.
+ }
+ sendJson(res, 500, { available: false, reason: 'Replay JSON generation failed', detail: error.message })
+ } finally {
+ tssCanvasRenderCount -= 1
+ }
+}
+
+function serveReplayIcon(req, res) {
+ let iconName = ''
+ try {
+ const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
+ iconName = requestPath.slice('/api/icons/type/'.length)
+ } catch {
+ sendJson(res, 400, { error: 'Bad request' })
+ return
+ }
+
+ if (!iconName || !/^[A-Za-z0-9_-]+$/.test(iconName)) {
+ sendJson(res, 400, { error: 'Invalid icon name' })
+ return
+ }
+
+ const iconsBase = path.join(SHARED_DIR, 'ICONS')
+ const candidates = [
+ path.join(iconsBase, `${iconName}.png`),
+ path.join(iconsBase, 'FALLBACKS', `${iconName}.png`),
+ path.join(iconsBase, 'MINIS', `${iconName}.png`),
+ ]
+ for (const candidate of candidates) {
+ const relative = path.relative(iconsBase, candidate)
+ if (relative.startsWith('..') || path.isAbsolute(relative)) continue
+ if (fs.existsSync(candidate)) {
+ readFileResponse(req, res, candidate, {
+ 'content-type': 'image/png',
+ 'cache-control': 'public, max-age=604800',
+ })
+ return
+ }
+ }
+ sendJson(res, 404, { error: 'Icon not found' })
+}
+
+function serveReplayMinimap(req, res) {
+ let level = ''
+ let fullMap = false
+ try {
+ const url = new URL(req.url, `http://localhost:${PORT}`)
+ const requestPath = decodeURIComponent(url.pathname)
+ level = requestPath.slice('/api/match/minimap/'.length)
+ fullMap = url.searchParams.get('type') === 'full'
+ } catch {
+ sendJson(res, 400, { error: 'Bad request' })
+ return
+ }
+
+ if (!level || !/^[A-Za-z0-9_]+$/.test(level)) {
+ sendJson(res, 400, { error: 'Invalid level name' })
+ return
+ }
+
+ const minimapsDir = path.join(SHARED_DIR, 'MAPS', 'MINIMAPS')
+ const names = fullMap
+ ? [`${level}.png`, `${level}_map.png`]
+ : [`${level}_tankmap.png`, `${level}.png`, `${level}_map.png`]
+
+ for (const name of [...new Set(names)]) {
+ const candidate = path.resolve(minimapsDir, name)
+ const relative = path.relative(minimapsDir, candidate)
+ if (relative.startsWith('..') || path.isAbsolute(relative)) continue
+ if (fs.existsSync(candidate)) {
+ readFileResponse(req, res, candidate, {
+ 'content-type': 'image/png',
+ 'cache-control': 'public, max-age=604800',
+ })
+ return
+ }
+ }
+
+ sendJson(res, 404, { error: 'Minimap not found' })
+}
+
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/robots.txt') {
sendRobotsTxt(req, res)
@@ -2189,6 +2421,31 @@ const server = http.createServer((req, res) => {
return
}
+ if (req.method === 'GET') {
+ let pathname = ''
+ try {
+ pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
+ } catch {
+ pathname = ''
+ }
+
+ const replayMatch = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/replay-canvas$/)
+ if (replayMatch) {
+ serveTssReplayCanvas(req, res, replayMatch[1])
+ return
+ }
+
+ if (pathname.startsWith('/api/icons/type/')) {
+ serveReplayIcon(req, res)
+ return
+ }
+
+ if (pathname.startsWith('/api/match/minimap/')) {
+ serveReplayMinimap(req, res)
+ return
+ }
+ }
+
if (req.method === 'GET' && req.url.startsWith('/vehicle-icons/')) {
serveVehicleIcon(req, res)
return