A way out of a mission that is not wiping the campaign
state.active was only ever cleared by completing a job. A target can end up behind concrete there is no way round, and the only exit was holding R for five seconds - which does not drop the job, it destroys every mile of wear, every road the enemy learned and the whole map you built. Park at any base with a job you have not started bringing home and the board offers to take it off you for X. It costs 12m of front line, half what dying costs: deciding a route is not worth it is a judgement the game should let you make, and it cannot be free or the board becomes a reroll button. Not offered on the return leg, since that arrival is a hand-in and it is about to pay you. Dropped jobs are counted apart from completed ones. They are not runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2e0373fb27
commit
9e476087d4
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
35
src/main.ts
35
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();
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -89,6 +89,26 @@ export function createBoard() {
|
||||
.join('')}</ol>
|
||||
<footer>press 1–${offers.length} to accept</footer>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<h2>${base.name}</h2>
|
||||
<ol><li>
|
||||
You are still carrying: ${label}.
|
||||
<div class="meta">Nobody here will take it off your hands, and nobody
|
||||
here will hold the board open while you decide.</div>
|
||||
</li></ol>
|
||||
<footer><span class="key">[X]</span> give it up — we lose ground for it</footer>`;
|
||||
},
|
||||
|
||||
hide() {
|
||||
el.classList.remove('open');
|
||||
},
|
||||
|
||||
@ -153,7 +153,8 @@ export function createHud(seed: number) {
|
||||
`[debug] area ${bar(model.heat.area)}`,
|
||||
'',
|
||||
`seed ${seed}`,
|
||||
`WASD drive · space handbrake · R respawn · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
`WASD drive · space handbrake · R respawn · X drop job at a base` +
|
||||
` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
'hold R to reset the campaign',
|
||||
];
|
||||
if (model.resetProgress > 0.06) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user