Zum Inhalt

native/macos/KuroNativeSaver/Core/Hud.swift

// 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)
    }
}