fuzzyworld

basin / files / src / main.ts

main.ts

Blame: the commit that last changed each line · file · history

9e605e0f 2026-06-30 Pavel Safronov1import './style.css';
2import logoUrl from '../water.png';
3import { Game } from './game';
a7bf123a 2026-07-01 Pavel Safronov4import {
b9acd697 2026-07-01 Pavel Safronov5 SIZE_MAX,
a7bf123a 2026-07-01 Pavel Safronov6 decodeConfig,
7 encodeConfig,
8 normalizeConfig,
9 randomConfig,
10} from './model/puzzle';
11import type { Config } from './model/puzzle';
9e605e0f 2026-06-30 Pavel Safronov12
13// Logo asset → on-page logo and favicon.
14document.querySelector<HTMLImageElement>('#logo')!.src = logoUrl;
15const favicon = document.createElement('link');
16favicon.rel = 'icon';
17favicon.href = logoUrl;
18document.head.appendChild(favicon);
19
a7bf123a 2026-07-01 Pavel Safronov20const $ = <T extends HTMLElement>(sel: string): T => document.querySelector<T>(sel)!;
9e605e0f 2026-06-30 Pavel Safronov21
a7bf123a 2026-07-01 Pavel Safronov22const canvas = $<HTMLCanvasElement>('#board');
23const status = $<HTMLElement>('#status');
b9acd697 2026-07-01 Pavel Safronov24const loadingEl = $<HTMLElement>('#loading');
25const progressBarEl = $<HTMLElement>('#progress-bar');
26
27function setProgress(fraction: number): void {
28 progressBarEl.style.width = `${Math.round(fraction * 100)}%`;
29}
30
31function showLoading(on: boolean): void {
32 loadingEl.classList.toggle('loading--visible', on);
33 if (on) setProgress(0);
34}
35
36const game = new Game(canvas, status, setProgress);
9e605e0f 2026-06-30 Pavel Safronov37
a7bf123a 2026-07-01 Pavel Safronov38// Config controls.
39const sizeInput = $<HTMLInputElement>('#cfg-size');
b9acd697 2026-07-01 Pavel Safronov40sizeInput.max = String(SIZE_MAX);
a7bf123a 2026-07-01 Pavel Safronov41const sizeVal = $<HTMLElement>('#cfg-size-val');
42const springsInput = $<HTMLInputElement>('#cfg-springs');
03c2cc12 2026-07-01 Pavel Safronov43const erosionInput = $<HTMLInputElement>('#cfg-erosion');
44const erosionVal = $<HTMLElement>('#cfg-erosion-val');
36e6a8b9 2026-07-02 Pavel Safronov45const reachInput = $<HTMLInputElement>('#cfg-reach');
46const reachVal = $<HTMLElement>('#cfg-reach-val');
a7bf123a 2026-07-01 Pavel Safronov47const 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};
53const seedInput = $<HTMLInputElement>('#cfg-seed');
54const 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.
58let config: Config;
59
60/** Reflect the current config back into every control + the shared code + URL. */
61function syncControls(): void {
62 sizeInput.value = String(config.size);
63 sizeVal.textContent = `${config.size}×${config.size}`;
64 springsInput.value = String(config.springCount);
03c2cc12 2026-07-01 Pavel Safronov65 erosionInput.value = String(config.erosion);
66 erosionVal.textContent = agingLabel(config.erosion);
36e6a8b9 2026-07-02 Pavel Safronov67 reachInput.value = String(config.reach);
68 reachVal.textContent = reachLabel(config.reach);
a7bf123a 2026-07-01 Pavel Safronov69 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. */
b9acd697 2026-07-01 Pavel Safronov82async function apply(next: Config): Promise<void> {
a7bf123a 2026-07-01 Pavel Safronov83 config = normalizeConfig(next);
84 syncControls();
b9acd697 2026-07-01 Pavel Safronov85 showLoading(true);
86 await game.load(config);
87 showLoading(false);
a7bf123a 2026-07-01 Pavel Safronov88}
89
90const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean');
91
b9acd697 2026-07-01 Pavel Safronov92// 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).
36e6a8b9 2026-07-02 Pavel Safronov94type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'reach' | 'terrainSeed';
b9acd697 2026-07-01 Pavel Safronov95const locks: Record<LockAxis, boolean> = {
96 size: false,
97 springCount: false,
98 edges: false,
99 erosion: false,
36e6a8b9 2026-07-02 Pavel Safronov100 reach: false,
b9acd697 2026-07-01 Pavel Safronov101 terrainSeed: false,
102};
103
104/** Reflect a lock button's state into its icon, label, and its control's styling. */
105function 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
112for (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. */
122function 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,
36e6a8b9 2026-07-02 Pavel Safronov129 reach: locks.reach ? config.reach : r.reach,
b9acd697 2026-07-01 Pavel Safronov130 terrainSeed: locks.terrainSeed ? config.terrainSeed : r.terrainSeed,
131 };
132}
133
03c2cc12 2026-07-01 Pavel Safronov134/** Human tag for an erosion level, so the slider reads as more than a number. */
135function agingLabel(n: number): string {
136 const word = n === 0 ? 'pristine' : n <= 4 ? 'weathered' : n <= 9 ? 'eroded' : 'dissected';
137 return `${n} · ${word}`;
138}
139
36e6a8b9 2026-07-02 Pavel Safronov140/** Human tag for a reach level, so the slider reads as more than a number. */
141function reachLabel(n: number): string {
142 const word = n <= 3 ? 'shore' : n <= 11 ? 'mixed' : 'peaks';
143 return `${n} · ${word}`;
144}
145
a7bf123a 2026-07-01 Pavel Safronov146// Live-update the size label while dragging, but only rebuild on release.
147sizeInput.addEventListener('input', () => (sizeVal.textContent = `${sizeInput.value}×${sizeInput.value}`));
148sizeInput.addEventListener('change', () => apply({ ...config, size: Number(sizeInput.value) }));
149springsInput.addEventListener('change', () =>
150 apply({ ...config, springCount: Number(springsInput.value) }),
151);
03c2cc12 2026-07-01 Pavel Safronov152// Live-update the aging label while dragging; erosion is costly, so only rebuild
153// on release.
154erosionInput.addEventListener('input', () => (erosionVal.textContent = agingLabel(Number(erosionInput.value))));
155erosionInput.addEventListener('change', () => apply({ ...config, erosion: Number(erosionInput.value) }));
36e6a8b9 2026-07-02 Pavel Safronov156// Live-update the reach label while dragging, but only rebuild on release.
157reachInput.addEventListener('input', () => (reachVal.textContent = reachLabel(Number(reachInput.value))));
158reachInput.addEventListener('change', () => apply({ ...config, reach: Number(reachInput.value) }));
a7bf123a 2026-07-01 Pavel Safronov159for (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}
bfd86d6e 2026-07-01 Pavel Safronov164seedInput.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});
a7bf123a 2026-07-01 Pavel Safronov170$<HTMLButtonElement>('#cfg-seed-roll').addEventListener('click', () =>
171 apply({ ...config, terrainSeed: (Math.random() * 2 ** 32) >>> 0 }),
172);
173codeInput.addEventListener('change', () => {
174 const decoded = decodeConfig(codeInput.value);
175 if (decoded) apply(decoded);
176 else syncControls(); // reject: snap back to the current config
177});
b9acd697 2026-07-01 Pavel Safronov178$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomizeUnlocked()));
a7bf123a 2026-07-01 Pavel Safronov179
180// Start from a shared code if present, otherwise a fresh random board.
181const codeParam = new URLSearchParams(location.search).get('code');
182apply((codeParam && decodeConfig(codeParam)) || randomConfig());