Planet Explorer

Play Demo

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