1099 lines
39 KiB
React
1099 lines
39 KiB
React
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 = `
|
|
<button class="rc-btn rc-play" type="button">Play</button>
|
|
<div class="rc-speeds">
|
|
<button class="rc-btn rc-sp" type="button" data-speed="1">1x</button>
|
|
<button class="rc-btn rc-sp" type="button" data-speed="2">2x</button>
|
|
<button class="rc-btn rc-sp active" type="button" data-speed="4">4x</button>
|
|
<button class="rc-btn rc-sp" type="button" data-speed="8">8x</button>
|
|
</div>
|
|
<input type="range" class="rc-scrub" min="0" max="1000" value="0" aria-label="Replay timeline">
|
|
<span class="rc-time">0:00 / 0:00</span>
|
|
`
|
|
center.appendChild(controls)
|
|
|
|
const logWrap = document.createElement('div')
|
|
logWrap.className = 'rc-log-wrap'
|
|
logWrap.innerHTML = '<div class="rc-log"></div>'
|
|
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 ? `<span class="rc-clan-tag">${esc(clanTag)}</span>` : (isWinner ? 'Winners' : 'Losers')
|
|
let html = `<div class="rc-panel-head"><span class="rc-panel-label" style="color:${color}">${label}</span></div><div class="rc-panel-list">`
|
|
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 += `<div class="rc-row" data-player-id="${ent.playerId}" data-entity-index="${ent.entityIndex}">
|
|
<img class="rc-type-icon" src="/api/icons/type/${panelIcon}" alt="" loading="lazy" onerror="this.style.display='none'">
|
|
<div class="rc-row-info">
|
|
<span class="rc-row-name">${name}</span>
|
|
<span class="rc-row-veh">${veh}</span>
|
|
</div>
|
|
<span class="rc-row-status"></span>
|
|
</div>`
|
|
}
|
|
html += '</div>'
|
|
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 = `<span class="${victimIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${esc(victimName)}</span><span class="rc-ev-action"> crashed</span>`
|
|
} else {
|
|
const killerIsWin = killer.team === this.data.teamWon
|
|
html = `<span class="${killerIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${esc(killer.name)}</span><span class="rc-ev-action"> destroyed </span><span class="${killerIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${esc(victimName)}</span>${k.weapon ? `<span class="rc-ev-weapon">[${esc(k.weapon)}]</span>` : ''}`
|
|
}
|
|
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: `<span class="${atkIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${esc(atk.name)}</span><span class="rc-ev-action"> hit </span><span class="${atkIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${esc(vic.name)}</span>`,
|
|
})
|
|
}
|
|
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 = `<span class="rc-ev-time">${mm}:${String(ss).padStart(2, '0')}</span>${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 = '<span class="rc-tk-val rc-tk-val-win">0</span><div class="rc-tk-track"><div class="rc-tk-fill rc-tk-fill-win"></div><div class="rc-tk-fill rc-tk-fill-lose"></div></div><span class="rc-tk-val rc-tk-val-lose">0</span>'
|
|
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 (
|
|
<section className="replay-card rounded-lg border border-border bg-fury-white p-5 shadow-sm">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 className="text-lg font-bold text-text">Replay</h2>
|
|
{state.status === 'ready' && state.hasAirMode ? (
|
|
<div className="rc-mode-toggle visible">
|
|
<button
|
|
className={`rc-mode-btn ${state.mode === 'ground' ? 'active' : ''}`}
|
|
onClick={() => switchMode('ground')}
|
|
type="button"
|
|
>
|
|
Ground
|
|
</button>
|
|
<button
|
|
className={`rc-mode-btn ${state.mode === 'air' ? 'active' : ''}`}
|
|
onClick={() => switchMode('air')}
|
|
type="button"
|
|
>
|
|
Air
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{state.status === 'loading' ? (
|
|
<div className="replay-status">Loading replay</div>
|
|
) : null}
|
|
{state.status === 'error' ? (
|
|
<div className="replay-status replay-status-error">{state.error}</div>
|
|
) : null}
|
|
<div ref={containerRef} className={state.status === 'ready' ? '' : 'hidden'} />
|
|
</section>
|
|
)
|
|
}
|