diff --git a/src/main.ts b/src/main.ts index 5637e93..26fff6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,6 +62,18 @@ import { createBoard } from './ui/board'; import { createAudio } from './audio/audio'; import { CAR, WHEELS } from './carSpec'; +/** What the wheel reads while nobody is holding it. */ +const DEAD_HANDS = { + throttle: 0, + steer: 0, + handbrake: true, + respawn: false, + select: null, + toggleMute: false, + abandon: false, + overhaul: false, +} as const; + /** Debug handle and frame capture, for inspecting a build that cannot be seen. */ export const DEBUG = new URLSearchParams(location.search).has('debug'); @@ -77,6 +89,18 @@ const RESET_HOLD_SECONDS = 5; * becomes a reroll button: park, dislike the route, drop it, take another. */ const ABANDON_LAND_LOSS = 12; +/** + * Seconds spent watching the wreck before you come round somewhere else. + * + * Long enough to register what happened and where it happened, short enough + * that it does not become the thing you dread about dying. + */ +const KIA_HOLD = 5; +/** + * Radians per second the view turns while the camera climbs away from a wreck. + * Slow: this is a drift across the scene, not an orbit around the car. + */ +const WAKE_ORBIT_RATE = 0.22; function resolveSeed(): number { const raw = new URLSearchParams(location.search).get('seed'); @@ -214,6 +238,10 @@ async function boot() { let provocation = 0; /** Times the car has been written off underneath the player. */ let kia = 0; + /** Seconds left of watching the wreck, or 0 when alive. */ + let dying = 0; + /** Where the camera has got to in its drift around it. */ + let deathAngle = 0; const say = (text: string, seconds = 4) => { notice = text; @@ -324,7 +352,32 @@ async function boot() { captureChassis(); /** - * The car came apart around you. + * The moment the car came apart around you. + * + * The run does not end here — it ends `KIA_HOLD` seconds later. What happens + * in between is nothing: you have no control, the camera pulls off the bumper + * and drifts round the wreck, and the world carries on around it. Cutting + * straight from the crash to a base three hundred metres away read as a bug + * rather than as a death, and the one moment in the campaign worth actually + * looking at was the one moment you were not allowed to see. + */ + const beginDeath = () => { + dying = KIA_HOLD; + deathAngle = 0; + // Nobody is chasing a car that is no longer going anywhere, and being shot + // at while you watch your own wreck is not a thing anyone needs to see. + for (const unit of units.units) unit.hunting = false; + pursuit.hunters.clear(); + pursuit.suspicion = 0; + pursuit.alert = 'clear'; + pursuit.lastSeen = null; + pursuit.unseenFor = 0; + audio.impact(26000); + say('That is the car finished.', KIA_HOLD); + }; + + /** + * Coming round somewhere else. * * You wake up at whichever base was nearest, in a worse car than the one you * wrote off, with the job you were carrying written off along with it and the @@ -332,7 +385,7 @@ async function boot() { * number — the front moving against you is something you find out about by * driving back out there. */ - const killedInAction = () => { + const finishDeath = () => { kia++; const at = physics.chassis.translation(); const home = bases.reduce((best, b) => @@ -345,14 +398,6 @@ async function boot() { applyMissionImpact(front, -KIA_LAND_LOSS); condition = replacementCar(condition); - // Whoever was chasing is chasing a car that no longer exists. - for (const unit of units.units) unit.hunting = false; - pursuit.hunters.clear(); - pursuit.suspicion = 0; - pursuit.alert = 'clear'; - pursuit.lastSeen = null; - pursuit.unseenFor = 0; - physics.chassis.setTranslation({ x: home.x, y: CAR.spawn.y, z: home.z }, true); physics.chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true); physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true); @@ -368,7 +413,6 @@ async function boot() { : `You woke up at ${home.name}. Somebody else is driving what is left of the car.`, 9, ); - audio.impact(26000); persistence.checkpoint(elapsed, snapshot()); }; @@ -377,7 +421,22 @@ async function boot() { const handlers: LoopHandlers = { fixedUpdate(dt) { elapsed += dt; - const cmd = input.read(); + const raw = input.read(); + + // While the wreck is being looked at, the world carries on and the player + // does not. Everything below still runs — traffic, the war, the front — + // because a death cam over a frozen world is a screenshot, not a moment. + // What stops is the driver. + const dead = dying > 0; + if (dead) { + dying -= dt; + deathAngle += WAKE_ORBIT_RATE * dt; + if (dying <= 0) { + dying = 0; + finishDeath(); + } + } + const cmd = dead ? DEAD_HANDS : raw; // Tap R to get unstuck; hold it to wipe the campaign and start over. // A reset has to be hard to do by accident — it throws away every mile @@ -470,7 +529,8 @@ async function boot() { if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed); reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf); - const base = baseAt(bases, at.x, at.z); + // A wreck is not parked at a base, whatever the coordinates say. + const base = dead ? null : baseAt(bases, at.x, at.z); const stopped = Math.abs(speed) < 2.5; // --- Overhaul: buying back what the car is capable of --- @@ -572,7 +632,9 @@ async function boot() { } // --- Active mission --- - const quest = quests.active; + // Not while you are unconscious: a wreck sitting on the target must not + // quietly finish the job, and stepQuest only asks whether you are stopped. + const quest = dead ? null : quests.active; if (quest) { const target = nodeOf(quest.targetNode); const event = stepQuest(quests, { @@ -635,11 +697,13 @@ async function boot() { control, carX: at.x, carZ: at.z, - onMission: quests.active !== null, + // Nothing turns up for a man who is not going anywhere, and a wreck + // must not collect one by virtue of having stopped moving. + onMission: dead || quests.active !== null, }, model.roads, chatterRng, - stopped, + stopped && !dead, ); if (found?.kind === 'appeared') say(found.opportunity.hail, 7); else if (found?.kind === 'helped') { @@ -775,7 +839,7 @@ async function boot() { // --- The end of a car --- // Checked here rather than beside `applyWear`, because being shot at // damages the chassis too and either route has to be able to finish you. - if (isWrittenOff(condition)) killedInAction(); + if (!dead && isWrittenOff(condition)) beginDeath(); // --- Radio --- // Front drift is measured, not narrated: the chatter about the enemy @@ -856,7 +920,12 @@ async function boot() { view.followSun(); view.setTone(control, frameDt); markers.update(elapsed); - updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed()); + updateCamera( + view, + frameDt, + physics.vehicle.currentVehicleSpeed(), + dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null, + ); view.renderer.render(view.scene, view.camera); const quest = quests.active; @@ -963,6 +1032,7 @@ async function boot() { condition, front: front.boundaries, opportunity: opportunities.current, + dying: +dying.toFixed(2), alert: pursuit.alert, suspicion: +pursuit.suspicion.toFixed(2), hunters: pursuit.hunters.size, diff --git a/src/render/scene.ts b/src/render/scene.ts index 159efa2..0d321c3 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -286,10 +286,52 @@ const camTarget = new THREE.Vector3(); const camDesired = new THREE.Vector3(); const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5); -/** Smoothed chase camera. Frame-rate independent damping. */ -export function updateCamera(view: SceneView, dt: number, speed: number): void { +/** + * Where the camera starts and ends up while pulling off a wreck. + * + * It leaves from just behind the car and climbs away until the whole scene is + * below it: the street, whatever was hit, and whoever happens to be standing + * around watching. The point is not the car — by then the car is a fact — it is + * *where* this happened, which is the thing you have to drive back out to. + */ +const WAKE_FROM = { radius: 9, height: 3.5 }; +const WAKE_TO = { radius: 26, height: 62 }; + +/** Smoothstep. Eases both ends, so the pull-away neither jerks nor drifts. */ +const ease = (t: number) => t * t * (3 - 2 * t); + +/** + * Smoothed chase camera. Frame-rate independent damping. + * + * @param wake When set, the camera stops chasing and climbs slowly away from + * the car, looking down at where the run ended. Used after the car is written + * off: a hard cut from a crash to a base three hundred metres away reads as a + * bug rather than as a death. `angle` turns the view a little as it goes so + * the shot is not a straight retreat; `progress` runs 0 to 1 over the hold. + */ +export function updateCamera( + view: SceneView, + dt: number, + speed: number, + wake: { angle: number; progress: number } | null = null, +): void { const { camera, car } = view; + if (wake) { + const t = ease(Math.min(1, Math.max(0, wake.progress))); + const radius = WAKE_FROM.radius + (WAKE_TO.radius - WAKE_FROM.radius) * t; + const height = WAKE_FROM.height + (WAKE_TO.height - WAKE_FROM.height) * t; + camDesired + .set(Math.sin(wake.angle) * radius, height, Math.cos(wake.angle) * radius) + .add(car.position); + // Barely damped: the easing above already shapes the move, and heavy + // smoothing on top of it turns the climb into a lag. + camera.position.lerp(camDesired, 1 - Math.exp(-6 * dt)); + camTarget.copy(car.position); + camera.lookAt(camTarget); + return; + } + camDesired.copy(CHASE_OFFSET); // Pull back a little at speed for a sense of pace. camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);