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