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.emit()
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.emit(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        ]
337
338    def _steer(self, dt: float, field: FlowField) -> None:
339        live = self.alive_count
340        plane = self.positions[:live][:, [0, 2]]
341        desired = field.sample_many(plane) * (MITE_SURGE_SPEED if self.surging else MITE_SPEED)
342        desired += separation(plane, MITE_SEPARATION_RADIUS) * MITE_SEPARATION_WEIGHT
343        # A mite that just bit has no drive of its own for CONTACT_STALL_S; the
344        # recoil below is the only thing carrying it, which is what peels the
345        # swarm off the hull instead of letting it grind through.
346        desired[self._mite_stall[:live] > 0.0] = 0.0
347        blend = 1.0 - math.exp(-MITE_TURN_RATE * dt)
348        self.velocities[:live] += (desired - self.velocities[:live]) * blend
349        step = self.velocities[:live] + self._mite_recoil[:live]
350        self.positions[:live, 0] += step[:, 0] * dt
351        self.positions[:live, 2] += step[:, 1] * dt
352        self.positions[:live, 1] = PLANE_Y
353        self._mite_recoil[:live] *= math.exp(-balance.CONTACT_RECOIL_DAMPING * dt)
354
355    def _touch_ship(self, dt: float, ship) -> None:
356        live = self.alive_count
357        self._touch_cooldown[:live] = np.maximum(0.0, self._touch_cooldown[:live] - dt)
358        self._mite_stall[:live] = np.maximum(0.0, self._mite_stall[:live] - dt)
359        if ship is None or ship.destroying:
360            return
361        dx = self.positions[:live, 0] - float(ship.position.x)
362        dz = self.positions[:live, 2] - float(ship.position.z)
363        touching = (dx * dx + dz * dz <= MITE_TOUCH_RADIUS * MITE_TOUCH_RADIUS) & (self._touch_cooldown[:live] <= 0.0)
364        rows = np.flatnonzero(touching)
365        if rows.size == 0:
366            return
367        self._touch_cooldown[rows] = MITE_TOUCH_COOLDOWN_S
368        self._contact_rows = rows
369        self._contact_bearings = self._bearings_from(dx[rows], dz[rows])
370        self.contact_damage(ship, float(self.spec.damage) * rows.size)
371        self._contact_rows = np.zeros(0, dtype=np.int64)
372
373    @staticmethod
374    def _bearings_from(dx: np.ndarray, dz: np.ndarray) -> np.ndarray:
375        """Unit bearings away from the hull for the rows that touched it."""
376        lengths = np.hypot(dx, dz)
377        safe = np.where(lengths > 1e-6, lengths, np.float32(1.0))
378        bearings = np.stack((dx / safe, dz / safe), axis=1).astype(np.float32)
379        bearings[lengths <= 1e-6] = np.asarray((1.0, 0.0), dtype=np.float32)
380        return bearings
381
382    # -- Contact ------------------------------------------------------------
383    #
384    # A shoal is one node but twenty bodies, so the base class's contact hooks
385    # are redirected onto the handful of rows that actually reached the hull.
386    # Stalling the whole shoal because three mites bit would freeze the other
387    # seventeen mid-approach, and shoving them all along one aggregate bearing
388    # would slide the swarm sideways instead of scattering it off the hull.
389
390    def stall(self, seconds: float) -> None:
391        """Stall only the mites that made contact this step."""
392        if self._contact_rows.size:
393            self._mite_stall[self._contact_rows] = float(seconds)
394
395    def apply_contact_impulse(self, direction: Vec3, speed: float) -> None:
396        """Throw the mites that made contact back along their own bearings.
397
398        *direction* is the shoal-wide axis the router resolved, and is
399        deliberately superseded: each mite bounces off the point it hit.
400        """
401        rows = self._contact_rows
402        if rows.size:
403            self._mite_recoil[rows] = self._contact_bearings * float(speed)
404
405    def _sync_multimesh(self) -> None:
406        if self._multi_mesh is None:
407            return
408        live = self.alive_count
409        transforms = np.zeros((self.capacity, 4, 4), dtype=np.float32)
410        transforms[:, 3, 3] = 1.0
411        if live:
412            velocities = self.velocities[:live]
413            heading = np.arctan2(-velocities[:, 1], velocities[:, 0])
414            cos, sin = np.cos(heading), np.sin(heading)
415            transforms[:live, 0, 0] = cos
416            transforms[:live, 0, 2] = sin
417            transforms[:live, 1, 1] = 1.0
418            transforms[:live, 2, 0] = -sin
419            transforms[:live, 2, 2] = cos
420            transforms[:live, 0, 3] = self.positions[:live, 0] - float(self.position.x)
421            transforms[:live, 1, 3] = 0.0
422            transforms[:live, 2, 3] = self.positions[:live, 2] - float(self.position.z)
423        self._multi_mesh.set_all_transforms(transforms)
424        if self._mesh_node is not None:
425            self._mesh_node.visible_instance_count = live
426
427
428# ============================================================================
429# Skimmers
430# ============================================================================
431
432
433class Skimmer(Enemy):
434    """A fast wedge that ignores the player and steals floating salvage.
435
436    It never shoots and never chases the ship: it runs the nearest salvage
437    down, telegraphs the grab, carries the mote off the field and takes it out
438    of the run. Killing it before it escapes drops the mote where it dies, so
439    the archetype taxes greed rather than hull, and shooting it is a decision
440    about income rather than about survival.
441    """
442
443    ARCHETYPE = "skimmer"
444    HITBOX_RADIUS = 1.4
445    BODY_COLOUR = artkit.palette("enemy").hull_colour
446
447    #: Fires when the Skimmer leaves the field with its loot.
448    escaped = Signal()
449
450    def __init__(self, **kwargs):
451        super().__init__(**kwargs)
452        self.state = "hunting"
453        self.carried: Node3D | None = None
454        self._carried_home: Node3D | None = None
455        self._flee_direction = Vec2(1.0, 0.0)
456        self._flee_origin = Vec2(0.0, 0.0)
457
458    def build_body(self):
459        self.add_child(artkit.build_enemy("skimmer"))
460
461    # -- Behaviour ----------------------------------------------------------
462
463    def on_update(self, dt: float):
464        super().on_update(dt)
465        if self.state != "hunting" or self.telegraphing:
466            return
467        target = self.nearest_salvage()
468        if target is not None and self.distance_to(target) <= SKIMMER_GRAB_RADIUS:
469            self.begin_attack()
470
471    def perform_attack(self) -> None:
472        target = self.nearest_salvage()
473        if target is None or self.distance_to(target) > SKIMMER_GRAB_RADIUS:
474            return
475        self._grab(target)
476
477    def on_fixed_update(self, dt: float):
478        if self.destroying:
479            return
480        if self.state == "fleeing":
481            self.face(self._flee_direction)
482            self.move(dt, self._flee_direction, SKIMMER_FLEE_SPEED)
483            travelled = math.hypot(
484                float(self.position.x) - float(self._flee_origin.x),
485                float(self.position.z) - float(self._flee_origin.y),
486            )
487            if travelled >= SKIMMER_ESCAPE_DISTANCE:
488                self._escape()
489            return
490        if self.telegraphing:
491            self.move(dt, Vec2(0.0, 0.0), 0.0)
492            return
493        target = self.nearest_salvage()
494        if target is None:
495            self.move(dt, seek(self.plane_position, Vec2(0.0, 0.0)), SKIMMER_IDLE_SPEED)
496            return
497        direction = self.steer_toward(to_plane(target.position))
498        self.face(direction)
499        self.move(dt, direction, SKIMMER_SPEED)
500
501    # -- Salvage ------------------------------------------------------------
502
503    def nearest_salvage(self) -> Node3D | None:
504        """The closest uncarried salvage mote, or None when the field is clean."""
505        tree = self.tree
506        if tree is None:
507            return None
508        best: Node3D | None = None
509        best_distance = math.inf
510        for mote in tree.group(Groups.SALVAGE):
511            distance = self.distance_to(mote)
512            if distance < best_distance:
513                best, best_distance = mote, distance
514        return best
515
516    def _grab(self, mote: Node3D) -> None:
517        self._carried_home = mote.parent
518        world = Vec3(mote.position)
519        if mote.parent is not None:
520            mote.parent.remove_child(mote)
521        self.add_child(mote)
522        # After the reparent, not before: entering a tree re-runs the mote's own
523        # ``on_enter_tree``, which is where a salvage node joins the group.
524        mote.remove_from_group(Groups.SALVAGE)
525        mote.position = Vec3(
526            world.x - float(self.position.x),
527            PLANE_Y,
528            world.z - float(self.position.z),
529        )
530        self.carried = mote
531        self.state = "fleeing"
532        self._flee_origin = self.plane_position
533        outward = seek(Vec2(0.0, 0.0), self.plane_position)
534        self._flee_direction = Vec2(1.0, 0.0) if not float(outward.x) and not float(outward.y) else outward
535
536    def drop_carried(self) -> Node3D | None:
537        """Return the stolen mote to the world at this Skimmer's position."""
538        mote = self.carried
539        if mote is None:
540            return None
541        world = Vec3(
542            float(self.position.x) + float(mote.position.x),
543            PLANE_Y,
544            float(self.position.z) + float(mote.position.z),
545        )
546        self.remove_child(mote)
547        home = self._carried_home
548        if home is not None and not home.destroying:
549            home.add_child(mote)
550            mote.position = world
551            mote.add_to_group(Groups.SALVAGE)
552        self.carried = None
553        self._carried_home = None
554        return mote
555
556    def _escape(self) -> None:
557        mote = self.carried
558        if mote is not None:
559            mote.destroy()
560            self.carried = None
561        self.escaped.emit()
562        self.destroy()
563
564    def kill(self) -> None:
565        self.drop_carried()
566        super().kill()
567
568
569# ============================================================================
570# Lancers
571# ============================================================================
572
573
574class Lancer(Enemy):
575    """A dash attacker that draws its attack line half a second early.
576
577    It closes to :data:`LANCER_STANDOFF`, commits to a direction, then holds
578    still while the white flash and a drawn line show exactly where it is about
579    to be for ``balance.LANCER_TELEGRAPH_S``. The line is the contract: it is
580    aimed when the flash starts, so sidestepping during the flash always works
581    and the sidestep-and-punish rhythm is learnable rather than reactive.
582    """
583
584    ARCHETYPE = "lancer"
585    HITBOX_RADIUS = 1.8
586    BODY_COLOUR = artkit.palette("enemy").hull_colour
587    telegraph_duration = balance.LANCER_TELEGRAPH_S
588
589    def __init__(self, **kwargs):
590        super().__init__(**kwargs)
591        self._dash_direction = Vec2(1.0, 0.0)
592        self._dash_remaining = 0.0
593        self._cooldown = 0.0
594        self._hit_this_dash = False
595        self._line: Node3D | None = None
596        self.stagger_cadence()
597
598    def stagger_cadence(self) -> None:
599        """Start somewhere inside the dash cooldown rather than ready to fire.
600
601        This is what stops a pair of Lancers spawned on the same frame from
602        drawing the same line at the same instant for the rest of the fight.
603        """
604        self._cooldown = self.cadence_offset(LANCER_DASH_COOLDOWN_S)
605
606    def build_body(self):
607        self.add_child(artkit.build_enemy("lancer"))
608        self._line = self.add_child(Node3D(name="TelegraphLine"))
609        self._line.add_child(
610            MeshInstance3D(
611                name="Line",
612                mesh=Mesh.box((LANCER_DASH_DISTANCE, 0.05, LANCER_LINE_WIDTH)),
613                material=Material(colour=(1.0, 1.0, 1.0, 1.0), unlit=True),
614                position=Vec3(LANCER_DASH_DISTANCE * 0.5, 0.0, 0.0),
615            )
616        )
617        self._line.visible = False
618
619    # -- Queries ------------------------------------------------------------
620
621    @property
622    def dashing(self) -> bool:
623        """Whether the dash itself is in progress."""
624        return self._dash_remaining > 0.0
625
626    def telegraph_line(self) -> tuple[Vec3, Vec3] | None:
627        """The drawn dash line as ``(start, end)``, or None when not telegraphing."""
628        if not self.telegraphing:
629            return None
630        start = Vec3(self.position)
631        end = Vec3(
632            float(start.x) + float(self._dash_direction.x) * LANCER_DASH_DISTANCE,
633            PLANE_Y,
634            float(start.z) + float(self._dash_direction.y) * LANCER_DASH_DISTANCE,
635        )
636        return start, end
637
638    # -- Behaviour ----------------------------------------------------------
639
640    def on_telegraph(self) -> None:
641        ship = self.player_ship()
642        if ship is not None:
643            self._dash_direction = seek(self.plane_position, to_plane(ship.position))
644            self.face(self._dash_direction)
645        super().on_telegraph()
646
647    def apply_telegraph_colour(self, active: bool) -> None:
648        super().apply_telegraph_colour(active)
649        if self._line is not None:
650            self._line.visible = active
651
652    def perform_attack(self) -> None:
653        self._dash_remaining = LANCER_DASH_DISTANCE / LANCER_DASH_SPEED
654        self._hit_this_dash = False
655        self._cooldown = LANCER_DASH_COOLDOWN_S
656
657    def on_update(self, dt: float):
658        super().on_update(dt)
659        if self._cooldown > 0.0:
660            self._cooldown = max(0.0, self._cooldown - dt)
661        if self.dashing or self.telegraphing or self._cooldown > 0.0:
662            return
663        ship = self.player_ship()
664        if ship is not None and self.distance_to(ship) <= LANCER_DASH_RANGE:
665            self.begin_attack()
666
667    def on_fixed_update(self, dt: float):
668        if self.destroying:
669            return
670        if self.dashing:
671            self._dash_remaining = max(0.0, self._dash_remaining - dt)
672            self.move(dt, self._dash_direction, LANCER_DASH_SPEED)
673            self._strike()
674            return
675        if self.telegraphing:
676            self.move(dt, Vec2(0.0, 0.0), 0.0)
677            return
678        ship = self.player_ship()
679        if ship is None:
680            return
681        self.face(seek(self.plane_position, to_plane(ship.position)))
682        if self.distance_to(ship) <= LANCER_STANDOFF:
683            self.move(dt, Vec2(0.0, 0.0), 0.0)
684            return
685        self.move(dt, self.steer_toward(to_plane(ship.position)), LANCER_APPROACH_SPEED)
686
687    def _strike(self) -> None:
688        if self._hit_this_dash:
689            return
690        ship = self.player_ship()
691        if ship is None or self.distance_to(ship) > LANCER_HIT_RADIUS + self.HITBOX_RADIUS:
692            return
693        self._hit_this_dash = True
694        self.contact_damage(ship, float(self.spec.damage))
695
696
697# ============================================================================
698# Mag-mines
699# ============================================================================
700
701
702class MagMine(Enemy):
703    """A drifting sphere that lunges once the ship is inside its trigger range.
704
705    ``balance.MAG_MINE_LUNGE_RANGE`` is deliberately short enough that the mine,
706    its white flash and the lunge itself all happen inside the frame: a mine
707    that woke up off-screen would deal its damage before the player ever saw it.
708    It commits to the ship's position at the moment the flash starts, so the
709    flash is a dodge window rather than a warning that arrives too late. Contact
710    detonates it: the mine trades itself for a heavy hit, which is what makes a
711    corridor of them a route decision rather than an obstacle.
712    """
713
714    ARCHETYPE = "mag_mine"
715    HITBOX_RADIUS = 1.6
716    BODY_COLOUR = artkit.palette("enemy").hull_colour
717
718    def __init__(self, *, drift: Vec2 | None = None, **kwargs):
719        super().__init__(**kwargs)
720        self.drift = Vec2(0.0, 0.0) if drift is None else Vec2(float(drift.x), float(drift.y))
721        self._lunge_direction = Vec2(1.0, 0.0)
722        self._lunge_remaining = 0.0
723        self._rearm = 0.0
724        self.stagger_cadence()
725
726    def stagger_cadence(self) -> None:
727        """Arm somewhere inside the rearm window, so a minefield goes off ragged."""
728        self._rearm = self.cadence_offset(MAG_MINE_REARM_S)
729
730    def build_body(self):
731        self.add_child(artkit.build_enemy("mag_mine"))
732
733    @property
734    def lunging(self) -> bool:
735        """Whether the mine is mid-lunge."""
736        return self._lunge_remaining > 0.0
737
738    def on_telegraph(self) -> None:
739        ship = self.player_ship()
740        if ship is not None:
741            self._lunge_direction = seek(self.plane_position, to_plane(ship.position))
742        super().on_telegraph()
743
744    def perform_attack(self) -> None:
745        self._lunge_remaining = MAG_MINE_LUNGE_S
746
747    def on_update(self, dt: float):
748        super().on_update(dt)
749        if self._rearm > 0.0:
750            self._rearm = max(0.0, self._rearm - dt)
751        if self.lunging or self.telegraphing or self._rearm > 0.0:
752            return
753        ship = self.player_ship()
754        if ship is not None and self.distance_to(ship) <= balance.MAG_MINE_LUNGE_RANGE:
755            self.begin_attack()
756
757    def on_fixed_update(self, dt: float):
758        if self.destroying:
759            return
760        if self.lunging:
761            self._lunge_remaining = max(0.0, self._lunge_remaining - dt)
762            self.move(dt, self._lunge_direction, MAG_MINE_LUNGE_SPEED)
763            self._detonate_on_contact()
764            if not self.lunging:
765                self._rearm = MAG_MINE_REARM_S
766            return
767        if self.telegraphing:
768            self.move(dt, Vec2(0.0, 0.0), 0.0)
769            return
770        self.move(dt, limit(self.drift, 1.0), MAG_MINE_DRIFT_SPEED)
771
772    def _detonate_on_contact(self) -> None:
773        ship = self.player_ship()
774        if ship is None or self.distance_to(ship) > MAG_MINE_HIT_RADIUS + self.HITBOX_RADIUS:
775            return
776        self.contact_damage(ship, float(self.spec.damage))
777        self.kill()