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 = { 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): 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'); }, }; }