From fb08e99e5d768c57bd4a6a717b981943b4527f50 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 20:10:47 -0700 Subject: [PATCH] replay canvas --- frontend/src/App.jsx | 34 +- frontend/src/ReplayCanvas.jsx | 1098 +++++++++++++++++++++++++++++++++ frontend/src/styles.css | 438 +++++++++++++ server.cjs | 257 ++++++++ 4 files changed, 1823 insertions(+), 4 deletions(-) create mode 100644 frontend/src/ReplayCanvas.jsx 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) => (
- +

{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 = ` + +
+ + + + +
+ + 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 ? ( +
+ + +
+ ) : 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