update renderer to have cap status also tickets (#1325)

This commit is contained in:
NotSoToothless
2026-06-14 22:36:06 -07:00
committed by GitHub
parent 4b75ce1533
commit deb4e0fb12
4 changed files with 355 additions and 4 deletions
+133
View File
@@ -15,6 +15,7 @@ const RC = {
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;
@@ -44,6 +45,13 @@ class ReplayCanvas {
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';
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
@@ -179,6 +187,9 @@ class ReplayCanvas {
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);
this.canvas = document.createElement('canvas');
this.canvas.width = this.canvasSize;
this.canvas.height = this.canvasSize;
@@ -233,6 +244,7 @@ class ReplayCanvas {
this._updatePanelDeathStates();
this._updateBattleLog();
this.render();
this._updateTicketsBar(this.currentTime);
});
controls.querySelectorAll('.rc-sp').forEach(btn => {
btn.addEventListener('click', () => {
@@ -651,6 +663,125 @@ class ReplayCanvas {
}
}
// ── 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 ──────────────────────────────────────────────────────
_teamSlotName(slot) {
return (this._teamNames && this._teamNames[String(slot)])
|| (slot === this._winnerSlot ? 'Winner' : 'Loser');
}
_buildTicketsBar(center) {
if (!this._tickets) return;
const winSlot = this._winnerSlot || 1;
const loseSlot = winSlot === 1 ? 2 : 1;
this._tkWinSlot = winSlot;
this._tkLoseSlot = loseSlot;
const bar = document.createElement('div');
bar.className = 'rc-tickets';
bar.innerHTML = `
<div class="rc-tk-side rc-tk-win">
<span class="rc-tk-name">${this._esc(this._teamSlotName(winSlot))}</span>
<span class="rc-tk-val rc-tk-val-win">0</span>
</div>
<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>
<div class="rc-tk-side rc-tk-lose">
<span class="rc-tk-val rc-tk-val-lose">0</span>
<span class="rc-tk-name">${this._esc(this._teamSlotName(loseSlot))}</span>
</div>`;
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;
}
async _loadEntityIcons() {
this._iconCache = {};
this._iconDebug = {};
@@ -774,6 +905,7 @@ class ReplayCanvas {
}
}
this.render();
this._updateTicketsBar(this.currentTime);
this._updateControls();
// Update panel death states + battle log every ~250ms
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
@@ -815,6 +947,7 @@ class ReplayCanvas {
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);