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