diff --git a/src/core/input.ts b/src/core/input.ts index 16e4efc..5673e34 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -9,6 +9,8 @@ export interface DriverInput { select: number | null; /** True on the frame M was pressed. Consumed on read. */ toggleMute: boolean; + /** True on the frame X was pressed: give up the job in hand. Consumed on read. */ + abandon: boolean; } const KEYS = { @@ -19,6 +21,7 @@ const KEYS = { handbrake: ['Space'], respawn: ['KeyR'], mute: ['KeyM'], + abandon: ['KeyX'], } as const; const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4']; @@ -34,6 +37,7 @@ export function createInput(): { // landed between two fixed steps, and must not fire twice if it spans three. let pendingSelect: number | null = null; let pendingMute = false; + let pendingAbandon = false; /** Called on the first real interaction, to satisfy autoplay policy. */ let onFirstGesture: (() => void) | null = null; @@ -44,6 +48,7 @@ export function createInput(): { } if (!down.has(e.code)) { if (e.code === 'KeyM') pendingMute = true; + if (e.code === 'KeyX') pendingAbandon = true; const index = SELECT_KEYS.indexOf(e.code); if (index >= 0) pendingSelect = index + 1; } @@ -57,6 +62,7 @@ export function createInput(): { down.clear(); pendingSelect = null; pendingMute = false; + pendingAbandon = false; }; window.addEventListener('keydown', onDown); @@ -67,8 +73,10 @@ export function createInput(): { read: () => { const select = pendingSelect; const toggleMute = pendingMute; + const abandon = pendingAbandon; pendingSelect = null; pendingMute = false; + pendingAbandon = false; return { throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), @@ -76,6 +84,7 @@ export function createInput(): { respawn: held(KEYS.respawn), select, toggleMute, + abandon, }; }, diff --git a/src/main.ts b/src/main.ts index f3f7229..da719e5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,6 +61,14 @@ export const DEBUG = new URLSearchParams(location.search).has('debug'); const SIGHT_RADIUS = 85; /** Seconds R must be held down to wipe the save and start a fresh campaign. */ const RESET_HOLD_SECONDS = 5; +/** + * Metres of front line given up for walking away from a job. + * + * Half what dying costs (`KIA_LAND_LOSS`). Deciding a route is not worth it is + * a judgement the game should let you make, but it cannot be free, or the board + * becomes a reroll button: park, dislike the route, drop it, take another. + */ +const ABANDON_LAND_LOSS = 12; function resolveSeed(): number { const raw = new URLSearchParams(location.search).get('seed'); @@ -480,6 +488,33 @@ async function boot() { // Checkpoint: taking a job is a decision worth not having to make twice. persistence.checkpoint(elapsed, snapshot()); } + } else if (base && stopped && quests.active && quests.active.stage !== 'return') { + // Parked somewhere friendly with a job you have not started bringing + // home. There has to be a way out of one: a target can end up behind + // concrete there is no way round, and the only exit used to be holding + // R for five seconds, which does not drop the job — it destroys the + // whole campaign. Not offered on the return leg, because that arrival + // is a hand-in and stepQuest is about to pay you for it. + if (openBase !== base.nodeId) { + openBase = base.nodeId; + board.showActive(base, MISSION_SHAPE[quests.active.type].label.toLowerCase()); + audio.ui('open'); + } + if (cmd.abandon) { + const dropped = failQuest(quests); + // Walking away is cheaper than dying, and it still costs the war + // something. Otherwise the board is a free reroll: park, dislike the + // route, drop it, take another. + applyMissionImpact(front, -ABANDON_LAND_LOSS); + board.hide(); + openBase = null; + audio.ui('open'); + say( + `${MISSION_SHAPE[dropped!.type].label} dropped. That is ground somebody else has to take back.`, + 6, + ); + persistence.checkpoint(elapsed, snapshot()); + } } else if (openBase !== null) { openBase = null; board.hide(); diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index e8e1cc5..033b772 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -20,6 +20,7 @@ const IDLE: DriverInput = { respawn: false, select: null, toggleMute: false, + abandon: false, }; /** Rapier runs headless, so vehicle tuning is checkable without a browser. */ diff --git a/src/ui/board.ts b/src/ui/board.ts index 862c85a..50d7a2a 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -89,6 +89,26 @@ export function createBoard() { .join('')} `; }, + + /** + * What a base has to say to somebody who turns up still carrying a job. + * + * There has to be a way out of a mission. A target can end up behind + * concrete you cannot get round, and until now the only exit was holding R + * for five seconds — which does not drop the job, it destroys the campaign. + */ + showActive(base: Base, label: string) { + el.classList.add('open'); + el.innerHTML = ` +