Planet Explorer

Infinite procedural flyover with terrain and day/night cycle.

▶ Run in browser

Tags: 3d procedural camera terrain particles

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.

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

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