shrike/enemies/basic.py¶

Part of SHRIKE.

  1"""The four common hostiles: Mites, Skimmers, Lancers and Mag-mines.
  2
  3These are the archetypes a player meets in the first sector, and between them
  4they teach the whole enemy grammar: a swarm that punishes single-target
  5weapons, a thief that attacks your greed instead of your hull, a dash attacker
  6that trades a readable wind-up for a heavy hit, and a mine that makes an
  7afterburner corridor a decision.
  8
  9Every one of them announces its attack the same way. :class:`~.steering.Enemy`
 10flashes the hull white for ``balance.ENEMY_FIRE_TELEGRAPH_S`` (the Lancer for
 11the longer ``balance.LANCER_TELEGRAPH_S``, and it draws its dash line as well)
 12and only then calls ``perform_attack``. Nothing here schedules an attack any
 13other way.
 14
 15Mites are data-oriented on purpose: one :class:`MiteShoal` node holds every
 16mite's position and velocity in numpy arrays and renders the shoal through a
 17single :class:`~simvx.core.MultiMeshInstance3D`, so twenty mites cost one draw
 18and one flow-field sample rather than twenty of each. The consequence is that a
 19mite is a row, not a node, so weapons reach the shoal through
 20:meth:`MiteShoal.damage_at` rather than through a per-mite collision body.
 21"""
 22
 23from __future__ import annotations
 24
 25import math
 26
 27import numpy as np
 28
 29from simvx.core import Material, Mesh, MeshInstance3D, MultiMesh, MultiMeshInstance3D, Node3D, Signal, Vec2, Vec3
 30
 31from .. import artkit, balance
 32from ..runtime import PLANE_Y, Groups, to_plane
 33from .steering import (
 34    DEFAULT_FIELD_BOUNDS,
 35    FLOW_REBUILD_INTERVAL_S,
 36    Enemy,
 37    FlowField,
 38    limit,
 39    seek,
 40    separation,
 41)
 42
 43# ============================================================================
 44# Movement and presentation constants
 45#
 46# Every speed and every engagement range on this page lives in balance.py,
 47# where speeds are quoted as multiples of the player's cruise speed and ranges
 48# against what the camera actually shows. The names below are local bindings to
 49# those numbers so the behaviour code reads as prose; balance.py stays the only
 50# place either is tuned. What is genuinely local here is presentation and
 51# cadence: body sizes, colours, contact radii, and how often an archetype is
 52# allowed to try again.
 53# ============================================================================
 54
 55# Mites
 56MITE_SPEED = balance.MITE_SPEED
 57MITE_SURGE_SPEED = balance.MITE_SURGE_SPEED
 58MITE_SURGE_RANGE = balance.MITE_SURGE_RANGE
 59MITE_SURGE_S = 1.2
 60MITE_SURGE_COOLDOWN_S = 3.0
 61MITE_TURN_RATE = 7.0
 62MITE_SEPARATION_RADIUS = 2.5
 63MITE_SEPARATION_WEIGHT = 18.0
 64MITE_TOUCH_RADIUS = 2.4
 65MITE_TOUCH_COOLDOWN_S = 0.8
 66MITE_SPAWN_SCATTER = 6.0
 67#: A mite is a splinter a couple of pixels wide, so the hull alone would be a
 68#: dark speck on a dark screen. The shoal is lit instead: enough emissive for
 69#: the swarm to read as a moving cloud, well short of the accent strength that
 70#: would turn twenty of them into one white blob under bloom.
 71MITE_EMISSIVE_STRENGTH = 2.0
 72
 73# Skimmers
 74SKIMMER_SPEED = balance.SKIMMER_SPEED
 75SKIMMER_FLEE_SPEED = balance.SKIMMER_FLEE_SPEED
 76SKIMMER_IDLE_SPEED = balance.SKIMMER_IDLE_SPEED
 77SKIMMER_GRAB_RADIUS = balance.SKIMMER_GRAB_RADIUS
 78SKIMMER_ESCAPE_DISTANCE = balance.SKIMMER_ESCAPE_DISTANCE
 79
 80# Lancers
 81LANCER_APPROACH_SPEED = balance.LANCER_APPROACH_SPEED
 82LANCER_STANDOFF = balance.LANCER_STANDOFF
 83LANCER_DASH_SPEED = balance.LANCER_DASH_SPEED
 84LANCER_DASH_DISTANCE = balance.LANCER_DASH_DISTANCE
 85LANCER_DASH_RANGE = balance.LANCER_DASH_RANGE
 86LANCER_DASH_COOLDOWN_S = 2.6
 87LANCER_HIT_RADIUS = 2.6
 88LANCER_LINE_WIDTH = 0.35
 89
 90# Mag-mines
 91MAG_MINE_DRIFT_SPEED = balance.MAG_MINE_DRIFT_SPEED
 92MAG_MINE_LUNGE_SPEED = balance.MAG_MINE_LUNGE_SPEED
 93#: Long enough to cross the trigger range and bury itself in the hull, no longer.
 94MAG_MINE_LUNGE_S = 0.45
 95MAG_MINE_REARM_S = 2.0
 96MAG_MINE_HIT_RADIUS = 2.8
 97
 98
 99# ============================================================================
