Hold on the wreck before moving the player

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 a campaign
worth actually looking at was the one moment the player was not allowed
to see it.

The run now ends five seconds after the car does. Control goes; the
camera leaves the bumper and climbs away to sixty metres, turning
slowly, until the street, whatever was hit and whoever is standing
around watching are all in frame. The point of the shot 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.

The world keeps running underneath it. A death cam over a frozen world
is a screenshot, not a moment. What stops is the driver: no throttle,
no board, no overhaul, and no quietly finishing the job by virtue of
having stopped moving on top of the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 07:57:11 +02:00
parent d9cdca70d5
commit 563ee66dcd
2 changed files with 132 additions and 20 deletions

View File

@ -62,6 +62,18 @@ import { createBoard } from './ui/board';
import { createAudio } from './audio/audio'; import { createAudio } from './audio/audio';
import { CAR, WHEELS } from './carSpec'; 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. */ /** Debug handle and frame capture, for inspecting a build that cannot be seen. */
export const DEBUG = new URLSearchParams(location.search).has('debug'); 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. * becomes a reroll button: park, dislike the route, drop it, take another.
*/ */
const ABANDON_LAND_LOSS = 12; 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 { function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed'); const raw = new URLSearchParams(location.search).get('seed');
@ -214,6 +238,10 @@ async function boot() {
let provocation = 0; let provocation = 0;
/** Times the car has been written off underneath the player. */ /** Times the car has been written off underneath the player. */
let kia = 0; 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) => { const say = (text: string, seconds = 4) => {
notice = text; notice = text;
@ -324,7 +352,32 @@ async function boot() {
captureChassis(); 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 * 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 * 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 * number the front moving against you is something you find out about by
* driving back out there. * driving back out there.
*/ */
const killedInAction = () => { const finishDeath = () => {
kia++; kia++;
const at = physics.chassis.translation(); const at = physics.chassis.translation();
const home = bases.reduce((best, b) => const home = bases.reduce((best, b) =>
@ -345,14 +398,6 @@ async function boot() {
applyMissionImpact(front, -KIA_LAND_LOSS); applyMissionImpact(front, -KIA_LAND_LOSS);
condition = replacementCar(condition); 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.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.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, 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.`, : `You woke up at ${home.name}. Somebody else is driving what is left of the car.`,
9, 9,
); );
audio.impact(26000);
persistence.checkpoint(elapsed, snapshot()); persistence.checkpoint(elapsed, snapshot());
}; };
@ -377,7 +421,22 @@ async function boot() {
const handlers: LoopHandlers = { const handlers: LoopHandlers = {
fixedUpdate(dt) { fixedUpdate(dt) {
elapsed += 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. // 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 // 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); if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf); 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; const stopped = Math.abs(speed) < 2.5;
// --- Overhaul: buying back what the car is capable of --- // --- Overhaul: buying back what the car is capable of ---
@ -572,7 +632,9 @@ async function boot() {
} }
// --- Active mission --- // --- 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) { if (quest) {
const target = nodeOf(quest.targetNode); const target = nodeOf(quest.targetNode);
const event = stepQuest(quests, { const event = stepQuest(quests, {
@ -635,11 +697,13 @@ async function boot() {
control, control,
carX: at.x, carX: at.x,
carZ: at.z, 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, model.roads,
chatterRng, chatterRng,
stopped, stopped && !dead,
); );
if (found?.kind === 'appeared') say(found.opportunity.hail, 7); if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
else if (found?.kind === 'helped') { else if (found?.kind === 'helped') {
@ -775,7 +839,7 @@ async function boot() {
// --- The end of a car --- // --- The end of a car ---
// Checked here rather than beside `applyWear`, because being shot at // Checked here rather than beside `applyWear`, because being shot at
// damages the chassis too and either route has to be able to finish you. // damages the chassis too and either route has to be able to finish you.
if (isWrittenOff(condition)) killedInAction(); if (!dead && isWrittenOff(condition)) beginDeath();
// --- Radio --- // --- Radio ---
// Front drift is measured, not narrated: the chatter about the enemy // Front drift is measured, not narrated: the chatter about the enemy
@ -856,7 +920,12 @@ async function boot() {
view.followSun(); view.followSun();
view.setTone(control, frameDt); view.setTone(control, frameDt);
markers.update(elapsed); 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); view.renderer.render(view.scene, view.camera);
const quest = quests.active; const quest = quests.active;
@ -963,6 +1032,7 @@ async function boot() {
condition, condition,
front: front.boundaries, front: front.boundaries,
opportunity: opportunities.current, opportunity: opportunities.current,
dying: +dying.toFixed(2),
alert: pursuit.alert, alert: pursuit.alert,
suspicion: +pursuit.suspicion.toFixed(2), suspicion: +pursuit.suspicion.toFixed(2),
hunters: pursuit.hunters.size, hunters: pursuit.hunters.size,

View File

@ -286,10 +286,52 @@ const camTarget = new THREE.Vector3();
const camDesired = new THREE.Vector3(); const camDesired = new THREE.Vector3();
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5); 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; 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); camDesired.copy(CHASE_OFFSET);
// Pull back a little at speed for a sense of pace. // Pull back a little at speed for a sense of pace.
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3); camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);