Planet Explorer

Infinite procedural flyover with terrain, weather, and a day/night cycle.

▶ Run in browser

Tags: 3d procedural camera terrain particles

An endless flight over procedurally generated terrain with biome colouring, water, and drifting clouds. A two-minute day/night cycle repaints the sky and moves the sun, moon, stars, and aurora curtains, while a slower storm cycle greys the clouds, thickens the fog, and steps lightning up from a rare flicker to a strike every few seconds. Steer with the arrow keys, drag with a mouse or finger, or hand over to auto-fly and watch it go by.

Engine features on show: Terrain and cloud chunks streamed around the player from FastNoiseLite, built with vectorised numpy (positions, normals, and indices, no per-vertex Python). WorldEnvironment post-processing animated every frame: fog, bloom, ACES tonemap exposure, ambient colour, plus lightning as a short exposure spike. A MultiMesh star dome of unlit instanced spheres, and eight emissive aurora curtains textured from numpy arrays handed straight to Material. ParticleEmitter meteor trails, each meteor carrying a PointLight3D through its shooting-star, fireball, and impact-flash phases.

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