100# Mites
101# ============================================================================
102
103
104class MiteShoal(Enemy):
105    """A shoal of 8 to 20 mites steered as one body of numpy arrays.
106
107    The shoal is a single node. :attr:`positions` is an ``(capacity, 3)``
108    world-space array whose first :attr:`alive_count` rows are live mites, and
109    the same rows drive a :class:`~simvx.core.MultiMesh`, so vfx and the HUD can
110    read the swarm without a node per mite. Killing a mite swap-removes its row,
111    which keeps the live rows contiguous for the vectorised steering pass.
112
113    Steering is one flow-field sample for the whole shoal plus a separation
114    push, which is what makes it wrap around obstacles and around fire rather
115    than queue up behind it. Inside :data:`MITE_SURGE_RANGE` the shoal
116    telegraphs and then surges: the white flash is the only warning, and it is
117    the same flash every other enemy in the game uses.
118    """
119
120    ARCHETYPE = "mite"
121    HITBOX_RADIUS = None
122    BODY_COLOUR = artkit.palette("enemy").hull_colour
123
124    #: Fires with the world position of each mite as it dies, for vfx.
125    mite_killed = Signal(Vec3)
126
127    def __init__(
128        self,
129        *,
130        count: int | None = None,
131        seed: int = 0,
132        bounds: tuple[float, float, float, float] = DEFAULT_FIELD_BOUNDS,
133        scatter: float = MITE_SPAWN_SCATTER,
134        **kwargs,
135    ):
136        super().__init__(seed=seed, **kwargs)
137        self._rng = np.random.default_rng(seed)
138        if count is None:
139            count = int(self._rng.integers(balance.MITE_SHOAL_MIN, balance.MITE_SHOAL_MAX + 1))
140        self.capacity = int(min(max(count, balance.MITE_SHOAL_MIN), balance.MITE_SHOAL_MAX))
141        self.alive_count = self.capacity
142        self.scatter = float(scatter)
143        self.positions = np.zeros((self.capacity, 3), dtype=np.float32)
144        self.velocities = np.zeros((self.capacity, 2), dtype=np.float32)
145        self.mite_hp = np.full(self.capacity, float(self.spec.hp), dtype=np.float32)
146        self._touch_cooldown = np.zeros(self.capacity, dtype=np.float32)
147        self._mite_stall = np.zeros(self.capacity, dtype=np.float32)
148        self._mite_recoil = np.zeros((self.capacity, 2), dtype=np.float32)
149        self._contact_rows = np.zeros(0, dtype=np.int64)
150        self._contact_bearings = np.zeros((0, 2), dtype=np.float32)
151        self._own_field = FlowField(bounds)
152        self._until_rebuild = 0.0
153        self._surge_remaining = 0.0
154        self._surge_cooldown = 0.0
155        self._multi_mesh: MultiMesh | None = None
156        self._mesh_node: MultiMeshInstance3D | None = None
157        self.hp = float(self.spec.hp) * self.capacity
158        self.stagger_cadence()
159
160    def stagger_cadence(self) -> None:
161        """Offset the first surge, so two shoals never break at the same instant."""
162        self._surge_cooldown = self.cadence_offset(MITE_SURGE_S + MITE_SURGE_COOLDOWN_S)
163
164    # -- Setup --------------------------------------------------------------
165
166    def build_body(self):
167        self._multi_mesh = MultiMesh(mesh=artkit.enemy_mesh("mite"), instance_count=self.capacity)
168        self._mesh_node = self.add_child(
169            MultiMeshInstance3D(
170                name="Shoal",
171                multi_mesh=self._multi_mesh,
172                material=self._shoal_material(),
173            )
174        )
175
176    @staticmethod
177    def _shoal_material() -> Material:
178        """The whole shoal's one material: an enemy hull with its own glow."""
179        palette = artkit.palette("enemy")
180        return Material(
181            colour=palette.hull_colour,
182            metallic=palette.hull_metallic,
183            roughness=palette.hull_roughness,
184            emissive_colour=palette.accent,
185            emissive_strength=MITE_EMISSIVE_STRENGTH,
186        )
187
188    def on_ready(self):
189        super().on_ready()
190        centre = np.asarray([float(self.position.x), PLANE_Y, float(self.position.z)], dtype=np.float32)
191        offsets = self._rng.normal(0.0, self.scatter, size=(self.capacity, 2)).astype(np.float32)
192        self.positions[:] = centre
193        self.positions[:, 0] += offsets[:, 0]
194        self.positions[:, 2] += offsets[:, 1]
195        self._sync_multimesh()
196
197    # -- Queries ------------------------------------------------------------
198
199    @property
200    def live_positions(self) -> np.ndarray:
201        """A view of the live rows of :attr:`positions`, ``(alive_count, 3)``."""
202        return self.positions[: self.alive_count]
203
204    @property
205    def surging(self) -> bool:
206        """Whether the telegraphed attack run is in progress."""
207        return self._surge_remaining > 0.0
208
209    def centroid(self) -> Vec3:
210        """The live shoal's centre of mass, or the node position when empty."""
211        if self.alive_count == 0:
212            return Vec3(self.position)
213        mean = self.live_positions.mean(axis=0)
214        return Vec3(float(mean[0]), PLANE_Y, float(mean[2]))
215
216    # -- Damage -------------------------------------------------------------
217
218    def damage_at(self, centre: Vec3, radius: float, amount: float, kind: str = "impact") -> int:
219        """Hurt every mite within *radius* of a world point; returns kills.
220
221        This is how weapons reach a shoal: a mite has no collision body of its
222        own, so a projectile or a flak burst resolves against the swarm here.
223        A wide burst clearing eight rows at once is exactly the moment the
224        archetype exists for.
225        """
226        if self.alive_count == 0 or amount <= 0.0:
227            return 0
228        live = self.live_positions
229        dx = live[:, 0] - float(centre.x)
230        dz = live[:, 2] - float(centre.z)
231        hit = np.flatnonzero(dx * dx + dz * dz <= float(radius) * float(radius))
232        if hit.size == 0:
233            return 0
234        self.mite_hp[hit] -= float(amount)
235        # The whole shoal shares one material, so the pulse is the shoal's; the
236        # spark is not, and lands exactly where the burst went off.
237        self.show_hit(centre)
238        return self._retire_dead()
239
240    def take_damage(self, amount: float, kind: str) -> None:
241        """Hurt the single mite nearest the shoal's centre.
242
243        The whole-node fallback, for damage that arrives without a hit point
244        (a scripted effect, a debug command). Anything that knows where it hit
245        should call :meth:`damage_at` instead.
246        """
247        if self.destroying or self.alive_count == 0 or amount <= 0.0:
248            return
249        live = self.live_positions
250        dx = live[:, 0] - float(self.position.x)
251        dz = live[:, 2] - float(self.position.z)
252        nearest = int(np.argmin(dx * dx + dz * dz))
253        self.mite_hp[nearest] -= float(amount)
254        self.show_hit(Vec3(*(float(v) for v in live[nearest])))
255        self._retire_dead()
256
257    def carries_pip(self) -> bool:
258        """Never. A shoal's pool is twenty splinters, not one hull worth reading."""
259        return False
260
261    def _retire_dead(self) -> int:
262        """Swap-remove every mite at or below zero hull; returns how many."""
263        killed = 0
264        index = 0
265        while index < self.alive_count:
266            if self.mite_hp[index] > 0.0:
267                index += 1
268                continue
269            self._retire(index)
270            killed += 1
271        self.hp = float(self.mite_hp[: self.alive_count].sum())
272        if killed and self.alive_count == 0:
273            self.died()
274            self.destroy()
275        return killed
276
277    def _retire(self, index: int) -> None:
278        position = Vec3(*(float(v) for v in self.positions[index]))
279        last = self.alive_count - 1
280        if index != last:
281            self.positions[index] = self.positions[last]
282            self.velocities[index] = self.velocities[last]
283            self.mite_hp[index] = self.mite_hp[last]
284            self._touch_cooldown[index] = self._touch_cooldown[last]
285            self._mite_stall[index] = self._mite_stall[last]
286            self._mite_recoil[index] = self._mite_recoil[last]
287        self.alive_count = last
288        self.mite_killed(position)
289        router = self.damage_router()
290        if router is not None:
291            router.report_kill(self)
292
293    # -- Behaviour ----------------------------------------------------------
294
295    def on_update(self, dt: float):
296        super().on_update(dt)
297        if self._surge_cooldown > 0.0:
298            self._surge_cooldown = max(0.0, self._surge_cooldown - dt)
299        if self._surge_remaining > 0.0:
300            self._surge_remaining = max(0.0, self._surge_remaining - dt)
301        if self.surging or self.telegraphing or self._surge_cooldown > 0.0 or self.alive_count == 0:
302            return
303        ship = self.player_ship()
304        if ship is not None and self.distance_to(ship) <= MITE_SURGE_RANGE:
305            self.begin_attack()
306
307    def perform_attack(self) -> None:
308        self._surge_remaining = MITE_SURGE_S
309        self._surge_cooldown = MITE_SURGE_S + MITE_SURGE_COOLDOWN_S
310
311    def on_fixed_update(self, dt: float):
312        if self.alive_count == 0 or self.destroying:
313            return
314        ship = self.player_ship()
315        target = to_plane(ship.position) if ship is not None else to_plane(self.centroid())
316        field = self.flow_field
317        if field is None or not field.targets(target):
318            field = self._own_field
319            self._until_rebuild -= dt
320            if self._until_rebuild <= 0.0 or not field.targets(target):
321                self._until_rebuild = FLOW_REBUILD_INTERVAL_S
322                field.rebuild(target, self._obstacles())
323        self._steer(dt, field)
324        self._touch_ship(dt, ship)
325        self.position = self.centroid()
326        self._sync_multimesh()
327
328    def _obstacles(self) -> list[Vec2]:
329        tree = self.tree
330        if tree is None:
331            return []
332        return [
333            to_plane(node.position)
334            for group in (Groups.WRECKS, Groups.DEPOSITS, Groups.HAZARDS)
335            for node in tree.group(group)
336            if not node.destroying
337        ]
338
339    def _steer(self, dt: float, field: FlowField) -> None:
340        live = self.alive_count
341        plane = self.positions[:live][:, [0, 2]]
342        desired = field.sample_many(plane) * (MITE_SURGE_SPEED if self.surging else MITE_SPEED)
343        desired += separation(plane, MITE_SEPARATION_RADIUS) * MITE_SEPARATION_WEIGHT
344        # A mite that just bit has no drive of its own for CONTACT_STALL_S; the
345        # recoil below is the only thing carrying it, which is what peels the
346        # swarm off the hull instead of letting it grind through.
347        desired[self._mite_stall[:live] > 0.0] = 0.0
348        blend = 1.0 - math.exp(-MITE_TURN_RATE * dt)
349        self.velocities[:live] += (desired - self.velocities[:live]) * blend
350        step = self.velocities[:live] + self._mite_recoil[:live]
351        self.positions[:live, 0] += step[:, 0] * dt
352        self.positions[:live, 2] += step[:, 1] * dt
353        self.positions[:live, 1] = PLANE_Y
354        self._mite_recoil[:live] *= math.exp(-balance.CONTACT_RECOIL_DAMPING * dt)
355
356    def _touch_ship(self, dt: float, ship) -> None:
357        live = self.alive_count
358        self._touch_cooldown[:live] = np.maximum(0.0, self._touch_cooldown[:live] - dt)
359        self._mite_stall[:live] = np.maximum(0.0, self._mite_stall[:live] - dt)
360        if ship is None or ship.destroying:
361            return
362        dx = self.positions[:live, 0] - float(ship.position.x)
363        dz = self.positions[:live, 2] - float(ship.position.z)
364        touching = (dx * dx + dz * dz <= MITE_TOUCH_RADIUS * MITE_TOUCH_RADIUS) & (self._touch_cooldown[:live] <= 0.0)
365        rows = np.flatnonzero(touching)
366        if rows.size == 0:
367            return
368        self._touch_cooldown[rows] = MITE_TOUCH_COOLDOWN_S
369        self._contact_rows = rows
370        self._contact_bearings = self._bearings_from(dx[rows], dz[rows])
371        self.contact_damage(ship, float(self.spec.damage) * rows.size)
372        self._contact_rows = np.zeros(0, dtype=np.int64)
373
374    @staticmethod
375    def _bearings_from(dx: np.ndarray, dz: np.ndarray) -> np.ndarray:
376        """Unit bearings away from the hull for the rows that touched it."""
377        lengths = np.hypot(dx, dz)
378        safe = np.where(lengths > 1e-6, lengths, np.float32(1.0))
379        bearings = np.stack((dx / safe, dz / safe), axis=1).astype(np.float32)
380        bearings[lengths <= 1e-6] = np.asarray((1.0, 0.0), dtype=np.float32)
381        return bearings
382
383    # -- Contact ------------------------------------------------------------
384    #
385    # A shoal is one node but twenty bodies, so the base class's contact hooks
386    # are redirected onto the handful of rows that actually reached the hull.
387    # Stalling the whole shoal because three mites bit would freeze the other
388    # seventeen mid-approach, and shoving them all along one aggregate bearing
389    # would slide the swarm sideways instead of scattering it off the hull.
390
391    def stall(self, seconds: float) -> None:
392        """Stall only the mites that made contact this step."""
393        if self._contact_rows.size:
394            self._mite_stall[self._contact_rows] = float(seconds)
395
396    def apply_contact_impulse(self, direction: Vec3, speed: float) -> None:
397        """Throw the mites that made contact back along their own bearings.
398
399        *direction* is the shoal-wide axis the router resolved, and is
400        deliberately superseded: each mite bounces off the point it hit.
401        """
402        rows = self._contact_rows
403        if rows.size:
404            self._mite_recoil[rows] = self._contact_bearings * float(speed)
405
406    def _sync_multimesh(self) -> None:
407        if self._multi_mesh is None:
408            return
409        live = self.alive_count
410        transforms = np.zeros((self.capacity, 4, 4), dtype=np.float32)
411        transforms[:, 3, 3] = 1.0
412        if live:
413            velocities = self.velocities[:live]
414            heading = np.arctan2(-velocities[:, 1], velocities[:, 0])
415            cos, sin = np.cos(heading), np.sin(heading)
416            transforms[:live, 0, 0] = cos
417            transforms[:live, 0, 2] = sin
418            transforms[:live, 1, 1] = 1.0
419            transforms[:live, 2, 0] = -sin
420            transforms[:live, 2, 2] = cos
421            transforms[:live, 0, 3] = self.positions[:live, 0] - float(self.position.x)
422            transforms[:live, 1, 3] = 0.0
423            transforms[:live, 2, 3] = self.positions[:live, 2] - float(self.position.z)
424        self._multi_mesh.set_all_transforms(transforms)
425        if self._mesh_node is not None:
426            self._mesh_node.visible_instance_count = live
427
428
429# ============================================================================
430# Skimmers
431# ============================================================================
432
433
434class Skimmer(Enemy):
435    """A fast wedge that ignores the player and steals floating salvage.
436
437    It never shoots and never chases the ship: it runs the nearest salvage
438    down, telegraphs the grab, carries the mote off the field and takes it out
439    of the run. Killing it before it escapes drops the mote where it dies, so
440    the archetype taxes greed rather than hull, and shooting it is a decision
441    about income rather than about survival.
442    """
443
444    ARCHETYPE = "skimmer"
445    HITBOX_RADIUS = 1.4
446    BODY_COLOUR = artkit.palette("enemy").hull_colour
447
448    #: Fires when the Skimmer leaves the field with its loot.
449    escaped = Signal()
450
451    def __init__(self, **kwargs):
452        super().__init__(**kwargs)
453        self.state = "hunting"
454        self.carried: Node3D | None = None
455        self._carried_home: Node3D | None = None
456        self._flee_direction = Vec2(1.0, 0.0)
457        self._flee_origin = Vec2(0.0, 0.0)
458
459    def build_body(self):
460        self.add_child(artkit.build_enemy("skimmer"))
461
462    # -- Behaviour ----------------------------------------------------------
463
464    def on_update(self, dt: float):
465        super().on_update(dt)
466        if self.state != "hunting" or self.telegraphing:
467            return
468        target = self.nearest_salvage()
469        if target is not None and self.distance_to(target) <= SKIMMER_GRAB_RADIUS:
470            self.begin_attack()
471
472    def perform_attack(self) -> None:
473        target = self.nearest_salvage()
474        if target is None or self.distance_to(target) > SKIMMER_GRAB_RADIUS:
475            return
476        self._grab(target)
477
478    def on_fixed_update(self, dt: float):
479        if self.destroying:
480            return
481        if self.state == "fleeing":
482            self.face(self._flee_direction)
483            self.move(dt, self._flee_direction, SKIMMER_FLEE_SPEED)
484            travelled = math.hypot(
485                float(self.position.x) - float(self._flee_origin.x),
486                float(self.position.z) - float(self._flee_origin.y),
487            )
488            if travelled >= SKIMMER_ESCAPE_DISTANCE:
489                self._escape()
490            return
491        if self.telegraphing:
492            self.move(dt, Vec2(0.0, 0.0), 0.0)
493            return
494        target = self.nearest_salvage()
495        if target is None:
496            self.move(dt, seek(self.plane_position, Vec2(0.0, 0.0)), SKIMMER_IDLE_SPEED)
497            return
498        direction = self.steer_toward(to_plane(target.position))
499        self.face(direction)
500        self.move(dt, direction, SKIMMER_SPEED)
501
502    # -- Salvage ------------------------------------------------------------
503
504    def nearest_salvage(self) -> Node3D | None:
505        """The closest uncarried salvage mote, or None when the field is clean."""
506        tree = self.tree
507        if tree is None:
508            return None
509        best: Node3D | None = None
510        best_distance = math.inf
511        for mote in tree.group(Groups.SALVAGE):
512            if mote.destroying:
513                continue
514            distance = self.distance_to(mote)
515            if distance < best_distance:
516                best, best_distance = mote, distance
517        return best
518
519    def _grab(self, mote: Node3D) -> None:
520        self._carried_home = mote.parent
521        world = Vec3(mote.position)
522        if mote.parent is not None:
523            mote.parent.remove_child(mote)
524        self.add_child(mote)
525        # After the reparent, not before: entering a tree re-runs the mote's own
526        # ``on_enter_tree``, which is where a salvage node joins the group.
527        mote.remove_from_group(Groups.SALVAGE)
528        mote.position = Vec3(
529            world.x - float(self.position.x),
530            PLANE_Y,
531            world.z - float(self.position.z),
532        )
533        self.carried = mote
534        self.state = "fleeing"
535        self._flee_origin = self.plane_position
536        outward = seek(Vec2(0.0, 0.0), self.plane_position)
537        self._flee_direction = Vec2(1.0, 0.0) if not float(outward.x) and not float(outward.y) else outward
538
539    def drop_carried(self) -> Node3D | None:
540        """Return the stolen mote to the world at this Skimmer's position."""
541        mote = self.carried
542        if mote is None:
543            return None
544        world = Vec3(
545            float(self.position.x) + float(mote.position.x),
546            PLANE_Y,
547            float(self.position.z) + float(mote.position.z),
548        )
549        self.remove_child(mote)
550        home = self._carried_home
551        if home is not None and not home.destroying:
552            home.add_child(mote)
553            mote.position = world
554            mote.add_to_group(Groups.SALVAGE)
555        self.carried = None
556        self._carried_home = None
557        return mote
558
559    def _escape(self) -> None:
560        mote = self.carried
561        if mote is not None:
562            mote.destroy()
563            self.carried = None
564        self.escaped()
565        self.destroy()
566
567    def kill(self) -> None:
568        self.drop_carried()
569        super().kill()
570
571
572# ============================================================================
573# Lancers
574# ============================================================================
575
576
577class Lancer(Enemy):
578    """A dash attacker that draws its attack line half a second early.
579
580    It closes to :data:`LANCER_STANDOFF`, commits to a direction, then holds
581    still while the white flash and a drawn line show exactly where it is about
582    to be for ``balance.LANCER_TELEGRAPH_S``. The line is the contract: it is
583    aimed when the flash starts, so sidestepping during the flash always works
584    and the sidestep-and-punish rhythm is learnable rather than reactive.
585    """
586
587    ARCHETYPE = "lancer"
588    HITBOX_RADIUS = 1.8
589    BODY_COLOUR = artkit.palette("enemy").hull_colour
590    telegraph_duration = balance.LANCER_TELEGRAPH_S
591
592    def __init__(self, **kwargs):
593        super().__init__(**kwargs)
594        self._dash_direction = Vec2(1.0, 0.0)
595        self._dash_remaining = 0.0
596        self._cooldown = 0.0
597        self._hit_this_dash = False
598        self._line: Node3D | None = None
599        self.stagger_cadence()
600
601    def stagger_cadence(self) -> None:
602        """Start somewhere inside the dash cooldown rather than ready to fire.
603
604        This is what stops a pair of Lancers spawned on the same frame from
605        drawing the same line at the same instant for the rest of the fight.
606        """
607        self._cooldown = self.cadence_offset(LANCER_DASH_COOLDOWN_S)
608
609    def build_body(self):
610        self.add_child(artkit.build_enemy("lancer"))
611        self._line = self.add_child(Node3D(name="TelegraphLine"))
612        self._line.add_child(
613            MeshInstance3D(
614                name="Line",
615                mesh=Mesh.cube(size=1.0),
616                material=Material(colour=(1.0, 1.0, 1.0, 1.0), unlit=True),
617                scale=Vec3(LANCER_DASH_DISTANCE, 0.05, LANCER_LINE_WIDTH),
618                position=Vec3(LANCER_DASH_DISTANCE * 0.5, 0.0, 0.0),
619            )
620        )
621        self._line.visible = False
622
623    # -- Queries ------------------------------------------------------------
624
625    @property
626    def dashing(self) -> bool:
627        """Whether the dash itself is in progress."""
628        return self._dash_remaining > 0.0
629
630    def telegraph_line(self) -> tuple[Vec3, Vec3] | None:
631        """The drawn dash line as ``(start, end)``, or None when not telegraphing."""
632        if not self.telegraphing:
633            return None
634        start = Vec3(self.position)
635        end = Vec3(
636            float(start.x) + float(self._dash_direction.x) * LANCER_DASH_DISTANCE,
637            PLANE_Y,
638            float(start.z) + float(self._dash_direction.y) * LANCER_DASH_DISTANCE,
639        )
640        return start, end
641
642    # -- Behaviour ----------------------------------------------------------
643
644    def on_telegraph(self) -> None:
645        ship = self.player_ship()
646        if ship is not None:
647            self._dash_direction = seek(self.plane_position, to_plane(ship.position))
648            self.face(self._dash_direction)
649        super().on_telegraph()
650
651    def apply_telegraph_colour(self, active: bool) -> None:
652        super().apply_telegraph_colour(active)
653        if self._line is not None:
654            self._line.visible = active
655
656    def perform_attack(self) -> None:
657        self._dash_remaining = LANCER_DASH_DISTANCE / LANCER_DASH_SPEED
658        self._hit_this_dash = False
659        self._cooldown = LANCER_DASH_COOLDOWN_S
660
661    def on_update(self, dt: float):
662        super().on_update(dt)
663        if self._cooldown > 0.0:
664            self._cooldown = max(0.0, self._cooldown - dt)
665        if self.dashing or self.telegraphing or self._cooldown > 0.0:
666            return
667        ship = self.player_ship()
668        if ship is not None and self.distance_to(ship) <= LANCER_DASH_RANGE:
669            self.begin_attack()
670
671    def on_fixed_update(self, dt: float):
672        if self.destroying:
673            return
674        if self.dashing:
675            self._dash_remaining = max(0.0, self._dash_remaining - dt)
676            self.move(dt, self._dash_direction, LANCER_DASH_SPEED)
677            self._strike()
678            return
679        if self.telegraphing:
680            self.move(dt, Vec2(0.0, 0.0), 0.0)
681            return
682        ship = self.player_ship()
683        if ship is None:
684            return
685        self.face(seek(self.plane_position, to_plane(ship.position)))
686        if self.distance_to(ship) <= LANCER_STANDOFF:
687            self.move(dt, Vec2(0.0, 0.0), 0.0)
688            return
689        self.move(dt, self.steer_toward(to_plane(ship.position)), LANCER_APPROACH_SPEED)
690
691    def _strike(self) -> None:
692        if self._hit_this_dash:
693            return
694        ship = self.player_ship()
695        if ship is None or self.distance_to(ship) > LANCER_HIT_RADIUS + self.HITBOX_RADIUS:
696            return
697        self._hit_this_dash = True
698        self.contact_damage(ship, float(self.spec.damage))
699
700
701# ============================================================================
702# Mag-mines
703# ============================================================================
704
705
706class MagMine(Enemy):
707    """A drifting sphere that lunges once the ship is inside its trigger range.
708
709    ``balance.MAG_MINE_LUNGE_RANGE`` is deliberately short enough that the mine,
710    its white flash and the lunge itself all happen inside the frame: a mine
711    that woke up off-screen would deal its damage before the player ever saw it.
712    It commits to the ship's position at the moment the flash starts, so the
713    flash is a dodge window rather than a warning that arrives too late. Contact
714    detonates it: the mine trades itself for a heavy hit, which is what makes a
715    corridor of them a route decision rather than an obstacle.
716    """
717
718    ARCHETYPE = "mag_mine"
719    HITBOX_RADIUS = 1.6
720    BODY_COLOUR = artkit.palette("enemy").hull_colour
721
722    def __init__(self, *, drift: Vec2 | None = None, **kwargs):
723        super().__init__(**kwargs)
724        self.drift = Vec2(0.0, 0.0) if drift is None else Vec2(float(drift.x), float(drift.y))
725        self._lunge_direction = Vec2(1.0, 0.0)
726        self._lunge_remaining = 0.0
727        self._rearm = 0.0
728        self.stagger_cadence()
729
730    def stagger_cadence(self) -> None:
731        """Arm somewhere inside the rearm window, so a minefield goes off ragged."""
732        self._rearm = self.cadence_offset(MAG_MINE_REARM_S)
733
734    def build_body(self):
735        self.add_child(artkit.build_enemy("mag_mine"))
736
737    @property
738    def lunging(self) -> bool:
739        """Whether the mine is mid-lunge."""
740        return self._lunge_remaining > 0.0
741
742    def on_telegraph(self) -> None:
743        ship = self.player_ship()
744        if ship is not None:
745            self._lunge_direction = seek(self.plane_position, to_plane(ship.position))
746        super().on_telegraph()
747
748    def perform_attack(self) -> None:
749        self._lunge_remaining = MAG_MINE_LUNGE_S
750
751    def on_update(self, dt: float):
752        super().on_update(dt)
753        if self._rearm > 0.0:
754            self._rearm = max(0.0, self._rearm - dt)
755        if self.lunging or self.telegraphing or self._rearm > 0.0:
756            return
757        ship = self.player_ship()
758        if ship is not None and self.distance_to(ship) <= balance.MAG_MINE_LUNGE_RANGE:
759            self.begin_attack()
760
761    def on_fixed_update(self, dt: float):
762        if self.destroying:
763            return
764        if self.lunging:
765            self._lunge_remaining = max(0.0, self._lunge_remaining - dt)
766            self.move(dt, self._lunge_direction, MAG_MINE_LUNGE_SPEED)
767            self._detonate_on_contact()
768            if not self.lunging:
769                self._rearm = MAG_MINE_REARM_S
770            return
771        if self.telegraphing:
772            self.move(dt, Vec2(0.0, 0.0), 0.0)
773            return
774        self.move(dt, limit(self.drift, 1.0), MAG_MINE_DRIFT_SPEED)
775
776    def _detonate_on_contact(self) -> None:
777        ship = self.player_ship()
778        if ship is None or self.distance_to(ship) > MAG_MINE_HIT_RADIUS + self.HITBOX_RADIUS:
779            return
780        self.contact_damage(ship, float(self.spec.damage))
781        self.kill()