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