/** * Car condition and what it does to handling. Pure — no engine imports. * * Design pillar this serves: "Decline, not reset." Repairs exist, but every * subsystem carries a *ceiling* that only ever falls. Patching a car brings it * back up to what it is still capable of, which is never quite what it was. */ export interface Subsystems { /** 1 = factory fresh, 0 = ruined. */ engine: number; tires: number; chassis: number; } export interface CarCondition { /** Current state of each subsystem. */ level: Subsystems; /** The best each subsystem can be restored to. Falls permanently with damage. */ ceiling: Subsystems; } export interface Handling { /** Newtons of drive force available per driven wheel at full throttle. */ engineForce: number; /** Braking impulse per wheel under the brake pedal. */ brakeForce: number; /** Braking applied with no throttle: engine braking and rolling resistance. */ coastBrake: 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 SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const; export const freshCondition = (): CarCondition => ({ level: { engine: 1, tires: 1, chassis: 1 }, ceiling: { 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 { const { engine, tires, chassis } = c.level; return { // A tired engine simply cannot push as hard. engineForce: lerp(900, 2600, engine), // Worn pads take longer to haul the car down. These are Rapier brake // impulses, which have to be large next to a 1100kg chassis — the first // values here were so weak the car would not come to a standstill at all. // Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and // the brake pedal stops feeling like a brake and starts feeling like a wall. brakeForce: lerp(12, 36, tires), // Lifting off has to actually slow you down. Without this the car coasts // almost forever and every stop needs a deliberate stab at the brake. coastBrake: lerp(4, 9, engine), maxSteer: lerp(0.4, 0.62, chassis), // Bald tyres are the most legible failure: the back end starts to leave. frictionSlip: lerp(1.6, 5, tires), sideFrictionStiffness: lerp(0.5, 1, tires), // A bent chassis pulls to one side. Sign is stable for a given car. steeringPull: (1 - chassis) * 0.06, }; } /** * What the surface under the wheels does to the car. * * Roads have to be worth using. Without this the tarmac is decorative — you can * cut any corner across country at full speed, which makes both the route * corridor and the whole road hierarchy pointless. */ export interface Surface { /** Share of engine force that reaches the road. */ drive: number; /** Extra braking from rough ground, as a share of the coast brake. */ drag: number; /** Grip multiplier. */ grip: number; } export const TARMAC: Surface = { drive: 1, drag: 0, grip: 1 }; /** * Rubble, verges and open country: slower, draggier, looser. * * Tuned so off-road tops out around a third of road speed. It has to *cost* * something without being impossible — going around a checkpoint cross-country * is a legitimate move, and the first numbers here made it a 12 km/h crawl, * which is not a choice, it is a wall. */ export const ROUGH: Surface = { drive: 0.55, drag: 0.45, grip: 0.8 }; export const surfaceFor = (onRoute: boolean): Surface => (onRoute ? TARMAC : ROUGH); 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; } /** * Share of any damage that can never be repaired out. This is the pillar in a * single number: at 0 the car is a rental, at 1 repairs do nothing at all. */ const PERMANENT_SHARE = 0.3; /** * 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; const damage: Subsystems = { engine: w.throttle * w.dt * 6e-4 + impact * 0.01, tires: w.distance * 8e-5 + impact * 0.02, chassis: impact * 0.05, }; const level = {} as Subsystems; const ceiling = {} as Subsystems; for (const part of SUBSYSTEMS) { ceiling[part] = clamp01(c.ceiling[part] - damage[part] * PERMANENT_SHARE); level[part] = Math.min(clamp01(c.level[part] - damage[part]), ceiling[part]); } return { level, ceiling }; } /** * Spends parts on the worst subsystem first, never above its ceiling. * Returns the new condition and whatever could not be used. */ export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } { const level = { ...c.level }; let remaining = parts; // Worst-first: a car with one ruined subsystem drives worse than one that is // evenly tired, so that is where a scarce part belongs. for (let pass = 0; pass < SUBSYSTEMS.length && remaining > 1e-9; pass++) { const worst = [...SUBSYSTEMS] .filter((p) => level[p] < c.ceiling[p] - 1e-9) .sort((a, b) => level[a] - level[b])[0]; if (!worst) break; const room = c.ceiling[worst] - level[worst]; const spend = Math.min(room, remaining); level[worst] += spend; remaining -= spend; } return { condition: { level, ceiling: c.ceiling }, unused: remaining }; } /** Rough single number for the HUD and for deciding when a car is finished. */ export const overallCondition = (c: CarCondition): number => (c.level.engine + c.level.tires + c.level.chassis) / 3;