// Hud — the tactical overlay (info panels, crosshair, scene-label slab) + the
// terminal scrollback, drawn via the TextRenderer into the HDR scene so the CRT
// post treats it. Faithful to the web hud/index.ts layout.
import simd
import Foundation
enum SceneMeta {
static let label: [String: String] = [
"terrain": "TERRAIN", "city": "CITY", "rift": "THE RIFT", "tunnel": "TUNNEL", "void": "VOID",
"wreckage": "WRECKAGE", "matrix": "MATRIX", "metro": "METRO",
]
/// Facade over the SSOT (`story-content.json` → `modeLabels`), which the web reads as
/// `DICT.MODE_LABELS`. Used to be a second hand-kept copy of the same table — and a
/// copy is how the native side came to know one scene fewer than the web
/// (AGENTS.md § The Swift twin: literals that duplicate SSOT content become facades).
static var modes: [String: [String]] { StoryContent.shared.modeLabels }
}
final class Hud {
let terminal = Terminal()
private var fps: Double = 60
func setFps(_ f: Double) { fps = f }
/// Matrix rain only — drawn into the HDR scene (gets CRT/bloom). Kept separate
/// from the overlay so the renderer can curve the rain into the monitor while
/// drawing the HUD/terminal flat on top (flatHud mode).
func renderMatrix(_ tr: TextRenderer, width: Int, height: Int, accent: SIMD3<Float>, t: Double, opacity: Float) {
MatrixRain.render(tr, width: width, height: height, accent: accent, t: t, opacity: opacity)
}
/// HUD panels + narrative terminal. Drawn either into the HDR scene (in-monitor,
/// curved) or flat onto the final composite (flatHud), depending on the renderer.
func renderOverlay(_ tr: TextRenderer, width: Int, height: Int, accent: SIMD3<Float>,
t: Double, scene: String, terminalScale: Float = 1,
terminalLayout: Settings.TerminalLayout = .strip, terminalBand: Float = 0.24,
showPanels: Bool = true, showRadar: Bool = true) {
let dim = accent * 0.72
let s = Float(height) / 64 // base cell height (px)
let pad = s * 1.5
let W = Float(width), H = Float(height)
// narrative terminal — independent of the HUD flight panels
switch terminalLayout {
case .off: break
case .strip: renderTerminalStrip(tr, W: W, H: H, s: s, accent: accent, dim: dim, t: t, scale: terminalScale)
case .stripDark: renderTerminalBand(tr, W: W, H: H, s: s, accent: accent, dim: dim, t: t, scale: terminalScale, bandFraction: terminalBand)
case .window: renderTerminalWindow(tr, W: W, H: H, accent: accent, dim: dim, t: t, scale: terminalScale)
}
guard showPanels else { return }
let mode = (SceneMeta.modes[scene]?.first) ?? scene.uppercased()
let elapsed = t
// --- HUD left ---
let alt = 42 + Int((sin(elapsed * 0.3) * 12).rounded())
let hdg = Int((270 + sin(elapsed * 0.12) * 15 + 360).truncatingRemainder(dividingBy: 360))
let left = [
"MODE \(mode)",
String(format: "ALT %04dM", alt),
"SPD 0.18",
String(format: "HDG %03d", hdg),
String(format: "T+ %02d:%02d", Int(elapsed) / 60, Int(elapsed) % 60),
"RT \(clock())",
]
for (i, line) in left.enumerated() {
tr.add(line, xPx: pad, yPx: pad + Float(i) * s * 1.35, pxHeight: s, color: dim, opacity: 0.9)
}
// --- HUD right ---
let right = ["SYS NOMINAL", "94% PWR", "SECURE LNK", String(format: "FPS %3d", Int(fps))]
for (i, line) in right.enumerated() {
let w = tr.width(line, pxHeight: s)
tr.add(line, xPx: W - w - pad, yPx: pad + Float(i) * s * 1.35, pxHeight: s, color: dim, opacity: 0.9)
}
// --- crosshair (center) ---
let cross = "──┤ ✛ ├──"
let cw = tr.width(cross, pxHeight: s)
tr.add(cross, xPx: (W - cw) / 2, yPx: H / 2 - s / 2, pxHeight: s, color: dim, opacity: 0.4)
// --- scene-label slab (upper-center) ---
let label = SceneMeta.label[scene] ?? scene.uppercased()
let lw = tr.width(label, pxHeight: s * 2)
tr.add(label, xPx: (W - lw) / 2, yPx: pad, pxHeight: s * 2, color: accent, opacity: 0.55)
// --- radar (bottom-right) ---
if showRadar {
let reserve: Float = terminalLayout == .stripDark ? max(s * 3, H * terminalBand) : 0
renderRadar(tr, W: W, H: H, s: s, accent: accent, t: t, bottomReserve: reserve)
}
}
/// Tactical radar — concentric rings, crosshair, a rotating sweep with fading
/// afterglow, drifting blips, and a centre dot. Bottom-right. Web mirror
/// (hud/index.ts drawRadar), deterministic from t (no per-frame RNG state).
private func renderRadar(_ tr: TextRenderer, W: Float, H: Float, s: Float,
accent: SIMD3<Float>, t: Double, bottomReserve: Float) {
let R = H * 0.05 // outer radius (proportional)
let pad = s * 1.5
let cx = W - R - pad
let cy = H - R - pad - bottomReserve
let lw = max(1, R * 0.03)
func h(_ x: Float) -> Float { let v = sin(x * 12.9898) * 43758.5453; return v - floor(v) }
// rings (polygon approximation)
let segs = 40
for rr in [R * 0.33, R * 0.57, R * 0.86] {
var px = cx + rr, py = cy
for k in 1...segs {
let a = Float(k) / Float(segs) * 6.2831853
let nx = cx + cos(a) * rr, ny = cy + sin(a) * rr
tr.line(px, py, nx, ny, width: lw, color: accent, opacity: 0.14)
px = nx; py = ny
}
}
// crosshair
tr.line(cx, cy - R, cx, cy + R, width: lw, color: accent, opacity: 0.10)
tr.line(cx - R, cy, cx + R, cy, width: lw, color: accent, opacity: 0.10)
// rotating sweep + fading afterglow
let rA = Float(t.truncatingRemainder(dividingBy: 1000)) * 2.5
for i in 0..<16 {
let a = rA - Float(i) * 0.07
tr.line(cx, cy, cx + cos(a) * R, cy + sin(a) * R,
width: lw * 1.5, color: accent, opacity: (1 - Float(i) / 16) * 0.34)
}
tr.line(cx, cy, cx + cos(rA) * R, cy + sin(rA) * R, width: lw * 1.6, color: accent, opacity: 0.85)
// blips (deterministic fade/respawn)
for i in 0..<12 {
let cyc = Float(t) * 0.5 + Float(i) * 0.131
let k = floor(cyc)
let life = 1 - (cyc - k)
let ang = h(Float(i) * 7.13 + k * 1.7) * 6.2831853
let rr = (0.22 + h(Float(i) * 13.1 + k * 2.3) * 0.74) * R
let bs = max(2, R * 0.06)
tr.fillRect(xPx: cx + cos(ang) * rr - bs / 2, yPx: cy + sin(ang) * rr - bs / 2,
wPx: bs, hPx: bs, color: accent, opacity: life * 0.85)
}
// centre dot
let ds = max(3, R * 0.09)
tr.fillRect(xPx: cx - ds / 2, yPx: cy - ds / 2, wPx: ds, hPx: ds, color: accent, opacity: 0.95)
}
// --- terminal: bottom-left strip, fading upward (the compact overlay) ---
private func renderTerminalStrip(_ tr: TextRenderer, W: Float, H: Float, s: Float,
accent: SIMD3<Float>, dim: SIMD3<Float>, t: Double, scale: Float) {
// Keep clear of the curved glass edge: extra left/bottom margin, cap lines.
let ts = s * scale
let lh = ts * 1.3
let cell = ts * 0.9
let leftPad = W * 0.03 + s
let promptY = H - (H * 0.07 + ts * 1.6)
let maxLines = max(2, min(10, Int((promptY - H * 0.06) / lh)))
let lines = terminal.visibleLines(max: maxLines)
for (i, ln) in lines.reversed().enumerated() {
let op = max(0.05, pow(0.72, Float(i))) // upward fade by age (the strip's deliberate gradient)
tr.add(ln.text, xPx: leftPad, yPx: promptY - Float(i + 1) * lh, pxHeight: cell, color: dim, opacity: op)
}
tr.add(terminal.promptLine(t: t), xPx: leftPad, yPx: promptY, pxHeight: cell, color: dim, opacity: 0.9)
}
// --- terminal: a full-width darkened band at the bottom, height = bandFraction·H ---
private func renderTerminalBand(_ tr: TextRenderer, W: Float, H: Float, s: Float,
accent: SIMD3<Float>, dim: SIMD3<Float>, t: Double, scale: Float, bandFraction: Float) {
let ts = s * scale
let lh = ts * 1.3, cell = ts * 0.9
let bandH = max(lh * 2.4, H * bandFraction)
let bandTop = H - bandH
tr.fillRect(xPx: 0, yPx: bandTop, wPx: W, hPx: bandH, color: SIMD3(0, 0, 0), opacity: 0.5)
tr.fillRect(xPx: 0, yPx: bandTop, wPx: W, hPx: max(1, H * 0.0016), color: accent, opacity: 0.28)
let leftPad = W * 0.025
let contentTop = bandTop + lh * 0.35
let promptY = H - lh * 1.05
let maxRows = max(2, Int((promptY - contentTop) / lh))
let maxChars = max(12, Int((W - 2 * leftPad) / tr.charWidth(pxHeight: cell)))
let logical = terminal.visibleLines(max: maxRows + 6)
var rows: [(String, SIMD3<Float>, Float)] = []
for ln in logical {
let (col, op) = colorFor(ln.category, accent: accent, dim: dim)
for r in wrap(ln.text, maxChars) { rows.append((r, col, op)) }
}
for r in wrap(terminal.promptLine(t: t), maxChars) { rows.append((r, dim, 0.66)) } // prompt matches the log (uniform glow)
let shown = Array(rows.suffix(maxRows + 1)) // +1 for the prompt row
let n = shown.count
for (i, row) in shown.enumerated() {
let y = promptY - Float(n - 1 - i) * lh
if y < bandTop - lh { continue }
tr.add(row.0, xPx: leftPad, yPx: y, pxHeight: cell, color: row.1, opacity: row.2)
}
}
// --- terminal: Apple-Lisa-style centered CORP OS window (the story mode) ---
private func renderTerminalWindow(_ tr: TextRenderer, W: Float, H: Float,
accent: SIMD3<Float>, dim: SIMD3<Float>, t: Double, scale: Float) {
let winW = min(W * 0.84, H * 1.5)
let winH = H * 0.64
let winX = (W - winW) / 2, winY = (H - winH) / 2
let border = max(2, H * 0.0028)
let cell = (H / 52) * min(max(scale, 0.6), 1.8) // text cell (terminalScale-aware)
let lh = cell * 1.28
let titleH = cell * 1.7
let statusH = cell * 1.4
let pad = cell * 0.85
let bg = SIMD3<Float>(0.012, 0.022, 0.012) // near-black green fill
// backdrop dimmer + window background + a 4-edge border frame (NOT a full
// accent fill behind — that washed the interior bright green).
tr.fillRect(xPx: 0, yPx: 0, wPx: W, hPx: H, color: SIMD3(0, 0, 0), opacity: 0.52)
tr.fillRect(xPx: winX, yPx: winY, wPx: winW, hPx: winH, color: bg, opacity: 0.97)
let bop: Float = 0.55
tr.fillRect(xPx: winX - border, yPx: winY - border, wPx: winW + 2 * border, hPx: border, color: accent, opacity: bop) // top
tr.fillRect(xPx: winX - border, yPx: winY + winH, wPx: winW + 2 * border, hPx: border, color: accent, opacity: bop) // bottom
tr.fillRect(xPx: winX - border, yPx: winY, wPx: border, hPx: winH, color: accent, opacity: bop) // left
tr.fillRect(xPx: winX + winW, yPx: winY, wPx: border, hPx: winH, color: accent, opacity: bop) // right
// titlebar — Lisa horizontal stripes
let stripeN = 6
let lineH1 = max(1, H * 0.0016)
for i in 0..<stripeN {
let sy = winY + titleH * (0.26 + 0.52 * Float(i) / Float(stripeN - 1))
tr.fillRect(xPx: winX + pad, yPx: sy, wPx: winW - 2 * pad, hPx: lineH1, color: accent, opacity: 0.3)
}
// close box (top-left): accent square with a hollow dark centre
let boxS = titleH * 0.42
let boxX = winX + pad, boxY = winY + (titleH - boxS) / 2
tr.fillRect(xPx: boxX, yPx: boxY, wPx: boxS, hPx: boxS, color: accent, opacity: 0.62)
tr.fillRect(xPx: boxX + boxS * 0.22, yPx: boxY + boxS * 0.22, wPx: boxS * 0.56, hPx: boxS * 0.56, color: bg, opacity: 1)
// centered title label on a dark patch (interrupts the stripes)
let title = "CORP TERMINAL.APP v4.1"
let titleCell = titleH * 0.5
let titleW = tr.width(title, pxHeight: titleCell)
let titleX = winX + (winW - titleW) / 2
tr.fillRect(xPx: titleX - pad * 0.7, yPx: winY + titleH * 0.12, wPx: titleW + pad * 1.4, hPx: titleH * 0.76, color: bg, opacity: 1)
tr.add(title, xPx: titleX, yPx: winY + (titleH - titleCell) / 2, pxHeight: titleCell, color: accent, opacity: 0.7)
tr.fillRect(xPx: winX, yPx: winY + titleH, wPx: winW, hPx: max(1, border * 0.6), color: accent, opacity: 0.45)
// status bar
let statusY = winY + winH - statusH
tr.fillRect(xPx: winX, yPx: statusY, wPx: winW, hPx: max(1, border * 0.6), color: accent, opacity: 0.35)
let statusCell = statusH * 0.5
let statusYMid = statusY + (statusH - statusCell) / 2
tr.add("SECTOR 7 · UTC \(clock())", xPx: winX + pad, yPx: statusYMid, pxHeight: statusCell, color: dim, opacity: 0.6)
let rightS = "SECURE LNK"
let rw = tr.width(rightS, pxHeight: statusCell)
tr.add(rightS, xPx: winX + winW - pad - rw, yPx: statusYMid, pxHeight: statusCell, color: dim, opacity: 0.6)
// content: chronological scrollback + prompt, word-wrapped (no left-clip)
let contentTop = winY + titleH + pad
let contentBottom = statusY - pad
let maxRows = max(3, Int((contentBottom - contentTop) / lh))
let maxChars = max(8, Int((winW - 2 * pad) / tr.charWidth(pxHeight: cell)))
let logical = terminal.visibleLines(max: maxRows + 6) // extra: wrapping expands the count
var rows: [(String, SIMD3<Float>, Float)] = []
for ln in logical {
let (col, op) = colorFor(ln.category, accent: accent, dim: dim)
for r in wrap(ln.text, maxChars) { rows.append((r, col, op)) }
}
for r in wrap(terminal.promptLine(t: t), maxChars) { rows.append((r, dim, 0.66)) } // prompt matches the log (uniform glow)
let shown = rows.suffix(maxRows)
let startI = maxRows - shown.count // bottom-align under the prompt
for (i, row) in shown.enumerated() {
tr.add(row.0, xPx: winX + pad, yPx: contentTop + Float(startI + i) * lh, pxHeight: cell, color: row.1, opacity: row.2)
}
}
/// Word-wrap a line to ≤ maxChars per row (hard-splits overlong single tokens).
private func wrap(_ s: String, _ maxChars: Int) -> [String] {
if s.count <= maxChars { return [s] }
var rows: [String] = [], cur = ""
for word in s.split(separator: " ", omittingEmptySubsequences: false) {
var w = String(word)
while w.count > maxChars { // a single token longer than a row
if !cur.isEmpty { rows.append(cur); cur = "" }
rows.append(String(w.prefix(maxChars))); w = String(w.dropFirst(maxChars))
}
if cur.isEmpty { cur = w }
else if cur.count + 1 + w.count <= maxChars { cur += " " + w }
else { rows.append(cur); cur = w }
}
if !cur.isEmpty { rows.append(cur) }
return rows
}
private func colorFor(_ c: Terminal.Cat, accent: SIMD3<Float>, dim: SIMD3<Float>) -> (SIMD3<Float>, Float) {
// Uniform glow across all categories. Bright per-category colors (accent vs dim)
// bloomed unevenly into the HDR haze — some lines glowed much harder than others,
// which read as accidental. One deposited luma keeps the whole log calm, crisp and
// consistent; the live prompt's cursor is the only moving accent.
(dim, 0.66)
}
private func clock() -> String {
let d = Date(); var c = Calendar(identifier: .gregorian)
c.timeZone = .current
let h = c.component(.hour, from: d), m = c.component(.minute, from: d), sec = c.component(.second, from: d)
return String(format: "%02d:%02d:%02d", h, m, sec)
}
}