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