commit a66032674d0be89a5fc61eda1e993eb4fe1e57a2 Author: dejvino Date: Fri Aug 7 07:39:05 2026 +0200 Phase 0: drivable car with persistent wear Vite + TypeScript + three.js + Rapier raycast vehicle, fixed 60 Hz step. Flat plate, seeded obstacle scatter, chase camera, debug HUD. Car condition (engine/tires/chassis) degrades permanently and is derived into handling numbers, so decline is felt through the wheel rather than read off a meter. src/sim/ is kept free of three.js and Rapier imports — the later heat, region and front-line systems all live there, and staying engine-free is what makes them unit-testable without a browser. Co-Authored-By: Claude Opus 5 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8b30f3b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbd1bd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.DS_Store +*.local + +# local Claude Code settings +.claude/settings.local.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..177c8de --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Drive Between the Lines — Phase 0 prototype + +Endless driving survival game. This is the **Phase 0** skeleton from the roadmap: +a drivable car on a flat plate with scattered obstacles, plus the persistent wear +system that makes condition felt through the wheel. + +## Running + +```bash +npm run dev +``` + +Then open http://localhost:5173. Append `?seed=anything` to regenerate the world — +seeds may be numbers or words. + +| | | +|---|---| +| `W` / `S` | throttle / brake-reverse | +| `A` / `D` | steer | +| `Space` | handbrake (rear wheels only) | +| `R` | respawn the car — **does not** repair it | + +```bash +npm test # sim + headless physics +npm run build # typecheck + production bundle +``` + +## Layout + +The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.** +Phases 1–4 (heat, regions, front lines, quests) are all simulation, and keeping +them engine-free is what makes them unit-testable and fast-forwardable — you can +run a hundred simulated days in milliseconds to tune an escalation curve without +ever opening a browser. + +``` +src/ + sim/ pure model — world generation, car condition → handling + physics/ Rapier world, raycast vehicle, input → wheel forces + render/ three.js scene, chase camera + core/ fixed-timestep loop, seeded RNG, keyboard + ui/ debug HUD + carSpec.ts shared car dimensions, so body and mesh cannot drift apart +``` + +Data flows one way: `sim` → `physics` → `render`. Condition reaches the physics +layer already digested into a `Handling` by `deriveHandling`, so there is exactly +one place where "how broken the car is" turns into "how it drives". + +## Notes on the state of it + +- **The car handles like a placeholder.** Suspension, grip, and engine numbers in + `physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test, + not something tuned by feel. That tuning *is* Phase 0's verification gate. +- **Wear rates are deliberately aggressive** so decline is visible in minutes + rather than hours. Turn them down in `applyWear` once the curve reads right. +- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step + rate changes. +- **Condition is not yet persisted** across reloads. IndexedDB comes with the + campaign layer. +- **Bundle:** ~2.7 MB raw / ~945 KB gzipped, dominated by Rapier's WASM, which + `rapier3d-compat` inlines as base64. Switching to the non-compat `@dimforge/rapier3d` + package serves the WASM as a separate file (~570 KB gzipped, compiled in + parallel with the JS) at the cost of extra Vite plugin config. Worth doing + before anyone but you plays it; not worth doing now. + +## Next + +Phase 0's gate: drive for 15–20 minutes, feel the condition changing how the car +handles, and want to keep driving anyway. Everything else waits on that. diff --git a/index.html b/index.html new file mode 100644 index 0000000..2e393e8 --- /dev/null +++ b/index.html @@ -0,0 +1,36 @@ + + + + + + Drive Between the Lines + + + +
loading physics…
+
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d153898 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1624 @@ +{ + "name": "drive-between-the-lines", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drive-between-the-lines", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@dimforge/rapier3d-compat": "^0.19.3", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/three": "^0.185.4", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.19.3.tgz", + "integrity": "sha512-mMVdSj1PRTT108s9Swbu2GQOmHbn8kbJANRV5xfczL3s0T4vkgZAuoMRgvBzQcHanpKusbC0ZJj6z3mC3aj3vg==", + "license": "Apache-2.0" + }, + "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/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "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/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "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", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/three/node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "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", + "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/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "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", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "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.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "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.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "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", + "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/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "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 new file mode 100644 index 0000000..f95630c --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "drive-between-the-lines", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dimforge/rapier3d-compat": "^0.19.3", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/three": "^0.185.4", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } +} diff --git a/src/carSpec.ts b/src/carSpec.ts new file mode 100644 index 0000000..fef9d21 --- /dev/null +++ b/src/carSpec.ts @@ -0,0 +1,32 @@ +/** + * Physical dimensions of the car, shared by the physics body and the mesh so the + * two can never drift apart. Metres, SI throughout. + */ +export const CAR = { + /** Half-extents of the chassis box. */ + halfWidth: 0.9, + halfHeight: 0.35, + halfLength: 1.9, + mass: 1100, + /** Local forward is +Z, matching Rapier's default vehicle forward axis. */ + wheel: { + radius: 0.36, + width: 0.25, + /** Lateral offset of the wheel centres from the car's centreline. */ + offsetX: 0.85, + /** Longitudinal offset — front axle is +Z. */ + offsetZ: 1.35, + /** Vertical offset of the suspension hard point from the body centre. */ + offsetY: -0.15, + suspensionRestLength: 0.32, + }, + spawn: { x: 0, y: 1.2, z: 0 }, +} as const; + +/** Wheel order used everywhere: front-left, front-right, rear-left, rear-right. */ +export const WHEELS = [ + { x: +CAR.wheel.offsetX, z: +CAR.wheel.offsetZ, steered: true, driven: false }, + { x: -CAR.wheel.offsetX, z: +CAR.wheel.offsetZ, steered: true, driven: false }, + { x: +CAR.wheel.offsetX, z: -CAR.wheel.offsetZ, steered: false, driven: true }, + { x: -CAR.wheel.offsetX, z: -CAR.wheel.offsetZ, steered: false, driven: true }, +] as const; diff --git a/src/core/input.ts b/src/core/input.ts new file mode 100644 index 0000000..999d133 --- /dev/null +++ b/src/core/input.ts @@ -0,0 +1,50 @@ +export interface DriverInput { + /** -1 (reverse) .. 1 (forward) */ + throttle: number; + /** -1 (right) .. 1 (left) */ + steer: number; + handbrake: boolean; + respawn: boolean; +} + +const KEYS = { + forward: ['KeyW', 'ArrowUp'], + back: ['KeyS', 'ArrowDown'], + left: ['KeyA', 'ArrowLeft'], + right: ['KeyD', 'ArrowRight'], + handbrake: ['Space'], + respawn: ['KeyR'], +} as const; + +export function createInput(): { read(): DriverInput; dispose(): void } { + const down = new Set(); + const held = (codes: readonly string[]) => codes.some((c) => down.has(c)); + + const onDown = (e: KeyboardEvent) => { + down.add(e.code); + if (Object.values(KEYS).some((codes) => (codes as readonly string[]).includes(e.code))) { + e.preventDefault(); + } + }; + const onUp = (e: KeyboardEvent) => down.delete(e.code); + const onBlur = () => down.clear(); + + window.addEventListener('keydown', onDown); + window.addEventListener('keyup', onUp); + window.addEventListener('blur', onBlur); + + return { + read: () => ({ + throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), + steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), + handbrake: held(KEYS.handbrake), + respawn: held(KEYS.respawn), + }), + dispose() { + window.removeEventListener('keydown', onDown); + window.removeEventListener('keyup', onUp); + window.removeEventListener('blur', onBlur); + down.clear(); + }, + }; +} diff --git a/src/core/loop.ts b/src/core/loop.ts new file mode 100644 index 0000000..57a5d14 --- /dev/null +++ b/src/core/loop.ts @@ -0,0 +1,46 @@ +/** + * Fixed-timestep simulation with a free-running render. + * + * The physics step must not vary with framerate — a car that handles differently + * on a 144 Hz monitor than a 60 Hz one is untunable, and replaying a seeded world + * stops meaning anything. + */ +export const STEP = 1 / 60; +const MAX_STEPS_PER_FRAME = 5; + +export interface LoopHandlers { + fixedUpdate(dt: number): void; + render(alpha: number, frameDt: number): void; +} + +export function startLoop({ fixedUpdate, render }: LoopHandlers): () => void { + let last = performance.now() / 1000; + let accumulator = 0; + let running = true; + + const frame = () => { + if (!running) return; + requestAnimationFrame(frame); + + const now = performance.now() / 1000; + // Clamp: a backgrounded tab must not come back and simulate ten seconds at once. + const frameDt = Math.min(now - last, 0.25); + last = now; + + accumulator += frameDt; + let steps = 0; + while (accumulator >= STEP && steps < MAX_STEPS_PER_FRAME) { + fixedUpdate(STEP); + accumulator -= STEP; + steps++; + } + if (steps === MAX_STEPS_PER_FRAME) accumulator = 0; // give up on the backlog + + render(accumulator / STEP, frameDt); + }; + + requestAnimationFrame(frame); + return () => { + running = false; + }; +} diff --git a/src/core/rng.ts b/src/core/rng.ts new file mode 100644 index 0000000..355b8bd --- /dev/null +++ b/src/core/rng.ts @@ -0,0 +1,29 @@ +/** + * Seeded PRNG. Every piece of procedural generation goes through one of these — + * a world you cannot regenerate exactly is a world you cannot debug or re-test. + */ +export type Rng = () => number; + +/** mulberry32 — small, fast, good enough for level generation. */ +export function makeRng(seed: number): Rng { + let a = seed >>> 0; + return () => { + 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; + }; +} + +/** Hash an arbitrary string into a seed, so seeds can be human-readable. */ +export function seedFromString(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +export const randRange = (rng: Rng, min: number, max: number): number => + min + rng() * (max - min); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ae4e7af --- /dev/null +++ b/src/main.ts @@ -0,0 +1,96 @@ +import { generateWorld } from './sim/world'; +import { applyWear, deriveHandling, freshCondition } from './sim/car'; +import { seedFromString } from './core/rng'; +import { startLoop } from './core/loop'; +import { createInput } from './core/input'; +import { createPhysics } from './physics/physics'; +import { createDriveState, drive } from './physics/drive'; +import { createScene, updateCamera } from './render/scene'; +import { createHud } from './ui/hud'; +import { CAR, WHEELS } from './carSpec'; + +function resolveSeed(): number { + const raw = new URLSearchParams(location.search).get('seed'); + if (!raw) return 1337; + const n = Number(raw); + return Number.isFinite(n) ? n >>> 0 : seedFromString(raw); +} + +async function boot() { + const seed = resolveSeed(); + const model = generateWorld(seed); + + const physics = await createPhysics(model); + const view = createScene(model); + const input = createInput(); + const hud = createHud(seed); + const driveState = createDriveState(); + + let condition = freshCondition(); + let elapsed = 0; + let respawnLatch = false; + + document.getElementById('boot')?.remove(); + + startLoop({ + fixedUpdate(dt) { + elapsed += dt; + const cmd = input.read(); + + if (cmd.respawn && !respawnLatch) physics.respawn(); + respawnLatch = cmd.respawn; + + drive(physics, driveState, cmd, deriveHandling(condition), dt); + physics.step(dt); + + // Wear is applied from what actually happened this step, not from intent. + const speed = physics.vehicle.currentVehicleSpeed(); + condition = applyWear(condition, { + dt, + distance: Math.abs(speed) * dt, + throttle: Math.abs(cmd.throttle), + impactForce: physics.drainImpactForce(), + }); + }, + + render(_alpha, frameDt) { + // No transform interpolation yet: at a 60 Hz fixed step it is not yet + // worth the bookkeeping. Revisit if the step rate ever drops. + const p = physics.chassis.translation(); + const r = physics.chassis.rotation(); + view.car.position.set(p.x, p.y, p.z); + view.car.quaternion.set(r.x, r.y, r.z, r.w); + + for (let i = 0; i < WHEELS.length; i++) { + const pivot = view.wheels[i]!; + const suspension = + physics.vehicle.wheelSuspensionLength(i) ?? CAR.wheel.suspensionRestLength; + pivot.position.y = CAR.wheel.offsetY - suspension; + pivot.rotation.y = physics.vehicle.wheelSteering(i) ?? 0; + pivot.children[0]!.rotation.x = physics.vehicle.wheelRotation(i) ?? 0; + } + + // Static blocks never move; only the crates need syncing. + for (let i = 0; i < view.obstacles.length; i++) { + if (model.obstacles[i]!.kind !== 'crate') continue; + const body = physics.obstacleBodies[i]!; + const t = body.translation(); + const q = body.rotation(); + const mesh = view.obstacles[i]!; + mesh.position.set(t.x, t.y, t.z); + mesh.quaternion.set(q.x, q.y, q.z, q.w); + } + + view.followSun(); + updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed()); + view.renderer.render(view.scene, view.camera); + hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed); + }, + }); +} + +boot().catch((err) => { + console.error(err); + const boot = document.getElementById('boot'); + if (boot) boot.textContent = `failed to start: ${err}`; +}); diff --git a/src/physics/drive.ts b/src/physics/drive.ts new file mode 100644 index 0000000..bf99ce1 --- /dev/null +++ b/src/physics/drive.ts @@ -0,0 +1,66 @@ +import type { DriverInput } from '../core/input'; +import type { Handling } from '../sim/car'; +import type { PhysicsWorld } from './physics'; +import { WHEELS } from '../carSpec'; + +/** How fast the steering rack follows the key, radians per second. */ +const STEER_RATE = 2.6; +const STEER_RETURN_RATE = 4.5; +/** + * Steering authority falls off with speed, or the car is undriveable at pace. + * Lower = more falloff. At 12, full lock is roughly halved by 45 km/h. + */ +const STEER_SPEED_FALLOFF = 12; + +export interface DriveState { + steer: number; +} + +export const createDriveState = (): DriveState => ({ steer: 0 }); + +/** + * Translates player intent + current car condition into wheel forces. + * This is the only place the two meet — condition arrives already digested + * into a Handling by the sim layer. + */ +export function drive( + physics: PhysicsWorld, + state: DriveState, + input: DriverInput, + handling: Handling, + dt: number, +): void { + const { vehicle } = physics; + const speed = vehicle.currentVehicleSpeed(); + + // Steering: ease toward the target rather than snapping, and shrink the + // available lock as speed rises. + const authority = 1 / (1 + Math.abs(speed) / STEER_SPEED_FALLOFF); + const target = input.steer * handling.maxSteer * authority; + const rate = input.steer === 0 ? STEER_RETURN_RATE : STEER_RATE; + const maxDelta = rate * handling.maxSteer * dt; + state.steer += Math.max(-maxDelta, Math.min(maxDelta, target - state.steer)); + + // A bent chassis pulls constantly; the player has to hold against it. + const steerAngle = state.steer + handling.steeringPull; + + // Throttle vs. brake: pressing back while rolling forward is braking, not reverse. + const wantsReverse = input.throttle < 0; + const braking = input.handbrake || (wantsReverse && speed > 1) || (input.throttle > 0 && speed < -1); + const engineForce = braking ? 0 : input.throttle * handling.engineForce; + const brakeForce = input.handbrake + ? handling.brakeForce * 1.6 + : braking + ? handling.brakeForce + : 0; + + for (let i = 0; i < WHEELS.length; i++) { + const w = WHEELS[i]!; + vehicle.setWheelSteering(i, w.steered ? steerAngle : 0); + vehicle.setWheelEngineForce(i, w.driven ? engineForce : 0); + // Handbrake locks the rear only — that is where the rotation comes from. + vehicle.setWheelBrake(i, input.handbrake && w.steered ? 0 : brakeForce); + vehicle.setWheelFrictionSlip(i, handling.frictionSlip); + vehicle.setWheelSideFrictionStiffness(i, handling.sideFrictionStiffness); + } +} diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts new file mode 100644 index 0000000..d3a94f7 --- /dev/null +++ b/src/physics/physics.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { createPhysics } from './physics'; +import { createDriveState, drive } from './drive'; +import { deriveHandling, freshCondition } from '../sim/car'; +import type { DriverInput } from '../core/input'; +import { generateWorld } from '../sim/world'; + +const STEP = 1 / 60; +const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: false }; + +/** Rapier runs headless, so vehicle tuning is checkable without a browser. */ +async function run(input: Partial, seconds: number) { + const physics = await createPhysics(generateWorld(1, 0)); + const state = createDriveState(); + const handling = deriveHandling(freshCondition()); + const cmd = { ...IDLE, ...input }; + + let maxYawRate = 0; + for (let i = 0; i < Math.round(seconds / STEP); i++) { + drive(physics, state, cmd, handling, STEP); + physics.step(STEP); + maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y)); + } + return { + pos: physics.chassis.translation(), + speed: physics.vehicle.currentVehicleSpeed(), + maxYawRate, + grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)), + }; +} + +describe('vehicle', () => { + it('settles on its suspension instead of sinking or bouncing away', async () => { + const r = await run({}, 2); + expect(r.grounded).toBe(true); + expect(r.pos.y).toBeGreaterThan(0.4); + expect(r.pos.y).toBeLessThan(1.1); + expect(Math.abs(r.speed)).toBeLessThan(0.2); + }); + + it('accelerates forward along +Z at a plausible rate', async () => { + const r = await run({ throttle: 1 }, 5); + expect(r.pos.z).toBeGreaterThan(20); + // Roughly 40–140 km/h after five seconds: quick, but not a rocket. + expect(r.speed).toBeGreaterThan(11); + expect(r.speed).toBeLessThan(39); + }); + + it('turns when steered, without spinning like a top', async () => { + const straight = await run({ throttle: 1 }, 5); + const turning = await run({ throttle: 1, steer: 1 }, 5); + // Position is a poor check here — a hard turn loops back near the start. + expect(turning.maxYawRate).toBeGreaterThan(0.3); + expect(turning.maxYawRate).toBeLessThan(1.6); + expect(straight.maxYawRate).toBeLessThan(0.05); + }); + + it('steers left on positive input', async () => { + // Forward is +Z and up is +Y, so left is +X. + const r = await run({ throttle: 1, steer: 1 }, 2); + expect(r.pos.x).toBeGreaterThan(0.2); + }); + + it('stays upright under power and steering', async () => { + const r = await run({ throttle: 1, steer: 1 }, 8); + expect(r.grounded).toBe(true); + }); +}); diff --git a/src/physics/physics.ts b/src/physics/physics.ts new file mode 100644 index 0000000..57ea18d --- /dev/null +++ b/src/physics/physics.ts @@ -0,0 +1,137 @@ +import RAPIER from '@dimforge/rapier3d-compat'; +import type { WorldModel } from '../sim/world'; +import { CAR, WHEELS } from '../carSpec'; + +export interface PhysicsWorld { + rapier: RAPIER.World; + events: RAPIER.EventQueue; + chassis: RAPIER.RigidBody; + vehicle: RAPIER.DynamicRayCastVehicleController; + /** One body per obstacle, in the same order as model.obstacles. */ + obstacleBodies: RAPIER.RigidBody[]; + /** Contact-force magnitude accumulated on the chassis since the last read. */ + drainImpactForce(): number; + step(dt: number): void; + respawn(): void; +} + +/** Contacts weaker than this are just kerb-scrubbing, not damage. */ +const IMPACT_THRESHOLD = 4000; + +export async function createPhysics(model: WorldModel): Promise { + await RAPIER.init(); + + const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); + const events = new RAPIER.EventQueue(true); + + // --- Ground: a flat plate for now. Terrain and roads land here later. --- + const groundBody = world.createRigidBody( + RAPIER.RigidBodyDesc.fixed().setTranslation(0, -0.5, 0), + ); + world.createCollider( + RAPIER.ColliderDesc.cuboid(model.extent + 60, 0.5, model.extent + 60).setFriction(1.1), + groundBody, + ); + + // --- Obstacles --- + const obstacleBodies = model.obstacles.map((o) => { + const half = { x: o.width / 2, y: o.height / 2, z: o.depth / 2 }; + const desc = + o.kind === 'crate' ? RAPIER.RigidBodyDesc.dynamic() : RAPIER.RigidBodyDesc.fixed(); + const body = world.createRigidBody( + desc + .setTranslation(o.x, half.y, o.z) + .setRotation({ x: 0, y: Math.sin(o.yaw / 2), z: 0, w: Math.cos(o.yaw / 2) }), + ); + world.createCollider( + RAPIER.ColliderDesc.cuboid(half.x, half.y, half.z) + .setDensity(o.kind === 'crate' ? 60 : 0) + .setFriction(0.8), + body, + ); + return body; + }); + + // --- Car chassis --- + const chassis = world.createRigidBody( + RAPIER.RigidBodyDesc.dynamic() + .setTranslation(CAR.spawn.x, CAR.spawn.y, CAR.spawn.z) + .setLinearDamping(0.1) + .setAngularDamping(0.4) + // The mass comes from here, not from collider density, so the centre of mass + // can sit below the box centre — a high CoM makes the raycast vehicle flip. + .setAdditionalMassProperties( + CAR.mass, + { x: 0, y: -0.35, z: 0 }, + { x: 1369, y: 1621, z: 342 }, + { x: 0, y: 0, z: 0, w: 1 }, + ), + ); + const chassisCollider = world.createCollider( + RAPIER.ColliderDesc.cuboid(CAR.halfWidth, CAR.halfHeight, CAR.halfLength) + .setDensity(0) + .setFriction(0.4) + .setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS) + .setContactForceEventThreshold(IMPACT_THRESHOLD), + chassis, + ); + + const vehicle = world.createVehicleController(chassis); + vehicle.indexUpAxis = 1; + // Typings name this setter oddly; it is the forward-axis setter. 2 = local +Z. + vehicle.setIndexForwardAxis = 2; + + for (const w of WHEELS) { + vehicle.addWheel( + { x: w.x, y: CAR.wheel.offsetY, z: w.z }, + { x: 0, y: -1, z: 0 }, + { x: -1, y: 0, z: 0 }, + CAR.wheel.suspensionRestLength, + CAR.wheel.radius, + ); + } + for (let i = 0; i < WHEELS.length; i++) { + vehicle.setWheelSuspensionStiffness(i, 24); + vehicle.setWheelSuspensionCompression(i, 2.0); + vehicle.setWheelSuspensionRelaxation(i, 3.0); + vehicle.setWheelMaxSuspensionTravel(i, 0.25); + vehicle.setWheelMaxSuspensionForce(i, 40000); + vehicle.setWheelSideFrictionStiffness(i, 1); + vehicle.setWheelFrictionSlip(i, 4); + } + + let pendingImpact = 0; + const chassisHandle = chassisCollider.handle; + + return { + rapier: world, + events, + chassis, + vehicle, + obstacleBodies, + + step(dt: number) { + world.timestep = dt; + vehicle.updateVehicle(dt); + world.step(events); + events.drainContactForceEvents((e) => { + if (e.collider1() === chassisHandle || e.collider2() === chassisHandle) { + pendingImpact += e.totalForceMagnitude(); + } + }); + }, + + drainImpactForce() { + const v = pendingImpact; + pendingImpact = 0; + return v; + }, + + respawn() { + chassis.setTranslation({ x: CAR.spawn.x, y: CAR.spawn.y, z: CAR.spawn.z }, true); + chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true); + chassis.setLinvel({ x: 0, y: 0, z: 0 }, true); + chassis.setAngvel({ x: 0, y: 0, z: 0 }, true); + }, + }; +} diff --git a/src/render/scene.ts b/src/render/scene.ts new file mode 100644 index 0000000..1b29505 --- /dev/null +++ b/src/render/scene.ts @@ -0,0 +1,165 @@ +import * as THREE from 'three'; +import type { WorldModel } from '../sim/world'; +import { CAR, WHEELS } from '../carSpec'; + +export interface SceneView { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + car: THREE.Group; + wheels: THREE.Object3D[]; + obstacles: THREE.Mesh[]; + /** Keeps the shadow frustum centred on the car. */ + followSun(): void; + dispose(): void; +} + +const SKY = 0x11161c; + +export function createScene(model: WorldModel): SceneView { + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + renderer.setSize(innerWidth, innerHeight); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + document.body.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(SKY); + scene.fog = new THREE.Fog(SKY, 90, 320); + + const camera = new THREE.PerspectiveCamera(62, innerWidth / innerHeight, 0.2, 900); + camera.position.set(0, 6, -12); + + scene.add(new THREE.HemisphereLight(0x9fb4c7, 0x2a2823, 1.1)); + const sun = new THREE.DirectionalLight(0xfff0dc, 2.1); + sun.castShadow = true; + sun.shadow.mapSize.set(1024, 1024); + const cam = sun.shadow.camera; + cam.left = -40; + cam.right = 40; + cam.top = 40; + cam.bottom = -40; + cam.near = 1; + cam.far = 140; + scene.add(sun); + scene.add(sun.target); + + // --- Ground --- + const groundSize = (model.extent + 60) * 2; + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(groundSize, groundSize), + new THREE.MeshStandardMaterial({ color: 0x2f3630, roughness: 1 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + // A grid gives the flat plate enough texture to read speed off. + const grid = new THREE.GridHelper(groundSize, groundSize / 10, 0x4a5750, 0x3a423c); + grid.position.y = 0.02; + scene.add(grid); + + // --- Obstacles --- + const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 }); + const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 }); + const boxGeo = new THREE.BoxGeometry(1, 1, 1); + + const obstacles = model.obstacles.map((o) => { + const mesh = new THREE.Mesh(boxGeo, o.kind === 'crate' ? crateMat : blockMat); + mesh.scale.set(o.width, o.height, o.depth); + mesh.position.set(o.x, o.height / 2, o.z); + mesh.rotation.y = o.yaw; + mesh.castShadow = true; + mesh.receiveShadow = true; + scene.add(mesh); + return mesh; + }); + + // --- Car --- + const car = new THREE.Group(); + const body = new THREE.Mesh( + new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2), + new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }), + ); + body.castShadow = true; + car.add(body); + + // A cabin block, purely so the car's facing is readable at a glance. + const cabin = new THREE.Mesh( + new THREE.BoxGeometry(1.5, 0.55, 1.8), + new THREE.MeshStandardMaterial({ color: 0x25303a, roughness: 0.4 }), + ); + cabin.position.set(0, CAR.halfHeight + 0.25, -0.15); + cabin.castShadow = true; + car.add(cabin); + + const wheelGeo = new THREE.CylinderGeometry( + CAR.wheel.radius, + CAR.wheel.radius, + CAR.wheel.width, + 16, + ); + // Cylinders are Y-up; rotate so the axle runs along X. + wheelGeo.rotateZ(Math.PI / 2); + const wheelMat = new THREE.MeshStandardMaterial({ color: 0x1c1f22, roughness: 0.95 }); + + const wheels = WHEELS.map((w) => { + // Pivot carries steering yaw; the mesh inside carries roll. + const pivot = new THREE.Group(); + pivot.position.set(w.x, CAR.wheel.offsetY, w.z); + const mesh = new THREE.Mesh(wheelGeo, wheelMat); + mesh.castShadow = true; + pivot.add(mesh); + car.add(pivot); + return pivot; + }); + + scene.add(car); + + const onResize = () => { + camera.aspect = innerWidth / innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(innerWidth, innerHeight); + }; + addEventListener('resize', onResize); + + return { + renderer, + scene, + camera, + car, + wheels, + obstacles, + followSun() { + // Keep the shadow frustum centred on the car rather than the origin. + sun.position.set(car.position.x + 45, 70, car.position.z + 25); + sun.target.position.copy(car.position); + }, + dispose() { + removeEventListener('resize', onResize); + renderer.dispose(); + renderer.domElement.remove(); + }, + }; +} + +const camTarget = new THREE.Vector3(); +const camDesired = new THREE.Vector3(); +const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5); + +/** Smoothed chase camera. Frame-rate independent damping. */ +export function updateCamera(view: SceneView, dt: number, speed: number): void { + const { camera, car } = view; + + camDesired.copy(CHASE_OFFSET); + // Pull back a little at speed for a sense of pace. + camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3); + camDesired.applyQuaternion(car.quaternion).add(car.position); + + const lerp = 1 - Math.exp(-6 * dt); + camera.position.lerp(camDesired, lerp); + + camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position); + camera.lookAt(camTarget); +} diff --git a/src/sim/car.ts b/src/sim/car.ts new file mode 100644 index 0000000..16b886c --- /dev/null +++ b/src/sim/car.ts @@ -0,0 +1,72 @@ +/** + * Car condition and what it does to handling. Pure — no engine imports. + * + * Design pillar this serves: "Decline, not reset." Condition only ever falls. + * Phase 0's whole job is to make that decline *felt* through the wheel, so the + * handling numbers the physics layer uses are derived here rather than constants. + */ + +export interface CarCondition { + /** 1 = factory fresh, 0 = ruined. */ + engine: number; + tires: number; + chassis: number; +} + +export interface Handling { + /** Newtons of drive force available per driven wheel at full throttle. */ + engineForce: number; + /** Braking impulse per wheel. */ + brakeForce: number; + /** Max steering angle, radians. */ + maxSteer: number; + /** Tyre grip. Lower = slides. */ + frictionSlip: number; + sideFrictionStiffness: number; + /** Constant tug on the wheel from a bent chassis, radians. Signed. */ + steeringPull: number; +} + +export const freshCondition = (): CarCondition => ({ engine: 1, tires: 1, chassis: 1 }); + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; +const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); + +export function deriveHandling(c: CarCondition): Handling { + return { + // A tired engine simply cannot push as hard. + engineForce: lerp(900, 2600, c.engine), + // Worn pads take longer to haul the car down. + brakeForce: lerp(4, 14, c.tires), + maxSteer: lerp(0.35, 0.55, c.chassis), + // Bald tyres are the most legible failure: the back end starts to leave. + frictionSlip: lerp(1.6, 5, c.tires), + sideFrictionStiffness: lerp(0.5, 1, c.tires), + // A bent chassis pulls to one side. Sign is stable for a given car. + steeringPull: (1 - c.chassis) * 0.06, + }; +} + +export interface WearInput { + /** Seconds of simulated time. */ + dt: number; + /** Metres travelled this step. */ + distance: number; + /** Throttle actually applied, 0..1. */ + throttle: number; + /** Sum of impact force magnitudes registered this step, newtons. */ + impactForce: number; +} + +/** + * Returns a new condition. Numbers here are deliberately aggressive so a + * 15-minute session shows visible decline — tune down once the feel is right. + */ +export function applyWear(c: CarCondition, w: WearInput): CarCondition { + const impact = w.impactForce / 1e5; + return { + engine: clamp01(c.engine - w.throttle * w.dt * 6e-4 - impact * 0.01), + tires: clamp01(c.tires - w.distance * 8e-5 - impact * 0.02), + chassis: clamp01(c.chassis - impact * 0.05), + }; +} diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts new file mode 100644 index 0000000..aa62172 --- /dev/null +++ b/src/sim/sim.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { generateWorld } from './world'; +import { applyWear, deriveHandling, freshCondition } from './car'; + +describe('world generation', () => { + it('is reproducible from a seed', () => { + expect(generateWorld(42)).toEqual(generateWorld(42)); + }); + + it('differs between seeds', () => { + expect(generateWorld(42)).not.toEqual(generateWorld(43)); + }); + + it('keeps the spawn point clear', () => { + for (const o of generateWorld(7).obstacles) { + expect(Math.hypot(o.x, o.z)).toBeGreaterThanOrEqual(12); + } + }); +}); + +describe('car condition', () => { + it('never recovers', () => { + let c = freshCondition(); + for (let i = 0; i < 600; i++) { + const next = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 1, impactForce: 0 }); + expect(next.engine).toBeLessThanOrEqual(c.engine); + expect(next.tires).toBeLessThanOrEqual(c.tires); + c = next; + } + }); + + it('bottoms out rather than going negative', () => { + let c = freshCondition(); + for (let i = 0; i < 200; i++) { + c = applyWear(c, { dt: 1 / 60, distance: 5, throttle: 1, impactForce: 5e5 }); + } + expect(c.chassis).toBe(0); + expect(c.tires).toBe(0); + }); + + it('makes a worn car measurably worse to drive', () => { + const fresh = deriveHandling(freshCondition()); + const worn = deriveHandling({ engine: 0.3, tires: 0.3, chassis: 0.3 }); + expect(worn.engineForce).toBeLessThan(fresh.engineForce); + expect(worn.frictionSlip).toBeLessThan(fresh.frictionSlip); + expect(worn.steeringPull).toBeGreaterThan(fresh.steeringPull); + }); +}); diff --git a/src/sim/world.ts b/src/sim/world.ts new file mode 100644 index 0000000..1ca9503 --- /dev/null +++ b/src/sim/world.ts @@ -0,0 +1,66 @@ +/** + * The world model. Pure data + pure functions: no three.js, no Rapier. + * + * Right now this is only a flat plate and some scattered boxes. It is the seam + * where roads, heat, regions and the front line will live later — keeping it free + * of engine imports is what makes those systems unit-testable and fast-forwardable. + */ +import { makeRng, randRange, type Rng } from '../core/rng'; + +export interface Obstacle { + x: number; + z: number; + /** Full extents, not half-extents. */ + width: number; + height: number; + depth: number; + yaw: number; + /** Static blocks are terrain-like; dynamic ones can be shoved around. */ + kind: 'block' | 'crate'; +} + +export interface WorldModel { + seed: number; + /** Half-width of the drivable plate, in metres. */ + extent: number; + obstacles: Obstacle[]; +} + +const SPAWN_CLEARANCE = 12; + +export function generateWorld(seed: number, count = 140, extent = 220): WorldModel { + const rng: Rng = makeRng(seed); + const obstacles: Obstacle[] = []; + + while (obstacles.length < count) { + const x = randRange(rng, -extent, extent); + const z = randRange(rng, -extent, extent); + // Leave the player's spawn point clear so the car never starts inside a wall. + if (Math.hypot(x, z) < SPAWN_CLEARANCE) continue; + + const crate = rng() < 0.45; + obstacles.push( + crate + ? { + x, + z, + width: randRange(rng, 0.8, 1.4), + height: randRange(rng, 0.8, 1.4), + depth: randRange(rng, 0.8, 1.4), + yaw: rng() * Math.PI, + kind: 'crate', + } + : { + x, + z, + width: randRange(rng, 1.5, 5), + height: randRange(rng, 1.2, 3.5), + depth: randRange(rng, 1.5, 5), + yaw: rng() * Math.PI, + kind: 'block', + }, + ); + } + + return { seed, extent, obstacles }; +} diff --git a/src/ui/hud.ts b/src/ui/hud.ts new file mode 100644 index 0000000..97bfb78 --- /dev/null +++ b/src/ui/hud.ts @@ -0,0 +1,31 @@ +import type { CarCondition } from '../sim/car'; + +const BAR_WIDTH = 12; + +function bar(value: number): string { + const filled = Math.round(value * BAR_WIDTH); + return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled); +} + +export function createHud(seed: number) { + const el = document.getElementById('hud')!; + let last = 0; + + return { + update(speedMs: number, condition: CarCondition, now: number) { + // The HUD is debug scaffolding, not the real interface — 10 Hz is plenty. + if (now - last < 0.1) return; + last = now; + el.textContent = [ + `${Math.abs(speedMs * 3.6).toFixed(0).padStart(3)} km/h`, + '', + `engine ${bar(condition.engine)} ${(condition.engine * 100).toFixed(0)}%`, + `tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`, + `chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`, + '', + `seed ${seed}`, + 'WASD drive · space handbrake · R respawn', + ].join('\n'); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..69c47e3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +}