shrike/enemies/advanced.py¶

Part of SHRIKE.

   1"""The advanced half of the enemy roster: support, siege, alarm, emplacement and herald.
   2
   3Where ``basic.py`` holds the archetypes that come at you, this module holds the
   4five that change the shape of a fight rather than adding hulls to shoot:
   5
   6* :class:`Welder` keeps other enemies alive down a visible repair umbilical you
   7  cut by flying through it, which turns a wave into a kill-order problem.
   8* :class:`Bombardier` never closes. It paints landing reticles on where you are
   9  about to be, so parking on a deposit is what costs hull.
  10* :class:`Screamer` never shoots. Leave it alive
  11  ``balance.SCREAMER_AGGRO_TIMER_S`` after it notices you and it pays for itself
  12  in signature, which is the next wave's budget.
  13* :class:`HuskTurret` is terrain that shoots: a dead hull sweeping a tracking
  14  beam across a corridor, damaging in ticks rather than continuously.
  15* :class:`Herald` pours in during the hunter's arrival window and carries the
  16  Fletchings the Roost campaign is built from.
  17
  18Every one of them attacks through :meth:`Enemy.begin_attack`, so the white
  19telegraph promise from ``steering.py`` holds here too.
  20
  21The three elite modifiers are composable wrappers rather than subclasses.
  22:func:`apply_elite` wraps an enemy's bound ``take_damage`` and hooks its ``died``
  23signal, so Armoured, Splitting and EMP-laced apply equally to everything in
  24``basic.py`` and everything here, and two of them nest cleanly on one hull.
  25
  26Balance numbers come from ``shrike.balance``, speeds and engagement ranges
  27included: the module-level names below are bindings to that table. What is
  28named here for its own sake is cadence, geometry and the elite coefficients.
  29
  30Each of these archetypes staggers its own attack timer at spawn through
  31``Enemy.stagger_cadence``, so a battery of Bombardiers walks its fire and a
  32flight of Heralds lances in ones rather than in a single volley.
  33"""
  34
  35from __future__ import annotations
  36
  37import math
  38import random
  39from dataclasses import dataclass, field
  40
  41import numpy as np
  42
  43from simvx.core import (
  44    Material,
  45    Mesh,
  46    MeshInstance3D,
  47    MultiMesh,
  48    MultiMeshInstance3D,
  49    Node3D,
  50    Property,
  51    Quat,
  52    Signal,
  53    Vec2,
  54    Vec3,
  55)
  56
  57from .. import artkit, balance
  58from ..runtime import PLANE_Y, Groups, Services, from_plane, to_plane
  59from ..vfx import Vfx
  60from .steering import Enemy, seek
  61
  62# ============================================================================
  63# Module-local anchors
  64#
  65# Speeds and engagement ranges live in balance.py, quoted as multiples of the
  66# player's cruise speed and against what the camera actually shows; the bindings
  67# below give the behaviour code readable names for them and nothing more. What
  68# is genuinely local is cadence, geometry and the elite coefficients: how often
  69# an archetype may try, how wide its sweep is, and what a modifier costs.
  70# ============================================================================
  71
  72# Welder
  73WELDER_SPEED = balance.WELDER_SPEED
  74#: Longest umbilical the welder projects, and the range it looks for patients in.
  75WELDER_UMBILICAL_RANGE = balance.WELDER_UMBILICAL_RANGE
  76#: Distance it tries to hold from its patient while the link is live.
  77WELDER_TETHER_DISTANCE = balance.WELDER_TETHER_DISTANCE
  78WELDER_REPAIR_PER_S = 6.0
  79#: How near the umbilical the ship must pass to shear it.
  80WELDER_UMBILICAL_BREAK_RADIUS = balance.WELDER_UMBILICAL_BREAK_RADIUS
  81#: Dead time before a sheared welder finds another patient.
  82WELDER_RELINK_DELAY_S = 2.5
  83#: Where an unlinked welder loiters relative to the ship.
  84WELDER_LOITER_DISTANCE = balance.WELDER_LOITER_DISTANCE
  85#: Drawn radius of the umbilical. Thinner than a weapon beam: it is a link to
  86#: read and fly through, not a thing that hurts.
  87WELDER_UMBILICAL_RADIUS = 0.09
  88
  89# Bombardier
  90BOMBARDIER_SPEED = balance.BOMBARDIER_SPEED
  91BOMBARDIER_STANDOFF_DISTANCE = balance.BOMBARDIER_STANDOFF_DISTANCE
  92#: Dead band around the stand-off distance, inside which it strafes rather than closes.
  93BOMBARDIER_STANDOFF_BAND = balance.BOMBARDIER_STANDOFF_BAND
  94#: Beyond this the ship is out of the tube's reach.
  95BOMBARDIER_MAX_RANGE = balance.BOMBARDIER_MAX_RANGE
  96BOMBARDIER_SALVO_INTERVAL_S = 4.5
  97BOMBARDIER_SALVO_SHELLS = 3
  98#: Seconds a reticle is painted on the plane before its shell lands. Two full
  99#: seconds is the design's warning, and it is a floor rather than a taste: it
 100#: has to cover reading the paint, deciding, and flying out of a blast radius
 101#: from a standing start.
 102BOMBARDIER_SHELL_FLIGHT_S = 2.0
 103#: Extra flight time per shell, so a salvo walks across the target rather than stacking.
 104BOMBARDIER_SHELL_STAGGER_S = 0.35
 105BOMBARDIER_BLAST_RADIUS = balance.BOMBARDIER_BLAST_RADIUS
 106#: How far ahead of the ship's current velocity the first shell is aimed.
 107BOMBARDIER_LEAD_S = 0.9
 108#: Random scatter applied to each shell after the lead, in plane units.
 109BOMBARDIER_SCATTER = balance.BOMBARDIER_SCATTER
 110
 111# Screamer
 112SCREAMER_SPEED = balance.SCREAMER_SPEED
 113SCREAMER_AGGRO_RANGE = balance.SCREAMER_AGGRO_RANGE
 114#: Once aggroed it runs, and keeps running until it is this far from the ship.
 115SCREAMER_FLEE_DISTANCE = balance.SCREAMER_FLEE_DISTANCE
 116
 117# Husk turret
 118HUSK_TURRET_BEAM_RANGE = balance.HUSK_TURRET_BEAM_RANGE
 119#: Idle sweep: the beam oscillates across this arc, centred on its rest heading.
 120HUSK_TURRET_SWEEP_ARC = math.radians(120.0)
 121HUSK_TURRET_SWEEP_RATE = math.radians(45.0)
 122#: How fast the beam pulls onto a target once the ship is in range.
 123HUSK_TURRET_TRACK_RATE = math.radians(70.0)
 124#: Angular tolerance for the beam counting as on target.
 125HUSK_TURRET_BEAM_HALF_ANGLE = math.radians(4.0)
 126#: The beam damages in ticks, not continuously.
 127HUSK_TURRET_TICK_S = 0.5
 128#: Height of the barrel's trunnion above the wreck, world units: the drum the
 129#: art kit sits on the base is 1.7 deep and the barrel rides its shoulder.
 130HUSK_TURRET_BARREL_HEIGHT = 0.75
 131
 132# Herald
 133HERALD_SPEED = balance.HERALD_SPEED
 134#: Radius of the strafing orbit a herald holds around the ship.
 135HERALD_ORBIT_RADIUS = balance.HERALD_ORBIT_RADIUS
 136HERALD_ORBIT_RATE = 1.1
 137HERALD_FIRE_INTERVAL_S = 2.2
 138HERALD_LANCE_RANGE = balance.HERALD_LANCE_RANGE
 139#: The telegraph stage the flight arrives on.
 140HERALD_ARRIVAL_STAGE = "t30"
 141HERALD_FLIGHT_MIN = 3
 142HERALD_FLIGHT_MAX = 5
 143#: Heralds tear in on a ring this far from the ship.
 144HERALD_SPAWN_RADIUS = balance.HERALD_SPAWN_RADIUS
 145#: Heralds are the hunter's language spoken by an enemy hull, so they carry the
 146#: enemy palette like everything else the player shoots; what marks them out is
 147#: the silhouette, a shard of the Shrike itself.
 148HERALD_COLOUR = artkit.palette("enemy").hull_colour
 149
 150# Elite modifiers
 151ELITE_ARMOURED_HP_MULT = 1.8
 152#: Plating trims most incoming damage, but ballistics were built for plating.
 153ELITE_ARMOURED_REDUCTION = 0.45
 154ELITE_ARMOURED_BALLISTIC_REDUCTION = 0.20
 155ELITE_SPLIT_COUNT = 2
 156ELITE_SPLIT_HP_FRACTION = 0.35
 157#: How far the halves are thrown apart when the parent comes open.
 158ELITE_SPLIT_SCATTER = 4.0
 159ELITE_EMP_CAPACITOR_DRAIN = 25.0
 160ELITE_EMP_RADIUS = 14.0
 161
 162
 163# ============================================================================
 164# Ordnance presentation
 165#
 166# An attack the player cannot see is not a difficulty setting, it is a bug: a
 167# mortar reticle that is only a dataclass, a turret beam that is only a heading
 168# and a lance that is only a damage number all read to the player as dying for
 169# no reason. Everything an enemy puts in the air is built from the constants
 170# below, so the readability contract (enemy fire is cold magenta, and emissive
 171# so it survives two hundred particles) is enforced at one site rather than
 172# remembered five times.
 173# ============================================================================
 174
 175#: Cold magenta, from the enemy palette. Every hostile muzzle, shell, beam and
 176#: reticle in the game is this colour.
 177ENEMY_FIRE_COLOUR = artkit.palette("enemy").accent
 178ENEMY_FIRE_ENERGY = artkit.palette("enemy").accent_strength
 179#: How much larger than the radius its damage resolves against a piece of
 180#: ordnance is drawn. Fire that is drawn at its true size is a pixel at this
 181#: camera distance, so it is drawn at the size it needs to be read at, and the
 182#: hit stays honest by resolving against the smaller number.
 183ENEMY_ORDNANCE_VISUAL_GAIN = 2.0
 184
 185#: Radius the mortar shell's own body would occupy. Nothing collides with it in
 186#: flight (its reticle owns the landing), so this exists to size the tracer.
 187MORTAR_SHELL_BODY_RADIUS = 0.26
 188#: The drawn tracer, at the visual gain.
 189MORTAR_SHELL_DRAWN_RADIUS = MORTAR_SHELL_BODY_RADIUS * ENEMY_ORDNANCE_VISUAL_GAIN
 190#: How high above the plane the shell arcs at the top of its flight. Gameplay
 191#: never leaves the plane; the picture is allowed to.
 192MORTAR_SHELL_ARC_HEIGHT = 7.0
 193#: The audio cue a launched shell asks for. See :attr:`Bombardier.shell_launched`.
 194MORTAR_WHISTLE_CUE = "mortar_whistle"
 195
 196#: The landing reticle: a dim rim at the blast radius that says where, and a
 197#: bright ring closing inside it that says when. Both are rings of dashes on
 198#: one multimesh, so a full salvo of three costs six draws.
 199RETICLE_RING_DASHES = 24
 200RETICLE_DASH_LENGTH = 0.22
 201RETICLE_DASH_WIDTH = 0.05
 202RETICLE_DASH_THICKNESS = 0.02
 203RETICLE_RIM_ALPHA = 0.45
 204RETICLE_CLOSER_ALPHA = 1.0
 205#: The closing ring never quite reaches zero: a ring with no radius is a dot,
 206#: and the last tenth of a second is when the player most needs to see one.
 207RETICLE_CLOSER_MIN_FRACTION = 0.12
 208#: Reticles sit a hair above the plane so they never z-fight the deck.
 209RETICLE_HEIGHT = 0.04
 210
 211#: Beams: the husk turret's sustained sweep and the herald's lance.
 212ENEMY_BEAM_RADIUS = 0.16
 213#: How long a lance stays drawn after it has landed. Long enough to be seen and
 214#: located, short enough that a flight of five does not paint the screen.
 215LANCE_TRACER_S = 0.18
 216
 217
 218def enemy_fire_material(*, alpha: float = 1.0, energy: float = ENEMY_FIRE_ENERGY) -> Material:
 219    """One piece of hostile ordnance's material: unlit magenta that blooms."""
 220    return Material(
 221        colour=(*ENEMY_FIRE_COLOUR, alpha),
 222        blend="alpha",
 223        unlit=True,
 224        emissive_colour=ENEMY_FIRE_COLOUR,
 225        emissive_strength=energy,
 226    )
 227
 228
 229def _ring_multimesh(dashes: int = RETICLE_RING_DASHES) -> MultiMesh:
 230    """A unit-radius ring of tangential dashes, as one instanced draw.
 231
 232    Scaling the node that carries it scales the ring, which is what lets the
 233    closing ring shrink every frame without touching a transform buffer.
 234    """
 235    transforms = np.zeros((dashes, 4, 4), dtype=np.float32)
 236    transforms[:, 3, 3] = 1.0
 237    angles = np.arange(dashes, dtype=np.float32) * (2.0 * math.pi / dashes)
 238    # Yaw each dash so its long axis lies along the tangent at its angle.
 239    yaw = -(angles + math.pi * 0.5)
 240    cos, sin = np.cos(yaw), np.sin(yaw)
 241    transforms[:, 0, 0] = cos * RETICLE_DASH_LENGTH
 242    transforms[:, 2, 0] = -sin * RETICLE_DASH_LENGTH
 243    transforms[:, 1, 1] = RETICLE_DASH_THICKNESS
 244    transforms[:, 0, 2] = sin * RETICLE_DASH_WIDTH
 245    transforms[:, 2, 2] = cos * RETICLE_DASH_WIDTH
 246    transforms[:, 0, 3] = np.cos(angles)
 247    transforms[:, 2, 3] = np.sin(angles)
 248    mesh = MultiMesh(mesh=Mesh.cube(size=1.0), instance_count=dashes)
 249    mesh.set_all_transforms(transforms)
 250    return mesh
 251
 252
 253class PlaneBeam(Node3D):
 254    """A magenta bolt drawn between two points on the flight plane.
 255
 256    The husk turret holds one lit for as long as its beam is on target; a
 257    herald strikes one for :data:`LANCE_TRACER_S` and lets it fade. Either way
 258    the player sees the line the damage came down, which is the whole point:
 259    the geometry is the only difference between an attack and an unexplained
 260    hull loss.
 261    """
 262
 263    visible = Property(
 264        False,
 265        coerce=bool,
 266        hint="Whether this node and its subtree are drawn",
 267        on_change="_on_visible_changed",
 268    )
 269
 270    def __init__(self, *, radius: float = ENEMY_BEAM_RADIUS, **kwargs):
 271        kwargs.setdefault("name", "Beam")
 272        super().__init__(**kwargs)
 273        self.radius = float(radius)
 274        self._core: MeshInstance3D | None = None
 275        self._hold = 0.0
 276
 277    @property
 278    def lit(self) -> bool:
 279        """Whether the beam is currently drawn."""
 280        return self.visible
 281
 282    def on_ready(self):
 283        self._core = self.add_child(
 284            MeshInstance3D(
 285                name="Core",
 286                mesh=Mesh.cylinder(radius=self.radius, height=1.0, segments=8),
 287                material=enemy_fire_material(),
 288                rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 289            )
 290        )
 291
 292    def draw_between(self, start: Vec2, end: Vec2, *, hold_s: float = 0.0) -> None:
 293        """Light the beam from *start* to *end*, holding it for *hold_s*.
 294
 295        A zero hold means the caller re-draws it every frame while it burns; a
 296        positive one is a strike that fades itself out.
 297        """
 298        dx = float(end.x) - float(start.x)
 299        dz = float(end.y) - float(start.y)
 300        length = math.hypot(dx, dz)
 301        if length < 1e-6 or self._core is None:
 302            return
 303        self.position = Vec3(float(start.x), PLANE_Y, float(start.y))
 304        self.face_along(Vec3(dx / length, 0.0, dz / length))
 305        self._core.position = Vec3(0.0, 0.0, -length * 0.5)
 306        self._core.scale = Vec3(1.0, length, 1.0)
 307        self._hold = float(hold_s)
 308        self.visible = True
 309
 310    def douse(self) -> None:
 311        """Put the beam out now."""
 312        self._hold = 0.0
 313        self.visible = False
 314
 315    def on_update(self, dt: float):
 316        if self._hold <= 0.0:
 317            return
 318        self._hold -= dt
 319        if self._hold <= 0.0:
 320            self.douse()
 321
 322
 323# ============================================================================
 324# Plane geometry
 325# ============================================================================
 326
 327
 328def wrap_angle(radians: float) -> float:
 329    """Fold an angle into ``[-pi, pi]``."""
 330    return (float(radians) + math.pi) % (2.0 * math.pi) - math.pi
 331
 332
 333def point_to_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> float:
 334    """Shortest distance from *point* to the segment ``start -> end`` on the plane."""
 335    sx, sy = float(start.x), float(start.y)
 336    dx, dy = float(end.x) - sx, float(end.y) - sy
 337    length_sq = dx * dx + dy * dy
 338    if length_sq <= 1e-9:
 339        return math.hypot(float(point.x) - sx, float(point.y) - sy)
 340    t = ((float(point.x) - sx) * dx + (float(point.y) - sy) * dy) / length_sq
 341    t = min(1.0, max(0.0, t))
 342    return math.hypot(float(point.x) - (sx + dx * t), float(point.y) - (sy + dy * t))
 343
 344
 345def bearing(origin: Vec2, target: Vec2) -> float:
 346    """Heading in radians from *origin* to *target*, anticlockwise from +X seen from above."""
 347    return math.atan2(-(float(target.y) - float(origin.y)), float(target.x) - float(origin.x))
 348
 349
 350def heading_vector(heading: float) -> Vec2:
 351    """Unit plane vector for a heading, matching ``runtime.heading_to_direction``."""
 352    return Vec2(math.cos(heading), -math.sin(heading))
 353
 354
 355def full_hp(enemy) -> float:
 356    """Full hull points for *enemy*, honouring an Armoured elite's inflated pool.
 357
 358    An archetype that tracks its own ceiling (everything in this module, and
 359    anything an elite has been attached to) reports it through ``max_hp``.
 360    Otherwise the archetype's balance entry is the ceiling, which is right for a
 361    single hull and only approximate for an aggregate one such as a mite shoal,
 362    so callers that heal clamp against the current value as well.
 363    """
 364    return float(getattr(enemy, "max_hp", enemy.spec.hp))
 365
 366
 367# ============================================================================
 368# Shared scaffolding
 369# ============================================================================
 370
 371
 372class AdvancedEnemy(Enemy):
 373    """Station-keeping, seeding and elite bookkeeping shared by the advanced five.
 374
 375    The base in ``steering.py`` already owns hull points, the telegraph and the
 376    damage route. What the advanced archetypes add is a reason to sit still at a
 377    chosen distance, per-node randomness the wave composer can seed for a
 378    learnable spawn, and the ``max_hp`` ceiling a Welder heals toward and an
 379    Armoured elite raises.
 380    """
 381
 382    #: Cruising speed on the flight plane in units per second; 0 when stationary.
 383    SPEED: float = 0.0
 384
 385    def __init__(self, **kwargs):
 386        super().__init__(**kwargs)
 387        self.max_hp = float(self.spec.hp)
 388        self.elite_modifiers: list[EliteModifier] = []
 389        self._ordnance: list[Node3D] = []
 390
 391    # -- Ordnance ownership -------------------------------------------------
 392
 393    def mount_beam(self, name: str = "Beam", **kwargs) -> PlaneBeam:
 394        """Mount a :class:`PlaneBeam` this enemy owns but does not carry.
 395
 396        Ordnance is parented beside the hull rather than under it, because a
 397        beam is drawn between two points on the flight plane and a hull that
 398        turns to face its target would otherwise swing its own beam twice. The
 399        enemy still owns the node: :meth:`on_exit_tree` takes it away.
 400        """
 401        parent = self.parent if self.parent is not None else self
 402        beam = parent.add_child(PlaneBeam(name=f"{self.name}{name}", **kwargs))
 403        self._ordnance.append(beam)
 404        return beam
 405
 406    def release_ordnance(self) -> None:
 407        """Destroy everything this enemy put on the plane. Idempotent."""
 408        for node in self._ordnance:
 409            if node is not None and not node.destroying:
 410                node.destroy()
 411        self._ordnance = []
 412
 413    def on_exit_tree(self):
 414        super().on_exit_tree()
 415        self.release_ordnance()
 416
 417    def place(self, plane_point: Vec2) -> None:
 418        """Move to *plane_point*, holding the flight plane exactly."""
 419        self.position = from_plane(Vec2(float(plane_point.x), float(plane_point.y)))
 420
 421    def hold_standoff(self, target: Vec2, dt: float, distance: float, band: float) -> None:
 422        """Keep *distance* from *target*, strafing sideways once inside the *band*.
 423
 424        This is what makes a stand-off archetype read as deliberate: it closes
 425        when it is too far, backs off when you charge it, and circles when it is
 426        happy, rather than oscillating across the ideal range.
 427        """
 428        offset = self.plane_position - Vec2(float(target.x), float(target.y))
 429        current = math.hypot(float(offset.x), float(offset.y))
 430        if current <= 1e-6:
 431            self.move(dt, Vec2(1.0, 0.0), self.SPEED)
 432            return
 433        outward = Vec2(float(offset.x) / current, float(offset.y) / current)
 434        if current > distance + band:
 435            direction = Vec2(-float(outward.x), -float(outward.y))
 436        elif current < distance - band:
 437            direction = outward
 438        else:
 439            direction = Vec2(-float(outward.y), float(outward.x))
 440        self.move(dt, direction, self.SPEED)
 441        self.face(direction)
 442
 443    def deal_damage_from(self, origin: Vec2, target, amount: float, kind: str) -> None:
 444        """Hurt *target* as though the blow came from *origin* rather than this hull.
 445
 446        A mortar shell landing beside the ship must desaturate the screen edge it
 447        actually fell on, not the edge the tube is parked behind, so the blast
 448        direction comes from the shell.
 449
 450        A shell that lands squarely on the hull has no bearing of its own, and a
 451        zero direction is the one value every consumer of ``PLAYER_DAMAGED``
 452        silently drops: no edge desaturation, no punch, no directional cue at
 453        all, on the hit that most deserves one. In that case the bearing falls
 454        back to the tube's, which is where the player has to look anyway.
 455        """
 456        here = Vec2(float(origin.x), float(origin.y))
 457        there = to_plane(target.position)
 458        bearing_from = seek(here, there)
 459        if not float(bearing_from.x) and not float(bearing_from.y):
 460            bearing_from = seek(self.plane_position, there)
 461        direction = from_plane(bearing_from)
 462        router = self.damage_router()
 463        if router is not None:
 464            router.deal(target, amount, kind=kind, direction=direction, source=self)
 465        elif hasattr(target, "apply_damage"):
 466            target.apply_damage(amount, direction, kind)
 467
 468
 469# ============================================================================
 470# Welder
 471# ============================================================================
 472
 473
 474class Welder(AdvancedEnemy):
 475    """Support crab that keeps other enemies alive down a visible repair umbilical.
 476
 477    It never shoots. It picks the most wounded enemy in range, holds station near
 478    it and pours :data:`WELDER_REPAIR_PER_S` back into its hull. The link is a
 479    line on the flight plane, and flying the ship through that line shears it:
 480    the welder emits :attr:`umbilical_broken` and cannot relink for
 481    :data:`WELDER_RELINK_DELAY_S`. That is the whole answer to the archetype, and
 482    it costs positioning rather than damage.
 483    """
 484
 485    ARCHETYPE = "welder"
 486    SPEED = WELDER_SPEED
 487    HITBOX_RADIUS = 1.5
 488
 489    #: ``runtime.SignalNames.UMBILICAL_BROKEN``. Emitted when the ship cuts the link.
 490    umbilical_broken = Signal()
 491
 492    def __init__(self, **kwargs):
 493        super().__init__(**kwargs)
 494        self.patient: Node3D | None = None
 495        self.umbilical: PlaneBeam | None = None
 496        self._relink_delay = 0.0
 497
 498    def build_body(self):
 499        self.add_child(artkit.build_enemy("welder"))
 500        # The link is the archetype's whole answer, so it has to be a line the
 501        # player can see and fly through, not a pair of coordinates.
 502        self.umbilical = self.mount_beam("Umbilical", radius=WELDER_UMBILICAL_RADIUS)
 503
 504    @property
 505    def linked(self) -> bool:
 506        """Whether an umbilical is currently projected."""
 507        return self.patient is not None
 508
 509    def umbilical_endpoints(self) -> tuple[Vec2, Vec2] | None:
 510        """The two plane points the umbilical runs between, or ``None`` when unlinked."""
 511        if self.patient is None:
 512            return None
 513        return self.plane_position, to_plane(self.patient.position)
 514
 515    def on_update(self, dt: float):
 516        super().on_update(dt)
 517        if self._relink_delay > 0.0:
 518            self._relink_delay -= dt
 519        self._drop_invalid_patient()
 520        if self.patient is None and self._relink_delay <= 0.0:
 521            self.patient = self._choose_patient()
 522        if self.patient is None:
 523            if self.umbilical is not None:
 524                self.umbilical.douse()
 525            return
 526        if self._ship_cuts_umbilical():
 527            self._shear()
 528            return
 529        if self.umbilical is not None:
 530            self.umbilical.draw_between(self.plane_position, to_plane(self.patient.position))
 531        # Never below where the patient already is: a welder repairs, and an
 532        # aggregate hull such as a shoal reports a pool bigger than its spec.
 533        ceiling = max(full_hp(self.patient), float(self.patient.hp))
 534        self.patient.hp = min(ceiling, float(self.patient.hp) + WELDER_REPAIR_PER_S * dt)
 535
 536    def on_fixed_update(self, dt: float):
 537        if self.patient is not None:
 538            self.hold_standoff(to_plane(self.patient.position), dt, WELDER_TETHER_DISTANCE, 2.0)
 539            return
 540        ship = self.player_ship()
 541        if ship is not None:
 542            self.hold_standoff(to_plane(ship.position), dt, WELDER_LOITER_DISTANCE, 4.0)
 543
 544    # -- internals ----------------------------------------------------------
 545
 546    def _drop_invalid_patient(self) -> None:
 547        patient = self.patient
 548        if patient is None:
 549            return
 550        if patient.destroying or patient.tree is None or float(patient.hp) <= 0.0:
 551            self.patient = None
 552            return
 553        if self.distance_to(patient) > WELDER_UMBILICAL_RANGE:
 554            self.patient = None
 555
 556    def _choose_patient(self) -> Node3D | None:
 557        """The most wounded enemy in range, nearest first among equals."""
 558        tree = self.tree
 559        if tree is None:
 560            return None
 561        best: Node3D | None = None
 562        best_key = (2.0, float("inf"))
 563        for node in tree.group(Groups.ENEMIES):
 564            if node is self or node.destroying or isinstance(node, Welder):
 565                continue
 566            if not hasattr(node, "hp") or not hasattr(node, "spec"):
 567                continue
 568            distance = self.distance_to(node)
 569            if distance > WELDER_UMBILICAL_RANGE:
 570                continue
 571            key = (float(node.hp) / max(full_hp(node), 1e-6), distance)
 572            if key < best_key:
 573                best, best_key = node, key
 574        return best
 575
 576    def _ship_cuts_umbilical(self) -> bool:
 577        ship = self.player_ship()
 578        if ship is None or self.patient is None:
 579            return False
 580        gap = point_to_segment_distance(to_plane(ship.position), self.plane_position, to_plane(self.patient.position))
 581        return gap <= WELDER_UMBILICAL_BREAK_RADIUS
 582
 583    def _shear(self) -> None:
 584        self.patient = None
 585        self._relink_delay = WELDER_RELINK_DELAY_S
 586        if self.umbilical is not None:
 587            self.umbilical.douse()
 588        self.umbilical_broken()
 589
 590
 591# ============================================================================
 592# Bombardier
 593# ============================================================================
 594
 595
 596class ReticleMarker(Node3D):
 597    """The painted landing circle, drawn: a rim, and a ring closing inside it.
 598
 599    The rim says *where*, at the blast radius, and never moves. The closer says
 600    *when*: it starts on the rim and shrinks toward the impact point as the
 601    shell falls, so a glance costs no reading at all. Both are magenta, because
 602    everything that can hurt the player is.
 603    """
 604
 605    def __init__(self, *, radius: float, **kwargs):
 606        kwargs.setdefault("name", "Reticle")
 607        super().__init__(**kwargs)
 608        self.radius = float(radius)
 609        self._rim: MultiMeshInstance3D | None = None
 610        self._closer: MultiMeshInstance3D | None = None
 611        self._fraction = 0.0
 612
 613    @property
 614    def closer_radius(self) -> float:
 615        """Current radius of the closing ring, in world units."""
 616        span = 1.0 - RETICLE_CLOSER_MIN_FRACTION
 617        return self.radius * (1.0 - span * self._fraction)
 618
 619    def on_ready(self):
 620        ring = _ring_multimesh()
 621        self._rim = self.add_child(
 622            MultiMeshInstance3D(
 623                name="Rim",
 624                multi_mesh=ring,
 625                material=enemy_fire_material(alpha=RETICLE_RIM_ALPHA, energy=ENEMY_FIRE_ENERGY * 0.4),
 626                scale=Vec3(self.radius, 1.0, self.radius),
 627            )
 628        )
 629        self._closer = self.add_child(
 630            MultiMeshInstance3D(
 631                name="Closer",
 632                multi_mesh=ring,
 633                material=enemy_fire_material(alpha=RETICLE_CLOSER_ALPHA),
 634                scale=Vec3(self.radius, 1.0, self.radius),
 635            )
 636        )
 637
 638    def set_fraction(self, fraction: float) -> None:
 639        """Close the ring to *fraction* of the way through the shell's flight."""
 640        self._fraction = min(1.0, max(0.0, float(fraction)))
 641        if self._closer is not None:
 642            radius = self.closer_radius
 643            self._closer.scale = Vec3(radius, 1.0, radius)
 644
 645
 646class MortarShell(Node3D):
 647    """The round itself: a glowing magenta tracer arcing onto its reticle.
 648
 649    It carries no collision body and resolves no damage; the reticle it was
 650    launched at owns the landing. What it owns is the answer to "what hit me":
 651    a shell in the air, on a visible arc, from a visible direction.
 652    """
 653
 654    def __init__(self, *, origin: Vec2, target: Vec2, flight: float, **kwargs):
 655        kwargs.setdefault("name", "Shell")
 656        super().__init__(**kwargs)
 657        self.origin = Vec2(float(origin.x), float(origin.y))
 658        self.target = Vec2(float(target.x), float(target.y))
 659        self.flight = max(float(flight), 1e-6)
 660        self.elapsed = 0.0
 661        self.position = Vec3(float(origin.x), PLANE_Y, float(origin.y))
 662
 663    @property
 664    def fraction(self) -> float:
 665        """How far along its arc the shell is, 0 at the tube and 1 on impact."""
 666        return min(1.0, max(0.0, self.elapsed / self.flight))
 667
 668    def on_ready(self):
 669        self.add_child(
 670            MeshInstance3D(
 671                name="Tracer",
 672                mesh=Mesh.sphere(radius=MORTAR_SHELL_DRAWN_RADIUS, rings=6, segments=8),
 673                material=enemy_fire_material(),
 674            )
 675        )
 676
 677    def advance(self, dt: float) -> None:
 678        """Fly one step of the arc. The bombardier drives this with its reticle."""
 679        self.elapsed += dt
 680        t = self.fraction
 681        self.position = Vec3(
 682            float(self.origin.x) + (float(self.target.x) - float(self.origin.x)) * t,
 683            PLANE_Y + MORTAR_SHELL_ARC_HEIGHT * 4.0 * t * (1.0 - t),
 684            float(self.origin.y) + (float(self.target.y) - float(self.origin.y)) * t,
 685        )
 686
 687
 688@dataclass
 689class MortarReticle:
 690    """One painted landing circle: where a shell falls, and how long until it does.
 691
 692    ``marker`` and ``shell`` are the geometry the player actually reads. They
 693    are optional so a reticle can still be reasoned about in a scene with no
 694    tree to mount them in, but in a live run every reticle has both.
 695    """
 696
 697    centre: Vec2
 698    radius: float
 699    remaining: float
 700    flight: float
 701    marker: ReticleMarker | None = field(default=None, compare=False)
 702    shell: MortarShell | None = field(default=None, compare=False)
 703
 704    @property
 705    def fraction(self) -> float:
 706        """How far the paint has filled: 0 when painted, 1 at the moment of impact."""
 707        if self.flight <= 0.0:
 708            return 1.0
 709        return min(1.0, max(0.0, 1.0 - self.remaining / self.flight))
 710
 711
 712class Bombardier(AdvancedEnemy):
 713    """Stand-off mortar that shells where you are about to be, never where it is.
 714
 715    It holds :data:`BOMBARDIER_STANDOFF_DISTANCE` and lobs a salvo of
 716    :data:`BOMBARDIER_SALVO_SHELLS`, each painting a reticle on the plane
 717    :data:`BOMBARDIER_SHELL_FLIGHT_S` before it lands. The reticles lead the
 718    ship's current velocity and walk outward, which is what makes camping a rich
 719    deposit expensive: reading the paint and moving costs nothing, ignoring it
 720    costs ``spec.damage`` a shell.
 721    """
 722
 723    ARCHETYPE = "bombardier"
 724    SPEED = BOMBARDIER_SPEED
 725    HITBOX_RADIUS = 1.8
 726
 727    #: ``(origin, flight_seconds)`` as each shell leaves the tube. Registered as
 728    #: ``SignalNames.SHELL_LAUNCHED``; ``audio.py`` plays ``MORTAR_WHISTLE_CUE``
 729    #: off it, panned to the origin, so the salvo is audible as well as visible.
 730    shell_launched = Signal(Vec3, float)
 731
 732    def __init__(self, **kwargs):
 733        super().__init__(**kwargs)
 734        self.reticles: list[MortarReticle] = []
 735        self._salvo_cooldown = BOMBARDIER_SALVO_INTERVAL_S
 736        self.stagger_cadence()
 737
 738    def stagger_cadence(self) -> None:
 739        """Start somewhere inside the salvo interval, so a battery walks its fire."""
 740        self._salvo_cooldown = self.cadence_offset(BOMBARDIER_SALVO_INTERVAL_S)
 741
 742    def build_body(self):
 743        self.add_child(artkit.build_enemy("bombardier"))
 744
 745    def on_update(self, dt: float):
 746        self._resolve_reticles(dt)
 747        super().on_update(dt)
 748        if self.telegraphing:
 749            return
 750        self._salvo_cooldown -= dt
 751        if self._salvo_cooldown > 0.0:
 752            return
 753        ship = self.player_ship()
 754        if ship is None or self.distance_to(ship) > BOMBARDIER_MAX_RANGE:
 755            return
 756        if self.begin_attack():
 757            self._salvo_cooldown = BOMBARDIER_SALVO_INTERVAL_S
 758
 759    def on_fixed_update(self, dt: float):
 760        ship = self.player_ship()
 761        if ship is None:
 762            return
 763        self.hold_standoff(to_plane(ship.position), dt, BOMBARDIER_STANDOFF_DISTANCE, BOMBARDIER_STANDOFF_BAND)
 764
 765    def perform_attack(self) -> None:
 766        """Paint the salvo's landing circles, leading the ship's current course."""
 767        ship = self.player_ship()
 768        if ship is None:
 769            return
 770        origin = to_plane(ship.position)
 771        velocity = getattr(ship, "velocity", None)
 772        lead = Vec2(0.0, 0.0)
 773        if velocity is not None:
 774            lead = Vec2(float(velocity.x) * BOMBARDIER_LEAD_S, float(velocity.y) * BOMBARDIER_LEAD_S)
 775        for index in range(BOMBARDIER_SALVO_SHELLS):
 776            walk = 1.0 + index * 0.5
 777            centre = Vec2(
 778                float(origin.x) + float(lead.x) * walk + self.rng.uniform(-BOMBARDIER_SCATTER, BOMBARDIER_SCATTER),
 779                float(origin.y) + float(lead.y) * walk + self.rng.uniform(-BOMBARDIER_SCATTER, BOMBARDIER_SCATTER),
 780            )
 781            flight = BOMBARDIER_SHELL_FLIGHT_S + index * BOMBARDIER_SHELL_STAGGER_S
 782            reticle = MortarReticle(centre=centre, radius=BOMBARDIER_BLAST_RADIUS, remaining=flight, flight=flight)
 783            self._launch(reticle)
 784            self.reticles.append(reticle)
 785
 786    # -- internals ----------------------------------------------------------
 787
 788    def _launch(self, reticle: MortarReticle) -> None:
 789        """Paint *reticle* on the plane and put a shell in the air toward it."""
 790        parent = self.parent if self.parent is not None else self
 791        reticle.marker = parent.add_child(
 792            ReticleMarker(
 793                radius=reticle.radius,
 794                position=Vec3(float(reticle.centre.x), PLANE_Y + RETICLE_HEIGHT, float(reticle.centre.y)),
 795            )
 796        )
 797        muzzle = self.plane_position
 798        reticle.shell = parent.add_child(MortarShell(origin=muzzle, target=reticle.centre, flight=reticle.flight))
 799        self.shell_launched(from_plane(muzzle), reticle.flight)
 800
 801    def _resolve_reticles(self, dt: float) -> None:
 802        if not self.reticles:
 803            return
 804        landed: list[MortarReticle] = []
 805        live: list[MortarReticle] = []
 806        for reticle in self.reticles:
 807            reticle.remaining -= dt
 808            if reticle.remaining > 0.0:
 809                self._drive_ordnance(reticle, dt)
 810                live.append(reticle)
 811            else:
 812                landed.append(reticle)
 813        self.reticles = live
 814        for reticle in landed:
 815            self._impact(reticle)
 816        if not landed:
 817            return
 818        ship = self.player_ship()
 819        if ship is None:
 820            return
 821        here = to_plane(ship.position)
 822        for reticle in landed:
 823            gap = math.hypot(float(here.x) - float(reticle.centre.x), float(here.y) - float(reticle.centre.y))
 824            if gap <= reticle.radius:
 825                self.deal_damage_from(reticle.centre, ship, self.spec.damage, "explosive")
 826
 827    def _drive_ordnance(self, reticle: MortarReticle, dt: float) -> None:
 828        """Close the ring and fly the shell one step along its arc."""
 829        if reticle.marker is not None and not reticle.marker.destroying:
 830            reticle.marker.set_fraction(reticle.fraction)
 831        if reticle.shell is not None and not reticle.shell.destroying:
 832            reticle.shell.advance(dt)
 833
 834    def _impact(self, reticle: MortarReticle) -> None:
 835        """Retire a landed shell's geometry and detonate it where it fell."""
 836        for node in (reticle.marker, reticle.shell):
 837            if node is not None and not node.destroying:
 838                node.destroy()
 839        reticle.marker = None
 840        reticle.shell = None
 841        tree = self.tree
 842        if tree is None or tree.root is None:
 843            return
 844        Vfx.spawn(
 845            tree,
 846            "explosion",
 847            Vec3(float(reticle.centre.x), PLANE_Y, float(reticle.centre.y)),
 848            faction="enemy",
 849            scale=reticle.radius / BOMBARDIER_BLAST_RADIUS,
 850        )
 851
 852    def on_exit_tree(self):
 853        """Take any ordnance still in the air with the tube that fired it."""
 854        super().on_exit_tree()
 855        for reticle in self.reticles:
 856            for node in (reticle.marker, reticle.shell):
 857                if node is not None and not node.destroying:
 858                    node.destroy()
 859        self.reticles = []
 860
 861
 862# ============================================================================
 863# Screamer
 864# ============================================================================
 865
 866
 867class Screamer(AdvancedEnemy):
 868    """Fragile siren that costs signature rather than hull.
 869
 870    It aggros when the ship comes inside :data:`SCREAMER_AGGRO_RANGE` or the
 871    moment it is hit, then runs. Survive that aggro by
 872    ``balance.SCREAMER_AGGRO_TIMER_S`` and it emits :attr:`screamer_screamed`;
 873    the signature meter is what turns that into
 874    ``balance.SIGNATURE_SCREAMER_SURVIVED``, which is the next wave's budget. It
 875    never fires a shot, so ignoring it only ever costs you later.
 876    """
 877
 878    ARCHETYPE = "screamer"
 879    SPEED = SCREAMER_SPEED
 880    HITBOX_RADIUS = 1.0
 881
 882    #: ``runtime.SignalNames.SCREAMER_SCREAMED``. Emitted once, if it lives long enough.
 883    screamer_screamed = Signal()
 884
 885    def __init__(self, **kwargs):
 886        super().__init__(**kwargs)
 887        self.aggroed = False
 888        self.screamed = False
 889        self._aggro_elapsed = 0.0
 890
 891    def build_body(self):
 892        self.add_child(artkit.build_enemy("screamer"))
 893
 894    @property
 895    def seconds_to_scream(self) -> float:
 896        """Time left on the aggro clock: ``inf`` before aggro, 0 once it has screamed."""
 897        if self.screamed:
 898            return 0.0
 899        if not self.aggroed:
 900            return float("inf")
 901        return max(0.0, balance.SCREAMER_AGGRO_TIMER_S - self._aggro_elapsed)
 902
 903    def aggro(self) -> None:
 904        """Notice the ship and start the clock. Idempotent."""
 905        if self.aggroed:
 906            return
 907        self.aggroed = True
 908        self._aggro_elapsed = 0.0
 909
 910    def take_damage(self, amount: float, kind: str) -> None:
 911        """Being shot counts as being noticed, whatever the range."""
 912        self.aggro()
 913        super().take_damage(amount, kind)
 914
 915    def on_update(self, dt: float):
 916        super().on_update(dt)
 917        if not self.aggroed:
 918            ship = self.player_ship()
 919            if ship is not None and self.distance_to(ship) <= SCREAMER_AGGRO_RANGE:
 920                self.aggro()
 921            return
 922        if self.screamed:
 923            return
 924        self._aggro_elapsed += dt
 925        if self._aggro_elapsed >= balance.SCREAMER_AGGRO_TIMER_S:
 926            self.screamed = True
 927            self.screamer_screamed()
 928
 929    def on_fixed_update(self, dt: float):
 930        if not self.aggroed:
 931            return
 932        ship = self.player_ship()
 933        if ship is None:
 934            return
 935        away = self.plane_position - to_plane(ship.position)
 936        distance = math.hypot(float(away.x), float(away.y))
 937        if distance >= SCREAMER_FLEE_DISTANCE:
 938            self.move(dt, Vec2(0.0, 0.0), 0.0)
 939            return
 940        direction = Vec2(1.0, 0.0) if distance <= 1e-6 else Vec2(float(away.x) / distance, float(away.y) / distance)
 941        self.move(dt, direction, self.SPEED)
 942        self.face(direction)
 943
 944
 945# ============================================================================
 946# Husk turret
 947# ============================================================================
 948
 949
 950class HuskTurret(AdvancedEnemy):
 951    """A dead hull's gun emplacement: terrain that shoots.
 952
 953    It cannot move. Idle, the beam oscillates across
 954    :data:`HUSK_TURRET_SWEEP_ARC` around its rest heading; with the ship inside
 955    :data:`HUSK_TURRET_BEAM_RANGE` it pulls onto the target at
 956    :data:`HUSK_TURRET_TRACK_RATE`. Coming on target runs the standard telegraph
 957    and only then lights the beam, which damages ``spec.damage`` per
 958    :data:`HUSK_TURRET_TICK_S` tick for as long as it stays on you. Breaking the
 959    beam's line therefore matters more than out-running it.
 960    """
 961
 962    ARCHETYPE = "husk_turret"
 963    SPEED = 0.0
 964    HITBOX_RADIUS = 2.2
 965
 966    def __init__(self, **kwargs):
 967        super().__init__(**kwargs)
 968        self.rest_heading = 0.0
 969        self.beam_heading = 0.0
 970        self.beam_active = False
 971        self.beam: PlaneBeam | None = None
 972        self._barrel: Node3D | None = None
 973        self._sweep_phase = 0.0
 974        self._tick_accumulator = 0.0
 975        self.stagger_cadence()
 976
 977    def stagger_cadence(self) -> None:
 978        """Enter the idle sweep at a random point of its cycle.
 979
 980        Two turrets covering the same corridor otherwise sweep as one gate that
 981        is either open or shut, instead of as two beams a player can time.
 982        """
 983        self._sweep_phase = self.cadence_offset(2.0 * math.pi)
 984
 985    def build_body(self):
 986        self.add_child(artkit.build_enemy("husk_turret"))
 987        # The barrel is a build of its own because it swivels: the drum below
 988        # it is a dead hull and never turns.
 989        self._barrel = self.add_child(Node3D(name="Barrel", position=Vec3(0.0, HUSK_TURRET_BARREL_HEIGHT, 0.0)))
 990        self._barrel.add_child(artkit.build_turret_barrel())
 991        self.beam = self.mount_beam("Beam")
 992
 993    @property
 994    def beam_endpoint(self) -> Vec2:
 995        """Where the beam terminates on the plane, for the vfx and hud passes."""
 996        unit = heading_vector(self.beam_heading)
 997        here = self.plane_position
 998        return Vec2(
 999            float(here.x) + float(unit.x) * HUSK_TURRET_BEAM_RANGE,
1000            float(here.y) + float(unit.y) * HUSK_TURRET_BEAM_RANGE,
1001        )
1002
1003    def on_update(self, dt: float):
1004        super().on_update(dt)
1005        ship = self.player_ship()
1006        if ship is None or self.distance_to(ship) > HUSK_TURRET_BEAM_RANGE:
1007            self._sweep(dt)
1008            self._stand_down()
1009            self._aim_barrel()
1010            return
1011        error = self._track(ship, dt)
1012        self._aim_barrel()
1013        if abs(error) > HUSK_TURRET_BEAM_HALF_ANGLE:
1014            self._stand_down()
1015            return
1016        if not self.beam_active:
1017            self.begin_attack()
1018            return
1019        self._draw_beam()
1020        self._tick_accumulator += dt
1021        while self._tick_accumulator >= HUSK_TURRET_TICK_S:
1022            self._tick_accumulator -= HUSK_TURRET_TICK_S
1023            self.deal_damage_to(ship, self.spec.damage, "beam")
1024
1025    def perform_attack(self) -> None:
1026        """Light the beam and land its first tick; later ticks run from ``on_update``.
1027
1028        The telegraph runs to completion even if the ship slips off the beam, so
1029        the attack re-checks the alignment it was announced for rather than
1030        trusting a decision made a third of a second ago.
1031        """
1032        ship = self.player_ship()
1033        if ship is None or self.distance_to(ship) > HUSK_TURRET_BEAM_RANGE:
1034            return
1035        error = wrap_angle(bearing(self.plane_position, to_plane(ship.position)) - self.beam_heading)
1036        if abs(error) > HUSK_TURRET_BEAM_HALF_ANGLE:
1037            return
1038        self.beam_active = True
1039        self._tick_accumulator = 0.0
1040        self._draw_beam()
1041        self.deal_damage_to(ship, self.spec.damage, "beam")
1042
1043    # -- internals ----------------------------------------------------------
1044
1045    def _draw_beam(self) -> None:
1046        """Light the beam down the heading it is currently damaging along."""
1047        if self.beam is not None:
1048            self.beam.draw_between(self.plane_position, self.beam_endpoint)
1049
1050    def _sweep(self, dt: float) -> None:
1051        self._sweep_phase += HUSK_TURRET_SWEEP_RATE * dt
1052        offset = math.sin(self._sweep_phase) * HUSK_TURRET_SWEEP_ARC * 0.5
1053        self.beam_heading = wrap_angle(self.rest_heading + offset)
1054
1055    def _track(self, ship, dt: float) -> float:
1056        """Turn the beam toward *ship*; returns the residual angular error."""
1057        wanted = bearing(self.plane_position, to_plane(ship.position))
1058        error = wrap_angle(wanted - self.beam_heading)
1059        step = HUSK_TURRET_TRACK_RATE * dt
1060        self.beam_heading = wrap_angle(self.beam_heading + max(-step, min(step, error)))
1061        self.rest_heading = self.beam_heading
1062        self._sweep_phase = 0.0
1063        return wrap_angle(wanted - self.beam_heading)
1064
1065    def _aim_barrel(self) -> None:
1066        if self._barrel is not None:
1067            self._barrel.rotation = Quat.from_euler(0.0, self.beam_heading, 0.0)
1068
1069    def _stand_down(self) -> None:
1070        self.beam_active = False
1071        self._tick_accumulator = 0.0
1072        if self.beam is not None:
1073            self.beam.douse()
1074
1075
1076# ============================================================================
1077# Heralds
1078# ============================================================================
1079
1080
1081class Herald(AdvancedEnemy):
1082    """Shrike-spawn that pours in during the arrival window, previewing the hunter.
1083
1084    Heralds orbit and lance rather than charge, which is the hunter's own attack
1085    language at a survivable scale. Each carries a share of its wave's Fletchings
1086    and emits :attr:`herald_killed` with that share when it dies, so the Roost
1087    ledger banks it without polling.
1088    """
1089
1090    ARCHETYPE = "herald"
1091    SPEED = HERALD_SPEED
1092    HITBOX_RADIUS = 1.4
1093    BODY_COLOUR = HERALD_COLOUR
1094
1095    #: Emitted once on death, carrying the Fletchings this herald was holding.
1096    herald_killed = Signal(int)
1097
1098    def __init__(self, **kwargs):
1099        super().__init__(**kwargs)
1100        self.fletchings = 0
1101        self.lance: PlaneBeam | None = None
1102        self._orbit_phase = 0.0
1103        self._fire_cooldown = HERALD_FIRE_INTERVAL_S
1104        self.stagger_cadence()
1105
1106    def stagger_cadence(self) -> None:
1107        """Spread a flight's lances out, so five heralds are not one volley."""
1108        self._fire_cooldown = self.cadence_offset(HERALD_FIRE_INTERVAL_S)
1109
1110    def build_body(self):
1111        self.add_child(artkit.build_enemy("herald"))
1112        self.lance = self.mount_beam("Lance")
1113
1114    def on_ready(self):
1115        super().on_ready()
1116        self.died.connect(self._announce_drop)
1117
1118    def set_orbit_phase(self, radians: float) -> None:
1119        """Put this herald at a given point of the strafing orbit, so a flight fans out."""
1120        self._orbit_phase = float(radians)
1121
1122    def on_update(self, dt: float):
1123        super().on_update(dt)
1124        if self.telegraphing:
1125            return
1126        self._fire_cooldown -= dt
1127        if self._fire_cooldown > 0.0:
1128            return
1129        ship = self.player_ship()
1130        if ship is not None and self.distance_to(ship) <= HERALD_LANCE_RANGE and self.begin_attack():
1131            self._fire_cooldown = HERALD_FIRE_INTERVAL_S
1132
1133    def on_fixed_update(self, dt: float):
1134        ship = self.player_ship()
1135        if ship is None:
1136            return
1137        self._orbit_phase += HERALD_ORBIT_RATE * dt
1138        centre = to_plane(ship.position)
1139        wanted = Vec2(
1140            float(centre.x) + math.cos(self._orbit_phase) * HERALD_ORBIT_RADIUS,
1141            float(centre.y) + math.sin(self._orbit_phase) * HERALD_ORBIT_RADIUS,
1142        )
1143        here = self.plane_position
1144        direction = seek(here, wanted)
1145        reach = math.hypot(float(wanted.x) - float(here.x), float(wanted.y) - float(here.y))
1146        self.move(dt, direction, min(self.SPEED, reach / max(dt, 1e-6)))
1147        self.face(direction)
1148
1149    def perform_attack(self) -> None:
1150        """Lance the ship, if it is still inside the strafing run's reach.
1151
1152        The bolt is drawn along the line the damage travelled and left up for
1153        :data:`LANCE_TRACER_S`, which is what turns a hull point loss into an
1154        attack the player can locate and answer.
1155        """
1156        ship = self.player_ship()
1157        if ship is None or self.distance_to(ship) > HERALD_LANCE_RANGE:
1158            return
1159        if self.lance is not None:
1160            self.lance.draw_between(self.plane_position, to_plane(ship.position), hold_s=LANCE_TRACER_S)
1161        self.deal_damage_to(ship, self.spec.damage, "energy")
1162
1163    def _announce_drop(self) -> None:
1164        self.herald_killed(int(self.fletchings))
1165
1166
1167def fletching_budget(act: int, runs_since_quill: int, rng: random.Random) -> int:
1168    """Fletchings one herald wave is worth, rising with the drought since your last quill."""
1169    low, high = balance.ACT_DROPS[act].fletchings_per_herald_wave
1170    if high <= 0:
1171        return 0
1172    base = rng.randint(low, high)
1173    rise = 1.0 + balance.FLETCHING_RATE_RISE_PER_RUN * max(0, int(runs_since_quill))
1174    return int(round(base * rise))
1175
1176
1177def share_out(total: int, parts: int, rng: random.Random) -> list[int]:
1178    """Split *total* into *parts* whole shares, scattering the remainder."""
1179    if parts <= 0:
1180        return []
1181    shares = [total // parts] * parts
1182    for index in rng.sample(range(parts), total % parts):
1183        shares[index] += 1
1184    return shares
1185
1186
1187def spawn_herald_flight(
1188    parent: Node3D,
1189    centre: Vec2,
1190    *,
1191    count: int,
1192    act: int = 1,
1193    runs_since_quill: int = 0,
1194    seed: int = 0,
1195    radius: float = HERALD_SPAWN_RADIUS,
1196) -> list[Herald]:
1197    """Tear *count* heralds in on a ring around *centre* and share the wave's Fletchings out.
1198
1199    The wave's whole budget is rolled once from ``balance.ACT_DROPS`` and then
1200    split across the flight, so clearing every herald pays what the design tables
1201    promise however many arrived.
1202    """
1203    if count <= 0:
1204        return []
1205    rng = random.Random(seed)
1206    shares = share_out(fletching_budget(act, runs_since_quill, rng), count, rng)
1207    offset = rng.uniform(0.0, 2.0 * math.pi)
1208
1209    flight: list[Herald] = []
1210    for index in range(count):
1211        angle = offset + index * (2.0 * math.pi / count)
1212        herald = Herald(name=f"Herald{index + 1}")
1213        herald.position = from_plane(
1214            Vec2(float(centre.x) + math.cos(angle) * radius, float(centre.y) + math.sin(angle) * radius)
1215        )
1216        parent.add_child(herald)
1217        herald.set_seed(seed + index)
1218        herald.fletchings = shares[index]
1219        herald.set_orbit_phase(angle)
1220        flight.append(herald)
1221    return flight
1222
1223
1224class HeraldFlight(Node3D):
1225    """Owns the herald flight's arrival window: in at ``t30``, out with the hunter.
1226
1227    Add one to the run scene and it watches the hunter's telegraph ladder. On the
1228    :data:`HERALD_ARRIVAL_STAGE` stage it spawns a flight around the ship and
1229    tallies the Fletchings the flight drops; when the hunter departs, any
1230    survivor leaves with it. The flight is a set-piece rather than a wave, so it
1231    spends none of the wave composer's threat budget.
1232    """
1233
1234    def __init__(self, *, act: int = 1, runs_since_quill: int = 0, seed: int = 0, **kwargs):
1235        super().__init__(**kwargs)
1236        self.act = act
1237        self.runs_since_quill = runs_since_quill
1238        self.seed = seed
1239        self.fletchings_dropped = 0
1240        self.heralds: list[Herald] = []
1241        self._hunter = None
1242        self._rng = random.Random(seed)
1243
1244    def on_update(self, dt: float):
1245        self._attach_to_hunter()
1246        self.heralds = [h for h in self.heralds if not h.destroying and h.tree is not None]
1247
1248    def on_hunter_telegraph(self, stage: str) -> None:
1249        """Spawn the flight when the ladder reaches the arrival window."""
1250        if stage == HERALD_ARRIVAL_STAGE and not self.heralds:
1251            self.spawn()
1252
1253    def on_hunter_departed(self) -> None:
1254        """The flight leaves with the thing that sent it."""
1255        for herald in self.heralds:
1256            if not herald.destroying:
1257                herald.destroy()
1258        self.heralds = []
1259
1260    def spawn(self) -> list[Herald]:
1261        """Tear a flight in around the ship and start counting its Fletchings."""
1262        centre = Vec2(0.0, 0.0)
1263        tree = self.tree
1264        if tree is not None:
1265            for ship in tree.group(Groups.SHIP):
1266                centre = to_plane(ship.position)
1267                break
1268        self.heralds = spawn_herald_flight(
1269            self,
1270            centre,
1271            count=self._rng.randint(HERALD_FLIGHT_MIN, HERALD_FLIGHT_MAX),
1272            act=self.act,
1273            runs_since_quill=self.runs_since_quill,
1274            seed=self.seed,
1275        )
1276        for herald in self.heralds:
1277            herald.herald_killed.connect(self._on_herald_killed)
1278        return self.heralds
1279
1280    # -- internals ----------------------------------------------------------
1281
1282    def _on_herald_killed(self, fletchings: int) -> None:
1283        self.fletchings_dropped += int(fletchings)
1284
1285    def _attach_to_hunter(self) -> None:
1286        tree = self.tree
1287        if self._hunter is not None or tree is None:
1288            return
1289        for hunter in tree.group(Groups.HUNTER):
1290            telegraph = getattr(hunter, "hunter_telegraph", None)
1291            if telegraph is None:
1292                continue
1293            telegraph.connect(self.on_hunter_telegraph)
1294            departed = getattr(hunter, "hunter_departed", None)
1295            if departed is not None:
1296                departed.connect(self.on_hunter_departed)
1297            self._hunter = hunter
1298            return
1299
1300
1301# ============================================================================
1302# Elite modifiers
1303# ============================================================================
1304
1305
1306class EliteModifier:
1307    """A composable wrapper that re-skins any enemy without subclassing it.
1308
1309    Attaching replaces the enemy's bound ``take_damage`` with the modifier's own
1310    and subscribes to its ``died`` signal, so the modifier sees every hit and the
1311    death that ends the chain without the archetype knowing it exists. Modifiers
1312    therefore nest: attach two and the second filters first, and both still get
1313    their :meth:`on_killed`. That is why the same three apply to a mite shoal and
1314    to a herald.
1315    """
1316
1317    #: Matches an entry in ``balance.ELITE_MODIFIERS``.
1318    id: str = ""
1319
1320    def __init__(self):
1321        self.enemy = None
1322        self._inner = None
1323
1324    def attach(self, enemy: Enemy) -> EliteModifier:
1325        """Wrap *enemy*, returning this modifier so callers can hold or chain it.
1326
1327        Attaching normalises ``max_hp`` onto the enemy first. Elites are applied
1328        at spawn, so whatever the archetype is carrying then is its full pool,
1329        and that is the number Armoured scales and Splitting divides, whether the
1330        hull is a single ship or an aggregate such as a mite shoal.
1331        """
1332        enemy.max_hp = float(getattr(enemy, "max_hp", enemy.hp))
1333        self.enemy = enemy
1334        self._inner = enemy.take_damage
1335        enemy.take_damage = self._filtered_take_damage
1336        modifiers = getattr(enemy, "elite_modifiers", None)
1337        if modifiers is None:
1338            modifiers = []
1339            enemy.elite_modifiers = modifiers
1340        modifiers.append(self)
1341        enemy.elite = self.id
1342        enemy.died.connect(self._on_died)
1343        self.on_attach(enemy)
1344        return self
1345
1346    def _filtered_take_damage(self, amount: float, kind: str) -> None:
1347        self._inner(self.filter_damage(float(amount), kind), kind)
1348
1349    def _on_died(self) -> None:
1350        if self.enemy is not None:
1351            self.on_killed(self.enemy)
1352
1353    # -- hooks --------------------------------------------------------------
1354
1355    def on_attach(self, enemy: Enemy) -> None:
1356        """Apply whatever this modifier changes at spawn."""
1357
1358    def filter_damage(self, amount: float, kind: str) -> float:
1359        """Transform an incoming hit before the enemy under this wrapper sees it."""
1360        return amount
1361
1362    def on_killed(self, enemy: Enemy) -> None:
1363        """React to the wrapped enemy running out of hull points."""
1364
1365
1366class Armoured(EliteModifier):
1367    """Plated: a bigger hull pool, and most incoming damage trimmed.
1368
1369    Ballistics were built for plating and lose far less to it than energy weapons
1370    do, so the modifier reads a hit's ``kind`` rather than only its size. That is
1371    the readable answer to an Armoured elite and one of the reasons a run carries
1372    both economies.
1373    """
1374
1375    id = "armoured"
1376
1377    def on_attach(self, enemy: Enemy) -> None:
1378        enemy.max_hp = full_hp(enemy) * ELITE_ARMOURED_HP_MULT
1379        enemy.hp = enemy.max_hp
1380
1381    def filter_damage(self, amount: float, kind: str) -> float:
1382        reduction = ELITE_ARMOURED_BALLISTIC_REDUCTION if kind == "ballistic" else ELITE_ARMOURED_REDUCTION
1383        return amount * (1.0 - reduction)
1384
1385
1386class Splitting(EliteModifier):
1387    """Comes apart: dying spawns :data:`ELITE_SPLIT_COUNT` smaller copies.
1388
1389    The halves are the same archetype at :data:`ELITE_SPLIT_HP_FRACTION` of the
1390    parent's pool and carry no modifier of their own, so a split never cascades
1391    and the threat budget stays honest.
1392    """
1393
1394    id = "splitting"
1395
1396    def on_killed(self, enemy: Enemy) -> None:
1397        parent = enemy.parent
1398        if parent is None or enemy.tree is None:
1399            return
1400        rng = getattr(enemy, "rng", None) or random.Random()
1401        pool = full_hp(enemy) * ELITE_SPLIT_HP_FRACTION
1402        here = to_plane(enemy.position)
1403        spread = rng.uniform(0.0, 2.0 * math.pi)
1404        for index in range(ELITE_SPLIT_COUNT):
1405            angle = spread + index * (2.0 * math.pi / ELITE_SPLIT_COUNT)
1406            half = type(enemy)(name=f"{enemy.name}Split{index + 1}")
1407            half.position = Vec3(
1408                float(here.x) + math.cos(angle) * ELITE_SPLIT_SCATTER,
1409                PLANE_Y,
1410                float(here.y) + math.sin(angle) * ELITE_SPLIT_SCATTER,
1411            )
1412            parent.add_child(half)
1413            half.max_hp = pool
1414            half.hp = pool
1415            half.elite = None
1416
1417
1418class EmpLaced(EliteModifier):
1419    """Wired to burst: dying dumps the ship's capacitor if it is standing close.
1420
1421    The drain runs through :class:`PowerSystem` like any other spend, so a fat
1422    capacitor, a running generator and silent running all change what the burst
1423    actually costs.
1424    """
1425
1426    id = "emp_laced"
1427
1428    def on_killed(self, enemy: Enemy) -> None:
1429        tree = enemy.tree
1430        if tree is None:
1431            return
1432        power = tree.singletons.get(Services.POWER)
1433        ship = next((node for node in tree.group(Groups.SHIP) if not node.destroying), None)
1434        if power is None or ship is None:
1435            return
1436        here = to_plane(enemy.position)
1437        there = to_plane(ship.position)
1438        if math.hypot(float(there.x) - float(here.x), float(there.y) - float(here.y)) > ELITE_EMP_RADIUS:
1439            return
1440        drained = min(ELITE_EMP_CAPACITOR_DRAIN, float(power.capacitor))
1441        if drained > 0.0:
1442            power.request(drained, "elite_emp")
1443
1444
1445#: Every elite modifier in ``balance.ELITE_MODIFIERS``, by id.
1446ELITE_MODIFIER_TYPES: dict[str, type[EliteModifier]] = {
1447    Armoured.id: Armoured,
1448    Splitting.id: Splitting,
1449    EmpLaced.id: EmpLaced,
1450}
1451
1452
1453def apply_elite(enemy: Enemy, modifier_id: str) -> EliteModifier:
1454    """Wrap *enemy* in the named modifier and return the wrapper.
1455
1456    Raises :class:`KeyError` for an id outside ``balance.ELITE_MODIFIERS``: a
1457    typo that silently produced a plain enemy is the kind of bug a wave composer
1458    hides for weeks.
1459    """
1460    modifier_type = ELITE_MODIFIER_TYPES.get(modifier_id)
1461    if modifier_type is None:
1462        known = ", ".join(sorted(ELITE_MODIFIER_TYPES))
1463        raise KeyError(f"unknown elite modifier {modifier_id!r}; expected one of {known}")
1464    return modifier_type().attach(enemy)
1465
1466
1467#: The advanced archetypes by their ``balance.ENEMIES`` id.
1468ARCHETYPES: dict[str, type[AdvancedEnemy]] = {
1469    Welder.ARCHETYPE: Welder,
1470    Bombardier.ARCHETYPE: Bombardier,
1471    Screamer.ARCHETYPE: Screamer,
1472    HuskTurret.ARCHETYPE: HuskTurret,
1473    Herald.ARCHETYPE: Herald,
1474}