The ceiling was the only number in the game that moved one way, with no counterplay at any price. In practice that reads less as a design pillar than as a punishment you cannot answer - you watch the car get permanently worse and there is nothing to *do* about it. Press C at any base to strip and rebuild, worst subsystem first. It buys ceiling and deliberately nothing else: an overhaul raises what the car could be, and ordinary repair still has to fill it in. Early on every part goes to repairs and there is never a surplus, so this is invisible; it only becomes the thing to spend on once the ceiling is what is actually holding you back. The rate is the design, not the impossibility. 0.12 of ceiling per part, against a full point of condition per part for a repair, so undoing one crash's permanent cost runs about seven missions. Decline is now something you can fight rather than only absorb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
|
||
import type { HeatLevel } from '../sim/heat';
|
||
import { MISSION_SHAPE, type MissionType } from '../sim/quests';
|
||
import type { Control } from '../sim/regions';
|
||
|
||
|
||
const BAR_WIDTH = 12;
|
||
|
||
/**
|
||
* Draws a bar with a notch for the ceiling: the fillable part is what repairs
|
||
* can still reach, and the gap past it is gone for good.
|
||
*/
|
||
function bar(value: number, ceiling = 1): string {
|
||
const filled = Math.round(value * BAR_WIDTH);
|
||
const reachable = Math.round(ceiling * BAR_WIDTH);
|
||
return (
|
||
'█'.repeat(filled) + '·'.repeat(Math.max(0, reachable - filled)) + '×'.repeat(BAR_WIDTH - reachable)
|
||
);
|
||
}
|
||
|
||
export interface HudModel {
|
||
heat: {
|
||
segmentId: number | null;
|
||
/** False while on the verge — still counts as using the road. */
|
||
onTarmac: boolean;
|
||
value: number;
|
||
/** Standing of the district, which feeds every road through it. */
|
||
area: number;
|
||
level: HeatLevel | null;
|
||
};
|
||
quest: {
|
||
type: MissionType;
|
||
stage: 'travel' | 'working' | 'return';
|
||
novel: boolean;
|
||
worked: number;
|
||
cargo: number;
|
||
distance: number;
|
||
/** Radians from the car's nose to the target, positive to the left. */
|
||
bearing: number;
|
||
} | null;
|
||
control: Control;
|
||
pursuit: {
|
||
/** 0..1 toward being recognised. */
|
||
meter: number;
|
||
alert: 'clear' | 'suspicious' | 'hunted';
|
||
hunters: number;
|
||
/** Seconds of staying out of sight before they give up. */
|
||
losingIn: number;
|
||
};
|
||
/** Rounds in the air nearby. Not a health bar — a reason to keep moving. */
|
||
danger: number;
|
||
completed: number;
|
||
/** Jobs dropped or lost with a car. */
|
||
failed: number;
|
||
/** Cars written off underneath the player. */
|
||
kia: number;
|
||
parts: number;
|
||
notice: string;
|
||
/** 0..1 toward wiping the campaign, while R is held. */
|
||
resetProgress: number;
|
||
muted: boolean;
|
||
}
|
||
|
||
/** Eight-point compass, starting at "straight ahead" and turning left. */
|
||
const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗'];
|
||
|
||
const arrowFor = (bearing: number): string => {
|
||
const sector = Math.round(bearing / (Math.PI / 4));
|
||
return ARROWS[((sector % 8) + 8) % 8]!;
|
||
};
|
||
|
||
/**
|
||
* The one meter the player is meant to watch. Deliberately not a number: it is
|
||
* "how close am I to being made", and it wants reading at a glance while
|
||
* driving.
|
||
*/
|
||
function pursuitLines(pursuit: HudModel['pursuit']): string[] {
|
||
if (pursuit.alert === 'hunted') {
|
||
return [
|
||
`HUNTED — ${pursuit.hunters} on you`,
|
||
pursuit.losingIn > 0
|
||
? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them`
|
||
: 'they can see you',
|
||
];
|
||
}
|
||
if (pursuit.meter <= 0.02) return ['cover intact'];
|
||
return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`];
|
||
}
|
||
|
||
const CONTROL_WORDS: Record<Control, string> = {
|
||
liberated: 'liberated — nobody is watching the roads here',
|
||
contested: 'contested — the roads remember, slowly',
|
||
occupied: 'occupied — every road you use is noticed',
|
||
frontier: 'frontier — beyond the line, and it shows',
|
||
};
|
||
|
||
function questLines(quest: NonNullable<HudModel['quest']>): string[] {
|
||
const shape = MISSION_SHAPE[quest.type];
|
||
const label = shape.label;
|
||
const kind = quest.novel ? 'new target' : 'known target';
|
||
if (quest.stage === 'return') {
|
||
// Only retrieval has anything aboard that can be spoiled on the way home.
|
||
const cargo = shape.cargoHome ? ` · cargo ${(quest.cargo * 100).toFixed(0)}%` : '';
|
||
return [`${label} done — return to any base${cargo}`];
|
||
}
|
||
if (quest.stage === 'working') {
|
||
return [`${label} — ${shape.working} ${quest.worked.toFixed(1)}/${shape.work}s (hold still)`];
|
||
}
|
||
return [
|
||
`${label} (${kind})`,
|
||
`${arrowFor(quest.bearing)} ${(quest.distance / 1000).toFixed(2)} km to target`,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param debug Show the scaffolding: heat values, the district's standing, the
|
||
* road you are on, and the name of the band you are in.
|
||
*
|
||
* These exist to tune curves, and every one of them actively works against
|
||
* the thing it is measuring. Phase 3's gate is telling occupied ground from
|
||
* liberated *without being told*, and there was a line on screen naming it.
|
||
* Phase 1's gate is avoiding a road because of its history, with a numeric
|
||
* heat meter sitting underneath it. You cannot judge either while reading the
|
||
* answer, so they live behind `?debug` and the world has to speak for itself.
|
||
*/
|
||
export function createHud(seed: number, debug = false) {
|
||
const el = document.getElementById('hud')!;
|
||
let last = 0;
|
||
|
||
return {
|
||
update(speedMs: number, condition: CarCondition, now: number, model: HudModel) {
|
||
// Text at 10 Hz. Nothing here changes fast enough to want a frame each.
|
||
if (now - last < 0.1) return;
|
||
last = now;
|
||
|
||
const lines = [
|
||
`${Math.abs(speedMs * 3.6).toFixed(0).padStart(3)} km/h`,
|
||
'',
|
||
...SUBSYSTEMS.map(
|
||
(part) =>
|
||
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
|
||
`${(condition.level[part] * 100).toFixed(0)}%`,
|
||
),
|
||
`parts ${model.parts.toFixed(2)} runs ${model.completed}` +
|
||
(model.failed > 0 ? ` lost ${model.failed}` : '') +
|
||
// Cars are counted, not lives. There is no stock of them to run out
|
||
// of; the number is there so a long campaign reads as a history.
|
||
(model.kia > 0 ? ` cars ${model.kia + 1}` : ''),
|
||
'',
|
||
...(model.quest ? questLines(model.quest) : ['no mission — find a base']),
|
||
'',
|
||
// Which band you are in is meant to be read off the light, the fog and
|
||
// who is standing in the street. Naming it hands over the answer.
|
||
...(debug ? [CONTROL_WORDS[model.control]] : []),
|
||
...pursuitLines(model.pursuit),
|
||
...(model.danger > 0 ? [`⚠ rounds in the air nearby (${model.danger})`] : []),
|
||
'',
|
||
// Heat is meant to be read off what is physically sitting in the road,
|
||
// so the meter that exists to tune it cannot also be on screen.
|
||
...(debug
|
||
? [
|
||
`[debug] road ${
|
||
model.heat.segmentId === null
|
||
? 'off-route'
|
||
: `#${model.heat.segmentId} ${model.heat.onTarmac ? '(on road)' : '(alongside)'}`
|
||
}`,
|
||
`[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`,
|
||
`[debug] area ${bar(model.heat.area)}`,
|
||
'',
|
||
`[debug] seed ${seed}`,
|
||
]
|
||
: []),
|
||
'WASD drive · space handbrake · R respawn',
|
||
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
|
||
'hold R to reset the campaign',
|
||
];
|
||
if (model.resetProgress > 0.06) {
|
||
lines.push('', `RESETTING ${bar(Math.min(1, model.resetProgress))} keep holding R`);
|
||
}
|
||
if (model.notice) lines.push('', `» ${model.notice}`);
|
||
el.textContent = lines.join('\n');
|
||
},
|
||
};
|
||
}
|