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