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 <noreply@anthropic.com>
This commit is contained in:
commit
a66032674d
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# local Claude Code settings
|
||||||
|
.claude/settings.local.json
|
||||||
70
README.md
Normal file
70
README.md
Normal file
@ -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.
|
||||||
36
index.html
Normal file
36
index.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Drive Between the Lines</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1014; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#hud {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
color: #cfd6dd;
|
||||||
|
text-shadow: 0 1px 2px #000;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
#boot {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font: 13px ui-monospace, monospace;
|
||||||
|
color: #6d7883;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="boot">loading physics…</div>
|
||||||
|
<div id="hud"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1624
package-lock.json
generated
Normal file
1624
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/carSpec.ts
Normal file
32
src/carSpec.ts
Normal file
@ -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;
|
||||||
50
src/core/input.ts
Normal file
50
src/core/input.ts
Normal file
@ -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<string>();
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/core/loop.ts
Normal file
46
src/core/loop.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/core/rng.ts
Normal file
29
src/core/rng.ts
Normal file
@ -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);
|
||||||
96
src/main.ts
Normal file
96
src/main.ts
Normal file
@ -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}`;
|
||||||
|
});
|
||||||
66
src/physics/drive.ts
Normal file
66
src/physics/drive.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/physics/physics.test.ts
Normal file
68
src/physics/physics.test.ts
Normal file
@ -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<DriverInput>, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/physics/physics.ts
Normal file
137
src/physics/physics.ts
Normal file
@ -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<PhysicsWorld> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
165
src/render/scene.ts
Normal file
165
src/render/scene.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
72
src/sim/car.ts
Normal file
72
src/sim/car.ts
Normal file
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
48
src/sim/sim.test.ts
Normal file
48
src/sim/sim.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/sim/world.ts
Normal file
66
src/sim/world.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
31
src/ui/hud.ts
Normal file
31
src/ui/hud.ts
Normal file
@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user