Planet Explorer

Planet Explorer — Infinite Procedural Flyover Demo.

A visually stunning, relaxing infinite-world flyover showcasing procedural terrain with biome colouring, water, clouds, full day/night cycle, aurora borealis, meteors, lightning, and atmospheric effects. Screensaver-grade visual showcase of the engine’s 3D rendering capabilities.

Run: uv run python packages/graphics/examples/game_planet_explorer.py

Controls: Up/Down Arrow Accelerate / decelerate Left/Right Arrow Turn (yaw) Click + Drag Horizontal = turn, vertical = speed (web/mobile) Space Toggle auto-fly (gentle S-curve turns) T Cycle time speed (1x / 4x / 16x) Escape Quit

Source Code

   1"""Planet Explorer — Infinite Procedural Flyover Demo.
   2
   3A visually stunning, relaxing infinite-world flyover showcasing procedural
   4terrain with biome colouring, water, clouds, full day/night cycle, aurora
   5borealis, meteors, lightning, and atmospheric effects. Screensaver-grade
   6visual showcase of the engine's 3D rendering capabilities.
   7
   8Run:  uv run python packages/graphics/examples/game_planet_explorer.py
   9
  10Controls:
  11    Up/Down Arrow    Accelerate / decelerate
  12    Left/Right Arrow Turn (yaw)
  13    Click + Drag     Horizontal = turn, vertical = speed (web/mobile)
  14    Space            Toggle auto-fly (gentle S-curve turns)
  15    T                Cycle time speed (1x / 4x / 16x)
  16    Escape           Quit
  17"""
  18
  19import math
  20import random
  21from collections import deque
  22
  23import numpy as np
  24
  25from simvx.core import (
  26    Camera3D,
  27    DirectionalLight3D,
  28    Input,
  29    InputMap,
  30    Key,
  31    Material,
  32    Mesh,
  33    MeshInstance3D,
  34    MouseButton,
  35    MultiMesh,
  36    MultiMeshInstance3D,
  37    Node3D,
  38    ParticleEmitter,
  39    PointLight3D,
  40    Quat,
  41    Vec3,
  42    WorldEnvironment,
  43    create_plane,
  44    mat4_from_trs,
  45)
  46from simvx.core.noise import FastNoiseLite, FractalType, NoiseType
  47from simvx.graphics import App
  48
  49# ===========================================================================
  50# Constants
  51# ===========================================================================
  52
  53FLY_HEIGHT = 45.0
  54CHUNK_SIZE = 64
  55CHUNK_RES = 24
  56VIEW_RADIUS = 4
  57REMOVE_RADIUS = 6  # Remove chunks at larger radius to avoid pop-in/pop-out
  58HEIGHT_SCALE = 40.0
  59WATER_LEVEL = 0.0
  60CLOUD_HEIGHT = 65.0
  61CLOUD_CHUNK_SIZE = 128
  62CLOUD_RES = 12
  63CLOUD_VIEW_RADIUS = 3
  64DAY_CYCLE_SECONDS = 120.0
  65STAR_COUNT = 200
  66AURORA_CURTAINS = 8  # spread evenly around 360°
  67
  68# Biome height bands: (min_height, max_height, colour, roughness)
  69BIOME_BANDS = [
  70    (float("-inf"), 3.0, (0.82, 0.72, 0.42), 0.85),  # Sand — warm gold
  71    (3.0, 10.0, (0.22, 0.62, 0.15), 0.90),  # Grass — vivid green
  72    (10.0, 18.0, (0.10, 0.40, 0.10), 0.88),  # Forest — deep green
  73    (18.0, 25.0, (0.50, 0.42, 0.35), 0.75),  # Rock — warm brown
  74    (25.0, float("inf"), (0.95, 0.95, 0.98), 0.60),  # Snow — bright white
  75]
  76
  77
  78# ===========================================================================
  79# Utility functions
  80# ===========================================================================
  81
  82
  83def _rgba_to_png(rgba: np.ndarray) -> bytes:
  84    """Encode an RGBA uint8 numpy array to PNG bytes (no PIL dependency)."""
  85    import struct
  86    import zlib
  87
  88    h, w = rgba.shape[:2]
  89    raw = b""
  90    for y in range(h):
  91        raw += b"\x00" + rgba[y].tobytes()
  92    compressed = zlib.compress(raw)
  93
  94    def _chunk(ctype: bytes, data: bytes) -> bytes:
  95        c = ctype + data
  96        return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
  97
  98    ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)
  99    return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", compressed) + _chunk(b"IEND", b"")
 100
 101
 102def _smoothstep(t: float) -> float:
 103    t = max(0.0, min(1.0, t))
 104    return t * t * (3.0 - 2.0 * t)
 105
 106
 107def _lerp(a: float, b: float, t: float) -> float:
 108    return a + (b - a) * t
 109
 110
 111def _lerp_colour(a: tuple, b: tuple, t: float) -> tuple:
 112    return tuple(a[i] + (b[i] - a[i]) * t for i in range(min(len(a), len(b))))
 113
 114
 115def _sample_keyframes(keyframes: list, t: float):
 116    """Sample value from sorted keyframe list [(time, value), ...].
 117
 118    Values can be floats or tuples. Smoothstep interpolation between keys.
 119    """
 120    t = t % 1.0
 121    if t <= keyframes[0][0]:
 122        return keyframes[0][1]
 123    if t >= keyframes[-1][0]:
 124        return keyframes[-1][1]
 125    for i in range(len(keyframes) - 1):
 126        t0, v0 = keyframes[i]
 127        t1, v1 = keyframes[i + 1]
 128        if t0 <= t <= t1:
 129            frac = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
 130            frac = _smoothstep(frac)
 131            if isinstance(v0, tuple):
 132                return _lerp_colour(v0, v1, frac)
 133            return _lerp(v0, v1, frac)
 134    return keyframes[-1][1]
 135
 136
 137# ===========================================================================
 138# Shared noise generators (module-level, reused across all chunks)
 139# ===========================================================================
 140
 141_terrain_noise = FastNoiseLite(seed=42, noise_type=NoiseType.SIMPLEX, frequency=0.008)
 142_terrain_noise.fractal_type = FractalType.FBM
 143_terrain_noise.fractal_octaves = 5
 144
 145_cloud_noise = FastNoiseLite(seed=137, noise_type=NoiseType.SIMPLEX, frequency=0.012)
 146_cloud_noise.fractal_type = FractalType.FBM
 147_cloud_noise.fractal_octaves = 3
 148
 149# Shared biome materials (pre-created, reused across all chunks for GPU batching)
 150_biome_materials = [Material(colour=band[2], roughness=band[3]) for band in BIOME_BANDS]
 151
 152
 153# ===========================================================================
 154# Terrain chunk builder (vectorised numpy — no Python loops over vertices)
 155# ===========================================================================
 156
 157
 158def _build_chunk(cx: int, cz: int) -> tuple[Vec3, list[tuple[Mesh, Material]]]:
 159    """Build terrain meshes for chunk at grid position (cx, cz).
 160
 161    Returns (chunk_center, [(Mesh, Material), ...]) where vertex positions
 162    are LOCAL to chunk_center (required for correct frustum culling).
 163    """
 164    res = CHUNK_RES
 165    x0 = cx * CHUNK_SIZE
 166    z0 = cz * CHUNK_SIZE
 167    step = CHUNK_SIZE / (res - 1)
 168
 169    # Chunk center in world space (MeshInstance3D position will be set to this)
 170    center_x = x0 + CHUNK_SIZE * 0.5
 171    center_z = z0 + CHUNK_SIZE * 0.5
 172
 173    # Grid coordinates in world space (for noise sampling)
 174    xs_1d = np.linspace(x0, x0 + CHUNK_SIZE, res, dtype=np.float64)
 175    zs_1d = np.linspace(z0, z0 + CHUNK_SIZE, res, dtype=np.float64)
 176    xs_2d, zs_2d = np.meshgrid(xs_1d, zs_1d, indexing="ij")
 177
 178    # Sample heights (single vectorised call)
 179    heights = (
 180        _terrain_noise.get_noise_2d_array(xs_2d.ravel(), zs_2d.ravel()).reshape(res, res).astype(np.float32)
 181        * HEIGHT_SCALE
 182    )
 183
 184    # Build positions LOCAL to chunk center (so model_matrix translation = chunk center)
 185    positions = np.empty((res * res, 3), dtype=np.float32)
 186    positions[:, 0] = (xs_2d.ravel() - center_x).astype(np.float32)
 187    positions[:, 1] = heights.ravel()
 188    positions[:, 2] = (zs_2d.ravel() - center_z).astype(np.float32)
 189
 190    # Compute normals via finite differences (vectorised)
 191    dx = np.zeros_like(heights)
 192    dz = np.zeros_like(heights)
 193    dx[1:-1, :] = (heights[2:, :] - heights[:-2, :]) / (2.0 * step)
 194    dx[0, :] = (heights[1, :] - heights[0, :]) / step
 195    dx[-1, :] = (heights[-1, :] - heights[-2, :]) / step
 196    dz[:, 1:-1] = (heights[:, 2:] - heights[:, :-2]) / (2.0 * step)
 197    dz[:, 0] = (heights[:, 1] - heights[:, 0]) / step
 198    dz[:, -1] = (heights[:, -1] - heights[:, -2]) / step
 199
 200    normals = np.empty((res * res, 3), dtype=np.float32)
 201    normals[:, 0] = -dx.ravel()
 202    normals[:, 1] = 1.0
 203    normals[:, 2] = -dz.ravel()
 204    lens = np.linalg.norm(normals, axis=1, keepdims=True)
 205    normals /= np.maximum(lens, 1e-8)
 206
 207    # Build triangle indices (vectorised — no Python loop, CCW winding)
 208    rows, cols = np.meshgrid(np.arange(res - 1), np.arange(res - 1), indexing="ij")
 209    a = (rows * res + cols).ravel()
 210    b = a + res
 211    indices = np.column_stack([a, a + 1, b, a + 1, b + 1, b]).ravel().astype(np.uint32)
 212
 213    # Texcoords: tile UV within each chunk for renderer compatibility
 214    texcoords = np.empty((res * res, 2), dtype=np.float32)
 215    u_1d = np.linspace(0, 1, res, dtype=np.float32)
 216    u_2d, v_2d = np.meshgrid(u_1d, u_1d, indexing="ij")
 217    texcoords[:, 0] = u_2d.ravel()
 218    texcoords[:, 1] = v_2d.ravel()
 219
 220    # Split triangles by biome band (based on average vertex height)
 221    tri_idx = indices.reshape(-1, 3)
 222    avg_h = positions[tri_idx, 1].mean(axis=1)
 223
 224    results = []
 225    for band_i, (h_min, h_max, _, _) in enumerate(BIOME_BANDS):
 226        mask = (avg_h >= h_min) & (avg_h < h_max)
 227        if not mask.any():
 228            continue
 229        band_tris = tri_idx[mask]
 230        unique_verts, inverse = np.unique(band_tris, return_inverse=True)
 231        remapped = inverse.reshape(-1, 3).astype(np.uint32)
 232        mesh = Mesh(positions[unique_verts], remapped.ravel(), normals[unique_verts], texcoords[unique_verts])
 233        results.append((mesh, _biome_materials[band_i]))
 234
 235    return Vec3(center_x, 0, center_z), results
 236
 237
 238# ===========================================================================
 239# Ship mesh builder
 240# ===========================================================================
 241
 242
 243def _build_ship_texture() -> bytes:
 244    """Generate a procedural hull texture as PNG bytes.
 245
 246    512x256 image with panel lines, cockpit gradient, and engine glow strip.
 247    UV mapping: u=0..1 left-to-right, v=0..1 nose-to-tail.
 248    """
 249    import io
 250    W, H = 512, 256
 251    img = np.zeros((H, W, 4), dtype=np.uint8)
 252
 253    # Base hull colour gradient: lighter on top (dorsal), darker on edges
 254    for y in range(H):
 255        v = y / H  # 0=nose, 1=tail
 256        # Nose-to-tail gradient: bright silver-blue forward, darker aft
 257        base_r = int(170 - v * 40)
 258        base_g = int(180 - v * 35)
 259        base_b = int(210 - v * 25)
 260        img[y, :, 0] = base_r
 261        img[y, :, 1] = base_g
 262        img[y, :, 2] = base_b
 263        img[y, :, 3] = 255
 264
 265    # Dorsal spine highlight: bright stripe down centre (u ≈ 0.45..0.55)
 266    cx = W // 2
 267    for x in range(max(0, cx - 25), min(W, cx + 25)):
 268        t = 1.0 - abs(x - cx) / 25.0
 269        for y in range(H):
 270            boost = int(t * 60)
 271            img[y, x, 0] = min(255, int(img[y, x, 0]) + boost)
 272            img[y, x, 1] = min(255, int(img[y, x, 1]) + boost + 8)
 273            img[y, x, 2] = min(255, int(img[y, x, 2]) + boost + 15)
 274
 275    # Panel lines: horizontal bands every ~32px (wide, dark grooves)
 276    for y in range(0, H, 32):
 277        img[y:y+2, :, :3] = np.clip(img[y:y+2, :, :3].astype(np.int16) - 80, 0, 255).astype(np.uint8)
 278
 279    # Panel lines: vertical bands every ~64px
 280    for x in range(0, W, 64):
 281        img[:, x:x+2, :3] = np.clip(img[:, x:x+2, :3].astype(np.int16) - 70, 0, 255).astype(np.uint8)
 282
 283    # Cockpit canopy: bright teal rectangle near nose centre (v ≈ 0.05..0.25, u ≈ 0.4..0.6)
 284    cy0, cy1 = int(0.05 * H), int(0.25 * H)
 285    cx0, cx1 = int(0.4 * W), int(0.6 * W)
 286    for y in range(cy0, cy1):
 287        for x in range(cx0, cx1):
 288            t = 1.0 - 2.0 * abs((y - (cy0 + cy1) / 2) / (cy1 - cy0))
 289            t *= 1.0 - 2.0 * abs((x - (cx0 + cx1) / 2) / (cx1 - cx0))
 290            t = max(0.0, t)
 291            img[y, x, 0] = int(img[y, x, 0] * (1 - t) + 200 * t)
 292            img[y, x, 1] = int(img[y, x, 1] * (1 - t) + 240 * t)
 293            img[y, x, 2] = int(img[y, x, 2] * (1 - t) + 255 * t)
 294
 295    # Engine exhaust glow strip at tail (v ≈ 0.85..0.95, u ≈ 0.35..0.65)
 296    ey0, ey1 = int(0.85 * H), int(0.95 * H)
 297    ex0, ex1 = int(0.35 * W), int(0.65 * W)
 298    for y in range(ey0, ey1):
 299        t = 1.0 - abs(2.0 * (y - (ey0 + ey1) / 2) / (ey1 - ey0))
 300        for x in range(ex0, ex1):
 301            tx = 1.0 - abs(2.0 * (x - (ex0 + ex1) / 2) / (ex1 - ex0))
 302            glow = t * tx
 303            img[y, x, 0] = min(255, int(img[y, x, 0] + glow * 80))
 304            img[y, x, 1] = min(255, int(img[y, x, 1] + glow * 130))
 305            img[y, x, 2] = min(255, int(img[y, x, 2] + glow * 200))
 306
 307    # Wing edge trim: bright accent along left (u<0.1) and right (u>0.9)
 308    for x in range(0, int(0.08 * W)):
 309        t = 1.0 - x / (0.08 * W)
 310        img[:, x, 1] = np.minimum(255, (img[:, x, 1].astype(np.int16) + int(t * 50)).astype(np.int16)).astype(np.uint8)
 311        img[:, x, 2] = np.minimum(255, (img[:, x, 2].astype(np.int16) + int(t * 80)).astype(np.int16)).astype(np.uint8)
 312    for x in range(int(0.92 * W), W):
 313        t = (x - 0.92 * W) / (0.08 * W)
 314        img[:, x, 1] = np.minimum(255, (img[:, x, 1].astype(np.int16) + int(t * 50)).astype(np.int16)).astype(np.uint8)
 315        img[:, x, 2] = np.minimum(255, (img[:, x, 2].astype(np.int16) + int(t * 80)).astype(np.int16)).astype(np.uint8)
 316
 317    return _rgba_to_png(img)
 318
 319
 320# Cache the texture bytes at module level (generated once)
 321_SHIP_TEXTURE: bytes | None = None
 322
 323
 324def _get_ship_texture() -> bytes:
 325    global _SHIP_TEXTURE
 326    if _SHIP_TEXTURE is None:
 327        _SHIP_TEXTURE = _build_ship_texture()
 328    return _SHIP_TEXTURE
 329
 330
 331def _build_ship_mesh() -> Mesh:
 332    """Detailed sci-fi delta wing craft with panel-line geometry and UVs."""
 333    from simvx.core import PrimitiveType, SurfaceTool
 334
 335    st = SurfaceTool()
 336    st.begin(PrimitiveType.TRIANGLES)
 337
 338    # Key points — forward is -Z
 339    nose = (0, 0.05, -3.5)
 340    left_tip = (-2.2, -0.05, 1.5)
 341    right_tip = (2.2, -0.05, 1.5)
 342    spine = (0, 0.55, 0.3)          # dorsal ridge peak
 343    spine_rear = (0, 0.4, 1.2)      # spine tapers down toward tail
 344    tail_l = (-0.45, 0.1, 1.8)
 345    tail_r = (0.45, 0.1, 1.8)
 346    # Wing mid-points for panel-line detail
 347    mid_l = (-1.2, 0.15, -0.3)
 348    mid_r = (1.2, 0.15, -0.3)
 349    # Cockpit canopy bulge
 350    canopy_f = (0, 0.35, -2.0)
 351    canopy_r = (0, 0.45, -0.8)
 352    canopy_l = (-0.3, 0.25, -1.4)
 353    canopy_r2 = (0.3, 0.25, -1.4)
 354    # Belly keel
 355    keel_f = (0, -0.15, -2.5)
 356    keel_r = (0, -0.1, 1.0)
 357
 358    def tri(a, b, c, ua, ub, uc):
 359        """Emit a triangle with per-vertex UVs."""
 360        st.set_uv(ua); st.add_vertex(a)
 361        st.set_uv(ub); st.add_vertex(b)
 362        st.set_uv(uc); st.add_vertex(c)
 363
 364    # --- Dorsal (top) surfaces — normals must point UP (+Y) ---
 365    # Left wing
 366    tri(nose, mid_l, spine, (0.5, 0.0), (0.0, 0.3), (0.5, 0.4))
 367    tri(mid_l, left_tip, spine, (0.0, 0.3), (0.0, 0.8), (0.5, 0.4))
 368    tri(spine, left_tip, spine_rear, (0.5, 0.4), (0.0, 0.8), (0.5, 0.7))
 369    # Right wing
 370    tri(nose, spine, mid_r, (0.5, 0.0), (0.5, 0.4), (1.0, 0.3))
 371    tri(mid_r, spine, right_tip, (1.0, 0.3), (0.5, 0.4), (1.0, 0.8))
 372    tri(spine, spine_rear, right_tip, (0.5, 0.4), (0.5, 0.7), (1.0, 0.8))
 373
 374    # --- Cockpit canopy (raised ridge on top) — normals must point UP ---
 375    tri(nose, canopy_l, canopy_f, (0.5, 0.0), (0.4, 0.15), (0.5, 0.1))
 376    tri(nose, canopy_f, canopy_r2, (0.5, 0.0), (0.5, 0.1), (0.6, 0.15))
 377    tri(canopy_f, canopy_l, canopy_r, (0.5, 0.1), (0.4, 0.15), (0.5, 0.25))
 378    tri(canopy_f, canopy_r, canopy_r2, (0.5, 0.1), (0.5, 0.25), (0.6, 0.15))
 379    tri(canopy_l, spine, canopy_r, (0.4, 0.15), (0.5, 0.4), (0.5, 0.25))
 380    tri(canopy_r2, canopy_r, spine, (0.6, 0.15), (0.5, 0.25), (0.5, 0.4))
 381
 382    # --- Ventral (bottom) surfaces — normals must point DOWN (-Y) ---
 383    # Left belly
 384    tri(nose, keel_f, mid_l, (0.5, 0.0), (0.5, 0.15), (0.0, 0.3))
 385    tri(keel_f, keel_r, mid_l, (0.5, 0.15), (0.5, 0.6), (0.0, 0.3))
 386    tri(mid_l, keel_r, left_tip, (0.0, 0.3), (0.5, 0.6), (0.0, 0.8))
 387    # Right belly
 388    tri(nose, mid_r, keel_f, (0.5, 0.0), (1.0, 0.3), (0.5, 0.15))
 389    tri(keel_f, mid_r, keel_r, (0.5, 0.15), (1.0, 0.3), (0.5, 0.6))
 390    tri(mid_r, right_tip, keel_r, (1.0, 0.3), (1.0, 0.8), (0.5, 0.6))
 391
 392    # --- Tail section ---
 393    # Top rear closure (normals point UP/back)
 394    tri(spine_rear, left_tip, tail_l, (0.5, 0.7), (0.0, 0.8), (0.4, 0.9))
 395    tri(spine_rear, tail_r, right_tip, (0.5, 0.7), (0.6, 0.9), (1.0, 0.8))
 396    tri(spine_rear, tail_l, tail_r, (0.5, 0.7), (0.4, 0.9), (0.6, 0.9))
 397    # Bottom rear closure (normals point DOWN/back)
 398    tri(keel_r, tail_l, left_tip, (0.5, 0.6), (0.4, 0.9), (0.0, 0.8))
 399    tri(keel_r, right_tip, tail_r, (0.5, 0.6), (1.0, 0.8), (0.6, 0.9))
 400    tri(keel_r, tail_r, tail_l, (0.5, 0.6), (0.6, 0.9), (0.4, 0.9))
 401
 402    st.generate_normals()
 403    return st.commit()
 404
 405
 406# ===========================================================================
 407# Ship
 408# ===========================================================================
 409
 410
 411class Ship(Node3D):
 412    """Player-controlled sci-fi delta craft."""
 413
 414    def __init__(self, **kwargs):
 415        super().__init__(**kwargs)
 416        self._yaw = 0.0
 417        self._speed = 30.0
 418        self._min_speed = 15.0
 419        self._max_speed = 80.0
 420        self._target_speed = 30.0
 421        self._turn_rate = math.radians(25)
 422        self._bank = 0.0
 423        self._auto_fly = False
 424        self._auto_fly_time = 0.0
 425        self._engine_mat: Material | None = None
 426
 427    def ready(self):
 428        # Ship body — procedural hull texture with metallic PBR
 429        body_mat = Material(
 430            colour=(1.5, 1.5, 1.5),
 431            albedo_map=_get_ship_texture(),
 432            emissive_colour=(0.05, 0.08, 0.15, 0.3),
 433            metallic=0.15, roughness=0.4,
 434        )
 435        self.add_child(MeshInstance3D(
 436            mesh=_build_ship_mesh(), material=body_mat,
 437            scale=Vec3(3, 3, 3), name="Body",
 438        ))
 439
 440        # Engine glow — emissive sphere + point light at thruster
 441        self._engine_mat = Material(
 442            colour=(0.05, 0.1, 0.2),
 443            emissive_colour=(0.4, 0.6, 1.2, 1.5),
 444            metallic=0.0, roughness=1.0,
 445        )
 446        engine_pos = Vec3(0, 0.4, 5.6)
 447        self.add_child(
 448            MeshInstance3D(
 449                mesh=Mesh.sphere(radius=0.35, rings=6, segments=6),
 450                material=self._engine_mat,
 451                position=engine_pos,
 452                name="Engine",
 453            )
 454        )
 455        self._engine_light = self.add_child(
 456            PointLight3D(colour=(0.4, 0.6, 1.0), intensity=2.0, range=12.0, position=engine_pos, name="EngineLight")
 457        )
 458
 459    def update_ship(self, dt: float):
 460        """Called explicitly by PlanetExplorer BEFORE camera update — NOT via scene tree process()."""
 461        # Speed control (keyboard)
 462        if Input.is_action_pressed("speed_up"):
 463            self._target_speed = min(self._target_speed + 30.0 * dt, self._max_speed)
 464        if Input.is_action_pressed("slow_down"):
 465            self._target_speed = max(self._target_speed - 30.0 * dt, self._min_speed)
 466
 467        # Mouse/touch drag: horizontal = turn, vertical = speed
 468        dragging = Input.is_mouse_button_pressed(MouseButton.LEFT)
 469        mouse_turn = 0.0
 470        if dragging:
 471            delta = Input.get_mouse_delta()
 472            # Horizontal drag → turn (scaled to ~1.0 at 2px/frame)
 473            mouse_turn = max(-1.0, min(1.0, -delta.x / 2.0))
 474            # Vertical drag → speed (drag up = accelerate, down = decelerate)
 475            if abs(delta.y) > 1.0:
 476                self._target_speed += -delta.y * 0.25
 477                self._target_speed = max(self._min_speed, min(self._max_speed, self._target_speed))
 478
 479        self._speed = _lerp(self._speed, self._target_speed, min(1.0, 3.0 * dt))
 480
 481        # Turn (keyboard)
 482        turn = 0.0
 483        if Input.is_action_pressed("turn_left"):
 484            turn = 1.0
 485        if Input.is_action_pressed("turn_right"):
 486            turn = -1.0
 487
 488        # Merge mouse drag turn (additive, keyboard takes priority if both active)
 489        if mouse_turn != 0.0 and turn == 0.0:
 490            turn = mouse_turn
 491
 492        # Auto-fly: gentle S-curve
 493        if self._auto_fly:
 494            self._auto_fly_time += dt
 495            turn = math.sin(self._auto_fly_time * 0.3) * 0.6
 496
 497        self._yaw += turn * self._turn_rate * dt
 498
 499        # Banking visual (roll proportional to turn)
 500        target_bank = turn * math.radians(15)
 501        self._bank = _lerp(self._bank, target_bank, min(1.0, 5.0 * dt))
 502
 503        # Move forward
 504        fwd_x = -math.sin(self._yaw)
 505        fwd_z = -math.cos(self._yaw)
 506        self.position = Vec3(
 507            self.position.x + fwd_x * self._speed * dt,
 508            FLY_HEIGHT,
 509            self.position.z + fwd_z * self._speed * dt,
 510        )
 511
 512        # Apply rotation (bank + yaw)
 513        self.rotation = Quat.from_euler(0, self._yaw, self._bank)
 514
 515        # Engine glow scales with speed
 516        if self._engine_mat:
 517            t = (self._speed - self._min_speed) / max(self._max_speed - self._min_speed, 1.0)
 518            intensity = 0.3 + t * 2.0
 519            self._engine_mat.emissive_colour = (0.3, 0.5, 1.0, intensity)
 520            if self._engine_light:
 521                self._engine_light.intensity = 1.0 + t * 4.0
 522
 523    def toggle_auto_fly(self):
 524        self._auto_fly = not self._auto_fly
 525        self._auto_fly_time = 0.0
 526
 527
 528# ===========================================================================
 529# Terrain Manager
 530# ===========================================================================
 531
 532
 533class TerrainManager(Node3D):
 534    """Streams terrain chunks around the player position."""
 535
 536    def __init__(self, **kwargs):
 537        super().__init__(**kwargs)
 538        self._chunks: dict[tuple[int, int], list[MeshInstance3D]] = {}
 539        self._pending: deque[tuple[int, int]] = deque()
 540        self._last_cell: tuple[int, int] | None = None
 541
 542    def update_chunks(self, player_pos: Vec3):
 543        cx = int(math.floor(player_pos.x / CHUNK_SIZE))
 544        cz = int(math.floor(player_pos.z / CHUNK_SIZE))
 545        cell = (cx, cz)
 546        if cell == self._last_cell:
 547            return
 548        self._last_cell = cell
 549
 550        # Build zone: VIEW_RADIUS. Remove zone: REMOVE_RADIUS (larger buffer).
 551        # This avoids pop-out at edges — chunks stay visible longer.
 552        required = set()
 553        for dx in range(-VIEW_RADIUS, VIEW_RADIUS + 1):
 554            for dz in range(-VIEW_RADIUS, VIEW_RADIUS + 1):
 555                required.add((cx + dx, cz + dz))
 556        self._required = required
 557
 558        # Only remove chunks beyond the larger buffer radius
 559        to_remove = []
 560        for k in self._chunks:
 561            if abs(k[0] - cx) > REMOVE_RADIUS or abs(k[1] - cz) > REMOVE_RADIUS:
 562                to_remove.append(k)
 563        for k in to_remove:
 564            for mi in self._chunks[k]:
 565                mi.destroy()
 566            del self._chunks[k]
 567
 568        # Queue chunks we need but don't have yet, sorted nearest-first
 569        needed = [k for k in required if k not in self._chunks]
 570        needed.sort(key=lambda k: (k[0] - cx) ** 2 + (k[1] - cz) ** 2)
 571        self._pending = deque(needed)
 572
 573    def process(self, dt: float):
 574        # Build up to 2 chunks per frame — balances fill speed vs frame time
 575        for _ in range(min(2, len(self._pending))):
 576            if not self._pending:
 577                break
 578            key = self._pending.popleft()
 579            if key in self._chunks:
 580                continue
 581            if hasattr(self, "_required") and key not in self._required:
 582                continue
 583            center, meshes = _build_chunk(key[0], key[1])
 584            nodes = []
 585            for mesh, mat in meshes:
 586                nodes.append(self.add_child(MeshInstance3D(mesh=mesh, material=mat, position=center)))
 587            self._chunks[key] = nodes
 588
 589
 590# ===========================================================================
 591# Cloud chunk builder + manager
 592# ===========================================================================
 593
 594
 595def _build_cloud_chunk(cx: int, cz: int, time_offset: float) -> tuple[Vec3, Mesh] | None:
 596    """Build a cloud plane chunk. Returns (center, Mesh) or None if no coverage."""
 597    res = CLOUD_RES
 598    x0 = cx * CLOUD_CHUNK_SIZE
 599    z0 = cz * CLOUD_CHUNK_SIZE
 600    center_x = x0 + CLOUD_CHUNK_SIZE * 0.5
 601    center_z = z0 + CLOUD_CHUNK_SIZE * 0.5
 602
 603    xs_1d = np.linspace(x0, x0 + CLOUD_CHUNK_SIZE, res, dtype=np.float64)
 604    zs_1d = np.linspace(z0, z0 + CLOUD_CHUNK_SIZE, res, dtype=np.float64)
 605    xs_2d, zs_2d = np.meshgrid(xs_1d, zs_1d, indexing="ij")
 606
 607    # Sample noise with time offset for drift animation
 608    density = (
 609        _cloud_noise.get_noise_2d_array(xs_2d.ravel() + time_offset, zs_2d.ravel())
 610        .reshape(res, res)
 611        .astype(np.float32)
 612    )
 613    density = np.clip((density + 1.0) * 0.5, 0.0, 1.0)  # Map [-1,1] → [0,1]
 614
 615    cloud_mask = density > 0.3
 616    if not cloud_mask.any():
 617        return None
 618
 619    # Positions LOCAL to chunk center
 620    positions = np.empty((res * res, 3), dtype=np.float32)
 621    positions[:, 0] = (xs_2d.ravel() - center_x).astype(np.float32)
 622    positions[:, 1] = CLOUD_HEIGHT + density.ravel() * 5.0
 623    positions[:, 2] = (zs_2d.ravel() - center_z).astype(np.float32)
 624
 625    normals = np.zeros((res * res, 3), dtype=np.float32)
 626    normals[:, 1] = 1.0
 627
 628    # Build indices — only quads where at least one vertex has cloud
 629    rows, cols = np.meshgrid(np.arange(res - 1), np.arange(res - 1), indexing="ij")
 630    a = (rows * res + cols).ravel()
 631    b = a + res
 632    flat_mask = cloud_mask.ravel()
 633    quad_has_cloud = flat_mask[a] | flat_mask[a + 1] | flat_mask[b] | flat_mask[b + 1]
 634    a, b = a[quad_has_cloud], b[quad_has_cloud]
 635    if len(a) == 0:
 636        return None
 637
 638    indices = np.column_stack([a, a + 1, b, a + 1, b + 1, b]).ravel().astype(np.uint32)
 639    unique_verts, inverse = np.unique(indices, return_inverse=True)
 640    return Vec3(center_x, 0, center_z), Mesh(positions[unique_verts], inverse.astype(np.uint32), normals[unique_verts])
 641
 642
 643class CloudManager(Node3D):
 644    """Manages streaming cloud chunks with drift animation."""
 645
 646    def __init__(self, **kwargs):
 647        super().__init__(**kwargs)
 648        self._chunks: dict[tuple[int, int], MeshInstance3D | None] = {}
 649        self._last_cell: tuple[int, int] | None = None
 650        self._time_offset = 0.0
 651        self._cloud_mat = Material(colour=(1.0, 1.0, 1.0, 0.4), blend="alpha", double_sided=True)
 652        self._rebuild_timer = 0.0
 653        self._rebuild_queue: deque[tuple[int, int]] = deque()
 654        self._required: set[tuple[int, int]] = set()
 655
 656    def process(self, dt: float):
 657        self._time_offset += dt * 3.0
 658        self._rebuild_timer += dt
 659        # Incremental cloud rebuild: 2 chunks per frame max (avoids spike)
 660        for _ in range(min(2, len(self._rebuild_queue))):
 661            if not self._rebuild_queue:
 662                break
 663            k = self._rebuild_queue.popleft()
 664            if k not in self._required:
 665                continue
 666            if k in self._chunks and self._chunks[k]:
 667                self._chunks[k].destroy()
 668            result = _build_cloud_chunk(k[0], k[1], self._time_offset)
 669            if result:
 670                center, mesh = result
 671                self._chunks[k] = self.add_child(MeshInstance3D(mesh=mesh, material=self._cloud_mat, position=center))
 672            else:
 673                self._chunks[k] = None
 674
 675    def update_chunks(self, player_pos: Vec3):
 676        cx = int(math.floor(player_pos.x / CLOUD_CHUNK_SIZE))
 677        cz = int(math.floor(player_pos.z / CLOUD_CHUNK_SIZE))
 678        cell = (cx, cz)
 679
 680        need_rebuild = self._rebuild_timer > 8.0
 681        if cell == self._last_cell and not need_rebuild:
 682            return
 683        if need_rebuild:
 684            self._rebuild_timer = 0.0
 685        self._last_cell = cell
 686
 687        required = set()
 688        for dx in range(-CLOUD_VIEW_RADIUS, CLOUD_VIEW_RADIUS + 1):
 689            for dz in range(-CLOUD_VIEW_RADIUS, CLOUD_VIEW_RADIUS + 1):
 690                required.add((cx + dx, cz + dz))
 691        self._required = required
 692
 693        # Remove old
 694        for k in [k for k in self._chunks if k not in required]:
 695            if self._chunks[k]:
 696                self._chunks[k].destroy()
 697            del self._chunks[k]
 698
 699        # Queue new/rebuild (processed incrementally in process())
 700        needed = [k for k in required if k not in self._chunks or need_rebuild]
 701        self._rebuild_queue = deque(needed)
 702
 703    def update_colour(self, tint: tuple, opacity: float = 0.4):
 704        self._cloud_mat.colour = (*tint[:3], opacity)
 705
 706
 707# ===========================================================================
 708# Star Field (night-time dome of emissive points)
 709# ===========================================================================
 710
 711
 712class StarField(Node3D):
 713    """Night-time star dome using MultiMeshInstance3D."""
 714
 715    def __init__(self, **kwargs):
 716        super().__init__(**kwargs)
 717        self._mm_node: MultiMeshInstance3D | None = None
 718        self._star_mat: Material | None = None
 719
 720    def ready(self):
 721        self._star_mat = Material(colour=(2.0, 2.0, 2.5, 1.0), unlit=True)
 722        mm = MultiMesh(mesh=Mesh.sphere(radius=0.15, rings=4, segments=4), instance_count=STAR_COUNT)
 723
 724        rng = np.random.default_rng(99)
 725        for i in range(STAR_COUNT):
 726            phi = rng.uniform(0.1, math.pi * 0.45)  # Above horizon
 727            theta = rng.uniform(0, math.tau)
 728            r = 180.0
 729            pos = Vec3(r * math.sin(phi) * math.cos(theta), r * math.cos(phi), r * math.sin(phi) * math.sin(theta))
 730            mm.set_instance_transform(i, mat4_from_trs(pos, Quat(), Vec3(1)))
 731
 732        self._mm_node = self.add_child(MultiMeshInstance3D(multi_mesh=mm, material=self._star_mat, name="Stars"))
 733
 734    def update_visibility(self, sun_elevation: float, camera_pos: Vec3):
 735        if not self._star_mat:
 736            return
 737        alpha = max(0.0, min(1.0, -sun_elevation * 5.0))
 738        self._star_mat.colour = (2.0 * alpha, 2.0 * alpha, 2.5 * alpha, alpha)
 739        if self._mm_node:
 740            self._mm_node.position = camera_pos
 741
 742
 743# ===========================================================================
 744# Aurora Borealis (animated emissive curtains, night only)
 745# ===========================================================================
 746
 747
 748def _build_aurora_texture(seed: int = 0, tint: tuple[int, int, int] = (50, 240, 120)) -> bytes:
 749    """Generate a procedural aurora texture with vertical ray pillars.
 750
 751    256x256 RGBA: vertical rays of varying brightness/width with a colour
 752    gradient bottom-to-top (white base → tint → fade) and alpha fade at edges.
 753    """
 754    W, H = 256, 256
 755    img = np.zeros((H, W, 4), dtype=np.uint8)
 756    rng = random.Random(seed)
 757
 758    # Generate ~15-25 vertical ray pillars at random X positions with random widths
 759    rays: list[tuple[float, float, float]] = []  # (center_u, width, brightness)
 760    n_rays = rng.randint(15, 25)
 761    for _ in range(n_rays):
 762        cx = rng.uniform(0.0, 1.0)
 763        w = rng.uniform(0.01, 0.06)
 764        b = rng.uniform(0.4, 1.0)
 765        rays.append((cx, w, b))
 766
 767    for y in range(H):
 768        v = y / H  # 0=bottom, 1=top (image row 0 = top of texture but UV v=0 maps to bottom)
 769        v_flip = 1.0 - v  # flip so row 0 = top of aurora
 770
 771        # Vertical fade: strong in lower 2/3, fading at top and bottom
 772        v_alpha = min(1.0, v_flip * 4.0) * max(0.0, 1.0 - (v_flip - 0.6) * 2.5) if v_flip < 1.0 else 0.0
 773
 774        # Colour gradient: white at base, tint colour in middle, fading at top
 775        # tint is passed per-curtain for variety
 776        tr, tg, tb = tint
 777        if v_flip < 0.3:
 778            t = v_flip / 0.3
 779            r = int(200 * (1 - t) + tr * t)
 780            g = int(220 * (1 - t) + tg * t)
 781            b_c = int(220 * (1 - t) + tb * t)
 782        elif v_flip < 0.7:
 783            r, g, b_c = tr, tg, tb
 784        else:
 785            t = (v_flip - 0.7) / 0.3
 786            r = int(tr * (1 - t) + tr * 0.3 * t)
 787            g = int(tg * (1 - t) + tg * 0.2 * t)
 788            b_c = int(tb * (1 - t) + tb * 0.4 * t)
 789
 790        for x in range(W):
 791            u = x / W
 792            # Sum ray contributions at this pixel
 793            ray_intensity = 0.0
 794            for cx, rw, rb in rays:
 795                # Wrap-aware distance for tiling
 796                dx = min(abs(u - cx), abs(u - cx + 1.0), abs(u - cx - 1.0))
 797                if dx < rw:
 798                    # Gaussian-ish falloff within ray
 799                    t = dx / rw
 800                    ray_intensity += rb * (1.0 - t * t)
 801
 802            ray_intensity = min(1.0, ray_intensity)
 803            a = ray_intensity * v_alpha
 804
 805            if a > 0.001:
 806                img[y, x, 0] = min(255, int(r * a))
 807                img[y, x, 1] = min(255, int(g * a))
 808                img[y, x, 2] = min(255, int(b_c * a))
 809                img[y, x, 3] = min(255, int(a * 200))
 810
 811    return _rgba_to_png(img)
 812
 813
 814# Colour palette for aurora curtains: green, blue, pink/red, teal
 815_AURORA_TINTS = [
 816    (50, 240, 120),   # green
 817    (80, 160, 255),   # blue
 818    (240, 80, 140),   # pink/red
 819    (60, 220, 200),   # teal
 820]
 821
 822_AURORA_TEXTURES: dict[int, bytes] = {}
 823
 824
 825def _get_aurora_texture(index: int) -> bytes:
 826    if index not in _AURORA_TEXTURES:
 827        tint = _AURORA_TINTS[index % len(_AURORA_TINTS)]
 828        _AURORA_TEXTURES[index] = _build_aurora_texture(seed=200 + index, tint=tint)
 829    return _AURORA_TEXTURES[index]
 830
 831
 832class AuroraManager(Node3D):
 833    """Animated aurora curtains visible at night.
 834
 835    Curtains spread around the full sky, each with a unique procedural texture
 836    of vertical ray pillars in green/blue/pink/teal. Hidden during the day via
 837    node visibility. Individual curtains fade in and out independently and
 838    drift laterally.
 839    """
 840
 841    _NUM_CURTAINS = 8  # spread around 360°
 842
 843    def __init__(self, **kwargs):
 844        super().__init__(**kwargs)
 845        self._materials: list[Material] = []
 846        self._instances: list[MeshInstance3D] = []
 847        self._base_angles: list[float] = []
 848        self._time = 0.0
 849        self._was_visible = False
 850
 851    def ready(self):
 852        mesh = create_plane(size=(100.0, 40.0), subdivisions=6)
 853        for i in range(self._NUM_CURTAINS):
 854            tint = _AURORA_TINTS[i % len(_AURORA_TINTS)]
 855            tex = _get_aurora_texture(i)
 856            mat = Material(
 857                colour=(1.0, 1.0, 1.0, 0.0),
 858                albedo_map=tex,
 859                emissive_colour=(tint[0] / 255, tint[1] / 255, tint[2] / 255, 1.0),
 860                emissive_map=tex,
 861                blend="alpha",
 862                unlit=True,
 863                double_sided=True,
 864            )
 865            angle = math.tau * i / self._NUM_CURTAINS
 866            dist = 65.0 + (i % 3) * 8
 867            mi = self.add_child(
 868                MeshInstance3D(
 869                    mesh=mesh, material=mat,
 870                    position=Vec3(math.cos(angle) * dist, 15.0 + (i % 3) * 3, math.sin(angle) * dist),
 871                )
 872            )
 873            mi.rotation = Quat.from_euler(math.radians(90), angle + math.pi, 0)
 874            mi.visible = False
 875            self._materials.append(mat)
 876            self._instances.append(mi)
 877            self._base_angles.append(angle)
 878
 879    def update(self, dt: float, sun_elevation: float, camera_pos: Vec3):
 880        self._time += dt
 881        is_night = sun_elevation < -0.05
 882        night = max(0.0, min(1.0, (-sun_elevation - 0.05) * 8.0))
 883
 884        # Hide the entire aurora node tree during the day
 885        self.visible = is_night
 886        self.position = camera_pos
 887        if not is_night:
 888            return
 889
 890        for i, (mat, mi) in enumerate(zip(self._materials, self._instances)):
 891            tint = _AURORA_TINTS[i % len(_AURORA_TINTS)]
 892            tr, tg, tb = tint[0] / 255, tint[1] / 255, tint[2] / 255
 893
 894            # Per-curtain appear/disappear — slow independent cycles
 895            visibility = max(0.0, math.sin(self._time * 0.12 + i * 1.7) * 0.6
 896                             + math.sin(self._time * 0.07 + i * 2.3) * 0.4)
 897            # Shimmer flicker
 898            shimmer = (0.5 + 0.3 * math.sin(self._time * 2.5 + i * 2.1)
 899                       + 0.2 * math.sin(self._time * 4.0 + i * 1.3))
 900
 901            # Hide curtains with near-zero visibility
 902            mi.visible = visibility >= 0.05
 903            if not mi.visible:
 904                continue
 905
 906            intensity = shimmer * visibility * night
 907            mat.emissive_colour = (tr * intensity, tg * intensity, tb * intensity, intensity)
 908            mat.colour = (tr * 0.5, tg * 0.5, tb * 0.5, night * visibility * 0.12)
 909
 910            # Lateral drift — slow angular sway
 911            angle = self._base_angles[i] + math.sin(self._time * 0.1 + i * 0.9) * 0.08
 912            dist = 65.0 + (i % 3) * 8
 913            mi.position = Vec3(math.cos(angle) * dist, 15.0 + (i % 3) * 3, math.sin(angle) * dist)
 914            mi.rotation = Quat.from_euler(math.radians(90), angle + math.pi, 0)
 915
 916
 917# ===========================================================================
 918# Meteor system (rare shooting stars → fireballs → surface explosions)
 919# ===========================================================================
 920
 921
 922class Meteor(Node3D):
 923    """Single meteor with 3-phase lifecycle."""
 924
 925    PHASE_STAR = 0
 926    PHASE_FIREBALL = 1
 927    PHASE_EXPLOSION = 2
 928
 929    def __init__(self, start_pos: Vec3, direction: Vec3, **kwargs):
 930        super().__init__(**kwargs)
 931        self._start = start_pos
 932        self._dir = direction
 933        self._phase = self.PHASE_STAR
 934        self._phase_time = 0.0
 935        self._sphere: MeshInstance3D | None = None
 936        self._mat: Material | None = None
 937        self._trail: ParticleEmitter | None = None
 938        self._light: PointLight3D | None = None
 939        self.done = False
 940
 941    def ready(self):
 942        self._mat = Material(colour=(1.0, 1.0, 1.0), emissive_colour=(6.0, 5.0, 2.0, 3.0))
 943        self._sphere = self.add_child(
 944            MeshInstance3D(mesh=Mesh.sphere(radius=0.3, rings=6, segments=6), material=self._mat)
 945        )
 946        self._trail = self.add_child(
 947            ParticleEmitter(
 948                amount=30,
 949                lifetime=0.6,
 950                emission_rate=25.0,
 951                initial_velocity=(0.0, 0.0, 0.0),
 952                velocity_spread=0.5,
 953                gravity=(0.0, -2.0, 0.0),
 954                start_colour=(1.0, 0.9, 0.5, 1.0),
 955                end_colour=(1.0, 0.3, 0.0, 0.0),
 956                start_scale=0.5,
 957                end_scale=0.0,
 958            )
 959        )
 960        # Point light — illuminates terrain/clouds below the meteor
 961        self._light = self.add_child(
 962            PointLight3D(colour=(1.0, 0.8, 0.4), intensity=3.0, range=40.0)
 963        )
 964        self.position = self._start
 965
 966    def process(self, dt: float):
 967        self._phase_time += dt
 968
 969        if self._phase == self.PHASE_STAR:
 970            # Shooting star — fast diagonal descent at high altitude
 971            speed = 120.0
 972            self.position = Vec3(
 973                self.position.x + self._dir.x * speed * dt,
 974                self.position.y - 40.0 * dt,
 975                self.position.z + self._dir.z * speed * dt,
 976            )
 977            # Light: bright white streak
 978            if self._light:
 979                self._light.colour = (1.0, 0.9, 0.6)
 980                self._light.intensity = 3.0
 981                self._light.range = 40.0
 982            if self._phase_time > 1.5 or self.position.y < 80.0:
 983                self._phase = self.PHASE_FIREBALL
 984                self._phase_time = 0.0
 985
 986        elif self._phase == self.PHASE_FIREBALL:
 987            # Fireball — growing, slowing, shift to orange
 988            speed = 60.0
 989            self.position = Vec3(
 990                self.position.x + self._dir.x * speed * dt,
 991                self.position.y - 30.0 * dt,
 992                self.position.z + self._dir.z * speed * dt,
 993            )
 994            t = min(1.0, self._phase_time / 2.0)
 995            if self._sphere:
 996                s = 0.3 + t * 1.5
 997                self._sphere.scale = Vec3(s, s, s)
 998            if self._mat:
 999                self._mat.emissive_colour = (4.0, 1.5 - t * 0.5, 0.3, 3.0 + t * 2.0)
