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_process(self, dt: float):
576 # Build up to 2 chunks per frame: balances fill speed vs frame time
577 for _ in range(min(2, len(self._pending))):
578 if not self._pending:
579 break
580 key = self._pending.popleft()
581 if key in self._chunks:
582 continue
583 if hasattr(self, "_required") and key not in self._required:
584 continue
585 center, meshes = _build_chunk(key[0], key[1])
586 nodes = []
587 for mesh, mat in meshes:
588 nodes.append(self.add_child(MeshInstance3D(mesh=mesh, material=mat, position=center)))
589 self._chunks[key] = nodes
590
591
592# ===========================================================================
593# Cloud chunk builder + manager
594# ===========================================================================
595
596
597def _build_cloud_chunk(cx: int, cz: int, time_offset: float) -> tuple[Vec3, Mesh] | None:
598 """Build a cloud plane chunk. Returns (center, Mesh) or None if no coverage."""
599 res = CLOUD_RES
600 x0 = cx * CLOUD_CHUNK_SIZE
601 z0 = cz * CLOUD_CHUNK_SIZE
602 center_x = x0 + CLOUD_CHUNK_SIZE * 0.5
603 center_z = z0 + CLOUD_CHUNK_SIZE * 0.5
604
605 xs_1d = np.linspace(x0, x0 + CLOUD_CHUNK_SIZE, res, dtype=np.float64)
606 zs_1d = np.linspace(z0, z0 + CLOUD_CHUNK_SIZE, res, dtype=np.float64)
607 xs_2d, zs_2d = np.meshgrid(xs_1d, zs_1d, indexing="ij")
608
609 # Sample noise with time offset for drift animation
610 density = (
611 _cloud_noise.get_noise_2d_array(xs_2d.ravel() + time_offset, zs_2d.ravel())
612 .reshape(res, res)
613 .astype(np.float32)
614 )
615 density = np.clip((density + 1.0) * 0.5, 0.0, 1.0) # Map [-1,1] → [0,1]
616
617 cloud_mask = density > 0.3
618 if not cloud_mask.any():
619 return None
620
621 # Positions LOCAL to chunk center
622 positions = np.empty((res * res, 3), dtype=np.float32)
623 positions[:, 0] = (xs_2d.ravel() - center_x).astype(np.float32)
624 positions[:, 1] = CLOUD_HEIGHT + density.ravel() * 5.0
625 positions[:, 2] = (zs_2d.ravel() - center_z).astype(np.float32)
626
627 normals = np.zeros((res * res, 3), dtype=np.float32)
628 normals[:, 1] = 1.0
629
630 # Build indices: only quads where at least one vertex has cloud
631 rows, cols = np.meshgrid(np.arange(res - 1), np.arange(res - 1), indexing="ij")
632 a = (rows * res + cols).ravel()
633 b = a + res
634 flat_mask = cloud_mask.ravel()
635 quad_has_cloud = flat_mask[a] | flat_mask[a + 1] | flat_mask[b] | flat_mask[b + 1]
636 a, b = a[quad_has_cloud], b[quad_has_cloud]
637 if len(a) == 0:
638 return None
639
640 indices = np.column_stack([a, a + 1, b, a + 1, b + 1, b]).ravel().astype(np.uint32)
641 unique_verts, inverse = np.unique(indices, return_inverse=True)
642 return Vec3(center_x, 0, center_z), Mesh(positions[unique_verts], inverse.astype(np.uint32), normals[unique_verts])
643
644
645class CloudManager(Node3D):
646 """Manages streaming cloud chunks with drift animation."""
647
648 def __init__(self, **kwargs):
649 super().__init__(**kwargs)
650 self._chunks: dict[tuple[int, int], MeshInstance3D | None] = {}
651 self._last_cell: tuple[int, int] | None = None
652 self._time_offset = 0.0
653 self._cloud_mat = Material(colour=(1.0, 1.0, 1.0, 0.4), blend="alpha", double_sided=True)
654 self._rebuild_timer = 0.0
655 self._rebuild_queue: deque[tuple[int, int]] = deque()
656 self._required: set[tuple[int, int]] = set()
657
658 def on_process(self, dt: float):
659 self._time_offset += dt * 3.0
660 self._rebuild_timer += dt
661 # Incremental cloud rebuild: 2 chunks per frame max (avoids spike)
662 for _ in range(min(2, len(self._rebuild_queue))):
663 if not self._rebuild_queue:
664 break
665 k = self._rebuild_queue.popleft()
666 if k not in self._required:
667 continue
668 if k in self._chunks and self._chunks[k]:
669 self._chunks[k].destroy()
670 result = _build_cloud_chunk(k[0], k[1], self._time_offset)
671 if result:
672 center, mesh = result
673 self._chunks[k] = self.add_child(MeshInstance3D(mesh=mesh, material=self._cloud_mat, position=center))
674 else:
675 self._chunks[k] = None
676
677 def update_chunks(self, player_pos: Vec3):
678 cx = int(math.floor(player_pos.x / CLOUD_CHUNK_SIZE))
679 cz = int(math.floor(player_pos.z / CLOUD_CHUNK_SIZE))
680 cell = (cx, cz)
681
682 need_rebuild = self._rebuild_timer > 8.0
683 if cell == self._last_cell and not need_rebuild:
684 return
685 if need_rebuild:
686 self._rebuild_timer = 0.0
687 self._last_cell = cell
688
689 required = set()
690 for dx in range(-CLOUD_VIEW_RADIUS, CLOUD_VIEW_RADIUS + 1):
691 for dz in range(-CLOUD_VIEW_RADIUS, CLOUD_VIEW_RADIUS + 1):
692 required.add((cx + dx, cz + dz))
693 self._required = required
694
695 # Remove old
696 for k in [k for k in self._chunks if k not in required]:
697 if self._chunks[k]:
698 self._chunks[k].destroy()
699 del self._chunks[k]
700
701 # Queue new/rebuild (processed incrementally in process())
702 needed = [k for k in required if k not in self._chunks or need_rebuild]
703 self._rebuild_queue = deque(needed)
704
705 def update_colour(self, tint: tuple, opacity: float = 0.4):
706 self._cloud_mat.colour = (*tint[:3], opacity)
707
708
709# ===========================================================================
710# Star Field (night-time dome of emissive points)
711# ===========================================================================
712
713
714class StarField(Node3D):
715 """Night-time star dome using MultiMeshInstance3D."""
716
717 def __init__(self, **kwargs):
718 super().__init__(**kwargs)
719 self._mm_node: MultiMeshInstance3D | None = None
720 self._star_mat: Material | None = None
721
722 def on_ready(self):
723 self._star_mat = Material(colour=(2.0, 2.0, 2.5, 1.0), unlit=True)
724 mm = MultiMesh(mesh=Mesh.sphere(radius=0.15, rings=4, segments=4), instance_count=STAR_COUNT)
725
726 rng = np.random.default_rng(99)
727 for i in range(STAR_COUNT):
728 phi = rng.uniform(0.1, math.pi * 0.45) # Above horizon
729 theta = rng.uniform(0, math.tau)
730 r = 180.0
731 pos = Vec3(r * math.sin(phi) * math.cos(theta), r * math.cos(phi), r * math.sin(phi) * math.sin(theta))
732 mm.set_instance_transform(i, mat4_from_trs(pos, Quat(), Vec3(1)))
733
734 self._mm_node = self.add_child(MultiMeshInstance3D(multi_mesh=mm, material=self._star_mat, name="Stars"))
735
736 def update_visibility(self, sun_elevation: float, camera_pos: Vec3):
737 if not self._star_mat:
738 return
739 alpha = max(0.0, min(1.0, -sun_elevation * 5.0))
740 self._star_mat.colour = (2.0 * alpha, 2.0 * alpha, 2.5 * alpha, alpha)
741 if self._mm_node:
742 self._mm_node.position = camera_pos
743
744
745# ===========================================================================
746# Aurora Borealis (animated emissive curtains, night only)
747# ===========================================================================
748
749
750def _build_aurora_texture(seed: int = 0, tint: tuple[int, int, int] = (50, 240, 120)) -> bytes:
751 """Generate a procedural aurora texture with vertical ray pillars.
752
753 256x256 RGBA: vertical rays of varying brightness/width with a colour
754 gradient bottom-to-top (white base → tint → fade) and alpha fade at edges.
755 """
756 W, H = 256, 256
757 img = np.zeros((H, W, 4), dtype=np.uint8)
758 rng = random.Random(seed)
759
760 # Generate ~15-25 vertical ray pillars at random X positions with random widths
761 rays: list[tuple[float, float, float]] = [] # (center_u, width, brightness)
762 n_rays = rng.randint(15, 25)
763 for _ in range(n_rays):
764 cx = rng.uniform(0.0, 1.0)
765 w = rng.uniform(0.01, 0.06)
766 b = rng.uniform(0.4, 1.0)
767 rays.append((cx, w, b))
768
769 for y in range(H):
770 v = y / H # 0=bottom, 1=top (image row 0 = top of texture but UV v=0 maps to bottom)
771 v_flip = 1.0 - v # flip so row 0 = top of aurora
772
773 # Vertical fade: strong in lower 2/3, fading at top and bottom
774 v_alpha = min(1.0, v_flip * 4.0) * max(0.0, 1.0 - (v_flip - 0.6) * 2.5) if v_flip < 1.0 else 0.0
775
776 # Colour gradient: white at base, tint colour in middle, fading at top
777 # tint is passed per-curtain for variety
778 tr, tg, tb = tint
779 if v_flip < 0.3:
780 t = v_flip / 0.3
781 r = int(200 * (1 - t) + tr * t)
782 g = int(220 * (1 - t) + tg * t)
783 b_c = int(220 * (1 - t) + tb * t)
784 elif v_flip < 0.7:
785 r, g, b_c = tr, tg, tb
786 else:
787 t = (v_flip - 0.7) / 0.3
788 r = int(tr * (1 - t) + tr * 0.3 * t)
789 g = int(tg * (1 - t) + tg * 0.2 * t)
790 b_c = int(tb * (1 - t) + tb * 0.4 * t)
791
792 for x in range(W):
793 u = x / W
794 # Sum ray contributions at this pixel
795 ray_intensity = 0.0
796 for cx, rw, rb in rays:
797 # Wrap-aware distance for tiling
798 dx = min(abs(u - cx), abs(u - cx + 1.0), abs(u - cx - 1.0))
799 if dx < rw:
800 # Gaussian-ish falloff within ray
801 t = dx / rw
802 ray_intensity += rb * (1.0 - t * t)
803
804 ray_intensity = min(1.0, ray_intensity)
805 a = ray_intensity * v_alpha
806
807 if a > 0.001:
808 img[y, x, 0] = min(255, int(r * a))
809 img[y, x, 1] = min(255, int(g * a))
810 img[y, x, 2] = min(255, int(b_c * a))
811 img[y, x, 3] = min(255, int(a * 200))
812
813 return _rgba_to_png(img)
814
815
816# Colour palette for aurora curtains: green, blue, pink/red, teal
817_AURORA_TINTS = [
818 (50, 240, 120), # green
819 (80, 160, 255), # blue
820 (240, 80, 140), # pink/red
821 (60, 220, 200), # teal
822]
823
824_AURORA_TEXTURES: dict[int, bytes] = {}
825
826
827def _get_aurora_texture(index: int) -> bytes:
828 if index not in _AURORA_TEXTURES:
829 tint = _AURORA_TINTS[index % len(_AURORA_TINTS)]
830 _AURORA_TEXTURES[index] = _build_aurora_texture(seed=200 + index, tint=tint)
831 return _AURORA_TEXTURES[index]
832
833
834class AuroraManager(Node3D):
835 """Animated aurora curtains visible at night.
836
837 Curtains spread around the full sky, each with a unique procedural texture
838 of vertical ray pillars in green/blue/pink/teal. Hidden during the day via
839 node visibility. Individual curtains fade in and out independently and
840 drift laterally.
841 """
842
843 _NUM_CURTAINS = 8 # spread around 360°
844
845 def __init__(self, **kwargs):
846 super().__init__(**kwargs)
847 self._materials: list[Material] = []
848 self._instances: list[MeshInstance3D] = []
849 self._base_angles: list[float] = []
850 self._time = 0.0
851 self._was_visible = False
852
853 def on_ready(self):
854 mesh = create_plane(size=(100.0, 40.0), subdivisions=6)
855 for i in range(self._NUM_CURTAINS):
856 tint = _AURORA_TINTS[i % len(_AURORA_TINTS)]
857 tex = _get_aurora_texture(i)
858 mat = Material(
859 colour=(1.0, 1.0, 1.0, 0.0),
860 albedo_map=tex,
861 emissive_colour=(tint[0] / 255, tint[1] / 255, tint[2] / 255, 1.0),
862 emissive_map=tex,
863 blend="alpha",
864 unlit=True,
865 double_sided=True,
866 )
867 angle = math.tau * i / self._NUM_CURTAINS
868 dist = 65.0 + (i % 3) * 8
869 mi = self.add_child(
870 MeshInstance3D(
871 mesh=mesh, material=mat,
872 position=Vec3(math.cos(angle) * dist, 15.0 + (i % 3) * 3, math.sin(angle) * dist),
873 )
874 )
875 mi.rotation = Quat.from_euler(math.radians(90), angle + math.pi, 0)
876 mi.visible = False
877 self._materials.append(mat)
878 self._instances.append(mi)
879 self._base_angles.append(angle)
880
881 def update(self, dt: float, sun_elevation: float, camera_pos: Vec3):
882 self._time += dt
883 is_night = sun_elevation < -0.05
884 night = max(0.0, min(1.0, (-sun_elevation - 0.05) * 8.0))
885
886 # Hide the entire aurora node tree during the day
887 self.visible = is_night
888 self.position = camera_pos
889 if not is_night:
890 return
891
892 for i, (mat, mi) in enumerate(zip(self._materials, self._instances)):
893 tint = _AURORA_TINTS[i % len(_AURORA_TINTS)]
894 tr, tg, tb = tint[0] / 255, tint[1] / 255, tint[2] / 255
895
896 # Per-curtain appear/disappear: slow independent cycles
897 visibility = max(0.0, math.sin(self._time * 0.12 + i * 1.7) * 0.6
898 + math.sin(self._time * 0.07 + i * 2.3) * 0.4)
899 # Shimmer flicker
900 shimmer = (0.5 + 0.3 * math.sin(self._time * 2.5 + i * 2.1)
901 + 0.2 * math.sin(self._time * 4.0 + i * 1.3))
902
903 # Hide curtains with near-zero visibility
904 mi.visible = visibility >= 0.05
905 if not mi.visible:
906 continue
907
908 intensity = shimmer * visibility * night
909 mat.emissive_colour = (tr * intensity, tg * intensity, tb * intensity, intensity)
910 mat.colour = (tr * 0.5, tg * 0.5, tb * 0.5, night * visibility * 0.12)
911
912 # Lateral drift: slow angular sway
913 angle = self._base_angles[i] + math.sin(self._time * 0.1 + i * 0.9) * 0.08
914 dist = 65.0 + (i % 3) * 8
915 mi.position = Vec3(math.cos(angle) * dist, 15.0 + (i % 3) * 3, math.sin(angle) * dist)
916 mi.rotation = Quat.from_euler(math.radians(90), angle + math.pi, 0)
917
918
919# ===========================================================================
920# Meteor system (rare shooting stars → fireballs → surface explosions)
921# ===========================================================================
922
923
924class Meteor(Node3D):
925 """Single meteor with 3-phase lifecycle."""
926
927 PHASE_STAR = 0
928 PHASE_FIREBALL = 1
929 PHASE_EXPLOSION = 2
930
931 def __init__(self, start_pos: Vec3, direction: Vec3, **kwargs):
932 super().__init__(**kwargs)
933 self._start = start_pos
934 self._dir = direction
935 self._phase = self.PHASE_STAR
936 self._phase_time = 0.0
937 self._sphere: MeshInstance3D | None = None
938 self._mat: Material | None = None
939 self._trail: ParticleEmitter | None = None
940 self._light: PointLight3D | None = None
941 self.done = False
942
943 def on_ready(self):
944 self._mat = Material(colour=(1.0, 1.0, 1.0), emissive_colour=(6.0, 5.0, 2.0, 3.0))
945 self._sphere = self.add_child(
946 MeshInstance3D(mesh=Mesh.sphere(radius=0.3, rings=6, segments=6), material=self._mat)
947 )
948 self._trail = self.add_child(
949 ParticleEmitter(
950 amount=30,
951 lifetime=0.6,
952 emission_rate=25.0,
953 initial_velocity=(0.0, 0.0, 0.0),
954 velocity_spread=0.5,
955 gravity=(0.0, -2.0, 0.0),
956 start_colour=(1.0, 0.9, 0.5, 1.0),
957 end_colour=(1.0, 0.3, 0.0, 0.0),
958 start_scale=0.5,
959 end_scale=0.0,
960 )
961 )
962 # Point light: illuminates terrain/clouds below the meteor
963 self._light = self.add_child(
964 PointLight3D(colour=(1.0, 0.8, 0.4), intensity=3.0, range=40.0)
965 )
966 self.position = self._start
967
968 def on_process(self, dt: float):
969 self._phase_time += dt
970
971 if self._phase == self.PHASE_STAR:
972 # Shooting star: fast diagonal descent at high altitude
973 speed = 120.0
974 self.position = Vec3(
975 self.position.x + self._dir.x * speed * dt,
976 self.position.y - 40.0 * dt,
977 self.position.z + self._dir.z * speed * dt,
978 )
979 # Light: bright white streak
980 if self._light:
981 self._light.colour = (1.0, 0.9, 0.6)
982 self._light.intensity = 3.0
983 self._light.range = 40.0
984 if self._phase_time > 1.5 or self.position.y < 80.0:
985 self._phase = self.PHASE_FIREBALL
986 self._phase_time = 0.0
987
988 elif self._phase == self.PHASE_FIREBALL:
989 # Fireball: growing, slowing, shift to orange
990 speed = 60.0
991 self.position = Vec3(
992 self.position.x + self._dir.x * speed * dt,
993 self.position.y - 30.0 * dt,
994 self.position.z + self._dir.z * speed * dt,
995 )
996 t = min(1.0, self._phase_time / 2.0)
997 if self._sphere:
998 s = 0.3 + t * 1.5
999 self._sphere.scale = Vec3(s, s, s)
1000 if self._mat:
1001 self._mat.emissive_colour = (4.0, 1.5 - t * 0.5, 0.3, 3.0 + t * 2.0)
1002 if self._trail:
1003 self._trail.start_colour = (1.0, 0.5, 0.1, 1.0)
1004 self._trail.end_colour = (0.5, 0.5, 0.5, 0.0)
1005 # Light: intensifies and shifts orange as fireball grows
1006 if self._light:
1007 self._light.colour = (1.0, 0.6 - t * 0.2, 0.2)
1008 self._light.intensity = 4.0 + t * 4.0
1009 self._light.range = 50.0 + t * 30.0
1010
1011 # Hit terrain
1012 terrain_h = _terrain_noise.get_noise_2d(self.position.x, self.position.z) * HEIGHT_SCALE
1013 if self.position.y <= max(terrain_h, WATER_LEVEL) + 2.0 or self._phase_time > 3.0:
1014 self._phase = self.PHASE_EXPLOSION
1015 self._phase_time = 0.0
1016 if self._trail:
1017 self._trail.emitting = False
1018
1019 elif self._phase == self.PHASE_EXPLOSION:
1020 # Flash and fade
1021 t = self._phase_time
1022 if self._mat:
1023 flash = max(0.0, 1.0 - t * 2.0)
1024 self._mat.emissive_colour = (8.0 * flash, 4.0 * flash, 1.0 * flash, 5.0 * flash)
1025 if self._sphere:
1026 s = 1.8 + t * 3.0
1027 self._sphere.scale = Vec3(s, s, s)
1028 # Light: bright flash then rapid fade
1029 if self._light:
1030 flash = max(0.0, 1.0 - t * 2.0)
1031 self._light.colour = (1.0, 0.7 * flash, 0.3 * flash)
1032 self._light.intensity = 12.0 * flash
1033 self._light.range = 80.0 * flash
1034 if t > 1.0:
1035 self.done = True
1036
1037
1038class MeteorManager(Node3D):
1039 """Spawns rare meteors every 15-30 seconds. Max 2 active."""
1040
1041 def __init__(self, **kwargs):
1042 super().__init__(**kwargs)
1043 self._timer = random.uniform(10.0, 20.0)
1044 self._meteors: list[Meteor] = []
1045
1046 def on_process(self, dt: float):
1047 self._timer -= dt
1048 if self._timer <= 0 and len(self._meteors) < 2:
1049 self._spawn_meteor()
1050 self._timer = random.uniform(15.0, 30.0)
1051
1052 # Clean up finished meteors
1053 for m in self._meteors[:]:
1054 if m.done:
1055 m.destroy()
1056 self._meteors.remove(m)
1057
1058 def _spawn_meteor(self):
1059 root = self.parent
1060 ship = getattr(root, "_ship", None) if root else None
1061 if not ship:
1062 return
1063 offset_angle = random.uniform(-0.5, 0.5)
1064 yaw = ship._yaw + offset_angle
1065 dist = random.uniform(200, 400)
1066 start = Vec3(
1067 ship.position.x - math.sin(yaw) * dist,
1068 150.0 + random.uniform(0, 30),
1069 ship.position.z - math.cos(yaw) * dist,
1070 )
1071 direction = Vec3(random.uniform(-0.3, 0.3), 0, random.uniform(-0.3, 0.3))
1072 meteor = Meteor(start, direction)
1073 self.add_child(meteor)
1074 self._meteors.append(meteor)
1075
1076
1077# ===========================================================================
1078# Day/Night Cycle Keyframes
1079# ===========================================================================
1080# time_of_day: 0.0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset
1081
1082SKY_TOP_KEYS = [
1083 (0.00, (0.02, 0.02, 0.10, 1.0)),
1084 (0.20, (0.02, 0.02, 0.10, 1.0)),
1085 (0.25, (0.45, 0.22, 0.10, 1.0)),
1086 (0.30, (0.18, 0.35, 0.72, 1.0)),
1087 (0.50, (0.18, 0.35, 0.72, 1.0)),
1088 (0.70, (0.18, 0.35, 0.72, 1.0)),
1089 (0.75, (0.65, 0.28, 0.08, 1.0)),
1090 (0.80, (0.02, 0.02, 0.10, 1.0)),
1091 (1.00, (0.02, 0.02, 0.10, 1.0)),
1092]
1093
1094SKY_BOTTOM_KEYS = [
1095 (0.00, (0.01, 0.01, 0.05, 1.0)),
1096 (0.20, (0.01, 0.01, 0.05, 1.0)),
1097 (0.25, (0.60, 0.30, 0.10, 1.0)),
1098 (0.30, (0.35, 0.50, 0.72, 1.0)),
1099 (0.50, (0.42, 0.55, 0.78, 1.0)),
1100 (0.70, (0.35, 0.50, 0.72, 1.0)),
1101 (0.75, (0.65, 0.35, 0.12, 1.0)),
1102 (0.80, (0.01, 0.01, 0.05, 1.0)),
1103 (1.00, (0.01, 0.01, 0.05, 1.0)),
1104]
1105
1106SUN_COLOUR_KEYS = [
1107 (0.25, (1.0, 0.4, 0.15)),
1108 (0.35, (1.0, 0.95, 0.9)),
1109 (0.50, (1.0, 0.98, 0.95)),
1110 (0.65, (1.0, 0.95, 0.9)),
1111 (0.75, (1.0, 0.4, 0.15)),
1112]
1113
1114SUN_INTENSITY_KEYS = [
1115 (0.20, 0.0),
1116 (0.25, 0.3),
1117 (0.30, 0.9),
1118 (0.50, 1.1),
1119 (0.70, 0.9),
1120 (0.75, 0.3),
1121 (0.80, 0.0),
1122]
1123
1124EXPOSURE_KEYS = [
1125 (0.00, 0.5),
1126 (0.20, 0.5),
1127 (0.25, 0.8),
1128 (0.30, 0.75),
1129 (0.50, 0.7),
1130 (0.70, 0.75),
1131 (0.75, 0.9),
1132 (0.80, 0.5),
1133 (1.00, 0.5),
1134]
1135
1136BLOOM_THRESHOLD_KEYS = [
1137 (0.00, 0.8),
1138 (0.25, 0.5),
1139 (0.30, 1.0),
1140 (0.70, 1.0),
1141 (0.75, 0.5),
1142 (0.80, 0.8),
1143 (1.00, 0.8),
1144]
1145
1146BLOOM_INTENSITY_KEYS = [
1147 (0.00, 0.7),
1148 (0.25, 1.0),
1149 (0.30, 0.5),
1150 (0.70, 0.5),
1151 (0.75, 1.0),
1152 (0.80, 0.7),
1153 (1.00, 0.7),
1154]
1155
1156VIGNETTE_KEYS = [
1157 (0.00, 0.6),
1158 (0.25, 0.4),
1159 (0.30, 0.3),
1160 (0.70, 0.3),
1161 (0.75, 0.4),
1162 (0.80, 0.6),
1163 (1.00, 0.6),
1164]
1165
1166AMBIENT_KEYS = [
1167 (0.00, (0.02, 0.02, 0.06, 1.0)),
1168 (0.25, (0.06, 0.04, 0.03, 1.0)),
1169 (0.30, (0.08, 0.07, 0.06, 1.0)),
1170 (0.50, (0.10, 0.09, 0.08, 1.0)),
1171 (0.70, (0.08, 0.07, 0.06, 1.0)),
1172 (0.75, (0.06, 0.04, 0.03, 1.0)),
1173 (0.80, (0.02, 0.02, 0.06, 1.0)),
1174 (1.00, (0.02, 0.02, 0.06, 1.0)),
1175]
1176
1177
1178# ===========================================================================
1179# PlanetExplorer: Root Scene
1180# ===========================================================================
1181
1182
1183class PlanetExplorer(Node3D):
1184 """Root scene for the planet flyover demo."""
1185
1186 def __init__(self, **kwargs):
1187 super().__init__(**kwargs)
1188 self._time_of_day = 0.22 # Start just before dawn
1189 self._time_speed = 1.0
1190 self._time_speed_idx = 0
1191 self._time_speeds = [1.0, 4.0, 16.0]
1192
1193 # Storm weather cycle: intensity ramps up and down over time
1194 self._storm_intensity = 0.0 # 0.0 = clear, 1.0 = heavy storm
1195 self._storm_phase = 0.0 # cycles 0→2π
1196 self._storm_speed = 0.04 # ~160s full cycle
1197
1198 # Lightning state (frequency driven by storm intensity)
1199 self._lightning_timer = random.uniform(20.0, 40.0)
1200 self._lightning_flash = 0.0
1201 self._lightning_double = False
1202
1203 # Node references
1204 self._ship: Ship | None = None
1205 self._camera: Camera3D | None = None
1206 self._sun: DirectionalLight3D | None = None
1207 self._env: WorldEnvironment | None = None
1208 self._terrain: TerrainManager | None = None
1209 self._clouds: CloudManager | None = None
1210 self._water: MeshInstance3D | None = None
1211 self._sun_disc: MeshInstance3D | None = None
1212 self._moon_disc: MeshInstance3D | None = None
1213 self._sun_disc_mat: Material | None = None
1214 self._moon_disc_mat: Material | None = None
1215 self._stars: StarField | None = None
1216 self._aurora: AuroraManager | None = None
1217 self._meteors: MeteorManager | None = None
1218 self._hud_text: str = ""
1219 self._look_target: Vec3 | None = None
1220
1221 def on_ready(self):
1222 # Input actions: must be registered here (not main()) so web export works
1223 InputMap.add_action("speed_up", [Key.UP])
1224 InputMap.add_action("slow_down", [Key.DOWN])
1225 InputMap.add_action("turn_left", [Key.LEFT])
1226 InputMap.add_action("turn_right", [Key.RIGHT])
1227
1228 # Camera: start near the ship, far plane large enough for chunk grid
1229 self._camera = self.add_child(Camera3D(position=Vec3(0, FLY_HEIGHT + 8, 15), fov=65, far=1200.0))
1230
1231 # Directional sun light
1232 self._sun = self.add_child(DirectionalLight3D(colour=(1.0, 0.95, 0.9), intensity=1.4, name="Sun"))
1233
1234 # WorldEnvironment: fog, bloom, tonemap, vignette, film effects
1235 self._env = self.add_child(WorldEnvironment())
1236 self._env.fog_enabled = True
1237 self._env.fog_colour = (0.7, 0.8, 1.0, 1.0)
1238 self._env.fog_density = 0.003
1239 self._env.fog_mode = "exponential"
1240 self._env.bloom_enabled = True
1241 self._env.bloom_threshold = 1.0
1242 self._env.bloom_intensity = 0.5
1243 self._env.tonemap_mode = "aces"
1244 self._env.tonemap_exposure = 1.0
1245 self._env.vignette_enabled = False
1246 self._env.film_grain_enabled = True
1247 self._env.film_grain_intensity = 0.02
1248 self._env.chromatic_aberration_enabled = True
1249 self._env.chromatic_aberration_intensity = 0.002
1250 self._env.sky_mode = "colour"
1251
1252 # Ship
1253 self._ship = self.add_child(Ship(name="Ship"))
1254
1255 # Terrain
1256 self._terrain = self.add_child(TerrainManager(name="Terrain"))
1257
1258 # Water plane
1259 water_mat = Material(colour=(0.08, 0.25, 0.55, 0.65), blend="alpha", metallic=0.3, roughness=0.2)
1260 water_size = (REMOVE_RADIUS * 2 + 1) * CHUNK_SIZE # Cover the full chunk grid
1261 self._water = self.add_child(
1262 MeshInstance3D(mesh=create_plane(size=water_size, subdivisions=1), material=water_mat, position=Vec3(0, WATER_LEVEL, 0))
1263 )
1264
1265 # Clouds
1266 self._clouds = self.add_child(CloudManager(name="Clouds"))
1267
1268 # Sun disc: HDR emissive sphere, bloom creates natural halo
1269 self._sun_disc_mat = Material(
1270 colour=(1.0, 0.9, 0.5),
1271 emissive_colour=(8.0, 6.0, 2.0, 4.0),
1272 )
1273 self._sun_disc = self.add_child(
1274 MeshInstance3D(mesh=Mesh.sphere(radius=3.0, rings=12, segments=12), material=self._sun_disc_mat)
1275 )
1276
1277 # Moon disc
1278 self._moon_disc_mat = Material(
1279 colour=(0.8, 0.85, 0.9),
1280 emissive_colour=(2.0, 2.2, 2.5, 2.0),
1281 )
1282 self._moon_disc = self.add_child(
1283 MeshInstance3D(mesh=Mesh.sphere(radius=1.5, rings=10, segments=10), material=self._moon_disc_mat)
1284 )
1285
1286 # Stars
1287 self._stars = self.add_child(StarField(name="Stars"))
1288
1289 # Aurora
1290 self._aurora = self.add_child(AuroraManager(name="Aurora"))
1291
1292 # Meteors
1293 self._meteors = self.add_child(MeteorManager(name="Meteors"))
1294
1295 # HUD overlay: use draw_text() for cross-backend compatibility
1296
1297 def on_process(self, dt: float):
1298 if not self._ship or not self._camera:
1299 return
1300
1301 # Clamp dt globally: prevents frame-spike lurches during chunk builds
1302 dt = min(dt, 1.0 / 30.0)
1303
1304 # Input
1305 if Input.is_key_just_pressed(Key.SPACE):
1306 self._ship.toggle_auto_fly()
1307 if Input.is_key_just_pressed(Key.T):
1308 self._time_speed_idx = (self._time_speed_idx + 1) % len(self._time_speeds)
1309 self._time_speed = self._time_speeds[self._time_speed_idx]
1310 if Input.is_key_just_pressed(Key.ESCAPE):
1311 self.app.quit()
1312 return
1313
1314 # Advance time of day
1315 self._time_of_day = (self._time_of_day + dt / DAY_CYCLE_SECONDS * self._time_speed) % 1.0
1316
1317 # Storm weather cycle: slow sinusoidal with sharp onset
1318 self._storm_phase = (self._storm_phase + dt * self._storm_speed) % math.tau
1319 raw = math.sin(self._storm_phase)
1320 # Only positive half = storm, sharpen onset with pow
1321 self._storm_intensity = max(0.0, raw) ** 1.5
1322
1323 # Ship FIRST, then camera: same dt, guaranteed ordering
1324 self._ship.update_ship(dt)
1325 self._update_camera(dt)
1326
1327 # Day/night, terrain, clouds, storm effects
1328 self._update_day_night(dt)
1329 self._terrain.update_chunks(self._ship.position)
1330 self._clouds.update_chunks(self._ship.position)
1331 self._update_lightning(dt)
1332 self._update_hud()
1333
1334 # Re-centre water plane on player
1335 if self._water:
1336 self._water.position = Vec3(self._ship.position.x, WATER_LEVEL, self._ship.position.z)
1337
1338 # --- Camera follow ---
1339
1340 def _update_camera(self, dt: float):
1341 ship = self._ship
1342 cam = self._camera
1343
1344 # Position behind and above ship: chase cam, ship at lower 1/3 of screen
1345 fwd_x = -math.sin(ship._yaw)
1346 fwd_z = -math.cos(ship._yaw)
1347 target = Vec3(
1348 ship.position.x - fwd_x * 15.0,
1349 ship.position.y + 8.0,
1350 ship.position.z - fwd_z * 15.0,
1351 )
1352
1353 # Smooth lerp follow: position
1354 t = min(1.0, 4.0 * dt)
1355 cam.position = Vec3(
1356 _lerp(cam.position.x, target.x, t),
1357 _lerp(cam.position.y, target.y, t),
1358 _lerp(cam.position.z, target.z, t),
1359 )
1360
1361 # Smooth lerp follow: look target (ship at lower 1/3: look well ahead and below)
1362 raw_look = Vec3(ship.position.x + fwd_x * 30.0, ship.position.y - 4.0, ship.position.z + fwd_z * 30.0)
1363 if self._look_target is None:
1364 self._look_target = raw_look
1365 lt = min(1.0, 6.0 * dt)
1366 self._look_target = Vec3(
1367 _lerp(self._look_target.x, raw_look.x, lt),
1368 _lerp(self._look_target.y, raw_look.y, lt),
1369 _lerp(self._look_target.z, raw_look.z, lt),
1370 )
1371 cam.look_at(self._look_target)
1372
1373 # --- Day/night cycle ---
1374
1375 def _update_day_night(self, dt: float):
1376 t = self._time_of_day
1377 env = self._env
1378
1379 # Sun angle: sun_elevation = sin((t - 0.25) * 2pi)
1380 angle = (t - 0.25) * math.tau
1381 sun_elev = math.sin(angle)
1382
1383 # Sun light direction (from sun toward scene)
1384 dx, dy, dz = -math.cos(angle), -sun_elev, -0.3
1385 mag = math.sqrt(dx * dx + dy * dy + dz * dz)
1386 light_dir = Vec3(dx / mag, dy / mag, dz / mag)
1387
1388 storm = self._storm_intensity
1389
1390 if self._sun:
1391 if sun_elev > -0.05:
1392 self._sun.direction = light_dir
1393 self._sun.colour = _sample_keyframes(SUN_COLOUR_KEYS, t)
1394 # Storm dims sunlight significantly
1395 base_intensity = _sample_keyframes(SUN_INTENSITY_KEYS, t)
1396 self._sun.intensity = base_intensity * (1.0 - storm * 0.7)
1397 else:
1398 self._sun.intensity = 0.0
1399
1400 # Sky colours: storm darkens the sky
1401 sky_top = _sample_keyframes(SKY_TOP_KEYS, t)
1402 sky_bottom = _sample_keyframes(SKY_BOTTOM_KEYS, t)
1403 storm_grey = (0.25, 0.27, 0.3)
1404 if storm > 0.01:
1405 sky_top = _lerp_colour(sky_top, storm_grey, storm * 0.6)
1406 sky_bottom = _lerp_colour(sky_bottom, storm_grey, storm * 0.5)
1407 env.sky_colour_top = sky_top
1408 env.sky_colour_bottom = sky_bottom
1409 env.fog_colour = sky_bottom
1410
1411 # Storm increases fog density for atmosphere
1412 base_fog_density = 0.003
1413 env.fog_density = base_fog_density + storm * 0.006
1414
1415 # Post-processing animation: storm reduces exposure slightly
1416 base_exposure = _sample_keyframes(EXPOSURE_KEYS, t)
1417 env.tonemap_exposure = base_exposure * (1.0 - storm * 0.25)
1418 env.bloom_threshold = _sample_keyframes(BLOOM_THRESHOLD_KEYS, t)
1419 env.bloom_intensity = _sample_keyframes(BLOOM_INTENSITY_KEYS, t)
1420 env.ambient_light_colour = _sample_keyframes(AMBIENT_KEYS, t)
1421
1422 # Sun/moon disc positions
1423 cam_pos = self._camera.position if self._camera else Vec3(0, 0, 0)
1424
1425 if self._sun_disc:
1426 sx, sy, sz = math.cos(angle), sun_elev, 0.3
1427 smag = math.sqrt(sx * sx + sy * sy + sz * sz)
1428 self._sun_disc.position = cam_pos + Vec3(sx / smag, sy / smag, sz / smag) * 200.0
1429 alpha = max(0.0, min(1.0, sun_elev * 5.0 + 0.5))
1430 self._sun_disc_mat.emissive_colour = (8.0 * alpha, 6.0 * alpha, 2.0 * alpha, 4.0 * alpha)
1431
1432 if self._moon_disc:
1433 moon_angle = angle + math.pi
1434 moon_elev = math.sin(moon_angle)
1435 mx, my, mz = math.cos(moon_angle), moon_elev, -0.3
1436 mmag = math.sqrt(mx * mx + my * my + mz * mz)
1437 self._moon_disc.position = cam_pos + Vec3(mx / mmag, my / mmag, mz / mmag) * 200.0
1438 alpha = max(0.0, min(1.0, moon_elev * 5.0 + 0.5))
1439 self._moon_disc_mat.emissive_colour = (2.0 * alpha, 2.2 * alpha, 2.5 * alpha, 2.0 * alpha)
1440
1441 # Stars
1442 if self._stars:
1443 self._stars.update_visibility(sun_elev, cam_pos)
1444
1445 # Aurora
1446 if self._aurora:
1447 self._aurora.update(dt, sun_elev, cam_pos)
1448
1449 # Cloud tint: storm darkens clouds from white to threatening dark grey
1450 if self._clouds:
1451 base_tint = _sample_keyframes(SKY_TOP_KEYS, t)
1452 clear_r, clear_g, clear_b = 0.7 + base_tint[0] * 0.3, 0.7 + base_tint[1] * 0.3, 0.7 + base_tint[2] * 0.3
1453 storm_r, storm_g, storm_b = 0.25, 0.25, 0.28
1454 sr = _lerp(clear_r, storm_r, storm)
1455 sg = _lerp(clear_g, storm_g, storm)
1456 sb = _lerp(clear_b, storm_b, storm)
1457 # Storm thickens clouds: opacity 0.4 (clear) → 0.85 (heavy storm)
1458 opacity = _lerp(0.4, 0.85, storm)
1459 self._clouds.update_colour((sr, sg, sb), opacity)
1460
1461 # --- Lightning flashes ---
1462
1463 def _update_lightning(self, dt: float):
1464 storm = self._storm_intensity
1465 self._lightning_timer -= dt
1466
1467 if self._lightning_flash > 0:
1468 self._lightning_flash -= dt
1469 if self._lightning_flash <= 0 and self._lightning_double:
1470 self._lightning_double = False
1471 self._lightning_flash = 0.15
1472 return
1473 if self._lightning_flash > 0 and self._env:
1474 base_exp = _sample_keyframes(EXPOSURE_KEYS, self._time_of_day)
1475 # Stronger flashes during storms
1476 flash_t = self._lightning_flash / 0.15
1477 flash_strength = 2.0 + storm * 3.0
1478 self._env.tonemap_exposure = base_exp + flash_t * flash_strength
1479
1480 if self._lightning_timer <= 0:
1481 # Storm increases lightning frequency: 20-40s (clear) → 3-8s (heavy storm)
1482 min_t = _lerp(20.0, 3.0, storm)
1483 max_t = _lerp(40.0, 8.0, storm)
1484 self._lightning_timer = random.uniform(min_t, max_t)
1485 # Only flash if there's at least some storm activity (or rare clear-sky bolts)
1486 if storm > 0.1 or random.random() < 0.15:
1487 self._lightning_flash = 0.15
1488 self._lightning_double = random.random() > 0.4
1489
1490 # --- HUD ---
1491
1492 def _update_hud(self):
1493 if not self._ship:
1494 return
1495 t = self._time_of_day
1496 if 0.20 <= t < 0.30:
1497 phase = "Sunrise"
1498 elif 0.30 <= t < 0.70:
1499 phase = "Day"
1500 elif 0.70 <= t < 0.80:
1501 phase = "Sunset"
1502 else:
1503 phase = "Night"
1504
1505 storm = self._storm_intensity
1506 weather = ""
1507 if storm > 0.6:
1508 weather = " STORM"
1509 elif storm > 0.3:
1510 weather = " Overcast"
1511 elif storm > 0.05:
1512 weather = " Cloudy"
1513
1514 mult = f" [{self._time_speed:.0f}x]" if self._time_speed > 1 else ""
1515 auto = " [AUTO]" if self._ship._auto_fly else ""
1516 self._hud_text = f"Speed: {self._ship._speed:.0f} Alt: {FLY_HEIGHT:.0f} {phase}{weather}{mult}{auto}"
1517
1518 def on_draw(self, renderer):
1519 if self._hud_text:
1520 renderer.draw_text(self._hud_text, (10, 10), scale=1.5, colour=(1.0, 1.0, 1.0))
1521 renderer.draw_text("Arrows: fly Space: auto T: time speed", (10, 32), scale=1, colour=(0.55, 0.55, 0.55))
1522
1523
1524# ===========================================================================
1525# Main
1526# ===========================================================================
1527
1528
1529def main():
1530 app = App(title="Planet Explorer", width=1280, height=720)
1531 app.run(PlanetExplorer(name="PlanetExplorer"))
1532
1533
1534if __name__ == "__main__":
1535 main()