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:
parent
d9cdca70d5
commit
563ee66dcd
106
src/main.ts
106
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,
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user