update renderer and web viewer to correctly cut map and show caps (#1261)

This commit is contained in:
NotSoToothless
2026-05-19 15:37:19 -07:00
committed by GitHub
parent 899dfbb9e5
commit 2c9e89eee2
3 changed files with 1448 additions and 236 deletions
+149 -70
View File
@@ -12,6 +12,9 @@ const RC = {
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;
function replayT(key) {
return (window.__t && window.__t(key)) || key;
@@ -39,6 +42,7 @@ class ReplayCanvas {
this._groundCoords = data.levelCoords;
this._airCoords = data.mapCoords || null;
this._fullMapLevel = data.fullMapLevel || null;
this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : [];
this._mode = 'ground';
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
@@ -47,7 +51,7 @@ class ReplayCanvas {
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
// Source rect for minimap image (full image by default)
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
this.players = {};
@@ -74,9 +78,6 @@ class ReplayCanvas {
});
}
// Zoom into the area players actually use (like the video maker autocrop)
this._computeAutocrop();
// Pre-compute deaths
this._computeDeaths();
this.currentTime = this.tStart;
@@ -110,70 +111,6 @@ class ReplayCanvas {
];
}
/** 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;
@@ -533,6 +470,140 @@ class ReplayCanvas {
ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh);
}
_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) {
const outline = this._pickContrastOutlineColor(outlinePoints);
this.mapCtx.strokeStyle = outline;
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)],
];
const outline = this._pickContrastOutlineColor(ringSamples);
this.mapCtx.strokeStyle = outline;
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);
}
}
async _loadEntityIcons() {
this._iconCache = {};
this._iconDebug = {};
@@ -587,12 +658,21 @@ class ReplayCanvas {
} 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);
@@ -602,6 +682,7 @@ class ReplayCanvas {
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) {
@@ -617,8 +698,6 @@ class ReplayCanvas {
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