f14d780f29
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
- Stream compact live replay updates across local and clustered dashboards. - Render responsive snake bodies as SVG paths with aligned custom icons. - Add cache-busted assets, replay fallback routes, and live-follow playback. - Support PostgreSQL benchmark sampling and idempotent SQLite migration. - Add dry-run cleanup for old low-quality PostgreSQL replay payloads. - Reward safe perimeter lanes and bump Prism to version 1.5.0. - Add backend, migration, dashboard, and perimeter regression coverage.
562 lines
24 KiB
JavaScript
562 lines
24 KiB
JavaScript
class GameBoard {
|
|
static SVG_NS = "http://www.w3.org/2000/svg";
|
|
// Stroke width as a fraction of the cell size. Slightly wider than the cell
|
|
// interior so the stroke bridges the 2px grid gap between adjacent cells.
|
|
static BODY_WIDTH_RATIO = 0.86;
|
|
// How far the body stroke runs past the point where the icon artwork starts.
|
|
static SEAM_OVERLAP_PX = 1;
|
|
|
|
constructor(boardEl) {
|
|
this._boardEl = boardEl;
|
|
this._svgCache = new Map();
|
|
this._iconLeadInset = new Map();
|
|
this._measureCanvas = null;
|
|
this._boardWidth = 0;
|
|
this._boardHeight = 0;
|
|
this._snakeLayer = null;
|
|
this._lastPaint = null;
|
|
this._lastArgs = null;
|
|
this._resizeObserver = typeof ResizeObserver === "undefined"
|
|
? null
|
|
: new ResizeObserver(() => {
|
|
this._fitBoard();
|
|
this._renderSnakeLayer();
|
|
});
|
|
if (this._resizeObserver && this._boardEl.parentElement) {
|
|
this._resizeObserver.observe(this._boardEl.parentElement);
|
|
}
|
|
}
|
|
|
|
clearBoard() {
|
|
this._boardEl.innerHTML = "";
|
|
this._boardEl.style.gridTemplateColumns = "none";
|
|
this._boardEl.style.gridTemplateRows = "none";
|
|
this._boardEl.style.width = "";
|
|
this._boardEl.style.height = "";
|
|
this._boardWidth = 0;
|
|
this._boardHeight = 0;
|
|
this._snakeLayer = null;
|
|
this._lastPaint = null;
|
|
this._lastArgs = null;
|
|
}
|
|
|
|
_fitBoard() {
|
|
const container = this._boardEl.parentElement;
|
|
if (!container || !this._boardWidth || !this._boardHeight) return;
|
|
|
|
const availableWidth = container.clientWidth;
|
|
const availableHeight = container.clientHeight;
|
|
if (availableWidth <= 0 || availableHeight <= 0) return;
|
|
|
|
const style = window.getComputedStyle(this._boardEl);
|
|
const horizontalChrome = Number.parseFloat(style.paddingLeft)
|
|
+ Number.parseFloat(style.paddingRight)
|
|
+ Number.parseFloat(style.borderLeftWidth)
|
|
+ Number.parseFloat(style.borderRightWidth);
|
|
const verticalChrome = Number.parseFloat(style.paddingTop)
|
|
+ Number.parseFloat(style.paddingBottom)
|
|
+ Number.parseFloat(style.borderTopWidth)
|
|
+ Number.parseFloat(style.borderBottomWidth);
|
|
const columnGap = Number.parseFloat(style.columnGap) || 0;
|
|
const rowGap = Number.parseFloat(style.rowGap) || 0;
|
|
const fixedWidth = horizontalChrome + (columnGap * Math.max(0, this._boardWidth - 1));
|
|
const fixedHeight = verticalChrome + (rowGap * Math.max(0, this._boardHeight - 1));
|
|
const cellSize = Math.max(0, Math.min(
|
|
(availableWidth - fixedWidth) / this._boardWidth,
|
|
(availableHeight - fixedHeight) / this._boardHeight,
|
|
));
|
|
|
|
this._boardEl.style.width = `${(cellSize * this._boardWidth) + fixedWidth}px`;
|
|
this._boardEl.style.height = `${(cellSize * this._boardHeight) + fixedHeight}px`;
|
|
}
|
|
|
|
async preloadSvgs(replay) {
|
|
if (!replay || !Array.isArray(replay.turns)) return;
|
|
const urls = new Set();
|
|
for (const turn of replay.turns) {
|
|
const snakes = turn && turn.board && Array.isArray(turn.board.snakes) ? turn.board.snakes : [];
|
|
for (const snake of snakes) {
|
|
const custom = snake && (snake.customizations || {});
|
|
const headUrl = SnakeUtils.buildCustomizationIconUrl("heads", custom.head);
|
|
const tailUrl = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
|
|
if (headUrl) urls.add(headUrl);
|
|
if (tailUrl) urls.add(tailUrl);
|
|
}
|
|
}
|
|
await Promise.all([...urls].map((url) => this._loadSvg(url)));
|
|
}
|
|
|
|
async _loadSvg(url) {
|
|
if (this._svgCache.has(url)) return this._svgCache.get(url);
|
|
let text = null;
|
|
try {
|
|
const res = await fetch(url);
|
|
text = res.ok ? await res.text() : null;
|
|
} catch {
|
|
text = null;
|
|
}
|
|
this._svgCache.set(url, text);
|
|
await this._measureLeadInset(url, text);
|
|
return text;
|
|
}
|
|
|
|
// How far the artwork sits back from the edge that meets the body, as a
|
|
// fraction of the cell. Most icons touch it (0), but a handful of designs
|
|
// start further in and would leave a visible seam if the stroke stopped at
|
|
// the cell border. Measured once per icon by rasterising it at the same
|
|
// aspect ratio the layer uses. A null result means the artwork never spans
|
|
// the full body width, so the stroke should not be pulled back at all.
|
|
async _measureLeadInset(url, svgMarkup) {
|
|
if (this._iconLeadInset.has(url)) return;
|
|
this._iconLeadInset.set(url, null);
|
|
if (!svgMarkup) return;
|
|
|
|
const width = 200;
|
|
const height = Math.round(width * GameBoard.BODY_WIDTH_RATIO);
|
|
try {
|
|
const parsed = new DOMParser().parseFromString(
|
|
this._normalizeIconSvgMarkup(svgMarkup) || svgMarkup, "image/svg+xml",
|
|
);
|
|
const svgEl = parsed.querySelector("svg");
|
|
if (!svgEl) return;
|
|
svgEl.setAttribute("preserveAspectRatio", "none");
|
|
svgEl.setAttribute("width", String(width));
|
|
svgEl.setAttribute("height", String(height));
|
|
|
|
const image = new Image();
|
|
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svgEl))}`;
|
|
await image.decode();
|
|
|
|
if (!this._measureCanvas) this._measureCanvas = document.createElement("canvas");
|
|
const canvas = this._measureCanvas;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
context.clearRect(0, 0, width, height);
|
|
context.drawImage(image, 0, 0, width, height);
|
|
const pixels = context.getImageData(0, 0, width, height).data;
|
|
|
|
// The edge must be covered across its whole width. Accepting "almost
|
|
// covered" leaves a notch at the seam corners that reads as a line, so
|
|
// the bar is every row inked, with a low alpha cut-off so antialiased
|
|
// edge pixels still count.
|
|
for (let x = 0; x < width / 2; x += 1) {
|
|
let covered = 0;
|
|
for (let y = 0; y < height; y += 1) {
|
|
if (pixels[(((y * width) + x) * 4) + 3] > 8) covered += 1;
|
|
}
|
|
if (covered === height) {
|
|
this._iconLeadInset.set(url, x / width);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Leave the inset unknown; the stroke then runs to the cell centre.
|
|
}
|
|
}
|
|
|
|
_parseViewBox(svgEl) {
|
|
const raw = String(svgEl.getAttribute("viewBox") || "").trim();
|
|
const parts = raw.split(/\s+/).map((item) => Number(item));
|
|
if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) {
|
|
return { minX: 0, minY: 0, width: 100, height: 100 };
|
|
}
|
|
return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
|
|
}
|
|
|
|
_groupLooksOffCanvas(groupEl, viewBox) {
|
|
const attrNames = new Set(["x", "y", "cx", "cy", "x1", "y1", "x2", "y2", "d", "points"]);
|
|
const allElements = [groupEl, ...groupEl.querySelectorAll("*")];
|
|
let farOutsideCount = 0;
|
|
let numericCount = 0;
|
|
const minAllowedX = viewBox.minX - Math.max(40, viewBox.width * 0.8);
|
|
const minAllowedY = viewBox.minY - Math.max(40, viewBox.height * 0.8);
|
|
const maxAllowedX = viewBox.minX + viewBox.width + Math.max(40, viewBox.width * 0.8);
|
|
const maxAllowedY = viewBox.minY + viewBox.height + Math.max(40, viewBox.height * 0.8);
|
|
|
|
for (const node of allElements) {
|
|
for (const attr of node.getAttributeNames()) {
|
|
if (!attrNames.has(attr)) continue;
|
|
const value = node.getAttribute(attr);
|
|
if (!value) continue;
|
|
const matches = value.match(/-?\d*\.?\d+/g);
|
|
if (!matches) continue;
|
|
for (let idx = 0; idx < matches.length; idx += 1) {
|
|
const num = Number(matches[idx]);
|
|
if (Number.isNaN(num)) continue;
|
|
numericCount += 1;
|
|
const isXCoord = idx % 2 === 0;
|
|
if (isXCoord) {
|
|
if (num < minAllowedX || num > maxAllowedX) farOutsideCount += 1;
|
|
} else {
|
|
if (num < minAllowedY || num > maxAllowedY) farOutsideCount += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (numericCount < 10) return false;
|
|
return farOutsideCount / numericCount > 0.55;
|
|
}
|
|
|
|
_maxNestedGroupDepth(groupEl) {
|
|
let maxDepth = 1;
|
|
const stack = [{ node: groupEl, depth: 1 }];
|
|
while (stack.length > 0) {
|
|
const entry = stack.pop();
|
|
if (!entry) continue;
|
|
maxDepth = Math.max(maxDepth, entry.depth);
|
|
for (const child of Array.from(entry.node.children)) {
|
|
if (!child.tagName || child.tagName.toLowerCase() !== "g") continue;
|
|
stack.push({ node: child, depth: entry.depth + 1 });
|
|
}
|
|
}
|
|
return maxDepth;
|
|
}
|
|
|
|
_normalizeIconSvgMarkup(svgMarkup) {
|
|
if (!svgMarkup) return null;
|
|
try {
|
|
const parser = new DOMParser();
|
|
const parsed = parser.parseFromString(svgMarkup, "image/svg+xml");
|
|
const svgEl = parsed.querySelector("svg");
|
|
if (!svgEl) return svgMarkup;
|
|
const topLevelGroups = Array.from(svgEl.children).filter(
|
|
(el) => el.tagName && el.tagName.toLowerCase() === "g"
|
|
);
|
|
if (topLevelGroups.length > 1) {
|
|
const viewBox = this._parseViewBox(svgEl);
|
|
const firstGroup = topLevelGroups[0];
|
|
if (this._groupLooksOffCanvas(firstGroup, viewBox) || this._maxNestedGroupDepth(firstGroup) >= 3) {
|
|
firstGroup.remove();
|
|
}
|
|
}
|
|
return new XMLSerializer().serializeToString(svgEl);
|
|
} catch {
|
|
return svgMarkup;
|
|
}
|
|
}
|
|
|
|
_createIconLayer(iconUrl, color, transformValue, type) {
|
|
const layer = document.createElement("div");
|
|
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
|
|
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
|
|
// The icon artwork joins the body along its full leading edge, so the layer
|
|
// is squeezed to the body stroke width and stretched (no aspect ratio) to
|
|
// meet the stroke end flush.
|
|
layer.style.setProperty("--icon-cross-inset", `${(1 - GameBoard.BODY_WIDTH_RATIO) * 50}%`);
|
|
|
|
// Both head and tail artwork is inlined rather than used as a CSS mask: an
|
|
// SVG referenced as an image keeps its own preserveAspectRatio, so it would
|
|
// letterbox inside the non-square layer and leave a gap towards the body.
|
|
const svgMarkup = this._svgCache.get(iconUrl);
|
|
if (svgMarkup) {
|
|
layer.innerHTML = this._normalizeIconSvgMarkup(svgMarkup);
|
|
const svgEl = layer.querySelector("svg");
|
|
if (svgEl) {
|
|
svgEl.setAttribute("preserveAspectRatio", "none");
|
|
svgEl.style.width = "100%";
|
|
svgEl.style.height = "100%";
|
|
svgEl.style.fill = color || "currentColor";
|
|
svgEl.removeAttribute("width");
|
|
svgEl.removeAttribute("height");
|
|
}
|
|
return layer;
|
|
}
|
|
|
|
// Artwork not cached yet (live turn arriving before the preload finished):
|
|
// fall back to the mask so the icon still shows, and repaint once loaded.
|
|
layer.classList.add("icon-layer--masked");
|
|
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
|
|
layer.style.setProperty("--icon-color", color || "var(--you)");
|
|
this._loadSvg(iconUrl).then((markup) => {
|
|
if (markup) this._repaintLast();
|
|
});
|
|
return layer;
|
|
}
|
|
|
|
_repaintLast() {
|
|
const args = this._lastArgs;
|
|
if (!args) return;
|
|
this.paintBoard(args.turnData, args.width, args.height, args.selectedSnakeId, args.replay);
|
|
}
|
|
|
|
_cellKey(x, y) {
|
|
return `${x}:${y}`;
|
|
}
|
|
|
|
// Collapses the stacked duplicate segments Battlesnake emits at spawn and
|
|
// right after eating, so the body becomes a clean orthogonal polyline.
|
|
_bodyPolyline(snake) {
|
|
const body = Array.isArray(snake && snake.body) ? snake.body : [];
|
|
const points = [];
|
|
for (const part of body) {
|
|
if (!part) continue;
|
|
const point = { x: Number(part.x), y: Number(part.y) };
|
|
if (Number.isNaN(point.x) || Number.isNaN(point.y)) continue;
|
|
const previous = points[points.length - 1];
|
|
if (previous && previous.x === point.x && previous.y === point.y) continue;
|
|
points.push(point);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
// Pixel geometry of the grid, derived from the same box metrics the CSS grid
|
|
// uses, so SVG coordinates land exactly on cell centres.
|
|
_gridMetrics() {
|
|
if (!this._boardWidth || !this._boardHeight) return null;
|
|
const style = window.getComputedStyle(this._boardEl);
|
|
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
|
|
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
|
|
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
|
|
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
|
|
const columnGap = Number.parseFloat(style.columnGap) || 0;
|
|
const rowGap = Number.parseFloat(style.rowGap) || 0;
|
|
const boxWidth = this._boardEl.clientWidth;
|
|
const boxHeight = this._boardEl.clientHeight;
|
|
const cellWidth = (boxWidth - paddingLeft - paddingRight - (columnGap * (this._boardWidth - 1))) / this._boardWidth;
|
|
const cellHeight = (boxHeight - paddingTop - paddingBottom - (rowGap * (this._boardHeight - 1))) / this._boardHeight;
|
|
if (!(cellWidth > 0) || !(cellHeight > 0)) return null;
|
|
return { paddingLeft, paddingTop, columnGap, rowGap, cellWidth, cellHeight, boxWidth, boxHeight };
|
|
}
|
|
|
|
_cellCenter(point, metrics) {
|
|
const row = this._boardHeight - 1 - point.y;
|
|
return {
|
|
x: metrics.paddingLeft + (point.x * (metrics.cellWidth + metrics.columnGap)) + (metrics.cellWidth / 2),
|
|
y: metrics.paddingTop + (row * (metrics.cellHeight + metrics.rowGap)) + (metrics.cellHeight / 2),
|
|
};
|
|
}
|
|
|
|
// Pulls the polyline end back so the customization icon owns its cell.
|
|
// Combined with a butt cap the stroke stops exactly where the artwork starts:
|
|
// at the cell border for the usual icon, deeper into the cell for artwork
|
|
// that sits back from that border (leadInset).
|
|
_retractEnd(endCenter, neighbourCenter, metrics, leadInset) {
|
|
const dx = endCenter.x - neighbourCenter.x;
|
|
const dy = endCenter.y - neighbourCenter.y;
|
|
const distance = Math.hypot(dx, dy);
|
|
if (!(distance > 0)) return endCenter;
|
|
const halfCell = Math.abs(dx) >= Math.abs(dy)
|
|
? metrics.cellWidth / 2
|
|
: metrics.cellHeight / 2;
|
|
// A full CSS pixel of overlap: both sides are the same colour, so overlap
|
|
// is free, and it keeps sub-pixel rounding from opening a hairline seam on
|
|
// displays with a fractional device pixel ratio.
|
|
const artworkOffset = (leadInset || 0) * halfCell * 2;
|
|
const pullBack = Math.min(distance, Math.max(0, halfCell - artworkOffset - GameBoard.SEAM_OVERLAP_PX));
|
|
return {
|
|
x: endCenter.x - ((dx / distance) * pullBack),
|
|
y: endCenter.y - ((dy / distance) * pullBack),
|
|
};
|
|
}
|
|
|
|
_appendRoundEnd(group, center, radius, color) {
|
|
const dot = document.createElementNS(GameBoard.SVG_NS, "circle");
|
|
dot.setAttribute("cx", `${center.x}`);
|
|
dot.setAttribute("cy", `${center.y}`);
|
|
dot.setAttribute("r", `${radius}`);
|
|
dot.setAttribute("fill", color);
|
|
group.appendChild(dot);
|
|
}
|
|
|
|
_renderSnakeLayer() {
|
|
if (!this._snakeLayer || !this._lastPaint) return;
|
|
const metrics = this._gridMetrics();
|
|
while (this._snakeLayer.firstChild) this._snakeLayer.removeChild(this._snakeLayer.firstChild);
|
|
if (!metrics) return;
|
|
|
|
this._snakeLayer.setAttribute("viewBox", `0 0 ${metrics.boxWidth} ${metrics.boxHeight}`);
|
|
this._snakeLayer.setAttribute("width", `${metrics.boxWidth}`);
|
|
this._snakeLayer.setAttribute("height", `${metrics.boxHeight}`);
|
|
|
|
const cellSize = Math.min(metrics.cellWidth, metrics.cellHeight);
|
|
const strokeWidth = cellSize * GameBoard.BODY_WIDTH_RATIO;
|
|
const { snakes, selectedSnakeId } = this._lastPaint;
|
|
|
|
for (const entry of snakes) {
|
|
const points = entry.points;
|
|
if (points.length === 0) continue;
|
|
const centers = points.map((point) => this._cellCenter(point, metrics));
|
|
const dimmed = Boolean(selectedSnakeId) && entry.snakeId !== selectedSnakeId;
|
|
// One group per snake so dimming applies once instead of stacking up on
|
|
// overlapping shapes.
|
|
const group = document.createElementNS(GameBoard.SVG_NS, "g");
|
|
if (dimmed) group.setAttribute("opacity", "0.2");
|
|
this._snakeLayer.appendChild(group);
|
|
|
|
if (centers.length === 1) {
|
|
// Fully stacked body (spawn turn): a single round blob, unless the head
|
|
// icon already fills that cell.
|
|
if (!entry.headIcon) {
|
|
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const last = centers.length - 1;
|
|
// An icon only takes over its cell if its artwork spans the full body
|
|
// width somewhere; otherwise the stroke runs to the cell centre and keeps
|
|
// its rounded end, with the icon drawn on top. Ends without an icon keep
|
|
// the rounded look via an explicit cap circle, because linecap applies to
|
|
// both ends of the path at once.
|
|
if (entry.headIcon && entry.headLeadInset !== null) {
|
|
centers[0] = this._retractEnd(centers[0], centers[1], metrics, entry.headLeadInset);
|
|
} else {
|
|
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
|
|
}
|
|
if (entry.tailIcon && entry.tailLeadInset !== null) {
|
|
centers[last] = this._retractEnd(centers[last], centers[last - 1], metrics, entry.tailLeadInset);
|
|
} else {
|
|
this._appendRoundEnd(group, centers[last], strokeWidth / 2, entry.color);
|
|
}
|
|
|
|
const path = document.createElementNS(GameBoard.SVG_NS, "path");
|
|
path.setAttribute("d", centers.map((point, idx) => `${idx === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" "));
|
|
path.setAttribute("fill", "none");
|
|
path.setAttribute("stroke", entry.color);
|
|
path.setAttribute("stroke-width", `${strokeWidth}`);
|
|
path.setAttribute("stroke-linecap", "butt");
|
|
path.setAttribute("stroke-linejoin", "round");
|
|
group.appendChild(path);
|
|
}
|
|
}
|
|
|
|
paintBoard(turnData, width, height, selectedSnakeId, replay) {
|
|
this.clearBoard();
|
|
if (!turnData || !width || !height) return;
|
|
this._lastArgs = { turnData, width, height, selectedSnakeId, replay };
|
|
const colorById = SnakeUtils.buildSnakeColorById(turnData, replay);
|
|
const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay);
|
|
|
|
this._boardWidth = Number(width);
|
|
this._boardHeight = Number(height);
|
|
this._boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(0, 1fr))`;
|
|
this._boardEl.style.gridTemplateRows = `repeat(${height}, minmax(0, 1fr))`;
|
|
this._fitBoard();
|
|
const foods = new Set((turnData.food || []).map((p) => this._cellKey(p.x, p.y)));
|
|
const hazards = new Set((turnData.hazards || []).map((p) => this._cellKey(p.x, p.y)));
|
|
|
|
const occupiedCells = new Set();
|
|
const snakeHead = new Set();
|
|
const snakeTail = new Map();
|
|
const headVariantByCell = new Map();
|
|
const tailVariantByCell = new Map();
|
|
const headIconByCell = new Map();
|
|
const tailIconByCell = new Map();
|
|
const headTransformByCell = new Map();
|
|
const tailTransformByCell = new Map();
|
|
const snakeColorByCell = new Map();
|
|
const snakeIdByCell = new Map();
|
|
const snakeEntries = [];
|
|
|
|
(turnData.snakes || []).forEach((snake, idx) => {
|
|
if (!snake) return;
|
|
const snakeId = snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${idx}`;
|
|
const bodyColor = SnakeUtils.resolveSnakeColor(snakeId, snake.is_you, colorById);
|
|
const custom = customById.get(snakeId) || {};
|
|
const headVariant = SnakeUtils.stableVariantFromString(custom.head);
|
|
const tailVariant = SnakeUtils.stableVariantFromString(custom.tail);
|
|
const headIcon = SnakeUtils.buildCustomizationIconUrl("heads", custom.head);
|
|
const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
|
|
const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake));
|
|
const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake));
|
|
const points = this._bodyPolyline(snake);
|
|
|
|
for (const part of (snake.body || [])) {
|
|
occupiedCells.add(this._cellKey(part.x, part.y));
|
|
snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId);
|
|
}
|
|
// `head` is authoritative when present, but some payloads omit it; the
|
|
// polyline start is the same cell.
|
|
const headPoint = snake.head || points[0] || null;
|
|
const headKey = headPoint ? this._cellKey(headPoint.x, headPoint.y) : null;
|
|
if (headKey !== null) {
|
|
snakeHead.add(headKey);
|
|
headVariantByCell.set(headKey, headVariant);
|
|
headTransformByCell.set(headKey, headTransform);
|
|
snakeColorByCell.set(headKey, bodyColor);
|
|
if (headIcon) headIconByCell.set(headKey, headIcon);
|
|
}
|
|
// A tail stacked under this snake's own head has no free cell to draw in.
|
|
// Another snake's head landing there must not suppress it, so the check is
|
|
// snake-local rather than against every head seen so far.
|
|
let drawTailIcon = false;
|
|
if (points.length > 0) {
|
|
const tail = points[points.length - 1];
|
|
const tailKey = this._cellKey(tail.x, tail.y);
|
|
drawTailIcon = Boolean(tailIcon) && tailKey !== headKey;
|
|
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
|
|
tailVariantByCell.set(tailKey, tailVariant);
|
|
tailTransformByCell.set(tailKey, tailTransform);
|
|
snakeColorByCell.set(tailKey, bodyColor);
|
|
if (drawTailIcon) tailIconByCell.set(tailKey, tailIcon);
|
|
}
|
|
|
|
const leadInset = (url) => (this._iconLeadInset.has(url) ? this._iconLeadInset.get(url) : 0);
|
|
snakeEntries.push({
|
|
snakeId,
|
|
color: bodyColor,
|
|
points,
|
|
headIcon: Boolean(headIcon),
|
|
tailIcon: drawTailIcon,
|
|
headLeadInset: headIcon ? leadInset(headIcon) : 0,
|
|
tailLeadInset: drawTailIcon ? leadInset(tailIcon) : 0,
|
|
});
|
|
});
|
|
|
|
for (let y = height - 1; y >= 0; y--) {
|
|
for (let x = 0; x < width; x++) {
|
|
const key = this._cellKey(x, y);
|
|
const cell = document.createElement("div");
|
|
cell.className = "cell";
|
|
const occupied = occupiedCells.has(key);
|
|
if (hazards.has(key)) {
|
|
cell.classList.add("hazard");
|
|
// Keep the hazard hatch readable on top of the snake stroke.
|
|
if (occupied) cell.classList.add("hazard-over-snake");
|
|
}
|
|
if (foods.has(key) && !occupied) cell.classList.add("food");
|
|
if (occupied && selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
|
|
cell.style.opacity = "0.2";
|
|
}
|
|
if (snakeTail.has(key)) {
|
|
cell.classList.add(snakeTail.get(key));
|
|
cell.classList.add(`tail-style-${tailVariantByCell.get(key) || 1}`);
|
|
const tailIcon = tailIconByCell.get(key);
|
|
if (tailIcon) {
|
|
cell.classList.add("has-tail-icon", "icon-tail");
|
|
cell.appendChild(this._createIconLayer(
|
|
tailIcon,
|
|
snakeColorByCell.get(key) || "var(--you)",
|
|
tailTransformByCell.get(key) || "scaleX(-1)",
|
|
"tail",
|
|
));
|
|
}
|
|
}
|
|
if (snakeHead.has(key)) {
|
|
cell.classList.add("snake-head");
|
|
cell.classList.add(`head-style-${headVariantByCell.get(key) || 1}`);
|
|
const headIcon = headIconByCell.get(key);
|
|
if (headIcon) {
|
|
cell.classList.add("has-head-icon", "icon-head");
|
|
cell.appendChild(this._createIconLayer(
|
|
headIcon,
|
|
snakeColorByCell.get(key) || "var(--you)",
|
|
headTransformByCell.get(key) || "rotate(0deg)",
|
|
"head",
|
|
));
|
|
}
|
|
}
|
|
this._boardEl.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
this._snakeLayer = document.createElementNS(GameBoard.SVG_NS, "svg");
|
|
this._snakeLayer.setAttribute("class", "snake-layer");
|
|
this._boardEl.appendChild(this._snakeLayer);
|
|
this._lastPaint = { snakes: snakeEntries, selectedSnakeId: selectedSnakeId || null };
|
|
this._renderSnakeLayer();
|
|
}
|
|
}
|