1000            if self._trail:
1001                self._trail.start_colour = (1.0, 0.5, 0.1, 1.0)
1002                self._trail.end_colour = (0.5, 0.5, 0.5, 0.0)
1003            # Light: intensifies and shifts orange as fireball grows
1004            if self._light:
1005                self._light.colour = (1.0, 0.6 - t * 0.2, 0.2)
1006                self._light.intensity = 4.0 + t * 4.0
1007                self._light.range = 50.0 + t * 30.0
1008
1009            # Hit terrain
1010            terrain_h = _terrain_noise.get_noise_2d(self.position.x, self.position.z) * HEIGHT_SCALE
1011            if self.position.y <= max(terrain_h, WATER_LEVEL) + 2.0 or self._phase_time > 3.0:
1012                self._phase = self.PHASE_EXPLOSION
1013                self._phase_time = 0.0
1014                if self._trail:
1015                    self._trail.emitting = False
1016
1017        elif self._phase == self.PHASE_EXPLOSION:
1018            # Flash and fade
1019            t = self._phase_time
1020            if self._mat:
1021                flash = max(0.0, 1.0 - t * 2.0)
1022                self._mat.emissive_colour = (8.0 * flash, 4.0 * flash, 1.0 * flash, 5.0 * flash)
1023            if self._sphere:
1024                s = 1.8 + t * 3.0
1025                self._sphere.scale = Vec3(s, s, s)
1026            # Light: bright flash then rapid fade
1027            if self._light:
1028                flash = max(0.0, 1.0 - t * 2.0)
1029                self._light.colour = (1.0, 0.7 * flash, 0.3 * flash)
1030                self._light.intensity = 12.0 * flash
1031                self._light.range = 80.0 * flash
1032            if t > 1.0:
1033                self.done = True
1034
1035
1036class MeteorManager(Node3D):
1037    """Spawns rare meteors every 15-30 seconds. Max 2 active."""
1038
1039    def __init__(self, **kwargs):
1040        super().__init__(**kwargs)
1041        self._timer = random.uniform(10.0, 20.0)
1042        self._meteors: list[Meteor] = []
1043
1044    def process(self, dt: float):
1045        self._timer -= dt
1046        if self._timer <= 0 and len(self._meteors) < 2:
1047            self._spawn_meteor()
1048            self._timer = random.uniform(15.0, 30.0)
1049
1050        # Clean up finished meteors
1051        for m in self._meteors[:]:
1052            if m.done:
1053                m.destroy()
1054                self._meteors.remove(m)
1055
1056    def _spawn_meteor(self):
1057        root = self.parent
1058        ship = getattr(root, "_ship", None) if root else None
1059        if not ship:
1060            return
1061        offset_angle = random.uniform(-0.5, 0.5)
1062        yaw = ship._yaw + offset_angle
1063        dist = random.uniform(200, 400)
1064        start = Vec3(
1065            ship.position.x - math.sin(yaw) * dist,
1066            150.0 + random.uniform(0, 30),
1067            ship.position.z - math.cos(yaw) * dist,
1068        )
1069        direction = Vec3(random.uniform(-0.3, 0.3), 0, random.uniform(-0.3, 0.3))
1070        meteor = Meteor(start, direction)
1071        self.add_child(meteor)
1072        self._meteors.append(meteor)
1073
1074
1075# ===========================================================================
1076# Day/Night Cycle Keyframes
1077# ===========================================================================
1078# time_of_day: 0.0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset
1079
1080SKY_TOP_KEYS = [
1081    (0.00, (0.02, 0.02, 0.10, 1.0)),
1082    (0.20, (0.02, 0.02, 0.10, 1.0)),
1083    (0.25, (0.45, 0.22, 0.10, 1.0)),
1084    (0.30, (0.18, 0.35, 0.72, 1.0)),
1085    (0.50, (0.18, 0.35, 0.72, 1.0)),
1086    (0.70, (0.18, 0.35, 0.72, 1.0)),
1087    (0.75, (0.65, 0.28, 0.08, 1.0)),
1088    (0.80, (0.02, 0.02, 0.10, 1.0)),
1089    (1.00, (0.02, 0.02, 0.10, 1.0)),
1090]
1091
1092SKY_BOTTOM_KEYS = [
1093    (0.00, (0.01, 0.01, 0.05, 1.0)),
1094    (0.20, (0.01, 0.01, 0.05, 1.0)),
1095    (0.25, (0.60, 0.30, 0.10, 1.0)),
1096    (0.30, (0.35, 0.50, 0.72, 1.0)),
1097    (0.50, (0.42, 0.55, 0.78, 1.0)),
1098    (0.70, (0.35, 0.50, 0.72, 1.0)),
1099    (0.75, (0.65, 0.35, 0.12, 1.0)),
1100    (0.80, (0.01, 0.01, 0.05, 1.0)),
1101    (1.00, (0.01, 0.01, 0.05, 1.0)),
1102]
1103
1104SUN_COLOUR_KEYS = [
1105    (0.25, (1.0, 0.4, 0.15)),
1106    (0.35, (1.0, 0.95, 0.9)),
1107    (0.50, (1.0, 0.98, 0.95)),
1108    (0.65, (1.0, 0.95, 0.9)),
1109    (0.75, (1.0, 0.4, 0.15)),
1110]
1111
1112SUN_INTENSITY_KEYS = [
1113    (0.20, 0.0),
1114    (0.25, 0.3),
1115    (0.30, 0.9),
1116    (0.50, 1.1),
1117    (0.70, 0.9),
1118    (0.75, 0.3),
1119    (0.80, 0.0),
1120]
1121
1122EXPOSURE_KEYS = [
1123    (0.00, 0.5),
1124    (0.20, 0.5),
1125    (0.25, 0.8),
1126    (0.30, 0.75),
1127    (0.50, 0.7),
1128    (0.70, 0.75),
1129    (0.75, 0.9),
1130    (0.80, 0.5),
1131    (1.00, 0.5),
1132]
1133
1134BLOOM_THRESHOLD_KEYS = [
1135    (0.00, 0.8),
1136    (0.25, 0.5),
1137    (0.30, 1.0),
1138    (0.70, 1.0),
1139    (0.75, 0.5),
1140    (0.80, 0.8),
1141    (1.00, 0.8),
1142]
1143
1144BLOOM_INTENSITY_KEYS = [
1145    (0.00, 0.7),
1146    (0.25, 1.0),
1147    (0.30, 0.5),
1148    (0.70, 0.5),
1149    (0.75, 1.0),
1150    (0.80, 0.7),
1151    (1.00, 0.7),
1152]
1153
1154VIGNETTE_KEYS = [
1155    (0.00, 0.6),
1156    (0.25, 0.4),
1157    (0.30, 0.3),
1158    (0.70, 0.3),
1159    (0.75, 0.4),
1160    (0.80, 0.6),
1161    (1.00, 0.6),
1162]
1163
1164AMBIENT_KEYS = [
1165    (0.00, (0.02, 0.02, 0.06, 1.0)),
1166    (0.25, (0.06, 0.04, 0.03, 1.0)),
1167    (0.30, (0.08, 0.07, 0.06, 1.0)),
1168    (0.50, (0.10, 0.09, 0.08, 1.0)),
1169    (0.70, (0.08, 0.07, 0.06, 1.0)),
1170    (0.75, (0.06, 0.04, 0.03, 1.0)),
1171    (0.80, (0.02, 0.02, 0.06, 1.0)),
1172    (1.00, (0.02, 0.02, 0.06, 1.0)),
1173]
1174
1175
1176# ===========================================================================
1177# PlanetExplorer — Root Scene
1178# ===========================================================================
1179
1180
1181class PlanetExplorer(Node3D):
1182    """Root scene for the planet flyover demo."""
1183
1184    def __init__(self, **kwargs):
1185        super().__init__(**kwargs)
1186        self._time_of_day = 0.22  # Start just before dawn
1187        self._time_speed = 1.0
1188        self._time_speed_idx = 0
1189        self._time_speeds = [1.0, 4.0, 16.0]
1190
1191        # Storm weather cycle: intensity ramps up and down over time
1192        self._storm_intensity = 0.0  # 0.0 = clear, 1.0 = heavy storm
1193        self._storm_phase = 0.0  # cycles 0→2π
1194        self._storm_speed = 0.04  # ~160s full cycle
1195
1196        # Lightning state (frequency driven by storm intensity)
1197        self._lightning_timer = random.uniform(20.0, 40.0)
1198        self._lightning_flash = 0.0
1199        self._lightning_double = False
1200
1201        # Node references
1202        self._ship: Ship | None = None
1203        self._camera: Camera3D | None = None
1204        self._sun: DirectionalLight3D | None = None
1205        self._env: WorldEnvironment | None = None
1206        self._terrain: TerrainManager | None = None
1207        self._clouds: CloudManager | None = None
1208        self._water: MeshInstance3D | None = None
1209        self._sun_disc: MeshInstance3D | None = None
1210        self._moon_disc: MeshInstance3D | None = None
1211        self._sun_disc_mat: Material | None = None
1212        self._moon_disc_mat: Material | None = None
1213        self._stars: StarField | None = None
1214        self._aurora: AuroraManager | None = None
1215        self._meteors: MeteorManager | None = None
1216        self._hud_text: str = ""
1217        self._look_target: Vec3 | None = None
1218
1219    def ready(self):
1220        # Input actions — must be registered here (not main()) so web export works
1221        InputMap.add_action("speed_up", [Key.UP])
1222        InputMap.add_action("slow_down", [Key.DOWN])
1223        InputMap.add_action("turn_left", [Key.LEFT])
1224        InputMap.add_action("turn_right", [Key.RIGHT])
1225
1226        # Camera — start near the ship, far plane large enough for chunk grid
1227        self._camera = self.add_child(Camera3D(position=Vec3(0, FLY_HEIGHT + 8, 15), fov=65, far=1200.0))
1228
1229        # Directional sun light
1230        self._sun = self.add_child(DirectionalLight3D(colour=(1.0, 0.95, 0.9), intensity=1.4, name="Sun"))
1231
1232        # WorldEnvironment — fog, bloom, tonemap, vignette, film effects
1233        self._env = self.add_child(WorldEnvironment())
1234        self._env.fog_enabled = True
1235        self._env.fog_colour = (0.7, 0.8, 1.0, 1.0)
1236        self._env.fog_density = 0.003
1237        self._env.fog_mode = "exponential"
1238        self._env.bloom_enabled = True
1239        self._env.bloom_threshold = 1.0
1240        self._env.bloom_intensity = 0.5
1241        self._env.tonemap_mode = "aces"
1242        self._env.tonemap_exposure = 1.0
1243        self._env.vignette_enabled = False
1244        self._env.film_grain_enabled = True
1245        self._env.film_grain_intensity = 0.02
1246        self._env.chromatic_aberration_enabled = True
1247        self._env.chromatic_aberration_intensity = 0.002
1248        self._env.sky_mode = "colour"
1249
1250        # Ship
1251        self._ship = self.add_child(Ship(name="Ship"))
1252
1253        # Terrain
1254        self._terrain = self.add_child(TerrainManager(name="Terrain"))
1255
1256        # Water plane
1257        water_mat = Material(colour=(0.08, 0.25, 0.55, 0.65), blend="alpha", metallic=0.3, roughness=0.2)
1258        water_size = (REMOVE_RADIUS * 2 + 1) * CHUNK_SIZE  # Cover the full chunk grid
1259        self._water = self.add_child(
1260            MeshInstance3D(mesh=create_plane(size=water_size, subdivisions=1), material=water_mat, position=Vec3(0, WATER_LEVEL, 0))
1261        )
1262
1263        # Clouds
1264        self._clouds = self.add_child(CloudManager(name="Clouds"))
1265
1266        # Sun disc — HDR emissive sphere, bloom creates natural halo
1267        self._sun_disc_mat = Material(
1268            colour=(1.0, 0.9, 0.5),
1269            emissive_colour=(8.0, 6.0, 2.0, 4.0),
1270        )
1271        self._sun_disc = self.add_child(
1272            MeshInstance3D(mesh=Mesh.sphere(radius=3.0, rings=12, segments=12), material=self._sun_disc_mat)
1273        )
1274
1275        # Moon disc
1276        self._moon_disc_mat = Material(
1277            colour=(0.8, 0.85, 0.9),
1278            emissive_colour=(2.0, 2.2, 2.5, 2.0),
1279        )
1280        self._moon_disc = self.add_child(
1281            MeshInstance3D(mesh=Mesh.sphere(radius=1.5, rings=10, segments=10), material=self._moon_disc_mat)
1282        )
1283
1284        # Stars
1285        self._stars = self.add_child(StarField(name="Stars"))
1286
1287        # Aurora
1288        self._aurora = self.add_child(AuroraManager(name="Aurora"))
1289
1290        # Meteors
1291        self._meteors = self.add_child(MeteorManager(name="Meteors"))
1292
1293        # HUD overlay — use draw_text() for cross-backend compatibility
1294
1295    def process(self, dt: float):
1296        if not self._ship or not self._camera:
1297            return
1298
1299        # Clamp dt globally — prevents frame-spike lurches during chunk builds
1300        dt = min(dt, 1.0 / 30.0)
1301
1302        # Input
1303        if Input.is_key_just_pressed(Key.SPACE):
1304            self._ship.toggle_auto_fly()
1305        if Input.is_key_just_pressed(Key.T):
1306            self._time_speed_idx = (self._time_speed_idx + 1) % len(self._time_speeds)
1307            self._time_speed = self._time_speeds[self._time_speed_idx]
1308        if Input.is_key_just_pressed(Key.ESCAPE):
1309            self.tree.root = None
1310            return
1311
1312        # Advance time of day
1313        self._time_of_day = (self._time_of_day + dt / DAY_CYCLE_SECONDS * self._time_speed) % 1.0
1314
1315        # Storm weather cycle — slow sinusoidal with sharp onset
1316        self._storm_phase = (self._storm_phase + dt * self._storm_speed) % math.tau
1317        raw = math.sin(self._storm_phase)
1318        # Only positive half = storm, sharpen onset with pow
1319        self._storm_intensity = max(0.0, raw) ** 1.5
1320
1321        # Ship FIRST, then camera — same dt, guaranteed ordering
1322        self._ship.update_ship(dt)
1323        self._update_camera(dt)
1324
1325        # Day/night, terrain, clouds, storm effects
1326        self._update_day_night(dt)
1327        self._terrain.update_chunks(self._ship.position)
1328        self._clouds.update_chunks(self._ship.position)
1329        self._update_lightning(dt)
1330        self._update_hud()
1331
1332        # Re-centre water plane on player
1333        if self._water:
1334            self._water.position = Vec3(self._ship.position.x, WATER_LEVEL, self._ship.position.z)
1335
1336    # --- Camera follow ---
1337
1338    def _update_camera(self, dt: float):
1339        ship = self._ship
1340        cam = self._camera
1341
1342        # Position behind and above ship — chase cam, ship at lower 1/3 of screen
1343        fwd_x = -math.sin(ship._yaw)
1344        fwd_z = -math.cos(ship._yaw)
1345        target = Vec3(
1346            ship.position.x - fwd_x * 15.0,
1347            ship.position.y + 8.0,
1348            ship.position.z - fwd_z * 15.0,
1349        )
1350
1351        # Smooth lerp follow — position
1352        t = min(1.0, 4.0 * dt)
1353        cam.position = Vec3(
1354            _lerp(cam.position.x, target.x, t),
1355            _lerp(cam.position.y, target.y, t),
1356            _lerp(cam.position.z, target.z, t),
1357        )
1358
1359        # Smooth lerp follow — look target (ship at lower 1/3: look well ahead and below)
1360        raw_look = Vec3(ship.position.x + fwd_x * 30.0, ship.position.y - 4.0, ship.position.z + fwd_z * 30.0)
1361        if self._look_target is None:
1362            self._look_target = raw_look
1363        lt = min(1.0, 6.0 * dt)
1364        self._look_target = Vec3(
1365            _lerp(self._look_target.x, raw_look.x, lt),
1366            _lerp(self._look_target.y, raw_look.y, lt),
1367            _lerp(self._look_target.z, raw_look.z, lt),
1368        )
1369        cam.look_at(self._look_target)
1370
1371    # --- Day/night cycle ---
1372
1373    def _update_day_night(self, dt: float):
1374        t = self._time_of_day
1375        env = self._env
1376
1377        # Sun angle: sun_elevation = sin((t - 0.25) * 2pi)
1378        angle = (t - 0.25) * math.tau
1379        sun_elev = math.sin(angle)
1380
1381        # Sun light direction (from sun toward scene)
1382        dx, dy, dz = -math.cos(angle), -sun_elev, -0.3
1383        mag = math.sqrt(dx * dx + dy * dy + dz * dz)
1384        light_dir = Vec3(dx / mag, dy / mag, dz / mag)
1385
1386        storm = self._storm_intensity
1387
1388        if self._sun:
1389            if sun_elev > -0.05:
1390                self._sun.direction = light_dir
1391                self._sun.colour = _sample_keyframes(SUN_COLOUR_KEYS, t)
1392                # Storm dims sunlight significantly
1393                base_intensity = _sample_keyframes(SUN_INTENSITY_KEYS, t)
1394                self._sun.intensity = base_intensity * (1.0 - storm * 0.7)
1395            else:
1396                self._sun.intensity = 0.0
1397
1398        # Sky colours — storm darkens the sky
1399        sky_top = _sample_keyframes(SKY_TOP_KEYS, t)
1400        sky_bottom = _sample_keyframes(SKY_BOTTOM_KEYS, t)
1401        storm_grey = (0.25, 0.27, 0.3)
1402        if storm > 0.01:
1403            sky_top = _lerp_colour(sky_top, storm_grey, storm * 0.6)
1404            sky_bottom = _lerp_colour(sky_bottom, storm_grey, storm * 0.5)
1405        env.sky_colour_top = sky_top
1406        env.sky_colour_bottom = sky_bottom
1407        env.fog_colour = sky_bottom
1408
1409        # Storm increases fog density for atmosphere
1410        base_fog_density = 0.003
1411        env.fog_density = base_fog_density + storm * 0.006
1412
1413        # Post-processing animation — storm reduces exposure slightly
1414        base_exposure = _sample_keyframes(EXPOSURE_KEYS, t)
1415        env.tonemap_exposure = base_exposure * (1.0 - storm * 0.25)
1416        env.bloom_threshold = _sample_keyframes(BLOOM_THRESHOLD_KEYS, t)
1417        env.bloom_intensity = _sample_keyframes(BLOOM_INTENSITY_KEYS, t)
1418        env.ambient_light_colour = _sample_keyframes(AMBIENT_KEYS, t)
1419
1420        # Sun/moon disc positions
1421        cam_pos = self._camera.position if self._camera else Vec3(0, 0, 0)
1422
1423        if self._sun_disc:
1424            sx, sy, sz = math.cos(angle), sun_elev, 0.3
1425            smag = math.sqrt(sx * sx + sy * sy + sz * sz)
1426            self._sun_disc.position = cam_pos + Vec3(sx / smag, sy / smag, sz / smag) * 200.0
1427            alpha = max(0.0, min(1.0, sun_elev * 5.0 + 0.5))
1428            self._sun_disc_mat.emissive_colour = (8.0 * alpha, 6.0 * alpha, 2.0 * alpha, 4.0 * alpha)
1429
1430        if self._moon_disc:
1431            moon_angle = angle + math.pi
1432            moon_elev = math.sin(moon_angle)
1433            mx, my, mz = math.cos(moon_angle), moon_elev, -0.3
1434            mmag = math.sqrt(mx * mx + my * my + mz * mz)
1435            self._moon_disc.position = cam_pos + Vec3(mx / mmag, my / mmag, mz / mmag) * 200.0
1436            alpha = max(0.0, min(1.0, moon_elev * 5.0 + 0.5))
1437            self._moon_disc_mat.emissive_colour = (2.0 * alpha, 2.2 * alpha, 2.5 * alpha, 2.0 * alpha)
1438
1439        # Stars
1440        if self._stars:
1441            self._stars.update_visibility(sun_elev, cam_pos)
1442
1443        # Aurora
1444        if self._aurora:
1445            self._aurora.update(dt, sun_elev, cam_pos)
1446
1447        # Cloud tint — storm darkens clouds from white to threatening dark grey
1448        if self._clouds:
1449            base_tint = _sample_keyframes(SKY_TOP_KEYS, t)
1450            clear_r, clear_g, clear_b = 0.7 + base_tint[0] * 0.3, 0.7 + base_tint[1] * 0.3, 0.7 + base_tint[2] * 0.3
1451            storm_r, storm_g, storm_b = 0.25, 0.25, 0.28
1452            sr = _lerp(clear_r, storm_r, storm)
1453            sg = _lerp(clear_g, storm_g, storm)
1454            sb = _lerp(clear_b, storm_b, storm)
1455            # Storm thickens clouds: opacity 0.4 (clear) → 0.85 (heavy storm)
1456            opacity = _lerp(0.4, 0.85, storm)
1457            self._clouds.update_colour((sr, sg, sb), opacity)
1458
1459    # --- Lightning flashes ---
1460
1461    def _update_lightning(self, dt: float):
1462        storm = self._storm_intensity
1463        self._lightning_timer -= dt
1464
1465        if self._lightning_flash > 0:
1466            self._lightning_flash -= dt
1467            if self._lightning_flash <= 0 and self._lightning_double:
1468                self._lightning_double = False
1469                self._lightning_flash = 0.15
1470                return
1471            if self._lightning_flash > 0 and self._env:
1472                base_exp = _sample_keyframes(EXPOSURE_KEYS, self._time_of_day)
1473                # Stronger flashes during storms
1474                flash_t = self._lightning_flash / 0.15
1475                flash_strength = 2.0 + storm * 3.0
1476                self._env.tonemap_exposure = base_exp + flash_t * flash_strength
1477
1478        if self._lightning_timer <= 0:
1479            # Storm increases lightning frequency: 20-40s (clear) → 3-8s (heavy storm)
1480            min_t = _lerp(20.0, 3.0, storm)
1481            max_t = _lerp(40.0, 8.0, storm)
1482            self._lightning_timer = random.uniform(min_t, max_t)
1483            # Only flash if there's at least some storm activity (or rare clear-sky bolts)
1484            if storm > 0.1 or random.random() < 0.15:
1485                self._lightning_flash = 0.15
1486                self._lightning_double = random.random() > 0.4
1487
1488    # --- HUD ---
1489
1490    def _update_hud(self):
1491        if not self._ship:
1492            return
1493        t = self._time_of_day
1494        if 0.20 <= t < 0.30:
1495            phase = "Sunrise"
1496        elif 0.30 <= t < 0.70:
1497            phase = "Day"
1498        elif 0.70 <= t < 0.80:
1499            phase = "Sunset"
1500        else:
1501            phase = "Night"
1502
1503        storm = self._storm_intensity
1504        weather = ""
1505        if storm > 0.6:
1506            weather = "  STORM"
1507        elif storm > 0.3:
1508            weather = "  Overcast"
1509        elif storm > 0.05:
1510            weather = "  Cloudy"
1511
1512        mult = f" [{self._time_speed:.0f}x]" if self._time_speed > 1 else ""
1513        auto = " [AUTO]" if self._ship._auto_fly else ""
1514        self._hud_text = f"Speed: {self._ship._speed:.0f}  Alt: {FLY_HEIGHT:.0f}  {phase}{weather}{mult}{auto}"
1515
1516    def draw(self, renderer):
1517        if self._hud_text:
1518            renderer.draw_text(self._hud_text, (10, 10), scale=1.5, colour=(1.0, 1.0, 1.0))
1519            renderer.draw_text("Arrows: fly  Space: auto  T: time speed", (10, 32), scale=1, colour=(0.55, 0.55, 0.55))
1520
1521
1522# ===========================================================================
1523# Main
1524# ===========================================================================
1525
1526
1527def main():
1528    app = App(title="Planet Explorer", width=1280, height=720)
1529    app.run(PlanetExplorer(name="PlanetExplorer"))
1530
1531
1532if __name__ == "__main__":
1533    main()