shrike/enemies/steering.py¶

Part of SHRIKE.

  1"""Flow-field steering and the shared enemy base.
  2
  3Every hostile in SHRIKE moves the same way. A :class:`FlowField` turns the
  4flight plane into a grid of unit vectors that all point down the cheapest path
  5to one target, so a shoal of twenty mites costs one field rebuild rather than
  6twenty pathfinding queries, and a wreck field bends the whole swarm around it
  7instead of pinning individuals against its hull. :class:`Enemy` is the base
  8every archetype derives from: it owns hull points, the elite tag, the group and
  9collision-layer registration, and the universal telegraph.
 10
 11The telegraph convention
 12========================
 13
 14**No enemy in the game attacks without flashing white first.** The base class
 15owns that promise: :meth:`Enemy.begin_attack` flashes the body white for
 16:attr:`Enemy.telegraph_duration` seconds (``balance.ENEMY_FIRE_TELEGRAPH_S``
 17for everything except the Lancer's longer dash wind-up) and only then calls
 18:meth:`Enemy.perform_attack`. Archetypes override ``perform_attack``; they do
 19not schedule attacks of their own. The flash is the readability contract from
 20the art direction: player fire is warm gold, enemy fire is cold magenta, and
 21white belongs to telegraphs alone.
 22
 23Every wound shows
 24=================
 25
 26A hull that takes nine seconds of fire and gives nothing back reads as
 27invulnerable, whatever the number underneath it is doing, so the feel budget
 28cannot all belong to kills. :meth:`Enemy.take_damage` now answers every hit.
 29The struck body pulses white for :data:`HIT_FLASH_S` on its emissive channel
 30(the telegraph keeps the albedo, so the two never fight), a spark goes off
 31where the round came apart, and a hull big enough to be worth the question
 32(:data:`HP_PIP_MIN_HP` and up) grows a :class:`~shrike.vfx.HealthPip` on its
 33first wound that fades away again at full or at zero.
 34
 35Contact has weight
 36==================
 37
 38An enemy that touches the hull calls :meth:`Enemy.contact_damage`, never
 39:meth:`Enemy.deal_damage_to`. Contact resolves through the damage router's
 40contact path: the attacker's drive dies for ``balance.CONTACT_STALL_S`` and
 41both hulls take a radial impulse apart. The stall and the recoil are honoured
 42by :meth:`Enemy.move`, so an archetype gets the collision for free by steering
 43the way every archetype already steers, and nothing glides through the ship.
 44
 45Staggered cadences
 46==================
 47
 48Every archetype that attacks on a timer seeds that timer from
 49:meth:`Enemy.stagger_cadence`, so two Lancers that arrive on the same frame do
 50not wind up, dash and recover in lockstep for the rest of the fight. The offset
 51is drawn from a stream of its own rather than from :attr:`Enemy.rng`, because a
 52seeded spawn has to stay reproducible whatever the cadence does.
 53"""
 54
 55from __future__ import annotations
 56
 57import math
 58import random
 59
 60import numpy as np
 61
 62from simvx.core import Area3D, Material, Node3D, Quat, Signal, SphereShape3D, Vec2, Vec3
 63
 64from .. import balance
 65from ..combat import separate_on_contact
 66from ..runtime import PLANE_Y, Groups, Layers, Services, from_plane, to_plane
 67from ..vfx import HealthPip, Vfx
 68
 69# ============================================================================
 70# Steering constants
 71#
 72# These are movement-feel numbers rather than balance numbers: they describe
 73# how the grid is discretised and how strongly geometry repels a swarm, not how
 74# hard anything hits. balance.py carries no equivalents.
 75# ============================================================================
 76
 77#: Side length of one flow-field cell, in world units.
 78FLOW_CELL_SIZE = 8.0
 79#: Default playfield the field covers when a caller does not supply bounds:
 80#: the 60-unit sector (``sector.SECTOR_RADIUS``) with a margin for spawns that
 81#: approach from off the edge.
 82DEFAULT_FIELD_BOUNDS = (-120.0, -120.0, 120.0, 120.0)
 83#: Extra traversal cost charged to a cell covered by an obstacle. High enough
 84#: that a swarm flows around a wreck, finite so nothing is ever unreachable.
 85OBSTACLE_CELL_COST = 24.0
 86#: Radius around an obstacle point whose cells carry the extra cost.
 87OBSTACLE_RADIUS = 10.0
 88#: How often the scene-wide field is re-integrated toward the player ship.
 89FLOW_REBUILD_INTERVAL_S = 0.25
 90#: Group holding the scene's shared :class:`FlowFieldDirector`, if one exists.
 91FLOW_FIELD_GROUP = "flow_field"
 92
 93#: Telegraph colour. White is reserved for telegraphs across the whole game.
 94TELEGRAPH_COLOUR = (1.0, 1.0, 1.0, 1.0)
 95
 96# ----------------------------------------------------------------------------
 97# Damage feedback
 98#
 99# A hit that produces no picture is a hit the player does not believe in. Three
