move dashboard script block content into own files with new classes to update, render to have a better code overview
Build and Push Docker Container / build-and-push (push) Successful in 3m55s
Build and Push Docker Container / build-and-push (push) Successful in 3m55s
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
class GameBoard {
|
||||
constructor(boardEl) {
|
||||
this._boardEl = boardEl;
|
||||
this._svgCache = new Map();
|
||||
}
|
||||
|
||||
clearBoard() {
|
||||
this._boardEl.innerHTML = "";
|
||||
this._boardEl.style.gridTemplateColumns = "none";
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const text = res.ok ? await res.text() : null;
|
||||
this._svgCache.set(url, text);
|
||||
return text;
|
||||
} catch {
|
||||
this._svgCache.set(url, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
_normalizeHeadSvgMarkup(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)");
|
||||
if (type === "head") {
|
||||
const svgMarkup = this._svgCache.get(iconUrl);
|
||||
if (svgMarkup) {
|
||||
layer.innerHTML = this._normalizeHeadSvgMarkup(svgMarkup);
|
||||
const svgEl = layer.querySelector("svg");
|
||||
if (svgEl) {
|
||||
svgEl.style.width = "100%";
|
||||
svgEl.style.height = "100%";
|
||||
svgEl.style.fill = color || "currentColor";
|
||||
svgEl.removeAttribute("width");
|
||||
svgEl.removeAttribute("height");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
|
||||
layer.style.setProperty("--icon-color", color || "var(--you)");
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
_cellKey(x, y) {
|
||||
return `${x}:${y}`;
|
||||
}
|
||||
|
||||
paintBoard(turnData, width, height, selectedSnakeId, replay) {
|
||||
this.clearBoard();
|
||||
if (!turnData || !width || !height) return;
|
||||
const colorById = SnakeUtils.buildSnakeColorById(turnData, replay);
|
||||
const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay);
|
||||
|
||||
this._boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
|
||||
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 snakeBody = new Map();
|
||||
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();
|
||||
|
||||
(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));
|
||||
|
||||
for (const part of (snake.body || [])) {
|
||||
snakeBody.set(this._cellKey(part.x, part.y), bodyColor);
|
||||
snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId);
|
||||
}
|
||||
if (snake.head) {
|
||||
const headKey = this._cellKey(snake.head.x, snake.head.y);
|
||||
snakeHead.add(headKey);
|
||||
headVariantByCell.set(headKey, headVariant);
|
||||
headTransformByCell.set(headKey, headTransform);
|
||||
snakeColorByCell.set(headKey, bodyColor);
|
||||
if (headIcon) headIconByCell.set(headKey, headIcon);
|
||||
}
|
||||
if (Array.isArray(snake.body) && snake.body.length > 0) {
|
||||
const tail = snake.body[snake.body.length - 1];
|
||||
const tailKey = this._cellKey(tail.x, tail.y);
|
||||
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 (tailIcon) tailIconByCell.set(tailKey, tailIcon);
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
if (hazards.has(key)) cell.classList.add("hazard");
|
||||
if (foods.has(key)) cell.classList.add("food");
|
||||
if (snakeBody.has(key)) {
|
||||
const bodyColor = snakeBody.get(key);
|
||||
const hasHeadIcon = headIconByCell.has(key);
|
||||
const hasTailIcon = tailIconByCell.has(key);
|
||||
const isIconCell = hasHeadIcon || hasTailIcon;
|
||||
cell.style.borderRadius = "0";
|
||||
if (!isIconCell) cell.style.background = bodyColor;
|
||||
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
|
||||
cell.style.opacity = "0.2";
|
||||
}
|
||||
|
||||
const snakeId = snakeIdByCell.get(key);
|
||||
if (snakeId) {
|
||||
const up = snakeIdByCell.get(this._cellKey(x, y + 1)) === snakeId;
|
||||
const down = snakeIdByCell.get(this._cellKey(x, y - 1)) === snakeId;
|
||||
const left = snakeIdByCell.get(this._cellKey(x - 1, y)) === snakeId;
|
||||
const right = snakeIdByCell.get(this._cellKey(x + 1, y)) === snakeId;
|
||||
|
||||
if (!snakeHead.has(key) && !snakeTail.has(key)) {
|
||||
if (up && right && !down && !left) {
|
||||
cell.classList.add("snake-turn-cell", "snake-turn-dl");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
} else if (up && left && !down && !right) {
|
||||
cell.classList.add("snake-turn-cell", "snake-turn-dr");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
} else if (down && right && !up && !left) {
|
||||
cell.classList.add("snake-turn-cell", "snake-turn-ul");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
} else if (down && left && !up && !right) {
|
||||
cell.classList.add("snake-turn-cell", "snake-turn-ur");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
}
|
||||
}
|
||||
|
||||
// Outward shadows bridge the 2px gap to adjacent snake cells.
|
||||
// For icon cells (head/tail), also add inset shadows to color the
|
||||
// connecting edge of the cell itself, since the background stays
|
||||
// transparent so the icon remains visible.
|
||||
const bridgeShadows = [];
|
||||
if (up) {
|
||||
bridgeShadows.push(`0 -2px 0 ${bodyColor}`);
|
||||
if (isIconCell) bridgeShadows.push(`inset 0 2px 0 ${bodyColor}`);
|
||||
}
|
||||
if (down) {
|
||||
bridgeShadows.push(`0 2px 0 ${bodyColor}`);
|
||||
if (isIconCell) bridgeShadows.push(`inset 0 -2px 0 ${bodyColor}`);
|
||||
}
|
||||
if (left) {
|
||||
bridgeShadows.push(`-2px 0 0 ${bodyColor}`);
|
||||
if (isIconCell) bridgeShadows.push(`inset 2px 0 0 ${bodyColor}`);
|
||||
}
|
||||
if (right) {
|
||||
bridgeShadows.push(`2px 0 0 ${bodyColor}`);
|
||||
if (isIconCell) bridgeShadows.push(`inset -2px 0 0 ${bodyColor}`);
|
||||
}
|
||||
if (bridgeShadows.length > 0) cell.style.boxShadow = bridgeShadows.join(", ");
|
||||
}
|
||||
}
|
||||
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 && !snakeHead.has(key)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user