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()