/** * replay-canvas.js * * Interactive HTML5 Canvas replay viewer for War Thunder GOB replays. */ 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_DIM: 'rgba(0,200,0,', LOSE_DIM: 'rgba(220,30,30,', WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,', DOT_R: 5, AIR_R: 4, DRONE_R: 3, }; const RC_CAP_STROKE_PX = 3; const RC_CAP_ICON_ALPHA = 0.35; const RC_CAP_ICON_MIN_SIZE = 10; const RC_CAP_FILL_ALPHA = 0.5; function replayT(key) { return (window.__t && window.__t(key)) || key; } class ReplayCanvas { 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.canvas = null; this.ctx = null; this.mapCanvas = null; this.mapCtx = null; // Store both coordinate sets 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 : []; // Dynamic capture-state + tickets timelines (Spectra v2+); null if absent this._captureState = (data.captureState && typeof data.captureState === 'object' && Object.keys(data.captureState).length) ? data.captureState : null; this._tickets = (data.tickets && typeof data.tickets === 'object' && Object.keys(data.tickets).length) ? data.tickets : null; this._teamNames = (data.teamNames && typeof data.teamNames === 'object') ? data.teamNames : {}; this._winnerSlot = Number(data.winnerSlot) || 0; // winning team slot (1/2) this._mode = 'ground'; this._viewMode = '2d'; this.view3d = null; this.supports3d = false; const hasAircraft = data.entities.some(e => e.type === 'aircraft'); this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft); this.x0 = data.levelCoords.x0; this.z0 = data.levelCoords.z0; this.xRange = data.levelCoords.x1 - data.levelCoords.x0; this.zRange = data.levelCoords.z1 - data.levelCoords.z0; // Source rect for minimap image (full image by default) this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; this._updateMapSourceRect(); this.players = {}; for (const p of data.players) this.players[p.id] = p; this.entities = []; for (const e of data.entities) { if (!e.path || e.path.length === 0) 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] = e.path[i].t; positions[i * 2] = e.path[i].x; positions[i * 2 + 1] = 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, }); } // Pre-compute deaths this._computeDeaths(); this.currentTime = this.tStart; } _computeDeaths() { // Reset deaths so they can be recomputed after coord changes 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; } } } } 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, 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], t1 = times[hi]; const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0; const i0 = lo * 2, 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) { // Compute heading in radians (0=up/north, CW) from position delta const dt = 500; // sample window in game ms const p0 = this.getPositionAtTime(entity, time - dt); const p1 = this.getPositionAtTime(entity, time); if (!p0 || !p1) return null; const dx = p1[0] - p0[0]; const dy = p1[1] - p0[1]; if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return null; return Math.atan2(dx, -dy); // 0=up, CW positive } _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; } async init() { this._buildDOM(); await Promise.all([this._loadMap(), this._loadEntityIcons()]); this._initView3d(); this._onResize = () => { if (this._viewMode === '3d') this.view3d?.resize(); }; window.addEventListener('resize', this._onResize); this.playing = true; this.playBtn.innerHTML = ''; this.lastFrameTime = performance.now(); this._tick = this._tick.bind(this); this.animFrameId = requestAnimationFrame(this._tick); } _initView3d() { try { if (typeof window.ReplayCanvas3D !== 'function') return; this.view3d = new window.ReplayCanvas3D(this.view3dContainer, this.data); this.supports3d = true; } catch (e) { this.view3d = null; this.supports3d = false; } } _renderActive() { if (this._viewMode === '3d') { this.view3d?.setTime(this.currentTime); } else { this.render(); } } setViewMode(mode) { const next = mode === '3d' && this.view3d ? '3d' : '2d'; if (next === this._viewMode) return; this._viewMode = next; const is3d = next === '3d'; this.canvas.classList.toggle('rc-hidden', is3d); this.view3dContainer.classList.toggle('rc-hidden', !is3d); if (is3d) { this.view3d.setMode(this._mode); this.view3d.resize(); this.view3d.setTime(this.currentTime); } else { this.render(); } } focus(playerId) { if (this._viewMode === '3d') this.view3d?.focus(playerId); } _buildDOM() { this.container.innerHTML = ''; const layout = document.createElement('div'); layout.className = 'rc-layout'; // Left panel (winners) this.leftPanel = document.createElement('div'); this.leftPanel.className = 'rc-panel rc-panel-win'; this._buildTeamPanel(this.leftPanel, true); // Center const center = document.createElement('div'); center.className = 'rc-center'; // Tickets meter above the battle view (its top aligns with the team panels) this._buildTicketsBar(center); const stage = document.createElement('div'); stage.className = 'rc-stage'; 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'); stage.appendChild(this.canvas); this.view3dContainer = document.createElement('div'); this.view3dContainer.className = 'rc-3d-container rc-hidden'; stage.appendChild(this.view3dContainer); center.appendChild(stage); // Controls const controls = document.createElement('div'); controls.className = 'rc-controls'; controls.innerHTML = `
0:00 / 0:00 `; center.appendChild(controls); // Battle log const logWrap = document.createElement('div'); logWrap.className = 'rc-log-wrap'; logWrap.innerHTML = '
'; center.appendChild(logWrap); this.battleLog = logWrap.querySelector('#rcBattleLog'); // Pre-build sorted event list for the log this._buildEventList(); // Right panel (losers) 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); // Wire controls 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._renderActive(); this._updateTicketsBar(this.currentTime); }); 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 = parseInt(btn.dataset.speed); }); }); // Canvas hover — store mouse pos, re-evaluate each frame 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); }); // Offscreen map canvas this.mapCanvas = document.createElement('canvas'); this.mapCanvas.width = this.canvasSize; this.mapCanvas.height = this.canvasSize; this.mapCtx = this.mapCanvas.getContext('2d'); } _buildTeamPanel(panel, isWinner) { // Show all players on this team, using their first entity (prefer ground) const teamEntities = this.entities.filter(e => e.playerId > 0 && e.isWinner === isWinner); const seen = new Set(); const unique = []; // First pass: ground entities for (const e of teamEntities) { if (e.type === 'ground' && !seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); } } // Second pass: any remaining players (aircraft etc) 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 dimColor = isWinner ? 'rgba(0,200,0,0.15)' : 'rgba(220,30,30,0.15)'; // Use squadron clan tag rendered with skyquake font const firstPlayer = unique.length > 0 ? this.players[unique[0].playerId] : null; const clanTag = firstPlayer?.clan || ''; const label = clanTag ? `${this._esc(clanTag)}` : (isWinner ? 'Winners' : 'Losers'); let html = `
${label}
`; for (const ent of unique) { const p = this.players[ent.playerId]; const name = p ? this._esc(p.name) : '?'; const veh = this._esc(ent.vehicleName); const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium'); html += `
${name} ${veh}
`; } html += '
'; panel.innerHTML = html; // Hover panel.querySelectorAll('.rc-row').forEach(row => { row.addEventListener('mouseenter', () => { const pid = parseInt(row.dataset.playerId); const eidx = parseInt(row.dataset.entityIndex); const ent = this.entities.find(e => e.entityIndex === eidx); if (ent && !this._isEntityGone(ent, this.currentTime)) { this._setHighlight(pid); } }); row.addEventListener('mouseleave', () => this._setHighlight(null)); row.addEventListener('click', () => this.focus(parseInt(row.dataset.playerId))); }); } _updatePanelDeathStates() { const t = this.currentTime; this.container.querySelectorAll('.rc-row').forEach(row => { const eidx = parseInt(row.dataset.entityIndex); const ent = this.entities.find(e => e.entityIndex === eidx); 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'); if (gone) { status.innerHTML = ''; row.style.cursor = 'default'; } else if (dead) { status.innerHTML = ''; row.style.cursor = 'default'; } else { status.innerHTML = ''; row.style.cursor = 'pointer'; } }); } _setHighlight(playerId) { if (this.highlightedPlayerId === playerId) return; this.highlightedPlayerId = playerId; this.container.querySelectorAll('.rc-row').forEach(row => { row.classList.toggle('rc-hl', parseInt(row.dataset.playerId) === playerId); }); if (!this.playing) this.render(); } _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } _buildEventList() { // Merge kills and damages into a single sorted timeline this._events = []; for (const k of this.data.kills) { const killer = this.players[k.killerId]; // Find victim name 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) { // No killer (crash / environment kill) const victimIsWin = victimTeam === this.data.teamWon; html = `${this._esc(victimName)}` + ` ${replayT('replay.crashed')}`; } else { const killerIsWin = killer.team === this.data.teamWon; html = `${this._esc(killer.name)}` + ` ${replayT('replay.destroyed')} ` + `${this._esc(victimName)}` + (k.weapon ? `[${this._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: `${this._esc(atk.name)}` + ` ${replayT('replay.hit')} ` + `${this._esc(vic.name)}`, }); } this._events.sort((a, b) => a.time - b.time); this._lastLogIndex = -1; } _updateBattleLog() { const t = this.currentTime; // Find how many events should be visible 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; // Rebuild log content const log = this.battleLog; log.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 = (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}`; log.appendChild(el); } // Auto-scroll to bottom log.scrollTop = log.scrollHeight; } _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 || !img.naturalWidth) return null; try { 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; } catch (e) { // CORS or other canvas tainting — fall back to untinted this._tintCache[cacheKey] = null; return null; } } _containedImageRect(img, size) { const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1; let w = size, 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); } _updateMapSourceRect() { // Air/full-map mode always uses the full source image. 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; if (!base || !render) { this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; return; } const bx0 = Number(base.x0), bz0 = Number(base.z0); const bx1 = Number(base.x1), bz1 = Number(base.z1); const rx0 = Number(render.x0), rz0 = Number(render.z0); const rx1 = Number(render.x1), 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 xLo = Math.min(rx0, rx1), xHi = Math.max(rx0, rx1); const zLo = Math.min(rz0, rz1), zHi = Math.max(rz0, rz1); const u0 = (xLo - bx0) / dx; const u1 = (xHi - bx0) / dx; const v0 = (zLo - bz0) / dz; const v1 = (zHi - 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; if (!(w > 0 && h > 0)) { this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; return; } // Source top-left in image space: X -> uMin, Z -> (1 - vMax) this._mapSrc = { u: uMin, v: 1 - vMax, w, h }; } _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]), az0 = Number(tm.a0[2]); const ax2 = Number(tm.a2[0]), az2 = Number(tm.a2[2]); const cx = Number(tm.center[0]), cz = Number(tm.center[2]); if (!Number.isFinite(ax0) || !Number.isFinite(az0) || !Number.isFinite(ax2) || !Number.isFinite(az2) || !Number.isFinite(cx) || !Number.isFinite(cz)) return []; const capType = String(cap?.type || '').toLowerCase(); const points = []; if (capType === 'sphere' || capType === 'cylinder') { const steps = 64; for (let i = 0; i < steps; i++) { const t = (2 * Math.PI * i) / steps; const wx = cx + Math.cos(t) * ax0 + Math.sin(t) * ax2; const wz = cz + Math.cos(t) * az0 + Math.sin(t) * az2; points.push(this.worldToPixel(wx, wz)); } return points; } if (capType === 'box') { const corners = [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; for (const [sx, sz] of corners) { const wx = cx + sx * ax0 + sz * ax2; const wz = cz + sx * az0 + sz * az2; points.push(this.worldToPixel(wx, wz)); } return points; } return []; } _pickContrastOutlineColor(points) { if (!points || points.length === 0) return '#fff'; const W = this.canvasSize; const H = this.canvasSize; let lumaSum = 0; let n = 0; for (const [px, py] of points) { const x = Math.max(0, Math.min(W - 1, Math.round(px))); const y = Math.max(0, Math.min(H - 1, Math.round(py))); try { const d = this.mapCtx.getImageData(x, y, 1, 1).data; const luma = 0.2126 * d[0] + 0.7152 * d[1] + 0.0722 * d[2]; lumaSum += luma; n++; } catch (_) { continue; } } if (n === 0) return '#fff'; return (lumaSum / n) < 128 ? '#fff' : '#000'; } _capIconForLabel(label) { const key = String(label || '').toLowerCase(); if (!this._capIconCache) return null; return this._capIconCache[`capture_${key}`] || this._capIconCache.cap_icon || null; } _drawCaptureIcon(label, px, py, size) { const img = this._capIconForLabel(label); if (!img || !img.naturalWidth) return; const s = Math.max(RC_CAP_ICON_MIN_SIZE, Math.round(size)); this.mapCtx.save(); this.mapCtx.globalAlpha = RC_CAP_ICON_ALPHA; this.mapCtx.drawImage(img, px - s / 2, py - s / 2, s, s); this.mapCtx.restore(); } _drawCaptureAreasOnMap() { if (this._mode !== 'ground') return; if (!this.captureAreas || this.captureAreas.length === 0) 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 cx = Number(cap?.x || 0); const cz = Number(cap?.z || 0); const [px, py] = this.worldToPixel(cx, cz); const outlinePoints = this._capOutlinePoints(cap); let iconSize = 18; if (outlinePoints.length >= 3) { this.mapCtx.strokeStyle = '#fff'; this.mapCtx.lineWidth = RC_CAP_STROKE_PX; this.mapCtx.beginPath(); this.mapCtx.moveTo(outlinePoints[0][0], outlinePoints[0][1]); for (let p = 1; p < outlinePoints.length; p++) { this.mapCtx.lineTo(outlinePoints[p][0], outlinePoints[p][1]); } this.mapCtx.closePath(); this.mapCtx.stroke(); let area2 = 0; const nPts = outlinePoints.length; for (let p = 0; p < nPts; p++) { const a = outlinePoints[p]; const b = outlinePoints[(p + 1) % nPts]; area2 += a[0] * b[1] - b[0] * a[1]; } const capArea = Math.abs(area2) * 0.5; if (capArea > 0) { iconSize = Math.max(RC_CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(capArea / 2)))); } } else { const rr = Math.max(0, Number(cap?.radius || 0)); let rp = Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5)); rp = Math.max(8, rp); if (px < -rp || py < -rp || px > this.canvasSize + rp || py > this.canvasSize + rp) continue; const ringSamples = [ [px + rp, py], [px - rp, py], [px, py + rp], [px, py - rp], [px + Math.round(rp * 0.707), py + Math.round(rp * 0.707)], [px - Math.round(rp * 0.707), py + Math.round(rp * 0.707)], [px + Math.round(rp * 0.707), py - Math.round(rp * 0.707)], [px - Math.round(rp * 0.707), py - Math.round(rp * 0.707)], ]; this.mapCtx.strokeStyle = '#fff'; this.mapCtx.lineWidth = RC_CAP_STROKE_PX; this.mapCtx.beginPath(); this.mapCtx.arc(px, py, rp, 0, Math.PI * 2); this.mapCtx.stroke(); const capArea = Math.PI * rp * rp; iconSize = Math.max(RC_CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(capArea / 2)))); } this._drawCaptureIcon(label, px, py, iconSize); } } // ── Dynamic capture state ────────────────────────────────────────────── _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) { // Zero-order hold for caps: hold last value until the next change // (avoids a false early fill across the initial [0,0] gap). if (step) return series[i - 1][1]; const t0 = series[i - 1][0], v0 = series[i - 1][1]; const t1 = series[i][0], v1 = series[i][1]; const f = t1 === t0 ? 0 : (t - t0) / (t1 - t0); return v0 + (v1 - v0) * f; } } return last[1]; } _capCircleRadiusPx(cap) { const rr = Math.max(0, Number(cap?.radius || 0)); let rp = Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5)); return Math.max(8, rp); } _drawCaptureState(ctx, t) { if (this._mode !== 'ground' || !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); // step-hold if (val === null) continue; const frac = Math.min(1, Math.abs(val) / 100); if (frac <= 0.01) continue; // cap sign is the owning team slot (positive == slot 2, negative == slot 1) 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(); // Radial (pie) sweep from top, clockwise, proportional to capture % const start = -Math.PI / 2; const end = start + 2 * Math.PI * frac; ctx.globalAlpha = RC_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(); } } // ── Tickets meter ────────────────────────────────────────────────────── _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'; // No team names here — the win/lose team sections flank the bar already. 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 = w; this._tkLoseVal.textContent = l; // Game over (loser bled out) → glow the winning side (bar, number, and // the winning team panel). Class goes on the shared container so the CSS // can reach both the bar and the side panel. this.container.classList.toggle('rc-game-over', l <= 0); } async _loadEntityIcons() { this._iconCache = {}; this._iconDebug = {}; 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; } } const promises = []; for (const key of keysToLoad) { promises.push(new Promise((resolve) => { const img = new Image(); img.onload = () => { this._iconCache[key] = img; this._iconDebug[key] = 'ok'; resolve(); }; img.onerror = () => { this._iconDebug[key] = 'err'; resolve(); }; img.src = `/api/icons/type/${key}`; })); } await Promise.all(promises); } async _loadMap() { const level = this.data.mission?.level; if (!level) { this.mapCtx.fillStyle = '#111'; this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); return; } const loadImg = (src) => new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = src; }); // Load ground (tank) map this._groundMapImg = await loadImg(`/api/match/minimap/${level}`); // Load full (air) map if available if (this._fullMapLevel) { this._airMapImg = await loadImg(`/api/match/minimap/${this._fullMapLevel}?type=full`); } else { this._airMapImg = null; } this._capIconCache = { cap_icon: await loadImg('/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)); for (const letter of capLetters) { this._capIconCache[`capture_${letter}`] = await loadImg(`/api/icons/type/capture_${letter}`); } this._drawMapToCanvas(); } _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; const sx = u * img.naturalWidth, sy = v * img.naturalHeight; const sw = w * img.naturalWidth, sh = h * img.naturalHeight; this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize); this._drawCaptureAreasOnMap(); } setMode(mode) { if (mode === this._mode) return; if (mode === 'air' && !this.hasAirMode) return; this._mode = mode; // Swap coordinate system const coords = mode === 'air' ? this._airCoords : this._groundCoords; this.x0 = coords.x0; this.z0 = coords.z0; this.xRange = coords.x1 - coords.x0; this.zRange = coords.z1 - coords.z0; this._updateMapSourceRect(); // Redraw map background with new crop region this._drawMapToCanvas(); // Recompute death positions in new coordinate space this._computeDeaths(); this.view3d?.setMode(mode); // Render immediately this._renderActive(); } _togglePlay() { this.playing = !this.playing; this.playBtn.innerHTML = this.playing ? '' : ''; 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.innerHTML = ''; } } this._renderActive(); this._updateTicketsBar(this.currentTime); this._updateControls(); // Update panel death states + battle log every ~250ms if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) { this._updatePanelDeathStates(); this._updateBattleLog(); this._lastPanelUpdate = now; } this.animFrameId = requestAnimationFrame(this._tick); } _updateControls() { const frac = (this.currentTime - this.tStart) / (this.tEnd - this.tStart); const pct = Math.round(frac * 1000); this.scrubber.value = pct; this.scrubber.style.setProperty('--rc-progress', (frac * 100).toFixed(1) + '%'); const cur = (this.currentTime - this.tStart) / 1000; const total = (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, bestDist = 400; for (const ent of this.entities) { if (ent.playerId === 0) continue; if (this._isEntityGone(ent, this.currentTime)) continue; const pos = this._entityScreenPos(ent, this.currentTime); if (!pos) continue; const dx = pos[0] - this._mouseX, 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; 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; const { times, positions } = ent; ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5); ctx.lineCap = 'round'; // Aircraft in air mode: interpolate at fixed time steps so trail // segments are always pixel-visible (raw path points can be sub-pixel // apart at full-map scale). if (this._mode === 'air' && ent.type === 'aircraft') { const step = 200; // ms between interpolated trail points let prevPx = null, prevPy = null; for (let t = Math.max(tMin, times[0]); t <= Math.min(endT, times[times.length - 1]); t += step) { const pos = this.getPositionAtTime(ent, t); if (!pos) continue; const [px, py] = pos; if (prevPx !== null) { const age = time - t; const alpha = Math.max(0.08, 1 - age / trailLen); ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')'; ctx.beginPath(); ctx.moveTo(prevPx, prevPy); ctx.lineTo(px, py); ctx.stroke(); } prevPx = px; prevPy = py; } continue; } let prevPx = null, prevPy = null; for (let i = 0; i < times.length; i++) { if (times[i] < tMin) continue; if (times[i] > endT) break; const [px, py] = this.worldToPixel(positions[i*2], positions[i*2+1]); if (prevPx !== null) { const age = time - times[i]; const alpha = Math.max(0.08, 1 - age / trailLen); ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')'; ctx.beginPath(); ctx.moveTo(prevPx, prevPy); ctx.lineTo(px, py); ctx.stroke(); } prevPx = px; prevPy = py; } } } _drawDamageLines(ctx, time) { for (const dm of this.data.damages) { const age = time - dm.time; if (age < 0 || age > RC.DMG_TTL) continue; // Find attacker and victim positions at damage time 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) continue; if (!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([]); // X marker ctx.globalAlpha = alpha * 0.9; ctx.strokeStyle = '#ff3333'; ctx.lineWidth = 2; const s = 5; ctx.beginPath(); ctx.moveTo(vx-s, vy-s); ctx.lineTo(vx+s, vy+s); ctx.moveTo(vx+s, vy-s); ctx.lineTo(vx-s, vy+s); ctx.stroke(); // Weapon label 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; } } _drawEntities(ctx, time) { const hl = this.highlightedPlayerId; // Draw dead entities first (fading) for (const ent of this.entities) { if (!this._isEntityDead(ent, time)) continue; if (this._isEntityGone(ent, time)) continue; const pos = ent.deathPos; if (!pos) continue; const [px, py] = pos; 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(px, py, r, 0, Math.PI*2); ctx.fill(); ctx.globalAlpha = 1; } // Draw alive entities 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; // Highlight ring 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 && iconImg.naturalWidth) { const s = iconSize; const tinted = this._getTintedIcon(ent._canvasIconKey, color, s); const drawSrc = tinted || iconImg; // Rotate aircraft/drones to face their heading 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, -s/2, -s/2); else this._drawContainedIcon(ctx, drawSrc, 0, 0, s); ctx.restore(); } else { if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2); else this._drawContainedIcon(ctx, drawSrc, px, py, s); } } else { if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2); else this._drawContainedIcon(ctx, drawSrc, px, py, s); } } else { // Fallback: circle dot 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; } if (this._onResize) window.removeEventListener('resize', this._onResize); this.view3d?.dispose(); this.view3d = null; this.container.innerHTML = ''; } } window.ReplayCanvas = ReplayCanvas;