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