update renderer to have cap status also tickets (#1325)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user