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