2b399fdb81
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
901 lines
36 KiB
JavaScript
901 lines
36 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|
|
|
|
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._airCoords = data.mapCoords || null;
|
|
this._fullMapLevel = data.fullMapLevel || null;
|
|
this._mode = 'ground';
|
|
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;
|
|
// Default map source rect (full image) — overwritten by _computeAutocrop
|
|
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
// Zoom into the area players actually use (like the video maker autocrop)
|
|
this._computeAutocrop();
|
|
|
|
// 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
|
|
];
|
|
}
|
|
|
|
/** Recompute x0/z0/xRange/zRange to zoom into entity activity.
|
|
* In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */
|
|
_computeAutocrop() {
|
|
const origX0 = this.x0, origZ0 = this.z0;
|
|
const origXR = this.xRange, origZR = this.zRange;
|
|
|
|
const airMode = this._mode === 'air';
|
|
let minX = Infinity, maxX = -Infinity;
|
|
let minZ = Infinity, maxZ = -Infinity;
|
|
for (const ent of this.entities) {
|
|
if (airMode) {
|
|
if (ent.type !== 'aircraft' && ent.type !== 'drone') continue;
|
|
} else {
|
|
if (ent.type !== 'ground') continue;
|
|
}
|
|
const { positions } = ent;
|
|
for (let i = 0; i < positions.length; i += 2) {
|
|
const x = positions[i], z = positions[i + 1];
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (z < minZ) minZ = z;
|
|
if (z > maxZ) maxZ = z;
|
|
}
|
|
}
|
|
if (!isFinite(minX)) return; // no relevant positions — keep full map
|
|
|
|
// Padding: 10% of span or 50 world units, whichever is larger
|
|
const span = Math.max(maxX - minX, maxZ - minZ);
|
|
const pad = Math.max(50, span * 0.10);
|
|
minX -= pad; maxX += pad;
|
|
minZ -= pad; maxZ += pad;
|
|
|
|
// Expand to square, with a minimum of 15% of the map range
|
|
const minSide = Math.max(origXR, origZR) * 0.15;
|
|
let side = Math.max(maxX - minX, maxZ - minZ, minSide);
|
|
const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2;
|
|
minX = midX - side / 2; maxX = midX + side / 2;
|
|
minZ = midZ - side / 2; maxZ = midZ + side / 2;
|
|
|
|
// Clamp to LevelDef bounds (shift first, then hard-clamp)
|
|
const x1 = origX0 + origXR, z1 = origZ0 + origZR;
|
|
if (minX < origX0) { maxX += origX0 - minX; minX = origX0; }
|
|
if (maxX > x1) { minX -= maxX - x1; maxX = x1; }
|
|
if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; }
|
|
if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; }
|
|
minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1);
|
|
minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1);
|
|
|
|
// Fractional source rect for the minimap image
|
|
// Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0)
|
|
this._mapSrc = {
|
|
u: (minX - origX0) / origXR,
|
|
v: (z1 - maxZ) / origZR,
|
|
w: (maxX - minX) / origXR,
|
|
h: (maxZ - minZ) / origZR,
|
|
};
|
|
|
|
// Apply new bounds — worldToPixel will now map this sub-region to the full canvas
|
|
this.x0 = minX;
|
|
this.z0 = minZ;
|
|
this.xRange = maxX - minX;
|
|
this.zRange = maxZ - minZ;
|
|
}
|
|
|
|
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.playing = true;
|
|
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
|
|
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';
|
|
|
|
// 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';
|
|
|
|
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);
|
|
|
|
// Controls
|
|
const controls = document.createElement('div');
|
|
controls.className = 'rc-controls';
|
|
controls.innerHTML = `
|
|
<button class="rc-btn rc-play" title="${replayT('replay.playPause')}"><i class="fas fa-play"></i></button>
|
|
<div class="rc-speeds">
|
|
<button class="rc-btn rc-sp" data-speed="1">1x</button>
|
|
<button class="rc-btn rc-sp" data-speed="2">2x</button>
|
|
<button class="rc-btn rc-sp active" data-speed="4">4x</button>
|
|
<button class="rc-btn rc-sp" data-speed="8">8x</button>
|
|
</div>
|
|
<input type="range" class="rc-scrub" min="0" max="1000" value="0">
|
|
<span class="rc-time">0:00 / 0:00</span>
|
|
`;
|
|
center.appendChild(controls);
|
|
|
|
// Battle log
|
|
const logWrap = document.createElement('div');
|
|
logWrap.className = 'rc-log-wrap';
|
|
logWrap.innerHTML = '<div class="rc-log" id="rcBattleLog"></div>';
|
|
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.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 = 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
|
|
? `<span class="rc-clan-tag">${this._esc(clanTag)}</span>`
|
|
: (isWinner ? 'Winners' : 'Losers');
|
|
|
|
let html = `<div class="rc-panel-head" style="border-bottom-color:${dimColor}">
|
|
<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 ? this._esc(p.name) : '?';
|
|
const veh = this._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;
|
|
|
|
// 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));
|
|
});
|
|
}
|
|
|
|
_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 = '<i class="fas fa-skull" style="opacity:0.3"></i>';
|
|
row.style.cursor = 'default';
|
|
} else if (dead) {
|
|
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.5"></i>';
|
|
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 = `<span class="${victimIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(victimName)}</span>`
|
|
+ `<span class="rc-ev-action"> ${replayT('replay.crashed')}</span>`;
|
|
} else {
|
|
const killerIsWin = killer.team === this.data.teamWon;
|
|
html = `<span class="${killerIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(killer.name)}</span>`
|
|
+ `<span class="rc-ev-action"> ${replayT('replay.destroyed')} </span>`
|
|
+ `<span class="${killerIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(victimName)}</span>`
|
|
+ (k.weapon ? `<span class="rc-ev-weapon">[${this._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'}">${this._esc(atk.name)}</span>`
|
|
+ `<span class="rc-ev-action"> ${replayT('replay.hit')} </span>`
|
|
+ `<span class="${atkIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(vic.name)}</span>`,
|
|
});
|
|
}
|
|
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 = `<span class="rc-ev-time">${mm}:${String(ss).padStart(2,'0')}</span>${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);
|
|
}
|
|
|
|
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._drawMapToCanvas();
|
|
}
|
|
|
|
_drawMapToCanvas() {
|
|
const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg;
|
|
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);
|
|
}
|
|
|
|
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._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
|
|
|
|
// Recompute autocrop for the new entity filter
|
|
this._computeAutocrop();
|
|
// Redraw map background with new crop region
|
|
this._drawMapToCanvas();
|
|
// Recompute death positions in new coordinate space
|
|
this._computeDeaths();
|
|
// Render immediately
|
|
this.render();
|
|
}
|
|
|
|
_togglePlay() {
|
|
this.playing = !this.playing;
|
|
this.playBtn.innerHTML = this.playing ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
|
|
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 = '<i class="fas fa-play"></i>';
|
|
}
|
|
}
|
|
this.render();
|
|
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._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; }
|
|
this.container.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
window.ReplayCanvas = ReplayCanvas;
|