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;
|
select: number | null;
|
||||||
/** True on the frame M was pressed. Consumed on read. */
|
/** True on the frame M was pressed. Consumed on read. */
|
||||||
toggleMute: boolean;
|
toggleMute: boolean;
|
||||||
|
/** True on the frame X was pressed: give up the job in hand. Consumed on read. */
|
||||||
|
abandon: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
@ -19,6 +21,7 @@ const KEYS = {
|
|||||||
handbrake: ['Space'],
|
handbrake: ['Space'],
|
||||||
respawn: ['KeyR'],
|
respawn: ['KeyR'],
|
||||||
mute: ['KeyM'],
|
mute: ['KeyM'],
|
||||||
|
abandon: ['KeyX'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
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.
|
// landed between two fixed steps, and must not fire twice if it spans three.
|
||||||
let pendingSelect: number | null = null;
|
let pendingSelect: number | null = null;
|
||||||
let pendingMute = false;
|
let pendingMute = false;
|
||||||
|
let pendingAbandon = false;
|
||||||
/** Called on the first real interaction, to satisfy autoplay policy. */
|
/** Called on the first real interaction, to satisfy autoplay policy. */
|
||||||
let onFirstGesture: (() => void) | null = null;
|
let onFirstGesture: (() => void) | null = null;
|
||||||
|
|
||||||
@ -44,6 +48,7 @@ export function createInput(): {
|
|||||||
}
|
}
|
||||||
if (!down.has(e.code)) {
|
if (!down.has(e.code)) {
|
||||||
if (e.code === 'KeyM') pendingMute = true;
|
if (e.code === 'KeyM') pendingMute = true;
|
||||||
|
if (e.code === 'KeyX') pendingAbandon = true;
|
||||||
const index = SELECT_KEYS.indexOf(e.code);
|
const index = SELECT_KEYS.indexOf(e.code);
|
||||||
if (index >= 0) pendingSelect = index + 1;
|
if (index >= 0) pendingSelect = index + 1;
|
||||||
}
|
}
|
||||||
@ -57,6 +62,7 @@ export function createInput(): {
|
|||||||
down.clear();
|
down.clear();
|
||||||
pendingSelect = null;
|
pendingSelect = null;
|
||||||
pendingMute = false;
|
pendingMute = false;
|
||||||
|
pendingAbandon = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', onDown);
|
window.addEventListener('keydown', onDown);
|
||||||
@ -67,8 +73,10 @@ export function createInput(): {
|
|||||||
read: () => {
|
read: () => {
|
||||||
const select = pendingSelect;
|
const select = pendingSelect;
|
||||||
const toggleMute = pendingMute;
|
const toggleMute = pendingMute;
|
||||||
|
const abandon = pendingAbandon;
|
||||||
pendingSelect = null;
|
pendingSelect = null;
|
||||||
pendingMute = false;
|
pendingMute = false;
|
||||||
|
pendingAbandon = false;
|
||||||
return {
|
return {
|
||||||
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
|
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
|
||||||
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
|
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
|
||||||
@ -76,6 +84,7 @@ export function createInput(): {
|
|||||||
respawn: held(KEYS.respawn),
|
respawn: held(KEYS.respawn),
|
||||||
select,
|
select,
|
||||||
toggleMute,
|
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;
|
const SIGHT_RADIUS = 85;
|
||||||
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
|
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
|
||||||
const RESET_HOLD_SECONDS = 5;
|
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 {
|
function resolveSeed(): number {
|
||||||
const raw = new URLSearchParams(location.search).get('seed');
|
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.
|
// Checkpoint: taking a job is a decision worth not having to make twice.
|
||||||
persistence.checkpoint(elapsed, snapshot());
|
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) {
|
} else if (openBase !== null) {
|
||||||
openBase = null;
|
openBase = null;
|
||||||
board.hide();
|
board.hide();
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const IDLE: DriverInput = {
|
|||||||
respawn: false,
|
respawn: false,
|
||||||
select: null,
|
select: null,
|
||||||
toggleMute: false,
|
toggleMute: false,
|
||||||
|
abandon: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||||
|
|||||||
@ -89,6 +89,26 @@ export function createBoard() {
|
|||||||
.join('')}</ol>
|
.join('')}</ol>
|
||||||
<footer>press 1–${offers.length} to accept</footer>`;
|
<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() {
|
hide() {
|
||||||
el.classList.remove('open');
|
el.classList.remove('open');
|
||||||
},
|
},
|
||||||
|
|||||||
@ -153,7 +153,8 @@ export function createHud(seed: number) {
|
|||||||
`[debug] area ${bar(model.heat.area)}`,
|
`[debug] area ${bar(model.heat.area)}`,
|
||||||
'',
|
'',
|
||||||
`seed ${seed}`,
|
`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',
|
'hold R to reset the campaign',
|
||||||
];
|
];
|
||||||
if (model.resetProgress > 0.06) {
|
if (model.resetProgress > 0.06) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user