Phase 1: road network and heat
Roads are a jittered 7x7 grid with ~a quarter of the edges thinned out, kept connected. The loops are the point: "take a different road" is not a decision without alternative routes. Every metre driven adds heat to that segment; all roads shed it slowly. Crossing a threshold escalates Clear -> Patrol -> Barricade -> Turret, each of which puts real obstructions on the tarmac. Hysteresis stops a road on a boundary from rebuilding its barricade every few seconds. Level changes are deferred while the car is on the segment. Building a barricade around the player would spawn a static collider inside the chassis, and the player is meant to discover escalation by returning to a road, not by watching it assemble behind them. Checkpoint positions are seeded from the segment id, so a stretch of road always fortifies in the same place — recognising it is part of learning the map. Scenery now avoids the tarmac and the car spawns at a junction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a66032674d
commit
506d07be5d
78
README.md
78
README.md
@ -1,8 +1,9 @@
|
|||||||
# Drive Between the Lines — Phase 0 prototype
|
# Drive Between the Lines — prototype
|
||||||
|
|
||||||
Endless driving survival game. This is the **Phase 0** skeleton from the roadmap:
|
Endless driving survival game.
|
||||||
a drivable car on a flat plate with scattered obstacles, plus the persistent wear
|
|
||||||
system that makes condition felt through the wheel.
|
- **Phase 0** — a drivable car with persistent wear that is felt through the wheel.
|
||||||
|
- **Phase 1** — a road network whose roads remember being driven, and escalate.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
@ -25,6 +26,38 @@ npm test # sim + headless physics
|
|||||||
npm run build # typecheck + production bundle
|
npm run build # typecheck + production bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Road heat
|
||||||
|
|
||||||
|
Every metre driven on a road adds heat to that segment; every road everywhere
|
||||||
|
sheds it slowly. Cross a threshold and the road escalates:
|
||||||
|
|
||||||
|
`Clear → Patrol → Barricade → Turret`
|
||||||
|
|
||||||
|
- **Patrol** parks a vehicle on the verge. The road narrows.
|
||||||
|
- **Barricade** puts concrete across it, leaving a gap you have to slow for.
|
||||||
|
- **Turret** adds a tower overlooking the checkpoint.
|
||||||
|
|
||||||
|
Roughly four traversals take a road from clear to turret; an untouched road
|
||||||
|
cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
|
||||||
|
guesses meant to be tuned.
|
||||||
|
|
||||||
|
Three things are deliberate:
|
||||||
|
|
||||||
|
- **No AI yet.** The props are stationary hazards. Escalation currently means the
|
||||||
|
road gets slower and more expensive to get wrong, which is enough to test the
|
||||||
|
phase's real question.
|
||||||
|
- **Changes never happen on the road you are on.** They are deferred until you
|
||||||
|
leave. Otherwise a barricade would spawn a static collider inside your car —
|
||||||
|
and you are supposed to discover escalation by *coming back*, not by watching
|
||||||
|
it assemble behind you.
|
||||||
|
- **A checkpoint's position is seeded from its segment**, so a given stretch of
|
||||||
|
road always fortifies in the same place. Recognising it is part of learning
|
||||||
|
the map.
|
||||||
|
|
||||||
|
The road network is a jittered grid with roughly a quarter of its edges removed,
|
||||||
|
keeping the whole thing connected. The loops are the point: "take a different
|
||||||
|
road this time" is not a decision unless alternative routes exist.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
|
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
|
||||||
@ -35,14 +68,20 @@ ever opening a browser.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
sim/ pure model — world generation, car condition → handling
|
sim/ pure model — roads, heat, world generation, condition → handling
|
||||||
physics/ Rapier world, raycast vehicle, input → wheel forces
|
physics/ Rapier world, raycast vehicle, input → wheel forces
|
||||||
render/ three.js scene, chase camera
|
render/ three.js scene, roads, chase camera
|
||||||
core/ fixed-timestep loop, seeded RNG, keyboard
|
core/ fixed-timestep loop, seeded RNG, keyboard
|
||||||
ui/ debug HUD
|
ui/ debug HUD
|
||||||
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
|
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
|
||||||
|
heatProps.ts integration layer: heat levels → colliders + meshes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`heatProps.ts` sits at the top level on purpose — it is the one module allowed to
|
||||||
|
touch both Rapier and three.js, because it owns objects that must exist in both
|
||||||
|
or neither. Its tests run headless: three.js scene graphs work fine in Node, so
|
||||||
|
"the barricade's collider was removed along with its mesh" is a unit test.
|
||||||
|
|
||||||
Data flows one way: `sim` → `physics` → `render`. Condition reaches the physics
|
Data flows one way: `sim` → `physics` → `render`. Condition reaches the physics
|
||||||
layer already digested into a `Handling` by `deriveHandling`, so there is exactly
|
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".
|
one place where "how broken the car is" turns into "how it drives".
|
||||||
@ -56,8 +95,13 @@ one place where "how broken the car is" turns into "how it drives".
|
|||||||
rather than hours. Turn them down in `applyWear` once the curve reads right.
|
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
|
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
||||||
rate changes.
|
rate changes.
|
||||||
- **Condition is not yet persisted** across reloads. IndexedDB comes with the
|
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
|
||||||
campaign layer.
|
with the campaign layer.
|
||||||
|
- **Heat has no diegetic signal at a distance.** You learn a road is hot by
|
||||||
|
arriving at the checkpoint. The brief wants it readable from patrol density and
|
||||||
|
wreckage before you commit — that needs the enemy presence Phase 5 brings.
|
||||||
|
- **The heat HUD lines are debug scaffolding.** The brief is explicit that no
|
||||||
|
numeric heat meter ships; they exist to tune the curve and should come out.
|
||||||
- **Bundle:** ~2.7 MB raw / ~945 KB gzipped, dominated by Rapier's WASM, which
|
- **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`
|
`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
|
package serves the WASM as a separate file (~570 KB gzipped, compiled in
|
||||||
@ -66,5 +110,11 @@ one place where "how broken the car is" turns into "how it drives".
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
Phase 0's gate: drive for 15–20 minutes, feel the condition changing how the car
|
Phase 1's gate: **you catch yourself avoiding a road because of its history, not
|
||||||
handles, and want to keep driving anyway. Everything else waits on that.
|
its distance.** That cannot be checked by a test — it needs you driving the same
|
||||||
|
routes for a while and noticing what you start doing.
|
||||||
|
|
||||||
|
If it fails, the likely culprits, in order: escalation is too slow to matter
|
||||||
|
within a session (`METRES_PER_HEAT`), decay is so fast that nothing accumulates
|
||||||
|
(`DECAY_PER_SECOND`), or the props are not actually inconvenient enough to route
|
||||||
|
around. Tune before building Phase 2 on top.
|
||||||
|
|||||||
@ -20,7 +20,8 @@ export const CAR = {
|
|||||||
offsetY: -0.15,
|
offsetY: -0.15,
|
||||||
suspensionRestLength: 0.32,
|
suspensionRestLength: 0.32,
|
||||||
},
|
},
|
||||||
spawn: { x: 0, y: 1.2, z: 0 },
|
/** Only the drop height; the horizontal spawn comes from the world model. */
|
||||||
|
spawn: { y: 1.2 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Wheel order used everywhere: front-left, front-right, rear-left, rear-right. */
|
/** Wheel order used everywhere: front-left, front-right, rear-left, rear-right. */
|
||||||
|
|||||||
71
src/heatProps.test.ts
Normal file
71
src/heatProps.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createHeatProps } from './heatProps';
|
||||||
|
import { createPhysics } from './physics/physics';
|
||||||
|
import { createHeat, stepHeat } from './sim/heat';
|
||||||
|
import { generateWorld } from './sim/world';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* three.js scene graphs work without a browser (only WebGLRenderer needs one),
|
||||||
|
* so the prop lifecycle can be checked end to end — including that removing a
|
||||||
|
* barricade removes its collider, not just its mesh.
|
||||||
|
*/
|
||||||
|
describe('heat props lifecycle', () => {
|
||||||
|
it('builds and tears down colliders and meshes together', async () => {
|
||||||
|
const world = generateWorld(3, 0);
|
||||||
|
const physics = await createPhysics(world);
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const props = createHeatProps(world.roads, physics, scene);
|
||||||
|
const heat = createHeat(world.roads);
|
||||||
|
|
||||||
|
const baseBodies = physics.rapier.bodies.len();
|
||||||
|
const baseMeshes = scene.children.length;
|
||||||
|
|
||||||
|
const drive = (metres: number) => {
|
||||||
|
for (let m = 0; m < metres; m++) {
|
||||||
|
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const idle = (seconds: number) => {
|
||||||
|
for (let i = 0; i < seconds * 60; i++) {
|
||||||
|
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
drive(400);
|
||||||
|
expect(heat.level[0]).toBe('turret');
|
||||||
|
// Still on the road, so nothing has been built around the car yet.
|
||||||
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
|
|
||||||
|
// Leaving it lets the deferred work land.
|
||||||
|
idle(1);
|
||||||
|
const hotBodies = physics.rapier.bodies.len();
|
||||||
|
expect(hotBodies).toBeGreaterThan(baseBodies);
|
||||||
|
expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies);
|
||||||
|
|
||||||
|
// Left alone, the road should give everything back.
|
||||||
|
idle(300);
|
||||||
|
expect(heat.level[0]).toBe('clear');
|
||||||
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
|
expect(scene.children.length).toBe(baseMeshes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak bodies when a road escalates through every level', async () => {
|
||||||
|
const world = generateWorld(3, 0);
|
||||||
|
const physics = await createPhysics(world);
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const props = createHeatProps(world.roads, physics, scene);
|
||||||
|
const heat = createHeat(world.roads);
|
||||||
|
const baseBodies = physics.rapier.bodies.len();
|
||||||
|
|
||||||
|
for (let cycle = 0; cycle < 3; cycle++) {
|
||||||
|
for (let m = 0; m < 400; m++) {
|
||||||
|
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 300 * 60; i++) {
|
||||||
|
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
||||||
|
}
|
||||||
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/heatProps.ts
Normal file
87
src/heatProps.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Turns heat levels into things that physically exist on the road.
|
||||||
|
*
|
||||||
|
* This is an integration layer: it is allowed to touch both Rapier and three.js,
|
||||||
|
* which is why it sits outside src/sim/. The sim decides *what* should be there
|
||||||
|
* ({@link propsFor}); this only builds and tears it down.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type RAPIER from '@dimforge/rapier3d-compat';
|
||||||
|
import { propsFor, type HeatProp, type HeatState } from './sim/heat';
|
||||||
|
import type { RoadNetwork } from './sim/roads';
|
||||||
|
import type { PhysicsWorld } from './physics/physics';
|
||||||
|
|
||||||
|
const MATERIALS: Record<HeatProp['kind'], THREE.MeshStandardMaterial> = {
|
||||||
|
patrol: new THREE.MeshStandardMaterial({ color: 0x4b5540, roughness: 0.7 }),
|
||||||
|
barricade: new THREE.MeshStandardMaterial({ color: 0x9aa0a4, roughness: 0.95 }),
|
||||||
|
tower: new THREE.MeshStandardMaterial({ color: 0x54493d, roughness: 0.9 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOX = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
|
||||||
|
interface Placed {
|
||||||
|
bodies: RAPIER.RigidBody[];
|
||||||
|
meshes: THREE.Mesh[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHeatProps(
|
||||||
|
roads: RoadNetwork,
|
||||||
|
physics: PhysicsWorld,
|
||||||
|
scene: THREE.Scene,
|
||||||
|
) {
|
||||||
|
const placed = new Map<number, Placed>();
|
||||||
|
/** Level changes waiting for the player to get off the road in question. */
|
||||||
|
const pending = new Set<number>();
|
||||||
|
|
||||||
|
const clear = (segmentId: number) => {
|
||||||
|
const existing = placed.get(segmentId);
|
||||||
|
if (!existing) return;
|
||||||
|
for (const body of existing.bodies) physics.removeBody(body);
|
||||||
|
for (const mesh of existing.meshes) scene.remove(mesh);
|
||||||
|
placed.delete(segmentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const build = (segmentId: number, heat: HeatState) => {
|
||||||
|
const segment = roads.segments[segmentId]!;
|
||||||
|
const props = propsFor(segment, heat.level[segmentId]!);
|
||||||
|
if (props.length === 0) return;
|
||||||
|
|
||||||
|
const entry: Placed = { bodies: [], meshes: [] };
|
||||||
|
for (const prop of props) {
|
||||||
|
entry.bodies.push(physics.addStaticBox(prop));
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(BOX, MATERIALS[prop.kind]);
|
||||||
|
mesh.scale.set(prop.width, prop.height, prop.depth);
|
||||||
|
mesh.position.set(prop.x, prop.height / 2, prop.z);
|
||||||
|
mesh.rotation.y = prop.yaw;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
scene.add(mesh);
|
||||||
|
entry.meshes.push(mesh);
|
||||||
|
}
|
||||||
|
placed.set(segmentId, entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Rebuild the segments whose level changed — but never the one the car is
|
||||||
|
* currently on. A barricade appearing around the player would spawn a static
|
||||||
|
* collider inside the chassis, and watching a checkpoint assemble itself in
|
||||||
|
* the mirror would break the fiction anyway. Changes wait until they are
|
||||||
|
* out of sight, which is how the player is meant to find them: by returning.
|
||||||
|
*
|
||||||
|
* Call every step, not only when something changed, so deferred work drains.
|
||||||
|
*/
|
||||||
|
sync(changedSegmentIds: readonly number[], heat: HeatState, occupiedSegmentId: number | null) {
|
||||||
|
for (const id of changedSegmentIds) pending.add(id);
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
|
||||||
|
for (const id of [...pending]) {
|
||||||
|
if (id === occupiedSegmentId) continue;
|
||||||
|
pending.delete(id);
|
||||||
|
clear(id);
|
||||||
|
build(id, heat);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/main.ts
21
src/main.ts
@ -1,5 +1,8 @@
|
|||||||
import { generateWorld } from './sim/world';
|
import { generateWorld } from './sim/world';
|
||||||
import { applyWear, deriveHandling, freshCondition } from './sim/car';
|
import { applyWear, deriveHandling, freshCondition } from './sim/car';
|
||||||
|
import { createHeat, stepHeat } from './sim/heat';
|
||||||
|
import { segmentAt } from './sim/roads';
|
||||||
|
import { createHeatProps } from './heatProps';
|
||||||
import { seedFromString } from './core/rng';
|
import { seedFromString } from './core/rng';
|
||||||
import { startLoop } from './core/loop';
|
import { startLoop } from './core/loop';
|
||||||
import { createInput } from './core/input';
|
import { createInput } from './core/input';
|
||||||
@ -25,10 +28,13 @@ async function boot() {
|
|||||||
const input = createInput();
|
const input = createInput();
|
||||||
const hud = createHud(seed);
|
const hud = createHud(seed);
|
||||||
const driveState = createDriveState();
|
const driveState = createDriveState();
|
||||||
|
const heat = createHeat(model.roads);
|
||||||
|
const heatProps = createHeatProps(model.roads, physics, view.scene);
|
||||||
|
|
||||||
let condition = freshCondition();
|
let condition = freshCondition();
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
let respawnLatch = false;
|
let respawnLatch = false;
|
||||||
|
let currentSegment: number | null = null;
|
||||||
|
|
||||||
document.getElementById('boot')?.remove();
|
document.getElementById('boot')?.remove();
|
||||||
|
|
||||||
@ -45,12 +51,18 @@ async function boot() {
|
|||||||
|
|
||||||
// Wear is applied from what actually happened this step, not from intent.
|
// Wear is applied from what actually happened this step, not from intent.
|
||||||
const speed = physics.vehicle.currentVehicleSpeed();
|
const speed = physics.vehicle.currentVehicleSpeed();
|
||||||
|
const distance = Math.abs(speed) * dt;
|
||||||
condition = applyWear(condition, {
|
condition = applyWear(condition, {
|
||||||
dt,
|
dt,
|
||||||
distance: Math.abs(speed) * dt,
|
distance,
|
||||||
throttle: Math.abs(cmd.throttle),
|
throttle: Math.abs(cmd.throttle),
|
||||||
impactForce: physics.drainImpactForce(),
|
impactForce: physics.drainImpactForce(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Heat: the road under the wheels remembers being used.
|
||||||
|
const at = physics.chassis.translation();
|
||||||
|
currentSegment = segmentAt(model.roads, at.x, at.z)?.id ?? null;
|
||||||
|
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
|
||||||
},
|
},
|
||||||
|
|
||||||
render(_alpha, frameDt) {
|
render(_alpha, frameDt) {
|
||||||
@ -84,7 +96,12 @@ async function boot() {
|
|||||||
view.followSun();
|
view.followSun();
|
||||||
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
|
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
|
||||||
view.renderer.render(view.scene, view.camera);
|
view.renderer.render(view.scene, view.camera);
|
||||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed);
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
||||||
|
segmentId: currentSegment,
|
||||||
|
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
||||||
|
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
||||||
|
hottest: Math.max(...heat.value),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,9 @@ const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: fa
|
|||||||
|
|
||||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||||
async function run(input: Partial<DriverInput>, seconds: number) {
|
async function run(input: Partial<DriverInput>, seconds: number) {
|
||||||
const physics = await createPhysics(generateWorld(1, 0));
|
const world = generateWorld(1, 0);
|
||||||
|
const physics = await createPhysics(world);
|
||||||
|
const start = physics.chassis.translation();
|
||||||
const state = createDriveState();
|
const state = createDriveState();
|
||||||
const handling = deriveHandling(freshCondition());
|
const handling = deriveHandling(freshCondition());
|
||||||
const cmd = { ...IDLE, ...input };
|
const cmd = { ...IDLE, ...input };
|
||||||
@ -21,8 +23,10 @@ async function run(input: Partial<DriverInput>, seconds: number) {
|
|||||||
physics.step(STEP);
|
physics.step(STEP);
|
||||||
maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y));
|
maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y));
|
||||||
}
|
}
|
||||||
|
const now = physics.chassis.translation();
|
||||||
return {
|
return {
|
||||||
pos: physics.chassis.translation(),
|
// Displacement from the spawn point, which is a road junction, not the origin.
|
||||||
|
pos: { x: now.x - start.x, y: now.y, z: now.z - start.z },
|
||||||
speed: physics.vehicle.currentVehicleSpeed(),
|
speed: physics.vehicle.currentVehicleSpeed(),
|
||||||
maxYawRate,
|
maxYawRate,
|
||||||
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),
|
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),
|
||||||
|
|||||||
@ -13,6 +13,18 @@ export interface PhysicsWorld {
|
|||||||
drainImpactForce(): number;
|
drainImpactForce(): number;
|
||||||
step(dt: number): void;
|
step(dt: number): void;
|
||||||
respawn(): void;
|
respawn(): void;
|
||||||
|
/** Static box, added and removed at runtime as road heat rises and falls. */
|
||||||
|
addStaticBox(box: StaticBox): RAPIER.RigidBody;
|
||||||
|
removeBody(body: RAPIER.RigidBody): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticBox {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
yaw: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contacts weaker than this are just kerb-scrubbing, not damage. */
|
/** Contacts weaker than this are just kerb-scrubbing, not damage. */
|
||||||
@ -55,7 +67,7 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
|||||||
// --- Car chassis ---
|
// --- Car chassis ---
|
||||||
const chassis = world.createRigidBody(
|
const chassis = world.createRigidBody(
|
||||||
RAPIER.RigidBodyDesc.dynamic()
|
RAPIER.RigidBodyDesc.dynamic()
|
||||||
.setTranslation(CAR.spawn.x, CAR.spawn.y, CAR.spawn.z)
|
.setTranslation(model.spawn.x, CAR.spawn.y, model.spawn.z)
|
||||||
.setLinearDamping(0.1)
|
.setLinearDamping(0.1)
|
||||||
.setAngularDamping(0.4)
|
.setAngularDamping(0.4)
|
||||||
// The mass comes from here, not from collider density, so the centre of mass
|
// The mass comes from here, not from collider density, so the centre of mass
|
||||||
@ -127,8 +139,25 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
|||||||
return v;
|
return v;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addStaticBox(box) {
|
||||||
|
const body = world.createRigidBody(
|
||||||
|
RAPIER.RigidBodyDesc.fixed()
|
||||||
|
.setTranslation(box.x, box.height / 2, box.z)
|
||||||
|
.setRotation({ x: 0, y: Math.sin(box.yaw / 2), z: 0, w: Math.cos(box.yaw / 2) }),
|
||||||
|
);
|
||||||
|
world.createCollider(
|
||||||
|
RAPIER.ColliderDesc.cuboid(box.width / 2, box.height / 2, box.depth / 2).setFriction(0.8),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeBody(body) {
|
||||||
|
world.removeRigidBody(body);
|
||||||
|
},
|
||||||
|
|
||||||
respawn() {
|
respawn() {
|
||||||
chassis.setTranslation({ x: CAR.spawn.x, y: CAR.spawn.y, z: CAR.spawn.z }, true);
|
chassis.setTranslation({ x: model.spawn.x, y: CAR.spawn.y, z: model.spawn.z }, true);
|
||||||
chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
|
chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
|
||||||
chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
|
|||||||
@ -60,6 +60,27 @@ export function createScene(model: WorldModel): SceneView {
|
|||||||
grid.position.y = 0.02;
|
grid.position.y = 0.02;
|
||||||
scene.add(grid);
|
scene.add(grid);
|
||||||
|
|
||||||
|
// --- Roads ---
|
||||||
|
const roadMat = new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 1 });
|
||||||
|
const quad = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
|
||||||
|
for (const s of model.roads.segments) {
|
||||||
|
const strip = new THREE.Mesh(quad, roadMat);
|
||||||
|
strip.scale.set(s.width, 1, s.length);
|
||||||
|
strip.position.set((s.ax + s.bx) / 2, 0.03, (s.az + s.bz) / 2);
|
||||||
|
strip.rotation.y = Math.atan2(s.bx - s.ax, s.bz - s.az);
|
||||||
|
strip.receiveShadow = true;
|
||||||
|
scene.add(strip);
|
||||||
|
}
|
||||||
|
// Discs fill the wedge-shaped gaps where segments meet at an angle.
|
||||||
|
const junction = new THREE.CircleGeometry(1, 20).rotateX(-Math.PI / 2);
|
||||||
|
for (const n of model.roads.nodes) {
|
||||||
|
const disc = new THREE.Mesh(junction, roadMat);
|
||||||
|
disc.scale.setScalar(4.5);
|
||||||
|
disc.position.set(n.x, 0.031, n.z);
|
||||||
|
disc.receiveShadow = true;
|
||||||
|
scene.add(disc);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Obstacles ---
|
// --- Obstacles ---
|
||||||
const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 });
|
const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 });
|
||||||
const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 });
|
const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 });
|
||||||
|
|||||||
147
src/sim/heat.test.ts
Normal file
147
src/sim/heat.test.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateRoads, segmentAt, distanceToRoad, projectOntoSegment } from './roads';
|
||||||
|
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
|
||||||
|
import { generateWorld } from './world';
|
||||||
|
|
||||||
|
const roads = generateRoads(99, 220);
|
||||||
|
|
||||||
|
describe('road network', () => {
|
||||||
|
it('is reproducible and connected', () => {
|
||||||
|
expect(generateRoads(99, 220)).toEqual(roads);
|
||||||
|
// Every node must be reachable, or part of the map is unusable.
|
||||||
|
const adjacency = new Map<number, number[]>();
|
||||||
|
for (const s of roads.segments) {
|
||||||
|
adjacency.set(s.a, [...(adjacency.get(s.a) ?? []), s.b]);
|
||||||
|
adjacency.set(s.b, [...(adjacency.get(s.b) ?? []), s.a]);
|
||||||
|
}
|
||||||
|
const seen = new Set([roads.nodes[0]!.id]);
|
||||||
|
const stack = [roads.nodes[0]!.id];
|
||||||
|
while (stack.length) {
|
||||||
|
for (const next of adjacency.get(stack.pop()!) ?? []) {
|
||||||
|
if (seen.has(next)) continue;
|
||||||
|
seen.add(next);
|
||||||
|
stack.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(seen.size).toBe(roads.nodes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers alternative routes, not just a tree', () => {
|
||||||
|
// More edges than nodes-1 means the graph contains loops — which is the
|
||||||
|
// whole premise of "take a different road this time".
|
||||||
|
expect(roads.segments.length).toBeGreaterThan(roads.nodes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locates the segment under a point on it, and none off it', () => {
|
||||||
|
const s = roads.segments[3]!;
|
||||||
|
const mid = { x: (s.ax + s.bx) / 2, z: (s.az + s.bz) / 2 };
|
||||||
|
expect(segmentAt(roads, mid.x, mid.z)?.id).toBe(s.id);
|
||||||
|
expect(projectOntoSegment(s, mid.x, mid.z).t).toBeCloseTo(0.5, 5);
|
||||||
|
expect(distanceToRoad(roads, mid.x, mid.z)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('world', () => {
|
||||||
|
it('spawns the car on the network and keeps scenery off the tarmac', () => {
|
||||||
|
const world = generateWorld(5);
|
||||||
|
expect(segmentAt(world.roads, world.spawn.x, world.spawn.z)).not.toBeNull();
|
||||||
|
for (const o of world.obstacles) {
|
||||||
|
const reach = Math.hypot(o.width, o.depth) / 2;
|
||||||
|
expect(distanceToRoad(world.roads, o.x, o.z)).toBeGreaterThan(reach);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Drive `metres` along one segment, then idle for `idleSeconds`. */
|
||||||
|
function simulate(state: HeatState, segmentId: number, metres: number, idleSeconds = 0) {
|
||||||
|
const changed: number[] = [];
|
||||||
|
const stepMetres = 1;
|
||||||
|
for (let m = 0; m < metres; m += stepMetres) {
|
||||||
|
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId, distance: stepMetres }));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < idleSeconds * 60; i++) {
|
||||||
|
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId: null, distance: 0 }));
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('heat', () => {
|
||||||
|
it('escalates through every level as a road is reused', () => {
|
||||||
|
const state = createHeat(roads);
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (let trip = 0; trip < 6; trip++) {
|
||||||
|
simulate(state, 0, 80);
|
||||||
|
seen.push(state.level[0]!);
|
||||||
|
}
|
||||||
|
expect(seen).toContain('patrol');
|
||||||
|
expect(seen).toContain('barricade');
|
||||||
|
expect(seen).toContain('turret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unused roads alone', () => {
|
||||||
|
const state = createHeat(roads);
|
||||||
|
simulate(state, 0, 300);
|
||||||
|
expect(state.value[0]).toBeGreaterThan(0.5);
|
||||||
|
expect(state.value[1]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cools a road that goes unused', () => {
|
||||||
|
const state = createHeat(roads);
|
||||||
|
simulate(state, 0, 300);
|
||||||
|
const hot = state.value[0]!;
|
||||||
|
simulate(state, 1, 0, 120);
|
||||||
|
expect(state.value[0]).toBeLessThan(hot - 0.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flap between levels while hovering on a threshold', () => {
|
||||||
|
const state = createHeat(roads);
|
||||||
|
// Park heat just above the patrol threshold, then hold it there.
|
||||||
|
simulate(state, 0, 90);
|
||||||
|
expect(state.level[0]).toBe('patrol');
|
||||||
|
let flips = 0;
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
// Roughly cancel the decay, so heat sits on the boundary.
|
||||||
|
const changed = stepHeat(state, { dt: 1 / 60, segmentId: 0, distance: 0.022 });
|
||||||
|
flips += changed.length;
|
||||||
|
}
|
||||||
|
expect(flips).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hysteresis means a level survives a small dip below its threshold', () => {
|
||||||
|
expect(levelFor(0.24, 'clear')).toBe('clear');
|
||||||
|
expect(levelFor(0.25, 'clear')).toBe('patrol');
|
||||||
|
expect(levelFor(0.2, 'patrol')).toBe('patrol');
|
||||||
|
expect(levelFor(0.16, 'patrol')).toBe('clear');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('heat props', () => {
|
||||||
|
const segment = roads.segments[0]!;
|
||||||
|
|
||||||
|
it('puts nothing on a clear road and escalates from there', () => {
|
||||||
|
expect(propsFor(segment, 'clear')).toEqual([]);
|
||||||
|
const counts = (['patrol', 'barricade', 'turret'] as const).map(
|
||||||
|
(l) => propsFor(segment, l).length,
|
||||||
|
);
|
||||||
|
expect(counts[0]).toBeLessThan(counts[1]!);
|
||||||
|
expect(counts[1]).toBeLessThan(counts[2]!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable for a segment, so a checkpoint stays where you left it', () => {
|
||||||
|
expect(propsFor(segment, 'turret')).toEqual(propsFor(segment, 'turret'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a gap in the barricade rather than sealing the road', () => {
|
||||||
|
const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade');
|
||||||
|
const spanned = blocks.reduce((sum, b) => sum + b.width, 0);
|
||||||
|
expect(spanned).toBeLessThan(segment.width - 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps patrols and barricades within the road, and towers beside it', () => {
|
||||||
|
for (const prop of propsFor(segment, 'turret')) {
|
||||||
|
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
|
||||||
|
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
|
||||||
|
else expect(distance).toBeLessThan(segment.width / 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
142
src/sim/heat.ts
Normal file
142
src/sim/heat.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Road heat. Pure — no three.js, no Rapier.
|
||||||
|
*
|
||||||
|
* "Reuse has consequences": every metre driven on a road makes that road worse,
|
||||||
|
* and roads left alone quietly recover. The player is never shown a number — the
|
||||||
|
* escalation is meant to be read off what is physically sitting in the road.
|
||||||
|
*/
|
||||||
|
import { makeRng, randRange } from '../core/rng';
|
||||||
|
import { pointOnSegment, type RoadNetwork, type RoadSegment } from './roads';
|
||||||
|
|
||||||
|
export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const;
|
||||||
|
export type HeatLevel = (typeof HEAT_LEVELS)[number];
|
||||||
|
|
||||||
|
/** Heat at which each level takes hold. */
|
||||||
|
const THRESHOLDS: Record<Exclude<HeatLevel, 'clear'>, number> = {
|
||||||
|
patrol: 0.25,
|
||||||
|
barricade: 0.55,
|
||||||
|
turret: 0.85,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A level, once established, holds until heat drops this far below its threshold.
|
||||||
|
* Without it a road sitting on a boundary rebuilds and dismantles its barricade
|
||||||
|
* every few seconds.
|
||||||
|
*/
|
||||||
|
const HYSTERESIS = 0.08;
|
||||||
|
|
||||||
|
/** Metres of driving that add a full point of heat. ~4 traversals to turret. */
|
||||||
|
const METRES_PER_HEAT = 330;
|
||||||
|
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
|
||||||
|
const DECAY_PER_SECOND = 0.004;
|
||||||
|
|
||||||
|
export interface HeatState {
|
||||||
|
/** Indexed by segment id. */
|
||||||
|
value: number[];
|
||||||
|
level: HeatLevel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHeat(roads: RoadNetwork): HeatState {
|
||||||
|
return {
|
||||||
|
value: new Array(roads.segments.length).fill(0),
|
||||||
|
level: new Array(roads.segments.length).fill('clear'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelFor(heat: number, current: HeatLevel): HeatLevel {
|
||||||
|
const rank = HEAT_LEVELS.indexOf(current);
|
||||||
|
let next: HeatLevel = 'clear';
|
||||||
|
for (const name of ['patrol', 'barricade', 'turret'] as const) {
|
||||||
|
// Rising uses the plain threshold; falling has to clear the hysteresis band.
|
||||||
|
const isCurrentOrLower = HEAT_LEVELS.indexOf(name) <= rank;
|
||||||
|
const bar = THRESHOLDS[name] - (isCurrentOrLower ? HYSTERESIS : 0);
|
||||||
|
if (heat >= bar) next = name;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatStep {
|
||||||
|
dt: number;
|
||||||
|
/** Segment the car is on right now, or null if off-road. */
|
||||||
|
segmentId: number | null;
|
||||||
|
/** Metres travelled this step. */
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances heat and returns the segments whose *level* changed. */
|
||||||
|
export function stepHeat(state: HeatState, step: HeatStep): number[] {
|
||||||
|
const changed: number[] = [];
|
||||||
|
const decay = DECAY_PER_SECOND * step.dt;
|
||||||
|
|
||||||
|
for (let id = 0; id < state.value.length; id++) {
|
||||||
|
let heat = state.value[id]! - decay;
|
||||||
|
if (id === step.segmentId) heat += step.distance / METRES_PER_HEAT;
|
||||||
|
heat = Math.min(1, Math.max(0, heat));
|
||||||
|
state.value[id] = heat;
|
||||||
|
|
||||||
|
const next = levelFor(heat, state.level[id]!);
|
||||||
|
if (next !== state.level[id]) {
|
||||||
|
state.level[id] = next;
|
||||||
|
changed.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatProp {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
yaw: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
depth: number;
|
||||||
|
kind: 'patrol' | 'barricade' | 'tower';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a given heat level physically puts on a road.
|
||||||
|
*
|
||||||
|
* There is no enemy AI yet, so these are stationary hazards: escalation shows up
|
||||||
|
* as the road getting harder to drive fast and more expensive to get wrong. That
|
||||||
|
* is enough to test the phase's real question — do you start avoiding a road
|
||||||
|
* because of its history?
|
||||||
|
*/
|
||||||
|
export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
|
||||||
|
if (level === 'clear') return [];
|
||||||
|
|
||||||
|
// Seeded off the segment so a road's checkpoint is always in the same place —
|
||||||
|
// recognising a specific stretch is part of learning the map.
|
||||||
|
const rng = makeRng((segment.id + 1) * 0x2545f491);
|
||||||
|
const props: HeatProp[] = [];
|
||||||
|
const half = segment.width / 2;
|
||||||
|
|
||||||
|
// Patrol: parked on the verge. Narrows the road, does not block it.
|
||||||
|
const patrolT = randRange(rng, 0.3, 0.7);
|
||||||
|
const patrolSide = rng() < 0.5 ? -1 : 1;
|
||||||
|
const patrol = pointOnSegment(segment, patrolT, patrolSide * (half - 1.4));
|
||||||
|
props.push({ ...patrol, width: 2, height: 1.6, depth: 4.4, kind: 'patrol' });
|
||||||
|
|
||||||
|
if (level === 'patrol') return props;
|
||||||
|
|
||||||
|
// Barricade: blocks across the road with a gap you have to slow down for.
|
||||||
|
const barricadeT = randRange(rng, 0.35, 0.65);
|
||||||
|
const gapSide = rng() < 0.5 ? -1 : 1;
|
||||||
|
const gapCentre = gapSide * randRange(rng, half * 0.35, half * 0.6);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const inner = gapCentre + side * 2.1;
|
||||||
|
const outer = side * half;
|
||||||
|
const blockWidth = Math.abs(outer - inner);
|
||||||
|
if (blockWidth < 0.8) continue;
|
||||||
|
const at = pointOnSegment(segment, barricadeT, (inner + outer) / 2);
|
||||||
|
props.push({ ...at, width: blockWidth, height: 1.5, depth: 1.6, kind: 'barricade' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 'barricade') return props;
|
||||||
|
|
||||||
|
// Turret: a tower overlooking the checkpoint. Inert for now — it is a landmark
|
||||||
|
// that says "this road has been noticed", and something to collide with.
|
||||||
|
const tower = pointOnSegment(segment, barricadeT + 0.06, (rng() < 0.5 ? -1 : 1) * (half + 2.5));
|
||||||
|
props.push({ ...tower, width: 3, height: 6, depth: 3, kind: 'tower' });
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
178
src/sim/roads.ts
Normal file
178
src/sim/roads.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* The road network. Pure data — no three.js, no Rapier.
|
||||||
|
*
|
||||||
|
* A jittered grid with some edges removed. The grid matters: the central choice
|
||||||
|
* of the game is "reuse this road or take another one", which only exists if
|
||||||
|
* there are genuinely alternative routes between the same two places. Removed
|
||||||
|
* edges give the network character; the connectivity check stops removal from
|
||||||
|
* stranding a corner of the map.
|
||||||
|
*/
|
||||||
|
import { makeRng, randRange, type Rng } from '../core/rng';
|
||||||
|
|
||||||
|
export interface RoadNode {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadSegment {
|
||||||
|
id: number;
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
ax: number;
|
||||||
|
az: number;
|
||||||
|
bx: number;
|
||||||
|
bz: number;
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadNetwork {
|
||||||
|
nodes: RoadNode[];
|
||||||
|
segments: RoadSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRID = 7;
|
||||||
|
const ROAD_WIDTH = 9;
|
||||||
|
/** Fraction of grid spacing a node may wander from its lattice point. */
|
||||||
|
const JITTER = 0.3;
|
||||||
|
/** Share of edges to try to remove, budget permitting. */
|
||||||
|
const THINNING = 0.28;
|
||||||
|
|
||||||
|
export function generateRoads(seed: number, extent: number): RoadNetwork {
|
||||||
|
const rng: Rng = makeRng(seed ^ 0x9e3779b9);
|
||||||
|
const spacing = (extent * 2) / (GRID - 1);
|
||||||
|
|
||||||
|
const nodes: RoadNode[] = [];
|
||||||
|
for (let row = 0; row < GRID; row++) {
|
||||||
|
for (let col = 0; col < GRID; col++) {
|
||||||
|
nodes.push({
|
||||||
|
id: row * GRID + col,
|
||||||
|
x: -extent + col * spacing + randRange(rng, -1, 1) * spacing * JITTER,
|
||||||
|
z: -extent + row * spacing + randRange(rng, -1, 1) * spacing * JITTER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full lattice first, then thin it out.
|
||||||
|
const edges: Array<[number, number]> = [];
|
||||||
|
for (let row = 0; row < GRID; row++) {
|
||||||
|
for (let col = 0; col < GRID; col++) {
|
||||||
|
const i = row * GRID + col;
|
||||||
|
if (col + 1 < GRID) edges.push([i, i + 1]);
|
||||||
|
if (row + 1 < GRID) edges.push([i, i + GRID]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = shuffle(edges.map((_, i) => i), rng);
|
||||||
|
const removed = new Set<number>();
|
||||||
|
const budget = Math.floor(edges.length * THINNING);
|
||||||
|
for (const idx of order) {
|
||||||
|
if (removed.size >= budget) break;
|
||||||
|
removed.add(idx);
|
||||||
|
// A road that cuts the map in two is worse than a boring one.
|
||||||
|
if (!isConnected(nodes.length, edges, removed)) removed.delete(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: RoadSegment[] = [];
|
||||||
|
edges.forEach((edge, idx) => {
|
||||||
|
if (removed.has(idx)) return;
|
||||||
|
const a = nodes[edge[0]]!;
|
||||||
|
const b = nodes[edge[1]]!;
|
||||||
|
segments.push({
|
||||||
|
id: segments.length,
|
||||||
|
a: a.id,
|
||||||
|
b: b.id,
|
||||||
|
ax: a.x,
|
||||||
|
az: a.z,
|
||||||
|
bx: b.x,
|
||||||
|
bz: b.z,
|
||||||
|
length: Math.hypot(b.x - a.x, b.z - a.z),
|
||||||
|
width: ROAD_WIDTH,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, segments };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[], rng: Rng): T[] {
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[items[i], items[j]] = [items[j]!, items[i]!];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConnected(
|
||||||
|
nodeCount: number,
|
||||||
|
edges: Array<[number, number]>,
|
||||||
|
removed: ReadonlySet<number>,
|
||||||
|
): boolean {
|
||||||
|
const adjacency: number[][] = Array.from({ length: nodeCount }, () => []);
|
||||||
|
edges.forEach(([a, b], idx) => {
|
||||||
|
if (removed.has(idx)) return;
|
||||||
|
adjacency[a]!.push(b);
|
||||||
|
adjacency[b]!.push(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Uint8Array(nodeCount);
|
||||||
|
const stack = [0];
|
||||||
|
seen[0] = 1;
|
||||||
|
let visited = 1;
|
||||||
|
while (stack.length) {
|
||||||
|
for (const next of adjacency[stack.pop()!]!) {
|
||||||
|
if (seen[next]) continue;
|
||||||
|
seen[next] = 1;
|
||||||
|
visited++;
|
||||||
|
stack.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited === nodeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Squared distance from a point to a segment, plus how far along it fell (0..1). */
|
||||||
|
export function projectOntoSegment(
|
||||||
|
s: RoadSegment,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
): { distance: number; t: number } {
|
||||||
|
const dx = s.bx - s.ax;
|
||||||
|
const dz = s.bz - s.az;
|
||||||
|
const lenSq = dx * dx + dz * dz;
|
||||||
|
const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - s.ax) * dx + (z - s.az) * dz) / lenSq));
|
||||||
|
return { distance: Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)), t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest segment the point is actually *on*, or null if off-road. */
|
||||||
|
export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment | null {
|
||||||
|
let best: RoadSegment | null = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
for (const s of roads.segments) {
|
||||||
|
const { distance } = projectOntoSegment(s, x, z);
|
||||||
|
if (distance < s.width / 2 && distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
best = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance to the nearest road surface, used to keep scenery off the tarmac. */
|
||||||
|
export function distanceToRoad(roads: RoadNetwork, x: number, z: number): number {
|
||||||
|
let best = Infinity;
|
||||||
|
for (const s of roads.segments) {
|
||||||
|
best = Math.min(best, projectOntoSegment(s, x, z).distance - s.width / 2);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point a fraction `t` along the segment, offset `side` metres to its left. */
|
||||||
|
export function pointOnSegment(s: RoadSegment, t: number, side: number) {
|
||||||
|
const dx = (s.bx - s.ax) / s.length;
|
||||||
|
const dz = (s.bz - s.az) / s.length;
|
||||||
|
return {
|
||||||
|
x: s.ax + (s.bx - s.ax) * t + dz * side,
|
||||||
|
z: s.az + (s.bz - s.az) * t - dx * side,
|
||||||
|
yaw: Math.atan2(s.bx - s.ax, s.bz - s.az),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -12,8 +12,9 @@ describe('world generation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the spawn point clear', () => {
|
it('keeps the spawn point clear', () => {
|
||||||
for (const o of generateWorld(7).obstacles) {
|
const world = generateWorld(7);
|
||||||
expect(Math.hypot(o.x, o.z)).toBeGreaterThanOrEqual(12);
|
for (const o of world.obstacles) {
|
||||||
|
expect(Math.hypot(o.x - world.spawn.x, o.z - world.spawn.z)).toBeGreaterThanOrEqual(12);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
* of engine imports is what makes those systems unit-testable and fast-forwardable.
|
* of engine imports is what makes those systems unit-testable and fast-forwardable.
|
||||||
*/
|
*/
|
||||||
import { makeRng, randRange, type Rng } from '../core/rng';
|
import { makeRng, randRange, type Rng } from '../core/rng';
|
||||||
|
import { distanceToRoad, generateRoads, type RoadNetwork } from './roads';
|
||||||
|
|
||||||
export interface Obstacle {
|
export interface Obstacle {
|
||||||
x: number;
|
x: number;
|
||||||
@ -23,44 +24,62 @@ export interface WorldModel {
|
|||||||
seed: number;
|
seed: number;
|
||||||
/** Half-width of the drivable plate, in metres. */
|
/** Half-width of the drivable plate, in metres. */
|
||||||
extent: number;
|
extent: number;
|
||||||
|
roads: RoadNetwork;
|
||||||
obstacles: Obstacle[];
|
obstacles: Obstacle[];
|
||||||
|
/** Where the car starts: a junction, so the network is reachable immediately. */
|
||||||
|
spawn: { x: number; z: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPAWN_CLEARANCE = 12;
|
const SPAWN_CLEARANCE = 12;
|
||||||
|
/** Scenery this close to tarmac would read as a roadblock. Heat places those. */
|
||||||
|
const ROAD_CLEARANCE = 2.5;
|
||||||
|
|
||||||
export function generateWorld(seed: number, count = 140, extent = 220): WorldModel {
|
export function generateWorld(seed: number, count = 200, extent = 220): WorldModel {
|
||||||
const rng: Rng = makeRng(seed);
|
const rng: Rng = makeRng(seed);
|
||||||
|
const roads = generateRoads(seed, extent);
|
||||||
const obstacles: Obstacle[] = [];
|
const obstacles: Obstacle[] = [];
|
||||||
|
|
||||||
while (obstacles.length < count) {
|
const spawnNode = roads.nodes.reduce((best, n) =>
|
||||||
|
Math.hypot(n.x, n.z) < Math.hypot(best.x, best.z) ? n : best,
|
||||||
|
);
|
||||||
|
const spawn = { x: spawnNode.x, z: spawnNode.z };
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (obstacles.length < count && attempts < count * 40) {
|
||||||
|
attempts++;
|
||||||
const x = randRange(rng, -extent, extent);
|
const x = randRange(rng, -extent, extent);
|
||||||
const z = 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.
|
// Leave the player's spawn point clear so the car never starts inside a wall.
|
||||||
if (Math.hypot(x, z) < SPAWN_CLEARANCE) continue;
|
if (Math.hypot(x - spawn.x, z - spawn.z) < SPAWN_CLEARANCE) continue;
|
||||||
|
|
||||||
const crate = rng() < 0.45;
|
const crate = rng() < 0.45;
|
||||||
obstacles.push(
|
const obstacle: Obstacle = crate
|
||||||
crate
|
? {
|
||||||
? {
|
x,
|
||||||
x,
|
z,
|
||||||
z,
|
width: randRange(rng, 0.8, 1.4),
|
||||||
width: randRange(rng, 0.8, 1.4),
|
height: randRange(rng, 0.8, 1.4),
|
||||||
height: randRange(rng, 0.8, 1.4),
|
depth: randRange(rng, 0.8, 1.4),
|
||||||
depth: randRange(rng, 0.8, 1.4),
|
yaw: rng() * Math.PI,
|
||||||
yaw: rng() * Math.PI,
|
kind: 'crate',
|
||||||
kind: 'crate',
|
}
|
||||||
}
|
: {
|
||||||
: {
|
x,
|
||||||
x,
|
z,
|
||||||
z,
|
width: randRange(rng, 1.5, 5),
|
||||||
width: randRange(rng, 1.5, 5),
|
height: randRange(rng, 1.2, 3.5),
|
||||||
height: randRange(rng, 1.2, 3.5),
|
depth: randRange(rng, 1.5, 5),
|
||||||
depth: randRange(rng, 1.5, 5),
|
yaw: rng() * Math.PI,
|
||||||
yaw: rng() * Math.PI,
|
kind: 'block',
|
||||||
kind: 'block',
|
};
|
||||||
},
|
|
||||||
);
|
// Roads have to stay drivable — obstruction is heat's job, not scenery's.
|
||||||
|
// Measured from the box's corner, since it may be rotated any which way.
|
||||||
|
const reach = Math.hypot(obstacle.width, obstacle.depth) / 2;
|
||||||
|
if (distanceToRoad(roads, x, z) < ROAD_CLEARANCE + reach) continue;
|
||||||
|
|
||||||
|
obstacles.push(obstacle);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { seed, extent, obstacles };
|
return { seed, extent, roads, obstacles, spawn };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { CarCondition } from '../sim/car';
|
import type { CarCondition } from '../sim/car';
|
||||||
|
import type { HeatLevel } from '../sim/heat';
|
||||||
|
|
||||||
const BAR_WIDTH = 12;
|
const BAR_WIDTH = 12;
|
||||||
|
|
||||||
@ -7,12 +8,19 @@ function bar(value: number): string {
|
|||||||
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled);
|
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HeatReadout {
|
||||||
|
segmentId: number | null;
|
||||||
|
value: number;
|
||||||
|
level: HeatLevel | null;
|
||||||
|
hottest: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function createHud(seed: number) {
|
export function createHud(seed: number) {
|
||||||
const el = document.getElementById('hud')!;
|
const el = document.getElementById('hud')!;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(speedMs: number, condition: CarCondition, now: number) {
|
update(speedMs: number, condition: CarCondition, now: number, heat: HeatReadout) {
|
||||||
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
|
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
|
||||||
if (now - last < 0.1) return;
|
if (now - last < 0.1) return;
|
||||||
last = now;
|
last = now;
|
||||||
@ -23,6 +31,12 @@ export function createHud(seed: number) {
|
|||||||
`tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`,
|
`tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`,
|
||||||
`chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`,
|
`chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`,
|
||||||
'',
|
'',
|
||||||
|
// Debug only. The real game signals heat through the road itself —
|
||||||
|
// see the design brief: no numeric meter ships.
|
||||||
|
`[debug] road ${heat.segmentId === null ? 'off-road' : `#${heat.segmentId}`}`,
|
||||||
|
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
|
||||||
|
`[debug] hottest ${bar(heat.hottest)}`,
|
||||||
|
'',
|
||||||
`seed ${seed}`,
|
`seed ${seed}`,
|
||||||
'WASD drive · space handbrake · R respawn',
|
'WASD drive · space handbrake · R respawn',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user