fuzzyworld

basin / files / src / game.ts

game.ts

3.6 KB · history · blame

1import type { Config, Puzzle } from './model/puzzle';
2import { isSolutionSpring } from './model/puzzle';
3import { simulateFlows } from './model/flow';
4import type { Cell } from './model/flow';
5import { Renderer } from './render/renderer';
6import type { WorkerRequest, WorkerResponse } from './model/puzzle.worker';
7
8export class Game {
9 private puzzle: Puzzle | null = null;
10 private found: Cell[] = [];
11 private probe: Cell | null = null;
12 private solved = false;
13 private renderer: Renderer;
14
15 private worker!: Worker;
16 private seq = 0;
17 private pendingResolve: (() => void) | null = null;
18
19 constructor(
20 private canvas: HTMLCanvasElement,
21 private statusEl: HTMLElement,
22 private onProgress?: (fraction: number) => void,
23 ) {
24 this.renderer = new Renderer(canvas);
25 canvas.addEventListener('click', (e) => this.onClick(e));
26 window.addEventListener('resize', () => { if (this.puzzle) this.draw(); });
27 this.spawnWorker();
28 }
29
30 private spawnWorker(): void {
31 this.worker = new Worker(new URL('./model/puzzle.worker.ts', import.meta.url), {
32 type: 'module',
33 });
34 this.worker.addEventListener('message', (e: MessageEvent<WorkerResponse>) => {
35 this.onWorkerMessage(e.data);
36 });
37 }
38
39 /** Load a board from a config, clearing any progress. Returns when the board is ready. */
40 load(config: Config): Promise<void> {
41 // Terminate any in-flight generation to avoid stale results.
42 this.worker.terminate();
43 this.pendingResolve?.();
44 this.pendingResolve = null;
45 this.spawnWorker();
46
47 const seq = ++this.seq;
48 this.worker.postMessage({ type: 'generate', seq, config } satisfies WorkerRequest);
49
50 return new Promise((resolve) => {
51 this.pendingResolve = resolve;
52 });
53 }
54
55 private onWorkerMessage(data: WorkerResponse): void {
56 if (data.seq !== this.seq) return;
57
58 if (data.type === 'progress') {
59 this.onProgress?.(data.value);
60 } else if (data.type === 'done') {
61 this.puzzle = data.puzzle;
62 this.found = [];
63 this.probe = null;
64 this.solved = false;
65 this.updateStatus();
66 this.draw();
67 this.pendingResolve?.();
68 this.pendingResolve = null;
69 }
70 }
71
72 private onClick(e: MouseEvent): void {
73 if (!this.puzzle || this.solved) return;
74 const rect = this.canvas.getBoundingClientRect();
75 const cell = this.renderer.pickCell(this.puzzle.hm, e.clientX - rect.left, e.clientY - rect.top);
76 if (!cell) return;
77
78 if (this.found.some((f) => f.x === cell.x && f.y === cell.y)) return;
79
80 if (isSolutionSpring(this.puzzle, cell)) {
81 this.found.push(cell);
82 this.probe = null;
83 if (this.found.length === this.puzzle.solution.length) this.solved = true;
84 } else {
85 this.probe = cell;
86 }
87
88 this.updateStatus();
89 this.draw();
90 }
91
92 private currentSprings(): Cell[] {
93 return this.probe ? [...this.found, this.probe] : this.found;
94 }
95
96 private updateStatus(): void {
97 if (!this.puzzle) return;
98 const n = this.puzzle.solution.length;
99 const springs = n === 1 ? 'spring' : 'springs';
100 this.statusEl.textContent = this.solved
101 ? `Solved! ${n === 1 ? 'The spring' : `All ${n} springs`} found 🎉`
102 : `${this.found.length} / ${n} ${springs} found`;
103 }
104
105 private draw(): void {
106 if (!this.puzzle) return;
107 const springs = this.currentSprings();
108 const flow = springs.length ? simulateFlows(this.puzzle.hm, springs) : null;
109 this.renderer.render(this.puzzle.hm, flow, {
110 target: this.puzzle.target,
111 found: this.found,
112 probe: this.probe,
113 solution: import.meta.env.DEV ? this.puzzle.solution : undefined,
114 });
115 }
116}