| 1 | import './style.css'; |
| 2 | import logoUrl from '../water.png'; |
| 3 | import { Game } from './game'; |
| 4 | import { |
| 5 | SIZE_MAX, |
| 6 | decodeConfig, |
| 7 | encodeConfig, |
| 8 | normalizeConfig, |
| 9 | randomConfig, |
| 10 | } from './model/puzzle'; |
| 11 | import type { Config } from './model/puzzle'; |
| 12 | |
| 13 | // Logo asset → on-page logo and favicon. |
| 14 | document.querySelector<HTMLImageElement>('#logo')!.src = logoUrl; |
| 15 | const favicon = document.createElement('link'); |
| 16 | favicon.rel = 'icon'; |
| 17 | favicon.href = logoUrl; |
| 18 | document.head.appendChild(favicon); |
| 19 | |
| 20 | const $ = <T extends HTMLElement>(sel: string): T => document.querySelector<T>(sel)!; |
| 21 | |
| 22 | const canvas = $<HTMLCanvasElement>('#board'); |
| 23 | const status = $<HTMLElement>('#status'); |
| 24 | const loadingEl = $<HTMLElement>('#loading'); |
| 25 | const progressBarEl = $<HTMLElement>('#progress-bar'); |
| 26 | |
| 27 | function setProgress(fraction: number): void { |
| 28 | progressBarEl.style.width = `${Math.round(fraction * 100)}%`; |
| 29 | } |
| 30 | |
| 31 | function showLoading(on: boolean): void { |
| 32 | loadingEl.classList.toggle('loading--visible', on); |
| 33 | if (on) setProgress(0); |
| 34 | } |
| 35 | |
| 36 | const game = new Game(canvas, status, setProgress); |
| 37 | |
| 38 | // Config controls. |
| 39 | const sizeInput = $<HTMLInputElement>('#cfg-size'); |
| 40 | sizeInput.max = String(SIZE_MAX); |
| 41 | const sizeVal = $<HTMLElement>('#cfg-size-val'); |
| 42 | const springsInput = $<HTMLInputElement>('#cfg-springs'); |
| 43 | const erosionInput = $<HTMLInputElement>('#cfg-erosion'); |
| 44 | const erosionVal = $<HTMLElement>('#cfg-erosion-val'); |
| 45 | const reachInput = $<HTMLInputElement>('#cfg-reach'); |
| 46 | const reachVal = $<HTMLElement>('#cfg-reach-val'); |
| 47 | const wall = { |
| 48 | n: $<HTMLInputElement>('#cfg-wall-n'), |
| 49 | e: $<HTMLInputElement>('#cfg-wall-e'), |
| 50 | s: $<HTMLInputElement>('#cfg-wall-s'), |
| 51 | w: $<HTMLInputElement>('#cfg-wall-w'), |
| 52 | }; |
| 53 | const seedInput = $<HTMLInputElement>('#cfg-seed'); |
| 54 | const codeInput = $<HTMLInputElement>('#cfg-code'); |
| 55 | |
| 56 | // The config is the single source of truth; the code and every control are just |
| 57 | // two-way views of it. |
| 58 | let config: Config; |
| 59 | |
| 60 | /** Reflect the current config back into every control + the shared code + URL. */ |
| 61 | function syncControls(): void { |
| 62 | sizeInput.value = String(config.size); |
| 63 | sizeVal.textContent = `${config.size}×${config.size}`; |
| 64 | springsInput.value = String(config.springCount); |
| 65 | erosionInput.value = String(config.erosion); |
| 66 | erosionVal.textContent = agingLabel(config.erosion); |
| 67 | reachInput.value = String(config.reach); |
| 68 | reachVal.textContent = reachLabel(config.reach); |
| 69 | wall.n.checked = config.edges.n === 'wall'; |
| 70 | wall.e.checked = config.edges.e === 'wall'; |
| 71 | wall.s.checked = config.edges.s === 'wall'; |
| 72 | wall.w.checked = config.edges.w === 'wall'; |
| 73 | seedInput.value = String(config.terrainSeed); |
| 74 | codeInput.value = encodeConfig(config); |
| 75 | |
| 76 | const url = new URL(location.href); |
| 77 | url.searchParams.set('code', codeInput.value); |
| 78 | history.replaceState(null, '', url); |
| 79 | } |
| 80 | |
| 81 | /** Adopt a new config: normalise it, rebuild the board, resync the UI. */ |
| 82 | async function apply(next: Config): Promise<void> { |
| 83 | config = normalizeConfig(next); |
| 84 | syncControls(); |
| 85 | showLoading(true); |
| 86 | await game.load(config); |
| 87 | showLoading(false); |
| 88 | } |
| 89 | |
| 90 | const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean'); |
| 91 | |
| 92 | // Which config axes are pinned against Randomize. Session-only: locks aren't part |
| 93 | // of a board (the code encodes the config, not what you froze while rolling). |
| 94 | type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'reach' | 'terrainSeed'; |
| 95 | const locks: Record<LockAxis, boolean> = { |
| 96 | size: false, |
| 97 | springCount: false, |
| 98 | edges: false, |
| 99 | erosion: false, |
| 100 | reach: false, |
| 101 | terrainSeed: false, |
| 102 | }; |
| 103 | |
| 104 | /** Reflect a lock button's state into its icon, label, and its control's styling. */ |
| 105 | function syncLock(btn: HTMLButtonElement, on: boolean): void { |
| 106 | btn.textContent = on ? '🔒' : '🔓'; |
| 107 | btn.setAttribute('aria-pressed', String(on)); |
| 108 | btn.title = on ? 'Locked — Random won’t change this' : 'Lock this against Random'; |
| 109 | btn.closest('.ctl')?.classList.toggle('locked', on); |
| 110 | } |
| 111 | |
| 112 | for (const btn of document.querySelectorAll<HTMLButtonElement>('.lock')) { |
| 113 | const axis = btn.dataset.lock as LockAxis; |
| 114 | syncLock(btn, locks[axis]); |
| 115 | btn.addEventListener('click', () => { |
| 116 | locks[axis] = !locks[axis]; |
| 117 | syncLock(btn, locks[axis]); |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | /** A fresh random config, but every locked axis keeps its current value. */ |
| 122 | function randomizeUnlocked(): Config { |
| 123 | const r = randomConfig(); |
| 124 | return { |
| 125 | size: locks.size ? config.size : r.size, |
| 126 | springCount: locks.springCount ? config.springCount : r.springCount, |
| 127 | edges: locks.edges ? { ...config.edges } : r.edges, |
| 128 | erosion: locks.erosion ? config.erosion : r.erosion, |
| 129 | reach: locks.reach ? config.reach : r.reach, |
| 130 | terrainSeed: locks.terrainSeed ? config.terrainSeed : r.terrainSeed, |
| 131 | }; |
| 132 | } |
| 133 | |
| 134 | /** Human tag for an erosion level, so the slider reads as more than a number. */ |
| 135 | function agingLabel(n: number): string { |
| 136 | const word = n === 0 ? 'pristine' : n <= 4 ? 'weathered' : n <= 9 ? 'eroded' : 'dissected'; |
| 137 | return `${n} · ${word}`; |
| 138 | } |
| 139 | |
| 140 | /** Human tag for a reach level, so the slider reads as more than a number. */ |
| 141 | function reachLabel(n: number): string { |
| 142 | const word = n <= 3 ? 'shore' : n <= 11 ? 'mixed' : 'peaks'; |
| 143 | return `${n} · ${word}`; |
| 144 | } |
| 145 | |
| 146 | // Live-update the size label while dragging, but only rebuild on release. |
| 147 | sizeInput.addEventListener('input', () => (sizeVal.textContent = `${sizeInput.value}×${sizeInput.value}`)); |
| 148 | sizeInput.addEventListener('change', () => apply({ ...config, size: Number(sizeInput.value) })); |
| 149 | springsInput.addEventListener('change', () => |
| 150 | apply({ ...config, springCount: Number(springsInput.value) }), |
| 151 | ); |
| 152 | // Live-update the aging label while dragging; erosion is costly, so only rebuild |
| 153 | // on release. |
| 154 | erosionInput.addEventListener('input', () => (erosionVal.textContent = agingLabel(Number(erosionInput.value)))); |
| 155 | erosionInput.addEventListener('change', () => apply({ ...config, erosion: Number(erosionInput.value) })); |
| 156 | // Live-update the reach label while dragging, but only rebuild on release. |
| 157 | reachInput.addEventListener('input', () => (reachVal.textContent = reachLabel(Number(reachInput.value)))); |
| 158 | reachInput.addEventListener('change', () => apply({ ...config, reach: Number(reachInput.value) })); |
| 159 | for (const side of ['n', 'e', 's', 'w'] as const) { |
| 160 | wall[side].addEventListener('change', () => |
| 161 | apply({ ...config, edges: { ...config.edges, [side]: edgeOf(wall[side].checked) } }), |
| 162 | ); |
| 163 | } |
| 164 | seedInput.addEventListener('change', () => { |
| 165 | // Free-text field: accept a non-negative integer, otherwise snap back. |
| 166 | const raw = seedInput.value.trim(); |
| 167 | if (/^\d+$/.test(raw)) apply({ ...config, terrainSeed: Number(raw) >>> 0 }); |
| 168 | else syncControls(); |
| 169 | }); |
| 170 | $<HTMLButtonElement>('#cfg-seed-roll').addEventListener('click', () => |
| 171 | apply({ ...config, terrainSeed: (Math.random() * 2 ** 32) >>> 0 }), |
| 172 | ); |
| 173 | codeInput.addEventListener('change', () => { |
| 174 | const decoded = decodeConfig(codeInput.value); |
| 175 | if (decoded) apply(decoded); |
| 176 | else syncControls(); // reject: snap back to the current config |
| 177 | }); |
| 178 | $<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomizeUnlocked())); |
| 179 | |
| 180 | // Start from a shared code if present, otherwise a fresh random board. |
| 181 | const codeParam = new URLSearchParams(location.search).get('code'); |
| 182 | apply((codeParam && decodeConfig(codeParam)) || randomConfig()); |