Zum Inhalt

native/macos/KuroNativeSaver/Core/VoidScene.swift

// VoidScene — flythrough an asteroid belt: a recycled pool of wireframe
// polyhedra, a static starfield, and streaming speed particles. Ported from
// scenes/void.ts. Layout uses our LCG (web-seed parity is a non-goal).

import Metal
import simd
import Foundation

final class VoidScene: Scene {
    var camera = Camera()
    private(set) var items: [DrawItem] = []
    private let debris: Bool
    var fogDensity: Float { debris ? 0.006 : 0.0045 }   // wreckage variant: denser haze

    private let spdMul: Float
    private var rng: LCG
    private var fly: CameraFly

    private struct Roid { var item: DrawItem; var pos: SIMD3<Float>; var rot: SIMD3<Float>; var scale: Float; var spin: SIMD3<Float>; var baseOpacity: Float }
    private var roids: [Roid] = []
    private var heading: Float = 0, headTarget: Float = 0, prevHeading: Float = 0
    private var nextTurnT: Double = 20
    private var camPath = SIMD3<Float>(0, 0, 0)
    private let speed: DrawItem
    var storm: Float = 0            // reactive-world PANIC storm (set by Renderer)
    var threatLevel: Float = 0      // reactive threat 0..1 (set by Renderer; mirrors web ctx.threat())
    private let spvCount = 56
    // Debris-dodge state (Brick D-next).
    private var hazard: DrawItem!
    private var hazardPos = SIMD3<Float>(0, 0, 0)
    private var hazardRot = SIMD3<Float>(0, 0, 0)
    private var hazardActive = false
    private var hazardSide: Float = 1, hazardVert: Float = 1
    private var nextHazardT: Double = 12
    private var dodgeX: Float = 0, dodgeY: Float = 0

    // field constants (void.ts)
    private let FIELD_W: Float = 44, NOSPAWN: Float = 16, BELT_S: Float = 14
    private let OFFBELT_P: Float = 0.08, FAR: Float = -100, FAR_VAR: Float = 55, BEHIND: Float = 10
    private let FADE_START: Float = 100, FADE_RANGE: Float = 24   // distance-based fade-in (no pop)

