shrike/combat.py¶
Part of SHRIKE.
1"""The damage router and the collision-layer contract.
2
3Two things live here, and they are the same job seen from two sides.
4
5**The layer contract.** ``runtime.Layers`` holds the bits; this module holds
6the matrix, as :data:`LAYER_CONTRACT`, one ``(layer, mask)`` pair per role a
7body or sensor can play. :func:`bind_layers` is the only supported way to set
8those two numbers, :func:`layer_of` answers "what is this node" for a gameplay
9node whose collision shape is a child, and :func:`may_damage` answers "is this
10pair allowed to hurt each other" from the masks rather than from the caller's
11good intentions. The one documented exception is the Welder's umbilical, a
12sensor that watches the ship alone: see :data:`Roles.UMBILICAL`.
13
14**The router.** :class:`DamageRouter` is the only code in the game that applies
15a damage number. Weapons, enemies and the hunter all call :meth:`deal`, which
16resolves the ballistic premium, hull plating and the direction convention, then
17forwards to the target's own damage entry point. Kills come back through
18:meth:`report_kill`, which drops the scrap and emits ``ENEMY_KILLED``; the
19signature meter, the notoriety tally, the HUD and the juice layer all hang off
20that one signal rather than off the enemy that died.
21
22Contact
23=======
24
25Hull-to-hull damage goes through :meth:`DamageRouter.resolve_contact` rather
26than through :meth:`deal`, because contact is the one damage source with
27nothing flying between the two bodies and so the one that has to read as a
28collision. Resolving it stalls the attacker for ``balance.CONTACT_STALL_S`` and
29puts a radial separation impulse on both hulls, so a Lancer that connects stops
30dead and bounces off instead of gliding through the ship trailing a damage
31number.
32
33Direction convention
34====================
35
36Every caller passes ``direction`` as the direction the damage **travelled**,
37source to target: a projectile's flight vector, an enemy's charge vector. The
38ship, the HUD and the shield arc all speak the opposite convention, the bearing
39from the ship **toward** its attacker, because that is the bearing a player
40defends. The router is the single place that flips it, so no caller has to
41remember which way round it is.
42"""
43
44import math
45import random
46
47from simvx.core import Node, Node3D, Signal, Vec2, Vec3
48
49from . import balance
50from .power import iter_tree_nodes
51from .runtime import PLANE_Y, Groups, Layers, to_plane
52
53# ============================================================================
54# Numbers the design fixes in prose rather than in a table, derived from the
55# constants balance.py does carry. Flagged for the balance pass.
56# ============================================================================
57
58#: Ballistic weapons hit harder per shot than energy weapons and pay for it in
59#: the scrap economy. Balance carries the band, not the working figure, so the
60#: router uses the middle of it.
61BALLISTIC_PREMIUM = (balance.BALLISTIC_DAMAGE_PREMIUM_MIN + balance.BALLISTIC_DAMAGE_PREMIUM_MAX) * 0.5
62
63
64class Roles:
65 """Every part a body or sensor can play in the collision matrix."""
66
67 SHIP = "ship"
68 ENEMY = "enemy"
69 PLAYER_FIRE = "player_fire"
70 ENEMY_FIRE = "enemy_fire"
71 SALVAGE = "salvage"
72 SCOOP = "scoop"
73 TERRAIN = "terrain"
74 HUNTER = "hunter"
75 INTERACT = "interact"
76 #: The Welder's repair umbilical: it is enemy-side geometry, but the only
77 #: thing it must notice is the ship flying through it to snap the beam, so
78 #: it watches ``SHIP`` alone instead of taking ``MASK_ENEMY``.
79 UMBILICAL = "umbilical"
80
81
82#: ``role -> (collision_layer, collision_mask)``. The whole matrix, in one place.
83LAYER_CONTRACT: dict[str, tuple[int, int]] = {
84 Roles.SHIP: (Layers.SHIP, Layers.MASK_SHIP),
85 Roles.ENEMY: (Layers.ENEMY, Layers.MASK_ENEMY),
86 Roles.PLAYER_FIRE: (Layers.PLAYER_FIRE, Layers.MASK_PLAYER_FIRE),
87 Roles.ENEMY_FIRE: (Layers.ENEMY_FIRE, Layers.MASK_ENEMY_FIRE),
88 Roles.SALVAGE: (Layers.SALVAGE, 0),
89 Roles.SCOOP: (Layers.SHIP, Layers.MASK_SCOOP),
90 Roles.TERRAIN: (Layers.TERRAIN, 0),
91 Roles.HUNTER: (Layers.HUNTER, Layers.SHIP | Layers.TERRAIN),
92 Roles.INTERACT: (Layers.INTERACT, Layers.MASK_INTERACT_SENSOR),
93 Roles.UMBILICAL: (Layers.ENEMY, Layers.SHIP),
94}
95
96#: The mask a bare layer bit implies, for a node that never carried a shape of
97#: its own. Roles that share a layer (the scoop is ship-layered) are resolved by
98#: the node itself when it carries a real ``collision_mask``.
99_MASK_FOR_LAYER: dict[int, int] = {
100 Layers.SHIP: Layers.MASK_SHIP,
101 Layers.ENEMY: Layers.MASK_ENEMY,
102 Layers.PLAYER_FIRE: Layers.MASK_PLAYER_FIRE,
103 Layers.ENEMY_FIRE: Layers.MASK_ENEMY_FIRE,
104 Layers.SALVAGE: 0,
105 Layers.TERRAIN: 0,
106 Layers.HUNTER: Layers.SHIP | Layers.TERRAIN,
107 Layers.INTERACT: Layers.MASK_INTERACT_SENSOR,
108}
109
110#: Group membership implies a layer for gameplay nodes whose collision shape is
111#: a child: an ``Enemy`` is a plain ``Node3D`` with a hitbox underneath it.
112_LAYER_FOR_GROUP: tuple[tuple[str, int], ...] = (
113 (Groups.SHIP, Layers.SHIP),
114 (Groups.HUNTER, Layers.HUNTER),
115 (Groups.ENEMIES, Layers.ENEMY),
116 (Groups.PLAYER_PROJECTILES, Layers.PLAYER_FIRE),
117 (Groups.ENEMY_PROJECTILES, Layers.ENEMY_FIRE),
118 (Groups.SALVAGE, Layers.SALVAGE),
119 (Groups.DEPOSITS, Layers.TERRAIN),
120 (Groups.WRECKS, Layers.TERRAIN),
121 (Groups.VAULTS, Layers.INTERACT),
122 (Groups.DEPOTS, Layers.INTERACT),
123)
124
125
126def bind_layers(node: Node, role: str) -> None:
127 """Stamp the contract's ``(layer, mask)`` for *role* onto *node*.
128
129 Raises ``KeyError`` for an unknown role: a body with the wrong bits is a
130 silent gameplay bug, so a typo has to fail loudly at the seam instead.
131 """
132 try:
133 layer, mask = LAYER_CONTRACT[role]
134 except KeyError:
135 known = ", ".join(sorted(LAYER_CONTRACT))
136 raise KeyError(f"unknown collision role {role!r}; expected one of {known}") from None
137 node.collision_layer = layer
138 node.collision_mask = mask
139
140
141def layer_of(node: Node) -> int:
142 """What *node* is, as a layer bit, or 0 when nothing claims it.
143
144 Reads the node's own ``collision_layer`` first, then falls back to group
145 membership so a gameplay node that keeps its shape in a child still answers
146 the question.
147 """
148 layer = int(getattr(node, "collision_layer", 0) or 0)
149 if layer:
150 return layer
151 for group, bit in _LAYER_FOR_GROUP:
152 if node.is_in_group(group):
153 return bit
154 return 0
155
156
157def mask_of(node: Node) -> int:
158 """What *node* can see, as a mask, falling back to its layer's default."""
159 mask = int(getattr(node, "collision_mask", 0) or 0)
160 if mask:
161 return mask
162 return _MASK_FOR_LAYER.get(layer_of(node), 0)
163
164
165def may_damage(source: Node, target: Node) -> bool:
166 """True when the matrix lets *source* hurt *target*.
167
168 The mask is the authority in one direction only: what the attacker sees.
169 Player fire cannot see the ship and enemy fire cannot see enemies, so this
170 is what keeps friendly fire out of the game without a per-caller check.
171 """
172 return bool(mask_of(source) & layer_of(target))
173
174
175def _normalised(direction: Vec3) -> Vec3:
176 """*direction* flattened onto the flight plane and scaled to unit length."""
177 x, z = float(direction.x), float(direction.z)
178 length = math.hypot(x, z)
179 if length < 1e-9:
180 return Vec3(0.0, 0.0, 0.0)
181 return Vec3(x / length, 0.0, z / length)
182
183
184def damage_multiplier_for(target: Node) -> float:
185 """How much of an incoming number *target* actually takes.
186
187 A target may declare its own ``damage_taken_mult`` (the Shrike's fins carry
188 plating, a Warden's shielded face does not). Anything hunter-side that has
189 not declared one is plated: that is what makes shearing a fin a 15-second
190 commitment rather than a burst.
191 """
192 declared = getattr(target, "damage_taken_mult", None)
193 if declared is not None:
194 return max(0.0, float(declared))
195 if target.is_in_group(Groups.HUNTER):
196 return balance.SHRIKE_FIN_DAMAGE_TAKEN_MULT
197 return 1.0
198
199
200# ============================================================================
201# Contact resolution
202# ============================================================================
203
204
205def contact_axis(attacker: Node3D, target: Node3D) -> Vec3:
206 """The unit plane vector a contact hit travelled along, attacker to target.
207
208 Two hulls resting exactly on top of each other fall back to a fixed axis, so
209 a collision at zero distance still separates instead of dividing by zero.
210 """
211 dx = float(target.position.x) - float(attacker.position.x)
212 dz = float(target.position.z) - float(attacker.position.z)
213 length = math.hypot(dx, dz)
214 if length < 1e-6:
215 return Vec3(1.0, 0.0, 0.0)
216 return Vec3(dx / length, 0.0, dz / length)
217
218
219def apply_separation(node: Node3D, direction: Vec3, speed: float) -> None:
220 """Shove *node* along *direction* at *speed*, whatever kind of body it is.
221
222 A hostile owns a recoil channel that survives its own steering pass
223 (``Enemy.apply_contact_impulse``); anything else carrying a plane
224 ``velocity``, the ship included, takes the impulse straight onto it and
225 bleeds it off through its own dampening. A body with neither is not
226 something a collision can move, and is left where it is.
227 """
228 if node is None or speed <= 0.0:
229 return
230 impulse = getattr(node, "apply_contact_impulse", None)
231 if callable(impulse):
232 impulse(direction, speed)
233 return
234 velocity = getattr(node, "velocity", None)
235 if velocity is None:
236 return
237 node.velocity = Vec2(
238 float(velocity.x) + float(direction.x) * speed,
239 float(velocity.y) + float(direction.z) * speed,
240 )
241
242
243def separate_on_contact(attacker: Node3D, target: Node3D) -> Vec3:
244 """Stall *attacker* and push both hulls apart; returns the travel axis.
245
246 The attacker takes the whole impulse and the target the fraction
247 ``balance.CONTACT_TARGET_IMPULSE_FRACTION`` of it, because the ship is the
248 heavier of the two and a collision that flung the player around would cost
249 more control than the hit costs hull.
250 """
251 travel = contact_axis(attacker, target)
252 stall = getattr(attacker, "stall", None)
253 if callable(stall):
254 stall(balance.CONTACT_STALL_S)
255 away = Vec3(-float(travel.x), 0.0, -float(travel.z))
256 apply_separation(attacker, away, balance.CONTACT_SEPARATION_IMPULSE)
257 apply_separation(target, travel, balance.CONTACT_SEPARATION_IMPULSE * balance.CONTACT_TARGET_IMPULSE_FRACTION)
258 return travel
259
260
261class DamageRouter(Node):
262 """The single owner of every damage number in the run.
263
264 Registered as ``Services.DAMAGE``. Callers never touch a target's hull:
265 they hand :meth:`deal` an amount, a kind and the direction the damage
266 travelled, and the router resolves the premium, the plating and the
267 direction convention before forwarding. Player hits go to
268 ``PlayerShip.apply_damage``, which owns the shield arc, so the router never
269 resolves shield coverage a second time and never re-emits the shield's own
270 signals.
271
272 Deaths come back through :meth:`report_kill` rather than through the enemy
273 node, so scrap, signature, notoriety, audio and juice all read one event.
274 """
275
276 #: (target: Node, amount: float, kind: str) after every resolution step.
277 damage_dealt = Signal(Node, float, str)
278 #: (archetype: str, position: Vec3, elite: bool)
279 enemy_killed = Signal(str, Vec3, bool)
280 #: (amount: float, direction: Vec3) with the bearing toward the attacker,
281 #: and only for damage that actually reached the hull.
282 player_damaged = Signal(float, Vec3)
283
284 def __init__(self, *, seed: int = 0, **kwargs):
285 super().__init__(**kwargs)
286 #: Total damage the router has put on hulls this run.
287 self.damage_applied = 0.0
288 #: Kills reported this run, including each mite of a shoal.
289 self.kills = 0
290 #: Scrap released by kills this run, before any scoop pass.
291 self.kill_scrap = 0.0
292 #: Archetype of whatever last put a hit on the ship, "" for an
293 #: unsourced hit. Recorded before the hull resolves the hit, because
294 #: ``SHIP_DESTROYED`` fires from inside that resolution: this is what
295 #: lets the death ledger name the killer instead of guessing it.
296 self.last_ship_hit_by = ""
297 self._rng = random.Random(seed)
298 self._sector_cache = None
299
300 # ------------------------------------------------------------------ damage
301
302 def deal(self, target: Node, amount: float, *, kind: str, direction: Vec3, source: Node | None = None) -> float:
303 """Apply *amount* to *target* and return what actually landed.
304
305 *direction* is the direction the damage travelled, source to target.
306 *kind* is the weapon family ("energy", "ballistic") or the contact kind
307 ("impact", "beam"). Passing *source* opts the call into the collision
308 matrix (a shot its mask cannot see is dropped rather than applied) and,
309 for a hit on the ship, is what lets the death ledger name the killer.
310 """
311 amount = float(amount)
312 if target is None or amount <= 0.0 or target.destroying:
313 return 0.0
314 if source is not None and not may_damage(source, target):
315 return 0.0
316
317 travel = _normalised(Vec3(direction))
318 if layer_of(target) == Layers.SHIP:
319 return self._damage_ship(target, amount, kind, travel, source)
320 return self._damage_hostile(target, amount, kind, travel)
321
322 def resolve_contact(self, attacker: Node3D, target: Node3D, amount: float, *, kind: str = "impact") -> float:
323 """Land a hull-to-hull hit and make it read as a collision.
324
325 The damage goes through :meth:`deal` like any other, so the shield arc,
326 the plating and the matrix all still apply. What contact adds is weight:
327 the attacker's drive dies for ``balance.CONTACT_STALL_S`` and both hulls
328 take a radial impulse apart, whether or not the hit got past the shield.
329 The collision happened either way.
330 """
331 if attacker is None or target is None or target.destroying:
332 return 0.0
333 travel = contact_axis(attacker, target)
334 landed = self.deal(target, amount, kind=kind, direction=travel, source=attacker)
335 separate_on_contact(attacker, target)
336 return landed
337
338 def _damage_ship(self, ship: Node, amount: float, kind: str, travel: Vec3, source: Node | None = None) -> float:
339 """Hand a hit to the ship and report only what got past the shield.
340
341 The attacker is recorded *before* the hull resolves the hit: a fatal
342 hit emits ``SHIP_DESTROYED`` from inside ``apply_damage``, and the
343 run's death handler reads :meth:`last_ship_attacker` synchronously
344 from within that emission.
345 """
346 self.last_ship_hit_by = self._archetype_of(source) if source is not None else ""
347 toward_attacker = Vec3(-float(travel.x), 0.0, -float(travel.z))
348 before = float(getattr(ship, "hull", 0.0))
349 ship.apply_damage(amount, toward_attacker, kind)
350 leaked = max(0.0, before - float(getattr(ship, "hull", 0.0)))
351 if leaked <= 0.0:
352 return 0.0
353 self.damage_applied += leaked
354 self.damage_dealt(ship, leaked, kind)
355 self.player_damaged(leaked, toward_attacker)
356 return leaked
357
358 def _damage_hostile(self, target: Node, amount: float, kind: str, travel: Vec3) -> float:
359 """Resolve the premium and the plating, then hurt the target."""
360 if kind == "ballistic":
361 amount *= 1.0 + BALLISTIC_PREMIUM
362 amount *= damage_multiplier_for(target)
363 if amount <= 0.0:
364 return 0.0
365
366 take_damage = getattr(target, "take_damage", None)
367 if take_damage is not None:
368 take_damage(amount, kind)
369 elif hasattr(target, "apply_damage"):
370 target.apply_damage(amount, travel, kind)
371 else:
372 return 0.0
373
374 self.damage_applied += amount
375 self.damage_dealt(target, amount, kind)
376 return amount
377
378 def last_ship_attacker(self) -> str:
379 """Archetype of whatever last hit the ship, or "" when nothing sourced has.
380
381 Valid mid-death: the ship's destruction is emitted from inside the hit
382 that caused it, so during that emission this names the killer.
383 """
384 return self.last_ship_hit_by
385
386 # ------------------------------------------------------------------- kills
387
388 def report_kill(self, enemy: Node3D) -> None:
389 """Bank one kill: drop its scrap, then announce it.
390
391 A shoal reports once per mite, from the shoal's own node, so this must
392 stay cheap and must never assume the reporter is leaving the tree.
393 """
394 archetype = self._archetype_of(enemy)
395 elite = bool(getattr(enemy, "elite", None))
396 position = Vec3(enemy.position.x, PLANE_Y, enemy.position.z)
397
398 self.kills += 1
399 self._drop_scrap(position)
400 self.enemy_killed(archetype, position, elite)
401
402 def _archetype_of(self, enemy: Node) -> str:
403 spec = getattr(enemy, "spec", None)
404 archetype = getattr(spec, "id", None)
405 return str(archetype) if archetype else type(enemy).__name__.lower()
406
407 def _drop_scrap(self, position: Vec3) -> None:
408 """Scatter a kill's scrap as motes the ship has to fly through."""
409 amount = float(self._rng.randint(balance.SCRAP_KILL_MIN, balance.SCRAP_KILL_MAX))
410 if amount <= 0.0:
411 return
412 self.kill_scrap += amount
413 sector = self._sector()
414 if sector is not None:
415 sector.release("scrap", amount, to_plane(position))
416
417 def _sector(self):
418 """The live sector, or None in a scene assembled without one.
419
420 Cached, and re-resolved whenever the cached one has left: a mite shoal
421 reports a kill per mite, and the walk is the expensive half.
422 """
423 cached = self._sector_cache
424 if cached is not None and not cached.destroying and cached.tree is not None:
425 return cached
426 self._sector_cache = None
427 tree = self.tree
428 if tree is None:
429 return None
430 from . import sector as sector_module
431
432 for node in iter_tree_nodes(tree):
433 if isinstance(node, sector_module.Sector):
434 self._sector_cache = node
435 return node
436 return None
437
438
439__all__ = [
440 "BALLISTIC_PREMIUM",
441 "LAYER_CONTRACT",
442 "DamageRouter",
443 "Roles",
444 "apply_separation",
445 "bind_layers",
446 "contact_axis",
447 "damage_multiplier_for",
448 "layer_of",
449 "mask_of",
450 "may_damage",
451 "separate_on_contact",
452]