/** * 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; /** * Newtons of contact force that count as one unit of "impact". * * Measured, not guessed. Rapier reports contact-force magnitudes far larger * than the first pass here assumed: a head-on into a building at 85 km/h comes * back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25 * units of impact, which through the coefficients below wrote off the chassis — * and 37% of its ceiling — in one hit, on a car whose whole economy pays about * 0.3 parts a mission. One crash ended the campaign. * * At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by * the coefficients below, and they are deliberately brutal: a proper head-on * takes about four fifths of the chassis. Hitting a building at ninety should * very nearly be the end of the car, and the first calibration here — which * left it at 65% and driveable — made a serious crash into an inconvenience. */ const IMPACT_REFERENCE = 1.6e7; /** * Most damage any one subsystem can take in a single step. * * A crash is not one contact. It is a pile of them across a handful of steps, * plus whatever the car scrapes along on the way to a standstill, and the sum * has no natural bound. Without a cap the tail of a bad landing is worth more * than the landing. This is what stops a single frame from writing off a part. * * Set above even a bad head-on so it stays a backstop rather than the number * that actually decides what a collision costs. It exists for the pathological * case — a car wedged between two colliders reporting forces in the billions — * not for the crash the player just had. */ const MAX_DAMAGE_PER_STEP = 0.85; /** * Returns a new condition. * * Baseline wear is deliberately quicker than real life so decline is legible * within a session, but it has to stay inside what the parts economy can pay * for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean * kilometres — no crashes at all — took them from new to ruined, while a * two-kilometre round trip paid about 0.3 parts against 0.24 of condition * spent. Driving perfectly barely broke even and one knock put you permanently * behind, which is a decline with no way to arrest it rather than a decision * about how hard to push the car. * * At a quarter of that a typical mission spends about 0.06 condition against * ~0.3 parts earned, so roughly two clean runs pay for one bad crash. That is * the ratio the whole risk/reward loop rests on: enough slack to gamble with, * not enough to ignore. */ export function applyWear(c: CarCondition, w: WearInput): CarCondition { const impact = w.impactForce / IMPACT_REFERENCE; const damage: Subsystems = { // Ordered by what a collision actually ruins: the shell takes the worst of // it, the tyres and suspension a good share, the engine least of all. engine: w.throttle * w.dt * 1.5e-4 + impact * 1.6, tires: w.distance * 2e-5 + impact * 2.5, chassis: impact * 5.0, }; const level = {} as Subsystems; const ceiling = {} as Subsystems; for (const part of SUBSYSTEMS) { const taken = Math.min(damage[part], MAX_DAMAGE_PER_STEP); ceiling[part] = clamp01(c.ceiling[part] - taken * PERMANENT_SHARE); level[part] = Math.min(clamp01(c.level[part] - taken), 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;