100# things say "that landed", and between them they cover every scale of hull:
101# a white pulse on the body, a spark where the round came apart, and, for the
102# hulls big enough that the honest question is "how much further", a pip.
103# ----------------------------------------------------------------------------
104
105#: Length of the white emissive pulse a hit puts on a struck hull, seconds.
106#: Short on purpose: long enough to register at 60 fps, too short to wash the
107#: hull's own colour out under sustained fire.
108HIT_FLASH_S = 0.08
109#: Emissive strength the pulse drives the struck materials to.
110HIT_FLASH_STRENGTH = 6.0
111#: The pulse's colour. White, like every other "pay attention" signal.
112HIT_FLASH_EMISSIVE = (1.0, 1.0, 1.0)
113#: Shortest gap between two impact sparks on one hull. A beam lands a hit every
114#: frame, and sixty sparks a second is both an empty effect pool and a solid
115#: block of light where a stream of them should be.
116HIT_SPARK_INTERVAL_S = 0.06
117#: Smallest hull pool worth a health pip: the set the design names outright
118#: (bombardiers, husk turrets, wardens, magistrates) and anything an Armoured
119#: elite has plated up past them. Below it a hull dies inside a burst, and a
120#: read-out on it would be chrome the player never has time to read.
121HP_PIP_MIN_HP = 90.0
122#: How far above the flight plane the pip rides, world units.
123HP_PIP_HEIGHT = 1.9
124#: How far toward the top of the screen (-Z) the pip is offset from the hull.
125HP_PIP_OFFSET_Z = -1.6
126
127_LARGE_COST = np.float32(1e9)
128_SQRT2 = math.sqrt(2.0)
129
130#: Neighbour offsets as ``(row delta, column delta)``, rows being +Z and
131#: columns +X, in the order the relaxation stacks them.
132_NEIGHBOURS = ((-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1))
133
134
135class FlowField:
136    """A grid of unit vectors over the flight plane, all pointing at one target.
137
138    Rebuilding integrates a cost-to-go field outward from the target cell with
139    an eight-connected relaxation, charging obstacles extra to enter, then
140    stores the descent direction per cell. Sampling is bilinear, so a body
141    crossing a cell boundary turns smoothly rather than snapping.
142
143    Positions and directions are plane coordinates, ``Vec2(x, z)``. Bounds are
144    ``(min_x, min_z, max_x, max_z)``; a sample from outside the bounds falls
145    back to a straight line toward the target, which walks a stray body back
146    into the field rather than stalling it at the edge.
147    """
148
149    def __init__(self, bounds: tuple[float, float, float, float], cell: float = FLOW_CELL_SIZE):
150        min_x, min_z, max_x, max_z = (float(b) for b in bounds)
151        if max_x <= min_x or max_z <= min_z:
152            raise ValueError(f"FlowField bounds must be (min_x, min_z, max_x, max_z) with positive extent: {bounds}")
153        if cell <= 0.0:
154            raise ValueError(f"FlowField cell size must be positive, got {cell}")
155        self.bounds = (min_x, min_z, max_x, max_z)
156        self.cell = float(cell)
157        self.columns = max(1, int(math.ceil((max_x - min_x) / self.cell)))
158        self.rows = max(1, int(math.ceil((max_z - min_z) / self.cell)))
159        self.target = Vec2(0.0, 0.0)
160        self._flow = np.zeros((self.rows, self.columns, 2), dtype=np.float32)
161        self._cost_to_go = np.zeros((self.rows, self.columns), dtype=np.float32)
162        self._built = False
163        columns_x = min_x + (np.arange(self.columns, dtype=np.float32) + 0.5) * self.cell
164        rows_z = min_z + (np.arange(self.rows, dtype=np.float32) + 0.5) * self.cell
165        self._centre_x = np.tile(columns_x, (self.rows, 1))
166        self._centre_z = np.repeat(rows_z[:, None], self.columns, axis=1)
167
168    # -- Queries ------------------------------------------------------------
169
170    @property
171    def built(self) -> bool:
172        """Whether :meth:`rebuild` has run at least once."""
173        return self._built
174
175    @property
176    def cost_to_go(self) -> np.ndarray:
177        """The integrated cost field, ``(rows, columns)``, in world units."""
178        return self._cost_to_go
179
180    def contains(self, position: Vec2) -> bool:
181        """Whether a plane position lies inside the field's bounds."""
182        min_x, min_z, max_x, max_z = self.bounds
183        return min_x <= float(position.x) <= max_x and min_z <= float(position.y) <= max_z
184
185    def targets(self, point: Vec2, tolerance: float | None = None) -> bool:
186        """Whether the field is currently integrated toward *point*.
187
188        Enemies use this to decide whether the shared field answers the
189        question they are asking: one that chases something other than the
190        field's target steers directly instead.
191        """
192        if not self._built:
193            return False
194        limit = self.cell if tolerance is None else float(tolerance)
195        return math.hypot(float(point.x) - float(self.target.x), float(point.y) - float(self.target.y)) <= limit
196
197    # -- Rebuild ------------------------------------------------------------
198
199    def rebuild(self, target: Vec2, obstacles: list[Vec2]) -> None:
200        """Re-integrate the field toward *target*, charging *obstacles* extra.
201
202        Obstacles are plane points; every cell within :data:`OBSTACLE_RADIUS`
203        of one costs :data:`OBSTACLE_CELL_COST` more to enter. The cost stays
204        finite, so a body walled in by geometry still finds a way out instead
205        of freezing on an unreachable cell.
206        """
207        self.target = Vec2(float(target.x), float(target.y))
208        enter_cost = np.ones((self.rows, self.columns), dtype=np.float32)
209        radius_sq = OBSTACLE_RADIUS * OBSTACLE_RADIUS
210        for obstacle in obstacles:
211            dx = self._centre_x - float(obstacle.x)
212            dz = self._centre_z - float(obstacle.y)
213            enter_cost += np.where(dx * dx + dz * dz < radius_sq, np.float32(OBSTACLE_CELL_COST), np.float32(0.0))
214
215        row = self._row_of(float(target.y))
216        column = self._column_of(float(target.x))
217        costs = np.full((self.rows, self.columns), _LARGE_COST, dtype=np.float32)
218        costs[row, column] = 0.0
219        step = np.array([self.cell * (_SQRT2 if dr and dc else 1.0) for dr, dc in _NEIGHBOURS], dtype=np.float32)
220
221        for _ in range(self.rows + self.columns + 2):
222            candidates = self._neighbour_stack(costs) + step[:, None, None] * enter_cost[None, :, :]
223            relaxed = np.minimum(costs, candidates.min(axis=0))
224            if np.array_equal(relaxed, costs):
225                break
226            costs = relaxed
227
228        self._cost_to_go = costs
229        self._flow = self._descent_directions(costs)
230        self._built = True
231
232    def _neighbour_stack(self, costs: np.ndarray) -> np.ndarray:
233        """The eight shifted copies of *costs*, padded with an unreachable cost."""
234        padded = np.pad(costs, 1, mode="constant", constant_values=_LARGE_COST)
235        rows, columns = costs.shape
236        return np.stack(
237            [padded[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + columns] for dr, dc in _NEIGHBOURS],
238            axis=0,
239        )
240
241    def _descent_directions(self, costs: np.ndarray) -> np.ndarray:
242        """Per-cell unit vector toward the cheapest neighbour."""
243        stack = self._neighbour_stack(costs)
244        best = np.argmin(stack, axis=0)
245        offsets = np.array([(dc, dr) for dr, dc in _NEIGHBOURS], dtype=np.float32)
246        lengths = np.hypot(offsets[:, 0], offsets[:, 1])
247        unit = offsets / lengths[:, None]
248        flow = unit[best]
249        reachable = stack.min(axis=0) < _LARGE_COST * 0.5
250        flow[~reachable] = 0.0
251        flow[costs <= 0.0] = 0.0
252        return flow.astype(np.float32)
253
254    # -- Sampling -----------------------------------------------------------
255
256    def sample(self, position: Vec2) -> Vec2:
257        """The unit steering direction at a plane position."""
258        result = self.sample_many(np.asarray([[float(position.x), float(position.y)]], dtype=np.float32))
259        return Vec2(float(result[0, 0]), float(result[0, 1]))
260
261    def sample_many(self, positions: np.ndarray) -> np.ndarray:
262        """Sample an ``(N, 2)`` array of plane positions in one pass.
263
264        Returns an ``(N, 2)`` array of unit directions (zero where a body is
265        already on the target). This is the path the mite shoal takes: one
266        vectorised gather for the whole swarm.
267
268        The field resolves navigation at cell granularity, so it can only ever
269        aim a body at the target's cell rather than at the target itself. Inside
270        the last cell the sample steers straight at the target instead, which is
271        what lets a shoal close the final few units onto a ship rather than
272        settling into a ring around the cell centre.
273        """
274        points = np.asarray(positions, dtype=np.float32).reshape(-1, 2)
275        direct = self._direct_to_target(points)
276        if not self._built:
277            return direct
278
279        min_x, min_z, max_x, max_z = self.bounds
280        inside = (points[:, 0] >= min_x) & (points[:, 0] <= max_x) & (points[:, 1] >= min_z) & (points[:, 1] <= max_z)
281        offset = np.asarray([float(self.target.x), float(self.target.y)], dtype=np.float32) - points
282        inside &= np.hypot(offset[:, 0], offset[:, 1]) > self.cell
283        u = (points[:, 0] - min_x) / self.cell - 0.5
284        v = (points[:, 1] - min_z) / self.cell - 0.5
285        column0 = np.floor(u).astype(np.int32)
286        row0 = np.floor(v).astype(np.int32)
287        fu = (u - column0)[:, None]
288        fv = (v - row0)[:, None]
289        c0 = np.clip(column0, 0, self.columns - 1)
290        c1 = np.clip(column0 + 1, 0, self.columns - 1)
291        r0 = np.clip(row0, 0, self.rows - 1)
292        r1 = np.clip(row0 + 1, 0, self.rows - 1)
293        top = self._flow[r0, c0] * (1.0 - fu) + self._flow[r0, c1] * fu
294        bottom = self._flow[r1, c0] * (1.0 - fu) + self._flow[r1, c1] * fu
295        blended = top * (1.0 - fv) + bottom * fv
296
297        lengths = np.hypot(blended[:, 0], blended[:, 1])
298        usable = inside & (lengths > 1e-6)
299        out = direct.copy()
300        out[usable] = blended[usable] / lengths[usable, None]
301        return out.astype(np.float32)
302
303    def _direct_to_target(self, points: np.ndarray) -> np.ndarray:
304        offset = np.asarray([float(self.target.x), float(self.target.y)], dtype=np.float32) - points
305        lengths = np.hypot(offset[:, 0], offset[:, 1])
306        out = np.zeros_like(offset)
307        moving = lengths > 1e-6
308        out[moving] = offset[moving] / lengths[moving, None]
309        return out
310
311    def _row_of(self, z: float) -> int:
312        min_z = self.bounds[1]
313        return int(min(max(int((z - min_z) / self.cell), 0), self.rows - 1))
314
315    def _column_of(self, x: float) -> int:
316        min_x = self.bounds[0]
317        return int(min(max(int((x - min_x) / self.cell), 0), self.columns - 1))
318
319
320# ============================================================================
321# Steering helpers
322# ============================================================================
323
324
325def seek(position: Vec2, target: Vec2) -> Vec2:
326    """Unit vector from *position* toward *target*, zero when they coincide."""
327    dx = float(target.x) - float(position.x)
328    dz = float(target.y) - float(position.y)
329    length = math.hypot(dx, dz)
330    if length < 1e-6:
331        return Vec2(0.0, 0.0)
332    return Vec2(dx / length, dz / length)
333
334
335def limit(vector: Vec2, maximum: float) -> Vec2:
336    """Clamp a plane vector's magnitude to *maximum*."""
337    length = math.hypot(float(vector.x), float(vector.y))
338    if length <= maximum or length < 1e-9:
339        return Vec2(float(vector.x), float(vector.y))
340    scale = maximum / length
341    return Vec2(float(vector.x) * scale, float(vector.y) * scale)
342
343
344def separation(positions: np.ndarray, radius: float) -> np.ndarray:
345    """Per-body push-apart vectors for a swarm of ``(N, 2)`` plane positions.
346
347    Each body is pushed away from every neighbour inside *radius*, weighted by
348    how deeply the pair overlaps. The result is unnormalised so a body with one
349    distant neighbour barely deviates while one buried in the shoal squirts
350    out, which is what makes a shoal read as liquid rather than as a grid.
351    """
352    points = np.asarray(positions, dtype=np.float32).reshape(-1, 2)
353    count = points.shape[0]
354    if count < 2 or radius <= 0.0:
355        return np.zeros_like(points)
356    delta = points[:, None, :] - points[None, :, :]
357    distance = np.hypot(delta[:, :, 0], delta[:, :, 1])
358    np.fill_diagonal(distance, np.float32(np.inf))
359    weight = np.clip(1.0 - distance / radius, 0.0, 1.0)
360    safe = np.where(distance > 1e-6, distance, np.float32(1.0))
361    push = delta / safe[:, :, None] * weight[:, :, None]
362    return push.sum(axis=1).astype(np.float32)
363
364
365# ============================================================================
366# The shared enemy base
367# ============================================================================
368
369
370class Enemy(Node3D):
371    """Base for every hostile: hull points, the elite tag, and the telegraph.
372
373    Subclasses set :attr:`ARCHETYPE` to a key of ``balance.ENEMIES``, build
374    their body in :meth:`build_body`, drive movement from
375    ``on_fixed_update`` and implement :meth:`perform_attack`. They never call
376    ``perform_attack`` directly: :meth:`begin_attack` runs the white telegraph
377    flash first and fires the attack when it ends.
378    """
379
380    #: Key into ``balance.ENEMIES``; subclasses must set it.
381    ARCHETYPE: str = ""
382    #: Radius of the sensor hitbox other systems collide against. None builds
383    #: no hitbox, for an archetype whose hulls are numpy rows rather than nodes.
384    HITBOX_RADIUS: float | None = 1.5
385    #: Body colour before any telegraph flash.
386    BODY_COLOUR: tuple[float, float, float, float] = (0.55, 0.20, 0.42, 1.0)
387    #: Length of this archetype's telegraph flash, seconds.
388    telegraph_duration: float = balance.ENEMY_FIRE_TELEGRAPH_S
389
390    #: Fires when the white flash starts, with this enemy's archetype id.
391    telegraph_started = Signal(str)
392    #: Fires once when hull points run out, before the node leaves the tree.
393    died = Signal()
394
395    def __init__(self, *, elite: str | None = None, seed: int | None = None, **kwargs):
396        super().__init__(**kwargs)
397        if elite is not None and elite not in balance.ELITE_MODIFIERS:
398            raise ValueError(f"unknown elite modifier {elite!r}; expected one of {balance.ELITE_MODIFIERS}")
399        self.spec: balance.EnemySpec = balance.ENEMIES[self.ARCHETYPE]
400        self.hp: float = float(self.spec.hp)
401        self.elite: str | None = elite
402        self.velocity = Vec2(0.0, 0.0)
403        self.flow_field: FlowField | None = None
404        self.telegraph_remaining: float = 0.0
405        self.stall_remaining: float = 0.0
406        self.rng = random.Random(seed)
407        self._phase_rng = random.Random(None if seed is None else f"{seed}:cadence")
408        self._recoil = Vec2(0.0, 0.0)
409        self._attack_pending = False
410        self._materials: list[Material] = []
411        self._base_colours: list[tuple[float, ...]] = []
412        self._base_emissive: list[tuple[float, ...] | None] = []
413        self.hit_flash_remaining: float = 0.0
414        self.spark_cooldown: float = 0.0
415        self.pip: HealthPip | None = None
416
417    def set_seed(self, seed: int) -> None:
418        """Reseed this enemy's randomness so a spawn is reproducible per node.
419
420        Re-rolls the attack phase as well: the wave composer seeds a node after
421        it has been added, and a stagger drawn before that would make the fight
422        depend on construction order rather than on the seed.
423        """
424        self.rng = random.Random(seed)
425        self._phase_rng = random.Random(f"{seed}:cadence")
426        self.stagger_cadence()
427
428    def stagger_cadence(self) -> None:
429        """Offset this archetype's attack timer by a random slice of its cadence.
430
431        Overridden by every archetype that attacks on a timer, and called both
432        at construction and on :meth:`set_seed`. Without it a wave that spawns
433        three Lancers together gets one Lancer three times over: the same
434        wind-up, the same dash and the same recovery frame, which reads as a
435        scripted volley rather than as three ships.
436        """
437
438    def cadence_offset(self, interval: float) -> float:
439        """A random point inside *interval*, for seeding one cadence timer."""
440        return float(interval) * self._phase_rng.random()
441
442    # -- Lifecycle ----------------------------------------------------------
443
444    def on_enter_tree(self):
445        super().on_enter_tree()
446        self.add_to_group(Groups.ENEMIES)
447
448    def on_ready(self):
449        self.position = Vec3(float(self.position.x), PLANE_Y, float(self.position.z))
450        self.build_body()
451        self._collect_materials()
452        if self.HITBOX_RADIUS is not None:
453            hitbox = self.add_child(Area3D(name="Hitbox", shape=SphereShape3D(radius=self.HITBOX_RADIUS)))
454            hitbox.collision_layer = Layers.ENEMY
455            hitbox.collision_mask = Layers.MASK_ENEMY
456        self.flow_field = shared_flow_field(self)
457
458    def build_body(self):
459        """Add the archetype's visible geometry. Override in subclasses."""
460
461    def _collect_materials(self) -> None:
462        """Cache every body material and the look the telegraph and the flash restore."""
463        self._materials = []
464        self._base_colours = []
465        self._base_emissive = []
466        for node in self.walk():
467            material = getattr(node, "material", None)
468            if isinstance(material, Material):
469                self._materials.append(material)
470                self._base_colours.append(tuple(float(c) for c in material.colour))
471                emissive = material.emissive_colour
472                self._base_emissive.append(None if emissive is None else tuple(float(c) for c in emissive))
473
474    # -- Movement -----------------------------------------------------------
475
476    @property
477    def plane_position(self) -> Vec2:
478        """This enemy's position on the flight plane."""
479        return to_plane(self.position)
480
481    def steer_toward(self, target: Vec2) -> Vec2:
482        """Unit steering direction toward *target*.
483
484        Uses the shared flow field when it is integrated toward the same place,
485        so a whole wave bends around the same wreck; otherwise steers directly,
486        which is what a Skimmer chasing salvage needs.
487        """
488        field = self.flow_field
489        position = self.plane_position
490        if field is not None and field.targets(target):
491            direction = field.sample(position)
492            if float(direction.x) or float(direction.y):
493                return direction
494        return seek(position, target)
495
496    def move(self, dt: float, direction: Vec2, speed: float) -> None:
497        """Integrate one step along *direction*, staying exactly on the plane.
498
499        The drive is whatever the archetype asked for, unless a contact hit has
500        stalled it, in which case there is no drive at all for the rest of
501        ``balance.CONTACT_STALL_S``. On top of that sits the separation recoil
502        from the last collision, bleeding off at
503        ``balance.CONTACT_RECOIL_DAMPING``: that is what carries a hostile back
504        off the hull it just hit rather than through it.
505        """
506        drive_x = 0.0 if self.stalled else float(direction.x) * speed
507        drive_z = 0.0 if self.stalled else float(direction.y) * speed
508        vx = drive_x + float(self._recoil.x)
509        vz = drive_z + float(self._recoil.y)
510        self.velocity = Vec2(vx, vz)
511        self.position = Vec3(
512            float(self.position.x) + vx * dt,
513            PLANE_Y,
514            float(self.position.z) + vz * dt,
515        )
516        self.decay_recoil(dt)
517
518    # -- Contact ------------------------------------------------------------
519
520    @property
521    def stalled(self) -> bool:
522        """Whether a contact hit has this enemy's drive shut down."""
523        return self.stall_remaining > 0.0
524
525    def stall(self, seconds: float) -> None:
526        """Kill this enemy's drive for *seconds*. Never shortens a live stall."""
527        self.stall_remaining = max(self.stall_remaining, float(seconds))
528
529    def apply_contact_impulse(self, direction: Vec3, speed: float) -> None:
530        """Add a separation velocity along a world *direction* at *speed*.
531
532        Kept apart from :attr:`velocity` because an archetype rewrites its
533        velocity from scratch every step; the recoil has to survive that pass or
534        the collision would last exactly one frame.
535        """
536        self._recoil = Vec2(
537            float(self._recoil.x) + float(direction.x) * float(speed),
538            float(self._recoil.y) + float(direction.z) * float(speed),
539        )
540
541    def decay_recoil(self, dt: float) -> None:
542        """Bleed the separation recoil off exponentially."""
543        if not float(self._recoil.x) and not float(self._recoil.y):
544            return
545        keep = math.exp(-balance.CONTACT_RECOIL_DAMPING * dt)
546        self._recoil = Vec2(float(self._recoil.x) * keep, float(self._recoil.y) * keep)
547
548    def contact_damage(self, target, amount: float, kind: str = "impact") -> None:
549        """Hurt *target* by touching it, and take the collision on the chin.
550
551        The router owns contact when there is one, so the shield arc, the
552        matrix and the run's damage ledger all still apply. Without a router the
553        damage falls back to the target's own entry point and the separation is
554        applied here, so an enemy in a bare scene is still a body rather than a
555        ghost.
556        """
557        router = self.damage_router()
558        resolve = getattr(router, "resolve_contact", None)
559        if callable(resolve):
560            resolve(self, target, amount, kind=kind)
561            return
562        self.deal_damage_to(target, amount, kind)
563        separate_on_contact(self, target)
564
565    def face(self, direction: Vec2) -> None:
566        """Point the hull's nose along a plane direction."""
567        if abs(float(direction.x)) < 1e-6 and abs(float(direction.y)) < 1e-6:
568            return
569        self.rotation = Quat.from_euler(0.0, math.atan2(-float(direction.y), float(direction.x)), 0.0)
570
571    def distance_to(self, node) -> float:
572        """Plane distance from this enemy to another node."""
573        return math.hypot(
574            float(node.position.x) - float(self.position.x),
575            float(node.position.z) - float(self.position.z),
576        )
577
578    # -- The telegraph ------------------------------------------------------
579
580    @property
581    def telegraphing(self) -> bool:
582        """Whether the white pre-attack flash is running."""
583        return self.telegraph_remaining > 0.0
584
585    def begin_attack(self) -> bool:
586        """Telegraph now and attack when the flash ends.
587
588        Returns False when a telegraph or attack is already in flight, so an
589        archetype can call this every frame while a condition holds.
590        """
591        if self.telegraphing or self._attack_pending:
592            return False
593        self._attack_pending = True
594        self.on_telegraph()
595        return True
596
597    def on_telegraph(self) -> None:
598        """Flash the body white for :attr:`telegraph_duration` seconds."""
599        self.telegraph_remaining = float(self.telegraph_duration)
600        self.apply_telegraph_colour(True)
601        self.telegraph_started(self.ARCHETYPE)
602
603    def apply_telegraph_colour(self, active: bool) -> None:
604        """Switch the body between its own colour and the telegraph white."""
605        for material, base in zip(self._materials, self._base_colours, strict=True):
606            material.colour = TELEGRAPH_COLOUR if active else base
607
608    def perform_attack(self) -> None:
609        """Land the attack the telegraph announced. Override in subclasses."""
610
611    def on_update(self, dt: float):
612        if self.stall_remaining > 0.0:
613            self.stall_remaining = max(0.0, self.stall_remaining - dt)
614        self._tick_hit_flash(dt)
615        if self.telegraph_remaining <= 0.0:
616            return
617        self.telegraph_remaining -= dt
618        if self.telegraph_remaining > 0.0:
619            return
620        self.telegraph_remaining = 0.0
621        self.apply_telegraph_colour(False)
622        if self._attack_pending:
623            self._attack_pending = False
624            self.perform_attack()
625
626    # -- Damage -------------------------------------------------------------
627
628    def take_damage(self, amount: float, kind: str) -> None:
629        """Subtract hull points, show the hit, and die at zero."""
630        if self.destroying or amount <= 0.0:
631            return
632        self.hp -= float(amount)
633        self.show_hit()
634        if self.hp <= 0.0:
635            self.hp = 0.0
636            self.kill()
637            return
638        self.refresh_pip()
639
640    # -- Damage feedback ----------------------------------------------------
641
642    @property
643    def flashing_hit(self) -> bool:
644        """Whether the white hit pulse is currently on the hull."""
645        return self.hit_flash_remaining > 0.0
646
647    def hull_ceiling(self) -> float:
648        """This hull's full pool, honouring an elite that inflated it."""
649        return max(float(getattr(self, "max_hp", self.spec.hp)), float(self.hp), 1e-6)
650
651    def carries_pip(self) -> bool:
652        """Whether this hull is big enough to be worth a health read-out."""
653        return self.hull_ceiling() >= HP_PIP_MIN_HP
654
655    def show_hit(self, at: Vec3 | None = None) -> None:
656        """React to a hit that landed: pulse white and throw a spark.
657
658        *at* is where the round came apart; without one the hull's own position
659        stands in, which is accurate to within its own radius and is what a
660        caller that only knows "this node was hit" can honestly supply.
661        """
662        self.flash_hit()
663        self.spark_at(Vec3(self.position) if at is None else at)
664
665    def flash_hit(self) -> None:
666        """Drive the body's emissive to white for :data:`HIT_FLASH_S`."""
667        self.hit_flash_remaining = HIT_FLASH_S
668        self.apply_hit_flash(True)
669
670    def apply_hit_flash(self, active: bool) -> None:
671        """Switch the body's emissive between the pulse and its own value."""
672        for material, base in zip(self._materials, self._base_emissive, strict=True):
673            if active:
674                material.emissive_colour = (*HIT_FLASH_EMISSIVE, HIT_FLASH_STRENGTH)
675            else:
676                material.emissive_colour = base
677
678    def spark_at(self, at: Vec3) -> None:
679        """Throw a one-shot impact spark at a world point.
680
681        Rate-limited to :data:`HIT_SPARK_INTERVAL_S`, because a beam lands a hit
682        every frame and an unthrottled spark would both drain the effect pool
683        and turn a stream of impacts into one solid patch of light.
684        """
685        tree = self.tree
686        if tree is None or tree.root is None or self.spark_cooldown > 0.0:
687            return
688        self.spark_cooldown = HIT_SPARK_INTERVAL_S
689        Vfx.spawn(tree, "enemy_hit", Vec3(float(at.x), PLANE_Y, float(at.z)))
690
691    def refresh_pip(self) -> None:
692        """Show, update or retire this hull's health pip.
693
694        Mounted on the first hit rather than at spawn: an untouched field
695        carries no read-outs at all, which is what keeps the pips meaningful
696        when a fight does start.
697        """
698        if not self.carries_pip() or self.destroying:
699            return
700        if self.pip is None:
701            self.pip = self.add_child(HealthPip(position=Vec3(0.0, HP_PIP_HEIGHT, HP_PIP_OFFSET_Z)))
702        self.pip.set_fraction(float(self.hp) / self.hull_ceiling())
703
704    def _tick_hit_flash(self, dt: float) -> None:
705        if self.spark_cooldown > 0.0:
706            self.spark_cooldown = max(0.0, self.spark_cooldown - dt)
707        if self.hit_flash_remaining <= 0.0:
708            return
709        self.hit_flash_remaining -= dt
710        if self.hit_flash_remaining <= 0.0:
711            self.hit_flash_remaining = 0.0
712            self.apply_hit_flash(False)
713
714    def kill(self) -> None:
715        """Report the kill and leave the tree.
716
717        The damage router owns the consequences (scrap drop, signature, the
718        ``ENEMY_KILLED`` signal); this only reports and stands down.
719        """
720        if self.destroying:
721            return
722        if self.pip is not None:
723            self.pip.dismiss()
724        self.died()
725        router = self.damage_router()
726        if router is not None:
727            router.report_kill(self)
728        self.destroy()
729
730    def damage_router(self):
731        """The run's damage router, or None before one is registered."""
732        tree = self.tree
733        return None if tree is None else tree.singletons.get(Services.DAMAGE)
734
735    def deal_damage_to(self, target, amount: float, kind: str = "impact") -> None:
736        """Hurt *target* through the damage router, or directly as a fallback.
737
738        The router is the only code allowed to apply damage numbers once it
739        exists; the direct call keeps an enemy dangerous in a scene assembled
740        without one.
741        """
742        direction = from_plane(seek(self.plane_position, to_plane(target.position)))
743        router = self.damage_router()
744        if router is not None:
745            router.deal(target, amount, kind=kind, direction=direction, source=self)
746        elif hasattr(target, "apply_damage"):
747            target.apply_damage(amount, direction, kind)
748
749    # -- Lookups ------------------------------------------------------------
750
751    def player_ship(self):
752        """The player's ship node, or None when it is gone."""
753        tree = self.tree
754        if tree is None:
755            return None
756        for ship in tree.group(Groups.SHIP):
757            if not ship.destroying:
758                return ship
759        return None
760
761
762class FlowFieldDirector(Node3D):
763    """Owns the scene-wide flow field and re-integrates it toward the ship.
764
765    Add one to the run scene and every enemy picks it up automatically, so a
766    wave of twenty bodies costs one integration per
767    :data:`FLOW_REBUILD_INTERVAL_S` rather than twenty path queries per frame.
768    Wrecks, deposits and hazards are charged as obstacles, which is what bends
769    a swarm around the terrain instead of through it.
770    """
771
772    OBSTACLE_GROUPS = (Groups.WRECKS, Groups.DEPOSITS, Groups.HAZARDS)
773
774    def __init__(self, bounds: tuple[float, float, float, float] = DEFAULT_FIELD_BOUNDS, **kwargs):
775        super().__init__(**kwargs)
776        self.field = FlowField(bounds)
777        self._until_rebuild = 0.0
778
779    def on_enter_tree(self):
780        super().on_enter_tree()
781        self.add_to_group(FLOW_FIELD_GROUP)
782
783    def on_update(self, dt: float):
784        self._until_rebuild -= dt
785        if self._until_rebuild > 0.0:
786            return
787        self._until_rebuild = FLOW_REBUILD_INTERVAL_S
788        self.rebuild_now()
789
790    def rebuild_now(self) -> None:
791        """Re-integrate the field toward the ship's current position."""
792        tree = self.tree
793        if tree is None:
794            return
795        ships = [ship for ship in tree.group(Groups.SHIP) if not ship.destroying]
796        if not ships:
797            return
798        obstacles = [
799            to_plane(node.position)
800            for group in self.OBSTACLE_GROUPS
801            for node in tree.group(group)
802            if not node.destroying
803        ]
804        self.field.rebuild(to_plane(ships[0].position), obstacles)
805
806
807def shared_flow_field(node) -> FlowField | None:
808    """The scene's shared field, or None when no director is present."""
809    tree = node.tree
810    if tree is None:
811        return None
812    for director in tree.group(FLOW_FIELD_GROUP):
813        if not director.destroying:
814            return director.field
815    return None