109 lines
4.3 KiB
Python
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)
|