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