shrike/enemies/steering.py¶
Part of SHRIKE.
1"""Flow-field steering and the shared enemy base.
2
3Every hostile in SHRIKE moves the same way. A :class:`FlowField` turns the
4flight plane into a grid of unit vectors that all point down the cheapest path
5to one target, so a shoal of twenty mites costs one field rebuild rather than
6twenty pathfinding queries, and a wreck field bends the whole swarm around it
7instead of pinning individuals against its hull. :class:`Enemy` is the base
8every archetype derives from: it owns hull points, the elite tag, the group and
9collision-layer registration, and the universal telegraph.
10
11The telegraph convention
12========================
13
14**No enemy in the game attacks without flashing white first.** The base class
15owns that promise: :meth:`Enemy.begin_attack` flashes the body white for
16:attr:`Enemy.telegraph_duration` seconds (``balance.ENEMY_FIRE_TELEGRAPH_S``
17for everything except the Lancer's longer dash wind-up) and only then calls
18:meth:`Enemy.perform_attack`. Archetypes override ``perform_attack``; they do
19not schedule attacks of their own. The flash is the readability contract from
20the art direction: player fire is warm gold, enemy fire is cold magenta, and
21white belongs to telegraphs alone.
22
23Every wound shows
24=================
25
26A hull that takes nine seconds of fire and gives nothing back reads as
27invulnerable, whatever the number underneath it is doing, so the feel budget
28cannot all belong to kills. :meth:`Enemy.take_damage` now answers every hit.
29The struck body pulses white for :data:`HIT_FLASH_S` on its emissive channel
30(the telegraph keeps the albedo, so the two never fight), a spark goes off
31where the round came apart, and a hull big enough to be worth the question
32(:data:`HP_PIP_MIN_HP` and up) grows a :class:`~shrike.vfx.HealthPip` on its
33first wound that fades away again at full or at zero.
34
35Contact has weight
36==================
37
38An enemy that touches the hull calls :meth:`Enemy.contact_damage`, never
39:meth:`Enemy.deal_damage_to`. Contact resolves through the damage router's
40contact path: the attacker's drive dies for ``balance.CONTACT_STALL_S`` and
41both hulls take a radial impulse apart. The stall and the recoil are honoured
42by :meth:`Enemy.move`, so an archetype gets the collision for free by steering
43the way every archetype already steers, and nothing glides through the ship.
44
45Staggered cadences
46==================
47
48Every archetype that attacks on a timer seeds that timer from
49:meth:`Enemy.stagger_cadence`, so two Lancers that arrive on the same frame do
50not wind up, dash and recover in lockstep for the rest of the fight. The offset
51is drawn from a stream of its own rather than from :attr:`Enemy.rng`, because a
52seeded spawn has to stay reproducible whatever the cadence does.
53"""
54
55from __future__ import annotations
56
57import math
58import random
59
60import numpy as np
61
62from simvx.core import Area3D, Material, Node3D, Quat, Signal, SphereShape3D, Vec2, Vec3
63
64from .. import balance
65from ..combat import separate_on_contact
66from ..runtime import PLANE_Y, Groups, Layers, Services, from_plane, to_plane
67from ..vfx import HealthPip, Vfx
68
69# ============================================================================
70# Steering constants
71#
72# These are movement-feel numbers rather than balance numbers: they describe
73# how the grid is discretised and how strongly geometry repels a swarm, not how
74# hard anything hits. balance.py carries no equivalents.
75# ============================================================================
76
77#: Side length of one flow-field cell, in world units.
78FLOW_CELL_SIZE = 8.0
79#: Default playfield the field covers when a caller does not supply bounds:
80#: the 60-unit sector (``sector.SECTOR_RADIUS``) with a margin for spawns that
81#: approach from off the edge.
82DEFAULT_FIELD_BOUNDS = (-120.0, -120.0, 120.0, 120.0)
83#: Extra traversal cost charged to a cell covered by an obstacle. High enough
84#: that a swarm flows around a wreck, finite so nothing is ever unreachable.
85OBSTACLE_CELL_COST = 24.0
86#: Radius around an obstacle point whose cells carry the extra cost.
87OBSTACLE_RADIUS = 10.0
88#: How often the scene-wide field is re-integrated toward the player ship.
89FLOW_REBUILD_INTERVAL_S = 0.25
90#: Group holding the scene's shared :class:`FlowFieldDirector`, if one exists.
91FLOW_FIELD_GROUP = "flow_field"
92
93#: Telegraph colour. White is reserved for telegraphs across the whole game.
94TELEGRAPH_COLOUR = (1.0, 1.0, 1.0, 1.0)
95
96# ----------------------------------------------------------------------------
97# Damage feedback
98#
99# A hit that produces no picture is a hit the player does not believe in. Three
100# things say "that landed", and between them they cover every scale of hull:
101# a white pulse on the body, a spark where the round came apart, and, for the
102# hulls big enough that the honest question is "how much further", a pip.
103# ----------------------------------------------------------------------------
104
105#: Length of the white emissive pulse a hit puts on a struck hull, seconds.
106#: Short on purpose: long enough to register at 60 fps, too short to wash the
107#: hull's own colour out under sustained fire.
108HIT_FLASH_S = 0.08
109#: Emissive strength the pulse drives the struck materials to.
110HIT_FLASH_STRENGTH = 6.0
111#: The pulse's colour. White, like every other "pay attention" signal.
112HIT_FLASH_EMISSIVE = (1.0, 1.0, 1.0)
113#: Shortest gap between two impact sparks on one hull. A beam lands a hit every
114#: frame, and sixty sparks a second is both an empty effect pool and a solid
115#: block of light where a stream of them should be.
116HIT_SPARK_INTERVAL_S = 0.06
117#: Smallest hull pool worth a health pip: the set the design names outright
118#: (bombardiers, husk turrets, wardens, magistrates) and anything an Armoured
119#: elite has plated up past them. Below it a hull dies inside a burst, and a
120#: read-out on it would be chrome the player never has time to read.
121HP_PIP_MIN_HP = 90.0
122#: How far above the flight plane the pip rides, world units.
123HP_PIP_HEIGHT = 1.9
124#: How far toward the top of the screen (-Z) the pip is offset from the hull.
125HP_PIP_OFFSET_Z = -1.6
126
127_LARGE_COST = np.float32(1e9)
128_SQRT2 = math.sqrt(2.0)
129
130#: Neighbour offsets as ``(row delta, column delta)``, rows being +Z and
131#: columns +X, in the order the relaxation stacks them.
132_NEIGHBOURS = ((-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1))
133
134
135class FlowField:
136 """A grid of unit vectors over the flight plane, all pointing at one target.
137
138 Rebuilding integrates a cost-to-go field outward from the target cell with
139 an eight-connected relaxation, charging obstacles extra to enter, then
140 stores the descent direction per cell. Sampling is bilinear, so a body
141 crossing a cell boundary turns smoothly rather than snapping.
142
143 Positions and directions are plane coordinates, ``Vec2(x, z)``. Bounds are
144 ``(min_x, min_z, max_x, max_z)``; a sample from outside the bounds falls
145 back to a straight line toward the target, which walks a stray body back
146 into the field rather than stalling it at the edge.
147 """
148
149 def __init__(self, bounds: tuple[float, float, float, float], cell: float = FLOW_CELL_SIZE):
150 min_x, min_z, max_x, max_z = (float(b) for b in bounds)
151 if max_x <= min_x or max_z <= min_z:
152 raise ValueError(f"FlowField bounds must be (min_x, min_z, max_x, max_z) with positive extent: {bounds}")
153 if cell <= 0.0:
154 raise ValueError(f"FlowField cell size must be positive, got {cell}")
155 self.bounds = (min_x, min_z, max_x, max_z)
156 self.cell = float(cell)
157 self.columns = max(1, int(math.ceil((max_x - min_x) / self.cell)))
158 self.rows = max(1, int(math.ceil((max_z - min_z) / self.cell)))
159 self.target = Vec2(0.0, 0.0)
160 self._flow = np.zeros((self.rows, self.columns, 2), dtype=np.float32)
161 self._cost_to_go = np.zeros((self.rows, self.columns), dtype=np.float32)
162 self._built = False
163 columns_x = min_x + (np.arange(self.columns, dtype=np.float32) + 0.5) * self.cell
164 rows_z = min_z + (np.arange(self.rows, dtype=np.float32) + 0.5) * self.cell
165 self._centre_x = np.tile(columns_x, (self.rows, 1))
166 self._centre_z = np.repeat(rows_z[:, None], self.columns, axis=1)
167
168 # -- Queries ------------------------------------------------------------
169
170 @property
171 def built(self) -> bool:
172 """Whether :meth:`rebuild` has run at least once."""
173 return self._built
174
175 @property
176 def cost_to_go(self) -> np.ndarray:
177 """The integrated cost field, ``(rows, columns)``, in world units."""
178 return self._cost_to_go
179
180 def contains(self, position: Vec2) -> bool:
181 """Whether a plane position lies inside the field's bounds."""
182 min_x, min_z, max_x, max_z = self.bounds
183 return min_x <= float(position.x) <= max_x and min_z <= float(position.y) <= max_z
184
185 def targets(self, point: Vec2, tolerance: float | None = None) -> bool:
186 """Whether the field is currently integrated toward *point*.
187
188 Enemies use this to decide whether the shared field answers the
189 question they are asking: one that chases something other than the
190 field's target steers directly instead.
191 """
192 if not self._built:
193 return False
194 limit = self.cell if tolerance is None else float(tolerance)
195 return math.hypot(float(point.x) - float(self.target.x), float(point.y) - float(self.target.y)) <= limit
196
197 # -- Rebuild ------------------------------------------------------------
198
199 def rebuild(self, target: Vec2, obstacles: list[Vec2]) -> None:
200 """Re-integrate the field toward *target*, charging *obstacles* extra.
201
202 Obstacles are plane points; every cell within :data:`OBSTACLE_RADIUS`
203 of one costs :data:`OBSTACLE_CELL_COST` more to enter. The cost stays
204 finite, so a body walled in by geometry still finds a way out instead
205 of freezing on an unreachable cell.
206 """
207 self.target = Vec2(float(target.x), float(target.y))
208 enter_cost = np.ones((self.rows, self.columns), dtype=np.float32)
209 radius_sq = OBSTACLE_RADIUS * OBSTACLE_RADIUS
210 for obstacle in obstacles:
211 dx = self._centre_x - float(obstacle.x)
212 dz = self._centre_z - float(obstacle.y)
213 enter_cost += np.where(dx * dx + dz * dz < radius_sq, np.float32(OBSTACLE_CELL_COST), np.float32(0.0))
214
215 row = self._row_of(float(target.y))
216 column = self._column_of(float(target.x))
217 costs = np.full((self.rows, self.columns), _LARGE_COST, dtype=np.float32)
218 costs[row, column] = 0.0
219 step = np.array([self.cell * (_SQRT2 if dr and dc else 1.0) for dr, dc in _NEIGHBOURS], dtype=np.float32)
220
221 for _ in range(self.rows + self.columns + 2):
222 candidates = self._neighbour_stack(costs) + step[:, None, None] * enter_cost[None, :, :]
223 relaxed = np.minimum(costs, candidates.min(axis=0))
224 if np.array_equal(relaxed, costs):
225 break
226 costs = relaxed
227
228 self._cost_to_go = costs
229 self._flow = self._descent_directions(costs)
230 self._built = True
231
232 def _neighbour_stack(self, costs: np.ndarray) -> np.ndarray:
233 """The eight shifted copies of *costs*, padded with an unreachable cost."""
234 padded = np.pad(costs, 1, mode="constant", constant_values=_LARGE_COST)
235 rows, columns = costs.shape
236 return np.stack(
237 [padded[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + columns] for dr, dc in _NEIGHBOURS],
238 axis=0,
239 )
240
241 def _descent_directions(self, costs: np.ndarray) -> np.ndarray:
242 """Per-cell unit vector toward the cheapest neighbour."""
243 stack = self._neighbour_stack(costs)
244 best = np.argmin(stack, axis=0)
245 offsets = np.array([(dc, dr) for dr, dc in _NEIGHBOURS], dtype=np.float32)
246 lengths = np.hypot(offsets[:, 0], offsets[:, 1])
247 unit = offsets / lengths[:, None]
248 flow = unit[best]
249 reachable = stack.min(axis=0) < _LARGE_COST * 0.5
250 flow[~reachable] = 0.0
251 flow[costs <= 0.0] = 0.0
252 return flow.astype(np.float32)
253
254 # -- Sampling -----------------------------------------------------------
255
256 def sample(self, position: Vec2) -> Vec2:
257 """The unit steering direction at a plane position."""
258 result = self.sample_many(np.asarray([[float(position.x), float(position.y)]], dtype=np.float32))
259 return Vec2(float(result[0, 0]), float(result[0, 1]))
260
261 def sample_many(self, positions: np.ndarray) -> np.ndarray:
262 """Sample an ``(N, 2)`` array of plane positions in one pass.
263
264 Returns an ``(N, 2)`` array of unit directions (zero where a body is
265 already on the target). This is the path the mite shoal takes: one
266 vectorised gather for the whole swarm.
267
268 The field resolves navigation at cell granularity, so it can only ever
269 aim a body at the target's cell rather than at the target itself. Inside
270 the last cell the sample steers straight at the target instead, which is
271 what lets a shoal close the final few units onto a ship rather than
272 settling into a ring around the cell centre.
273 """
274 points = np.asarray(positions, dtype=np.float32).reshape(-1, 2)
275 direct = self._direct_to_target(points)
276 if not self._built:
277 return direct
278
279 min_x, min_z, max_x, max_z = self.bounds
280 inside = (points[:, 0] >= min_x) & (points[:, 0] <= max_x) & (points[:, 1] >= min_z) & (points[:, 1] <= max_z)
281 offset = np.asarray([float(self.target.x), float(self.target.y)], dtype=np.float32) - points
282 inside &= np.hypot(offset[:, 0], offset[:, 1]) > self.cell
283 u = (points[:, 0] - min_x) / self.cell - 0.5
284 v = (points[:, 1] - min_z) / self.cell - 0.5
285 column0 = np.floor(u).astype(np.int32)
286 row0 = np.floor(v).astype(np.int32)
287 fu = (u - column0)[:, None]
288 fv = (v - row0)[:, None]
289 c0 = np.clip(column0, 0, self.columns - 1)
290 c1 = np.clip(column0 + 1, 0, self.columns - 1)
291 r0 = np.clip(row0, 0, self.rows - 1)
292 r1 = np.clip(row0 + 1, 0, self.rows - 1)
293 top = self._flow[r0, c0] * (1.0 - fu) + self._flow[r0, c1] * fu
294 bottom = self._flow[r1, c0] * (1.0 - fu) + self._flow[r1, c1] * fu
295 blended = top * (1.0 - fv) + bottom * fv
296
297 lengths = np.hypot(blended[:, 0], blended[:, 1])
298 usable = inside & (lengths > 1e-6)
299 out = direct.copy()
300 out[usable] = blended[usable] / lengths[usable, None]
301 return out.astype(np.float32)
302
303 def _direct_to_target(self, points: np.ndarray) -> np.ndarray:
304 offset = np.asarray([float(self.target.x), float(self.target.y)], dtype=np.float32) - points
305 lengths = np.hypot(offset[:, 0], offset[:, 1])
306 out = np.zeros_like(offset)
307 moving = lengths > 1e-6
308 out[moving] = offset[moving] / lengths[moving, None]
309 return out
310
311 def _row_of(self, z: float) -> int:
312 min_z = self.bounds[1]
313 return int(min(max(int((z - min_z) / self.cell), 0), self.rows - 1))
314
315 def _column_of(self, x: float) -> int:
316 min_x = self.bounds[0]
317 return int(min(max(int((x - min_x) / self.cell), 0), self.columns - 1))
318
319
320# ============================================================================
321# Steering helpers
322# ============================================================================
323
324
325def seek(position: Vec2, target: Vec2) -> Vec2:
326 """Unit vector from *position* toward *target*, zero when they coincide."""
327 dx = float(target.x) - float(position.x)
328 dz = float(target.y) - float(position.y)
329 length = math.hypot(dx, dz)
330 if length < 1e-6:
331 return Vec2(0.0, 0.0)
332 return Vec2(dx / length, dz / length)
333
334
335def limit(vector: Vec2, maximum: float) -> Vec2:
336 """Clamp a plane vector's magnitude to *maximum*."""
337 length = math.hypot(float(vector.x), float(vector.y))
338 if length <= maximum or length < 1e-9:
339 return Vec2(float(vector.x), float(vector.y))
340 scale = maximum / length
341 return Vec2(float(vector.x) * scale, float(vector.y) * scale)
342
343
344def separation(positions: np.ndarray, radius: float) -> np.ndarray:
345 """Per-body push-apart vectors for a swarm of ``(N, 2)`` plane positions.
346
347 Each body is pushed away from every neighbour inside *radius*, weighted by
348 how deeply the pair overlaps. The result is unnormalised so a body with one
349 distant neighbour barely deviates while one buried in the shoal squirts
350 out, which is what makes a shoal read as liquid rather than as a grid.
351 """
352 points = np.asarray(positions, dtype=np.float32).reshape(-1, 2)
353 count = points.shape[0]
354 if count < 2 or radius <= 0.0:
355 return np.zeros_like(points)
356 delta = points[:, None, :] - points[None, :, :]
357 distance = np.hypot(delta[:, :, 0], delta[:, :, 1])
358 np.fill_diagonal(distance, np.float32(np.inf))
359 weight = np.clip(1.0 - distance / radius, 0.0, 1.0)
360 safe = np.where(distance > 1e-6, distance, np.float32(1.0))
361 push = delta / safe[:, :, None] * weight[:, :, None]
362 return push.sum(axis=1).astype(np.float32)
363
364
365# ============================================================================
366# The shared enemy base
367# ============================================================================
368
369
370class Enemy(Node3D):
371 """Base for every hostile: hull points, the elite tag, and the telegraph.
372
373 Subclasses set :attr:`ARCHETYPE` to a key of ``balance.ENEMIES``, build
374 their body in :meth:`build_body`, drive movement from
375 ``on_fixed_update`` and implement :meth:`perform_attack`. They never call
376 ``perform_attack`` directly: :meth:`begin_attack` runs the white telegraph
377 flash first and fires the attack when it ends.
378 """
379
380 #: Key into ``balance.ENEMIES``; subclasses must set it.
381 ARCHETYPE: str = ""
382 #: Radius of the sensor hitbox other systems collide against. None builds
383 #: no hitbox, for an archetype whose hulls are numpy rows rather than nodes.
384 HITBOX_RADIUS: float | None = 1.5
385 #: Body colour before any telegraph flash.
386 BODY_COLOUR: tuple[float, float, float, float] = (0.55, 0.20, 0.42, 1.0)
387 #: Length of this archetype's telegraph flash, seconds.
388 telegraph_duration: float = balance.ENEMY_FIRE_TELEGRAPH_S
389
390 #: Fires when the white flash starts, with this enemy's archetype id.
391 telegraph_started = Signal(str)
392 #: Fires once when hull points run out, before the node leaves the tree.
393 died = Signal()
394
395 def __init__(self, *, elite: str | None = None, seed: int | None = None, **kwargs):
396 super().__init__(**kwargs)
397 if elite is not None and elite not in balance.ELITE_MODIFIERS:
398 raise ValueError(f"unknown elite modifier {elite!r}; expected one of {balance.ELITE_MODIFIERS}")
399 self.spec: balance.EnemySpec = balance.ENEMIES[self.ARCHETYPE]
400 self.hp: float = float(self.spec.hp)
401 self.elite: str | None = elite
402 self.velocity = Vec2(0.0, 0.0)
403 self.flow_field: FlowField | None = None
404 self.telegraph_remaining: float = 0.0
405 self.stall_remaining: float = 0.0
406 self.rng = random.Random(seed)
407 self._phase_rng = random.Random(None if seed is None else f"{seed}:cadence")
408 self._recoil = Vec2(0.0, 0.0)
409 self._attack_pending = False
410 self._materials: list[Material] = []
411 self._base_colours: list[tuple[float, ...]] = []
412 self._base_emissive: list[tuple[float, ...] | None] = []
413 self.hit_flash_remaining: float = 0.0
414 self.spark_cooldown: float = 0.0
415 self.pip: HealthPip | None = None
416
417 def set_seed(self, seed: int) -> None:
418 """Reseed this enemy's randomness so a spawn is reproducible per node.
419
420 Re-rolls the attack phase as well: the wave composer seeds a node after
421 it has been added, and a stagger drawn before that would make the fight
422 depend on construction order rather than on the seed.
423 """
424 self.rng = random.Random(seed)
425 self._phase_rng = random.Random(f"{seed}:cadence")
426 self.stagger_cadence()
427
428 def stagger_cadence(self) -> None:
429 """Offset this archetype's attack timer by a random slice of its cadence.
430
431 Overridden by every archetype that attacks on a timer, and called both
432 at construction and on :meth:`set_seed`. Without it a wave that spawns
433 three Lancers together gets one Lancer three times over: the same
434 wind-up, the same dash and the same recovery frame, which reads as a
435 scripted volley rather than as three ships.
436 """
437
438 def cadence_offset(self, interval: float) -> float:
439 """A random point inside *interval*, for seeding one cadence timer."""
440 return float(interval) * self._phase_rng.random()
441
442 # -- Lifecycle ----------------------------------------------------------
443
444 def on_enter_tree(self):
445 super().on_enter_tree()
446 self.add_to_group(Groups.ENEMIES)
447
448 def on_ready(self):
449 self.position = Vec3(float(self.position.x), PLANE_Y, float(self.position.z))
450 self.build_body()
451 self._collect_materials()
452 if self.HITBOX_RADIUS is not None:
453 hitbox = self.add_child(Area3D(name="Hitbox", shape=SphereShape3D(radius=self.HITBOX_RADIUS)))
454 hitbox.collision_layer = Layers.ENEMY
455 hitbox.collision_mask = Layers.MASK_ENEMY
456 self.flow_field = shared_flow_field(self)
457
458 def build_body(self):
459 """Add the archetype's visible geometry. Override in subclasses."""
460
461 def _collect_materials(self) -> None:
462 """Cache every body material and the look the telegraph and the flash restore."""
463 self._materials = []
464 self._base_colours = []
465 self._base_emissive = []
466 for node in self.walk():
467 material = getattr(node, "material", None)
468 if isinstance(material, Material):
469 self._materials.append(material)
470 self._base_colours.append(tuple(float(c) for c in material.colour))
471 emissive = material.emissive_colour
472 self._base_emissive.append(None if emissive is None else tuple(float(c) for c in emissive))
473
474 # -- Movement -----------------------------------------------------------
475
476 @property
477 def plane_position(self) -> Vec2:
478 """This enemy's position on the flight plane."""
479 return to_plane(self.position)
480
481 def steer_toward(self, target: Vec2) -> Vec2:
482 """Unit steering direction toward *target*.
483
484 Uses the shared flow field when it is integrated toward the same place,
485 so a whole wave bends around the same wreck; otherwise steers directly,
486 which is what a Skimmer chasing salvage needs.
487 """
488 field = self.flow_field
489 position = self.plane_position
490 if field is not None and field.targets(target):
491 direction = field.sample(position)
492 if float(direction.x) or float(direction.y):
493 return direction
494 return seek(position, target)
495
496 def move(self, dt: float, direction: Vec2, speed: float) -> None:
497 """Integrate one step along *direction*, staying exactly on the plane.
498
499 The drive is whatever the archetype asked for, unless a contact hit has
500 stalled it, in which case there is no drive at all for the rest of
501 ``balance.CONTACT_STALL_S``. On top of that sits the separation recoil
502 from the last collision, bleeding off at
503 ``balance.CONTACT_RECOIL_DAMPING``: that is what carries a hostile back
504 off the hull it just hit rather than through it.
505 """
506 drive_x = 0.0 if self.stalled else float(direction.x) * speed
507 drive_z = 0.0 if self.stalled else float(direction.y) * speed
508 vx = drive_x + float(self._recoil.x)
509 vz = drive_z + float(self._recoil.y)
510 self.velocity = Vec2(vx, vz)
511 self.position = Vec3(
512 float(self.position.x) + vx * dt,
513 PLANE_Y,
514 float(self.position.z) + vz * dt,
515 )
516 self.decay_recoil(dt)
517
518 # -- Contact ------------------------------------------------------------
519
520 @property
521 def stalled(self) -> bool:
522 """Whether a contact hit has this enemy's drive shut down."""
523 return self.stall_remaining > 0.0
524
525 def stall(self, seconds: float) -> None:
526 """Kill this enemy's drive for *seconds*. Never shortens a live stall."""
527 self.stall_remaining = max(self.stall_remaining, float(seconds))
528
529 def apply_contact_impulse(self, direction: Vec3, speed: float) -> None:
530 """Add a separation velocity along a world *direction* at *speed*.
531
532 Kept apart from :attr:`velocity` because an archetype rewrites its
533 velocity from scratch every step; the recoil has to survive that pass or
534 the collision would last exactly one frame.
535 """
536 self._recoil = Vec2(
537 float(self._recoil.x) + float(direction.x) * float(speed),
538 float(self._recoil.y) + float(direction.z) * float(speed),
539 )
540
541 def decay_recoil(self, dt: float) -> None:
542 """Bleed the separation recoil off exponentially."""
543 if not float(self._recoil.x) and not float(self._recoil.y):
544 return
545 keep = math.exp(-balance.CONTACT_RECOIL_DAMPING * dt)
546 self._recoil = Vec2(float(self._recoil.x) * keep, float(self._recoil.y) * keep)
547
548 def contact_damage(self, target, amount: float, kind: str = "impact") -> None:
549 """Hurt *target* by touching it, and take the collision on the chin.
550
551 The router owns contact when there is one, so the shield arc, the
552 matrix and the run's damage ledger all still apply. Without a router the
553 damage falls back to the target's own entry point and the separation is
554 applied here, so an enemy in a bare scene is still a body rather than a
555 ghost.
556 """
557 router = self.damage_router()
558 resolve = getattr(router, "resolve_contact", None)
559 if callable(resolve):
560 resolve(self, target, amount, kind=kind)
561 return
562 self.deal_damage_to(target, amount, kind)
563 separate_on_contact(self, target)
564
565 def face(self, direction: Vec2) -> None:
566 """Point the hull's nose along a plane direction."""
567 if abs(float(direction.x)) < 1e-6 and abs(float(direction.y)) < 1e-6:
568 return
569 self.rotation = Quat.from_euler(0.0, math.atan2(-float(direction.y), float(direction.x)), 0.0)
570
571 def distance_to(self, node) -> float:
572 """Plane distance from this enemy to another node."""
573 return math.hypot(
574 float(node.position.x) - float(self.position.x),
575 float(node.position.z) - float(self.position.z),
576 )
577
578 # -- The telegraph ------------------------------------------------------
579
580 @property
581 def telegraphing(self) -> bool:
582 """Whether the white pre-attack flash is running."""
583 return self.telegraph_remaining > 0.0
584
585 def begin_attack(self) -> bool:
586 """Telegraph now and attack when the flash ends.
587
588 Returns False when a telegraph or attack is already in flight, so an
589 archetype can call this every frame while a condition holds.
590 """
591 if self.telegraphing or self._attack_pending:
592 return False
593 self._attack_pending = True
594 self.on_telegraph()
595 return True
596
597 def on_telegraph(self) -> None:
598 """Flash the body white for :attr:`telegraph_duration` seconds."""
599 self.telegraph_remaining = float(self.telegraph_duration)
600 self.apply_telegraph_colour(True)
601 self.telegraph_started(self.ARCHETYPE)
602
603 def apply_telegraph_colour(self, active: bool) -> None:
604 """Switch the body between its own colour and the telegraph white."""
605 for material, base in zip(self._materials, self._base_colours, strict=True):
606 material.colour = TELEGRAPH_COLOUR if active else base
607
608 def perform_attack(self) -> None:
609 """Land the attack the telegraph announced. Override in subclasses."""
610
611 def on_update(self, dt: float):
612 if self.stall_remaining > 0.0:
613 self.stall_remaining = max(0.0, self.stall_remaining - dt)
614 self._tick_hit_flash(dt)
615 if self.telegraph_remaining <= 0.0:
616 return
617 self.telegraph_remaining -= dt
618 if self.telegraph_remaining > 0.0:
619 return
620 self.telegraph_remaining = 0.0
621 self.apply_telegraph_colour(False)
622 if self._attack_pending:
623 self._attack_pending = False
624 self.perform_attack()
625
626 # -- Damage -------------------------------------------------------------
627
628 def take_damage(self, amount: float, kind: str) -> None:
629 """Subtract hull points, show the hit, and die at zero."""
630 if self.destroying or amount <= 0.0:
631 return
632 self.hp -= float(amount)
633 self.show_hit()
634 if self.hp <= 0.0:
635 self.hp = 0.0
636 self.kill()
637 return
638 self.refresh_pip()
639
640 # -- Damage feedback ----------------------------------------------------
641
642 @property
643 def flashing_hit(self) -> bool:
644 """Whether the white hit pulse is currently on the hull."""
645 return self.hit_flash_remaining > 0.0
646
647 def hull_ceiling(self) -> float:
648 """This hull's full pool, honouring an elite that inflated it."""
649 return max(float(getattr(self, "max_hp", self.spec.hp)), float(self.hp), 1e-6)
650
651 def carries_pip(self) -> bool:
652 """Whether this hull is big enough to be worth a health read-out."""
653 return self.hull_ceiling() >= HP_PIP_MIN_HP
654
655 def show_hit(self, at: Vec3 | None = None) -> None:
656 """React to a hit that landed: pulse white and throw a spark.
657
658 *at* is where the round came apart; without one the hull's own position
659 stands in, which is accurate to within its own radius and is what a
660 caller that only knows "this node was hit" can honestly supply.
661 """
662 self.flash_hit()
663 self.spark_at(Vec3(self.position) if at is None else at)
664
665 def flash_hit(self) -> None:
666 """Drive the body's emissive to white for :data:`HIT_FLASH_S`."""
667 self.hit_flash_remaining = HIT_FLASH_S
668 self.apply_hit_flash(True)
669
670 def apply_hit_flash(self, active: bool) -> None:
671 """Switch the body's emissive between the pulse and its own value."""
672 for material, base in zip(self._materials, self._base_emissive, strict=True):
673 if active:
674 material.emissive_colour = (*HIT_FLASH_EMISSIVE, HIT_FLASH_STRENGTH)
675 else:
676 material.emissive_colour = base
677
678 def spark_at(self, at: Vec3) -> None:
679 """Throw a one-shot impact spark at a world point.
680
681 Rate-limited to :data:`HIT_SPARK_INTERVAL_S`, because a beam lands a hit
682 every frame and an unthrottled spark would both drain the effect pool
683 and turn a stream of impacts into one solid patch of light.
684 """
685 tree = self.tree
686 if tree is None or tree.root is None or self.spark_cooldown > 0.0:
687 return
688 self.spark_cooldown = HIT_SPARK_INTERVAL_S
689 Vfx.spawn(tree, "enemy_hit", Vec3(float(at.x), PLANE_Y, float(at.z)))
690
691 def refresh_pip(self) -> None:
692 """Show, update or retire this hull's health pip.
693
694 Mounted on the first hit rather than at spawn: an untouched field
695 carries no read-outs at all, which is what keeps the pips meaningful
696 when a fight does start.
697 """
698 if not self.carries_pip() or self.destroying:
699 return
700 if self.pip is None:
701 self.pip = self.add_child(HealthPip(position=Vec3(0.0, HP_PIP_HEIGHT, HP_PIP_OFFSET_Z)))
702 self.pip.set_fraction(float(self.hp) / self.hull_ceiling())
703
704 def _tick_hit_flash(self, dt: float) -> None:
705 if self.spark_cooldown > 0.0:
706 self.spark_cooldown = max(0.0, self.spark_cooldown - dt)
707 if self.hit_flash_remaining <= 0.0:
708 return
709 self.hit_flash_remaining -= dt
710 if self.hit_flash_remaining <= 0.0:
711 self.hit_flash_remaining = 0.0
712 self.apply_hit_flash(False)
713
714 def kill(self) -> None:
715 """Report the kill and leave the tree.
716
717 The damage router owns the consequences (scrap drop, signature, the
718 ``ENEMY_KILLED`` signal); this only reports and stands down.
719 """
720 if self.destroying:
721 return
722 if self.pip is not None:
723 self.pip.dismiss()
724 self.died()
725 router = self.damage_router()
726 if router is not None:
727 router.report_kill(self)
728 self.destroy()
729
730 def damage_router(self):
731 """The run's damage router, or None before one is registered."""
732 tree = self.tree
733 return None if tree is None else tree.singletons.get(Services.DAMAGE)
734
735 def deal_damage_to(self, target, amount: float, kind: str = "impact") -> None:
736 """Hurt *target* through the damage router, or directly as a fallback.
737
738 The router is the only code allowed to apply damage numbers once it
739 exists; the direct call keeps an enemy dangerous in a scene assembled
740 without one.
741 """
742 direction = from_plane(seek(self.plane_position, to_plane(target.position)))
743 router = self.damage_router()
744 if router is not None:
745 router.deal(target, amount, kind=kind, direction=direction, source=self)
746 elif hasattr(target, "apply_damage"):
747 target.apply_damage(amount, direction, kind)
748
749 # -- Lookups ------------------------------------------------------------
750
751 def player_ship(self):
752 """The player's ship node, or None when it is gone."""
753 tree = self.tree
754 if tree is None:
755 return None
756 for ship in tree.group(Groups.SHIP):
757 if not ship.destroying:
758 return ship
759 return None
760
761
762class FlowFieldDirector(Node3D):
763 """Owns the scene-wide flow field and re-integrates it toward the ship.
764
765 Add one to the run scene and every enemy picks it up automatically, so a
766 wave of twenty bodies costs one integration per
767 :data:`FLOW_REBUILD_INTERVAL_S` rather than twenty path queries per frame.
768 Wrecks, deposits and hazards are charged as obstacles, which is what bends
769 a swarm around the terrain instead of through it.
770 """
771
772 OBSTACLE_GROUPS = (Groups.WRECKS, Groups.DEPOSITS, Groups.HAZARDS)
773
774 def __init__(self, bounds: tuple[float, float, float, float] = DEFAULT_FIELD_BOUNDS, **kwargs):
775 super().__init__(**kwargs)
776 self.field = FlowField(bounds)
777 self._until_rebuild = 0.0
778
779 def on_enter_tree(self):
780 super().on_enter_tree()
781 self.add_to_group(FLOW_FIELD_GROUP)
782
783 def on_update(self, dt: float):
784 self._until_rebuild -= dt
785 if self._until_rebuild > 0.0:
786 return
787 self._until_rebuild = FLOW_REBUILD_INTERVAL_S
788 self.rebuild_now()
789
790 def rebuild_now(self) -> None:
791 """Re-integrate the field toward the ship's current position."""
792 tree = self.tree
793 if tree is None:
794 return
795 ships = [ship for ship in tree.group(Groups.SHIP) if not ship.destroying]
796 if not ships:
797 return
798 obstacles = [
799 to_plane(node.position)
800 for group in self.OBSTACLE_GROUPS
801 for node in tree.group(group)
802 if not node.destroying
803 ]
804 self.field.rebuild(to_plane(ships[0].position), obstacles)
805
806
807def shared_flow_field(node) -> FlowField | None:
808 """The scene's shared field, or None when no director is present."""
809 tree = node.tree
810 if tree is None:
811 return None
812 for director in tree.group(FLOW_FIELD_GROUP):
813 if not director.destroying:
814 return director.field
815 return None