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); } } } }