drive-between-the-lines/src/sim/car.ts
dejvino a66032674d 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>
2026-08-07 07:39:05 +02:00

73 lines
2.4 KiB
TypeScript

/**
* 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),
};
}