    init(ctx: SceneContext, debris: Bool = false) {
        self.debris = debris
        let device = ctx.device
        spdMul = ctx.speed()
        fly = CameraFly(seed: (ctx.settings.seed ?? freshSeed()) &+ 6262,
                        latAmp: 6, vertAmp: 3, vertBase: 0, pitchBase: 0, bankScale: ctx.settings.bankStrength)
        var r = ctx.rng

        camera.position = SIMD3(0, 0, 0)
        camera.rotation = SIMD3(0, 0, 0)

        // --- 5 shared geometry templates ---
        struct Tpl { let pos: MTLBuffer; let idx: MTLBuffer; let vc: Int; let ic: Int }
        func tpl(_ g: (pos: [SIMD3<Float>], idx: [UInt32])) -> Tpl {
            var flat = [Float](); flat.reserveCapacity(g.pos.count * 3)
            for p in g.pos { flat.append(p.x); flat.append(p.y); flat.append(p.z) }
            return Tpl(pos: Geo.buffer(device, flat), idx: Geo.buffer(device, g.idx),
                       vc: g.pos.count, ic: g.idx.count)
        }
        var irr = Geo.icosahedron(detail: 1)
        for i in irr.pos.indices { irr.pos[i] *= (0.62 + r.nextF() * 0.55) }
        let tpls: [Tpl]
        let cumW: [Float]
        if debris {
            tpls = [
                tpl(Geo.box(1.5, 1.0, 0.06)),    // hull panel
                tpl(Geo.box(0.7, 1.8, 0.06)),    // tall panel
                tpl(Geo.box(0.13, 0.13, 3.0)),   // strut / girder
                tpl(Geo.icosahedron(detail: 0)), // angular chunk
            ]
            cumW = [0.30, 0.52, 0.74, 1.00]
        } else {
            tpls = [
                tpl(Geo.icosahedron(detail: 2)),
                tpl(Geo.icosahedron(detail: 1)),
                tpl(Geo.icosahedron(detail: 0)),
                tpl(Geo.dodecahedron()),
                tpl(irr),
            ]
            cumW = [0.20, 0.52, 0.70, 0.85, 1.00]
        }
        let tum: Float = debris ? 2.2 : 1   // erratic wreckage tumble
        func pickTpl() -> Tpl {
            let x = r.nextF()
            for i in 0..<cumW.count where x < cumW[i] { return tpls[i] }
            return tpls[0]
        }

        func sampleBeltY() -> Float {
            if r.nextF() < 0.08 { return (r.nextF() - 0.5) * 50 }
            return ((r.nextF() - 0.5) + (r.nextF() - 0.5)) * 14 * 1.6
        }
        func inTube(_ x: Float, _ y: Float, _ cx: Float, _ cy: Float) -> Bool {
            let dx = x - cx, dy = y - cy; return dx * dx + dy * dy < 16 * 16
        }
        func seedInitial() -> SIMD3<Float> {
            for _ in 0..<5 {
                let x = (r.nextF() - 0.5) * 2 * 44
                let y = sampleBeltY()
                let z = -18 - r.nextF() * 95
                if !inTube(x, y, 0, 0) { return SIMD3(x, y, z) }
            }
            return SIMD3((r.nextF() < 0.5 ? -1 : 1) * (8 + r.nextF() * 10), sampleBeltY(), -18 - r.nextF() * 95)
        }

        // --- asteroids (fewer; fade in from the far plane) ---
        for _ in 0..<60 {
            let t = pickTpl()
            let opacity = 0.60 + r.nextF() * 0.30
            let it = DrawItem(positions: t.pos, indices: t.idx, count: t.ic, vertexCount: t.vc,
                              primitive: .line, opacity: opacity)
            it.infectable = r.nextF() < 0.3   // ~30% crossfade to the enemy colour with threat
            let scale = 0.9 + r.nextF() * 3.0
            let pos = seedInitial()
            let rot = SIMD3(r.nextF() * .pi, r.nextF() * .pi, r.nextF() * .pi)
            let spin = SIMD3((r.nextF() - 0.5) * 0.024 * tum, (r.nextF() - 0.5) * 0.024 * tum, (r.nextF() - 0.5) * 0.020 * tum)
            roids.append(Roid(item: it, pos: pos, rot: rot, scale: scale, spin: spin, baseOpacity: opacity))
        }

        // --- starfield (2200 static) ---
        var sv = [Float](); sv.reserveCapacity(2200 * 3)
        for _ in 0..<2200 {
            sv.append((r.nextF() - 0.5) * 240)
            sv.append((r.nextF() - 0.5) * 140)
            sv.append((r.nextF() - 0.5) * 260 - 50)
        }
        let stars = DrawItem(positions: Geo.buffer(device, sv), indices: nil, count: 2200,
                             vertexCount: 2200, primitive: .point, opacity: 1.0,
                             isPoint: true, pointSizeWorld: 0.09)

        // --- speed particles (56 streamed) ---
        var pv = [Float](); pv.reserveCapacity(spvCount * 3)
        for _ in 0..<spvCount {
            pv.append((r.nextF() - 0.5) * 24); pv.append((r.nextF() - 0.5) * 16); pv.append(-r.nextF() * 60)
        }
        speed = DrawItem(positions: Geo.buffer(device, pv), indices: nil, count: spvCount,
                         vertexCount: spvCount, primitive: .point, opacity: 1.0,
                         isPoint: true, pointSizeWorld: 0.14)

        // Debris hazard (Brick D-next): an oversized enemy-tinted chunk, hidden until launched.
        let ht = pickTpl()
        let hz = DrawItem(positions: ht.pos, indices: ht.idx, count: ht.ic, vertexCount: ht.vc,
                          primitive: .line, opacity: 0)
        hz.infectable = true   // takes the enemy tint under threat
        hazard = hz

        rng = r
        items = roids.map { $0.item } + [stars, speed, hz]
    }

    private func model(_ a: Roid) -> matrix_float4x4 {
        Mathx.translation(a.pos)
        * Mathx.rotationY(a.rot.y) * Mathx.rotationX(a.rot.x) * Mathx.rotationZ(a.rot.z)
        * Mathx.scale(a.scale)
    }

    private func sampleBeltY() -> Float {
        if rng.nextF() < OFFBELT_P { return (rng.nextF() - 0.5) * 50 }
        return ((rng.nextF() - 0.5) + (rng.nextF() - 0.5)) * BELT_S * 1.6
    }
    private func respawnAhead(_ fwd: SIMD3<Float>, _ right: SIMD3<Float>) -> SIMD3<Float>? {
        for _ in 0..<5 {
            let lat = (rng.nextF() - 0.5) * 2 * FIELD_W, y = sampleBeltY()
            if lat * lat + y * y < NOSPAWN * NOSPAWN { continue }   // keep the path tube clear
            let d = -FAR + rng.nextF() * FAR_VAR                    // ahead along the heading
            return SIMD3(camPath.x + fwd.x * d + right.x * lat, y, camPath.z + fwd.z * d + right.z * lat)
        }
        return nil
    }

