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)