Zum Inhalt

native/macos/KuroNativeSaver/Core/FlightDirector.swift

// FlightDirector — Swift mirror of src/engine/modes/flight-director.ts. Sequences
// camera manoeuvres on ONE continuous clock and owns the continuous speed
// multiplier. It POST-PROCESSES the camera the scene already wrote (additive, like
// the parallax/hesitation seam) and NEVER writes fog/bloom. In Brick A the director
// only overlays (ownsCamera=false); full ownership during transitions is Brick B.

import Foundation

enum ManoeuvreKind { case kick, roll, dive, climb, bank }

struct Manoeuvre {
    var kind: ManoeuvreKind
    var dur: Double            // seconds
    var dir: Float             // -1 | 0 | +1
    var intensity: Float       // unit-ish amplitude
    var start: Double = -1     // filled when dequeued (continuous director clock)
}

final class FlightDirector {
    private var clock: Double = 0
    private var active: [Manoeuvre] = []
    private(set) var speedMul: Float = 1     // 1 outside a transition; ramped by the warp
    var ownsCamera = false
    private var rng: LCG                      // seeded warp shake + reserved for Brick C
    private struct Trans { var p: TransitionProfile; var t0: Double; var swapped: Bool; var onSwap: () -> Void; var baseFov: Float }
    private var trans: Trans?
    var inTransition: Bool { trans != nil }

    init(seed: Int32, bus: EventBus? = nil) {
        rng = LCG(seed: seed ^ Int32(bitPattern: 0x9e37_79b9))
        bus?.subscribe(.intrusion) { [weak self] e in
            self?.enqueue(Manoeuvre(kind: .kick, dur: max(0.3, e.intensity), dir: 0, intensity: 0.55))
        }
    }

    /// sin²(πp): value AND slope are 0 at p=0 and p=1 → chained manoeuvres never snap.
    static func envelope(_ p: Double) -> Float {
        if p <= 0 || p >= 1 { return 0 }
        let s = sin(Double.pi * p)
        return Float(s * s)
    }

    /// continuous forward-speed multiplier (enum base is applied by the caller).
    func speed() -> Float { speedMul }

    func enqueue(_ m: Manoeuvre) { active.append(m) }

    /// Start a warp transition; fires onSwap once at the warp peak (the scene swap).
    func beginTransition(_ p: TransitionProfile, baseFov: Float, onSwap: @escaping () -> Void) {
        trans = Trans(p: p, t0: clock, swapped: false, onSwap: onSwap, baseFov: baseFov)
        ownsCamera = true
    }

    /// advance the director clock and retire finished manoeuvres. Call once/frame.
    func update(t: Double, dt: Double) {
        clock += dt
        for i in active.indices where active[i].start < 0 { active[i].start = clock }
        active.removeAll { (clock - $0.start) / $0.dur >= 1 }

        if var tr = trans {
            let el = clock - tr.t0
            let total = tr.p.windupDur + tr.p.warpDur + tr.p.emergeDur
            if el >= tr.p.windupDur && el < tr.p.windupDur + tr.p.warpDur {
                let pw = (el - tr.p.windupDur) / tr.p.warpDur
                speedMul = 1 + (tr.p.warpSpeed - 1) * FlightDirector.envelope(pw)   // sin² hump (C1)
                if !tr.swapped && pw >= 0.5 { tr.swapped = true; tr.onSwap() }      // swap at the peak
            } else {
                speedMul = 1
            }
            if el >= total { trans = nil; ownsCamera = false; speedMul = 1 } else { trans = tr }
        }
    }

    /// additive overlay on the pose the scene just wrote (modifies in place).
    func apply(_ cam: inout Camera) {
        for m in active {
            let p = (clock - m.start) / m.dur
            let env = FlightDirector.envelope(p)
            if env == 0 { continue }
            switch m.kind {
            case .kick:  cam.position.x *= 1 - m.intensity * env       // lateral steadying (== old hesitation)
            case .roll:  cam.rotation.z += m.dir * m.intensity * env   // barrel-roll
            case .bank:  cam.position.x += m.dir * m.intensity * env; cam.rotation.z += m.dir * 0.3 * env
            case .dive:  cam.position.y -= m.intensity * env
            case .climb: cam.position.y += m.intensity * env
            }
        }
    }

    /// Override the scene-written pose during a transition. Returns true if FOV changed.
    /// Reads the scene's just-written pose as the emerge blend target (smooth hand-back).
    func applyTransition(_ cam: inout Camera) -> Bool {
        guard let tr = trans else { return false }
        let el = clock - tr.t0
        let w = tr.p.windupDur, wa = tr.p.warpDur, em = tr.p.emergeDur
        if el < w {
            let e = FlightDirector.envelope(0.5 + 0.5 * (el / w))   // ramp the climb in, hold near peak
            if let cp = tr.p.climbPitch { cam.rotation.x += cp * e }
            return false
        }
        if el < w + wa {
            let h = FlightDirector.envelope((el - w) / wa)
            cam.fovDegrees = tr.baseFov + tr.p.fovPush * h
            cam.position.x += (rng.nextF() - 0.5) * 0.06 * h        // seeded warp shake
            cam.position.y += (rng.nextF() - 0.5) * 0.06 * h
            return true
        }
        let pe = min(1, (el - w - wa) / em)
        let k = Float(1 - (1 - pe) * (1 - pe))                      // easeOut
        if let ea = tr.p.entryAltitude { cam.position.y = ea + (cam.position.y - ea) * k }
        cam.fovDegrees = tr.baseFov
        return true
    }
}