| 1 | import type { Config, Puzzle } from './model/puzzle'; |
| 2 | import { isSolutionSpring } from './model/puzzle'; |
| 3 | import { simulateFlows } from './model/flow'; |
| 4 | import type { Cell } from './model/flow'; |
| 5 | import { Renderer } from './render/renderer'; |
| 6 | import type { WorkerRequest, WorkerResponse } from './model/puzzle.worker'; |
| 7 | |
| 8 | export 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 | } |