/**
 * 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 = '<i class="fas fa-pause"></i>';
        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 = `
            <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._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
            ? `<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));
            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 = '<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);
    }

    _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 = `
            <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 = 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 ? '<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._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;
