shrike/enemies/advanced.py¶
Part of SHRIKE.
1"""The advanced half of the enemy roster: support, siege, alarm, emplacement and herald.
2
3Where ``basic.py`` holds the archetypes that come at you, this module holds the
4five that change the shape of a fight rather than adding hulls to shoot:
5
6* :class:`Welder` keeps other enemies alive down a visible repair umbilical you
7 cut by flying through it, which turns a wave into a kill-order problem.
8* :class:`Bombardier` never closes. It paints landing reticles on where you are
9 about to be, so parking on a deposit is what costs hull.
10* :class:`Screamer` never shoots. Leave it alive
11 ``balance.SCREAMER_AGGRO_TIMER_S`` after it notices you and it pays for itself
12 in signature, which is the next wave's budget.
13* :class:`HuskTurret` is terrain that shoots: a dead hull sweeping a tracking
14 beam across a corridor, damaging in ticks rather than continuously.
15* :class:`Herald` pours in during the hunter's arrival window and carries the
16 Fletchings the Roost campaign is built from.
17
18Every one of them attacks through :meth:`Enemy.begin_attack`, so the white
19telegraph promise from ``steering.py`` holds here too.
20
21The three elite modifiers are composable wrappers rather than subclasses.
22:func:`apply_elite` wraps an enemy's bound ``take_damage`` and hooks its ``died``
23signal, so Armoured, Splitting and EMP-laced apply equally to everything in
24``basic.py`` and everything here, and two of them nest cleanly on one hull.
25
26Balance numbers come from ``shrike.balance``, speeds and engagement ranges
27included: the module-level names below are bindings to that table. What is
28named here for its own sake is cadence, geometry and the elite coefficients.
29
30Each of these archetypes staggers its own attack timer at spawn through
31``Enemy.stagger_cadence``, so a battery of Bombardiers walks its fire and a
32flight of Heralds lances in ones rather than in a single volley.
33"""
34
35from __future__ import annotations
36
37import math
38import random
39from dataclasses import dataclass, field
40
41import numpy as np
42
43from simvx.core import (
44 Material,
45 Mesh,
46 MeshInstance3D,
47 MultiMesh,
48 MultiMeshInstance3D,
49 Node3D,
50 Property,
51 Quat,
52 Signal,
53 Vec2,
54 Vec3,
55)
56
57from .. import artkit, balance
58from ..runtime import PLANE_Y, Groups, Services, from_plane, to_plane
59from ..vfx import Vfx
60from .steering import Enemy, seek
61
62# ============================================================================
63# Module-local anchors
64#
65# Speeds and engagement ranges live in balance.py, quoted as multiples of the
66# player's cruise speed and against what the camera actually shows; the bindings
67# below give the behaviour code readable names for them and nothing more. What
68# is genuinely local is cadence, geometry and the elite coefficients: how often
69# an archetype may try, how wide its sweep is, and what a modifier costs.
70# ============================================================================
71
72# Welder
73WELDER_SPEED = balance.WELDER_SPEED
74#: Longest umbilical the welder projects, and the range it looks for patients in.
75WELDER_UMBILICAL_RANGE = balance.WELDER_UMBILICAL_RANGE
76#: Distance it tries to hold from its patient while the link is live.
77WELDER_TETHER_DISTANCE = balance.WELDER_TETHER_DISTANCE
78WELDER_REPAIR_PER_S = 6.0
79#: How near the umbilical the ship must pass to shear it.
80WELDER_UMBILICAL_BREAK_RADIUS = balance.WELDER_UMBILICAL_BREAK_RADIUS
81#: Dead time before a sheared welder finds another patient.
82WELDER_RELINK_DELAY_S = 2.5
83#: Where an unlinked welder loiters relative to the ship.
84WELDER_LOITER_DISTANCE = balance.WELDER_LOITER_DISTANCE
85#: Drawn radius of the umbilical. Thinner than a weapon beam: it is a link to
86#: read and fly through, not a thing that hurts.
87WELDER_UMBILICAL_RADIUS = 0.09
88
89# Bombardier
90BOMBARDIER_SPEED = balance.BOMBARDIER_SPEED
91BOMBARDIER_STANDOFF_DISTANCE = balance.BOMBARDIER_STANDOFF_DISTANCE
92#: Dead band around the stand-off distance, inside which it strafes rather than closes.
93BOMBARDIER_STANDOFF_BAND = balance.BOMBARDIER_STANDOFF_BAND
94#: Beyond this the ship is out of the tube's reach.
95BOMBARDIER_MAX_RANGE = balance.BOMBARDIER_MAX_RANGE
96BOMBARDIER_SALVO_INTERVAL_S = 4.5
97BOMBARDIER_SALVO_SHELLS = 3
98#: Seconds a reticle is painted on the plane before its shell lands. Two full
99#: seconds is the design's warning, and it is a floor rather than a taste: it
100#: has to cover reading the paint, deciding, and flying out of a blast radius
101#: from a standing start.
102BOMBARDIER_SHELL_FLIGHT_S = 2.0
103#: Extra flight time per shell, so a salvo walks across the target rather than stacking.
104BOMBARDIER_SHELL_STAGGER_S = 0.35
105BOMBARDIER_BLAST_RADIUS = balance.BOMBARDIER_BLAST_RADIUS
106#: How far ahead of the ship's current velocity the first shell is aimed.
107BOMBARDIER_LEAD_S = 0.9
108#: Random scatter applied to each shell after the lead, in plane units.
109BOMBARDIER_SCATTER = balance.BOMBARDIER_SCATTER
110
111# Screamer
112SCREAMER_SPEED = balance.SCREAMER_SPEED
113SCREAMER_AGGRO_RANGE = balance.SCREAMER_AGGRO_RANGE
114#: Once aggroed it runs, and keeps running until it is this far from the ship.
115SCREAMER_FLEE_DISTANCE = balance.SCREAMER_FLEE_DISTANCE
116
117# Husk turret
118HUSK_TURRET_BEAM_RANGE = balance.HUSK_TURRET_BEAM_RANGE
119#: Idle sweep: the beam oscillates across this arc, centred on its rest heading.
120HUSK_TURRET_SWEEP_ARC = math.radians(120.0)
121HUSK_TURRET_SWEEP_RATE = math.radians(45.0)
122#: How fast the beam pulls onto a target once the ship is in range.
123HUSK_TURRET_TRACK_RATE = math.radians(70.0)
124#: Angular tolerance for the beam counting as on target.
125HUSK_TURRET_BEAM_HALF_ANGLE = math.radians(4.0)
126#: The beam damages in ticks, not continuously.
127HUSK_TURRET_TICK_S = 0.5
128#: Height of the barrel's trunnion above the wreck, world units: the drum the
129#: art kit sits on the base is 1.7 deep and the barrel rides its shoulder.
130HUSK_TURRET_BARREL_HEIGHT = 0.75
131
132# Herald
133HERALD_SPEED = balance.HERALD_SPEED
134#: Radius of the strafing orbit a herald holds around the ship.
135HERALD_ORBIT_RADIUS = balance.HERALD_ORBIT_RADIUS
136HERALD_ORBIT_RATE = 1.1
137HERALD_FIRE_INTERVAL_S = 2.2
138HERALD_LANCE_RANGE = balance.HERALD_LANCE_RANGE
139#: The telegraph stage the flight arrives on.
140HERALD_ARRIVAL_STAGE = "t30"
141HERALD_FLIGHT_MIN = 3
142HERALD_FLIGHT_MAX = 5
143#: Heralds tear in on a ring this far from the ship.
144HERALD_SPAWN_RADIUS = balance.HERALD_SPAWN_RADIUS
145#: Heralds are the hunter's language spoken by an enemy hull, so they carry the
146#: enemy palette like everything else the player shoots; what marks them out is
147#: the silhouette, a shard of the Shrike itself.
148HERALD_COLOUR = artkit.palette("enemy").hull_colour
149
150# Elite modifiers
151ELITE_ARMOURED_HP_MULT = 1.8
152#: Plating trims most incoming damage, but ballistics were built for plating.
153ELITE_ARMOURED_REDUCTION = 0.45
154ELITE_ARMOURED_BALLISTIC_REDUCTION = 0.20
155ELITE_SPLIT_COUNT = 2
156ELITE_SPLIT_HP_FRACTION = 0.35
157#: How far the halves are thrown apart when the parent comes open.
158ELITE_SPLIT_SCATTER = 4.0
159ELITE_EMP_CAPACITOR_DRAIN = 25.0
160ELITE_EMP_RADIUS = 14.0
161
162
163# ============================================================================
164# Ordnance presentation
165#
166# An attack the player cannot see is not a difficulty setting, it is a bug: a
167# mortar reticle that is only a dataclass, a turret beam that is only a heading
168# and a lance that is only a damage number all read to the player as dying for
169# no reason. Everything an enemy puts in the air is built from the constants
170# below, so the readability contract (enemy fire is cold magenta, and emissive
171# so it survives two hundred particles) is enforced at one site rather than
172# remembered five times.
173# ============================================================================
174
175#: Cold magenta, from the enemy palette. Every hostile muzzle, shell, beam and
176#: reticle in the game is this colour.
177ENEMY_FIRE_COLOUR = artkit.palette("enemy").accent
178ENEMY_FIRE_ENERGY = artkit.palette("enemy").accent_strength
179#: How much larger than the radius its damage resolves against a piece of
180#: ordnance is drawn. Fire that is drawn at its true size is a pixel at this
181#: camera distance, so it is drawn at the size it needs to be read at, and the
182#: hit stays honest by resolving against the smaller number.
183ENEMY_ORDNANCE_VISUAL_GAIN = 2.0
184
185#: Radius the mortar shell's own body would occupy. Nothing collides with it in
186#: flight (its reticle owns the landing), so this exists to size the tracer.
187MORTAR_SHELL_BODY_RADIUS = 0.26
188#: The drawn tracer, at the visual gain.
189MORTAR_SHELL_DRAWN_RADIUS = MORTAR_SHELL_BODY_RADIUS * ENEMY_ORDNANCE_VISUAL_GAIN
190#: How high above the plane the shell arcs at the top of its flight. Gameplay
191#: never leaves the plane; the picture is allowed to.
192MORTAR_SHELL_ARC_HEIGHT = 7.0
193#: The audio cue a launched shell asks for. See :attr:`Bombardier.shell_launched`.
194MORTAR_WHISTLE_CUE = "mortar_whistle"
195
196#: The landing reticle: a dim rim at the blast radius that says where, and a
197#: bright ring closing inside it that says when. Both are rings of dashes on
198#: one multimesh, so a full salvo of three costs six draws.
199RETICLE_RING_DASHES = 24
200RETICLE_DASH_LENGTH = 0.22
201RETICLE_DASH_WIDTH = 0.05
202RETICLE_DASH_THICKNESS = 0.02
203RETICLE_RIM_ALPHA = 0.45
204RETICLE_CLOSER_ALPHA = 1.0
205#: The closing ring never quite reaches zero: a ring with no radius is a dot,
206#: and the last tenth of a second is when the player most needs to see one.
207RETICLE_CLOSER_MIN_FRACTION = 0.12
208#: Reticles sit a hair above the plane so they never z-fight the deck.
209RETICLE_HEIGHT = 0.04
210
211#: Beams: the husk turret's sustained sweep and the herald's lance.
212ENEMY_BEAM_RADIUS = 0.16
213#: How long a lance stays drawn after it has landed. Long enough to be seen and
214#: located, short enough that a flight of five does not paint the screen.
215LANCE_TRACER_S = 0.18
216
217
218def enemy_fire_material(*, alpha: float = 1.0, energy: float = ENEMY_FIRE_ENERGY) -> Material:
219 """One piece of hostile ordnance's material: unlit magenta that blooms."""
220 return Material(
221 colour=(*ENEMY_FIRE_COLOUR, alpha),
222 blend="alpha",
223 unlit=True,
224 emissive_colour=ENEMY_FIRE_COLOUR,
225 emissive_strength=energy,
226 )
227
228
229def _ring_multimesh(dashes: int = RETICLE_RING_DASHES) -> MultiMesh:
230 """A unit-radius ring of tangential dashes, as one instanced draw.
231
232 Scaling the node that carries it scales the ring, which is what lets the
233 closing ring shrink every frame without touching a transform buffer.
234 """
235 transforms = np.zeros((dashes, 4, 4), dtype=np.float32)
236 transforms[:, 3, 3] = 1.0
237 angles = np.arange(dashes, dtype=np.float32) * (2.0 * math.pi / dashes)
238 # Yaw each dash so its long axis lies along the tangent at its angle.
239 yaw = -(angles + math.pi * 0.5)
240 cos, sin = np.cos(yaw), np.sin(yaw)
241 transforms[:, 0, 0] = cos * RETICLE_DASH_LENGTH
242 transforms[:, 2, 0] = -sin * RETICLE_DASH_LENGTH
243 transforms[:, 1, 1] = RETICLE_DASH_THICKNESS
244 transforms[:, 0, 2] = sin * RETICLE_DASH_WIDTH
245 transforms[:, 2, 2] = cos * RETICLE_DASH_WIDTH
246 transforms[:, 0, 3] = np.cos(angles)
247 transforms[:, 2, 3] = np.sin(angles)
248 mesh = MultiMesh(mesh=Mesh.cube(size=1.0), instance_count=dashes)
249 mesh.set_all_transforms(transforms)
250 return mesh
251
252
253class PlaneBeam(Node3D):
254 """A magenta bolt drawn between two points on the flight plane.
255
256 The husk turret holds one lit for as long as its beam is on target; a
257 herald strikes one for :data:`LANCE_TRACER_S` and lets it fade. Either way
258 the player sees the line the damage came down, which is the whole point:
259 the geometry is the only difference between an attack and an unexplained
260 hull loss.
261 """
262
263 visible = Property(
264 False,
265 coerce=bool,
266 hint="Whether this node and its subtree are drawn",
267 on_change="_on_visible_changed",
268 )
269
270 def __init__(self, *, radius: float = ENEMY_BEAM_RADIUS, **kwargs):
271 kwargs.setdefault("name", "Beam")
272 super().__init__(**kwargs)
273 self.radius = float(radius)
274 self._core: MeshInstance3D | None = None
275 self._hold = 0.0
276
277 @property
278 def lit(self) -> bool:
279 """Whether the beam is currently drawn."""
280 return self.visible
281
282 def on_ready(self):
283 self._core = self.add_child(
284 MeshInstance3D(
285 name="Core",
286 mesh=Mesh.cylinder(radius=self.radius, height=1.0, segments=8),
287 material=enemy_fire_material(),
288 rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
289 )
290 )
291
292 def draw_between(self, start: Vec2, end: Vec2, *, hold_s: float = 0.0) -> None:
293 """Light the beam from *start* to *end*, holding it for *hold_s*.
294
295 A zero hold means the caller re-draws it every frame while it burns; a
296 positive one is a strike that fades itself out.
297 """
298 dx = float(end.x) - float(start.x)
299 dz = float(end.y) - float(start.y)
300 length = math.hypot(dx, dz)
301 if length < 1e-6 or self._core is None:
302 return
303 self.position = Vec3(float(start.x), PLANE_Y, float(start.y))
304 self.face_along(Vec3(dx / length, 0.0, dz / length))
305 self._core.position = Vec3(0.0, 0.0, -length * 0.5)
306 self._core.scale = Vec3(1.0, length, 1.0)
307 self._hold = float(hold_s)
308 self.visible = True
309
310 def douse(self) -> None:
311 """Put the beam out now."""
312 self._hold = 0.0
313 self.visible = False
314
315 def on_update(self, dt: float):
316 if self._hold <= 0.0:
317 return
318 self._hold -= dt
319 if self._hold <= 0.0:
320 self.douse()
321
322
323# ============================================================================
324# Plane geometry
325# ============================================================================
326
327
328def wrap_angle(radians: float) -> float:
329 """Fold an angle into ``[-pi, pi]``."""
330 return (float(radians) + math.pi) % (2.0 * math.pi) - math.pi
331
332
333def point_to_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> float:
334 """Shortest distance from *point* to the segment ``start -> end`` on the plane."""
335 sx, sy = float(start.x), float(start.y)
336 dx, dy = float(end.x) - sx, float(end.y) - sy
337 length_sq = dx * dx + dy * dy
338 if length_sq <= 1e-9:
339 return math.hypot(float(point.x) - sx, float(point.y) - sy)
340 t = ((float(point.x) - sx) * dx + (float(point.y) - sy) * dy) / length_sq
341 t = min(1.0, max(0.0, t))
342 return math.hypot(float(point.x) - (sx + dx * t), float(point.y) - (sy + dy * t))
343
344
345def bearing(origin: Vec2, target: Vec2) -> float:
346 """Heading in radians from *origin* to *target*, anticlockwise from +X seen from above."""
347 return math.atan2(-(float(target.y) - float(origin.y)), float(target.x) - float(origin.x))
348
349
350def heading_vector(heading: float) -> Vec2:
351 """Unit plane vector for a heading, matching ``runtime.heading_to_direction``."""
352 return Vec2(math.cos(heading), -math.sin(heading))
353
354
355def full_hp(enemy) -> float:
356 """Full hull points for *enemy*, honouring an Armoured elite's inflated pool.
357
358 An archetype that tracks its own ceiling (everything in this module, and
359 anything an elite has been attached to) reports it through ``max_hp``.
360 Otherwise the archetype's balance entry is the ceiling, which is right for a
361 single hull and only approximate for an aggregate one such as a mite shoal,
362 so callers that heal clamp against the current value as well.
363 """
364 return float(getattr(enemy, "max_hp", enemy.spec.hp))
365
366
367# ============================================================================
368# Shared scaffolding
369# ============================================================================
370
371
372class AdvancedEnemy(Enemy):
373 """Station-keeping, seeding and elite bookkeeping shared by the advanced five.
374
375 The base in ``steering.py`` already owns hull points, the telegraph and the
376 damage route. What the advanced archetypes add is a reason to sit still at a
377 chosen distance, per-node randomness the wave composer can seed for a
378 learnable spawn, and the ``max_hp`` ceiling a Welder heals toward and an
379 Armoured elite raises.
380 """
381
382 #: Cruising speed on the flight plane in units per second; 0 when stationary.
383 SPEED: float = 0.0
384
385 def __init__(self, **kwargs):
386 super().__init__(**kwargs)
387 self.max_hp = float(self.spec.hp)
388 self.elite_modifiers: list[EliteModifier] = []
389 self._ordnance: list[Node3D] = []
390
391 # -- Ordnance ownership -------------------------------------------------
392
393 def mount_beam(self, name: str = "Beam", **kwargs) -> PlaneBeam:
394 """Mount a :class:`PlaneBeam` this enemy owns but does not carry.
395
396 Ordnance is parented beside the hull rather than under it, because a
397 beam is drawn between two points on the flight plane and a hull that
398 turns to face its target would otherwise swing its own beam twice. The
399 enemy still owns the node: :meth:`on_exit_tree` takes it away.
400 """
401 parent = self.parent if self.parent is not None else self
402 beam = parent.add_child(PlaneBeam(name=f"{self.name}{name}", **kwargs))
403 self._ordnance.append(beam)
404 return beam
405
406 def release_ordnance(self) -> None:
407 """Destroy everything this enemy put on the plane. Idempotent."""
408 for node in self._ordnance:
409 if node is not None and not node.destroying:
410 node.destroy()
411 self._ordnance = []
412
413 def on_exit_tree(self):
414 super().on_exit_tree()
415 self.release_ordnance()
416
417 def place(self, plane_point: Vec2) -> None:
418 """Move to *plane_point*, holding the flight plane exactly."""
419 self.position = from_plane(Vec2(float(plane_point.x), float(plane_point.y)))
420
421 def hold_standoff(self, target: Vec2, dt: float, distance: float, band: float) -> None:
422 """Keep *distance* from *target*, strafing sideways once inside the *band*.
423
424 This is what makes a stand-off archetype read as deliberate: it closes
425 when it is too far, backs off when you charge it, and circles when it is
426 happy, rather than oscillating across the ideal range.
427 """
428 offset = self.plane_position - Vec2(float(target.x), float(target.y))
429 current = math.hypot(float(offset.x), float(offset.y))
430 if current <= 1e-6:
431 self.move(dt, Vec2(1.0, 0.0), self.SPEED)
432 return
433 outward = Vec2(float(offset.x) / current, float(offset.y) / current)
434 if current > distance + band:
435 direction = Vec2(-float(outward.x), -float(outward.y))
436 elif current < distance - band:
437 direction = outward
438 else:
439 direction = Vec2(-float(outward.y), float(outward.x))
440 self.move(dt, direction, self.SPEED)
441 self.face(direction)
442
443 def deal_damage_from(self, origin: Vec2, target, amount: float, kind: str) -> None:
444 """Hurt *target* as though the blow came from *origin* rather than this hull.
445
446 A mortar shell landing beside the ship must desaturate the screen edge it
447 actually fell on, not the edge the tube is parked behind, so the blast
448 direction comes from the shell.
449
450 A shell that lands squarely on the hull has no bearing of its own, and a
451 zero direction is the one value every consumer of ``PLAYER_DAMAGED``
452 silently drops: no edge desaturation, no punch, no directional cue at
453 all, on the hit that most deserves one. In that case the bearing falls
454 back to the tube's, which is where the player has to look anyway.
455 """
456 here = Vec2(float(origin.x), float(origin.y))
457 there = to_plane(target.position)
458 bearing_from = seek(here, there)
459 if not float(bearing_from.x) and not float(bearing_from.y):
460 bearing_from = seek(self.plane_position, there)
461 direction = from_plane(bearing_from)
462 router = self.damage_router()
463 if router is not None:
464 router.deal(target, amount, kind=kind, direction=direction, source=self)
465 elif hasattr(target, "apply_damage"):
466 target.apply_damage(amount, direction, kind)
467
468
469# ============================================================================
470# Welder
471# ============================================================================
472
473
474class Welder(AdvancedEnemy):
475 """Support crab that keeps other enemies alive down a visible repair umbilical.
476
477 It never shoots. It picks the most wounded enemy in range, holds station near
478 it and pours :data:`WELDER_REPAIR_PER_S` back into its hull. The link is a
479 line on the flight plane, and flying the ship through that line shears it:
480 the welder emits :attr:`umbilical_broken` and cannot relink for
481 :data:`WELDER_RELINK_DELAY_S`. That is the whole answer to the archetype, and
482 it costs positioning rather than damage.
483 """
484
485 ARCHETYPE = "welder"
486 SPEED = WELDER_SPEED
487 HITBOX_RADIUS = 1.5
488
489 #: ``runtime.SignalNames.UMBILICAL_BROKEN``. Emitted when the ship cuts the link.
490 umbilical_broken = Signal()
491
492 def __init__(self, **kwargs):
493 super().__init__(**kwargs)
494 self.patient: Node3D | None = None
495 self.umbilical: PlaneBeam | None = None
496 self._relink_delay = 0.0
497
498 def build_body(self):
499 self.add_child(artkit.build_enemy("welder"))
500 # The link is the archetype's whole answer, so it has to be a line the
501 # player can see and fly through, not a pair of coordinates.
502 self.umbilical = self.mount_beam("Umbilical", radius=WELDER_UMBILICAL_RADIUS)
503
504 @property
505 def linked(self) -> bool:
506 """Whether an umbilical is currently projected."""
507 return self.patient is not None
508
509 def umbilical_endpoints(self) -> tuple[Vec2, Vec2] | None:
510 """The two plane points the umbilical runs between, or ``None`` when unlinked."""
511 if self.patient is None:
512 return None
513 return self.plane_position, to_plane(self.patient.position)
514
515 def on_update(self, dt: float):
516 super().on_update(dt)
517 if self._relink_delay > 0.0:
518 self._relink_delay -= dt
519 self._drop_invalid_patient()
520 if self.patient is None and self._relink_delay <= 0.0:
521 self.patient = self._choose_patient()
522 if self.patient is None:
523 if self.umbilical is not None:
524 self.umbilical.douse()
525 return
526 if self._ship_cuts_umbilical():
527 self._shear()
528 return
529 if self.umbilical is not None:
530 self.umbilical.draw_between(self.plane_position, to_plane(self.patient.position))
531 # Never below where the patient already is: a welder repairs, and an
532 # aggregate hull such as a shoal reports a pool bigger than its spec.
533 ceiling = max(full_hp(self.patient), float(self.patient.hp))
534 self.patient.hp = min(ceiling, float(self.patient.hp) + WELDER_REPAIR_PER_S * dt)
535
536 def on_fixed_update(self, dt: float):
537 if self.patient is not None:
538 self.hold_standoff(to_plane(self.patient.position), dt, WELDER_TETHER_DISTANCE, 2.0)
539 return
540 ship = self.player_ship()
541 if ship is not None:
542 self.hold_standoff(to_plane(ship.position), dt, WELDER_LOITER_DISTANCE, 4.0)
543
544 # -- internals ----------------------------------------------------------
545
546 def _drop_invalid_patient(self) -> None:
547 patient = self.patient
548 if patient is None:
549 return
550 if patient.destroying or patient.tree is None or float(patient.hp) <= 0.0:
551 self.patient = None
552 return
553 if self.distance_to(patient) > WELDER_UMBILICAL_RANGE:
554 self.patient = None
555
556 def _choose_patient(self) -> Node3D | None:
557 """The most wounded enemy in range, nearest first among equals."""
558 tree = self.tree
559 if tree is None:
560 return None
561 best: Node3D | None = None
562 best_key = (2.0, float("inf"))
563 for node in tree.group(Groups.ENEMIES):
564 if node is self or node.destroying or isinstance(node, Welder):
565 continue
566 if not hasattr(node, "hp") or not hasattr(node, "spec"):
567 continue
568 distance = self.distance_to(node)
569 if distance > WELDER_UMBILICAL_RANGE:
570 continue
571 key = (float(node.hp) / max(full_hp(node), 1e-6), distance)
572 if key < best_key:
573 best, best_key = node, key
574 return best
575
576 def _ship_cuts_umbilical(self) -> bool:
577 ship = self.player_ship()
578 if ship is None or self.patient is None:
579 return False
580 gap = point_to_segment_distance(to_plane(ship.position), self.plane_position, to_plane(self.patient.position))
581 return gap <= WELDER_UMBILICAL_BREAK_RADIUS
582
583 def _shear(self) -> None:
584 self.patient = None
585 self._relink_delay = WELDER_RELINK_DELAY_S
586 if self.umbilical is not None:
587 self.umbilical.douse()
588 self.umbilical_broken()
589
590
591# ============================================================================
592# Bombardier
593# ============================================================================
594
595
596class ReticleMarker(Node3D):
597 """The painted landing circle, drawn: a rim, and a ring closing inside it.
598
599 The rim says *where*, at the blast radius, and never moves. The closer says
600 *when*: it starts on the rim and shrinks toward the impact point as the
601 shell falls, so a glance costs no reading at all. Both are magenta, because
602 everything that can hurt the player is.
603 """
604
605 def __init__(self, *, radius: float, **kwargs):
606 kwargs.setdefault("name", "Reticle")
607 super().__init__(**kwargs)
608 self.radius = float(radius)
609 self._rim: MultiMeshInstance3D | None = None
610 self._closer: MultiMeshInstance3D | None = None
611 self._fraction = 0.0
612
613 @property
614 def closer_radius(self) -> float:
615 """Current radius of the closing ring, in world units."""
616 span = 1.0 - RETICLE_CLOSER_MIN_FRACTION
617 return self.radius * (1.0 - span * self._fraction)
618
619 def on_ready(self):
620 ring = _ring_multimesh()
621 self._rim = self.add_child(
622 MultiMeshInstance3D(
623 name="Rim",
624 multi_mesh=ring,
625 material=enemy_fire_material(alpha=RETICLE_RIM_ALPHA, energy=ENEMY_FIRE_ENERGY * 0.4),
626 scale=Vec3(self.radius, 1.0, self.radius),
627 )
628 )
629 self._closer = self.add_child(
630 MultiMeshInstance3D(
631 name="Closer",
632 multi_mesh=ring,
633 material=enemy_fire_material(alpha=RETICLE_CLOSER_ALPHA),
634 scale=Vec3(self.radius, 1.0, self.radius),
635 )
636 )
637
638 def set_fraction(self, fraction: float) -> None:
639 """Close the ring to *fraction* of the way through the shell's flight."""
640 self._fraction = min(1.0, max(0.0, float(fraction)))
641 if self._closer is not None:
642 radius = self.closer_radius
643 self._closer.scale = Vec3(radius, 1.0, radius)
644
645
646class MortarShell(Node3D):
647 """The round itself: a glowing magenta tracer arcing onto its reticle.
648
649 It carries no collision body and resolves no damage; the reticle it was
650 launched at owns the landing. What it owns is the answer to "what hit me":
651 a shell in the air, on a visible arc, from a visible direction.
652 """
653
654 def __init__(self, *, origin: Vec2, target: Vec2, flight: float, **kwargs):
655 kwargs.setdefault("name", "Shell")
656 super().__init__(**kwargs)
657 self.origin = Vec2(float(origin.x), float(origin.y))
658 self.target = Vec2(float(target.x), float(target.y))
659 self.flight = max(float(flight), 1e-6)
660 self.elapsed = 0.0
661 self.position = Vec3(float(origin.x), PLANE_Y, float(origin.y))
662
663 @property
664 def fraction(self) -> float:
665 """How far along its arc the shell is, 0 at the tube and 1 on impact."""
666 return min(1.0, max(0.0, self.elapsed / self.flight))
667
668 def on_ready(self):
669 self.add_child(
670 MeshInstance3D(
671 name="Tracer",
672 mesh=Mesh.sphere(radius=MORTAR_SHELL_DRAWN_RADIUS, rings=6, segments=8),
673 material=enemy_fire_material(),
674 )
675 )
676
677 def advance(self, dt: float) -> None:
678 """Fly one step of the arc. The bombardier drives this with its reticle."""
679 self.elapsed += dt
680 t = self.fraction
681 self.position = Vec3(
682 float(self.origin.x) + (float(self.target.x) - float(self.origin.x)) * t,
683 PLANE_Y + MORTAR_SHELL_ARC_HEIGHT * 4.0 * t * (1.0 - t),
684 float(self.origin.y) + (float(self.target.y) - float(self.origin.y)) * t,
685 )
686
687
688@dataclass
689class MortarReticle:
690 """One painted landing circle: where a shell falls, and how long until it does.
691
692 ``marker`` and ``shell`` are the geometry the player actually reads. They
693 are optional so a reticle can still be reasoned about in a scene with no
694 tree to mount them in, but in a live run every reticle has both.
695 """
696
697 centre: Vec2
698 radius: float
699 remaining: float
700 flight: float
701 marker: ReticleMarker | None = field(default=None, compare=False)
702 shell: MortarShell | None = field(default=None, compare=False)
703
704 @property
705 def fraction(self) -> float:
706 """How far the paint has filled: 0 when painted, 1 at the moment of impact."""
707 if self.flight <= 0.0:
708 return 1.0
709 return min(1.0, max(0.0, 1.0 - self.remaining / self.flight))
710
711
712class Bombardier(AdvancedEnemy):
713 """Stand-off mortar that shells where you are about to be, never where it is.
714
715 It holds :data:`BOMBARDIER_STANDOFF_DISTANCE` and lobs a salvo of
716 :data:`BOMBARDIER_SALVO_SHELLS`, each painting a reticle on the plane
717 :data:`BOMBARDIER_SHELL_FLIGHT_S` before it lands. The reticles lead the
718 ship's current velocity and walk outward, which is what makes camping a rich
719 deposit expensive: reading the paint and moving costs nothing, ignoring it
720 costs ``spec.damage`` a shell.
721 """
722
723 ARCHETYPE = "bombardier"
724 SPEED = BOMBARDIER_SPEED
725 HITBOX_RADIUS = 1.8
726
727 #: ``(origin, flight_seconds)`` as each shell leaves the tube. Registered as
728 #: ``SignalNames.SHELL_LAUNCHED``; ``audio.py`` plays ``MORTAR_WHISTLE_CUE``
729 #: off it, panned to the origin, so the salvo is audible as well as visible.
730 shell_launched = Signal(Vec3, float)
731
732 def __init__(self, **kwargs):
733 super().__init__(**kwargs)
734 self.reticles: list[MortarReticle] = []
735 self._salvo_cooldown = BOMBARDIER_SALVO_INTERVAL_S
736 self.stagger_cadence()
737
738 def stagger_cadence(self) -> None:
739 """Start somewhere inside the salvo interval, so a battery walks its fire."""
740 self._salvo_cooldown = self.cadence_offset(BOMBARDIER_SALVO_INTERVAL_S)
741
742 def build_body(self):
743 self.add_child(artkit.build_enemy("bombardier"))
744
745 def on_update(self, dt: float):
746 self._resolve_reticles(dt)
747 super().on_update(dt)
748 if self.telegraphing:
749 return
750 self._salvo_cooldown -= dt
751 if self._salvo_cooldown > 0.0:
752 return
753 ship = self.player_ship()
754 if ship is None or self.distance_to(ship) > BOMBARDIER_MAX_RANGE:
755 return
756 if self.begin_attack():
757 self._salvo_cooldown = BOMBARDIER_SALVO_INTERVAL_S
758
759 def on_fixed_update(self, dt: float):
760 ship = self.player_ship()
761 if ship is None:
762 return
763 self.hold_standoff(to_plane(ship.position), dt, BOMBARDIER_STANDOFF_DISTANCE, BOMBARDIER_STANDOFF_BAND)
764
765 def perform_attack(self) -> None:
766 """Paint the salvo's landing circles, leading the ship's current course."""
767 ship = self.player_ship()
768 if ship is None:
769 return
770 origin = to_plane(ship.position)
771 velocity = getattr(ship, "velocity", None)
772 lead = Vec2(0.0, 0.0)
773 if velocity is not None:
774 lead = Vec2(float(velocity.x) * BOMBARDIER_LEAD_S, float(velocity.y) * BOMBARDIER_LEAD_S)
775 for index in range(BOMBARDIER_SALVO_SHELLS):
776 walk = 1.0 + index * 0.5
777 centre = Vec2(
778 float(origin.x) + float(lead.x) * walk + self.rng.uniform(-BOMBARDIER_SCATTER, BOMBARDIER_SCATTER),
779 float(origin.y) + float(lead.y) * walk + self.rng.uniform(-BOMBARDIER_SCATTER, BOMBARDIER_SCATTER),
780 )
781 flight = BOMBARDIER_SHELL_FLIGHT_S + index * BOMBARDIER_SHELL_STAGGER_S
782 reticle = MortarReticle(centre=centre, radius=BOMBARDIER_BLAST_RADIUS, remaining=flight, flight=flight)
783 self._launch(reticle)
784 self.reticles.append(reticle)
785
786 # -- internals ----------------------------------------------------------
787
788 def _launch(self, reticle: MortarReticle) -> None:
789 """Paint *reticle* on the plane and put a shell in the air toward it."""
790 parent = self.parent if self.parent is not None else self
791 reticle.marker = parent.add_child(
792 ReticleMarker(
793 radius=reticle.radius,
794 position=Vec3(float(reticle.centre.x), PLANE_Y + RETICLE_HEIGHT, float(reticle.centre.y)),
795 )
796 )
797 muzzle = self.plane_position
798 reticle.shell = parent.add_child(MortarShell(origin=muzzle, target=reticle.centre, flight=reticle.flight))
799 self.shell_launched(from_plane(muzzle), reticle.flight)
800
801 def _resolve_reticles(self, dt: float) -> None:
802 if not self.reticles:
803 return
804 landed: list[MortarReticle] = []
805 live: list[MortarReticle] = []
806 for reticle in self.reticles:
807 reticle.remaining -= dt
808 if reticle.remaining > 0.0:
809 self._drive_ordnance(reticle, dt)
810 live.append(reticle)
811 else:
812 landed.append(reticle)
813 self.reticles = live
814 for reticle in landed:
815 self._impact(reticle)
816 if not landed:
817 return
818 ship = self.player_ship()
819 if ship is None:
820 return
821 here = to_plane(ship.position)
822 for reticle in landed:
823 gap = math.hypot(float(here.x) - float(reticle.centre.x), float(here.y) - float(reticle.centre.y))
824 if gap <= reticle.radius:
825 self.deal_damage_from(reticle.centre, ship, self.spec.damage, "explosive")
826
827 def _drive_ordnance(self, reticle: MortarReticle, dt: float) -> None:
828 """Close the ring and fly the shell one step along its arc."""
829 if reticle.marker is not None and not reticle.marker.destroying:
830 reticle.marker.set_fraction(reticle.fraction)
831 if reticle.shell is not None and not reticle.shell.destroying:
832 reticle.shell.advance(dt)
833
834 def _impact(self, reticle: MortarReticle) -> None:
835 """Retire a landed shell's geometry and detonate it where it fell."""
836 for node in (reticle.marker, reticle.shell):
837 if node is not None and not node.destroying:
838 node.destroy()
839 reticle.marker = None
840 reticle.shell = None
841 tree = self.tree
842 if tree is None or tree.root is None:
843 return
844 Vfx.spawn(
845 tree,
846 "explosion",
847 Vec3(float(reticle.centre.x), PLANE_Y, float(reticle.centre.y)),
848 faction="enemy",
849 scale=reticle.radius / BOMBARDIER_BLAST_RADIUS,
850 )
851
852 def on_exit_tree(self):
853 """Take any ordnance still in the air with the tube that fired it."""
854 super().on_exit_tree()
855 for reticle in self.reticles:
856 for node in (reticle.marker, reticle.shell):
857 if node is not None and not node.destroying:
858 node.destroy()
859 self.reticles = []
860
861
862# ============================================================================
863# Screamer
864# ============================================================================
865
866
867class Screamer(AdvancedEnemy):
868 """Fragile siren that costs signature rather than hull.
869
870 It aggros when the ship comes inside :data:`SCREAMER_AGGRO_RANGE` or the
871 moment it is hit, then runs. Survive that aggro by
872 ``balance.SCREAMER_AGGRO_TIMER_S`` and it emits :attr:`screamer_screamed`;
873 the signature meter is what turns that into
874 ``balance.SIGNATURE_SCREAMER_SURVIVED``, which is the next wave's budget. It
875 never fires a shot, so ignoring it only ever costs you later.
876 """
877
878 ARCHETYPE = "screamer"
879 SPEED = SCREAMER_SPEED
880 HITBOX_RADIUS = 1.0
881
882 #: ``runtime.SignalNames.SCREAMER_SCREAMED``. Emitted once, if it lives long enough.
883 screamer_screamed = Signal()
884
885 def __init__(self, **kwargs):
886 super().__init__(**kwargs)
887 self.aggroed = False
888 self.screamed = False
889 self._aggro_elapsed = 0.0
890
891 def build_body(self):
892 self.add_child(artkit.build_enemy("screamer"))
893
894 @property
895 def seconds_to_scream(self) -> float:
896 """Time left on the aggro clock: ``inf`` before aggro, 0 once it has screamed."""
897 if self.screamed:
898 return 0.0
899 if not self.aggroed:
900 return float("inf")
901 return max(0.0, balance.SCREAMER_AGGRO_TIMER_S - self._aggro_elapsed)
902
903 def aggro(self) -> None:
904 """Notice the ship and start the clock. Idempotent."""
905 if self.aggroed:
906 return
907 self.aggroed = True
908 self._aggro_elapsed = 0.0
909
910 def take_damage(self, amount: float, kind: str) -> None:
911 """Being shot counts as being noticed, whatever the range."""
912 self.aggro()
913 super().take_damage(amount, kind)
914
915 def on_update(self, dt: float):
916 super().on_update(dt)
917 if not self.aggroed:
918 ship = self.player_ship()
919 if ship is not None and self.distance_to(ship) <= SCREAMER_AGGRO_RANGE:
920 self.aggro()
921 return
922 if self.screamed:
923 return
924 self._aggro_elapsed += dt
925 if self._aggro_elapsed >= balance.SCREAMER_AGGRO_TIMER_S:
926 self.screamed = True
927 self.screamer_screamed()
928
929 def on_fixed_update(self, dt: float):
930 if not self.aggroed:
931 return
932 ship = self.player_ship()
933 if ship is None:
934 return
935 away = self.plane_position - to_plane(ship.position)
936 distance = math.hypot(float(away.x), float(away.y))
937 if distance >= SCREAMER_FLEE_DISTANCE:
938 self.move(dt, Vec2(0.0, 0.0), 0.0)
939 return
940 direction = Vec2(1.0, 0.0) if distance <= 1e-6 else Vec2(float(away.x) / distance, float(away.y) / distance)
941 self.move(dt, direction, self.SPEED)
942 self.face(direction)
943
944
945# ============================================================================
946# Husk turret
947# ============================================================================
948
949
950class HuskTurret(AdvancedEnemy):
951 """A dead hull's gun emplacement: terrain that shoots.
952
953 It cannot move. Idle, the beam oscillates across
954 :data:`HUSK_TURRET_SWEEP_ARC` around its rest heading; with the ship inside
955 :data:`HUSK_TURRET_BEAM_RANGE` it pulls onto the target at
956 :data:`HUSK_TURRET_TRACK_RATE`. Coming on target runs the standard telegraph
957 and only then lights the beam, which damages ``spec.damage`` per
958 :data:`HUSK_TURRET_TICK_S` tick for as long as it stays on you. Breaking the
959 beam's line therefore matters more than out-running it.
960 """
961
962 ARCHETYPE = "husk_turret"
963 SPEED = 0.0
964 HITBOX_RADIUS = 2.2
965
966 def __init__(self, **kwargs):
967 super().__init__(**kwargs)
968 self.rest_heading = 0.0
969 self.beam_heading = 0.0
970 self.beam_active = False
971 self.beam: PlaneBeam | None = None
972 self._barrel: Node3D | None = None
973 self._sweep_phase = 0.0
974 self._tick_accumulator = 0.0
975 self.stagger_cadence()
976
977 def stagger_cadence(self) -> None:
978 """Enter the idle sweep at a random point of its cycle.
979
980 Two turrets covering the same corridor otherwise sweep as one gate that
981 is either open or shut, instead of as two beams a player can time.
982 """
983 self._sweep_phase = self.cadence_offset(2.0 * math.pi)
984
985 def build_body(self):
986 self.add_child(artkit.build_enemy("husk_turret"))
987 # The barrel is a build of its own because it swivels: the drum below
988 # it is a dead hull and never turns.
989 self._barrel = self.add_child(Node3D(name="Barrel", position=Vec3(0.0, HUSK_TURRET_BARREL_HEIGHT, 0.0)))
990 self._barrel.add_child(artkit.build_turret_barrel())
991 self.beam = self.mount_beam("Beam")
992
993 @property
994 def beam_endpoint(self) -> Vec2:
995 """Where the beam terminates on the plane, for the vfx and hud passes."""
996 unit = heading_vector(self.beam_heading)
997 here = self.plane_position
998 return Vec2(
999 float(here.x) + float(unit.x) * HUSK_TURRET_BEAM_RANGE,
1000 float(here.y) + float(unit.y) * HUSK_TURRET_BEAM_RANGE,
1001 )
1002
1003 def on_update(self, dt: float):
1004 super().on_update(dt)
1005 ship = self.player_ship()
1006 if ship is None or self.distance_to(ship) > HUSK_TURRET_BEAM_RANGE:
1007 self._sweep(dt)
1008 self._stand_down()
1009 self._aim_barrel()
1010 return
1011 error = self._track(ship, dt)
1012 self._aim_barrel()
1013 if abs(error) > HUSK_TURRET_BEAM_HALF_ANGLE:
1014 self._stand_down()
1015 return
1016 if not self.beam_active:
1017 self.begin_attack()
1018 return
1019 self._draw_beam()
1020 self._tick_accumulator += dt
1021 while self._tick_accumulator >= HUSK_TURRET_TICK_S:
1022 self._tick_accumulator -= HUSK_TURRET_TICK_S
1023 self.deal_damage_to(ship, self.spec.damage, "beam")
1024
1025 def perform_attack(self) -> None:
1026 """Light the beam and land its first tick; later ticks run from ``on_update``.
1027
1028 The telegraph runs to completion even if the ship slips off the beam, so
1029 the attack re-checks the alignment it was announced for rather than
1030 trusting a decision made a third of a second ago.
1031 """
1032 ship = self.player_ship()
1033 if ship is None or self.distance_to(ship) > HUSK_TURRET_BEAM_RANGE:
1034 return
1035 error = wrap_angle(bearing(self.plane_position, to_plane(ship.position)) - self.beam_heading)
1036 if abs(error) > HUSK_TURRET_BEAM_HALF_ANGLE:
1037 return
1038 self.beam_active = True
1039 self._tick_accumulator = 0.0
1040 self._draw_beam()
1041 self.deal_damage_to(ship, self.spec.damage, "beam")
1042
1043 # -- internals ----------------------------------------------------------
1044
1045 def _draw_beam(self) -> None:
1046 """Light the beam down the heading it is currently damaging along."""
1047 if self.beam is not None:
1048 self.beam.draw_between(self.plane_position, self.beam_endpoint)
1049
1050 def _sweep(self, dt: float) -> None:
1051 self._sweep_phase += HUSK_TURRET_SWEEP_RATE * dt
1052 offset = math.sin(self._sweep_phase) * HUSK_TURRET_SWEEP_ARC * 0.5
1053 self.beam_heading = wrap_angle(self.rest_heading + offset)
1054
1055 def _track(self, ship, dt: float) -> float:
1056 """Turn the beam toward *ship*; returns the residual angular error."""
1057 wanted = bearing(self.plane_position, to_plane(ship.position))
1058 error = wrap_angle(wanted - self.beam_heading)
1059 step = HUSK_TURRET_TRACK_RATE * dt
1060 self.beam_heading = wrap_angle(self.beam_heading + max(-step, min(step, error)))
1061 self.rest_heading = self.beam_heading
1062 self._sweep_phase = 0.0
1063 return wrap_angle(wanted - self.beam_heading)
1064
1065 def _aim_barrel(self) -> None:
1066 if self._barrel is not None:
1067 self._barrel.rotation = Quat.from_euler(0.0, self.beam_heading, 0.0)
1068
1069 def _stand_down(self) -> None:
1070 self.beam_active = False
1071 self._tick_accumulator = 0.0
1072 if self.beam is not None:
1073 self.beam.douse()
1074
1075
1076# ============================================================================
1077# Heralds
1078# ============================================================================
1079
1080
1081class Herald(AdvancedEnemy):
1082 """Shrike-spawn that pours in during the arrival window, previewing the hunter.
1083
1084 Heralds orbit and lance rather than charge, which is the hunter's own attack
1085 language at a survivable scale. Each carries a share of its wave's Fletchings
1086 and emits :attr:`herald_killed` with that share when it dies, so the Roost
1087 ledger banks it without polling.
1088 """
1089
1090 ARCHETYPE = "herald"
1091 SPEED = HERALD_SPEED
1092 HITBOX_RADIUS = 1.4
1093 BODY_COLOUR = HERALD_COLOUR
1094
1095 #: Emitted once on death, carrying the Fletchings this herald was holding.
1096 herald_killed = Signal(int)
1097
1098 def __init__(self, **kwargs):
1099 super().__init__(**kwargs)
1100 self.fletchings = 0
1101 self.lance: PlaneBeam | None = None
1102 self._orbit_phase = 0.0
1103 self._fire_cooldown = HERALD_FIRE_INTERVAL_S
1104 self.stagger_cadence()
1105
1106 def stagger_cadence(self) -> None:
1107 """Spread a flight's lances out, so five heralds are not one volley."""
1108 self._fire_cooldown = self.cadence_offset(HERALD_FIRE_INTERVAL_S)
1109
1110 def build_body(self):
1111 self.add_child(artkit.build_enemy("herald"))
1112 self.lance = self.mount_beam("Lance")
1113
1114 def on_ready(self):
1115 super().on_ready()
1116 self.died.connect(self._announce_drop)
1117
1118 def set_orbit_phase(self, radians: float) -> None:
1119 """Put this herald at a given point of the strafing orbit, so a flight fans out."""
1120 self._orbit_phase = float(radians)
1121
1122 def on_update(self, dt: float):
1123 super().on_update(dt)
1124 if self.telegraphing:
1125 return
1126 self._fire_cooldown -= dt
1127 if self._fire_cooldown > 0.0:
1128 return
1129 ship = self.player_ship()
1130 if ship is not None and self.distance_to(ship) <= HERALD_LANCE_RANGE and self.begin_attack():
1131 self._fire_cooldown = HERALD_FIRE_INTERVAL_S
1132
1133 def on_fixed_update(self, dt: float):
1134 ship = self.player_ship()
1135 if ship is None:
1136 return
1137 self._orbit_phase += HERALD_ORBIT_RATE * dt
1138 centre = to_plane(ship.position)
1139 wanted = Vec2(
1140 float(centre.x) + math.cos(self._orbit_phase) * HERALD_ORBIT_RADIUS,
1141 float(centre.y) + math.sin(self._orbit_phase) * HERALD_ORBIT_RADIUS,
1142 )
1143 here = self.plane_position
1144 direction = seek(here, wanted)
1145 reach = math.hypot(float(wanted.x) - float(here.x), float(wanted.y) - float(here.y))
1146 self.move(dt, direction, min(self.SPEED, reach / max(dt, 1e-6)))
1147 self.face(direction)
1148
1149 def perform_attack(self) -> None:
1150 """Lance the ship, if it is still inside the strafing run's reach.
1151
1152 The bolt is drawn along the line the damage travelled and left up for
1153 :data:`LANCE_TRACER_S`, which is what turns a hull point loss into an
1154 attack the player can locate and answer.
1155 """
1156 ship = self.player_ship()
1157 if ship is None or self.distance_to(ship) > HERALD_LANCE_RANGE:
1158 return
1159 if self.lance is not None:
1160 self.lance.draw_between(self.plane_position, to_plane(ship.position), hold_s=LANCE_TRACER_S)
1161 self.deal_damage_to(ship, self.spec.damage, "energy")
1162
1163 def _announce_drop(self) -> None:
1164 self.herald_killed(int(self.fletchings))
1165
1166
1167def fletching_budget(act: int, runs_since_quill: int, rng: random.Random) -> int:
1168 """Fletchings one herald wave is worth, rising with the drought since your last quill."""
1169 low, high = balance.ACT_DROPS[act].fletchings_per_herald_wave
1170 if high <= 0:
1171 return 0
1172 base = rng.randint(low, high)
1173 rise = 1.0 + balance.FLETCHING_RATE_RISE_PER_RUN * max(0, int(runs_since_quill))
1174 return int(round(base * rise))
1175
1176
1177def share_out(total: int, parts: int, rng: random.Random) -> list[int]:
1178 """Split *total* into *parts* whole shares, scattering the remainder."""
1179 if parts <= 0:
1180 return []
1181 shares = [total // parts] * parts
1182 for index in rng.sample(range(parts), total % parts):
1183 shares[index] += 1
1184 return shares
1185
1186
1187def spawn_herald_flight(
1188 parent: Node3D,
1189 centre: Vec2,
1190 *,
1191 count: int,
1192 act: int = 1,
1193 runs_since_quill: int = 0,
1194 seed: int = 0,
1195 radius: float = HERALD_SPAWN_RADIUS,
1196) -> list[Herald]:
1197 """Tear *count* heralds in on a ring around *centre* and share the wave's Fletchings out.
1198
1199 The wave's whole budget is rolled once from ``balance.ACT_DROPS`` and then
1200 split across the flight, so clearing every herald pays what the design tables
1201 promise however many arrived.
1202 """
1203 if count <= 0:
1204 return []
1205 rng = random.Random(seed)
1206 shares = share_out(fletching_budget(act, runs_since_quill, rng), count, rng)
1207 offset = rng.uniform(0.0, 2.0 * math.pi)
1208
1209 flight: list[Herald] = []
1210 for index in range(count):
1211 angle = offset + index * (2.0 * math.pi / count)
1212 herald = Herald(name=f"Herald{index + 1}")
1213 herald.position = from_plane(
1214 Vec2(float(centre.x) + math.cos(angle) * radius, float(centre.y) + math.sin(angle) * radius)
1215 )
1216 parent.add_child(herald)
1217 herald.set_seed(seed + index)
1218 herald.fletchings = shares[index]
1219 herald.set_orbit_phase(angle)
1220 flight.append(herald)
1221 return flight
1222
1223
1224class HeraldFlight(Node3D):
1225 """Owns the herald flight's arrival window: in at ``t30``, out with the hunter.
1226
1227 Add one to the run scene and it watches the hunter's telegraph ladder. On the
1228 :data:`HERALD_ARRIVAL_STAGE` stage it spawns a flight around the ship and
1229 tallies the Fletchings the flight drops; when the hunter departs, any
1230 survivor leaves with it. The flight is a set-piece rather than a wave, so it
1231 spends none of the wave composer's threat budget.
1232 """
1233
1234 def __init__(self, *, act: int = 1, runs_since_quill: int = 0, seed: int = 0, **kwargs):
1235 super().__init__(**kwargs)
1236 self.act = act
1237 self.runs_since_quill = runs_since_quill
1238 self.seed = seed
1239 self.fletchings_dropped = 0
1240 self.heralds: list[Herald] = []
1241 self._hunter = None
1242 self._rng = random.Random(seed)
1243
1244 def on_update(self, dt: float):
1245 self._attach_to_hunter()
1246 self.heralds = [h for h in self.heralds if not h.destroying and h.tree is not None]
1247
1248 def on_hunter_telegraph(self, stage: str) -> None:
1249 """Spawn the flight when the ladder reaches the arrival window."""
1250 if stage == HERALD_ARRIVAL_STAGE and not self.heralds:
1251 self.spawn()
1252
1253 def on_hunter_departed(self) -> None:
1254 """The flight leaves with the thing that sent it."""
1255 for herald in self.heralds:
1256 if not herald.destroying:
1257 herald.destroy()
1258 self.heralds = []
1259
1260 def spawn(self) -> list[Herald]:
1261 """Tear a flight in around the ship and start counting its Fletchings."""
1262 centre = Vec2(0.0, 0.0)
1263 tree = self.tree
1264 if tree is not None:
1265 for ship in tree.group(Groups.SHIP):
1266 centre = to_plane(ship.position)
1267 break
1268 self.heralds = spawn_herald_flight(
1269 self,
1270 centre,
1271 count=self._rng.randint(HERALD_FLIGHT_MIN, HERALD_FLIGHT_MAX),
1272 act=self.act,
1273 runs_since_quill=self.runs_since_quill,
1274 seed=self.seed,
1275 )
1276 for herald in self.heralds:
1277 herald.herald_killed.connect(self._on_herald_killed)
1278 return self.heralds
1279
1280 # -- internals ----------------------------------------------------------
1281
1282 def _on_herald_killed(self, fletchings: int) -> None:
1283 self.fletchings_dropped += int(fletchings)
1284
1285 def _attach_to_hunter(self) -> None:
1286 tree = self.tree
1287 if self._hunter is not None or tree is None:
1288 return
1289 for hunter in tree.group(Groups.HUNTER):
1290 telegraph = getattr(hunter, "hunter_telegraph", None)
1291 if telegraph is None:
1292 continue
1293 telegraph.connect(self.on_hunter_telegraph)
1294 departed = getattr(hunter, "hunter_departed", None)
1295 if departed is not None:
1296 departed.connect(self.on_hunter_departed)
1297 self._hunter = hunter
1298 return
1299
1300
1301# ============================================================================
1302# Elite modifiers
1303# ============================================================================
1304
1305
1306class EliteModifier:
1307 """A composable wrapper that re-skins any enemy without subclassing it.
1308
1309 Attaching replaces the enemy's bound ``take_damage`` with the modifier's own
1310 and subscribes to its ``died`` signal, so the modifier sees every hit and the
1311 death that ends the chain without the archetype knowing it exists. Modifiers
1312 therefore nest: attach two and the second filters first, and both still get
1313 their :meth:`on_killed`. That is why the same three apply to a mite shoal and
1314 to a herald.
1315 """
1316
1317 #: Matches an entry in ``balance.ELITE_MODIFIERS``.
1318 id: str = ""
1319
1320 def __init__(self):
1321 self.enemy = None
1322 self._inner = None
1323
1324 def attach(self, enemy: Enemy) -> EliteModifier:
1325 """Wrap *enemy*, returning this modifier so callers can hold or chain it.
1326
1327 Attaching normalises ``max_hp`` onto the enemy first. Elites are applied
1328 at spawn, so whatever the archetype is carrying then is its full pool,
1329 and that is the number Armoured scales and Splitting divides, whether the
1330 hull is a single ship or an aggregate such as a mite shoal.
1331 """
1332 enemy.max_hp = float(getattr(enemy, "max_hp", enemy.hp))
1333 self.enemy = enemy
1334 self._inner = enemy.take_damage
1335 enemy.take_damage = self._filtered_take_damage
1336 modifiers = getattr(enemy, "elite_modifiers", None)
1337 if modifiers is None:
1338 modifiers = []
1339 enemy.elite_modifiers = modifiers
1340 modifiers.append(self)
1341 enemy.elite = self.id
1342 enemy.died.connect(self._on_died)
1343 self.on_attach(enemy)
1344 return self
1345
1346 def _filtered_take_damage(self, amount: float, kind: str) -> None:
1347 self._inner(self.filter_damage(float(amount), kind), kind)
1348
1349 def _on_died(self) -> None:
1350 if self.enemy is not None:
1351 self.on_killed(self.enemy)
1352
1353 # -- hooks --------------------------------------------------------------
1354
1355 def on_attach(self, enemy: Enemy) -> None:
1356 """Apply whatever this modifier changes at spawn."""
1357
1358 def filter_damage(self, amount: float, kind: str) -> float:
1359 """Transform an incoming hit before the enemy under this wrapper sees it."""
1360 return amount
1361
1362 def on_killed(self, enemy: Enemy) -> None:
1363 """React to the wrapped enemy running out of hull points."""
1364
1365
1366class Armoured(EliteModifier):
1367 """Plated: a bigger hull pool, and most incoming damage trimmed.
1368
1369 Ballistics were built for plating and lose far less to it than energy weapons
1370 do, so the modifier reads a hit's ``kind`` rather than only its size. That is
1371 the readable answer to an Armoured elite and one of the reasons a run carries
1372 both economies.
1373 """
1374
1375 id = "armoured"
1376
1377 def on_attach(self, enemy: Enemy) -> None:
1378 enemy.max_hp = full_hp(enemy) * ELITE_ARMOURED_HP_MULT
1379 enemy.hp = enemy.max_hp
1380
1381 def filter_damage(self, amount: float, kind: str) -> float:
1382 reduction = ELITE_ARMOURED_BALLISTIC_REDUCTION if kind == "ballistic" else ELITE_ARMOURED_REDUCTION
1383 return amount * (1.0 - reduction)
1384
1385
1386class Splitting(EliteModifier):
1387 """Comes apart: dying spawns :data:`ELITE_SPLIT_COUNT` smaller copies.
1388
1389 The halves are the same archetype at :data:`ELITE_SPLIT_HP_FRACTION` of the
1390 parent's pool and carry no modifier of their own, so a split never cascades
1391 and the threat budget stays honest.
1392 """
1393
1394 id = "splitting"
1395
1396 def on_killed(self, enemy: Enemy) -> None:
1397 parent = enemy.parent
1398 if parent is None or enemy.tree is None:
1399 return
1400 rng = getattr(enemy, "rng", None) or random.Random()
1401 pool = full_hp(enemy) * ELITE_SPLIT_HP_FRACTION
1402 here = to_plane(enemy.position)
1403 spread = rng.uniform(0.0, 2.0 * math.pi)
1404 for index in range(ELITE_SPLIT_COUNT):
1405 angle = spread + index * (2.0 * math.pi / ELITE_SPLIT_COUNT)
1406 half = type(enemy)(name=f"{enemy.name}Split{index + 1}")
1407 half.position = Vec3(
1408 float(here.x) + math.cos(angle) * ELITE_SPLIT_SCATTER,
1409 PLANE_Y,
1410 float(here.y) + math.sin(angle) * ELITE_SPLIT_SCATTER,
1411 )
1412 parent.add_child(half)
1413 half.max_hp = pool
1414 half.hp = pool
1415 half.elite = None
1416
1417
1418class EmpLaced(EliteModifier):
1419 """Wired to burst: dying dumps the ship's capacitor if it is standing close.
1420
1421 The drain runs through :class:`PowerSystem` like any other spend, so a fat
1422 capacitor, a running generator and silent running all change what the burst
1423 actually costs.
1424 """
1425
1426 id = "emp_laced"
1427
1428 def on_killed(self, enemy: Enemy) -> None:
1429 tree = enemy.tree
1430 if tree is None:
1431 return
1432 power = tree.singletons.get(Services.POWER)
1433 ship = next((node for node in tree.group(Groups.SHIP) if not node.destroying), None)
1434 if power is None or ship is None:
1435 return
1436 here = to_plane(enemy.position)
1437 there = to_plane(ship.position)
1438 if math.hypot(float(there.x) - float(here.x), float(there.y) - float(here.y)) > ELITE_EMP_RADIUS:
1439 return
1440 drained = min(ELITE_EMP_CAPACITOR_DRAIN, float(power.capacitor))
1441 if drained > 0.0:
1442 power.request(drained, "elite_emp")
1443
1444
1445#: Every elite modifier in ``balance.ELITE_MODIFIERS``, by id.
1446ELITE_MODIFIER_TYPES: dict[str, type[EliteModifier]] = {
1447 Armoured.id: Armoured,
1448 Splitting.id: Splitting,
1449 EmpLaced.id: EmpLaced,
1450}
1451
1452
1453def apply_elite(enemy: Enemy, modifier_id: str) -> EliteModifier:
1454 """Wrap *enemy* in the named modifier and return the wrapper.
1455
1456 Raises :class:`KeyError` for an id outside ``balance.ELITE_MODIFIERS``: a
1457 typo that silently produced a plain enemy is the kind of bug a wave composer
1458 hides for weeks.
1459 """
1460 modifier_type = ELITE_MODIFIER_TYPES.get(modifier_id)
1461 if modifier_type is None:
1462 known = ", ".join(sorted(ELITE_MODIFIER_TYPES))
1463 raise KeyError(f"unknown elite modifier {modifier_id!r}; expected one of {known}")
1464 return modifier_type().attach(enemy)
1465
1466
1467#: The advanced archetypes by their ``balance.ENEMIES`` id.
1468ARCHETYPES: dict[str, type[AdvancedEnemy]] = {
1469 Welder.ARCHETYPE: Welder,
1470 Bombardier.ARCHETYPE: Bombardier,
1471 Screamer.ARCHETYPE: Screamer,
1472 HuskTurret.ARCHETYPE: HuskTurret,
1473 Herald.ARCHETYPE: Herald,
1474}