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]