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