shrike/vfx.py¶
Part of SHRIKE.
1"""SHRIKE's effects library: pooled bursts, the two live emitters and the dust.
2
3Everything here is tinted from :mod:`shrike.artkit`'s palettes, so ownership
4survives a screen with two hundred grains on it: player warm gold and white,
5enemy cold magenta, telegraphs white, the hunter's lantern a bruised violet.
6
7Five shapes of effect live here.
8
9* **One-shots** go through :meth:`Vfx.spawn`, which draws from a fixed pool
10 parented under the run scene. Nothing allocates per hit, and the pool never
11 grows past :data:`VFX_POOL_SIZE`: under a barrage the oldest burst is
12 recycled rather than the frame budget.
13* **The thrust plume**, :class:`EngineRibbon`, is geometry rather than grains.
14 A drive under power is one continuous jet, and a trail of separate warm puffs
15 behind a hull is the visual grammar of something detonating: the flare is a
16 tapered cone anchored on the nozzle, stretched down the thrust vector, with a
17 tight mote stream inside it for texture.
18* **The lantern**, :class:`LanternCone`, is the Shrike's head: a drawn beam
19 with a shadow-casting spot inside it and its own dust, sweeping bright,
20 dimming for the one second that warns of a burn, and flaring on the burn. Its
21 shell is capped at :data:`LANTERN_CONE_EMISSIVE_MAX`, because the same beam
22 that has to be unmissable also crosses the HUD band, and a bloomed white plane
23 over HULL and SIGNATURE explains nothing about the blow it just landed.
24* **The dock ring**, :class:`DockRing`, is a bay drawn on the ground: dashed and
25 cool outside, lit and breathing once the hull is inside it. A radius in a
26 sensor is not a place, and a pilot crossed one three times looking for the
27 shop that was in it.
28* **The dust field**, :class:`DustField`, is the one thing here that is not a
29 flourish. Space with nothing in it has no motion cue at all, and a ship at
30 cruise on an empty screen reads as a ship sitting still. The field tiles two
31 parallax bands of motes around the camera focus so speed is always visible.
32 The motes are soft, dim and tinted with the act, because grain that is bright
33 and white is not dust: it is confetti in front of the fight.
34
35The engine ribbon is also where notoriety becomes visible: it consumes
36``NOTORIETY_CHANGED`` and brightens with the player's reputation, which is the
37one tell a hunting Magistrate reads before the player does.
38"""
39
40from __future__ import annotations
41
42import math
43from dataclasses import dataclass, field
44
45import numpy as np
46
47from simvx.core import (
48 GPUParticles3D,
49 Material,
50 Mesh,
51 MeshInstance3D,
52 MultiMesh,
53 MultiMeshInstance3D,
54 Node3D,
55 Property,
56 Quat,
57 SpotLight3D,
58 Vec2,
59 Vec3,
60)
61
62from . import balance
63from .artkit import ACT_GRADES, PLUME_CORE, PLUME_FLARE, act_grade, build_engine_plume, palette
64from .runtime import CameraRig, Groups, Services, SignalNames, camera_plane_extents, to_plane
65
66# ============================================================================
67# Module-local constants
68#
69# Presentation numbers, deliberately not in balance.py: none of them changes
70# what the game does, only what it looks like while doing it.
71# ============================================================================
72
73#: Concurrent one-shot bursts. Past this the oldest is recycled.
74VFX_POOL_SIZE = 48
75
76#: Extra seconds a burst is held before it returns to the pool, so the last
77#: grains of a long-lived emitter are not cut off mid-fade.
78VFX_RETIRE_MARGIN_S = 0.15
79
80#: Engine-trail emissive multiplier at zero and at maximum notoriety.
81TRAIL_BRIGHTNESS_QUIET = 1.0
82TRAIL_BRIGHTNESS_NOTORIOUS = 2.6
83
84#: The plume's mouth radius at full throttle, and the fraction of it a plume
85#: keeps at the lightest touch on the thrust, so a nudge is still a jet.
86PLUME_RADIUS_UNITS = 0.44
87PLUME_IDLE_FRACTION = 0.55
88#: How far the flare reaches behind the nozzle at the extremes of the throttle.
89#: The ship is 2.6 units long, so a full burn trails a plume longer than the
90#: hull: that length difference is the tell that the burner is lit.
91PLUME_LENGTH_MIN_UNITS = 1.2
92PLUME_LENGTH_MAX_UNITS = 3.6
93#: Opacity of the outer flare and the inner core at full throttle.
94PLUME_FLARE_ALPHA = 0.55
95PLUME_CORE_ALPHA = 0.7
96#: The core burns toward white: the hottest part of a flame is the least
97#: saturated part, and it is what makes the plume read as heat.
98PLUME_CORE_COLOUR = (1.0, 0.96, 0.88)
99PLUME_CORE_ENERGY = 1.0
100
101# -- The afterburner, as something to look at --------------------------------
102#
103# The throttle alone carries the burn from 0.65 to 1.0, which moves the plume
104# by a third of its length and a quarter of its brightness: a difference a
105# pilot can measure but not notice. Shift is a resource decision and it has to
106# read as a state change, so the burn is a multiplier laid over the throttle
107# rather than the top of the throttle's own range. At full burn the jet is
108# longer than the hull twice over and its core has gone from candle-white to
109# the blue-white of a torch.
110
111#: What a full burn does to the flare's length, its bore and its heat.
112PLUME_BURN_LENGTH_MULT = 1.9
113PLUME_BURN_RADIUS_MULT = 1.35
114PLUME_BURN_GLOW_MULT = 2.1
115#: The burning core's colour: hotter than the cruise core, and colder in hue,
116#: which is what "hotter" looks like.
117PLUME_BURN_CORE_COLOUR = (0.78, 0.90, 1.0)
118
119#: Dust motes are neutral and cold on purpose: the readability contract gives
120#: warm gold to player fire and magenta to enemy fire, and the one thing that
121#: is on screen at all times must claim neither.
122DUST_NEAR_COLOUR = (0.60, 0.68, 0.84)
123DUST_FAR_COLOUR = (0.34, 0.40, 0.54)
124#: Movement of the camera focus, in world units, below which the dust field
125#: leaves its transforms alone.
126DUST_REFRESH_EPSILON = 0.01
127#: How far a mote's colour is carried toward the act's own hue. Grain that is
128#: white in every sector is confetti; grain that is the colour of the place is
129#: the place, so the tint moves with the act while the neutral base keeps the
130#: motes from competing with either faction's fire.
131DUST_ACT_TINT = 0.55
132#: A mote's opacity. Solid grain reads as debris in the foreground: these are
133#: meant to be barely there, and it is their motion that carries the cue.
134DUST_ALPHA = 0.55
135#: Size attenuation across a band's tile: a mote at the tile edge, which is the
136#: furthest the camera can see it, is drawn at this fraction of its own size.
137#: Without it every mote is the same size wherever it is, which flattens the
138#: field into a screen-door pattern rather than a volume of dust.
139DUST_FAR_SIZE_FRACTION = 0.35
140#: Rings and segments of the mote sphere. Motes are two or three pixels across,
141#: so this is as much sphere as the screen can resolve.
142DUST_MOTE_RINGS = 4
143DUST_MOTE_SEGMENTS = 6
144
145#: The health pip: a thin flat bar for the hulls big enough to be worth a
146#: read-out. Deliberately small and dim next to everything else on the plane,
147#: because it is chrome on a screen whose readability contract belongs to fire.
148PIP_WIDTH_UNITS = 2.4
149PIP_THICKNESS_UNITS = 0.05
150PIP_DEPTH_UNITS = 0.22
151#: The fill is drawn slightly narrower than its track, so the track reads as a
152#: frame around it rather than as a second bar.
153PIP_FILL_INSET = 0.72
154PIP_TRACK_COLOUR = (0.06, 0.05, 0.08)
155PIP_TRACK_ALPHA = 0.75
156#: Enemy-side chrome carries the enemy's own hue, not the player's warm gold.
157PIP_FILL_COLOUR = palette("enemy").accent
158PIP_FILL_ENERGY = 1.6
159#: Seconds the pip takes to fade all the way in or out.
160PIP_FADE_S = 0.35
161
162#: How far the pilot can see across the flight plane in any direction, from the
163#: camera's own geometry rather than guessed. The rig biases its focus so the
164#: warning above and below the hull is equal, which makes the visible field a
165#: disc of this radius centred on the ship. Anything that decides how far away
166#: a threat may act from measures itself against this: what the player cannot
167#: see must not be allowed to hurt them.
168VISIBLE_REACH_UNITS = sum(camera_plane_extents()) * 0.5
169
170#: The lantern's cone half-angles, degrees, and its reach in world units.
171#:
172#: The reach is that visible field, not a number chosen for feel: the cone that
173#: burns you is the cone you can see coming, and at the old 46 units the beast
174#: could stand two screens off and burn from a place the player had no way to
175#: look at. Deriving it from the camera keeps the light, the burn and the
176#: window agreeing whatever shape the window is.
177LANTERN_INNER_CONE_DEGREES = 9.0
178LANTERN_OUTER_CONE_DEGREES = 17.0
179LANTERN_RANGE = VISIBLE_REACH_UNITS
180LANTERN_INTENSITY = 7.0
181#: Fraction of full intensity during the pre-burn dim that warns of a sweep.
182LANTERN_PREBURN_DIM = 0.22
183#: What the lantern does on the burn frame: the sweep, hard over. The burn is
184#: the only frame in the cycle that costs hull, so it is the only one that
185#: flares, and the flare is what the pilot can point at afterwards.
186LANTERN_BURN_FLARE = 2.4
187
188#: The cone's own geometry, per phase, as the alpha of a bruised-violet shell.
189#:
190#: A ``SpotLight3D`` and nothing else is invisible here. The arena is empty
191#: space: the beam crosses no floor and no wall, so there is no surface for it
192#: to fall on, and a sweep held at zero intensity on top of that landed on
193#: nothing twice over. A pilot could watch the beast in full view for a whole
194#: arrival and never see it attack, because the attack was a light with nothing
195#: to light. The shaft is therefore drawn as translucent emissive geometry, and
196#: the light is what it casts rather than what it is.
197LANTERN_CONE_ALPHA_SWEEP = 0.20
198LANTERN_CONE_ALPHA_PREBURN = 0.09
199LANTERN_CONE_ALPHA_BURN = 0.44
200#: Emissive strength of the shell per phase, so the flare reads as heat and not
201#: only as opacity. Multiplies the hunter palette's own accent strength.
202LANTERN_CONE_GLOW_SWEEP = 1.0
203LANTERN_CONE_GLOW_PREBURN = 0.45
204LANTERN_CONE_GLOW_BURN = 2.6
205#: Ceiling on the shell's own emissive strength, whatever the phase asks for.
206#:
207#: The hunter accent is authored at 6.5, which is right for a slim trim line on
208#: a hull seen across the arena and several stops too hot for a shaft that fills
209#: a third of the frame. Multiplied by the burn's glow it put the cone far over
210#: white, bloomed, and a blind playtest photographed the burn frame with the
211#: hull arc, the HULL number and the SIGNATURE reading all inside it and none of
212#: them readable. The cap keeps the shell a bruised violet flare that is still
213#: the brightest thing on the screen without being a white plate over the one
214#: set of readings the burn exists to explain. The HUD plates its readings as
215#: well (``hud.COLOUR_READING_PLATE``); neither fix is sufficient alone, because
216#: the arcs are not text and the plates are not the beam.
217LANTERN_CONE_EMISSIVE_MAX = 3.4
218#: How the flare decays: seconds held hard over, then seconds easing back to
219#: the sweep look for the rest of the burn window.
220#:
221#: The hull is spent on the first frame the ship is in the cone, so everything
222#: after that frame is punctuation. Held hard over for the whole window, the
223#: punctuation was the problem: nine per cent of the frame went over luminance
224#: 200, the label naming the blow was written into the middle of it, and the
225#: burn's own art was the only thing stopping the burn from being read. It
226#: still opens as the brightest thing on the screen; it just gets out of the
227#: way of the readouts it is supposed to be explaining.
228LANTERN_BURN_FLASH_S = 0.14
229LANTERN_BURN_FALLOFF_S = 0.26
230#: Segments around the shell's base. A cone this wide on screen needs enough
231#: of them that its edge reads as a beam rather than as a faceted prism.
232LANTERN_CONE_SEGMENTS = 24
233
234
235@dataclass(frozen=True)
236class EffectSpec:
237 """One one-shot effect's emitter shape.
238
239 ``faction`` names the :mod:`shrike.artkit` palette the grains are tinted
240 from, which is what keeps the readability contract true in the particle
241 layer as well as on the hulls.
242 """
243
244 id: str
245 faction: str
246 amount: int
247 lifetime: float
248 speed: float
249 speed_variance: float = 0.0
250 spread: float = 0.6
251 spread_pattern: str = "gaussian"
252 damping: float = 0.0
253 start_scale: float = 0.3
254 end_scale: float = 0.0
255 explosiveness: float = 1.0
256 streak: float = 0.0
257 emission_radius: float = 0.0
258 #: Multiplies the palette accent to make a grain hotter than its hull.
259 energy: float = 1.0
260 gravity: tuple[float, float, float] = field(default=(0.0, 0.0, 0.0))
261
262
263#: Every effect id :meth:`Vfx.spawn` accepts.
264EFFECTS: dict[str, EffectSpec] = {
265 "muzzle": EffectSpec(
266 "muzzle",
267 "player",
268 amount=24,
269 lifetime=0.14,
270 speed=22.0,
271 speed_variance=6.0,
272 spread=0.28,
273 start_scale=0.22,
274 streak=0.03,
275 energy=1.6,
276 ),
277 "impact": EffectSpec(
278 "impact",
279 "player",
280 amount=40,
281 lifetime=0.32,
282 speed=13.0,
283 speed_variance=5.0,
284 spread=1.1,
285 spread_pattern="ring",
286 damping=3.0,
287 start_scale=0.2,
288 energy=1.4,
289 ),
290 # A wound, not a kill. Small, brief and warm: it is the player's own round
291 # coming apart on a hull, and it has to read at a glance without competing
292 # with the explosion that ends the same hull a second later.
293 "enemy_hit": EffectSpec(
294 "enemy_hit",
295 "player",
296 amount=16,
297 lifetime=0.18,
298 speed=15.0,
299 speed_variance=6.0,
300 spread=1.3,
301 spread_pattern="ring",
302 damping=4.0,
303 start_scale=0.14,
304 end_scale=0.02,
305 streak=0.04,
306 energy=2.0,
307 ),
308 # Where a held beam is actually biting. Cheap and continuous rather than
309 # loud and one-off: it is re-emitted several times a second for as long as
310 # the beam is on a surface, so it has to read as a shower of chips coming
311 # off rock and never as an explosion. A beam crossing empty space emits
312 # none, which is the whole point of it.
313 "beam_spark": EffectSpec(
314 "beam_spark",
315 "player",
316 amount=10,
317 lifetime=0.22,
318 speed=9.0,
319 speed_variance=4.0,
320 spread=1.5,
321 spread_pattern="ring",
322 damping=5.0,
323 start_scale=0.11,
324 end_scale=0.01,
325 streak=0.03,
326 energy=1.7,
327 ),
328 "shield_spark": EffectSpec(
329 "shield_spark",
330 "player",
331 amount=64,
332 lifetime=0.45,
333 speed=17.0,
334 speed_variance=7.0,
335 spread=0.9,
336 spread_pattern="disc",
337 damping=2.2,
338 start_scale=0.16,
339 streak=0.05,
340 energy=1.8,
341 ),
342 "explosion": EffectSpec(
343 "explosion",
344 "enemy",
345 amount=180,
346 lifetime=0.85,
347 speed=19.0,
348 speed_variance=9.0,
349 spread=1.6,
350 spread_pattern="star",
351 damping=1.6,
352 start_scale=0.55,
353 end_scale=0.05,
354 emission_radius=0.4,
355 energy=2.2,
356 ),
357 "scrap_confetti": EffectSpec(
358 "scrap_confetti",
359 "environment",
360 amount=48,
361 lifetime=1.4,
362 speed=7.0,
363 speed_variance=3.5,
364 spread=1.4,
365 spread_pattern="ring",
366 damping=1.1,
367 start_scale=0.18,
368 end_scale=0.08,
369 energy=1.2,
370 ),
371 "salvage_motes": EffectSpec(
372 "salvage_motes",
373 "environment",
374 amount=64,
375 lifetime=1.0,
376 speed=9.0,
377 speed_variance=2.0,
378 spread=0.5,
379 spread_pattern="disc",
380 start_scale=0.12,
381 end_scale=0.02,
382 explosiveness=0.2,
383 emission_radius=1.6,
384 energy=1.6,
385 ),
386 # A negative launch speed on a ring of grains born at the spool radius is
387 # what makes the implosion collapse inwards rather than blow outwards.
388 "warp_implosion": EffectSpec(
389 "warp_implosion",
390 "player",
391 amount=220,
392 lifetime=0.7,
393 speed=-26.0,
394 speed_variance=4.0,
395 spread=1.2,
396 spread_pattern="ring",
397 start_scale=0.1,
398 end_scale=0.5,
399 emission_radius=7.0,
400 energy=2.4,
401 ),
402 # The mote stream inside the plume. Everything about it is chosen to read
403 # as one continuous jet: no explosiveness, so grains leave the nozzle at an
404 # even rate rather than in puffs; a spread of a couple of degrees, so the
405 # stream stays a line; a short life at high speed, so a grain is a streak
406 # and never a floating blob.
407 "engine_ribbon": EffectSpec(
408 "engine_ribbon",
409 "player",
410 amount=34,
411 lifetime=0.11,
412 speed=22.0,
413 speed_variance=1.5,
414 spread=0.02,
415 spread_pattern="disc",
416 damping=0.0,
417 start_scale=0.035,
418 end_scale=0.006,
419 streak=0.02,
420 explosiveness=0.0,
421 energy=1.0,
422 ),
423 "lantern_cone": EffectSpec(
424 "lantern_cone",
425 "hunter",
426 amount=96,
427 lifetime=0.9,
428 speed=14.0,
429 speed_variance=3.0,
430 spread=0.3,
431 damping=1.0,
432 start_scale=0.5,
433 end_scale=0.1,
434 explosiveness=0.3,
435 energy=2.0,
436 ),
437}
438
439
440def effect_spec(effect: str) -> EffectSpec:
441 """The spec for *effect*, raising on an unknown id."""
442 try:
443 return EFFECTS[effect]
444 except KeyError:
445 raise ValueError(f"Unknown effect {effect!r}; expected one of {', '.join(EFFECTS)}") from None
446
447
448def effect_colours(
449 effect: str, faction: str | None = None
450) -> tuple[tuple[float, float, float, float], tuple[float, float, float, float]]:
451 """The ``(start, end)`` grain colours for *effect* under *faction*.
452
453 The start colour is the palette accent driven past 1.0 by the effect's
454 energy so bloom catches it; the end colour is the same hue at zero alpha,
455 so a burst fades out rather than turning grey.
456 """
457 spec = effect_spec(effect)
458 accent = palette(faction or spec.faction).accent
459 start = tuple(min(channel * spec.energy, 4.0) for channel in accent)
460 return (*start, 1.0), (*start, 0.0)
461
462
463class Vfx(Node3D):
464 """One pooled one-shot burst: a particle emitter that returns itself.
465
466 Callers do not construct these. :meth:`spawn` is the whole interface; a Vfx
467 is idle and invisible until the pool hands it out, and retires itself once
468 its grains have lived out their lifetime.
469 """
470
471 visible = Property(
472 False,
473 coerce=bool,
474 hint="Whether this node and its subtree are drawn",
475 on_change="_on_visible_changed",
476 )
477
478 def __init__(self, **kwargs):
479 super().__init__(**kwargs)
480 self.particles: GPUParticles3D | None = None
481 self._age = 0.0
482 self._retire_at = 0.0
483 self._live = False
484
485 @staticmethod
486 def spawn(tree, effect: str, position: Vec3, **params) -> None:
487 """Play a pooled one-shot *effect* at *position*.
488
489 Keyword parameters: ``faction`` overrides the effect's default palette
490 (an elite's magenta explosion versus a player-ship debris burst),
491 ``direction`` aims a directional effect such as ``"muzzle"``, ``scale``
492 sizes the whole burst, and ``colour`` overrides the grain colour
493 outright for the rare case that is not a faction (the white telegraph
494 flash is ``faction="telegraph"``, not a colour override).
495
496 ``"engine_ribbon"`` and ``"lantern_cone"`` are available here as single
497 puffs; their persistent forms are :class:`EngineRibbon` and
498 :class:`LanternCone`, which a caller parents and keeps.
499 """
500 VfxPool.for_tree(tree).emit(effect, position, **params)
501
502 @property
503 def live(self) -> bool:
504 """Whether this burst is currently playing."""
505 return self._live
506
507 def on_ready(self):
508 self.particles = self.add_child(GPUParticles3D(name="Particles", emitting=False, one_shot=True))
509
510 def play(
511 self,
512 effect: str,
513 position: Vec3,
514 *,
515 faction: str | None = None,
516 direction: Vec3 | None = None,
517 scale: float = 1.0,
518 colour: tuple[float, float, float, float] | None = None,
519 ) -> None:
520 """Configure and start this burst. Called by :class:`VfxPool` only."""
521 spec = effect_spec(effect)
522 emitter = self.particles
523 if emitter is None:
524 raise RuntimeError("Vfx.play before the node is ready")
525 self.position = position
526 start, end = effect_colours(effect, faction)
527 if colour is not None:
528 start = tuple(colour)
529 end = (*tuple(colour)[:3], 0.0)
530
531 emitter.amount = spec.amount
532 emitter.lifetime = spec.lifetime
533 emitter.speed = spec.speed * scale
534 emitter.speed_variance = spec.speed_variance * scale
535 emitter.spread = spec.spread
536 emitter.spread_pattern = spec.spread_pattern
537 emitter.damping = spec.damping
538 emitter.start_scale = spec.start_scale * scale
539 emitter.end_scale = spec.end_scale * scale
540 emitter.explosiveness = spec.explosiveness
541 emitter.streak = spec.streak
542 emitter.emission_shape = "sphere" if spec.emission_radius > 0.0 else "point"
543 emitter.emission_radius = spec.emission_radius * scale
544 # Space, not a battlefield: nothing here falls.
545 emitter.gravity = spec.gravity
546 emitter.direction = tuple(direction) if direction is not None else (1.0, 0.0, 0.0)
547 emitter.start_colour = start
548 emitter.end_colour = end
549 emitter.one_shot = True
550 emitter.restart()
551
552 self._age = 0.0
553 self._retire_at = spec.lifetime + VFX_RETIRE_MARGIN_S
554 self._live = True
555 self.visible = True
556
557 def retire(self) -> None:
558 """Stop and hide this burst; idempotent."""
559 if self.particles is not None:
560 self.particles.emitting = False
561 self._live = False
562 self.visible = False
563
564 def on_update(self, dt: float):
565 if not self._live:
566 return
567 self._age += dt
568 if self._age >= self._retire_at:
569 self.retire()
570 pool = self.parent
571 if isinstance(pool, VfxPool):
572 pool.release(self)
573
574
575class VfxPool(Node3D):
576 """A fixed pool of :class:`Vfx` bursts, parented under the run scene.
577
578 One per scene, created on demand by :meth:`Vfx.spawn`. It lives in the
579 scene rather than as a tree singleton so a scene change takes its bursts
580 with it instead of leaving them hanging in the next sector.
581 """
582
583 def __init__(self, size: int = VFX_POOL_SIZE, **kwargs):
584 super().__init__(**kwargs)
585 if size < 1:
586 raise ValueError(f"Vfx pool size must be at least 1, got {size}")
587 self.size = size
588 self._free: list[Vfx] = []
589 self._busy: list[Vfx] = []
590
591 @classmethod
592 def for_tree(cls, tree) -> VfxPool:
593 """The scene's pool, creating it under the root the first time."""
594 root = getattr(tree, "root", None)
595 if root is None:
596 raise RuntimeError("Vfx needs a loaded scene: the tree has no root")
597 pool = root.find(cls)
598 if pool is None:
599 pool = root.add_child(cls(name="VfxPool"))
600 return pool
601
602 def on_ready(self):
603 for index in range(self.size):
604 self._free.append(self.add_child(Vfx(name=f"Vfx{index}")))
605
606 @property
607 def busy_count(self) -> int:
608 """How many bursts are playing right now."""
609 return len(self._busy)
610
611 def acquire(self) -> Vfx:
612 """A free burst, recycling the oldest busy one when the pool is dry."""
613 if self._free:
614 return self._free.pop()
615 oldest = self._busy.pop(0)
616 oldest.retire()
617 return oldest
618
619 def release(self, node: Vfx) -> None:
620 """Return a burst to the pool. Safe to call more than once."""
621 if node in self._busy:
622 self._busy.remove(node)
623 if node not in self._free:
624 self._free.append(node)
625
626 def emit(self, effect: str, position: Vec3, **params) -> Vfx:
627 """Play *effect* at *position* and return the burst that plays it."""
628 node = self.acquire()
629 node.play(effect, position, **params)
630 self._busy.append(node)
631 return node
632
633
634class EngineRibbon(Node3D):
635 """The ship's thrust plume: a stretched flare with a mote stream inside it.
636
637 Parent one per engine nacelle, call :meth:`set_throttle` from the ship's
638 movement code and :meth:`set_direction` with the world-space direction the
639 exhaust should stream in. The ribbon also consumes ``NOTORIETY_CHANGED``
640 and brightens with it, so a notorious player is literally easier to see.
641
642 The plume is geometry first and particles second, and that ordering is the
643 whole readability of the thing. A drive under power is one continuous jet:
644 a tapered flare anchored on the nozzle, stretching further and burning
645 brighter the harder the ship is pushed. Grains alone cannot say that. They
646 detach from the ship the instant they are born, and a trail of separate
647 warm puffs behind a hull is the visual grammar of something exploding, not
648 of something accelerating. The mote stream survives as texture on the jet:
649 tight, fast and short-lived, so it streaks rather than floats.
650 """
651
652 def __init__(self, faction: str = "player", **kwargs):
653 super().__init__(**kwargs)
654 self.faction = faction
655 self.particles: GPUParticles3D | None = None
656 #: The plume geometry's mount; :meth:`set_direction` aims this node.
657 self.plume: Node3D | None = None
658 self._flare: MeshInstance3D | None = None
659 self._core: MeshInstance3D | None = None
660 self._throttle = 0.0
661 self._burn = 0.0
662 self._brightness = TRAIL_BRIGHTNESS_QUIET
663
664 @property
665 def throttle(self) -> float:
666 """Current throttle fraction, 0 to 1."""
667 return self._throttle
668
669 @property
670 def burn(self) -> float:
671 """How far into the afterburner this plume is, 0 (cold) to 1 (lit)."""
672 return self._burn
673
674 @property
675 def brightness(self) -> float:
676 """Notoriety-driven emissive multiplier on the ribbon."""
677 return self._brightness
678
679 @property
680 def plume_length(self) -> float:
681 """How far the flare currently reaches behind the nozzle, world units.
682
683 Zero with the drive cold: an unlit plume reaches nowhere, whatever
684 scale the hidden geometry was last left at.
685 """
686 if self._flare is None or self._throttle <= 0.0:
687 return 0.0
688 return float(self._flare.scale.z)
689
690 def on_ready(self):
691 spec = effect_spec("engine_ribbon")
692 self.plume = self.add_child(build_engine_plume(self.faction))
693 self._flare = self.plume.node_at(PLUME_FLARE)
694 self._core = self.plume.node_at(PLUME_CORE)
695 self.particles = self.add_child(
696 GPUParticles3D(
697 name="Ribbon",
698 amount=spec.amount,
699 lifetime=spec.lifetime,
700 speed=spec.speed,
701 speed_variance=spec.speed_variance,
702 spread=spec.spread,
703 spread_pattern=spec.spread_pattern,
704 damping=spec.damping,
705 start_scale=spec.start_scale,
706 end_scale=spec.end_scale,
707 streak=spec.streak,
708 explosiveness=spec.explosiveness,
709 gravity=(0.0, 0.0, 0.0),
710 # A GPU emitter launches its grains along a world-space vector,
711 # not along the node's own axes, so the owner aims the ribbon
712 # every frame through set_direction. This is only the value it
713 # streams in until the first aim arrives.
714 direction=(-1.0, 0.0, 0.0),
715 emitting=False,
716 )
717 )
718 self._connect_notoriety()
719 self._refresh()
720
721 def _connect_notoriety(self) -> None:
722 notoriety = self.tree.singletons.get(Services.NOTORIETY) if self.tree is not None else None
723 signal = getattr(notoriety, SignalNames.NOTORIETY_CHANGED, None) if notoriety is not None else None
724 if signal is not None:
725 signal.connect(self._on_notoriety_changed)
726 self._on_notoriety_changed(getattr(notoriety, "value", 0), "initial")
727
728 def _on_notoriety_changed(self, value: int, reason: str = "") -> None:
729 fraction = max(0.0, min(float(value) / float(balance.NOTORIETY_MAX), 1.0))
730 self._brightness = TRAIL_BRIGHTNESS_QUIET + (TRAIL_BRIGHTNESS_NOTORIOUS - TRAIL_BRIGHTNESS_QUIET) * fraction
731 self._refresh()
732
733 def set_throttle(self, fraction: float) -> None:
734 """Set the ribbon's intensity from the throttle, 0 (idle) to 1 (full).
735
736 Callers push per frame, so the unchanged case is the common one and it
737 must not cost a rebuild: :meth:`_refresh` restates every emitter
738 parameter on the ribbon and its plumes.
739 """
740 value = max(0.0, min(float(fraction), 1.0))
741 if value == self._throttle:
742 return
743 self._throttle = value
744 self._refresh()
745
746 def set_burn(self, fraction: float) -> None:
747 """Say how far into the afterburner the drive is, 0 to 1.
748
749 Separate from the throttle because it answers a separate question. The
750 throttle says how hard the pilot is pushing; the burn says which of the
751 two drives is doing the pushing, and the pilot is spending a capacitor
752 on the answer. See :data:`PLUME_BURN_LENGTH_MULT`.
753
754 Gated on a change for the same reason as :meth:`set_throttle`. The gate
755 lives here rather than in each caller because the hunter drives the
756 lantern cone through this setter on every frame of a burn, and anything
757 written next gets the same protection without knowing to ask for it.
758 """
759 value = max(0.0, min(float(fraction), 1.0))
760 if value == self._burn:
761 return
762 self._burn = value
763 self._refresh()
764
765 def set_direction(self, direction: Vec3) -> None:
766 """Stream the exhaust along *direction*, a world-space vector.
767
768 A zero-length vector leaves the current aim alone, so a ship coasting
769 with no thrust keeps the last plume rather than firing along +X.
770 """
771 dx, dy, dz = float(direction[0]), float(direction[1]), float(direction[2])
772 length = math.sqrt(dx * dx + dy * dy + dz * dz)
773 if length < 1e-6:
774 return
775 unit = (dx / length, dy / length, dz / length)
776 if self.particles is not None:
777 self.particles.direction = unit
778 if self.plume is not None:
779 # The plume tapers down its own local -Z, which is the axis
780 # face_along aims, so aiming the mount is aiming the jet.
781 self.plume.face_along(unit)
782
783 def _refresh(self) -> None:
784 self._refresh_plume()
785 emitter = self.particles
786 if emitter is None:
787 return
788 spec = effect_spec("engine_ribbon")
789 accent = palette(self.faction).accent
790 energy = spec.energy * self._brightness * (0.35 + 0.65 * self._throttle)
791 start = tuple(min(channel * energy, 4.0) for channel in accent)
792 emitter.start_colour = (*start, 1.0)
793 emitter.end_colour = (*start, 0.0)
794 emitter.speed = spec.speed * (0.55 + 0.45 * self._throttle)
795 emitter.start_scale = spec.start_scale * (0.6 + 0.4 * self._throttle)
796 emitter.emitting = self._throttle > 0.0
797
798 def _refresh_plume(self) -> None:
799 """Stretch, fatten and light the flare for the throttle and the burn."""
800 if self.plume is None or self._flare is None or self._core is None:
801 return
802 lit = self._throttle > 0.0
803 self.plume.visible = lit
804 if not lit:
805 return
806 burn = self._burn
807 stretch = 1.0 + (PLUME_BURN_LENGTH_MULT - 1.0) * burn
808 fatten = 1.0 + (PLUME_BURN_RADIUS_MULT - 1.0) * burn
809 radius = PLUME_RADIUS_UNITS * (PLUME_IDLE_FRACTION + (1.0 - PLUME_IDLE_FRACTION) * self._throttle) * fatten
810 length = (PLUME_LENGTH_MIN_UNITS + (PLUME_LENGTH_MAX_UNITS - PLUME_LENGTH_MIN_UNITS) * self._throttle) * stretch
811 self._flare.scale = Vec3(radius, radius, length)
812 self._core.scale = Vec3(radius, radius, length)
813
814 accent = palette(self.faction).accent
815 glow = 1.0 + (PLUME_BURN_GLOW_MULT - 1.0) * burn
816 strength = palette(self.faction).accent_strength * self._brightness * glow
817 flare = self._flare.material
818 flare.colour = (*accent, PLUME_FLARE_ALPHA * (0.5 + 0.5 * self._throttle))
819 flare.emissive_colour = (*accent, strength * (0.45 + 0.55 * self._throttle))
820 core = self._core.material
821 core_hue = tuple(
822 cool + (hot - cool) * burn for cool, hot in zip(PLUME_CORE_COLOUR, PLUME_BURN_CORE_COLOUR, strict=True)
823 )
824 core.colour = (1.0, 1.0, 1.0, PLUME_CORE_ALPHA * (0.4 + 0.6 * self._throttle))
825 core.emissive_colour = (*core_hue, strength * PLUME_CORE_ENERGY * (0.35 + 0.65 * self._throttle))
826
827
828class HealthPip(Node3D):
829 """A thin damage read-out that rides above one big hull.
830
831 Small enemies do not get one: a mite has five hull points and a pip on it
832 would be a UI element per splinter. What a pip is for is the hull the player
833 has to commit to, where the honest question is "am I nearly through this, or
834 am I wasting a magazine". It appears on the first hit rather than at spawn,
835 so an untouched field carries no chrome at all, and it fades out again the
836 moment it has nothing left to say: back to full, or dead.
837
838 The bar lies flat on the flight plane under the near-top-down camera, which
839 is the one orientation that stays the same width whatever the hull is doing.
840 """
841
842 visible = Property(
843 False,
844 coerce=bool,
845 hint="Whether this node and its subtree are drawn",
846 on_change="_on_visible_changed",
847 )
848
849 def __init__(self, *, width: float = PIP_WIDTH_UNITS, **kwargs):
850 kwargs.setdefault("name", "HealthPip")
851 super().__init__(**kwargs)
852 self.width = float(width)
853 self._fraction = 1.0
854 self._opacity = 0.0
855 # A pip with nothing to say is already on its way out: mounted and never
856 # told a fraction, it stays invisible rather than fading itself in.
857 self._fading = True
858 self._track: MeshInstance3D | None = None
859 self._fill: MeshInstance3D | None = None
860
861 @property
862 def fraction(self) -> float:
863 """Hull fraction the fill currently shows, 0 to 1."""
864 return self._fraction
865
866 @property
867 def opacity(self) -> float:
868 """How far the pip has faded in, 0 (gone) to 1 (fully drawn)."""
869 return self._opacity
870
871 @property
872 def fading(self) -> bool:
873 """Whether the pip is on its way out."""
874 return self._fading
875
876 def on_ready(self):
877 bar = Mesh.cube(size=1.0)
878 self._track = self.add_child(
879 MeshInstance3D(
880 name="Track",
881 mesh=bar,
882 material=Material(colour=(*PIP_TRACK_COLOUR, 0.0), blend="alpha", unlit=True),
883 scale=Vec3(self.width, PIP_THICKNESS_UNITS, PIP_DEPTH_UNITS),
884 )
885 )
886 self._fill = self.add_child(
887 MeshInstance3D(
888 name="Fill",
889 mesh=bar,
890 material=Material(
891 colour=(*PIP_FILL_COLOUR, 0.0),
892 blend="alpha",
893 unlit=True,
894 emissive_colour=PIP_FILL_COLOUR,
895 emissive_strength=PIP_FILL_ENERGY,
896 ),
897 # Nudged toward the camera so the fill never z-fights its track.
898 position=Vec3(0.0, PIP_THICKNESS_UNITS, 0.0),
899 scale=Vec3(self.width, PIP_THICKNESS_UNITS, PIP_DEPTH_UNITS * PIP_FILL_INSET),
900 )
901 )
902 self._apply()
903
904 def set_fraction(self, fraction: float) -> None:
905 """Show *fraction* of the hull, fading in on the way and out at the ends."""
906 self._fraction = min(1.0, max(0.0, float(fraction)))
907 self._fading = self._fraction >= 1.0 or self._fraction <= 0.0
908 if not self._fading:
909 self.visible = True
910 self._apply()
911
912 def dismiss(self) -> None:
913 """Fade the pip out whatever it is showing, for a hull that has died."""
914 self._fading = True
915
916 def on_update(self, dt: float):
917 target = 0.0 if self._fading else 1.0
918 if self._opacity == target:
919 return
920 rate = dt / max(PIP_FADE_S, 1e-6)
921 if target > self._opacity:
922 self._opacity = min(target, self._opacity + rate)
923 else:
924 self._opacity = max(target, self._opacity - rate)
925 if self._opacity <= 0.0:
926 self.visible = False
927 self._apply()
928
929 def _apply(self) -> None:
930 if self._track is None or self._fill is None:
931 return
932 self._track.material.colour = (*PIP_TRACK_COLOUR, PIP_TRACK_ALPHA * self._opacity)
933 self._fill.material.colour = (*PIP_FILL_COLOUR, self._opacity)
934 filled = max(self._fraction, 0.0) * self.width
935 self._fill.scale = Vec3(max(filled, 1e-4), PIP_THICKNESS_UNITS, PIP_DEPTH_UNITS * PIP_FILL_INSET)
936 # The fill drains from the right-hand end, which is what makes a bar
937 # read as a quantity rather than as a shrinking object.
938 self._fill.position = Vec3(-(self.width - filled) * 0.5, PIP_THICKNESS_UNITS, 0.0)
939
940
941class LanternCone(Node3D):
942 """The Shrike's lantern: a drawn beam, a shadow-casting spot and its dust.
943
944 Parent it to the hunter's head and aim it with :meth:`aim_at`. Three
945 phases, and the pilot has to be able to tell them apart across the width of
946 the arena, because which one is running is the whole of whether standing
947 still costs 35 hull:
948
949 * **Sweep** (:meth:`set_active`) is the beam hunting for the ship. Bright,
950 dust streaming, and it moves.
951 * **Pre-burn** (:meth:`set_preburn`) is the one second of warning the design
952 promises. The beam locks where it is, stops streaming dust and drops to a
953 dim shell: several times fainter than the sweep, but never gone, because
954 "the light went out" and "the light is about to burn you" cannot look the
955 same.
956 * **Burn** (:meth:`set_burn`) is the frame that costs hull, and it flares
957 well past the sweep so the pilot can name what hit them.
958
959 The shell is geometry rather than light alone. A spot cast across an empty
960 arena illuminates nothing, so the beam is drawn as an emissive translucent
961 cone and the ``SpotLight3D`` is what that beam throws onto the hulls it
962 crosses.
963 """
964
965 def __init__(self, **kwargs):
966 super().__init__(**kwargs)
967 self.light: SpotLight3D | None = None
968 self.particles: GPUParticles3D | None = None
969 #: The drawn beam. Its apex sits on this node, opening down local -Z.
970 self.beam: MeshInstance3D | None = None
971 self._active = False
972 self._preburn = False
973 self._burn = False
974 #: Seconds since the burn frame opened, which is what the flare decays
975 #: against. Reset every time the burn phase is entered.
976 self._burn_age = 0.0
977
978 @property
979 def active(self) -> bool:
980 """Whether the lantern is lit at all."""
981 return self._active
982
983 @property
984 def preburn(self) -> bool:
985 """Whether the lantern is in its locked pre-burn dim."""
986 return self._preburn
987
988 @property
989 def burn(self) -> bool:
990 """Whether the lantern is on the burning frame."""
991 return self._burn
992
993 @property
994 def beam_alpha(self) -> float:
995 """Opacity of the drawn shell, or zero while the lantern is doused.
996
997 This is the number that makes the phase legible from across the arena,
998 so it is readable state rather than an implementation detail.
999 """
1000 if self.beam is None or not self._active:
1001 return 0.0
1002 return float(self.beam.material.colour[3])
1003
1004 def on_ready(self):
1005 spec = effect_spec("lantern_cone")
1006 accent = palette("hunter").accent
1007 self.beam = self.add_child(self._build_beam(accent))
1008 self.light = self.add_child(
1009 SpotLight3D(
1010 name="Lantern",
1011 range=LANTERN_RANGE,
1012 inner_cone=LANTERN_INNER_CONE_DEGREES,
1013 outer_cone=LANTERN_OUTER_CONE_DEGREES,
1014 shadows=True,
1015 )
1016 )
1017 self.light.colour = accent
1018 self.particles = self.add_child(
1019 GPUParticles3D(
1020 name="LanternDust",
1021 amount=spec.amount,
1022 lifetime=spec.lifetime,
1023 speed=spec.speed,
1024 speed_variance=spec.speed_variance,
1025 spread=spec.spread,
1026 damping=spec.damping,
1027 start_scale=spec.start_scale,
1028 end_scale=spec.end_scale,
1029 explosiveness=spec.explosiveness,
1030 gravity=(0.0, 0.0, 0.0),
1031 direction=(0.0, 0.0, -1.0),
1032 start_colour=(*tuple(min(c * spec.energy, 4.0) for c in accent), 1.0),
1033 end_colour=(*tuple(min(c * spec.energy, 4.0) for c in accent), 0.0),
1034 emitting=False,
1035 )
1036 )
1037 self._refresh()
1038
1039 @staticmethod
1040 def _build_beam(accent) -> MeshInstance3D:
1041 """The drawn shell: a cone with its apex on the head, opening down -Z.
1042
1043 ``Mesh.cone`` points up +Y with its base centred on the origin, so a
1044 quarter turn about X lays it along the axis a ``SpotLight3D`` beams
1045 down, and the half-length shift puts its apex on the lantern rather
1046 than half a beam behind it. The radius is the outer cone angle carried
1047 the full range, so the drawn shell and the burn test agree by
1048 construction instead of by a number kept in step by hand.
1049 """
1050 radius = LANTERN_RANGE * math.tan(math.radians(LANTERN_OUTER_CONE_DEGREES))
1051 return MeshInstance3D(
1052 name="LanternBeam",
1053 mesh=Mesh.cone(radius=radius, height=LANTERN_RANGE, segments=LANTERN_CONE_SEGMENTS),
1054 material=Material(
1055 colour=(*accent, LANTERN_CONE_ALPHA_SWEEP),
1056 blend="alpha",
1057 metallic=0.0,
1058 roughness=1.0,
1059 double_sided=True,
1060 emissive_colour=accent,
1061 emissive_strength=palette("hunter").accent_strength,
1062 ),
1063 rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), math.pi * 0.5),
1064 position=Vec3(0.0, 0.0, -LANTERN_RANGE * 0.5),
1065 )
1066
1067 def set_active(self, active: bool) -> None:
1068 """Light or douse the lantern. A doused lantern leaves both sub-phases."""
1069 self._active = bool(active)
1070 if not self._active:
1071 self._preburn = False
1072 self._burn = False
1073 self._refresh()
1074
1075 def set_preburn(self, active: bool) -> None:
1076 """Enter or leave the ``balance.SHRIKE_LANTERN_PREBURN_WARNING_S`` dim."""
1077 self._preburn = bool(active)
1078 if self._preburn:
1079 self._burn = False
1080 self._refresh()
1081
1082 def set_burn(self, active: bool) -> None:
1083 """Enter or leave the burning frame, the flare that costs hull."""
1084 was_burning = self._burn
1085 self._burn = bool(active)
1086 if self._burn:
1087 self._preburn = False
1088 if not was_burning:
1089 self._burn_age = 0.0
1090 self._refresh()
1091
1092 def on_update(self, dt: float):
1093 """Age the flare so it falls back to the sweep look after its flash."""
1094 if not self._burn:
1095 return
1096 if self._burn_age >= LANTERN_BURN_FLASH_S + LANTERN_BURN_FALLOFF_S:
1097 return
1098 self._burn_age += float(dt)
1099 self._refresh()
1100
1101 @property
1102 def burn_flare(self) -> float:
1103 """How much of the flare is still up, 1 on the burn frame and 0 after.
1104
1105 Readable state rather than bookkeeping: the flare's fall is the reason
1106 the burn's own readouts are legible, so a test can assert on it.
1107 """
1108 if not self._burn:
1109 return 0.0
1110 over = (self._burn_age - LANTERN_BURN_FLASH_S) / max(LANTERN_BURN_FALLOFF_S, 1e-6)
1111 return 1.0 - min(1.0, max(0.0, over))
1112
1113 def aim_at(self, direction: Vec3) -> None:
1114 """Aim the cone down *direction*; a zero vector leaves the aim alone.
1115
1116 A ``SpotLight3D`` beams along its node's forward axis, so aiming the
1117 cone is aiming this node, and the drawn shell rides the same rotation.
1118 """
1119 self.face_along(direction)
1120
1121 def _phase_look(self) -> tuple[float, float, float]:
1122 """``(light multiplier, shell alpha, shell glow)`` for the live phase."""
1123 if self._burn:
1124 flare = self.burn_flare
1125 return (
1126 1.0 + (LANTERN_BURN_FLARE - 1.0) * flare,
1127 LANTERN_CONE_ALPHA_SWEEP + (LANTERN_CONE_ALPHA_BURN - LANTERN_CONE_ALPHA_SWEEP) * flare,
1128 LANTERN_CONE_GLOW_SWEEP + (LANTERN_CONE_GLOW_BURN - LANTERN_CONE_GLOW_SWEEP) * flare,
1129 )
1130 if self._preburn:
1131 return LANTERN_PREBURN_DIM, LANTERN_CONE_ALPHA_PREBURN, LANTERN_CONE_GLOW_PREBURN
1132 return 1.0, LANTERN_CONE_ALPHA_SWEEP, LANTERN_CONE_GLOW_SWEEP
1133
1134 def _refresh(self) -> None:
1135 if self.light is None:
1136 return
1137 multiplier, alpha, glow = self._phase_look()
1138 self.light.intensity = LANTERN_INTENSITY * multiplier if self._active else 0.0
1139 if self.beam is not None:
1140 hunter = palette("hunter")
1141 self.beam.visible = self._active
1142 self.beam.material.colour = (*hunter.accent, alpha)
1143 self.beam.material.emissive_strength = min(hunter.accent_strength * glow, LANTERN_CONE_EMISSIVE_MAX)
1144 if self.particles is not None:
1145 # Dust streams while the beam is hunting and while it burns; the
1146 # pre-burn second is the one that holds still, and stillness is the
1147 # tell the dodge is read off.
1148 self.particles.emitting = self._active and not self._preburn
1149
1150
1151#: The docking ring: the bay drawn on the ground so it is a place and not a
1152#: radius. A blind pilot crossed a depot's dock radius three times in under two
1153#: seconds each without noticing, because nothing in the world said where the
1154#: bay was or that the hull was inside it. The ring is that line, and it changes
1155#: state when the ship crosses it, which is the other half of the same answer.
1156DOCK_RING_SEGMENTS = 40
1157#: Fraction of each segment's slot the segment actually fills, so the ring reads
1158#: as a dashed approach marker rather than as a solid disc edge.
1159DOCK_RING_SEGMENT_FILL = 0.55
1160DOCK_RING_THICKNESS_UNITS = 0.06
1161DOCK_RING_WIDTH_UNITS = 0.55
1162#: How far above the flight plane the ring lies. Gameplay stays on the plane;
1163#: this is visual flourish and is allowed to leave it by a hair, which is what
1164#: keeps it from z-fighting anything else drawn flat.
1165DOCK_RING_LIFT_UNITS = 0.05
1166#: The ring outside the bay and inside it. Idle is a cool marker; armed is the
1167#: HUD's own affordance green, so the ring, the beacon and the verb line are one
1168#: colour language.
1169DOCK_RING_IDLE_COLOUR = (0.34, 0.62, 0.72)
1170DOCK_RING_ARMED_COLOUR = (0.55, 1.00, 0.80)
1171DOCK_RING_IDLE_ALPHA = 0.34
1172DOCK_RING_ARMED_ALPHA = 0.85
1173DOCK_RING_IDLE_ENERGY = 0.7
1174DOCK_RING_ARMED_ENERGY = 2.6
1175#: Cycles per second the armed ring breathes at, and how deep the breath goes.
1176DOCK_RING_PULSE_HZ = 1.1
1177DOCK_RING_PULSE_DEPTH = 0.28
1178#: Seconds the ring takes to cross between its two states, so entering the bay
1179#: is a transition the eye catches rather than a swap between two frames.
1180DOCK_RING_ARM_S = 0.22
1181
1182
1183class DockRing(Node3D):
1184 """The depot's bay, drawn flat on the plane, lit when the hull is inside it.
1185
1186 One :class:`MultiMesh` of dashed segments and no per-frame transform work:
1187 arming changes the material and nothing else. Mount it at the dock's anchor
1188 with the bay's own radius and drive it with :meth:`set_armed` from whoever
1189 knows where the ship is; :attr:`armed` and :attr:`glow` are readable state,
1190 because "does the ring visibly change" is the assertion this exists for.
1191 """
1192
1193 def __init__(self, *, radius: float = 14.0, **kwargs):
1194 kwargs.setdefault("name", "DockRing")
1195 super().__init__(**kwargs)
1196 self.radius = float(radius)
1197 self._armed = False
1198 #: How far into the armed look the ring has travelled, 0 to 1.
1199 self._arm = 0.0
1200 self._elapsed = 0.0
1201 self._material: Material | None = None
1202
1203 @property
1204 def armed(self) -> bool:
1205 """Whether the ring has been told the hull is inside the bay."""
1206 return self._armed
1207
1208 @property
1209 def glow(self) -> float:
1210 """The ring's live emissive strength: the number the change is visible in."""
1211 material = self._material
1212 return float(material.emissive_strength) if material is not None else 0.0
1213
1214 def on_ready(self):
1215 segment = Mesh.cube(size=1.0)
1216 mesh = MultiMesh(mesh=segment, instance_count=DOCK_RING_SEGMENTS)
1217 mesh.set_all_transforms(self._segment_transforms())
1218 self._material = Material(
1219 colour=(*DOCK_RING_IDLE_COLOUR, DOCK_RING_IDLE_ALPHA),
1220 blend="alpha",
1221 metallic=0.0,
1222 roughness=1.0,
1223 emissive_colour=DOCK_RING_IDLE_COLOUR,
1224 emissive_strength=DOCK_RING_IDLE_ENERGY,
1225 )
1226 self.add_child(MultiMeshInstance3D(name="Segments", multi_mesh=mesh, material=self._material))
1227 self._paint()
1228
1229 def _segment_transforms(self) -> np.ndarray:
1230 """One tangential dash per slot around the circle, laid flat on the plane."""
1231 angles = np.linspace(0.0, math.tau, DOCK_RING_SEGMENTS, endpoint=False)
1232 length = math.tau * self.radius / DOCK_RING_SEGMENTS * DOCK_RING_SEGMENT_FILL
1233 cos, sin = np.cos(angles), np.sin(angles)
1234 transforms = np.zeros((DOCK_RING_SEGMENTS, 4, 4), dtype=np.float32)
1235 transforms[:, 3, 3] = 1.0
1236 # Local X runs along the tangent, local Z out along the radius.
1237 transforms[:, 0, 0] = -sin * length
1238 transforms[:, 2, 0] = cos * length
1239 transforms[:, 1, 1] = DOCK_RING_THICKNESS_UNITS
1240 transforms[:, 0, 2] = cos * DOCK_RING_WIDTH_UNITS
1241 transforms[:, 2, 2] = sin * DOCK_RING_WIDTH_UNITS
1242 transforms[:, 0, 3] = cos * self.radius
1243 transforms[:, 1, 3] = DOCK_RING_LIFT_UNITS
1244 transforms[:, 2, 3] = sin * self.radius
1245 return transforms
1246
1247 def set_armed(self, armed: bool) -> None:
1248 """Say whether the hull is inside the bay. Idempotent; cheap to push per frame."""
1249 self._armed = bool(armed)
1250
1251 def on_update(self, dt: float):
1252 self._elapsed += float(dt)
1253 rate = float(dt) / max(DOCK_RING_ARM_S, 1e-6)
1254 target = 1.0 if self._armed else 0.0
1255 if self._arm != target:
1256 self._arm = min(target, self._arm + rate) if target > self._arm else max(target, self._arm - rate)
1257 elif not self._armed:
1258 return # nothing is moving; the idle ring costs no writes at all
1259 self._paint()
1260
1261 def _paint(self) -> None:
1262 """Blend the two looks and breathe the armed one."""
1263 material = self._material
1264 if material is None:
1265 return
1266 arm = min(1.0, max(0.0, self._arm))
1267 pulse = 1.0 - DOCK_RING_PULSE_DEPTH * (0.5 - 0.5 * math.cos(self._elapsed * DOCK_RING_PULSE_HZ * math.tau))
1268 colour = tuple(
1269 idle + (lit - idle) * arm for idle, lit in zip(DOCK_RING_IDLE_COLOUR, DOCK_RING_ARMED_COLOUR, strict=True)
1270 )
1271 alpha = DOCK_RING_IDLE_ALPHA + (DOCK_RING_ARMED_ALPHA - DOCK_RING_IDLE_ALPHA) * arm
1272 energy = DOCK_RING_IDLE_ENERGY + (DOCK_RING_ARMED_ENERGY - DOCK_RING_IDLE_ENERGY) * arm
1273 material.colour = (*colour, alpha * (pulse if arm > 0.0 else 1.0))
1274 material.emissive_colour = colour
1275 material.emissive_strength = energy * (pulse if arm > 0.0 else 1.0)
1276
1277
1278# ============================================================================
1279# The dust field
1280# ============================================================================
1281
1282
1283@dataclass(frozen=True)
1284class DustBand:
1285 """One parallax layer of the dust field.
1286
1287 ``parallax`` is how much of the camera's travel the band gives back as
1288 apparent motion: 1.0 pins the motes in world space, so they stream past at
1289 exactly the ship's speed, and a value above 1.0 makes the band race the
1290 other way, which is what a layer between the camera and the plane does.
1291 """
1292
1293 id: str
1294 count: int
1295 #: Side of the square tile the band is wrapped into, world units. It must
1296 #: comfortably exceed the visible extent or motes recycle inside the frame.
1297 span: float
1298 parallax: float
1299 #: Inclusive height range above the flight plane the band occupies.
1300 height: tuple[float, float]
1301 scale: tuple[float, float]
1302 colour: tuple[float, float, float]
1303 #: Emissive multiplier; the near band runs hot enough for bloom to catch.
1304 energy: float
1305
1306
1307#: Two bands: grit lying on the flight plane, and a looser haze just above it.
1308#: The haze is nearer the camera, so it is both larger and faster, and that
1309#: difference is the whole depth cue.
1310DUST_BANDS: tuple[DustBand, ...] = (
1311 DustBand(
1312 "grit",
1313 count=320,
1314 span=112.0,
1315 parallax=1.0,
1316 height=(-0.7, 0.7),
1317 scale=(0.075, 0.15),
1318 colour=DUST_NEAR_COLOUR,
1319 energy=1.1,
1320 ),
1321 DustBand(
1322 "haze",
1323 count=200,
1324 span=112.0,
1325 parallax=1.45,
1326 height=(2.6, 5.4),
1327 scale=(0.1, 0.2),
1328 colour=DUST_FAR_COLOUR,
1329 energy=0.55,
1330 ),
1331)
1332
1333
1334class DustField(Node3D):
1335 """Tiled parallax motes that make the ship's own speed visible.
1336
1337 An empty sector gives the eye nothing to measure motion against: at cruise
1338 the hull sits dead centre of the frame with the stars fixed behind it, and
1339 the honest reading of that picture is a ship that is not moving. This field
1340 is the fix. Each band of :data:`DUST_BANDS` is a square tile of motes that
1341 is wrapped around the camera focus every frame, so the player always flies
1342 through the same density of grain no matter how far the sector runs, and
1343 each band gives back a different fraction of the camera's travel, so the
1344 screen shows depth as well as speed.
1345
1346 It costs one draw call per band and one vectorised wrap per frame; nothing
1347 is spawned, retired or simulated. Mount one per sector and forget it.
1348 """
1349
1350 def __init__(self, seed: int = 0, bands: tuple[DustBand, ...] = DUST_BANDS, act: int = 1, **kwargs):
1351 super().__init__(**kwargs)
1352 self.seed = int(seed)
1353 self.bands = tuple(bands)
1354 self.act = int(act)
1355 self._meshes: list[MultiMesh] = []
1356 self._transforms: list[np.ndarray] = []
1357 self._origins: list[np.ndarray] = []
1358 self._sizes: list[np.ndarray] = []
1359 self._materials: list[Material] = []
1360 self._rig: CameraRig | None = None
1361 self._last_focus: Vec2 | None = None
1362 self._fade_density = 1.0
1363 self._fade_saturation = 1.0
1364 self._fade_glow = 1.0
1365
1366 @property
1367 def mote_count(self) -> int:
1368 """How many motes the whole field carries."""
1369 return sum(band.count for band in self.bands)
1370
1371 def mote_colour(self, band: DustBand) -> tuple[float, float, float]:
1372 """*band*'s colour carried :data:`DUST_ACT_TINT` of the way to the act's hue."""
1373 tint = act_grade(self.act).nebula_bright
1374 peak = max(max(tint), 1e-6)
1375 return tuple(
1376 base * (1.0 - DUST_ACT_TINT) + (channel / peak) * DUST_ACT_TINT
1377 for base, channel in zip(band.colour, tint, strict=True)
1378 )
1379
1380 def set_act(self, act: int) -> None:
1381 """Re-tint the whole field for *act*; the geometry and fade are untouched."""
1382 self.act = int(act)
1383 self._paint()
1384
1385 def set_fade(self, *, density: float = 1.0, saturation: float = 1.0, glow: float | None = None) -> None:
1386 """Thin the field without it forgetting how it was authored.
1387
1388 *density* scales every band's alpha, *saturation* carries its colour
1389 toward grey, and *glow* scales the emissive push, defaulting to
1390 *density*. All three are fractions of the field as built and all three
1391 survive an act change: the sector drives this as the ship leaves the
1392 content behind, and a jump-deep re-tint out in the void must not snap
1393 the grain back to full density.
1394 """
1395 self._fade_density = min(1.0, max(0.0, float(density)))
1396 self._fade_saturation = min(1.0, max(0.0, float(saturation)))
1397 self._fade_glow = self._fade_density if glow is None else min(1.0, max(0.0, float(glow)))
1398 self._paint()
1399
1400 def _paint(self) -> None:
1401 """Write the act tint and the fade onto every band's material."""
1402 if not self._materials:
1403 return
1404 for band, material in zip(self.bands, self._materials, strict=True):
1405 colour = self.mote_colour(band)
1406 grey = sum(colour) / 3.0
1407 rgb = tuple(grey + (channel - grey) * self._fade_saturation for channel in colour)
1408 material.colour = (*rgb, DUST_ALPHA * self._fade_density)
1409 material.emissive_colour = (*rgb, band.energy * self._fade_glow)
1410
1411 def on_ready(self):
1412 rng = np.random.default_rng(self.seed)
1413 mote = Mesh.sphere(radius=0.5, rings=DUST_MOTE_RINGS, segments=DUST_MOTE_SEGMENTS)
1414 for band in self.bands:
1415 self._origins.append(rng.random((band.count, 2)) * band.span)
1416 self._sizes.append(rng.uniform(band.scale[0], band.scale[1], size=band.count).astype(np.float32))
1417 transforms = np.zeros((band.count, 4, 4), dtype=np.float32)
1418 transforms[:, 3, 3] = 1.0
1419 transforms[:, 1, 3] = rng.uniform(band.height[0], band.height[1], size=band.count)
1420 self._transforms.append(transforms)
1421
1422 mesh = MultiMesh(mesh=mote, instance_count=band.count)
1423 self._meshes.append(mesh)
1424 colour = self.mote_colour(band)
1425 material = Material(
1426 colour=(*colour, DUST_ALPHA),
1427 blend="alpha",
1428 metallic=0.0,
1429 roughness=1.0,
1430 emissive_colour=colour,
1431 emissive_strength=band.energy,
1432 )
1433 self._materials.append(material)
1434 self.add_child(
1435 MultiMeshInstance3D(name=f"Dust{band.id.title()}", multi_mesh=mesh, material=material),
1436 )
1437 self.set_act(self._sector_act())
1438 self._wrap(self._focus_point(), force=True)
1439
1440 def on_update(self, dt: float):
1441 act = self._sector_act()
1442 if act != self.act:
1443 self.set_act(act)
1444 self._wrap(self._focus_point())
1445
1446 def _sector_act(self) -> int:
1447 """The act the run is in, so a jump deeper re-tints the grain with the sky.
1448
1449 Read from the signature meter rather than passed in, because the field
1450 outlives the sector it was mounted in: it is the place, and the place
1451 changes colour when the run does.
1452 """
1453 tree = self.tree
1454 meter = tree.singletons.get(Services.SIGNATURE) if tree is not None else None
1455 act = getattr(meter, "act", None)
1456 return int(act) if act in ACT_GRADES else self.act
1457
1458 def _focus_point(self) -> Vec2:
1459 """Where the field centres itself: the camera focus, else the ship."""
1460 tree = self.tree
1461 if tree is None:
1462 return Vec2(0.0, 0.0)
1463 if self._rig is None and tree.root is not None:
1464 self._rig = tree.root.find(CameraRig)
1465 if self._rig is not None:
1466 return self._rig.focus
1467 ship = tree.get_first_in_group(Groups.SHIP)
1468 return to_plane(ship.position) if ship is not None else Vec2(0.0, 0.0)
1469
1470 def _wrap(self, focus: Vec2, *, force: bool = False) -> None:
1471 """Re-tile every band around *focus*, skipping a focus that has not moved."""
1472 fx, fz = float(focus.x), float(focus.y)
1473 if not force and self._last_focus is not None:
1474 moved = math.hypot(fx - float(self._last_focus.x), fz - float(self._last_focus.y))
1475 if moved < DUST_REFRESH_EPSILON:
1476 return
1477 self._last_focus = Vec2(fx, fz)
1478 here = self.world_position
1479 layers = zip(self.bands, self._origins, self._transforms, self._sizes, self._meshes, strict=True)
1480 for band, origins, transforms, sizes, mesh in layers:
1481 half = band.span * 0.5
1482 drift = np.array([fx, fz], dtype=np.float64) * band.parallax
1483 offsets = np.mod(origins - drift + half, band.span) - half
1484 transforms[:, 0, 3] = fx + offsets[:, 0] - float(here.x)
1485 transforms[:, 2, 3] = fz + offsets[:, 1] - float(here.z)
1486 # Distance attenuation: a mote shrinks as it travels out toward the
1487 # edge of its tile, which is both the depth cue and the reason a
1488 # mote recycling across the tile boundary is never seen popping.
1489 reach = np.hypot(offsets[:, 0], offsets[:, 1]) / half
1490 fade = 1.0 - (1.0 - DUST_FAR_SIZE_FRACTION) * np.clip(reach, 0.0, 1.0)
1491 scaled = (sizes * fade).astype(np.float32)
1492 transforms[:, 0, 0] = scaled
1493 transforms[:, 1, 1] = scaled
1494 transforms[:, 2, 2] = scaled
1495 mesh.set_all_transforms(transforms)
1496
1497 def sizes(self, band_index: int = 0) -> np.ndarray:
1498 """Current drawn diameter of each mote of one band, for tests."""
1499 return self._transforms[band_index][:, 0, 0].copy()
1500
1501 def positions(self, band_index: int = 0) -> np.ndarray:
1502 """Current world-space ``(N, 3)`` mote positions of one band, for tests."""
1503 transforms = self._transforms[band_index]
1504 here = self.world_position
1505 world = transforms[:, :3, 3].copy()
1506 world[:, 0] += float(here.x)
1507 world[:, 1] += float(here.y)
1508 world[:, 2] += float(here.z)
1509 return world