Mission types. Four of them, differing in pacing and risk rather than in the noun on the board: a quick supply drop, a retrieval whose danger is all on the return leg, a long stationary intel transmission in the open, and a cheap survey whose payoff is the map. Retrieval carries cargo that impacts damage, and pays for what arrives rather than what you set off with, which makes the drive home a different problem from the drive out. Radio. Chatter framing the player as an undercover driver being talked at by people busy elsewhere. Every line is triggered by something actually happening in the sim — crossing a border, a road you personally made notorious, a car past saving, or the front genuinely drifting. The enemy-has-other-priorities line fires off measured drift, so the brief's ambient-drift-is-the-mechanism idea is said out loud without ever showing a number. Topics carry cooldowns and never repeat a sentence back to back; a test caught that they could. Field work. Past the line, with nothing in hand, a wounded stranger or a contact or a strippable wreck occasionally turns up. Deliberately not on a board: no route preview, no comparison, no shopping between them. Take the detour in front of you or drive on. They never appear behind your own lines, where a safe errand would defeat the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
4.0 KiB
TypeScript
119 lines
4.0 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;
|
||
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;
|
||
completed: number;
|
||
parts: number;
|
||
notice: string;
|
||
}
|
||
|
||
/** 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]!;
|
||
};
|
||
|
||
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`,
|
||
];
|
||
}
|
||
|
||
export function createHud(seed: number) {
|
||
const el = document.getElementById('hud')!;
|
||
let last = 0;
|
||
|
||
return {
|
||
update(speedMs: number, condition: CarCondition, now: number, model: HudModel) {
|
||
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
|
||
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.quest ? questLines(model.quest) : ['no mission — find a base']),
|
||
'',
|
||
CONTROL_WORDS[model.control],
|
||
'',
|
||
// Debug only. The real game signals heat through the road itself —
|
||
// see the design brief: no numeric meter ships.
|
||
`[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 ?? '—'}`,
|
||
'',
|
||
`seed ${seed}`,
|
||
'WASD drive · space handbrake · R respawn',
|
||
];
|
||
if (model.notice) lines.push('', `» ${model.notice}`);
|
||
el.textContent = lines.join('\n');
|
||
},
|
||
};
|
||
}
|