diff --git a/src/main.ts b/src/main.ts index 16ad8bb..1ea7962 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,7 @@ import { type Offer, } from './sim/quests'; import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia'; +import { createGarage, current as drivingNow } from './sim/garage'; import { createRadio, pollRadio } from './sim/radio'; import { createOpportunities, stepOpportunities } from './sim/opportunities'; import { createHeatProps } from './heatProps'; @@ -167,6 +168,16 @@ async function boot() { const unitView = createUnitView(view.scene); const unitBodies = createUnitBodies(physics); const pursuit = createPursuit(); + const garage = createGarage(); + + /** Put whatever is in the garage under the player: weight, and paint. */ + const equip = () => { + const spec = drivingNow(garage); + physics.setCarMass(spec.mass); + view.setCarLook(spec); + return spec; + }; + let car = equip(); // Bullets stop at buildings, so combat needs a fast "is this inside a wall" // lookup. A grid built once at boot beats scanning a thousand obstacles. @@ -493,7 +504,14 @@ async function boot() { // Tarmac or open ground — decided last step, since the road lookup needs // a position and the car has not moved yet this one. - drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null)); + drive( + physics, + driveState, + cmd, + deriveHandling(condition, car), + dt, + surfaceFor(currentSegment !== null), + ); physics.step(dt); captureChassis(); @@ -506,12 +524,12 @@ async function boot() { const lastImpact = physics.drainImpactForce(); audio.impact(lastImpact); - condition = applyWear(condition, { - dt, - distance, - throttle: Math.abs(cmd.throttle), - impactForce: lastImpact, - }); + condition = applyWear( + condition, + { dt, distance, throttle: Math.abs(cmd.throttle), impactForce: lastImpact }, + // Armour is this number: what the car actually feels of a knock. + car.fragility, + ); // Heat: the road remembers being used — including being driven alongside. // Behind your own lines it remembers nothing: nobody there is watching. @@ -890,6 +908,7 @@ async function boot() { }, condition, chatterRng, + car.fragility, ); condition = shooting.condition; const listener = { x: at.x, z: at.z, heading: headingOf() }; diff --git a/src/physics/physics.ts b/src/physics/physics.ts index ed5665c..a9ddbed 100644 --- a/src/physics/physics.ts +++ b/src/physics/physics.ts @@ -20,6 +20,17 @@ export interface PhysicsWorld { telemetry(): Telemetry; /** A body the sim moves by hand, which still collides with the player. */ addKinematicBox(box: KinematicBox): RAPIER.RigidBody; + /** + * Swap what the player is driving. + * + * Only the mass properties change, not the collider: a tractor and an + * armoured car occupy visibly different boxes on screen, but giving them + * different colliders means rebuilding the vehicle controller mid-campaign, + * and every barricade gap and building alley in the world is sized against + * one car. Mass is what actually changes how the thing drives — what it + * shrugs off, how long it takes to stop — so that is what moves. + */ + setCarMass(mass: number): void; } export interface KinematicBox { @@ -157,6 +168,22 @@ export async function createPhysics(model: WorldModel): Promise { return v; }, + setCarMass(mass: number) { + chassis.setAdditionalMassProperties( + mass, + { x: 0, y: -0.35, z: 0 }, + // Scaled from the reference car's tensor, so a heavier vehicle also + // resists being spun round rather than merely being harder to shift. + { + x: 1369 * (mass / CAR.mass), + y: 1621 * (mass / CAR.mass), + z: 342 * (mass / CAR.mass), + }, + { x: 0, y: 0, z: 0, w: 1 }, + true, + ); + }, + addKinematicBox(box) { // Kinematic rather than dynamic: the unit sim owns where these are, but // they still shove the player's car when they meet it. diff --git a/src/render/scene.ts b/src/render/scene.ts index bc09f54..d3fdfef 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -12,6 +12,8 @@ export interface SceneView { wheels: THREE.Object3D[]; /** Only the dynamic crates need per-frame syncing; blocks are instanced. */ crates: Array<{ index: number; mesh: THREE.Mesh }>; + /** Dress the car as whatever is being driven: shape and paint. */ + setCarLook(spec: { colour: number; size: { width: number; height: number; length: number } }): void; /** Keeps the shadow frustum centred on the car. */ followSun(): void; /** Eases the world's colour and visibility toward the current territory. */ @@ -198,9 +200,14 @@ export function createScene(model: WorldModel): SceneView { // --- Car --- const car = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ + color: 0x8c3b34, + roughness: 0.55, + metalness: 0.15, + }); const body = new THREE.Mesh( new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2), - new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }), + bodyMat, ); body.castShadow = true; car.add(body); @@ -251,6 +258,16 @@ export function createScene(model: WorldModel): SceneView { car, wheels, crates, + setCarLook(spec) { + // The mesh changes shape; the collider deliberately does not — see + // `setCarMass`. A tractor should *look* like a tractor from the mirror + // without every barricade gap in the world having to be re-sized. + bodyMat.color.setHex(spec.colour); + body.scale.set(spec.size.width, spec.size.height, spec.size.length); + cabin.position.y = CAR.halfHeight * spec.size.height + 0.25; + cabin.scale.set(spec.size.width, spec.size.height, spec.size.length); + }, + followSun() { // Keep the shadow frustum centred on the car rather than the origin. sun.position.set(car.position.x + 45, 70, car.position.z + 25); diff --git a/src/sim/car.ts b/src/sim/car.ts index a7eb4aa..1219463 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -12,6 +12,17 @@ * what keeps it meaningful is the exchange rate rather than the impossibility. */ +import { carById, type CarSpec } from './garage'; + +/** + * The car every balance figure in this file is quoted against. + * + * The saloon is the ordinary one — fragility 1, presence near 1 — so "a crash + * costs 1.4 points of condition" means something concrete rather than + * depending on what happens to be in the garage. + */ +const REFERENCE_CAR: CarSpec = carById('saloon'); + export interface Subsystems { /** 1 = factory fresh, 0 = ruined. */ engine: number; @@ -70,23 +81,42 @@ export const freshCondition = (): CarCondition => ({ 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 { +/** + * Share of a car's figures still available when that subsystem is ruined. + * + * Condition scales what the car can do rather than replacing it, so a wrecked + * tractor is still a tractor and a wrecked armoured car is still heavy. Not + * zero: a car that stops entirely at 0% is a fail state wearing a dial, and the + * point of the garage is that being in a bad car is a situation rather than an + * ending. + */ +const RUINED = { drive: 0.35, brake: 0.33, steer: 0.65, grip: 0.35 }; + +/** + * What the car can do, from what it is and what state it is in. + * + * Both halves matter and they are different kinds of thing: the spec is the + * car you chose at the garage, the condition is what this sortie has done to + * it. Everything here is the spec's own figure scaled by wear, so the + * catalogue's numbers mean what they say. + */ +export function deriveHandling(c: CarCondition, spec: CarSpec = REFERENCE_CAR): Handling { const { engine, tires, chassis } = c.level; return { // A tired engine simply cannot push as hard. - engineForce: lerp(900, 2600, engine), + engineForce: lerp(spec.engineForce * RUINED.drive, spec.engineForce, 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), + brakeForce: lerp(spec.brakeForce * RUINED.brake, spec.brakeForce, 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), + maxSteer: lerp(spec.maxSteer * RUINED.steer, spec.maxSteer, chassis), // Bald tyres are the most legible failure: the back end starts to leave. - frictionSlip: lerp(1.6, 5, tires), + frictionSlip: lerp(spec.grip * RUINED.grip, spec.grip, tires), sideFrictionStiffness: lerp(0.5, 1, tires), /* * A bent chassis pulls to one side. Sign is stable for a given car. @@ -207,8 +237,13 @@ const MAX_DAMAGE_PER_STEP = 0.85; * 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; +export function applyWear( + c: CarCondition, + w: WearInput, + /** How much of a knock this car actually feels. Armour is a number below 1. */ + fragility = 1, +): CarCondition { + const impact = (w.impactForce / IMPACT_REFERENCE) * fragility; 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. diff --git a/src/sim/combat.ts b/src/sim/combat.ts index dca155a..1ff119c 100644 --- a/src/sim/combat.ts +++ b/src/sim/combat.ts @@ -116,6 +116,12 @@ export function stepCombat( step: CombatStep, condition: CarCondition, rng: Rng, + /** + * How much of a round the car actually feels. An armoured car is what this + * number is for: being shot at is precisely the situation it is bought for, + * so armour has to count here as much as it does against a wall. + */ + fragility = 1, ): CombatResult { const { dt } = step; let playerHit = false; @@ -185,16 +191,19 @@ export function stepCombat( // Routed through the same wear model as everything else, so a bullet // costs you ceiling too — it is permanent in the same way a crash is. updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 }); + const engineHit = HIT_ENGINE * fragility; + const tyreHit = HIT_TIRES * fragility; + const chassisHit = HIT_CHASSIS * fragility; updated = { level: { - engine: Math.max(0, updated.level.engine - HIT_ENGINE), - tires: Math.max(0, updated.level.tires - HIT_TIRES), - chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS), + engine: Math.max(0, updated.level.engine - engineHit), + tires: Math.max(0, updated.level.tires - tyreHit), + chassis: Math.max(0, updated.level.chassis - chassisHit), }, ceiling: { - engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3), - tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3), - chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3), + engine: Math.max(0, updated.ceiling.engine - engineHit * 0.3), + tires: Math.max(0, updated.ceiling.tires - tyreHit * 0.3), + chassis: Math.max(0, updated.ceiling.chassis - chassisHit * 0.3), }, }; } diff --git a/src/sim/garage.test.ts b/src/sim/garage.test.ts index cdcd741..4bf8e39 100644 --- a/src/sim/garage.test.ts +++ b/src/sim/garage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CARS, buy, carById, createGarage, current, nextUnlock, owns, take } from './garage'; +import { applyWear, deriveHandling, freshCondition } from './car'; describe('the catalogue', () => { it('gives you something to drive for nothing', () => { @@ -90,3 +91,57 @@ describe('buying and taking cars', () => { expect(carById('no-such-car').id).toBe(CARS[0]!.id); }); }); + +describe('what the car you picked actually changes', () => { + const fresh = freshCondition(); + const tractor = carById('tractor'); + const armoured = carById('armoured'); + + it('drives like the car in the catalogue, not like one car with a skin', () => { + const slow = deriveHandling(fresh, tractor); + const quick = deriveHandling(fresh, armoured); + expect(slow.engineForce).toBeCloseTo(tractor.engineForce, 6); + expect(quick.engineForce).toBeCloseTo(armoured.engineForce, 6); + // A tractor turns tighter than an armoured car and grips less. + expect(slow.maxSteer).toBeGreaterThan(quick.maxSteer); + expect(slow.frictionSlip).toBeLessThan(quick.frictionSlip); + }); + + it('still leaves a ruined car driveable, whichever car it is', () => { + // The garage exists so that being in a bad way is a situation rather than + // an ending. A car that stops entirely at 0% is a fail state wearing a dial. + const ruined = { + level: { engine: 0, tires: 0, chassis: 0 }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, + }; + for (const spec of CARS) { + const h = deriveHandling(ruined, spec); + expect(h.engineForce).toBeGreaterThan(0); + expect(h.maxSteer).toBeGreaterThan(0); + expect(h.brakeForce).toBeGreaterThan(0); + } + }); + + it('makes armour worth the attention it costs', () => { + // The same crash, in the cheapest car and the dearest. + const crash = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 2.47e6 }; + const inTractor = applyWear(fresh, crash, tractor.fragility); + const inArmour = applyWear(fresh, crash, armoured.fragility); + // Tin: one head-on and it is all but finished. The per-step cap is what + // stops it reading as exactly zero. + expect(inTractor.level.chassis).toBeLessThan(0.25); + // Steel: the same crash is a bad afternoon. + expect(inArmour.level.chassis).toBeGreaterThan(0.5); + // And the permanent scar scales with it too. + expect(inArmour.ceiling.chassis).toBeGreaterThan(inTractor.ceiling.chassis); + }); + + it('leaves the tractor genuinely fragile, so the trade has two sides', () => { + // If the cheap car merely went slower it would be a punishment rather than + // a choice. It has to actually be made of tin. + const knock = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 6e5 }; + const tin = applyWear(fresh, knock, tractor.fragility); + const steel = applyWear(fresh, knock, armoured.fragility); + expect(1 - tin.level.chassis).toBeGreaterThan((1 - steel.level.chassis) * 3); + }); +});