Mi2dRPGamEng/resources/logic/knockback.py

109 lines
4.3 KiB
Python

from __future__ import annotations
from engine.character import Character
from engine.entity import Entity, EntityPosition
from engine.item import ItemDef
from engine.world import World
UNARMED_MELEE_KNOCKBACK = 0.3 # tiles, at baseline strength
RECOIL_FACTOR = 0.3 # fraction of dealt knockback the shooter/attacker feels in return
STRENGTH_BASELINE = 10.0
def _sign(n: float) -> int:
return (n > 0) - (n < 0)
def direction_between(a: EntityPosition, b: EntityPosition) -> tuple[int, int, int]:
"""Unit-ish step direction from a toward b (each axis independently signed)."""
return (_sign(b.x - a.x), _sign(b.y - a.y), _sign(b.z - a.z))
def apply_knockback(world: World, entity: Entity, direction: tuple[int, int, int], distance: int) -> int:
"""Pushes `entity` up to `distance` tiles via normal movement, stopping early at an obstacle.
Returns how many tiles it actually moved (may be less than `distance` if blocked).
"""
dx, dy, dz = direction
moved = 0
for _ in range(distance):
if not world.try_move(entity, dx, dy, dz):
break
moved += 1
return moved
def melee_knockback_distance(attacker: Character, weapon_def: ItemDef | None, registry) -> int:
"""Melee knockback scales with the attacker's effective strength (organ damage - e.g. a
weakened heart - reduces it), at baseline STRENGTH_BASELINE = 1x.
"""
base = weapon_def.melee_knockback if weapon_def is not None else UNARMED_MELEE_KNOCKBACK
return round(base * (attacker.effective_strength(registry) / STRENGTH_BASELINE))
def apply_recoil(
world: World, shooter: Character, forward_direction: tuple[int, int, int], knockback_dealt: int, is_rocket: bool
) -> int:
"""Pushes the attacker/shooter backward, proportional to the knockback they just dealt.
Skipped entirely for rocket launchers (their AoE knockback isn't a single directed impulse
the shooter would feel the same way).
"""
if is_rocket or knockback_dealt <= 0:
return 0
recoil_distance = round(knockback_dealt * RECOIL_FACTOR)
if recoil_distance <= 0:
return 0
backward = (-forward_direction[0], -forward_direction[1], -forward_direction[2])
return apply_knockback(world, shooter, backward, recoil_distance)
def resolve_melee_knockback(
world: World, attacker: Character, defender: Character, weapon_def: ItemDef | None, hit: bool, registry
) -> int:
"""Applies melee knockback (+ the attacker's recoil) on a successful hit only."""
if not hit:
return 0
assert attacker.position is not None and defender.position is not None
distance = melee_knockback_distance(attacker, weapon_def, registry)
if distance <= 0:
return 0
direction = direction_between(attacker.position, defender.position)
moved = apply_knockback(world, defender, direction, distance)
apply_recoil(world, attacker, direction, moved, is_rocket=False)
return moved
def resolve_ranged_knockback(
world: World,
shooter: Character,
target: Character,
ammo_def: ItemDef,
shot_direction: tuple[int, int, int],
is_rocket: bool = False,
) -> int:
"""Applies ranged knockback from the ammo's own stat, regardless of hit/miss/blocked-check
outcome: the impact's kinetic force lands as long as the shot physically reached the target
(a shield hard-blocking/ricocheting it away is the one case this is never called for).
"""
distance = round(ammo_def.ranged_knockback)
if distance <= 0:
return 0
moved = apply_knockback(world, target, shot_direction, distance)
apply_recoil(world, shooter, shot_direction, moved, is_rocket=is_rocket)
return moved
def resolve_explosion_knockback(world: World, ammo_def: ItemDef, center: tuple[int, int, int], hit_entities) -> None:
"""Radial knockback for each entity caught in a blast, pushed directly away from the
impact point rather than along the shot's original travel direction.
"""
distance = round(ammo_def.ranged_knockback)
if distance <= 0:
return
for entity, _damage in hit_entities:
assert entity.position is not None
direction = (_sign(entity.position.x - center[0]), _sign(entity.position.y - center[1]), 0)
if direction == (0, 0, 0):
continue
apply_knockback(world, entity, direction, distance)