basin / commit
Add puzzle gameplay: springs, canyon walls, and configurable boards
index.html | 37 ++++- package-lock.json | 388 +++++++++++++++++++++++++++++++++++++++++++- package.json | 7 +- readme.md | 55 ++++++- src/game.ts | 77 ++++++--- src/main.ts | 86 +++++++++- src/model/flow.test.ts | 126 ++++++++++++++ src/model/flow.ts | 41 +++-- src/model/heightmap.test.ts | 134 +++++++++++++++ src/model/heightmap.ts | 91 ++++++++--- src/model/puzzle.test.ts | 152 +++++++++++++++++ src/model/puzzle.ts | 228 ++++++++++++++++++++++++++ src/render/renderer.ts | 76 ++++++++- src/style.css | 96 ++++++++++- 14 files changed, 1515 insertions(+), 79 deletions(-) diff --git a/index.html b/index.html index 8361dc1..99a30f5 100644 --- a/index.html +++ b/index.html @@ -10,13 +10,42 @@ <header> <img id="logo" class="logo" alt="basin logo" /> <h1>basin</h1> - <span id="seed" class="seed"></span> - <button id="reset">Reset</button> + <button id="randomize">π² Random</button> </header> + <section id="config" class="config"> + <label class="ctl"> + Size <span id="cfg-size-val" class="val"></span> + <input type="range" id="cfg-size" min="5" max="20" step="1" /> + </label> + <label class="ctl"> + Springs + <input type="number" id="cfg-springs" min="1" max="6" step="1" /> + </label> + <fieldset class="ctl edges"> + <legend>Walls</legend> + <label><input type="checkbox" id="cfg-wall-n" /> N</label> + <label><input type="checkbox" id="cfg-wall-e" /> E</label> + <label><input type="checkbox" id="cfg-wall-s" /> S</label> + <label><input type="checkbox" id="cfg-wall-w" /> W</label> + </fieldset> + <label class="ctl"> + Terrain + <span class="seedrow"> + <input type="number" id="cfg-seed" min="0" step="1" /> + <button id="cfg-seed-roll" title="Randomize terrain">π²</button> + </span> + </label> + <label class="ctl code"> + Code + <input type="text" id="cfg-code" spellcheck="false" autocomplete="off" /> + </label> + </section> <canvas id="board"></canvas> + <p id="status" class="status"></p> <p class="hint"> - Tap a cell to release water. It branches downhill and exits wherever it - reaches an edge. + Find the hidden springs that make water exit exactly at the gold edges. + Water leaves only at ocean (blue) sides β mountain walls block it. Tap to + explore the terrain; land on a spring and it locks in. </p> <footer class="credit"> Logo by diff --git a/package-lock.json b/package-lock.json index a35ad3c..78cfe58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "devDependencies": { "typescript": "^5.6.3", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.1.9" } }, "node_modules/@esbuild/aix-ppc64": { @@ -454,6 +455,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -804,6 +812,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -811,6 +844,153 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -853,6 +1033,26 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -886,6 +1086,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -905,6 +1115,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -999,6 +1230,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1009,6 +1247,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1026,6 +1295,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1114,6 +1393,113 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 9c26bef..f750f6b 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,13 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "typescript": "^5.6.3", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.1.9" } } diff --git a/readme.md b/readme.md index 843137b..50d0ecb 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,56 @@ # basin -a game about water basins \ No newline at end of file +A puzzle game about water basins. + +Each board has a heightmap that falls to the sea at its **ocean** sides. Water +released from a *spring* flows downhill, branching to every strictly-lower +neighbour, and exits wherever it reaches an ocean edge. Every board is generated +with a hidden set of springs; their combined flow lights up a **target edge +signature** (the gold-outlined border cells). + +Each of the four sides is independently rolled **ocean** (blue β water exits) or +a **mountain wall** (high ground that blocks water). Walls make canyons: e.g. +north + south walls with east + west oceans is a valley that drains sideways. +Water can never leave through a wall. + +**Goal:** find the positions of the hidden springs that reproduce that signature. +Tap cells to explore the terrain β a wrong tap just previews its flow (and marks +any edges it wrongly wets in red), while landing on a real spring **locks it in** +(green, numbered). Find them all to solve the board. + +## Configuring boards + +Every axis of a board is a control in the panel: **size**, **spring count**, which +sides are **walls**, and the **terrain seed**. All of it is captured in a single +shareable **code** β an 8-character token (e.g. `B0B0bgTL`) that packs the four +axes into 44 bits and base64url-encodes them, so the settings aren't legible at a +glance (reversible obfuscation, not encryption). The code and the controls are +two-way views of the same config β changing any control rewrites the code, and +pasting a code sets every control. + +The code also lives in the URL as `?code=<code>`, so a link reproduces the exact +board. **π² Random** rolls a whole new config; the π² next to Terrain rerolls just +the terrain while keeping the other settings. + +## Develop + +```bash +npm install +npm run dev # Vite dev server +npm run build # type-check + production build +npm test # run the model tests (Vitest) +``` + +## Architecture + +The model (typed arrays) is decoupled from the renderer so a tiled/WebGL renderer +can replace Canvas2D for large boards later. + +- `src/model/heightmap.ts` β terrain + per-side edge types (ocean/wall) + + priority-flood depression filling (guarantees every cell drains to an *ocean* + edge, seeded only from ocean sides). +- `src/model/flow.ts` β the branching watershed flood (single- and multi-spring). +- `src/model/puzzle.ts` β the board `Config`, its shareable code (encode/decode), + config-driven puzzle generation, and the solution check. +- `src/render/renderer.ts` β Canvas2D board + puzzle overlay. +- `src/game.ts` β puzzle state: exploration probe, locked springs, win detection. diff --git a/src/game.ts b/src/game.ts index b70bdb0..492c15d 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,48 +1,79 @@ -import { mulberry32 } from './rng'; -import { generateDome } from './model/heightmap'; -import type { Heightmap } from './model/heightmap'; -import { simulateFlow } from './model/flow'; -import type { FlowResult } from './model/flow'; +import { generatePuzzle, isSolutionSpring } from './model/puzzle'; +import type { Config, Puzzle } from './model/puzzle'; +import { simulateFlows } from './model/flow'; +import type { Cell } from './model/flow'; import { Renderer } from './render/renderer'; -/** Board edge length in cells. Larger reads more clearly as terrain. */ -const BOARD_SIZE = 24; - export class Game { - private hm!: Heightmap; - private flow: FlowResult | null = null; + private puzzle!: Puzzle; + /** Locked springs found so far, in discovery order. */ + private found: Cell[] = []; + /** Current transient exploration tap (replaced by the next non-spring tap). */ + private probe: Cell | null = null; + private solved = false; private renderer: Renderer; - private seed = 0; constructor( private canvas: HTMLCanvasElement, - private seedEl: HTMLElement, + private statusEl: HTMLElement, ) { this.renderer = new Renderer(canvas); canvas.addEventListener('click', (e) => this.onClick(e)); window.addEventListener('resize', () => this.draw()); } - /** Start a fresh board. Reset re-rolls the seed (and thus the whole config). */ - newGame(seed: number = (Math.random() * 2 ** 32) >>> 0): void { - this.seed = seed >>> 0; - const rng = mulberry32(this.seed); - this.hm = generateDome(BOARD_SIZE, BOARD_SIZE, rng); - this.flow = null; - this.seedEl.textContent = `seed ${this.seed}`; + /** Load a board from a config, clearing any progress. */ + load(config: Config): void { + this.puzzle = generatePuzzle(config); + this.found = []; + this.probe = null; + this.solved = false; + this.updateStatus(); this.draw(); } private onClick(e: MouseEvent): void { + if (this.solved) return; const rect = this.canvas.getBoundingClientRect(); - const cell = this.renderer.pickCell(this.hm, e.clientX - rect.left, e.clientY - rect.top); + const cell = this.renderer.pickCell(this.puzzle.hm, e.clientX - rect.left, e.clientY - rect.top); if (!cell) return; - // One spring at a time: a new tap replaces the previous flow. - this.flow = simulateFlow(this.hm, cell.x, cell.y); + + // Found springs are locked (immutable): tapping one again does nothing. + if (this.found.some((f) => f.x === cell.x && f.y === cell.y)) return; + + if (isSolutionSpring(this.puzzle, cell)) { + // Correct position β lock it in and clear the transient probe. + this.found.push(cell); + this.probe = null; + if (this.found.length === this.puzzle.solution.length) this.solved = true; + } else { + // Exploration only: one probe at a time, alongside the locked springs. + this.probe = cell; + } + + this.updateStatus(); this.draw(); } + /** Springs driving the currently-shown flow: locked + the live probe. */ + private currentSprings(): Cell[] { + return this.probe ? [...this.found, this.probe] : this.found; + } + + private updateStatus(): void { + const n = this.puzzle.solution.length; + this.statusEl.textContent = this.solved + ? `Solved! All ${n} springs found π` + : `${this.found.length} / ${n} springs found`; + } + private draw(): void { - this.renderer.render(this.hm, this.flow); + const springs = this.currentSprings(); + const flow = springs.length ? simulateFlows(this.puzzle.hm, springs) : null; + this.renderer.render(this.puzzle.hm, flow, { + target: this.puzzle.target, + found: this.found, + probe: this.probe, + }); } } diff --git a/src/main.ts b/src/main.ts index 94440ab..6af3ea9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,13 @@ import './style.css'; import logoUrl from '../water.png'; import { Game } from './game'; +import { + decodeConfig, + encodeConfig, + normalizeConfig, + randomConfig, +} from './model/puzzle'; +import type { Config } from './model/puzzle'; // Logo asset β on-page logo and favicon. document.querySelector<HTMLImageElement>('#logo')!.src = logoUrl; @@ -9,10 +16,79 @@ favicon.rel = 'icon'; favicon.href = logoUrl; document.head.appendChild(favicon); -const canvas = document.querySelector<HTMLCanvasElement>('#board')!; -const seed = document.querySelector<HTMLElement>('#seed')!; +const $ = <T extends HTMLElement>(sel: string): T => document.querySelector<T>(sel)!; -const game = new Game(canvas, seed); -game.newGame(); +const canvas = $<HTMLCanvasElement>('#board'); +const status = $<HTMLElement>('#status'); +const game = new Game(canvas, status); -document.querySelector<HTMLButtonElement>('#reset')!.addEventListener('click', () => game.newGame()); +// Config controls. +const sizeInput = $<HTMLInputElement>('#cfg-size'); +const sizeVal = $<HTMLElement>('#cfg-size-val'); +const springsInput = $<HTMLInputElement>('#cfg-springs'); +const wall = { + n: $<HTMLInputElement>('#cfg-wall-n'), + e: $<HTMLInputElement>('#cfg-wall-e'), + s: $<HTMLInputElement>('#cfg-wall-s'), + w: $<HTMLInputElement>('#cfg-wall-w'), +}; +const seedInput = $<HTMLInputElement>('#cfg-seed'); +const codeInput = $<HTMLInputElement>('#cfg-code'); + +// The config is the single source of truth; the code and every control are just +// two-way views of it. +let config: Config; + +/** Reflect the current config back into every control + the shared code + URL. */ +function syncControls(): void { + sizeInput.value = String(config.size); + sizeVal.textContent = `${config.size}Γ${config.size}`; + springsInput.value = String(config.springCount); + wall.n.checked = config.edges.n === 'wall'; + wall.e.checked = config.edges.e === 'wall'; + wall.s.checked = config.edges.s === 'wall'; + wall.w.checked = config.edges.w === 'wall'; + seedInput.value = String(config.terrainSeed); + codeInput.value = encodeConfig(config); + + const url = new URL(location.href); + url.searchParams.set('code', codeInput.value); + history.replaceState(null, '', url); +} + +/** Adopt a new config: normalise it, rebuild the board, resync the UI. */ +function apply(next: Config): void { + config = normalizeConfig(next); + game.load(config); + syncControls(); +} + +const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean'); + +// Live-update the size label while dragging, but only rebuild on release. +sizeInput.addEventListener('input', () => (sizeVal.textContent = `${sizeInput.value}Γ${sizeInput.value}`)); +sizeInput.addEventListener('change', () => apply({ ...config, size: Number(sizeInput.value) })); +springsInput.addEventListener('change', () => + apply({ ...config, springCount: Number(springsInput.value) }), +); +for (const side of ['n', 'e', 's', 'w'] as const) { + wall[side].addEventListener('change', () => + apply({ ...config, edges: { ...config.edges, [side]: edgeOf(wall[side].checked) } }), + ); +} +seedInput.addEventListener('change', () => + apply({ ...config, terrainSeed: Number(seedInput.value) >>> 0 }), +); +$<HTMLButtonElement>('#cfg-seed-roll').addEventListener('click', () => + apply({ ...config, terrainSeed: (Math.random() * 2 ** 32) >>> 0 }), +); +codeInput.addEventListener('change', () => { + const decoded = decodeConfig(codeInput.value); + if (decoded) apply(decoded); + else syncControls(); // reject: snap back to the current config +}); +$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomConfig())); + +// Start from a shared code if present, otherwise a fresh random board. +const codeParam = new URLSearchParams(location.search).get('code'); +apply((codeParam && decodeConfig(codeParam)) || randomConfig()); diff --git a/src/model/flow.test.ts b/src/model/flow.test.ts new file mode 100644 index 0000000..0d20dca --- /dev/null +++ b/src/model/flow.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { simulateFlow, simulateFlows } from './flow'; +import type { Edges, Heightmap } from './heightmap'; +import { ALL_OCEAN, generateDome } from './heightmap'; +import { mulberry32 } from '../rng'; + +/** Build a heightmap from an explicit grid of rows. */ +function grid(rows: number[][], edges: Edges = ALL_OCEAN): Heightmap { + const height = rows.length; + const width = rows[0].length; + const data = new Float32Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) data[y * width + x] = rows[y][x]; + } + return { width, height, data, edges }; +} + +/** Collect wet cells as "x,y" strings for order-independent comparison. */ +function wetCells(hm: Heightmap, wet: Uint8Array): Set<string> { + const s = new Set<string>(); + for (let y = 0; y < hm.height; y++) { + for (let x = 0; x < hm.width; x++) if (wet[y * hm.width + x]) s.add(`${x},${y}`); + } + return s; +} + +describe('simulateFlow', () => { + it('flows downhill along a ramp and stops at the low edge', () => { + // Heights decrease leftβright; a spring at the top of the ramp wets the row. + const hm = grid([ + [5, 4, 3], + [5, 4, 3], + [5, 4, 3], + ]); + const { wet, outflow } = simulateFlow(hm, 0, 1); + expect(wetCells(hm, wet)).toEqual(new Set(['0,1', '1,1', '2,1'])); + // Both ends sit on the border β both are outflow cells. + expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(new Set(['0,1', '2,1'])); + }); + + it('is strictly decreasing: equal-height neighbours stay dry', () => { + // A single peak over a flat plateau. Water reaches the 4 edge-mids (all < 3) + // but cannot cross from one plateau cell to an equal-height corner. + const hm = grid([ + [2, 2, 2], + [2, 3, 2], + [2, 2, 2], + ]); + const { wet } = simulateFlow(hm, 1, 1); + expect(wetCells(hm, wet)).toEqual(new Set(['1,1', '1,0', '0,1', '2,1', '1,2'])); + }); + + it('does not flow at all from a local pit', () => { + const hm = grid([ + [9, 9, 9], + [9, 0, 9], + [9, 9, 9], + ]); + const { wet, outflow } = simulateFlow(hm, 1, 1); + expect(wetCells(hm, wet)).toEqual(new Set(['1,1'])); + expect(outflow).toHaveLength(0); + }); + + it('reports outflow as exactly the wet border cells', () => { + const hm = grid([ + [5, 4, 3], + [5, 4, 3], + [5, 4, 3], + ]); + const { wet, outflow } = simulateFlow(hm, 0, 1); + const expected = new Set<string>(); + for (let y = 0; y < hm.height; y++) { + for (let x = 0; x < hm.width; x++) { + const isEdge = x === 0 || y === 0 || x === hm.width - 1 || y === hm.height - 1; + if (isEdge && wet[y * hm.width + x]) expected.add(`${x},${y}`); + } + } + expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(expected); + }); +}); + +describe('simulateFlows', () => { + it('unions the wet sets: wet = OR of each spring flooded alone', () => { + const hm = generateDome(10, 10, mulberry32(7)); + const a = simulateFlow(hm, 3, 3); + const b = simulateFlow(hm, 6, 6); + const both = simulateFlows(hm, [ + { x: 3, y: 3 }, + { x: 6, y: 6 }, + ]); + for (let i = 0; i < both.wet.length; i++) { + expect(both.wet[i]).toBe(a.wet[i] | b.wet[i]); + } + }); + + it('does not exit through a wall side (water pools against it)', () => { + // Heights fall toward the north row; north is a wall, south is ocean. + const hm = grid( + [ + [3, 3, 3], + [4, 4, 4], + [5, 5, 5], + ], + { n: 'wall', e: 'ocean', s: 'ocean', w: 'ocean' }, + ); + const { wet, outflow } = simulateFlow(hm, 1, 2); + // Water reaches the north wall cell... + expect(wetCells(hm, wet)).toEqual(new Set(['1,2', '1,1', '1,0'])); + // ...but only the south ocean cell (the spring itself) counts as outflow. + expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(new Set(['1,2'])); + }); + + it('deduplicates coincident springs', () => { + const hm = grid([ + [5, 4, 3], + [5, 4, 3], + [5, 4, 3], + ]); + const one = simulateFlow(hm, 0, 1); + const dup = simulateFlows(hm, [ + { x: 0, y: 1 }, + { x: 0, y: 1 }, + ]); + expect(wetCells(hm, dup.wet)).toEqual(wetCells(hm, one.wet)); + }); +}); diff --git a/src/model/flow.ts b/src/model/flow.ts index 736caa2..bf53787 100644 --- a/src/model/flow.ts +++ b/src/model/flow.ts @@ -1,5 +1,5 @@ import type { Heightmap } from './heightmap'; -import { heightAt } from './heightmap'; +import { heightAt, isOceanEdge } from './heightmap'; export interface Cell { x: number; @@ -11,7 +11,8 @@ export interface FlowResult { wet: Uint8Array; /** Wet border cells β where water exits the board. */ outflow: Cell[]; - spring: Cell; + /** The springs that produced this flow (order preserved). */ + springs: Cell[]; } // 4-connected (cardinal) neighbours: N, E, S, W. @@ -23,17 +24,34 @@ const NEIGHBORS: ReadonlyArray<readonly [number, number]> = [ ]; /** - * Flood from a spring: a cell is wet iff it's reachable from the spring along a - * path of strictly decreasing heights. Water branches to every lower neighbour, - * never pools, and stops at interior pits or board edges. + * Flood from a single spring. Convenience wrapper over {@link simulateFlows}. */ export function simulateFlow(hm: Heightmap, sx: number, sy: number): FlowResult { + return simulateFlows(hm, [{ x: sx, y: sy }]); +} + +/** + * Flood from a set of springs and union their wet sets: a cell is wet iff it's + * reachable from *some* spring along a path of strictly decreasing heights. + * Water branches to every lower neighbour, never pools, and stops at interior + * pits or board edges. + * + * A shared visited array is sound for the union: floods only ever move downhill, + * so the first time a cell is wetted its entire strictly-lower descendant set is + * already scheduled β a later spring reaching the same cell adds nothing new. + */ +export function simulateFlows(hm: Heightmap, springs: readonly Cell[]): FlowResult { const { width, height } = hm; const wet = new Uint8Array(width * height); const stack: number[] = []; - wet[sy * width + sx] = 1; - stack.push(sx, sy); + for (const s of springs) { + const i = s.y * width + s.x; + if (!wet[i]) { + wet[i] = 1; + stack.push(s.x, s.y); + } + } while (stack.length) { const y = stack.pop()!; @@ -51,13 +69,16 @@ export function simulateFlow(hm: Heightmap, sx: number, sy: number): FlowResult } } + // Water exits only where it reaches an *ocean* edge; wet cells that pool + // against a wall stop there and are not outflow. const outflow: Cell[] = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const isEdge = x === 0 || y === 0 || x === width - 1 || y === height - 1; - if (isEdge && wet[y * width + x]) outflow.push({ x, y }); + if (wet[y * width + x] && isOceanEdge(hm.edges, x, y, width, height)) { + outflow.push({ x, y }); + } } } - return { wet, outflow, spring: { x: sx, y: sy } }; + return { wet, outflow, springs: springs.map((s) => ({ x: s.x, y: s.y })) }; } diff --git a/src/model/heightmap.test.ts b/src/model/heightmap.test.ts new file mode 100644 index 0000000..2b1cf65 --- /dev/null +++ b/src/model/heightmap.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { generateDome, resolveDrainage, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap'; +import type { Edges, Heightmap } from './heightmap'; +import { simulateFlow } from './flow'; +import { mulberry32 } from '../rng'; + +/** True iff water released at (x, y) escapes to an ocean edge. */ +function drains(hm: Heightmap, x: number, y: number): boolean { + return simulateFlow(hm, x, y).outflow.length > 0; +} + +describe('generateDome', () => { + it('keeps every border cell at ocean height 0', () => { + const hm = generateDome(12, 9, mulberry32(42)); + for (let x = 0; x < hm.width; x++) { + expect(heightAt(hm, x, 0)).toBe(0); + expect(heightAt(hm, x, hm.height - 1)).toBe(0); + } + for (let y = 0; y < hm.height; y++) { + expect(heightAt(hm, 0, y)).toBe(0); + expect(heightAt(hm, hm.width - 1, y)).toBe(0); + } + }); + + it('guarantees every interior cell drains to an edge (no trapped pits)', () => { + // The drainage invariant is the whole point of resolveDrainage: across many + // seeds and sizes, water released anywhere interior must reach an ocean edge. + for (const seed of [1, 2, 3, 99, 12345]) { + for (const size of [6, 10, 15]) { + const hm = generateDome(size, size, mulberry32(seed)); + for (let y = 1; y < size - 1; y++) { + for (let x = 1; x < size - 1; x++) { + expect(drains(hm, x, y)).toBe(true); + } + } + } + } + }); +}); + +describe('isOceanEdge', () => { + it('reports ocean sides and treats corners as ocean if either side is', () => { + const edges: Edges = { n: 'wall', e: 'ocean', s: 'ocean', w: 'wall' }; + // 4x4 board. North (y=0) and west (x=0) are walls; east/south are ocean. + expect(isOceanEdge(edges, 2, 0, 4, 4)).toBe(false); // north wall + expect(isOceanEdge(edges, 0, 2, 4, 4)).toBe(false); // west wall + expect(isOceanEdge(edges, 3, 1, 4, 4)).toBe(true); // east ocean + expect(isOceanEdge(edges, 1, 3, 4, 4)).toBe(true); // south ocean + // NW corner touches two walls β not ocean; SW corner touches south ocean. + expect(isOceanEdge(edges, 0, 0, 4, 4)).toBe(false); + expect(isOceanEdge(edges, 0, 3, 4, 4)).toBe(true); + }); +}); + +describe('generateDome with walls', () => { + const canyon: Edges = { n: 'wall', e: 'ocean', s: 'wall', w: 'ocean' }; + + it('keeps ocean edges at 0 and stands wall edges up as high ground', () => { + const hm = generateDome(12, 12, mulberry32(3), canyon); + for (let y = 0; y < 12; y++) { + expect(heightAt(hm, 0, y)).toBe(0); // west ocean + expect(heightAt(hm, 11, y)).toBe(0); // east ocean + } + // The wall rows (excluding the ocean-corner ends) rise above 0. + for (let x = 1; x < 11; x++) { + expect(heightAt(hm, x, 0)).toBeGreaterThan(0); // north wall + expect(heightAt(hm, x, 11)).toBeGreaterThan(0); // south wall + } + }); + + it('still drains every interior cell β but only to ocean sides', () => { + for (const seed of [1, 2, 3, 99, 12345]) { + const hm = generateDome(12, 12, mulberry32(seed), canyon); + for (let y = 1; y < 11; y++) { + for (let x = 1; x < 11; x++) { + const { outflow } = simulateFlow(hm, x, y); + expect(outflow.length).toBeGreaterThan(0); + for (const o of outflow) { + expect(isOceanEdge(hm.edges, o.x, o.y, 12, 12)).toBe(true); + } + } + } + } + }); +}); + +describe('resolveDrainage', () => { + it('raises a sealed interior pit until it drains', () => { + // A single deep pit walled off by higher ground; before filling it cannot + // drain, after filling it must. + const make = (): Heightmap => ({ + width: 5, + height: 5, + edges: ALL_OCEAN, + data: Float32Array.from([ + 0, 0, 0, 0, 0, + 0, 1, 1, 1, 0, + 0, 1, 0, 1, 0, + 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0, + ]), + }); + + const before = make(); + expect(drains(before, 2, 2)).toBe(false); + + const after = make(); + resolveDrainage(after); + expect(heightAt(after, 2, 2)).toBeGreaterThan(0); // pit was raised + // Every interior cell now escapes. + for (let y = 1; y < 4; y++) { + for (let x = 1; x < 4; x++) expect(drains(after, x, y)).toBe(true); + } + }); + + it('leaves the ocean border untouched', () => { + const hm: Heightmap = { + width: 4, + height: 4, + edges: ALL_OCEAN, + data: Float32Array.from([ + 0, 0, 0, 0, + 0, 1, 1, 0, + 0, 1, 1, 0, + 0, 0, 0, 0, + ]), + }; + resolveDrainage(hm); + for (let x = 0; x < 4; x++) { + expect(heightAt(hm, x, 0)).toBe(0); + expect(heightAt(hm, x, 3)).toBe(0); + } + }); +}); diff --git a/src/model/heightmap.ts b/src/model/heightmap.ts index 4d3b536..1130134 100644 --- a/src/model/heightmap.ts +++ b/src/model/heightmap.ts @@ -1,10 +1,26 @@ import type { Rng } from '../rng'; +/** A board side is either ocean (water exits) or a mountain wall (water is blocked). */ +export type EdgeType = 'ocean' | 'wall'; + +/** Per-side edge configuration. e.g. n+s wall, e+w ocean = a canyon. */ +export interface Edges { + n: EdgeType; + e: EdgeType; + s: EdgeType; + w: EdgeType; +} + +/** The v1 default: all four sides ocean. */ +export const ALL_OCEAN: Edges = { n: 'ocean', e: 'ocean', s: 'ocean', w: 'ocean' }; + export interface Heightmap { width: number; height: number; /** Row-major elevations in [0, 1]. Ocean-border cells are exactly 0. */ data: Float32Array; + /** Which borders are ocean (water exits) vs wall (blocks & rises as terrain). */ + edges: Edges; } export function heightAt(hm: Heightmap, x: number, y: number): number { @@ -12,13 +28,47 @@ export function heightAt(hm: Heightmap, x: number, y: number): number { } /** - * Dome terrain: high in the middle, falling to 0 at every border cell, - * perturbed by smooth value noise so the watersheds branch interestingly. - * - * NOTE: this v1 generator treats all four edges as ocean (height 0). Per-side - * "mountain" walls (canyon configs) are a planned config-layer extension. + * Is border cell (x, y) on an ocean side β i.e. a place water can exit? Corner + * cells count as ocean if *either* adjoining side is ocean. + */ +export function isOceanEdge( + edges: Edges, + x: number, + y: number, + width: number, + height: number, +): boolean { + return ( + (y === 0 && edges.n === 'ocean') || + (y === height - 1 && edges.s === 'ocean') || + (x === 0 && edges.w === 'ocean') || + (x === width - 1 && edges.e === 'ocean') + ); +} + +/** Cell distance to the nearest ocean edge (0 on an ocean edge, rising inland). */ +function distToOcean(x: number, y: number, width: number, height: number, edges: Edges): number { + let d = Infinity; + if (edges.n === 'ocean') d = Math.min(d, y); + if (edges.s === 'ocean') d = Math.min(d, height - 1 - y); + if (edges.w === 'ocean') d = Math.min(d, x); + if (edges.e === 'ocean') d = Math.min(d, width - 1 - x); + return Number.isFinite(d) ? d : 0; +} + +/** + * Dome terrain, perturbed by smooth value noise so watersheds branch + * interestingly. Elevation rises with distance from the nearest *ocean* edge, so + * ocean sides fall to 0 while mountain-wall sides stand up as high ground β + * water runs off the walls toward the oceans (e.g. n+s walls make a canyon that + * drains east and west). */ -export function generateDome(width: number, height: number, rng: Rng): Heightmap { +export function generateDome( + width: number, + height: number, + rng: Rng, + edges: Edges = ALL_OCEAN, +): Heightmap { const data = new Float32Array(width * height); // Coarse value-noise lattice, bilinearly sampled across the board. @@ -46,23 +96,21 @@ export function generateDome(width: number, height: number, rng: Rng): Heightmap }; const amp = 0.6; - const maxD = Math.max(1, Math.min((width - 1) / 2, (height - 1) / 2)); let peak = 0; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const dEdge = Math.min(x, y, width - 1 - x, height - 1 - y); - const dome = dEdge / maxD; // 0 at border, 1 at centre - const n = 1 + amp * (sampleNoise(x, y) * 2 - 1); - const v = Math.max(0, dome * n); + const dome = distToOcean(x, y, width, height, edges); // 0 on ocean edges + const n = 1 + amp * (sampleNoise(x, y) * 2 - 1); // n > 0, so v >= 0 + const v = dome * n; data[y * width + x] = v; if (v > peak) peak = v; } } - // Normalise interior to [0, 1]; border stays 0. + // Normalise to [0, 1]; ocean edges stay 0, walls stand up as high ground. if (peak > 0) for (let i = 0; i < data.length; i++) data[i] /= peak; - const hm: Heightmap = { width, height, data }; + const hm: Heightmap = { width, height, data, edges }; resolveDrainage(hm); return hm; } @@ -77,19 +125,22 @@ const DRAIN_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [ /** * Priority-flood + epsilon depression filling (Barnes, Lehman & Mulla 2014). * - * Guarantees every non-edge cell has a strictly-decreasing 4-connected path to - * an ocean edge β so water released anywhere always drains out, never trapping - * in an interior pit. Depressions are raised to their spill elevation (a natural - * "lake" surface) and given an imperceptible gradient via `eps`; real peaks and - * slopes (already above their spill) are left untouched. + * Guarantees every cell has a strictly-decreasing 4-connected path to an *ocean* + * edge β so water released anywhere always drains out to an ocean side, never + * trapping in an interior pit and never needing to cross a wall. Depressions are + * raised to their spill elevation (a natural "lake" surface) and given an + * imperceptible gradient via `eps`; real peaks and slopes (already above their + * spill) are left untouched. */ export function resolveDrainage(hm: Heightmap, eps = 1e-4): void { - const { width, height, data } = hm; + const { width, height, data, edges } = hm; const closed = new Uint8Array(width * height); const heap = new MinHeap(); - // Seed the flood from every border cell β these are the ocean spill points. + // Seed the flood from ocean-edge cells only β these are the spill points. + // Wall edges are treated as ordinary (floodable) terrain and rise like it. const seed = (x: number, y: number): void => { + if (!isOceanEdge(edges, x, y, width, height)) return; const i = y * width + x; closed[i] = 1; heap.push(i, data[i]); diff --git a/src/model/puzzle.test.ts b/src/model/puzzle.test.ts new file mode 100644 index 0000000..745ecbc --- /dev/null +++ b/src/model/puzzle.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + generatePuzzle, + isSolutionSpring, + encodeConfig, + decodeConfig, + normalizeConfig, + randomConfig, + SIZE_MIN, + SIZE_MAX, + SPRINGS_MIN, + SPRINGS_MAX, +} from './puzzle'; +import type { Config } from './puzzle'; +import { simulateFlows } from './flow'; +import { isOceanEdge, ALL_OCEAN } from './heightmap'; +import type { Edges } from './heightmap'; + +const SIZE = 12; +const COUNT = 3; +const CANYON: Edges = { n: 'wall', e: 'ocean', s: 'wall', w: 'ocean' }; + +const cfg = (over: Partial<Config> = {}): Config => ({ + size: SIZE, + springCount: COUNT, + edges: ALL_OCEAN, + terrainSeed: 4242, + ...over, +}); + +describe('generatePuzzle', () => { + it('is deterministic in its config', () => { + const a = generatePuzzle(cfg()); + const b = generatePuzzle(cfg()); + expect(a.solution).toEqual(b.solution); + expect(Array.from(a.target)).toEqual(Array.from(b.target)); + }); + + it('honours the requested size, edges, and spring count', () => { + for (const seed of [1, 7, 55, 1000, 999999]) { + const p = generatePuzzle(cfg({ size: 10, springCount: 4, edges: CANYON, terrainSeed: seed })); + expect(p.width).toBe(10); + expect(p.height).toBe(10); + expect(p.edges).toEqual(CANYON); + expect(p.solution).toHaveLength(4); + + const seen = new Set<string>(); + for (const s of p.solution) { + expect(s.x).toBeGreaterThan(0); + expect(s.y).toBeGreaterThan(0); + expect(s.x).toBeLessThan(9); + expect(s.y).toBeLessThan(9); + seen.add(`${s.x},${s.y}`); + } + expect(seen.size).toBe(4); // distinct + } + }); + + it("target marks exactly the solution flow's wet ocean-edge cells", () => { + for (const seed of [1, 7, 55, 1000, 999999]) { + const p = generatePuzzle(cfg({ edges: CANYON, terrainSeed: seed })); + const flow = simulateFlows(p.hm, p.solution); + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + const i = y * SIZE + x; + const shouldBeWet = isOceanEdge(p.edges, x, y, SIZE, SIZE) && flow.wet[i] === 1; + expect(p.target[i] === 1).toBe(shouldBeWet); + } + } + } + }); + + it('produces a non-empty target (the puzzle is solvable/visible)', () => { + for (const seed of [1, 7, 55, 1000, 999999]) { + const p = generatePuzzle(cfg({ terrainSeed: seed })); + expect(p.targetOutflow.length).toBeGreaterThan(0); + } + }); +}); + +describe('isSolutionSpring', () => { + it('recognises solution cells and rejects others', () => { + const p = generatePuzzle(cfg({ terrainSeed: 321 })); + for (const s of p.solution) expect(isSolutionSpring(p, s)).toBe(true); + + const solutionKeys = new Set(p.solution.map((s) => `${s.x},${s.y}`)); + let checked = false; + for (let y = 1; y < SIZE - 1 && !checked; y++) { + for (let x = 1; x < SIZE - 1; x++) { + if (!solutionKeys.has(`${x},${y}`)) { + expect(isSolutionSpring(p, { x, y })).toBe(false); + checked = true; + break; + } + } + } + expect(checked).toBe(true); + }); +}); + +describe('config code', () => { + it('round-trips through encode/decode', () => { + const configs: Config[] = [ + cfg(), + cfg({ size: 5, springCount: 1, edges: { n: 'wall', e: 'wall', s: 'wall', w: 'ocean' } }), + cfg({ size: 20, springCount: 6, edges: CANYON, terrainSeed: 0xffffffff }), + cfg({ terrainSeed: 0 }), + ]; + for (const c of configs) { + const code = encodeConfig(c); + expect(code).toHaveLength(8); + expect(decodeConfig(code)).toEqual(normalizeConfig(c)); + } + }); + + it('returns null for codes of the wrong length or alphabet', () => { + for (const bad of ['', 'abc', 'AAAAAAA', 'AAAAAAAAA', '!!!!!!!!', 'BQAr3k f']) { + expect(decodeConfig(bad)).toBeNull(); + } + }); + + it('normalises out-of-range and all-wall configs', () => { + const clamped = normalizeConfig( + cfg({ size: 999, springCount: 999, edges: { n: 'wall', e: 'wall', s: 'wall', w: 'wall' } }), + ); + expect(clamped.size).toBe(SIZE_MAX); + expect(clamped.springCount).toBe(SPRINGS_MAX); + // All-wall is illegal (no exit) β at least one side forced ocean. + const sides = [clamped.edges.n, clamped.edges.e, clamped.edges.s, clamped.edges.w]; + expect(sides).toContain('ocean'); + + const tiny = normalizeConfig(cfg({ size: -3, springCount: 0 })); + expect(tiny.size).toBe(SIZE_MIN); + expect(tiny.springCount).toBe(SPRINGS_MIN); + }); +}); + +describe('randomConfig', () => { + it('stays within bounds with at least one ocean side', () => { + for (let i = 0; i < 200; i++) { + const c = randomConfig(); + expect(c.size).toBeGreaterThanOrEqual(SIZE_MIN); + expect(c.size).toBeLessThanOrEqual(SIZE_MAX); + expect(c.springCount).toBeGreaterThanOrEqual(SPRINGS_MIN); + expect(c.springCount).toBeLessThanOrEqual(SPRINGS_MAX); + const sides = [c.edges.n, c.edges.e, c.edges.s, c.edges.w]; + expect(sides.some((s) => s === 'ocean')).toBe(true); + // A random config must always be buildable into a solvable puzzle. + expect(generatePuzzle(c).solution).toHaveLength(c.springCount); + } + }); +}); diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts new file mode 100644 index 0000000..9b8a49d --- /dev/null +++ b/src/model/puzzle.ts @@ -0,0 +1,228 @@ +import { mulberry32 } from '../rng'; +import type { Rng } from '../rng'; +import { generateDome, heightAt } from './heightmap'; +import type { Edges, EdgeType, Heightmap } from './heightmap'; +import { simulateFlows } from './flow'; +import type { Cell } from './flow'; + +/** + * Everything that defines a board. A `Config` maps 1:1 to a shareable {@link + * encodeConfig | code}: same config β same board, and every axis is a knob the + * UI exposes. + */ +export interface Config { + /** Square board edge length, in cells. */ + size: number; + /** How many hidden springs the player must find. */ + springCount: number; + /** Per-side edge types (ocean vs wall). */ + edges: Edges; + /** Seed for the terrain noise + spring placement (given the other axes). */ + terrainSeed: number; +} + +export const SIZE_MIN = 5; +export const SIZE_MAX = 20; +export const SPRINGS_MIN = 1; +export const SPRINGS_MAX = 6; + +/** + * A solvable board (Reading A): terrain + a target edge signature are given, and + * the player must rediscover the hidden spring *positions* that produce it. + */ +export interface Puzzle { + config: Config; + hm: Heightmap; + width: number; + height: number; + /** Per-side edge configuration (ocean vs wall) for this board. */ + edges: Edges; + /** Hidden spring positions the player is hunting for. Order irrelevant. */ + solution: Cell[]; + /** 1 for every border cell water must reach in the solution, row-major. */ + target: Uint8Array; + /** The target's wet border cells, for rendering the goal. */ + targetOutflow: Cell[]; +} + +/** + * Build a puzzle from a config. The terrain seed drives terrain + spring + * placement, so a board is fully reproducible from its config (hence its code). + * + * Springs are drawn from interior land cells, kept a minimum gap apart so their + * watersheds stay distinct, and re-rolled until they yield a reasonably rich + * edge signature β a larger target is less ambiguous to hunt for. + */ +export function generatePuzzle(config: Config): Puzzle { + const norm = normalizeConfig(config); + const { size, springCount, edges, terrainSeed } = norm; + const rng = mulberry32(terrainSeed); + const hm = generateDome(size, size, rng, edges); + + // Candidates: interior (non-edge) land cells. After drainage resolution every + // interior cell has height > 0 and a strictly-decreasing path to an ocean edge. + const candidates: Cell[] = []; + for (let y = 1; y < size - 1; y++) { + for (let x = 1; x < size - 1; x++) { + if (heightAt(hm, x, y) > 0) candidates.push({ x, y }); + } + } + + const wanted = Math.max(3, springCount); // prefer a signature at least this wide + let best: { solution: Cell[]; outflow: Cell[] } | null = null; + + for (let attempt = 0; attempt < 128; attempt++) { + // Relax the spacing requirement if the board is too cramped to satisfy it. + const minGap = attempt < 96 ? 3 : attempt < 112 ? 2 : 1; + const picked = pickSprings(candidates, springCount, rng, minGap); + if (picked.length < springCount) continue; + + const flow = simulateFlows(hm, picked); + if (flow.outflow.length === 0) continue; // must reach an ocean edge + + if (!best || flow.outflow.length > best.outflow.length) { + best = { solution: picked, outflow: flow.outflow }; + } + if (flow.outflow.length >= wanted) break; // rich enough β good to go + } + + // `best` is set once any attempt yields springs; normalizeConfig guarantees the + // board has at least `springCount` interior candidates, so that always happens. + const solution = best!.solution; + const targetOutflow = best!.outflow; + const target = borderSignature(targetOutflow, size, size); + + return { config: norm, hm, width: size, height: size, edges, solution, target, targetOutflow }; +} + +/** Is `cell` one of the puzzle's hidden spring positions? */ +export function isSolutionSpring(puzzle: Puzzle, cell: Cell): boolean { + return puzzle.solution.some((s) => s.x === cell.x && s.y === cell.y); +} + +// --------------------------------------------------------------------------- +// Config: bounds, randomisation, and the shareable code. +// --------------------------------------------------------------------------- + +const clampInt = (v: number, lo: number, hi: number): number => + Math.min(hi, Math.max(lo, Math.round(v))); + +/** Clamp every axis into range and guarantee at least one ocean side. */ +export function normalizeConfig(c: Config): Config { + const size = clampInt(c.size, SIZE_MIN, SIZE_MAX); + const interior = (size - 2) * (size - 2); + const springCount = clampInt(c.springCount, SPRINGS_MIN, Math.min(SPRINGS_MAX, interior)); + return { size, springCount, edges: ensureOcean(c.edges), terrainSeed: c.terrainSeed >>> 0 }; +} + +/** A wholly random board within sensible bounds. */ +export function randomConfig(): Config { + return normalizeConfig({ + size: 8 + Math.floor(Math.random() * 5) * 2, // 8,10,12,14,16 + springCount: 2 + Math.floor(Math.random() * 3), // 2,3,4 + edges: rollEdges(Math.random), + terrainSeed: (Math.random() * 2 ** 32) >>> 0, + }); +} + +// The config packs into 44 bits β size(5) Β· springs(3) Β· edges(4) Β· seed(32) β +// which we emit as an 8-char base64url string so the axes aren't legible at a +// glance. It's obfuscation, not encryption: fully reversible, no secrets. +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const CODE_LEN = 8; + +/** A compact, reversible code for a config, e.g. "BQAr3kf9". */ +export function encodeConfig(c: Config): string { + const n = normalizeConfig(c); + let v = + BigInt(n.terrainSeed >>> 0) | + (BigInt(edgesToMask(n.edges)) << 32n) | + (BigInt(n.springCount) << 36n) | + (BigInt(n.size) << 39n); + let out = ''; + for (let i = 0; i < CODE_LEN; i++) { + out = B64[Number(v & 63n)] + out; + v >>= 6n; + } + return out; +} + +/** Parse a config code, or null if it isn't well-formed. */ +export function decodeConfig(code: string): Config | null { + const s = code.trim(); + if (s.length !== CODE_LEN) return null; + let v = 0n; + for (const ch of s) { + const d = B64.indexOf(ch); + if (d < 0) return null; + v = (v << 6n) | BigInt(d); + } + return normalizeConfig({ + size: Number((v >> 39n) & 0x1fn), + springCount: Number((v >> 36n) & 0x7n), + edges: maskToEdges(Number((v >> 32n) & 0xfn)), + terrainSeed: Number(v & 0xffffffffn) >>> 0, + }); +} + +/** Bit per side (1 = wall): n=8, e=4, s=2, w=1. */ +function edgesToMask(e: Edges): number { + return ( + (e.n === 'wall' ? 8 : 0) | + (e.e === 'wall' ? 4 : 0) | + (e.s === 'wall' ? 2 : 0) | + (e.w === 'wall' ? 1 : 0) + ); +} + +function maskToEdges(mask: number): Edges { + const side = (bit: number): EdgeType => ((mask >> bit) & 1 ? 'wall' : 'ocean'); + return { n: side(3), e: side(2), s: side(1), w: side(0) }; +} + +/** If every side is a wall, water could never exit β force the west side ocean. */ +function ensureOcean(edges: Edges): Edges { + const allWall = + edges.n === 'wall' && edges.e === 'wall' && edges.s === 'wall' && edges.w === 'wall'; + return allWall ? { ...edges, w: 'ocean' } : { ...edges }; +} + +/** + * Roll a per-side edge configuration. Each side is a wall ~35% of the time, with + * at least one ocean side kept so water can always exit somewhere. + */ +function rollEdges(rng: Rng): Edges { + const pick = (): EdgeType => (rng() < 0.35 ? 'wall' : 'ocean'); + return ensureOcean({ n: pick(), e: pick(), s: pick(), w: pick() }); +} + +/** + * Sample `k` distinct candidates that are pairwise at least `minGap` apart + * (Chebyshev distance). Returns fewer than `k` only if the spacing can't be met. + */ +function pickSprings(candidates: Cell[], k: number, rng: Rng, minGap: number): Cell[] { + const pool = candidates.slice(); + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + + const chosen: Cell[] = []; + for (const c of pool) { + const spaced = chosen.every( + (o) => Math.max(Math.abs(o.x - c.x), Math.abs(o.y - c.y)) >= minGap, + ); + if (spaced) { + chosen.push(c); + if (chosen.length === k) break; + } + } + return chosen; +} + +/** Mark the wet border cells of a flow as a row-major signature mask. */ +function borderSignature(outflow: Cell[], width: number, height: number): Uint8Array { + const sig = new Uint8Array(width * height); + for (const c of outflow) sig[c.y * width + c.x] = 1; + return sig; +} diff --git a/src/render/renderer.ts b/src/render/renderer.ts index b8608ad..10736ac 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -1,7 +1,17 @@ import type { Heightmap } from '../model/heightmap'; -import { heightAt } from '../model/heightmap'; +import { heightAt, isOceanEdge } from '../model/heightmap'; import type { Cell, FlowResult } from '../model/flow'; +/** Puzzle-mode overlay: the goal edges plus the player's springs. */ +export interface PuzzleView { + /** 1 for every border cell the solution wets, row-major. */ + target: Uint8Array; + /** Locked, correctly-found springs, in find order (labelled 1..k). */ + found: Cell[]; + /** The current transient exploration tap, if any. */ + probe: Cell | null; +} + /** * Canvas2D renderer for small boards (one filled rect per cell). Deliberately * decoupled from the model so a tiled / WebGL renderer can replace it for @@ -26,7 +36,7 @@ export class Renderer { return { x, y }; } - render(hm: Heightmap, flow: FlowResult | null): void { + render(hm: Heightmap, flow: FlowResult | null, view?: PuzzleView | null): void { const dpr = window.devicePixelRatio || 1; const maxPx = Math.min(window.innerWidth - 32, 560); this.cell = Math.max(1, Math.floor(maxPx / hm.width)); @@ -49,8 +59,8 @@ export class Renderer { } } + // Wet cells (union of the current springs' flow). if (flow) { - // Wet cells. ctx.fillStyle = 'rgba(40,120,255,0.55)'; for (let y = 0; y < hm.height; y++) { for (let x = 0; x < hm.width; x++) { @@ -59,12 +69,31 @@ export class Renderer { } } } - // Outflow markers. + } + + if (view) { + this.drawTarget(hm, view.target, flow); + // Locked, found springs: numbered so order-of-discovery reads clearly. + view.found.forEach((s, i) => { + ctx.fillStyle = '#34c759'; + this.dot(s.x, s.y, this.cell * 0.3, 5); + ctx.fillStyle = '#06210d'; + ctx.font = `${Math.max(9, Math.floor(this.cell * 0.4))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(i + 1), (s.x + 0.5) * this.cell, (s.y + 0.55) * this.cell); + }); + // Transient exploration tap. + if (view.probe) { + ctx.fillStyle = '#ff3b30'; + this.dot(view.probe.x, view.probe.y, this.cell * 0.28, 4); + } + } else if (flow) { + // Sandbox fallback: plain outflow + spring markers. ctx.fillStyle = '#ffe14d'; for (const o of flow.outflow) this.dot(o.x, o.y, this.cell * 0.18, 3); - // Spring marker. ctx.fillStyle = '#ff3b30'; - this.dot(flow.spring.x, flow.spring.y, this.cell * 0.28, 4); + for (const s of flow.springs) this.dot(s.x, s.y, this.cell * 0.28, 4); } // Grid lines (small boards only). @@ -86,6 +115,41 @@ export class Renderer { } } + /** + * Border feedback. Gold outline = a goal edge cell the solution wets. When + * the current flow reaches it the outline fills green (satisfied); a wet + * border cell that is *not* a goal fills red (overflow β a wrong spring). + */ + private drawTarget(hm: Heightmap, target: Uint8Array, flow: FlowResult | null): void { + const ctx = this.ctx; + const c = this.cell; + const inset = Math.max(1.5, c * 0.12); + ctx.lineWidth = Math.max(2, c * 0.14); + + for (let y = 0; y < hm.height; y++) { + for (let x = 0; x < hm.width; x++) { + // Only ocean edges carry goal/overflow feedback β water pooling against + // a wall is expected, not a mistake. + if (!isOceanEdge(hm.edges, x, y, hm.width, hm.height)) continue; + const i = y * hm.width + x; + const goal = target[i] === 1; + const wet = flow ? flow.wet[i] === 1 : false; + + if (goal) { + ctx.strokeStyle = '#ffd60a'; + ctx.strokeRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2); + if (wet) { + ctx.fillStyle = 'rgba(52,199,89,0.55)'; + ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2); + } + } else if (wet) { + ctx.fillStyle = 'rgba(255,59,48,0.5)'; + ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2); + } + } + } + } + private dot(cx: number, cy: number, r: number, min: number): void { const ctx = this.ctx; ctx.beginPath(); diff --git a/src/style.css b/src/style.css index fe9fdc9..126914c 100644 --- a/src/style.css +++ b/src/style.css @@ -39,14 +39,7 @@ h1 { font-size: 1.6rem; } -.seed { - font-size: 0.8rem; - opacity: 0.55; - font-variant-numeric: tabular-nums; -} - button { - margin-left: auto; padding: 6px 14px; border: 0; border-radius: 8px; @@ -60,12 +53,101 @@ button:hover { background: #3b7bf0; } +header button { + margin-left: auto; +} + +.config { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 16px; + width: 100%; + padding: 10px 12px; + box-sizing: border-box; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + font-size: 0.8rem; +} + +.ctl { + display: flex; + flex-direction: column; + gap: 4px; + opacity: 0.85; +} + +.ctl .val { + opacity: 0.6; + font-variant-numeric: tabular-nums; +} + +.config input[type='number'], +.config input[type='text'] { + padding: 4px 6px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + background: #0d1017; + color: #e7ecf3; + font: inherit; +} + +.config input[type='number'] { + width: 4.5em; +} + +.config .code { + flex: 1 1 12em; +} + +.config .code input { + width: 100%; + box-sizing: border-box; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.03em; +} + +.seedrow { + display: flex; + gap: 4px; +} + +.seedrow button { + padding: 4px 8px; +} + +.edges { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 2px 8px 6px; + margin: 0; +} + +.edges legend { + padding: 0 4px; + opacity: 0.6; +} + +.edges label { + display: inline-flex; + align-items: center; + gap: 2px; + margin-right: 6px; +} + canvas { border-radius: 8px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); touch-action: manipulation; } +.status { + margin: 0; + font-size: 1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + .hint { margin: 0; font-size: 0.85rem;