    func update(t: Double, dt: Double) {
        let dtf = Float(dt)
        // Course: occasional eased heading changes. Rocks recycle only when they fall behind,
        // so laterally-drifting rocks stream past with natural parallax — that's the cue that
        // sells a curve in open space (over-recycling them ahead read as a block rotating).
        if t > nextTurnT {
            headTarget += (rng.nextF() < 0.5 ? -1 : 1) * (0.5 + rng.nextF() * 0.9)
            nextTurnT = t + 8 + Double(rng.nextF()) * 12
        }
        prevHeading = heading
        heading += (headTarget - heading) * (1 - exp(-dtf / 2.6))
        let fwd = SIMD3<Float>(sin(heading), 0, -cos(heading))
        let right = SIMD3<Float>(cos(heading), 0, sin(heading))

        camPath += fwd * (spdMul * 14 * dtf)

        // Debris dodge (Brick D-next): under high threat, a big chunk barrels down the tube;
        // juke the camera aside (eased) so it whips past — a near-miss, not a hit.
        if !hazardActive && threatLevel > 0.55 && t > nextHazardT {
            hazardActive = true
            hazardSide = rng.nextF() < 0.5 ? -1 : 1
            hazardVert = rng.nextF() < 0.5 ? -1 : 1
            hazardPos = camPath + fwd * 120
        }
        var dodgeTX: Float = 0, dodgeTY: Float = 0
        if hazardActive {
            hazardRot += SIMD3(0.03, 0.022, 0) * spdMul
            let along = simd_dot(hazardPos - camPath, fwd)
            if along > 0 && along < 60 {
                let urgency = 1 - along / 60
                dodgeTX = hazardSide * 7.5 * urgency
                dodgeTY = hazardVert * 4.5 * urgency
            }
            if along < -BEHIND { hazardActive = false; nextHazardT = t + 7 + Double(rng.nextF()) * 9 }
            hazard.opacity = hazardActive ? 0.92 : 0
            hazard.model = Mathx.translation(hazardPos) * Mathx.rotationY(hazardRot.y)
                * Mathx.rotationX(hazardRot.x) * Mathx.scale(5.1)
        } else {
            hazard.opacity = 0
        }
        dodgeX += (dodgeTX - dodgeX) * (1 - exp(-dtf / 0.32))
        dodgeY += (dodgeTY - dodgeY) * (1 - exp(-dtf / 0.32))

        let f = fly.update(t: t, forwardSpeed: spdMul * 14)
        let latOff = f.x * 0.3 + dodgeX
        camera.position = SIMD3(camPath.x + right.x * latOff, f.y + dodgeY, camPath.z + right.z * latOff)
        let turnRate = (heading - prevHeading) / max(1e-4, dtf)
        camera.rotation = SIMD3(f.pitch, heading + f.yaw * 0.3, f.roll - turnRate * 2.6 - dodgeX * 0.05)

        let spinStep = spdMul * dtf * 60   // web spin is per-frame*spd; normalize to 60fps

        for i in roids.indices {
            roids[i].rot += roids[i].spin * spinStep
            let ahead = simd_dot(roids[i].pos - camPath, fwd)       // forward distance along heading
            // Recycle ONLY when a rock falls behind — laterally-drifting rocks stream past with
            // natural parallax (teleporting them ahead made the field rotate as a block = strafe).
            if ahead < -BEHIND, let p = respawnAhead(fwd, right) {
                roids[i].pos = p
                roids[i].scale = 0.9 + rng.nextF() * 3.0
                roids[i].rot = SIMD3(rng.nextF() * .pi, rng.nextF() * .pi, rng.nextF() * .pi)
            }
            // distance-based fade-in so they don't pop into view at the far plane
            let dist = -simd_dot(roids[i].pos - camPath, fwd)
            roids[i].item.opacity = roids[i].baseOpacity * max(0, min(1, (FADE_START - dist) / FADE_RANGE))
            roids[i].item.model = model(roids[i])
        }

        // speed particles (mutate buffer in place); the PANIC storm widens + enlarges them.
        let spreadX = 24 * (1 + storm * 0.5), spreadY = 16 * (1 + storm * 0.5)
        speed.pointSizeWorld = 0.14 + storm * 0.12
        let ptr = speed.positions.contents().bindMemory(to: Float.self, capacity: spvCount * 3)
        for i in 0..<spvCount {
            let ahead = (ptr[i * 3] - camPath.x) * fwd.x + (ptr[i * 3 + 2] - camPath.z) * fwd.z
            if ahead < -2 {
                let d = 40 + rng.nextF() * 30, lat = (rng.nextF() - 0.5) * spreadX
                ptr[i * 3]     = camPath.x + fwd.x * d + right.x * lat
                ptr[i * 3 + 1] = (rng.nextF() - 0.5) * spreadY + f.y
                ptr[i * 3 + 2] = camPath.z + fwd.z * d + right.z * lat
            }
        }
    }
}