basin / commit
Merge branch 'basin-water-game-design' into 'main'
.gitignore | 9 + .gitlab-ci.yml | 14 + index.html | 29 ++ package-lock.json | 1119 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 + src/game.ts | 48 +++ src/main.ts | 18 + src/model/flow.ts | 63 +++ src/model/heightmap.ts | 179 ++++++++ src/render/renderer.ts | 123 ++++++ src/rng.ts | 23 + src/style.css | 85 ++++ src/vite-env.d.ts | 1 + tsconfig.json | 15 + vite.config.ts | 15 + water.png | Bin 0 -> 27725 bytes 16 files changed, 1756 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..355cef7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +public/ +dist/ +*.local +.DS_Store + +# Local verification artifacts +.playwright-mcp/ +basin-*.png diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..664fc14 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,14 @@ +# Build the Vite app and publish it to GitLab Pages (https://pavelsafronov.gitlab.io/basin/). +image: node:22 + +pages: + stage: deploy + script: + - npm ci + - npm run build + artifacts: + paths: + - public + publish: public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/index.html b/index.html new file mode 100644 index 0000000..8361dc1 --- /dev/null +++ b/index.html @@ -0,0 +1,29 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>basin</title> + </head> + <body> + <div id="app"> + <header> + <img id="logo" class="logo" alt="basin logo" /> + <h1>basin</h1> + <span id="seed" class="seed"></span> + <button id="reset">Reset</button> + </header> + <canvas id="board"></canvas> + <p class="hint"> + Tap a cell to release water. It branches downhill and exits wherever it + reaches an edge. + </p> + <footer class="credit"> + Logo by + <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">icongeek26</a> + on Flaticon + </footer> + </div> + <script type="module" src="/src/main.ts"></script> + </body> +</html> diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a35ad3c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1119 @@ +{ + "name": "basin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "basin", + "version": "0.0.0", + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^6.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c26bef --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "basin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/src/game.ts b/src/game.ts new file mode 100644 index 0000000..b70bdb0 --- /dev/null +++ b/src/game.ts @@ -0,0 +1,48 @@ +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 { 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 renderer: Renderer; + private seed = 0; + + constructor( + private canvas: HTMLCanvasElement, + private seedEl: 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}`; + this.draw(); + } + + private onClick(e: MouseEvent): void { + const rect = this.canvas.getBoundingClientRect(); + const cell = this.renderer.pickCell(this.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); + this.draw(); + } + + private draw(): void { + this.renderer.render(this.hm, this.flow); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..94440ab --- /dev/null +++ b/src/main.ts @@ -0,0 +1,18 @@ +import './style.css'; +import logoUrl from '../water.png'; +import { Game } from './game'; + +// Logo asset → on-page logo and favicon. +document.querySelector<HTMLImageElement>('#logo')!.src = logoUrl; +const favicon = document.createElement('link'); +favicon.rel = 'icon'; +favicon.href = logoUrl; +document.head.appendChild(favicon); + +const canvas = document.querySelector<HTMLCanvasElement>('#board')!; +const seed = document.querySelector<HTMLElement>('#seed')!; + +const game = new Game(canvas, seed); +game.newGame(); + +document.querySelector<HTMLButtonElement>('#reset')!.addEventListener('click', () => game.newGame()); diff --git a/src/model/flow.ts b/src/model/flow.ts new file mode 100644 index 0000000..736caa2 --- /dev/null +++ b/src/model/flow.ts @@ -0,0 +1,63 @@ +import type { Heightmap } from './heightmap'; +import { heightAt } from './heightmap'; + +export interface Cell { + x: number; + y: number; +} + +export interface FlowResult { + /** 1 where water reaches, row-major. */ + wet: Uint8Array; + /** Wet border cells — where water exits the board. */ + outflow: Cell[]; + spring: Cell; +} + +// 4-connected (cardinal) neighbours: N, E, S, W. +const NEIGHBORS: ReadonlyArray<readonly [number, number]> = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; + +/** + * 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. + */ +export function simulateFlow(hm: Heightmap, sx: number, sy: number): FlowResult { + const { width, height } = hm; + const wet = new Uint8Array(width * height); + const stack: number[] = []; + + wet[sy * width + sx] = 1; + stack.push(sx, sy); + + while (stack.length) { + const y = stack.pop()!; + const x = stack.pop()!; + const h = heightAt(hm, x, y); + for (const [dx, dy] of NEIGHBORS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; + if (wet[ny * width + nx]) continue; + if (heightAt(hm, nx, ny) < h) { + wet[ny * width + nx] = 1; + stack.push(nx, ny); + } + } + } + + 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 }); + } + } + + return { wet, outflow, spring: { x: sx, y: sy } }; +} diff --git a/src/model/heightmap.ts b/src/model/heightmap.ts new file mode 100644 index 0000000..4d3b536 --- /dev/null +++ b/src/model/heightmap.ts @@ -0,0 +1,179 @@ +import type { Rng } from '../rng'; + +export interface Heightmap { + width: number; + height: number; + /** Row-major elevations in [0, 1]. Ocean-border cells are exactly 0. */ + data: Float32Array; +} + +export function heightAt(hm: Heightmap, x: number, y: number): number { + return hm.data[y * hm.width + x]; +} + +/** + * 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. + */ +export function generateDome(width: number, height: number, rng: Rng): Heightmap { + const data = new Float32Array(width * height); + + // Coarse value-noise lattice, bilinearly sampled across the board. + const noiseCols = Math.max(2, Math.round(width / 2)); + const noiseRows = Math.max(2, Math.round(height / 2)); + const lattice = new Float32Array(noiseCols * noiseRows); + for (let i = 0; i < lattice.length; i++) lattice[i] = rng(); + + const sampleNoise = (x: number, y: number): number => { + const gx = (x / Math.max(1, width - 1)) * (noiseCols - 1); + const gy = (y / Math.max(1, height - 1)) * (noiseRows - 1); + const x0 = Math.floor(gx); + const y0 = Math.floor(gy); + const x1 = Math.min(x0 + 1, noiseCols - 1); + const y1 = Math.min(y0 + 1, noiseRows - 1); + const fx = gx - x0; + const fy = gy - y0; + const a = lattice[y0 * noiseCols + x0]; + const b = lattice[y0 * noiseCols + x1]; + const c = lattice[y1 * noiseCols + x0]; + const d = lattice[y1 * noiseCols + x1]; + const top = a + (b - a) * fx; + const bot = c + (d - c) * fx; + return top + (bot - top) * fy; + }; + + 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); + data[y * width + x] = v; + if (v > peak) peak = v; + } + } + + // Normalise interior to [0, 1]; border stays 0. + if (peak > 0) for (let i = 0; i < data.length; i++) data[i] /= peak; + + const hm: Heightmap = { width, height, data }; + resolveDrainage(hm); + return hm; +} + +const DRAIN_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; + +/** + * 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. + */ +export function resolveDrainage(hm: Heightmap, eps = 1e-4): void { + const { width, height, data } = hm; + const closed = new Uint8Array(width * height); + const heap = new MinHeap(); + + // Seed the flood from every border cell — these are the ocean spill points. + const seed = (x: number, y: number): void => { + const i = y * width + x; + closed[i] = 1; + heap.push(i, data[i]); + }; + for (let x = 0; x < width; x++) { + seed(x, 0); + seed(x, height - 1); + } + for (let y = 1; y < height - 1; y++) { + seed(0, y); + seed(width - 1, y); + } + + // Pop cells lowest-first; each unseen neighbour is raised to at least + // (current + eps), so elevation strictly increases away from the ocean. + while (heap.size > 0) { + const { idx, key } = heap.pop(); + const cx = idx % width; + const cy = (idx / width) | 0; + for (const [dx, dy] of DRAIN_NEIGHBORS) { + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; + const ni = ny * width + nx; + if (closed[ni]) continue; + closed[ni] = 1; + const raised = Math.max(data[ni], key + eps); + data[ni] = raised; + heap.push(ni, raised); + } + } +} + +/** Tiny binary min-heap over (cell index, elevation key). */ +class MinHeap { + private idx: number[] = []; + private key: number[] = []; + + get size(): number { + return this.idx.length; + } + + push(i: number, k: number): void { + this.idx.push(i); + this.key.push(k); + let n = this.idx.length - 1; + while (n > 0) { + const p = (n - 1) >> 1; + if (this.key[p] <= this.key[n]) break; + this.swap(p, n); + n = p; + } + } + + pop(): { idx: number; key: number } { + const ri = this.idx[0]; + const rk = this.key[0]; + const li = this.idx.pop()!; + const lk = this.key.pop()!; + if (this.idx.length > 0) { + this.idx[0] = li; + this.key[0] = lk; + let n = 0; + const len = this.idx.length; + for (;;) { + let s = n; + const l = 2 * n + 1; + const r = 2 * n + 2; + if (l < len && this.key[l] < this.key[s]) s = l; + if (r < len && this.key[r] < this.key[s]) s = r; + if (s === n) break; + this.swap(s, n); + n = s; + } + } + return { idx: ri, key: rk }; + } + + private swap(a: number, b: number): void { + const ti = this.idx[a]; + this.idx[a] = this.idx[b]; + this.idx[b] = ti; + const tk = this.key[a]; + this.key[a] = this.key[b]; + this.key[b] = tk; + } +} diff --git a/src/render/renderer.ts b/src/render/renderer.ts new file mode 100644 index 0000000..b8608ad --- /dev/null +++ b/src/render/renderer.ts @@ -0,0 +1,123 @@ +import type { Heightmap } from '../model/heightmap'; +import { heightAt } from '../model/heightmap'; +import type { Cell, FlowResult } from '../model/flow'; + +/** + * Canvas2D renderer for small boards (one filled rect per cell). Deliberately + * decoupled from the model so a tiled / WebGL renderer can replace it for + * contour-scale boards without touching game logic. + */ +export class Renderer { + private ctx: CanvasRenderingContext2D; + private cell = 0; + + constructor(private canvas: HTMLCanvasElement) { + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('2D canvas not supported'); + this.ctx = ctx; + } + + /** Translate a click (canvas-local px) into board coords, or null if outside. */ + pickCell(hm: Heightmap, px: number, py: number): Cell | null { + if (this.cell === 0) return null; + const x = Math.floor(px / this.cell); + const y = Math.floor(py / this.cell); + if (x < 0 || y < 0 || x >= hm.width || y >= hm.height) return null; + return { x, y }; + } + + render(hm: Heightmap, flow: FlowResult | 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)); + const w = this.cell * hm.width; + const h = this.cell * hm.height; + + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.canvas.style.width = `${w}px`; + this.canvas.style.height = `${h}px`; + + const ctx = this.ctx; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Terrain. + for (let y = 0; y < hm.height; y++) { + for (let x = 0; x < hm.width; x++) { + ctx.fillStyle = terrainColor(heightAt(hm, x, y)); + ctx.fillRect(x * this.cell, y * this.cell, this.cell, this.cell); + } + } + + 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++) { + if (flow.wet[y * hm.width + x]) { + ctx.fillRect(x * this.cell, y * this.cell, this.cell, this.cell); + } + } + } + // Outflow 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); + } + + // Grid lines (small boards only). + if (this.cell >= 16) { + ctx.strokeStyle = 'rgba(0,0,0,0.15)'; + ctx.lineWidth = 1; + for (let x = 0; x <= hm.width; x++) { + ctx.beginPath(); + ctx.moveTo(x * this.cell, 0); + ctx.lineTo(x * this.cell, h); + ctx.stroke(); + } + for (let y = 0; y <= hm.height; y++) { + ctx.beginPath(); + ctx.moveTo(0, y * this.cell); + ctx.lineTo(w, y * this.cell); + ctx.stroke(); + } + } + } + + private dot(cx: number, cy: number, r: number, min: number): void { + const ctx = this.ctx; + ctx.beginPath(); + ctx.arc((cx + 0.5) * this.cell, (cy + 0.5) * this.cell, Math.max(min, r), 0, Math.PI * 2); + ctx.fill(); + } +} + +/** Hypsometric tint: water blue at 0, green low, tan mid, brown then white high. */ +function terrainColor(value: number): string { + const t = value < 0 ? 0 : value > 1 ? 1 : value; + const stops: Array<[number, [number, number, number]]> = [ + [0.0, [40, 80, 160]], + [0.02, [70, 130, 180]], + [0.15, [96, 160, 90]], + [0.45, [200, 190, 120]], + [0.75, [150, 110, 80]], + [1.0, [245, 245, 245]], + ]; + let lo = stops[0]; + let hi = stops[stops.length - 1]; + for (let i = 0; i < stops.length - 1; i++) { + if (t >= stops[i][0] && t <= stops[i + 1][0]) { + lo = stops[i]; + hi = stops[i + 1]; + break; + } + } + const span = hi[0] - lo[0] || 1; + const f = (t - lo[0]) / span; + const r = Math.round(lo[1][0] + (hi[1][0] - lo[1][0]) * f); + const g = Math.round(lo[1][1] + (hi[1][1] - lo[1][1]) * f); + const b = Math.round(lo[1][2] + (hi[1][2] - lo[1][2]) * f); + return `rgb(${r},${g},${b})`; +} diff --git a/src/rng.ts b/src/rng.ts new file mode 100644 index 0000000..9de4db3 --- /dev/null +++ b/src/rng.ts @@ -0,0 +1,23 @@ +/** A deterministic pseudo-random source: call it for the next float in [0, 1). */ +export type Rng = () => number; + +/** mulberry32 — tiny, fast, good-enough PRNG seeded by a 32-bit integer. */ +export function mulberry32(seed: number): Rng { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** FNV-1a hash so a human-typed name can also seed a board. */ +export function hashStringToSeed(s: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..fe9fdc9 --- /dev/null +++ b/src/style.css @@ -0,0 +1,85 @@ +:root { + color-scheme: light dark; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +body { + margin: 0; + display: grid; + place-items: start center; + min-height: 100vh; + background: #11151c; + color: #e7ecf3; +} + +#app { + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +header { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.logo { + height: 34px; + width: 34px; + /* Asset is black line-art; invert so it reads on the dark header. */ + filter: invert(1); +} + +h1 { + margin: 0; + 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; + background: #2d6cdf; + color: white; + font-size: 0.95rem; + cursor: pointer; +} + +button:hover { + background: #3b7bf0; +} + +canvas { + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); + touch-action: manipulation; +} + +.hint { + margin: 0; + font-size: 0.85rem; + opacity: 0.65; + max-width: 36ch; + text-align: center; +} + +.credit { + margin: 4px 0 0; + font-size: 0.75rem; + opacity: 0.5; +} + +.credit a { + color: inherit; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..94f0143 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4425139 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +// GitLab Pages serves this project under https://pavelsafronov.gitlab.io/basin/, +// so assets must be referenced relative to /basin/. Pages publishes the `public` +// dir, hence the custom outDir. +export default defineConfig({ + base: '/basin/', + // We build *into* `public/` (GitLab Pages' required dir), so disable Vite's + // default `public/` static-assets folder to avoid the name collision. + publicDir: false, + build: { + outDir: 'public', + emptyOutDir: true, + }, +}); diff --git a/water.png b/water.png new file mode 100644 index 0000000..125cd2e Binary files /dev/null and b/water.png differ