shrike/artkit.py¶
Part of SHRIKE.
1"""Procedural art kit for SHRIKE: every mesh, material, sky and grade is generated.
2
3No asset file ships with the game. Hulls, enemies, stations, wrecks and rocks
4are kitbashed from engine primitives, CSG cuts, swept extrusions and noise
5displacement; materials are metallic-roughness with the emissive accents that
6carry the readability contract; the sky is a synthesized nebula cubemap that
7also drives image-based lighting; and the per-act look is a grade applied to a
8``WorldEnvironment``.
9
10Nothing here needs a GPU or a window: every entry point produces plain CPU data
11and ordinary scene nodes, so the whole kit constructs headlessly.
12
13Conventions
14===========
15
16* Ships and enemies are built nose-along local **+X**, matching
17 :func:`runtime.heading_to_direction` at heading 0, so yawing a node about +Y
18 by its heading aims it where the gameplay code says it points.
19* Y is up. A build is centred on its own origin, so a node sitting at
20 ``y = runtime.PLANE_Y`` puts the hull's waist on the flight plane.
21* Every build returns a :class:`Node3D` carrying at most three
22 :class:`MeshInstance3D` children, named :data:`ROLE_HULL`, :data:`ROLE_TRIM`
23 and :data:`ROLE_ACCENT`. ``Accent`` holds the emissive geometry;
24 :func:`apply_faction_materials` re-skins all three from one palette.
25
26The readability contract
27========================
28
29Ownership reads off emissive colour against low-albedo surroundings: player
30warm gold and white, enemy cold magenta, telegraphs pure white, environments
31near-black. :data:`PALETTES` is the single place those three values live, and
32:func:`apply_faction_materials` is the only way geometry acquires them.
33"""
34
35from __future__ import annotations
36
37import math
38from dataclasses import dataclass
39from functools import cache
40
41import numpy as np
42
43from simvx.core import (
44 CSGOperation,
45 FastNoiseLite,
46 FractalType,
47 Material,
48 Mesh,
49 MeshInstance3D,
50 Node3D,
51 NoiseType,
52 Quat,
53 Vec3,
54 WorldEnvironment,
55 csg_combine,
56)
57from simvx.core.math.matrices import quat_to_mat4
58
59from . import balance
60from .runtime import PLANE_Y
61
62# ============================================================================
63# Module-local constants
64#
65# None of these is a balance number: they are proportions and render settings,
66# which balance.py deliberately does not carry.
67# ============================================================================
68
69#: Mesh instance names, one per material role in a build.
70ROLE_HULL = "Hull"
71ROLE_TRIM = "Trim"
72ROLE_ACCENT = "Accent"
73ROLES = (ROLE_HULL, ROLE_TRIM, ROLE_ACCENT)
74
75#: Every faction :func:`apply_faction_materials` accepts.
76FACTIONS = ("player", "enemy", "hunter", "environment", "telegraph")
77
78#: Per-face resolution of the synthesized nebula cubemap. A face covers 90
79#: degrees, so the face size sets how small a star can be: at 256 one texel
80#: spanned a third of a degree, which is five screen pixels, and a star stamped
81#: into a single texel came out as a hard white square the size of a die. 512
82#: halves that, and the soft stamp below spreads a star over it.
83NEBULA_FACE_SIZE = 512
84
85#: Resolution the cloud noise is actually evaluated at, before it is resampled
86#: up to the face size. Clouds are soft and dim, so they cost the same few
87#: milliseconds they always did however sharp the star layer gets.
88NEBULA_CLOUD_SIZE = 64
89
90#: How bright the cloud layer is allowed to get. The readability contract wants
91#: a near-black sky so emissive accents own the screen; the raw noise is graded
92#: for shape, and this is what keeps it a backdrop rather than a light box.
93NEBULA_CLOUD_ENERGY = 0.5
94
95#: Stars stamped into one cube face at :data:`NEBULA_FACE_SIZE`, scaled by area
96#: for any other bake size, and the energy band one star peaks at. Only the top of the band clears the bloom threshold,
97#: so a sky has a handful of bright stars in a field of faint ones rather than
98#: several hundred lamps.
99STARS_PER_FACE = 70
100STAR_ENERGY_MIN = 0.35
101STAR_ENERGY_MAX = 2.4
102#: However small a face is baked, it gets at least this many stars: a 16-pixel
103#: test bake must still contain a sky.
104STAR_MIN_PER_FACE = 3
105#: A star is a Gaussian splat, not a texel: this is its standard deviation in
106#: texels and the radius, in texels, the splat is evaluated out to.
107STAR_SIGMA = 0.85
108STAR_KERNEL_RADIUS = 2
109#: How far a star's hue is pushed from white toward warm or cold. Real skies
110#: are not monochrome, and the variation is what stops a field of dots reading
111#: as a texture artefact.
112STAR_TINT = 0.16
113
114#: Silent running drops the whole scene's exposure by this factor.
115SILENT_RUNNING_EXPOSURE_MULT = 0.65
116
117#: Body segments the Shrike is built from (head plus tail sections).
118SHRIKE_SEGMENT_COUNT = 7
119
120# Cube face bases in Vulkan order [+X, -X, +Y, -Y, +Z, -Z]: (right, up, forward).
121_FACE_BASIS = (
122 ((0.0, 0.0, -1.0), (0.0, -1.0, 0.0), (1.0, 0.0, 0.0)),
123 ((0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (-1.0, 0.0, 0.0)),
124 ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0)),
125 ((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, -1.0, 0.0)),
126 ((1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
127 ((-1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
128)
129
130# Quaternions that reorient a +Y primitive (cylinder, cone) onto another axis.
131_Y_TO_X = Quat.from_axis_angle(Vec3(0.0, 0.0, 1.0), -math.pi / 2.0)
132_Y_TO_Z = Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), math.pi / 2.0)
133_Y_TO_NEG_Z = Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), -math.pi / 2.0)
134_Y_TO_NEG_X = Quat.from_axis_angle(Vec3(0.0, 0.0, 1.0), math.pi / 2.0)
135
136
137# ============================================================================
138# Kitbash builder
139# ============================================================================
140
141
142class Kit:
143 """Accumulates transformed primitives into one merged :class:`Mesh`.
144
145 This is the kitbash itself: a build places scaled, rotated copies of a
146 handful of shared unit primitives and ends with a single mesh, so a whole
147 archetype costs one draw rather than one per greeble.
148
149 Mirrored parts are ordinary: a negative scale factor flips the winding and
150 the normals back, so ``scale=(1, 1, -1)`` gives a correct opposite wing.
151 """
152
153 def __init__(self) -> None:
154 self._positions: list[np.ndarray] = []
155 self._normals: list[np.ndarray] = []
156 self._texcoords: list[np.ndarray] = []
157 self._indices: list[np.ndarray] = []
158 self._vertex_count = 0
159
160 @property
161 def empty(self) -> bool:
162 return self._vertex_count == 0
163
164 def add(
165 self,
166 mesh: Mesh,
167 *,
168 position: tuple[float, float, float] = (0.0, 0.0, 0.0),
169 rotation: Quat | None = None,
170 scale: float | tuple[float, float, float] = 1.0,
171 ) -> Kit:
172 """Place a transformed copy of *mesh*. The source is never mutated."""
173 if mesh.indices is None:
174 raise ValueError("Kit.add requires an indexed mesh")
175 factors = np.asarray(scale if isinstance(scale, (tuple, list, np.ndarray)) else (scale,) * 3, dtype=np.float32)
176 positions = mesh.positions * factors
177 normals = mesh.normals if mesh.normals is not None else np.zeros_like(mesh.positions)
178 # Normals transform by the inverse transpose, which for a diagonal
179 # scale is the reciprocal; renormalised below.
180 normals = normals / np.where(np.abs(factors) < 1e-9, 1e-9, factors)
181 if rotation is not None:
182 rot = np.asarray(quat_to_mat4(rotation), dtype=np.float32)[:3, :3]
183 positions = positions @ rot.T
184 normals = normals @ rot.T
185 positions = positions + np.asarray(position, dtype=np.float32)
186 lengths = np.linalg.norm(normals, axis=1, keepdims=True)
187 normals = np.divide(normals, lengths, where=lengths > 1e-9, out=np.zeros_like(normals))
188
189 texcoords = mesh.texcoords if mesh.texcoords is not None else np.zeros((len(positions), 2), dtype=np.float32)
190 triangles = mesh.indices.reshape(-1, 3) + self._vertex_count
191 if float(np.prod(factors)) < 0.0:
192 # A mirroring scale turns every triangle inside out. The reciprocal
193 # above has already flipped the normals; the winding needs the same.
194 triangles = triangles[:, ::-1]
195
196 self._positions.append(positions.astype(np.float32))
197 self._normals.append(normals.astype(np.float32))
198 self._texcoords.append(np.asarray(texcoords, dtype=np.float32))
199 self._indices.append(triangles.astype(np.uint32))
200 self._vertex_count += len(positions)
201 return self
202
203 def build(self) -> Mesh:
204 """Merge everything placed so far into one mesh."""
205 if self.empty:
206 raise ValueError("Kit.build called with nothing placed")
207 return Mesh(
208 np.concatenate(self._positions),
209 np.concatenate(self._indices).ravel(),
210 np.concatenate(self._normals),
211 np.concatenate(self._texcoords),
212 )
213
214
215@cache
216def _unit_cube() -> Mesh:
217 return Mesh.cube(1.0)
218
219
220@cache
221def _unit_cylinder(segments: int = 12) -> Mesh:
222 return Mesh.cylinder(radius=0.5, height=1.0, segments=segments)
223
224
225@cache
226def _unit_cone(segments: int = 10) -> Mesh:
227 return Mesh.cone(radius=0.5, height=1.0, segments=segments)
228
229
230@cache
231def _unit_sphere(rings: int = 8, segments: int = 12) -> Mesh:
232 return Mesh.sphere(radius=0.5, rings=rings, segments=segments)
233
234
235def _greeble(kit: Kit, rng: np.random.Generator, *, span: tuple[float, float, float], count: int, size: float) -> None:
236 """Scatter small plated boxes over a body of the given half-extents.
237
238 Greebling is what makes one kit read as a whole faction: the same hull with
239 a different scatter seed is a different ship at a glance.
240 """
241 for _ in range(count):
242 along = float(rng.uniform(-span[0], span[0]))
243 side = float(rng.uniform(-span[2], span[2]))
244 top = span[1] * float(rng.choice([-1.0, 1.0])) * float(rng.uniform(0.75, 1.0))
245 block = (
246 size * float(rng.uniform(0.6, 2.2)),
247 size * float(rng.uniform(0.3, 0.8)),
248 size * float(rng.uniform(0.6, 1.8)),
249 )
250 kit.add(
251 _unit_cube(),
252 position=(along, top, side),
253 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(rng.uniform(0.0, math.tau))),
254 scale=block,
255 )
256
257
258def _displaced_rock(seed: int, radius: float, *, relief: float = 0.34, rings: int = 12, segments: int = 16) -> Mesh:
259 """A sphere pushed around by fractal noise: the asteroid and deposit body.
260
261 The UV sphere's seam and pole vertices are coincident, so sampling the
262 noise in object space displaces them identically and the surface stays
263 watertight.
264 """
265 mesh = Mesh.sphere(radius=radius, rings=rings, segments=segments)
266 noise = FastNoiseLite(seed=int(seed), noise_type=NoiseType.SIMPLEX, frequency=1.05 / max(radius, 1e-3))
267 noise.fractal_type = FractalType.FBM
268 noise.fractal_octaves = 4
269 points = mesh.positions
270 amount = noise.get_noise_3d_array(points[:, 0], points[:, 1], points[:, 2])
271 mesh.positions = (points * (1.0 + amount[:, None] * relief)).astype(np.float32)
272 # The mesh is no longer the primitive its factory spec claims.
273 mesh.factory_spec = None
274 mesh.generate_normals()
275 # A UV sphere's pole fan is degenerate, so face-averaged normals there come
276 # out zero. The body stays star-shaped under displacement, so the radial
277 # direction is the right normal for exactly those vertices.
278 radius_per_vertex = np.linalg.norm(mesh.positions, axis=1, keepdims=True)
279 radial = np.divide(
280 mesh.positions,
281 radius_per_vertex,
282 where=radius_per_vertex > 1e-9,
283 out=np.zeros_like(mesh.positions),
284 )
285 degenerate = np.linalg.norm(mesh.normals, axis=1) < 1e-6
286 mesh.normals[degenerate] = radial[degenerate]
287 return mesh
288
289
290def _mesh_instance(name: str, mesh: Mesh, material: Material) -> MeshInstance3D:
291 return MeshInstance3D(name=name, mesh=mesh, material=material)
292
293
294# ============================================================================
295# Materials: the readability contract
296# ============================================================================
297
298
299@dataclass(frozen=True)
300class FactionPalette:
301 """One faction's metallic-roughness look plus its emissive signature.
302
303 ``accent`` and ``accent_strength`` are the readability contract: they are
304 what the player reads at a glance through two hundred particles, so they
305 are deliberately few and far apart in hue.
306 """
307
308 id: str
309 hull_colour: tuple[float, float, float, float]
310 hull_metallic: float
311 hull_roughness: float
312 trim_colour: tuple[float, float, float, float]
313 trim_metallic: float
314 trim_roughness: float
315 accent: tuple[float, float, float]
316 accent_strength: float
317 #: Faint hull self-light, as an emissive strength on the hull's own colour.
318 #: Zero for everything that is allowed to read as a silhouette. A highly
319 #: metallic hull under a dark skybox reflects mostly nothing, and a blind
320 #: playtest read the flown ship as a black cutout against the bright acts'
321 #: nebulae; the glow floors the hull's brightness without touching the
322 #: emissive accents that own the readability contract, and it sits far
323 #: under the bloom threshold.
324 hull_glow: float = 0.0
325
326
327#: Player warm gold and white, enemy cold magenta, telegraph white, hunter a
328#: bruised violet, environments near-black so all four still win.
329PALETTES: dict[str, FactionPalette] = {
330 "player": FactionPalette(
331 "player",
332 hull_colour=(0.60, 0.62, 0.66, 1.0),
333 hull_metallic=0.62,
334 hull_roughness=0.42,
335 trim_colour=(0.24, 0.25, 0.28, 1.0),
336 trim_metallic=0.70,
337 trim_roughness=0.52,
338 accent=(1.0, 0.80, 0.42),
339 accent_strength=5.0,
340 hull_glow=0.30,
341 ),
342 "enemy": FactionPalette(
343 "enemy",
344 hull_colour=(0.26, 0.23, 0.30, 1.0),
345 hull_metallic=0.72,
346 hull_roughness=0.48,
347 trim_colour=(0.14, 0.12, 0.17, 1.0),
348 trim_metallic=0.55,
349 trim_roughness=0.68,
350 accent=(1.0, 0.10, 0.72),
351 accent_strength=5.0,
352 ),
353 "hunter": FactionPalette(
354 "hunter",
355 hull_colour=(0.10, 0.09, 0.13, 1.0),
356 hull_metallic=0.48,
357 hull_roughness=0.72,
358 trim_colour=(0.05, 0.05, 0.07, 1.0),
359 trim_metallic=0.40,
360 trim_roughness=0.82,
361 accent=(0.86, 0.62, 1.0),
362 accent_strength=6.5,
363 ),
364 "environment": FactionPalette(
365 "environment",
366 hull_colour=(0.045, 0.048, 0.055, 1.0),
367 hull_metallic=0.12,
368 hull_roughness=0.88,
369 trim_colour=(0.030, 0.032, 0.038, 1.0),
370 trim_metallic=0.30,
371 trim_roughness=0.74,
372 accent=(0.95, 0.55, 0.20),
373 accent_strength=2.0,
374 ),
375 "telegraph": FactionPalette(
376 "telegraph",
377 hull_colour=(0.90, 0.90, 0.90, 1.0),
378 hull_metallic=0.0,
379 hull_roughness=1.0,
380 trim_colour=(0.90, 0.90, 0.90, 1.0),
381 trim_metallic=0.0,
382 trim_roughness=1.0,
383 accent=(1.0, 1.0, 1.0),
384 accent_strength=8.0,
385 ),
386}
387
388#: Ceiling on any environment albedo channel. Emissive readability only works
389#: against surroundings that stay dark, so this is asserted, not hoped for.
390ENVIRONMENT_ALBEDO_CEILING = 0.12
391
392
393def palette(faction: str) -> FactionPalette:
394 """The palette for *faction*, raising on anything outside :data:`FACTIONS`."""
395 try:
396 return PALETTES[faction]
397 except KeyError:
398 raise ValueError(f"Unknown faction {faction!r}; expected one of {', '.join(FACTIONS)}") from None
399
400
401def faction_material(faction: str, role: str = ROLE_HULL) -> Material:
402 """A fresh material for one *role* of one *faction*.
403
404 Fresh rather than shared: consumers flash accents white for telegraphs and
405 tint hulls for elites, and a shared material would leak that across every
406 instance in the sector.
407 """
408 if role not in ROLES:
409 raise ValueError(f"Unknown material role {role!r}; expected one of {', '.join(ROLES)}")
410 pal = palette(faction)
411 if role == ROLE_ACCENT:
412 return Material(
413 colour=(*pal.accent, 1.0),
414 metallic=0.0,
415 roughness=0.45,
416 emissive_colour=pal.accent,
417 emissive_strength=pal.accent_strength,
418 )
419 if role == ROLE_TRIM:
420 return Material(colour=pal.trim_colour, metallic=pal.trim_metallic, roughness=pal.trim_roughness)
421 if pal.hull_glow > 0.0:
422 return Material(
423 colour=pal.hull_colour,
424 metallic=pal.hull_metallic,
425 roughness=pal.hull_roughness,
426 emissive_colour=pal.hull_colour[:3],
427 emissive_strength=pal.hull_glow,
428 )
429 return Material(colour=pal.hull_colour, metallic=pal.hull_metallic, roughness=pal.hull_roughness)
430
431
432def apply_faction_materials(node: Node3D, faction: str) -> None:
433 """Re-skin every mesh under *node* from one faction palette.
434
435 The single enforcement point for the readability contract: geometry gets
436 its ownership colour here and nowhere else, so a telegraph flash is a
437 re-skin to ``"telegraph"`` and back rather than an ad hoc colour poke.
438 """
439 palette(faction) # Validate before touching anything.
440 for instance in node.find_all(MeshInstance3D):
441 role = instance.name if instance.name in ROLES else ROLE_HULL
442 instance.material = faction_material(faction, role)
443
444
445# ============================================================================
446# Player hulls
447# ============================================================================
448
449
450@dataclass(frozen=True)
451class HullSpec:
452 """Proportions of one player hull. Socket counts come from balance."""
453
454 id: str
455 length: float
456 beam: float
457 depth: float
458 engines: int
459 greebles: int
460
461
462HULLS: dict[str, HullSpec] = {
463 "vagrant": HullSpec("vagrant", length=2.6, beam=1.7, depth=0.62, engines=2, greebles=14),
464 "barge": HullSpec("barge", length=3.2, beam=2.6, depth=0.92, engines=2, greebles=22),
465 "dart": HullSpec("dart", length=3.4, beam=1.1, depth=0.48, engines=1, greebles=8),
466 "hive": HullSpec("hive", length=2.8, beam=2.2, depth=0.78, engines=4, greebles=16),
467}
468
469#: The reference hull length the Shrike is sized against.
470PLAYER_SHIP_LENGTH = HULLS["vagrant"].length
471
472#: Where the weapon rack bolts a gun, in the kit's own nose-along-+X frame.
473#: ``weapons.HARDPOINT_OFFSETS`` names the same four places in the hull's local
474#: frame, where the nose runs down -Z; the ship mounts this build with a quarter
475#: turn about +Y, which sends art +X onto ship -Z, so ``(art_x, art_z)`` is
476#: ``(-ship_z, ship_x)``. The table is restated rather than imported because
477#: ``weapons`` sits above the art kit in the import order; ``test_ship`` pins the
478#: two together so they cannot drift apart in silence.
479HARDPOINT_MOUNTS = ((-0.15, -0.85), (-0.15, 0.85), (0.55, -1.45), (0.55, 1.45))
480
481#: How many mounts a hull carries unless the caller says otherwise. A bare mount
482#: is still built: an empty pylon is what tells a pilot the hull has room, and it
483#: is the only reason a third and fourth gun look like they belong on the ship
484#: rather than floating a metre off its flank.
485HARDPOINTS_DEFAULT = balance.HARDPOINTS_STARTER
486
487
488def hull_spec(hull: str) -> HullSpec:
489 """The :class:`HullSpec` for *hull*, raising on an unknown id."""
490 try:
491 return HULLS[hull]
492 except KeyError:
493 raise ValueError(f"Unknown hull {hull!r}; expected one of {', '.join(HULLS)}") from None
494
495
496def _add_hardpoint_mounts(trim: Kit, spec: HullSpec, hardpoints: int) -> None:
497 """Bolt a pylon and a collar under each of the hull's first *hardpoints* mounts.
498
499 Mounts go in the trim mesh rather than the hull mesh on purpose: the hull
500 mesh is the silhouette, and four pylons at fixed offsets would give every
501 hull the same beam and erase the difference between a Dart and a Barge.
502 """
503 for mount_x, mount_z in HARDPOINT_MOUNTS[: max(0, int(hardpoints))]:
504 root_z = math.copysign(spec.beam * 0.30, mount_z)
505 drop = -spec.depth * 0.22
506 trim.add(
507 _unit_cube(),
508 position=(mount_x, drop, (root_z + mount_z) * 0.5),
509 scale=(spec.length * 0.09, spec.depth * 0.24, abs(mount_z - root_z)),
510 )
511 trim.add(
512 _unit_cylinder(10),
513 position=(mount_x, drop, mount_z),
514 rotation=_Y_TO_X,
515 scale=(spec.depth * 0.36, spec.length * 0.11, spec.depth * 0.36),
516 )
517
518
519def _build_player_meshes(hull: str, seed: int, hardpoints: int) -> tuple[Mesh, Mesh, Mesh]:
520 spec = hull_spec(hull)
521 rng = np.random.default_rng((seed, hash(hull) & 0xFFFF))
522 half = spec.length / 2.0
523
524 body = Kit()
525 # Spine: a tapered fuselage cut from a box by a wedge, so the nose reads
526 # sharp without a separate cone joint.
527 fuselage = csg_combine(
528 _scaled(_unit_cube(), (spec.length * 0.78, spec.depth, spec.beam * 0.46)),
529 _scaled(_unit_cone(12), (spec.beam * 1.9, spec.length * 1.4, spec.beam * 1.9), rotation=_Y_TO_X),
530 CSGOperation.INTERSECT,
531 )
532 body.add(fuselage)
533 body.add(
534 _unit_cone(10),
535 position=(half * 0.86, 0.0, 0.0),
536 rotation=_Y_TO_X,
537 scale=(spec.beam * 0.34, half * 0.5, spec.depth * 0.9),
538 )
539 # Swept wings, mirrored across the spine.
540 for side in (1.0, -1.0):
541 body.add(
542 _unit_cube(),
543 position=(-spec.length * 0.06, 0.0, side * spec.beam * 0.34),
544 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.26),
545 scale=(spec.length * 0.42, spec.depth * 0.42, spec.beam * 0.5),
546 )
547 _greeble(body, rng, span=(spec.length * 0.34, spec.depth * 0.5, spec.beam * 0.4), count=spec.greebles, size=0.1)
548
549 trim = Kit()
550 engine_spacing = spec.beam * 0.62 / max(spec.engines, 1)
551 for index in range(spec.engines):
552 offset = (index - (spec.engines - 1) / 2.0) * engine_spacing * 2.0
553 trim.add(
554 _unit_cylinder(12),
555 position=(-half * 0.82, 0.0, offset),
556 rotation=_Y_TO_X,
557 scale=(spec.depth * 0.72, spec.length * 0.26, spec.depth * 0.72),
558 )
559 # Dorsal sensor spine and the shield-emitter ring collar.
560 trim.add(
561 _unit_cube(),
562 position=(half * 0.1, spec.depth * 0.52, 0.0),
563 scale=(spec.length * 0.22, spec.depth * 0.3, spec.beam * 0.12),
564 )
565 trim.add(
566 _unit_cylinder(12),
567 position=(0.0, 0.0, 0.0),
568 rotation=_Y_TO_X,
569 scale=(spec.beam * 0.5, spec.length * 0.05, spec.beam * 0.5),
570 )
571 _add_hardpoint_mounts(trim, spec, hardpoints)
572
573 accent = Kit()
574 for index in range(spec.engines):
575 offset = (index - (spec.engines - 1) / 2.0) * engine_spacing * 2.0
576 accent.add(
577 _unit_cylinder(12),
578 position=(-half * 0.94, 0.0, offset),
579 rotation=_Y_TO_X,
580 scale=(spec.depth * 0.58, spec.length * 0.04, spec.depth * 0.58),
581 )
582 # Cockpit strip: the one warm line that says which end is the nose.
583 accent.add(
584 _unit_cube(), position=(half * 0.42, spec.depth * 0.34, 0.0), scale=(spec.length * 0.16, 0.04, spec.beam * 0.1)
585 )
586 return body.build(), trim.build(), accent.build()
587
588
589def _scaled(
590 mesh: Mesh,
591 scale: tuple[float, float, float],
592 *,
593 rotation: Quat | None = None,
594 position: tuple[float, float, float] = (0.0, 0.0, 0.0),
595) -> Mesh:
596 """A standalone transformed copy of *mesh*, for feeding CSG operands."""
597 return Kit().add(mesh, scale=scale, rotation=rotation, position=position).build()
598
599
600def build_player_ship(seed: int = 0, *, hull: str = "vagrant", hardpoints: int = HARDPOINTS_DEFAULT) -> Node3D:
601 """The player's ship, nose along +X, as one hull, trim and accent mesh.
602
603 *hull* selects one of the four flyable hulls in :data:`HULLS`; the default
604 is the starting Vagrant. *seed* only moves the greeble scatter, so two
605 ships of the same hull read as the same class of ship. *hardpoints* builds
606 that many gun pylons at :data:`HARDPOINT_MOUNTS`, so a hull that carries
607 four mounts looks like a hull that carries four mounts even before the
608 third and fourth guns are bought.
609 """
610 hardpoints = max(0, min(int(hardpoints), len(HARDPOINT_MOUNTS)))
611 body, trim, accent = _cached(
612 ("player_ship", hull, seed, hardpoints), lambda: _build_player_meshes(hull, seed, hardpoints)
613 )
614 node = Node3D(name="ShipArt")
615 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("player", ROLE_HULL)))
616 node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("player", ROLE_TRIM)))
617 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("player", ROLE_ACCENT)))
618 return node
619
620
621# ============================================================================
622# The exhaust plume
623# ============================================================================
624
625#: Names of the two meshes an exhaust plume is made of.
626PLUME_FLARE = "PlumeFlare"
627PLUME_CORE = "PlumeCore"
628
629#: The flare is squashed vertically: seen down a camera that is 60 degrees off
630#: the plane, a flattened plume reads as a streak laid along the thrust vector
631#: where a round one reads as a ball of fire behind the ship.
632PLUME_FLATTEN = 0.45
633#: Radius of the core relative to the flare's mouth, and its share of the length.
634PLUME_CORE_RADIUS = 0.34
635PLUME_CORE_LENGTH = 0.62
636
637
638def _build_plume_meshes() -> tuple[Mesh, Mesh]:
639 """The flare and core cones of one exhaust plume, as unit meshes.
640
641 Both are built mouth-first: the wide end sits on the node's origin, which is
642 the nozzle, and the taper runs down local -Z, which is the axis
643 :meth:`Node3D.face_along` aims. A caller therefore points a plume by facing
644 the node along the exhaust vector and scales it by throttle, with no
645 offset to keep in step.
646 """
647 flare = Kit().add(_unit_cone(14), position=(0.0, 0.0, -0.5), rotation=_Y_TO_NEG_Z, scale=(2.0, 1.0, 2.0)).build()
648 flare = Kit().add(flare, scale=(1.0, PLUME_FLATTEN, 1.0)).build()
649 core = (
650 Kit()
651 .add(
652 _unit_cone(12),
653 position=(0.0, 0.0, -PLUME_CORE_LENGTH * 0.5),
654 rotation=_Y_TO_NEG_Z,
655 scale=(PLUME_CORE_RADIUS * 2.0, PLUME_CORE_LENGTH, PLUME_CORE_RADIUS * 2.0),
656 )
657 .build()
658 )
659 return flare, Kit().add(core, scale=(1.0, PLUME_FLATTEN, 1.0)).build()
660
661
662def plume_meshes() -> tuple[Mesh, Mesh]:
663 """The shared unit flare and core meshes of the exhaust plume."""
664 return _cached(("plume",), _build_plume_meshes)
665
666
667def build_engine_plume(faction: str = "player") -> Node3D:
668 """One nozzle's plume: a tapered flare with a hotter core inside it.
669
670 Continuous geometry rather than particles, because a plume is one jet of
671 burning gas and a scatter of grains behind a ship reads as debris coming
672 off it. :class:`shrike.vfx.EngineRibbon` owns one of these, stretches it
673 with the throttle and points it down the thrust vector; the fast mote
674 stream it also carries is the texture on top, not the plume itself.
675 """
676 accent = palette(faction).accent
677 flare_mesh, core_mesh = plume_meshes()
678 node = Node3D(name="Plume")
679 node.add_child(
680 _mesh_instance(
681 PLUME_FLARE,
682 flare_mesh,
683 Material(
684 colour=(*accent, 0.5),
685 blend="alpha",
686 metallic=0.0,
687 roughness=1.0,
688 double_sided=True,
689 emissive_colour=accent,
690 emissive_strength=palette(faction).accent_strength,
691 ),
692 )
693 )
694 node.add_child(
695 _mesh_instance(
696 PLUME_CORE,
697 core_mesh,
698 Material(
699 colour=(1.0, 1.0, 1.0, 0.85),
700 blend="alpha",
701 metallic=0.0,
702 roughness=1.0,
703 double_sided=True,
704 emissive_colour=(1.0, 0.96, 0.88),
705 emissive_strength=palette(faction).accent_strength * 1.6,
706 ),
707 )
708 )
709 return node
710
711
712# ============================================================================
713# Power modules
714#
715# The three generation modules are gameplay-real (they widen the hitbox, they
716# take fire, they pay in fuel and noise) and until now they had no geometry at
717# all: a pilot pressed X, the wings signal fired, the capacitor started filling
718# and the ship on screen did not change in any way. Everything below exists so
719# that the power economy is something the pilot watches rather than infers.
720# ============================================================================
721
722#: The two wing pivots of a solar array, in the order a build returns them.
723SOLAR_WING_PORT = "WingPort"
724SOLAR_WING_STARBOARD = "WingStarboard"
725SOLAR_WING_NAMES = (SOLAR_WING_PORT, SOLAR_WING_STARBOARD)
726
727#: One wing, root to tip and fore to aft, in world units, plus how thick a panel
728#: is. Sized against the Vagrant's 1.7-unit beam: a deployed array is about as
729#: wide again as the hull, which is what makes the widened hitbox honest.
730SOLAR_WING_SPAN = 1.15
731SOLAR_WING_CHORD = 0.85
732SOLAR_PANEL_THICKNESS = 0.05
733#: Where a wing's root sits outboard of the module's own origin.
734SOLAR_ROOT_OFFSET = 0.22
735#: Cells in one panel array: along the hull, then out along the span. The grid
736#: is the whole point of the accent mesh; a single lit slab reads as a mirror.
737SOLAR_CELLS_CHORDWISE = 3
738SOLAR_CELLS_SPANWISE = 4
739#: Fraction of a cell's footprint left as the frame between cells.
740SOLAR_CELL_GAP = 0.22
741
742#: The generator's block, its vent face and the cowling ribs over it.
743GENERATOR_BLOCK = (0.72, 0.46, 0.58)
744GENERATOR_VENT_BARS = 5
745GENERATOR_RIBS = 3
746
747#: The RTG: a small finned drum with one glowing window band.
748RTG_LENGTH = 0.56
749RTG_RADIUS = 0.19
750RTG_FINS = 6
751
752
753def _panel_grid(kit: Kit, *, chord: float, span: float, root: float, thickness: float) -> None:
754 """Lay a grid of emissive cells over one wing's planform."""
755 cell_chord = chord / SOLAR_CELLS_CHORDWISE
756 cell_span = span / SOLAR_CELLS_SPANWISE
757 for i in range(SOLAR_CELLS_CHORDWISE):
758 along = (i - (SOLAR_CELLS_CHORDWISE - 1) / 2.0) * cell_chord
759 for j in range(SOLAR_CELLS_SPANWISE):
760 out = root + (j + 0.5) * cell_span
761 kit.add(
762 _unit_cube(),
763 position=(along, thickness * 0.6, out),
764 scale=(cell_chord * (1.0 - SOLAR_CELL_GAP), thickness * 0.5, cell_span * (1.0 - SOLAR_CELL_GAP)),
765 )
766
767
768def _build_solar_wing_meshes(seed: int) -> tuple[Mesh, Mesh]:
769 """The starboard wing's spar and cell grid, span running out along +Z."""
770 rng = np.random.default_rng((seed, 0x5011))
771 span = SOLAR_WING_SPAN
772 chord = SOLAR_WING_CHORD
773
774 spar = Kit()
775 # The hinge boom out to the root, then the panel's own frame.
776 spar.add(
777 _unit_cylinder(8),
778 position=(0.0, 0.0, SOLAR_ROOT_OFFSET * 0.5),
779 rotation=_Y_TO_Z,
780 scale=(0.11, SOLAR_ROOT_OFFSET, 0.11),
781 )
782 spar.add(
783 _unit_cube(),
784 position=(0.0, 0.0, SOLAR_ROOT_OFFSET + span * 0.5),
785 scale=(chord * 1.02, SOLAR_PANEL_THICKNESS, span),
786 )
787 # Two stiffeners along the span, angled so the array is not a plain slab.
788 for side in (1.0, -1.0):
789 spar.add(
790 _unit_cube(),
791 position=(side * chord * 0.44, SOLAR_PANEL_THICKNESS * 0.9, SOLAR_ROOT_OFFSET + span * 0.5),
792 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.06),
793 scale=(chord * 0.1, SOLAR_PANEL_THICKNESS * 1.4, span * 0.98),
794 )
795 _greeble(spar, rng, span=(chord * 0.3, SOLAR_PANEL_THICKNESS, span * 0.4), count=4, size=0.05)
796
797 cells = Kit()
798 _panel_grid(cells, chord=chord, span=span, root=SOLAR_ROOT_OFFSET, thickness=SOLAR_PANEL_THICKNESS)
799 return spar.build(), cells.build()
800
801
802def build_solar_wings(seed: int = 0) -> Node3D:
803 """A folding solar array: two named wing pivots, each its own spar and cells.
804
805 The pivots are the animation surface. ``power.SolarWings`` yaws each one
806 about the hull's fore-aft axis to fold it and scales it along the span, and
807 dims or hides one when its cells are shot away, so the deployment state and
808 the wing damage the gameplay already tracks are both on the hull.
809 """
810 spar, cells = _cached(("solar_wing", seed), lambda: _build_solar_wing_meshes(seed))
811 flip = (1.0, 1.0, -1.0)
812 mirrored = _cached(("solar_wing_port", seed), lambda: (_scaled(spar, flip), _scaled(cells, flip)))
813 node = Node3D(name="SolarArray")
814 for name, (spar_mesh, cell_mesh) in zip(SOLAR_WING_NAMES, (mirrored, (spar, cells)), strict=True):
815 wing = node.add_child(Node3D(name=name))
816 wing.add_child(_mesh_instance(ROLE_TRIM, spar_mesh, faction_material("player", ROLE_TRIM)))
817 wing.add_child(_mesh_instance(ROLE_ACCENT, cell_mesh, faction_material("player", ROLE_ACCENT)))
818 return node
819
820
821def _build_generator_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh]:
822 """The generator's casing, its cowling ribs and the emissive vent bars."""
823 rng = np.random.default_rng((seed, 0x6E17))
824 length, height, width = GENERATOR_BLOCK
825
826 body = Kit()
827 body.add(_unit_cube(), scale=(length, height, width))
828 body.add(
829 _unit_cylinder(10),
830 position=(-length * 0.42, height * 0.24, 0.0),
831 rotation=_Y_TO_X,
832 scale=(height * 0.34, length * 0.5, height * 0.34),
833 )
834 _greeble(body, rng, span=(length * 0.35, height * 0.5, width * 0.35), count=5, size=0.07)
835
836 trim = Kit()
837 for index in range(GENERATOR_RIBS):
838 along = (index - (GENERATOR_RIBS - 1) / 2.0) * length * 0.3
839 trim.add(_unit_cube(), position=(along, height * 0.54, 0.0), scale=(length * 0.09, height * 0.14, width * 1.04))
840 # The exhaust stack: what the vent light spills out of.
841 trim.add(
842 _unit_cylinder(8),
843 position=(length * 0.3, height * 0.72, 0.0),
844 scale=(width * 0.28, height * 0.5, width * 0.28),
845 )
846
847 accent = Kit()
848 for index in range(GENERATOR_VENT_BARS):
849 along = (index - (GENERATOR_VENT_BARS - 1) / 2.0) * length * 0.17
850 accent.add(
851 _unit_cube(),
852 position=(along, 0.0, width * 0.52),
853 scale=(length * 0.1, height * 0.62, 0.04),
854 )
855 accent.add(_unit_cylinder(8), position=(length * 0.3, height * 0.97, 0.0), scale=(width * 0.2, 0.06, width * 0.2))
856 return body.build(), trim.build(), accent.build()
857
858
859def build_generator(seed: int = 0) -> Node3D:
860 """The fuel generator: a plated block whose vent glows while it is running.
861
862 The vent is the accent mesh, so ``power.Generator`` pulses one emissive
863 strength in step with the hum that is the game's signature warning, and a
864 running generator is visible on the hull from across the sector.
865 """
866 body, trim, accent = _cached(("generator", seed), lambda: _build_generator_meshes(seed))
867 node = Node3D(name="GeneratorArt")
868 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("player", ROLE_HULL)))
869 node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("player", ROLE_TRIM)))
870 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("player", ROLE_ACCENT)))
871 return node
872
873
874def _build_rtg_meshes(seed: int) -> tuple[Mesh, Mesh]:
875 """The RTG's finned drum and its one steady window band."""
876 del seed
877 body = Kit()
878 body.add(_unit_cylinder(10), rotation=_Y_TO_X, scale=(RTG_RADIUS * 2.0, RTG_LENGTH, RTG_RADIUS * 2.0))
879 for index in range(RTG_FINS):
880 angle = index * math.tau / RTG_FINS
881 body.add(
882 _unit_cube(),
883 position=(0.0, math.sin(angle) * RTG_RADIUS * 1.2, math.cos(angle) * RTG_RADIUS * 1.2),
884 rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), -angle),
885 scale=(RTG_LENGTH * 0.82, RTG_RADIUS * 0.6, 0.035),
886 )
887 accent = Kit()
888 accent.add(
889 _unit_cylinder(10),
890 rotation=_Y_TO_X,
891 scale=(RTG_RADIUS * 1.9, RTG_LENGTH * 0.22, RTG_RADIUS * 1.9),
892 )
893 return body.build(), accent.build()
894
895
896def build_rtg(seed: int = 0) -> Node3D:
897 """The radioisotope trickle: a small finned drum with a steady glow band.
898
899 Steady is the whole reading. It is the one source that survives silent
900 running, so its light never pulses and never goes out, and a dark hull with
901 one warm band still lit is the picture of what silent running costs.
902 """
903 body, accent = _cached(("rtg", seed), lambda: _build_rtg_meshes(seed))
904 node = Node3D(name="RtgArt")
905 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("player", ROLE_HULL)))
906 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("player", ROLE_ACCENT)))
907 return node
908
909
910# ============================================================================
911# Enemy archetypes
912# ============================================================================
913
914
915@dataclass(frozen=True)
916class EnemyShape:
917 """Silhouette anchors for one archetype. Read only by the builders below.
918
919 ``length`` runs nose to tail along +X, ``beam`` across the flight plane and
920 ``depth`` through it; ``limbs`` counts whatever the archetype repeats, be
921 that spikes, arms or mortar tubes.
922 """
923
924 length: float
925 beam: float
926 depth: float
927 limbs: int
928 greebles: int
929
930
931#: One entry per archetype in ``balance.ENEMIES``. The player's hull is 2.6
932#: units long, so these are read against that: a Mite is a splinter a third of
933#: the ship's length, a Bombardier is heavier than the ship in every dimension.
934#: Nothing here is smaller than it needs to be to be told apart in a fight,
935#: because an enemy the player cannot name is an enemy the player cannot answer.
936ENEMY_SHAPES: dict[str, EnemyShape] = {
937 "mite": EnemyShape(1.15, 0.36, 0.20, 2, 0),
938 "skimmer": EnemyShape(2.4, 3.2, 0.42, 2, 5),
939 "lancer": EnemyShape(4.0, 0.80, 0.52, 2, 5),
940 "mag_mine": EnemyShape(2.0, 2.0, 2.0, 12, 0),
941 "welder": EnemyShape(2.6, 2.5, 0.95, 2, 10),
942 "bombardier": EnemyShape(3.2, 2.4, 1.5, 3, 12),
943 "screamer": EnemyShape(1.6, 1.6, 3.0, 4, 4),
944 "husk_turret": EnemyShape(2.8, 2.8, 1.7, 1, 10),
945 "herald": EnemyShape(3.2, 2.3, 0.6, 3, 6),
946}
947
948
949def _align_y(direction: tuple[float, float, float]) -> Quat:
950 """The rotation that takes a +Y primitive onto *direction*."""
951 target = Vec3(*direction).normalized()
952 along = float(target.y)
953 if along > 1.0 - 1e-9:
954 return Quat()
955 if along < -1.0 + 1e-9:
956 return Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), math.pi)
957 axis = Vec3(0.0, 1.0, 0.0).cross(target).normalized()
958 return Quat.from_axis_angle(axis, math.acos(max(-1.0, min(1.0, along))))
959
960
961def _sphere_directions(count: int) -> list[tuple[float, float, float]]:
962 """*count* roughly even directions on the unit sphere, for radial spikes."""
963 golden = math.pi * (3.0 - math.sqrt(5.0))
964 directions = []
965 for index in range(count):
966 y = 1.0 - 2.0 * (index + 0.5) / count
967 radius = math.sqrt(max(0.0, 1.0 - y * y))
968 angle = golden * index
969 directions.append((math.cos(angle) * radius, y, math.sin(angle) * radius))
970 return directions
971
972
973def _build_mite(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
974 """A splinter: all nose, no hull, one spark of drive at the back."""
975 half = shape.length / 2.0
976 body.add(
977 _unit_cone(4),
978 position=(half * 0.14, 0.0, 0.0),
979 rotation=_Y_TO_X,
980 scale=(shape.beam, shape.length * 0.92, shape.depth),
981 )
982 for side in (1.0, -1.0):
983 body.add(
984 _unit_cube(),
985 position=(-half * 0.42, 0.0, side * shape.beam * 0.36),
986 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.55),
987 scale=(shape.length * 0.28, shape.depth * 0.7, shape.beam * 0.5),
988 )
989 accent.add(_unit_sphere(5, 6), position=(-half * 0.62, 0.0, 0.0), scale=(shape.beam * 0.85,) * 3)
990
991
992def _build_skimmer(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
993 """A winged wedge with two claw prongs: a thief built to close and grab."""
994 half = shape.length / 2.0
995 body.add(
996 _unit_cone(6),
997 position=(half * 0.18, 0.0, 0.0),
998 rotation=_Y_TO_X,
999 scale=(shape.beam * 0.30, shape.length * 0.8, shape.depth * 1.7),
1000 )
1001 # Wings, swept back hard so the outline is a delta from any distance.
1002 for side in (1.0, -1.0):
1003 body.add(
1004 _unit_cube(),
1005 position=(-shape.length * 0.14, 0.0, side * shape.beam * 0.28),
1006 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.52),
1007 scale=(shape.length * 0.5, shape.depth * 0.4, shape.beam * 0.52),
1008 )
1009 # The claws: two prongs reaching past the nose, canted inward around the maw.
1010 for side in (1.0, -1.0):
1011 body.add(
1012 _unit_cone(6),
1013 position=(half * 0.66, 0.0, side * shape.beam * 0.2),
1014 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.2) * _Y_TO_X,
1015 scale=(shape.depth * 0.55, shape.length * 0.62, shape.depth * 0.55),
1016 )
1017 _greeble(body, rng, span=(shape.length * 0.22, shape.depth * 0.4, shape.beam * 0.3), count=shape.greebles, size=0.1)
1018 # The maw between the claws is the emissive: a Skimmer is read by its grab.
1019 accent.add(
1020 _unit_cube(),
1021 position=(half * 0.5, 0.0, 0.0),
1022 scale=(shape.length * 0.1, shape.depth * 0.5, shape.beam * 0.3),
1023 )
1024 for side in (1.0, -1.0):
1025 accent.add(
1026 _unit_sphere(5, 6),
1027 position=(half * 0.94, 0.0, side * shape.beam * 0.14),
1028 scale=(shape.depth * 0.42,) * 3,
1029 )
1030
1031
1032def _build_lancer(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1033 """A needle with a lit tip: the whole ship is the weapon, and it points."""
1034 half = shape.length / 2.0
1035 body.add(
1036 _unit_cone(8),
1037 position=(half * 0.08, 0.0, 0.0),
1038 rotation=_Y_TO_X,
1039 scale=(shape.beam * 0.42, shape.length * 0.94, shape.beam * 0.42),
1040 )
1041 body.add(
1042 _unit_cylinder(10),
1043 position=(-half * 0.5, 0.0, 0.0),
1044 rotation=_Y_TO_X,
1045 scale=(shape.beam * 0.85, shape.length * 0.1, shape.beam * 0.85),
1046 )
1047 for side in (1.0, -1.0):
1048 body.add(
1049 _unit_cube(),
1050 position=(-half * 0.72, 0.0, side * shape.beam * 0.45),
1051 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.55),
1052 scale=(shape.length * 0.24, shape.depth * 0.55, shape.beam * 0.62),
1053 )
1054 _greeble(body, rng, span=(shape.length * 0.2, shape.depth * 0.3, shape.beam * 0.2), count=shape.greebles, size=0.09)
1055 # The lit tip: the one part of a Lancer a player must watch.
1056 accent.add(_unit_sphere(6, 8), position=(half * 0.9, 0.0, 0.0), scale=(shape.beam * 0.62,) * 3)
1057 accent.add(
1058 _unit_cube(),
1059 position=(half * 0.1, 0.0, 0.0),
1060 scale=(shape.length * 0.52, shape.depth * 0.14, shape.depth * 0.14),
1061 )
1062
1063
1064def _build_mag_mine(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1065 """A spiked sphere: radially symmetric, so it reads the same from any bearing."""
1066 radius = shape.length / 2.0
1067 body.add(_unit_sphere(10, 14), scale=(shape.length * 0.62,) * 3)
1068 for direction in _sphere_directions(shape.limbs):
1069 body.add(
1070 _unit_cone(5),
1071 position=tuple(component * radius * 0.62 for component in direction),
1072 rotation=_align_y(direction),
1073 scale=(radius * 0.3, radius * 0.95, radius * 0.3),
1074 )
1075 # A lit spike tip on every spine: the thing has no front, so the glow
1076 # has to be everywhere the hull is.
1077 accent.add(
1078 _unit_sphere(4, 6),
1079 position=tuple(component * radius * 1.02 for component in direction),
1080 scale=(radius * 0.17,) * 3,
1081 )
1082
1083
1084def _build_welder(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1085 """A crab: a squat body carried behind two long beam arms."""
1086 half = shape.length / 2.0
1087 body.add(
1088 _unit_sphere(8, 12),
1089 position=(-half * 0.24, 0.0, 0.0),
1090 scale=(shape.length * 0.52, shape.depth * 1.05, shape.beam * 0.62),
1091 )
1092 body.add(
1093 _unit_cube(),
1094 position=(-half * 0.62, 0.0, 0.0),
1095 scale=(shape.length * 0.22, shape.depth * 0.7, shape.beam * 0.42),
1096 )
1097 # Two arms, each an upper limb angled out and a forearm angled back in, so
1098 # the pair frames the space the umbilical crosses.
1099 for side in (1.0, -1.0):
1100 body.add(
1101 _unit_cylinder(8),
1102 position=(half * 0.06, 0.0, side * shape.beam * 0.28),
1103 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -side * 0.5) * _Y_TO_X,
1104 scale=(shape.depth * 0.34, shape.length * 0.62, shape.depth * 0.34),
1105 )
1106 body.add(
1107 _unit_cylinder(8),
1108 position=(half * 0.62, 0.0, side * shape.beam * 0.4),
1109 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.42) * _Y_TO_X,
1110 scale=(shape.depth * 0.28, shape.length * 0.5, shape.depth * 0.28),
1111 )
1112 # The emitter head at the end of each arm.
1113 accent.add(
1114 _unit_sphere(6, 8),
1115 position=(half * 0.88, 0.0, side * shape.beam * 0.26),
1116 scale=(shape.depth * 0.55,) * 3,
1117 )
1118 _greeble(
1119 body, rng, span=(shape.length * 0.24, shape.depth * 0.5, shape.beam * 0.24), count=shape.greebles, size=0.11
1120 )
1121 accent.add(
1122 _unit_cube(),
1123 position=(-half * 0.24, shape.depth * 0.42, 0.0),
1124 scale=(shape.length * 0.3, shape.depth * 0.12, shape.beam * 0.16),
1125 )
1126
1127
1128def _build_bombardier(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1129 """A barrel with mortar tubes: heavy, slow and obviously carrying shells."""
1130 half = shape.length / 2.0
1131 body.add(
1132 _unit_cylinder(14),
1133 rotation=_Y_TO_X,
1134 scale=(shape.depth * 1.05, shape.length * 0.78, shape.beam * 0.9),
1135 )
1136 # Hoops, which is what makes a cylinder read as a barrel rather than a pipe.
1137 for offset in (-0.28, 0.06, 0.34):
1138 body.add(
1139 _unit_cylinder(16),
1140 position=(shape.length * offset, 0.0, 0.0),
1141 rotation=_Y_TO_X,
1142 scale=(shape.depth * 1.18, shape.length * 0.05, shape.beam * 1.0),
1143 )
1144 body.add(
1145 _unit_cone(10),
1146 position=(-half * 0.52, 0.0, 0.0),
1147 rotation=_Y_TO_NEG_X,
1148 scale=(shape.depth * 0.9, shape.length * 0.3, shape.beam * 0.8),
1149 )
1150 _greeble(
1151 body, rng, span=(shape.length * 0.28, shape.depth * 0.55, shape.beam * 0.3), count=shape.greebles, size=0.13
1152 )
1153 # Mortar tubes on the spine, tilted up: the shells come from up there.
1154 for index in range(shape.limbs):
1155 offset = (index - (shape.limbs - 1) / 2.0) * shape.beam * 0.3
1156 tilt = Quat.from_axis_angle(Vec3(0.0, 0.0, 1.0), -0.5)
1157 body.add(
1158 _unit_cylinder(10),
1159 position=(shape.length * 0.1, shape.depth * 0.6, offset),
1160 rotation=tilt,
1161 scale=(shape.depth * 0.28, shape.length * 0.42, shape.depth * 0.28),
1162 )
1163 accent.add(
1164 _unit_cylinder(8),
1165 position=(shape.length * 0.28, shape.depth * 0.86, offset),
1166 rotation=tilt,
1167 scale=(shape.depth * 0.22, shape.length * 0.05, shape.depth * 0.22),
1168 )
1169 accent.add(
1170 _unit_cylinder(16),
1171 position=(shape.length * 0.2, 0.0, 0.0),
1172 rotation=_Y_TO_X,
1173 scale=(shape.depth * 1.12, shape.length * 0.04, shape.beam * 0.96),
1174 )
1175
1176
1177def _build_screamer(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1178 """An antenna cluster: a small hull under a mast that is all broadcast."""
1179 body.add(_unit_cylinder(10), scale=(shape.length * 0.5, shape.depth * 0.28, shape.beam * 0.5))
1180 body.add(
1181 _unit_cylinder(8),
1182 position=(0.0, shape.depth * 0.34, 0.0),
1183 scale=(shape.length * 0.11, shape.depth * 0.62, shape.length * 0.11),
1184 )
1185 # A stack of crossbars, each shorter than the one below, each ending in a
1186 # dish: an aerial, from the outline alone.
1187 for index in range(shape.limbs):
1188 height = shape.depth * (0.34 + 0.16 * (index + 1))
1189 reach = shape.beam * (0.62 - 0.09 * index)
1190 angle = math.tau * index / shape.limbs + 0.4
1191 for sign in (1.0, -1.0):
1192 direction = (math.cos(angle) * sign, 0.0, math.sin(angle) * sign)
1193 body.add(
1194 _unit_cube(),
1195 position=(direction[0] * reach * 0.5, height, direction[2] * reach * 0.5),
1196 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -angle),
1197 scale=(reach, shape.depth * 0.05, shape.depth * 0.05),
1198 )
1199 accent.add(
1200 _unit_cone(6),
1201 position=(direction[0] * reach, height, direction[2] * reach),
1202 rotation=_align_y(direction),
1203 scale=(shape.depth * 0.11, shape.depth * 0.1, shape.depth * 0.11),
1204 )
1205 _greeble(body, rng, span=(shape.length * 0.2, shape.depth * 0.12, shape.beam * 0.2), count=shape.greebles, size=0.1)
1206 accent.add(_unit_sphere(7, 9), position=(0.0, shape.depth * 0.98, 0.0), scale=(shape.length * 0.2,) * 3)
1207
1208
1209def _build_husk_turret(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1210 """A gun bolted to a dead hull: broken base, live drum, one barrel."""
1211 half = shape.length / 2.0
1212 # The wreck it is built on: a torn slab with a plate or two still standing.
1213 body.add(
1214 _displaced_rock(int(rng.integers(0, 1 << 20)), shape.length * 0.42, relief=0.3, rings=8, segments=12),
1215 scale=(1.15, 0.34, 1.0),
1216 )
1217 for _ in range(2):
1218 body.add(
1219 _unit_cube(),
1220 position=(
1221 float(rng.uniform(-half * 0.6, half * 0.6)),
1222 shape.depth * 0.16,
1223 float(rng.uniform(-half * 0.6, half * 0.6)),
1224 ),
1225 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(rng.uniform(0.0, math.tau))),
1226 scale=(shape.length * 0.3, shape.depth * 0.28, shape.beam * 0.1),
1227 )
1228 _greeble(body, rng, span=(shape.length * 0.3, shape.depth * 0.2, shape.beam * 0.3), count=shape.greebles, size=0.13)
1229 # The drum: the live half, sat proud of the wreck so it is unmistakable.
1230 body.add(
1231 _unit_cylinder(14),
1232 position=(0.0, shape.depth * 0.42, 0.0),
1233 scale=(shape.length * 0.44, shape.depth * 0.45, shape.beam * 0.44),
1234 )
1235 accent.add(
1236 _unit_cylinder(16),
1237 position=(0.0, shape.depth * 0.6, 0.0),
1238 scale=(shape.length * 0.46, shape.depth * 0.06, shape.beam * 0.46),
1239 )
1240
1241
1242def _build_turret_barrel_meshes(shape: EnemyShape) -> tuple[Mesh, Mesh]:
1243 """The Husk turret's barrel, as its own build: the class swivels it."""
1244 body, accent = Kit(), Kit()
1245 body.add(
1246 _unit_cylinder(10),
1247 position=(shape.length * 0.32, 0.0, 0.0),
1248 rotation=_Y_TO_X,
1249 scale=(shape.depth * 0.3, shape.length * 0.9, shape.depth * 0.3),
1250 )
1251 body.add(
1252 _unit_cylinder(10),
1253 position=(0.0, 0.0, 0.0),
1254 rotation=_Y_TO_X,
1255 scale=(shape.depth * 0.46, shape.length * 0.22, shape.depth * 0.46),
1256 )
1257 accent.add(
1258 _unit_cylinder(10),
1259 position=(shape.length * 0.76, 0.0, 0.0),
1260 rotation=_Y_TO_X,
1261 scale=(shape.depth * 0.34, shape.length * 0.06, shape.depth * 0.34),
1262 )
1263 return body.build(), accent.build()
1264
1265
1266def build_turret_barrel(seed: int = 0) -> Node3D:
1267 """The Husk turret's swivelling barrel, pointing along +X."""
1268 body, accent = _cached(("turret_barrel", seed), lambda: _build_turret_barrel_meshes(ENEMY_SHAPES["husk_turret"]))
1269 node = Node3D(name="BarrelArt")
1270 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1271 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("enemy", ROLE_ACCENT)))
1272 return node
1273
1274
1275def _build_herald(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1276 """A shard of the Shrike: angular, bladed, lit down its spine."""
1277 half = shape.length / 2.0
1278 body.add(
1279 _unit_cone(3),
1280 position=(half * 0.12, 0.0, 0.0),
1281 rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), 0.5) * _Y_TO_X,
1282 scale=(shape.beam * 0.34, shape.length * 0.95, shape.depth * 1.5),
1283 )
1284 # Two quills swept back off the shard, the Shrike's own signature.
1285 for side in (1.0, -1.0):
1286 body.add(
1287 _unit_cone(3),
1288 position=(-half * 0.34, 0.0, side * shape.beam * 0.3),
1289 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 1.05 + math.pi) * _Y_TO_X,
1290 scale=(shape.depth * 0.9, shape.length * 0.62, shape.depth * 0.9),
1291 )
1292 _greeble(body, rng, span=(shape.length * 0.22, shape.depth * 0.4, shape.beam * 0.3), count=shape.greebles, size=0.1)
1293 # Slits down the spine: lit segments, the way the Shrike's own body is lit.
1294 for index in range(shape.limbs):
1295 offset = (index - (shape.limbs - 1) / 2.0) * shape.length * 0.22
1296 accent.add(
1297 _unit_cube(),
1298 position=(offset, shape.depth * 0.2, 0.0),
1299 scale=(shape.length * 0.05, shape.depth * 0.16, shape.beam * 0.26),
1300 )
1301 accent.add(_unit_sphere(5, 7), position=(half * 0.82, 0.0, 0.0), scale=(shape.depth * 0.5,) * 3)
1302
1303
1304#: One builder per archetype. Silhouette follows role: a player who has met a
1305#: Lancer once can name the next one from its outline before it is in range.
1306ENEMY_BUILDERS = {
1307 "mite": _build_mite,
1308 "skimmer": _build_skimmer,
1309 "lancer": _build_lancer,
1310 "mag_mine": _build_mag_mine,
1311 "welder": _build_welder,
1312 "bombardier": _build_bombardier,
1313 "screamer": _build_screamer,
1314 "husk_turret": _build_husk_turret,
1315 "herald": _build_herald,
1316}
1317
1318
1319def _build_enemy_meshes(archetype: str, seed: int) -> tuple[Mesh, Mesh]:
1320 shape = ENEMY_SHAPES[archetype]
1321 rng = np.random.default_rng((seed, hash(archetype) & 0xFFFF))
1322 body, accent = Kit(), Kit()
1323 ENEMY_BUILDERS[archetype](body, accent, shape, rng)
1324 return body.build(), accent.build()
1325
1326
1327def enemy_mesh(archetype: str, seed: int = 0) -> Mesh:
1328 """The merged hull mesh for *archetype*, for multimesh instancing.
1329
1330 ``MiteShoal`` draws its whole shoal from one ``MultiMeshInstance3D``, which
1331 wants a mesh rather than a node; this is that mesh, cached so every shoal
1332 in a sector shares it.
1333 """
1334 return _enemy_meshes(archetype, seed)[0]
1335
1336
1337def _enemy_meshes(archetype: str, seed: int) -> tuple[Mesh, Mesh]:
1338 if archetype not in balance.ENEMIES:
1339 raise ValueError(f"Unknown archetype {archetype!r}; expected one of {', '.join(balance.ENEMIES)}")
1340 return _cached(("enemy", archetype, seed), lambda: _build_enemy_meshes(archetype, seed))
1341
1342
1343def build_enemy(archetype: str, seed: int = 0) -> Node3D:
1344 """One enemy archetype's art, nose along +X.
1345
1346 *archetype* must be a key of ``balance.ENEMIES``; anything else raises, so
1347 a typo in a wave recipe fails where it is written rather than spawning an
1348 invisible enemy.
1349 """
1350 body, accent = _enemy_meshes(archetype, seed)
1351 node = Node3D(name="EnemyArt")
1352 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1353 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("enemy", ROLE_ACCENT)))
1354 return node
1355
1356
1357# ============================================================================
1358# Bounty set pieces
1359# ============================================================================
1360
1361#: Silhouette anchors for the two set-piece hostiles, read against the same
1362#: player-hull yardstick as :data:`ENEMY_SHAPES`. The Warden is a bastion as
1363#: broad as it is long; the Magistrates share one hunter frame leaner than a
1364#: Lancer and half again the length of the player's hull.
1365WARDEN_SHAPE = EnemyShape(6.2, 6.2, 2.0, 8, 16)
1366MAGISTRATE_SHAPE = EnemyShape(4.2, 2.0, 0.66, 2, 8)
1367
1368#: Radius the Warden's shield emitters orbit at, and how many blocks draw the
1369#: visible cone. The cone spans ``balance.SHIELD_ARC_DEGREES``, the same arc
1370#: the class blocks with, so what the player sees is what the maths honours.
1371WARDEN_ARC_RADIUS = 4.1
1372WARDEN_ARC_SEGMENTS = 5
1373
1374
1375def _build_warden_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh, Mesh]:
1376 """The bastion drum, its static trim, the shield cone, and the gun."""
1377 shape = WARDEN_SHAPE
1378 rng = np.random.default_rng((seed, 0xBA57))
1379 body, accent, arc, gun = Kit(), Kit(), Kit(), Kit()
1380 radius = shape.length / 2.0
1381
1382 # The drum: broad and squat, a fort rather than a hull, with an armour
1383 # skirt of leaning slabs the whole way round.
1384 body.add(_unit_cylinder(16), scale=(shape.length * 0.84, shape.depth, shape.beam * 0.84))
1385 body.add(
1386 _unit_cylinder(16),
1387 position=(0.0, shape.depth * 0.3, 0.0),
1388 scale=(shape.length * 0.6, shape.depth * 0.5, shape.beam * 0.6),
1389 )
1390 for index in range(shape.limbs):
1391 angle = math.tau * index / shape.limbs
1392 direction = (math.cos(angle), 0.0, math.sin(angle))
1393 body.add(
1394 _unit_cube(),
1395 position=(direction[0] * radius * 0.78, -shape.depth * 0.08, direction[2] * radius * 0.78),
1396 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -angle),
1397 scale=(shape.length * 0.1, shape.depth * 0.85, shape.beam * 0.28),
1398 )
1399 _greeble(
1400 body, rng, span=(shape.length * 0.26, shape.depth * 0.5, shape.beam * 0.26), count=shape.greebles, size=0.16
1401 )
1402
1403 # Static light: the crown ring and the core lamp, lit whether it is awake
1404 # or not, because a Warden is a landmark before it is a fight.
1405 accent.add(
1406 _unit_cylinder(18),
1407 position=(0.0, shape.depth * 0.46, 0.0),
1408 scale=(shape.length * 0.64, shape.depth * 0.06, shape.beam * 0.64),
1409 )
1410 accent.add(_unit_sphere(8, 10), position=(0.0, shape.depth * 0.66, 0.0), scale=(shape.beam * 0.2,) * 3)
1411
1412 # The visible arc: emitter blocks spread over the shield cone, centred on
1413 # local +X, with a taller pylon on each edge so the gap reads at a glance.
1414 half_arc = math.radians(balance.SHIELD_ARC_DEGREES) / 2.0
1415 for index in range(WARDEN_ARC_SEGMENTS):
1416 angle = -half_arc + 2.0 * half_arc * index / (WARDEN_ARC_SEGMENTS - 1)
1417 direction = (math.cos(angle), 0.0, -math.sin(angle))
1418 arc.add(
1419 _unit_cube(),
1420 position=(direction[0] * WARDEN_ARC_RADIUS, shape.depth * 0.1, direction[2] * WARDEN_ARC_RADIUS),
1421 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), angle),
1422 scale=(0.26, shape.depth * 0.7, 1.05),
1423 )
1424 for sign in (1.0, -1.0):
1425 angle = sign * half_arc
1426 direction = (math.cos(angle), 0.0, -math.sin(angle))
1427 arc.add(
1428 _unit_cone(6),
1429 position=(
1430 direction[0] * (WARDEN_ARC_RADIUS + 0.35),
1431 shape.depth * 0.45,
1432 direction[2] * (WARDEN_ARC_RADIUS + 0.35),
1433 ),
1434 scale=(0.55, shape.depth * 0.75, 0.55),
1435 )
1436
1437 # The emplacement: a mantlet and one long barrel down local +X.
1438 gun.add(_unit_cube(), position=(0.0, shape.depth * 0.58, 0.0), scale=(1.3, shape.depth * 0.45, 1.05))
1439 gun.add(
1440 _unit_cylinder(10),
1441 position=(shape.length * 0.3, shape.depth * 0.58, 0.0),
1442 rotation=_Y_TO_X,
1443 scale=(0.5, shape.length * 0.56, 0.5),
1444 )
1445 return body.build(), accent.build(), arc.build(), gun.build()
1446
1447
1448def build_warden(seed: int = 0) -> Node3D:
1449 """The Warden's bastion, with the arc and the gun on children of their own.
1450
1451 The drum and its trim are static. ``ArcArt`` carries the emissive shield
1452 cone, centred on local +X, so the class shows the live arc by yawing that
1453 one node; ``GunArt`` carries the emplacement, barrel along local +X, and is
1454 yawed at whatever the Warden is shooting.
1455 """
1456 body, accent, arc, gun = _cached(("warden", seed), lambda: _build_warden_meshes(seed))
1457 node = Node3D(name="WardenArt")
1458 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1459 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("enemy", ROLE_ACCENT)))
1460 arc_art = node.add_child(Node3D(name="ArcArt"))
1461 arc_art.add_child(_mesh_instance(ROLE_ACCENT, arc, faction_material("enemy", ROLE_ACCENT)))
1462 gun_art = node.add_child(Node3D(name="GunArt"))
1463 gun_art.add_child(_mesh_instance(ROLE_HULL, gun, faction_material("enemy", ROLE_HULL)))
1464 return node
1465
1466
1467def _magistrate_frame(body: Kit, accent: Kit, rng: np.random.Generator) -> None:
1468 """The hunter frame all three Magistrates share: a sleek dart, finned aft.
1469
1470 Sharing the frame is the point: a Magistrate is recognised twice, once as
1471 "a hunter is here" from the family outline, and once by name from the
1472 variant geometry bolted onto it.
1473 """
1474 shape = MAGISTRATE_SHAPE
1475 half = shape.length / 2.0
1476 body.add(
1477 _unit_cone(8),
1478 position=(half * 0.22, 0.0, 0.0),
1479 rotation=_Y_TO_X,
1480 scale=(shape.beam * 0.34, shape.length * 0.8, shape.depth * 1.1),
1481 )
1482 body.add(
1483 _unit_cone(8),
1484 position=(-half * 0.5, 0.0, 0.0),
1485 rotation=_Y_TO_NEG_X,
1486 scale=(shape.beam * 0.3, shape.length * 0.36, shape.depth * 0.95),
1487 )
1488 for side in (1.0, -1.0):
1489 body.add(
1490 _unit_cube(),
1491 position=(-half * 0.42, 0.0, side * shape.beam * 0.3),
1492 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.6),
1493 scale=(shape.length * 0.34, shape.depth * 0.32, shape.beam * 0.4),
1494 )
1495 _greeble(
1496 body, rng, span=(shape.length * 0.2, shape.depth * 0.35, shape.beam * 0.16), count=shape.greebles, size=0.08
1497 )
1498 # The family's shared light: one strip down the spine, nose to waist.
1499 accent.add(
1500 _unit_cube(),
1501 position=(half * 0.2, shape.depth * 0.28, 0.0),
1502 scale=(shape.length * 0.44, shape.depth * 0.1, shape.depth * 0.16),
1503 )
1504
1505
1506def _variant_harrier(body: Kit, accent: Kit) -> None:
1507 """Marshal Harrier: twin booms around the needle nose, tips lit."""
1508 shape = MAGISTRATE_SHAPE
1509 half = shape.length / 2.0
1510 for side in (1.0, -1.0):
1511 body.add(
1512 _unit_cylinder(8),
1513 position=(half * 0.1, 0.0, side * shape.beam * 0.52),
1514 rotation=_Y_TO_X,
1515 scale=(shape.depth * 0.5, shape.length * 0.95, shape.depth * 0.5),
1516 )
1517 accent.add(
1518 _unit_sphere(5, 7),
1519 position=(half * 0.6, 0.0, side * shape.beam * 0.52),
1520 scale=(shape.depth * 0.42,) * 3,
1521 )
1522
1523
1524def _variant_caracara(body: Kit, accent: Kit) -> None:
1525 """Vice-Marshal Caracara: a broad waist ring and two claws past the nose."""
1526 shape = MAGISTRATE_SHAPE
1527 half = shape.length / 2.0
1528 body.add(
1529 _unit_cylinder(16),
1530 position=(-half * 0.1, 0.0, 0.0),
1531 scale=(shape.beam * 1.5, shape.depth * 0.34, shape.beam * 1.5),
1532 )
1533 accent.add(
1534 _unit_cylinder(18),
1535 position=(-half * 0.1, shape.depth * 0.18, 0.0),
1536 scale=(shape.beam * 1.52, shape.depth * 0.05, shape.beam * 1.52),
1537 )
1538 for side in (1.0, -1.0):
1539 body.add(
1540 _unit_cone(6),
1541 position=(half * 0.7, 0.0, side * shape.beam * 0.34),
1542 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -side * 0.18) * _Y_TO_X,
1543 scale=(shape.depth * 0.5, shape.length * 0.52, shape.depth * 0.5),
1544 )
1545 accent.add(
1546 _unit_sphere(4, 6),
1547 position=(half * 0.95, 0.0, side * shape.beam * 0.28),
1548 scale=(shape.depth * 0.32,) * 3,
1549 )
1550
1551
1552def _variant_nightjar(body: Kit, accent: Kit) -> None:
1553 """Magistrate Nightjar: the stretched shard, and the one shuttered lamp."""
1554 shape = MAGISTRATE_SHAPE
1555 half = shape.length / 2.0
1556 body.add(
1557 _unit_cone(6),
1558 position=(half * 0.05, shape.depth * 0.34, 0.0),
1559 rotation=_Y_TO_X,
1560 scale=(shape.beam * 0.2, shape.length * 1.05, shape.depth * 0.5),
1561 )
1562 # The cowl, then the lamp half-proud of it: shuttered, not hidden.
1563 body.add(
1564 _unit_cylinder(8),
1565 position=(half * 0.42, shape.depth * 0.2, 0.0),
1566 rotation=_Y_TO_X,
1567 scale=(shape.depth * 1.05, shape.length * 0.16, shape.depth * 1.05),
1568 )
1569 accent.add(
1570 _unit_sphere(7, 9),
1571 position=(half * 0.5, shape.depth * 0.2, 0.0),
1572 scale=(shape.depth * 0.62,) * 3,
1573 )
1574
1575
1576#: One variant builder per named bounty hunter, keyed the way bounty.py spells
1577#: them. Each earns the silhouette its codex line promises.
1578MAGISTRATE_VARIANTS = {
1579 "harrier": _variant_harrier,
1580 "caracara": _variant_caracara,
1581 "nightjar": _variant_nightjar,
1582}
1583
1584
1585def _build_magistrate_meshes(magistrate_id: str, seed: int) -> tuple[Mesh, Mesh]:
1586 # Seeded from integers only: a hull must build the same way in every
1587 # process, and Python's string hashing is salted per run.
1588 rng = np.random.default_rng((seed, list(MAGISTRATE_VARIANTS).index(magistrate_id)))
1589 body, accent = Kit(), Kit()
1590 _magistrate_frame(body, accent, rng)
1591 MAGISTRATE_VARIANTS[magistrate_id](body, accent)
1592 return body.build(), accent.build()
1593
1594
1595def build_magistrate(magistrate_id: str, seed: int = 0, *, accent: tuple | None = None) -> Node3D:
1596 """One named bounty hunter's art, nose along +X.
1597
1598 All three ride the same sleek hunter frame in the enemy palette's magenta
1599 accent language; the variant geometry is what carries the name. *accent*,
1600 when given, tints the emissive to that Magistrate's own shade of it.
1601 """
1602 if magistrate_id not in MAGISTRATE_VARIANTS:
1603 raise ValueError(f"Unknown magistrate {magistrate_id!r}; expected one of {', '.join(MAGISTRATE_VARIANTS)}")
1604 body, glow = _cached(("magistrate", magistrate_id, seed), lambda: _build_magistrate_meshes(magistrate_id, seed))
1605 node = Node3D(name="MagistrateArt")
1606 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1607 accent_material = faction_material("enemy", ROLE_ACCENT)
1608 if accent is not None:
1609 shade = tuple(float(channel) for channel in accent[:3])
1610 accent_material.colour = (*shade, 1.0)
1611 accent_material.emissive_colour = shade
1612 node.add_child(_mesh_instance(ROLE_ACCENT, glow, accent_material))
1613 return node
1614
1615
1616# ============================================================================
1617# The Shrike
1618# ============================================================================
1619
1620
1621def _build_shrike_meshes(index: int, seed: int) -> tuple[Mesh, Mesh]:
1622 total_length = PLAYER_SHIP_LENGTH * balance.SHRIKE_LENGTH_MULT
1623 segment_length = total_length / SHRIKE_SEGMENT_COUNT
1624 # Taper from the head back down the body.
1625 taper = 1.0 - 0.62 * (index / max(SHRIKE_SEGMENT_COUNT - 1, 1))
1626 girth = segment_length * 0.62 * taper
1627 rng = np.random.default_rng((seed, index))
1628
1629 body, accent = Kit(), Kit()
1630 body.add(_unit_sphere(8, 12), scale=(segment_length * 0.95, girth, girth * 1.15))
1631 # Quill fins: the shearable plating along the spine.
1632 for side in (1.0, -1.0):
1633 body.add(
1634 _unit_cone(8),
1635 position=(0.0, girth * 0.2, side * girth * 0.55),
1636 rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), side * 0.5) * _Y_TO_Z,
1637 scale=(girth * 0.34, girth * 1.5, girth * 0.14),
1638 )
1639 _greeble(body, rng, span=(segment_length * 0.3, girth * 0.5, girth * 0.4), count=6, size=girth * 0.18)
1640
1641 if index == 0:
1642 # The head carries the lantern housing and the jaw shear.
1643 jaw = csg_combine(
1644 _scaled(_unit_cone(12), (girth * 1.3, segment_length * 1.1, girth * 1.3), rotation=_Y_TO_X),
1645 _scaled(_unit_cube(), (segment_length * 0.5, girth * 0.5, girth * 2.0)),
1646 CSGOperation.SUBTRACT,
1647 )
1648 body.add(jaw, position=(segment_length * 0.55, 0.0, 0.0))
1649 accent.add(
1650 _unit_sphere(8, 12),
1651 position=(segment_length * 0.62, 0.0, 0.0),
1652 scale=(girth * 0.5, girth * 0.5, girth * 0.5),
1653 )
1654 else:
1655 accent.add(
1656 _unit_cylinder(10),
1657 position=(0.0, 0.0, 0.0),
1658 rotation=_Y_TO_X,
1659 scale=(girth * 0.72, segment_length * 0.06, girth * 0.72),
1660 )
1661 return body.build(), accent.build()
1662
1663
1664def build_shrike_segment(index: int, seed: int = 0) -> Node3D:
1665 """One body segment of the Shrike, index 0 being the lantern head.
1666
1667 Segments are built along +X and chained by the hunter module; the taper and
1668 the quill count fall off with *index*, so the eel reads as one animal.
1669 """
1670 if index < 0:
1671 raise ValueError(f"Shrike segment index must be non-negative, got {index}")
1672 body, accent = _cached(("shrike", index, seed), lambda: _build_shrike_meshes(index, seed))
1673 node = Node3D(name="ShrikeSegmentArt")
1674 node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("hunter", ROLE_HULL)))
1675 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("hunter", ROLE_ACCENT)))
1676 return node
1677
1678
1679# ============================================================================
1680# Environment geometry: rocks, deposits, wrecks, stations
1681# ============================================================================
1682
1683
1684def _build_deposit_meshes(rich: bool, seed: int) -> tuple[Mesh, Mesh]:
1685 radius = 2.2 if rich else 1.6
1686 rock = _displaced_rock(seed, radius, relief=0.38)
1687 rng = np.random.default_rng((seed, int(rich)))
1688 veins = Kit()
1689 # Ore veins sit in the deepest folds, so the surface layer visibly runs out
1690 # before the core the design pays double for.
1691 points = rock.positions
1692 picks = rng.choice(len(points), size=10 if rich else 6, replace=False)
1693 for pick in picks:
1694 spot = points[int(pick)] * 0.97
1695 veins.add(
1696 _unit_sphere(5, 6),
1697 position=(float(spot[0]), float(spot[1]), float(spot[2])),
1698 scale=radius * (0.22 if rich else 0.15),
1699 )
1700 return rock, veins.build()
1701
1702
1703def build_deposit(rich: bool, seed: int = 0) -> Node3D:
1704 """A minable deposit: a noise-displaced rock veined with visible ore.
1705
1706 A rich node is bigger and more heavily veined, which is the only warning
1707 the player gets that tapping it costs signature.
1708 """
1709 rock, veins = _cached(("deposit", bool(rich), seed), lambda: _build_deposit_meshes(bool(rich), seed))
1710 node = Node3D(name="DepositArt")
1711 node.add_child(_mesh_instance(ROLE_HULL, rock, faction_material("environment", ROLE_HULL)))
1712 accent = faction_material("environment", ROLE_ACCENT)
1713 if rich:
1714 accent.emissive_strength = palette("environment").accent_strength * 2.0
1715 node.add_child(_mesh_instance(ROLE_ACCENT, veins, accent))
1716 return node
1717
1718
1719def build_asteroid(seed: int = 0, radius: float = 3.0) -> Node3D:
1720 """A plain noise-displaced rock: cover, collision and clutter.
1721
1722 Not in the module contract but wanted by every sector layout, so it lives
1723 beside the deposit it shares its displacement with.
1724 """
1725 if radius <= 0.0:
1726 raise ValueError(f"Asteroid radius must be positive, got {radius}")
1727 rock = _cached(("asteroid", seed, round(float(radius), 3)), lambda: (_displaced_rock(seed, radius),))[0]
1728 node = Node3D(name="AsteroidArt")
1729 node.add_child(_mesh_instance(ROLE_HULL, rock, faction_material("environment", ROLE_HULL)))
1730 return node
1731
1732
1733def _build_wreck_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh]:
1734 rng = np.random.default_rng(seed)
1735 length = float(rng.uniform(5.0, 8.0))
1736 beam = length * 0.34
1737 # A torn hull: the blast hole is a real subtraction, so the break reads as
1738 # a break from any angle rather than a painted-on decal.
1739 torso = csg_combine(
1740 _scaled(_unit_cube(), (length * 0.5, beam * 0.6, beam)),
1741 _scaled(_unit_sphere(6, 8), (beam * 1.5, beam * 1.5, beam * 1.5)),
1742 CSGOperation.SUBTRACT,
1743 )
1744 hull = Kit()
1745 hull.add(torso)
1746 hull.add(
1747 _unit_cone(10),
1748 position=(length * 0.36, 0.0, 0.0),
1749 rotation=_Y_TO_X,
1750 scale=(beam * 0.7, length * 0.28, beam * 0.7),
1751 )
1752 _greeble(hull, rng, span=(length * 0.3, beam * 0.3, beam * 0.4), count=18, size=0.22)
1753
1754 # Exposed spars, swept as extruded ribs out of the break.
1755 trim = Kit()
1756 for _ in range(4):
1757 start = (float(rng.uniform(-length * 0.1, length * 0.2)), 0.0, 0.0)
1758 centreline = [
1759 start,
1760 (start[0] + float(rng.uniform(0.6, 1.4)), float(rng.uniform(-0.5, 0.5)), float(rng.uniform(-0.9, 0.9))),
1761 (start[0] + float(rng.uniform(1.6, 2.8)), float(rng.uniform(-0.9, 0.9)), float(rng.uniform(-1.8, 1.8))),
1762 ]
1763 trim.add(Mesh.extrude_path(centreline, sides=5, radius=float(rng.uniform(0.06, 0.14))))
1764
1765 # A cargo lamp still running on the wreck's last cell.
1766 accent = Kit()
1767 accent.add(_unit_cube(), position=(-length * 0.3, beam * 0.32, 0.0), scale=(0.5, 0.05, 0.12))
1768 return hull.build(), trim.build(), accent.build()
1769
1770
1771def build_wreck(seed: int = 0) -> Node3D:
1772 """A salvageable hulk: torn plating, exposed spars and one live lamp."""
1773 hull, trim, accent = _cached(("wreck", seed), lambda: _build_wreck_meshes(seed))
1774 node = Node3D(name="WreckArt")
1775 node.add_child(_mesh_instance(ROLE_HULL, hull, faction_material("environment", ROLE_HULL)))
1776 node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("environment", ROLE_TRIM)))
1777 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("environment", ROLE_ACCENT)))
1778 return node
1779
1780
1781def _build_depot_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh]:
1782 rng = np.random.default_rng(seed)
1783 radius = 5.0
1784 height = 2.6
1785 # Core drum with the docking bay cut through the rim: the cutter is offset
1786 # past the radius so the bay opens outward instead of leaving a sealed void.
1787 drum = csg_combine(
1788 _scaled(_unit_cylinder(16), (radius * 2.0, height, radius * 2.0)),
1789 _scaled(_unit_cube(), (radius * 1.4, height * 0.55, radius * 0.7), position=(radius * 0.7, 0.0, 0.0)),
1790 CSGOperation.SUBTRACT,
1791 )
1792 hull = Kit()
1793 hull.add(drum)
1794 _greeble(hull, rng, span=(radius * 0.7, height * 0.5, radius * 0.7), count=24, size=0.3)
1795
1796 # Habitation ring, swept as a closed extrusion around the drum.
1797 ring_points = [
1798 (math.cos(math.tau * i / 24) * radius * 1.5, 0.0, math.sin(math.tau * i / 24) * radius * 1.5) for i in range(24)
1799 ]
1800 trim = Kit()
1801 trim.add(Mesh.extrude_path(ring_points, sides=6, radius=0.32, closed=True))
1802 for index in range(4):
1803 angle = math.tau * index / 4
1804 trim.add(
1805 _unit_cube(),
1806 position=(math.cos(angle) * radius * 1.25, 0.0, math.sin(angle) * radius * 1.25),
1807 rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -angle),
1808 scale=(radius * 0.6, 0.16, 0.3),
1809 )
1810 trim.add(_unit_cylinder(8), position=(0.0, height * 0.9, 0.0), scale=(0.22, height * 0.9, 0.22))
1811
1812 # Approach lights around the bay mouth, the thing a docking pilot reads.
1813 accent = Kit()
1814 for index in range(10):
1815 angle = math.tau * index / 10
1816 accent.add(
1817 _unit_sphere(5, 6),
1818 position=(math.cos(angle) * radius * 1.02, height * 0.42, math.sin(angle) * radius * 1.02),
1819 scale=0.22,
1820 )
1821 accent.add(_unit_cube(), position=(radius * 0.75, 0.0, 0.0), scale=(0.12, height * 0.4, radius * 0.62))
1822 return hull.build(), trim.build(), accent.build()
1823
1824
1825def build_depot(seed: int = 0) -> Node3D:
1826 """A drift depot: drum, habitation ring, cut docking bay and approach lights."""
1827 hull, trim, accent = _cached(("depot", seed), lambda: _build_depot_meshes(seed))
1828 node = Node3D(name="DepotArt")
1829 node.add_child(_mesh_instance(ROLE_HULL, hull, faction_material("environment", ROLE_HULL)))
1830 node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("environment", ROLE_TRIM)))
1831 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("environment", ROLE_ACCENT)))
1832 return node
1833
1834
1835def _build_vault_meshes(seed: int) -> tuple[Mesh, Mesh]:
1836 rng = np.random.default_rng(seed)
1837 size = 2.8
1838 # A sealed block with a recessed hatch: the Shrike's shear opens it later.
1839 shell = csg_combine(
1840 _scaled(_unit_cube(), (size, size * 0.8, size)),
1841 _scaled(_unit_cylinder(12), (size * 0.66, size * 0.3, size * 0.66), rotation=_Y_TO_X),
1842 CSGOperation.SUBTRACT,
1843 )
1844 hull = Kit()
1845 hull.add(shell)
1846 _greeble(hull, rng, span=(size * 0.35, size * 0.4, size * 0.35), count=10, size=0.22)
1847 accent = Kit()
1848 accent.add(
1849 _unit_cylinder(12), position=(size * 0.42, 0.0, 0.0), rotation=_Y_TO_X, scale=(size * 0.5, 0.06, size * 0.5)
1850 )
1851 return hull.build(), accent.build()
1852
1853
1854def build_vault(seed: int = 0) -> Node3D:
1855 """A sealed vault: the twenty-second hack, and what the Shrike shears open.
1856
1857 Not in the module contract but the sector needs one, and it belongs with
1858 the station kit it is cut from.
1859 """
1860 hull, accent = _cached(("vault", seed), lambda: _build_vault_meshes(seed))
1861 node = Node3D(name="VaultArt")
1862 node.add_child(_mesh_instance(ROLE_HULL, hull, faction_material("environment", ROLE_HULL)))
1863 node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("environment", ROLE_ACCENT)))
1864 return node
1865
1866
1867# ============================================================================
1868# Mesh cache
1869# ============================================================================
1870
1871_MESH_CACHE: dict[tuple, tuple[Mesh, ...]] = {}
1872
1873
1874def _cached(key: tuple, factory) -> tuple:
1875 """Build once per key and share the result: meshes here are never mutated."""
1876 cached = _MESH_CACHE.get(key)
1877 if cached is None:
1878 cached = tuple(factory())
1879 _MESH_CACHE[key] = cached
1880 return cached
1881
1882
1883def clear_mesh_cache() -> None:
1884 """Drop every cached build. For tests and for a sector teardown."""
1885 _MESH_CACHE.clear()
1886
1887
1888# ============================================================================
1889# The nebula sky
1890# ============================================================================
1891
1892
1893@dataclass(frozen=True)
1894class ActGrade:
1895 """The look of one act: ambient, fog, exposure, bloom and nebula tints."""
1896
1897 id: str
1898 ambient_colour: tuple[float, float, float, float]
1899 ambient_energy: float
1900 fog_colour: tuple[float, float, float, float]
1901 fog_density: float
1902 tonemap_exposure: float
1903 bloom_threshold: float
1904 bloom_intensity: float
1905 sky_top: tuple[float, float, float, float]
1906 nebula_deep: tuple[float, float, float]
1907 nebula_bright: tuple[float, float, float]
1908
1909
1910#: Shallows cold cyan, Claims amber rust, Deep blood-violet on black. Exposure
1911#: falls act by act: the Deep is the darkest screen in the game.
1912ACT_GRADES: dict[int, ActGrade] = {
1913 1: ActGrade(
1914 "shallows",
1915 ambient_colour=(0.06, 0.11, 0.14, 1.0),
1916 ambient_energy=0.85,
1917 fog_colour=(0.05, 0.12, 0.16, 1.0),
1918 fog_density=0.010,
1919 tonemap_exposure=1.05,
1920 bloom_threshold=1.0,
1921 bloom_intensity=0.75,
1922 sky_top=(0.014, 0.030, 0.042, 1.0),
1923 nebula_deep=(0.02, 0.10, 0.14),
1924 nebula_bright=(0.16, 0.52, 0.60),
1925 ),
1926 2: ActGrade(
1927 "claims",
1928 ambient_colour=(0.12, 0.08, 0.05, 1.0),
1929 ambient_energy=0.70,
1930 fog_colour=(0.13, 0.08, 0.04, 1.0),
1931 fog_density=0.014,
1932 tonemap_exposure=0.95,
1933 bloom_threshold=1.05,
1934 bloom_intensity=0.85,
1935 sky_top=(0.036, 0.022, 0.012, 1.0),
1936 nebula_deep=(0.12, 0.06, 0.02),
1937 nebula_bright=(0.58, 0.32, 0.12),
1938 ),
1939 3: ActGrade(
1940 "deep",
1941 ambient_colour=(0.09, 0.03, 0.07, 1.0),
1942 ambient_energy=0.55,
1943 fog_colour=(0.09, 0.02, 0.06, 1.0),
1944 fog_density=0.020,
1945 tonemap_exposure=0.82,
1946 bloom_threshold=1.15,
1947 bloom_intensity=1.0,
1948 sky_top=(0.026, 0.006, 0.018, 1.0),
1949 nebula_deep=(0.10, 0.01, 0.05),
1950 nebula_bright=(0.48, 0.08, 0.26),
1951 ),
1952}
1953
1954
1955def act_grade(act: int) -> ActGrade:
1956 """The grade for *act* (1 to ``balance.CHART_ACTS``), raising outside that."""
1957 try:
1958 return ACT_GRADES[act]
1959 except KeyError:
1960 raise ValueError(f"No grade for act {act!r}; acts are 1 to {balance.CHART_ACTS}") from None
1961
1962
1963def _resample_square(field: np.ndarray, size: int) -> np.ndarray:
1964 """Bilinearly resize a square field to ``size`` by ``size``.
1965
1966 Sampling at texel centres, so the cloud layer arrives at the face
1967 resolution as a smooth gradient rather than as visible blocks.
1968 """
1969 source = field.shape[0]
1970 if source == size:
1971 return field
1972 coords = (np.arange(size, dtype=np.float64) + 0.5) * source / size - 0.5
1973 low = np.clip(np.floor(coords).astype(np.int64), 0, source - 1)
1974 high = np.clip(low + 1, 0, source - 1)
1975 weight = np.clip(coords - low, 0.0, 1.0)
1976 rows = field[low] * (1.0 - weight)[:, None] + field[high] * weight[:, None]
1977 return rows[:, low] * (1.0 - weight)[None, :] + rows[:, high] * weight[None, :]
1978
1979
1980def nebula_cubemap_faces(seed: int = 0, act: int = 1, size: int = NEBULA_FACE_SIZE) -> list[np.ndarray]:
1981 """Six float32 RGBA cube faces of a procedural nebula, in Vulkan face order.
1982
1983 The result is linear HDR: the clouds stay dim so environments read
1984 near-black, and the star field runs above 1.0 so bloom picks it up. Feeding
1985 it to ``WorldEnvironment.environment_map`` drives both the skybox and the
1986 image-based lighting from the same bake.
1987
1988 The two layers are baked at different resolutions on purpose. Clouds are
1989 evaluated on a :data:`NEBULA_CLOUD_SIZE` grid and resampled up, because
1990 noise is the expensive half and a soft cloud gains nothing from detail;
1991 stars are stamped at the full face resolution, because the face size is
1992 what decides how small a star can be.
1993
1994 Stars are Gaussian splats at sub-texel centres rather than single texels.
1995 A texel of a cube face is a third of a degree of arc even at this
1996 resolution, and a lone hot texel magnified onto the screen is a square: the
1997 splat is what makes it a round point of light instead.
1998 """
1999 if size < 8:
2000 raise ValueError(f"Nebula face size must be at least 8, got {size}")
2001 cloud_size = min(int(size), NEBULA_CLOUD_SIZE)
2002 grade = act_grade(act)
2003 clouds = FastNoiseLite(seed=int(seed), noise_type=NoiseType.SIMPLEX, frequency=1.0)
2004 clouds.fractal_type = FractalType.FBM
2005 clouds.fractal_octaves = 5
2006 filaments = FastNoiseLite(seed=int(seed) + 977, noise_type=NoiseType.SIMPLEX, frequency=2.4)
2007 filaments.fractal_type = FractalType.RIDGED
2008 filaments.fractal_octaves = 4
2009
2010 grid = (np.arange(cloud_size, dtype=np.float64) + 0.5) / cloud_size * 2.0 - 1.0
2011 u, v = np.meshgrid(grid, grid)
2012 deep = np.asarray(grade.nebula_deep, dtype=np.float32)
2013 bright = np.asarray(grade.nebula_bright, dtype=np.float32)
2014 scale = (size / NEBULA_FACE_SIZE) ** 2
2015 star_count = max(STAR_MIN_PER_FACE, int(round(STARS_PER_FACE * scale)))
2016
2017 faces: list[np.ndarray] = []
2018 for index, (right, up, forward) in enumerate(_FACE_BASIS):
2019 direction = (
2020 np.asarray(forward)[None, None, :]
2021 + u[..., None] * np.asarray(right)[None, None, :]
2022 + v[..., None] * np.asarray(up)[None, None, :]
2023 )
2024 direction /= np.linalg.norm(direction, axis=-1, keepdims=True)
2025 x = direction[..., 0].ravel()
2026 y = direction[..., 1].ravel()
2027 z = direction[..., 2].ravel()
2028
2029 body = (np.asarray(clouds.get_noise_3d_array(x, y, z)).reshape(cloud_size, cloud_size) * 0.5 + 0.5) ** 2.2
2030 threads = np.asarray(filaments.get_noise_3d_array(x, y, z)).reshape(cloud_size, cloud_size)
2031 thread = np.clip(threads, 0.0, 1.0) ** 3.0
2032 mix = _resample_square(np.clip(body * 0.8 + thread * 0.45, 0.0, 1.0), size).astype(np.float32)
2033
2034 face = np.empty((size, size, 4), dtype=np.float32)
2035 cloud = deep[None, None, :] * 0.35 + (bright - deep)[None, None, :] * mix[..., None]
2036 face[..., :3] = cloud * NEBULA_CLOUD_ENERGY
2037 _stamp_stars(face, seed=seed, act=act, face_index=index, count=star_count)
2038 face[..., 3] = 1.0
2039 faces.append(np.maximum(face, 0.0))
2040 return faces
2041
2042
2043def _stamp_stars(face: np.ndarray, *, seed: int, act: int, face_index: int, count: int) -> None:
2044 """Add *count* soft, tinted stars to one cube face, in place.
2045
2046 Seeded per face so no two skies repeat, and splatted rather than masked, so
2047 the cost is the star count and not the face area.
2048 """
2049 size = face.shape[0]
2050 rng = np.random.default_rng((int(seed), act, face_index))
2051 centres = rng.uniform(0.0, float(size), size=(count, 2))
2052 energy = rng.uniform(STAR_ENERGY_MIN, STAR_ENERGY_MAX, count)
2053 # One number per star bends its hue from cold blue-white to warm amber.
2054 temperature = rng.uniform(-1.0, 1.0, count)
2055 tint = np.stack(
2056 (
2057 1.0 + STAR_TINT * temperature,
2058 np.ones(count),
2059 1.0 - STAR_TINT * temperature,
2060 ),
2061 axis=1,
2062 )
2063
2064 steps = np.arange(-STAR_KERNEL_RADIUS, STAR_KERNEL_RADIUS + 1)
2065 offset_rows, offset_columns = (axis.ravel() for axis in np.meshgrid(steps, steps, indexing="ij"))
2066 anchor = np.floor(centres).astype(np.int64)
2067 delta_rows = anchor[:, 0, None] + offset_rows[None, :] + 0.5 - centres[:, 0, None]
2068 delta_columns = anchor[:, 1, None] + offset_columns[None, :] + 0.5 - centres[:, 1, None]
2069 weights = np.exp(-(delta_rows**2 + delta_columns**2) / (2.0 * STAR_SIGMA**2))
2070
2071 # Wrapping rather than clipping: a star near an edge keeps its round
2072 # profile instead of piling its whole splat into the last texel.
2073 rows = np.mod(anchor[:, 0, None] + offset_rows[None, :], size)
2074 columns = np.mod(anchor[:, 1, None] + offset_columns[None, :], size)
2075 light = (weights * energy[:, None])[..., None] * tint[:, None, :]
2076 np.add.at(face, (rows.ravel(), columns.ravel(), slice(0, 3)), light.reshape(-1, 3).astype(np.float32))
2077
2078
2079def apply_nebula_sky(env: WorldEnvironment, seed: int = 0, act: int = 1, size: int = NEBULA_FACE_SIZE) -> None:
2080 """Install a freshly baked nebula as the scene's skybox and IBL source."""
2081 grade = act_grade(act)
2082 env.sky_mode = "colour"
2083 env.sky_colour_top = grade.sky_top
2084 env.sky_colour_bottom = grade.sky_top
2085 env.environment_map = {"faces": nebula_cubemap_faces(seed, act, size)}
2086
2087
2088# ============================================================================
2089# Per-act grading and exposure
2090# ============================================================================
2091
2092
2093def apply_act_grading(env: WorldEnvironment, act: int, *, silent_running: bool = False) -> None:
2094 """Grade the scene for *act*, optionally under silent running.
2095
2096 Silent running drops the whole scene's exposure by
2097 :data:`SILENT_RUNNING_EXPOSURE_MULT`: the quietest screen is also the
2098 darkest, which is the point.
2099 """
2100 grade = act_grade(act)
2101 env.ambient_light_colour = grade.ambient_colour
2102 env.ambient_light_energy = grade.ambient_energy
2103 env.fog_enabled = True
2104 env.fog_colour = grade.fog_colour
2105 env.fog_density = grade.fog_density
2106 env.tonemap_mode = "aces"
2107 env.tonemap_exposure = grade.tonemap_exposure * (SILENT_RUNNING_EXPOSURE_MULT if silent_running else 1.0)
2108 env.bloom_enabled = True
2109 env.bloom_threshold = grade.bloom_threshold
2110 env.bloom_intensity = grade.bloom_intensity
2111
2112
2113def configure_environment(
2114 env: WorldEnvironment,
2115 act: int = 1,
2116 seed: int = 0,
2117 *,
2118 silent_running: bool = False,
2119 nebula_size: int = NEBULA_FACE_SIZE,
2120) -> WorldEnvironment:
2121 """Set a run scene's whole look in one call: sky, grade and post chain.
2122
2123 SSAO grounds the wrecks, screen-space reflections carry the plating, and
2124 bloom is driven entirely by the emissives the readability contract owns.
2125 """
2126 apply_nebula_sky(env, seed, act, nebula_size)
2127 apply_act_grading(env, act, silent_running=silent_running)
2128 env.ssao_enabled = True
2129 env.ssao_radius = 0.6
2130 env.ssao_intensity = 1.1
2131 env.ssr_enabled = True
2132 env.ssr_intensity = 0.55
2133 env.ssr_roughness_cutoff = 0.45
2134 env.fxaa_enabled = True
2135 return env
2136
2137
2138def build_environment(
2139 act: int = 1,
2140 seed: int = 0,
2141 *,
2142 silent_running: bool = False,
2143 nebula_size: int = NEBULA_FACE_SIZE,
2144) -> WorldEnvironment:
2145 """A fully configured ``WorldEnvironment`` for a run scene."""
2146 return configure_environment(
2147 WorldEnvironment(name="Environment"),
2148 act,
2149 seed,
2150 silent_running=silent_running,
2151 nebula_size=nebula_size,
2152 )
2153
2154
2155# ============================================================================
2156# Placement helper
2157# ============================================================================
2158
2159
2160def place_on_plane(node: Node3D, x: float, z: float, *, heading: float = 0.0) -> Node3D:
2161 """Put *node* on the flight plane at ``(x, z)`` facing *heading*.
2162
2163 A build points along +X, so a yaw about +Y by the heading matches
2164 :func:`runtime.heading_to_direction` exactly.
2165 """
2166 node.position = Vec3(float(x), PLANE_Y, float(z))
2167 node.rotation = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(heading))
2168 return node