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:
dejvino
2026-08-07 07:47:19 +02:00
co-authored by Claude Opus 5
parent a66032674d
commit 506d07be5d
14 changed files with 830 additions and 49 deletions
+6 -2
View File
@@ -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. */
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 handling = deriveHandling(freshCondition());
const cmd = { ...IDLE, ...input };
@@ -21,8 +23,10 @@ async function run(input: Partial<DriverInput>, seconds: number) {
physics.step(STEP);
maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y));
}
const now = physics.chassis.translation();
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(),
maxYawRate,
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),
+31 -2
View File
@@ -13,6 +13,18 @@ export interface PhysicsWorld {
drainImpactForce(): number;
step(dt: number): 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. */
@@ -55,7 +67,7 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
// --- Car chassis ---
const chassis = world.createRigidBody(
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)
.setAngularDamping(0.4)
// 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;
},
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() {
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.setLinvel({ x: 0, y: 0, z: 0 }, true);
chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);