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}
) }