const SIZE = 5; export const SIGNAL_GARDEN_MODES = Object.freeze([ "open", "trail", "wrap", "sequence", "rotate", "mirror", "rhythm", "trail-gates", "wrap-current", "wrap-anchor", "reverse", "rotate-double", "rotate-counter", "mirror-all", "mirror-toggle", "rhythm-every", "rhythm-third" ]); const starts = [ [0, 4], [2, 1], [4, 3], [1, 0], [3, 4], [4, 0] ]; export function mountSignalGarden(container, definition) { let position = 12; let collected = new Set(); let blocked = new Set(); let trail = []; let moves = 0; let rotation = 0; let mirrorHorizontal = definition.parameters.mode === "mirror"; let destroyed = false; const heading = document.createElement("h2"); heading.id = "play-heading"; heading.tabIndex = -1; heading.dataset.focusKey = "artifact-heading"; heading.textContent = definition.title; const summary = document.createElement("p"); summary.className = "artifact-summary"; summary.textContent = definition.summary; const rule = document.createElement("p"); rule.className = "rule-card"; const ruleLabel = document.createElement("strong"); ruleLabel.textContent = "Playable rule"; const ruleText = document.createElement("span"); ruleText.textContent = definition.parameters.rule; rule.append(ruleLabel, ruleText); const instructions = document.createElement("p"); instructions.id = "artifact-instructions"; instructions.className = "instructions"; instructions.textContent = definition.instructions; const board = document.createElement("div"); board.className = `garden garden--${definition.parameters.accent}`; board.setAttribute("role", "group"); board.setAttribute("aria-label", `${definition.title} play field`); board.setAttribute("aria-describedby", "artifact-instructions artifact-progress"); const progress = document.createElement("p"); progress.id = "artifact-progress"; progress.className = "progress"; progress.setAttribute("aria-live", "polite"); const reset = document.createElement("button"); reset.type = "button"; reset.className = "artifact-reset"; reset.textContent = "Reset artifact"; reset.dataset.focusKey = "artifact-reset"; const pulseCells = () => starts.slice(0, definition.parameters.target).map(([x, y]) => y * SIZE + x); const wanted = () => definition.parameters.mode === "reverse" ? pulseCells()[definition.parameters.target - 1 - collected.size] : pulseCells()[collected.size]; function draw() { const restoreSparkFocus = board.contains(document.activeElement); board.replaceChildren(); const pulses = pulseCells(); for (let index = 0; index < SIZE * SIZE; index += 1) { const cell = document.createElement("button"); cell.type = "button"; cell.className = "garden-cell"; cell.dataset.index = String(index); cell.tabIndex = index === position ? 0 : -1; const pulseIndex = pulses.indexOf(index); const hasPulse = pulseIndex >= 0 && !collected.has(index); const location = `row ${Math.floor(index / SIZE) + 1}, column ${(index % SIZE) + 1}`; if (index === position) { cell.classList.add("is-spark"); cell.textContent = "✦"; cell.setAttribute("aria-label", `Spark, ${location}. Use Arrow keys or WASD to move.`); cell.setAttribute("aria-current", "true"); } else if (blocked.has(index)) { cell.classList.add("is-blocked"); cell.textContent = "◆"; cell.setAttribute("aria-label", `Blocked tile, ${location}`); cell.disabled = true; } else if (hasPulse) { cell.classList.add("is-pulse"); cell.textContent = ["1", "2", "3", "4", "5", "6"][pulseIndex]; cell.setAttribute("aria-label", `Pulse ${pulseIndex + 1}, ${location}`); } else { cell.textContent = "·"; cell.setAttribute("aria-label", `Empty tile, ${location}`); } cell.addEventListener("click", onCell); board.append(cell); } progress.textContent = collected.size === definition.parameters.target ? `Garden complete — ${collected.size} of ${definition.parameters.target} pulses collected.` : `${collected.size} of ${definition.parameters.target} pulses collected.`; if (restoreSparkFocus) board.querySelector(".is-spark")?.focus(); } function collect() { const pulses = pulseCells(); if (!pulses.includes(position) || collected.has(position)) return; if (["sequence", "reverse"].includes(definition.parameters.mode) && position !== wanted()) return; collected.add(position); if (definition.parameters.mode === "rotate") rotation = (rotation + 1) % 4; if (definition.parameters.mode === "rotate-double") rotation = (rotation + 2) % 4; if (definition.parameters.mode === "rotate-counter") rotation = (rotation + 3) % 4; if (definition.parameters.mode === "mirror-toggle") mirrorHorizontal = !mirrorHorizontal; if (definition.parameters.mode === "wrap-anchor") blocked.add(position); } function move(dx, dy) { if (destroyed || collected.size === definition.parameters.target) return; let actualDx = dx; let actualDy = dy; if (mirrorHorizontal || definition.parameters.mode === "mirror-all") actualDx *= -1; if (definition.parameters.mode === "mirror-all") actualDy *= -1; for (let i = 0; i < rotation; i += 1) [actualDx, actualDy] = [-actualDy, actualDx]; const x = position % SIZE; const y = Math.floor(position / SIZE); let nx = x + actualDx; let ny = y + actualDy; const wraps = ["wrap", "trail-gates", "wrap-current", "wrap-anchor"]; const crossed = nx < 0 || nx >= SIZE || ny < 0 || ny >= SIZE; if (wraps.includes(definition.parameters.mode)) { nx = (nx + SIZE) % SIZE; ny = (ny + SIZE) % SIZE; if (crossed && definition.parameters.mode === "wrap-current") { nx = (nx + actualDx + SIZE) % SIZE; ny = (ny + actualDy + SIZE) % SIZE; } } else if (crossed) return; const nextPosition = ny * SIZE + nx; if (blocked.has(nextPosition)) return; const previous = position; position = nextPosition; moves += 1; if (["trail", "trail-gates"].includes(definition.parameters.mode)) { blocked.add(previous); trail.push(previous); if (crossed && definition.parameters.mode === "trail-gates" && trail.length) blocked.delete(trail.shift()); } if (definition.parameters.mode === "rhythm" && moves % 2 === 0) blocked.add(previous); if (definition.parameters.mode === "rhythm-every") blocked.add(previous); if (definition.parameters.mode === "rhythm-third" && moves % 3 === 0) blocked.add(previous); collect(); draw(); } function onCell(event) { const destination = Number(event.currentTarget.dataset.index); const dx = (destination % SIZE) - (position % SIZE); const dy = Math.floor(destination / SIZE) - Math.floor(position / SIZE); if (Math.abs(dx) + Math.abs(dy) === 1) move(dx, dy); } function onKey(event) { const keys = { ArrowUp: [0, -1], w: [0, -1], W: [0, -1], ArrowDown: [0, 1], s: [0, 1], S: [0, 1], ArrowLeft: [-1, 0], a: [-1, 0], A: [-1, 0], ArrowRight: [1, 0], d: [1, 0], D: [1, 0] }; if (!keys[event.key]) return; event.preventDefault(); move(...keys[event.key]); } function resetRuntime() { if (destroyed) return; position = 12; collected = new Set(); blocked = new Set(); trail = []; moves = 0; rotation = 0; mirrorHorizontal = definition.parameters.mode === "mirror"; collect(); draw(); } board.addEventListener("keydown", onKey); reset.addEventListener("click", resetRuntime); container.replaceChildren(heading, summary, rule, instructions, board, progress, reset); resetRuntime(); return { destroy() { if (destroyed) return; destroyed = true; board.removeEventListener("keydown", onKey); reset.removeEventListener("click", resetRuntime); container.replaceChildren(); } }; }