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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user