Mi2dRPGamEng/resources/logic/combat.py

270 lines
10 KiB
Python

from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Protocol, Sequence, TypeVar
from engine.aim import apply_cone_deviation
from engine.character import BodyPart, Character
from engine.defs import DefRegistry
from engine.item import ItemDef
from engine.map import rotate_vector
from engine.world import World
SKILL_CHECK_DIE = 20
BASE_DIFFICULTY = 10
UNARMED_SKILL = "athletics"
BLOCK_SKILL = "athletics"
# Ordered easiest/biggest -> hardest/smallest target. A precise (high-rolling) attacker reaches
# further down this list; a scattershot hit lands on whatever's first (torso - biggest target).
BODY_PART_DIFFICULTY_ORDER = ("torso", "left_arm", "right_arm", "left_leg", "right_leg", "head")
PRECISION_PER_SLOT = 4 # attacker_check points needed to reach one slot further down the list
_T = TypeVar("_T")
class DiceRoller(Protocol):
"""What combat needs from a random source - satisfied by random.Random and fakes alike."""
def randint(self, a: int, b: int) -> int: ...
def choice(self, seq: Sequence[_T]) -> _T: ...
def uniform(self, a: float, b: float) -> float: ...
@dataclass
class AttackResult:
hit: bool
roll: int
effective_skill: float
weapon_bonus: float
damage: float
skill_id: str
target_slot: str | None
blocked_check: bool # True if this was resolved as an opposed check against a blocking defender
armor_reduction: float
@dataclass
class ShotResult:
"""Outcome of a ranged shot: either it lands as an AttackResult, is blocked, or misses entirely."""
hit: bool
blocked: bool
ricocheted: bool
attack: AttackResult | None
impact: tuple[int, int, int] | None
target_entity_id: str | None
def apply_body_part_damage(character: Character, slot: str, amount: float) -> BodyPart | None:
"""Damages a body part in place, creating an override from the default if none exists yet.
Integrity hitting zero marks the part removed (severed) - the same 'removed' flag the
bleeding tick and skill_penalty already understand, so combat plugs directly into both.
"""
override = character.get_or_create_body_part_override(slot)
if override is None:
return None
override.integrity = max(0.0, override.integrity - amount)
if override.integrity <= 0.0:
override.removed = True
return override
def roll_skill_check(
character: Character, skill_id: str, registry: DefRegistry, rng: DiceRoller
) -> tuple[int, float]:
"""1d20 + the character's skill level, reduced by damage to relevant body parts and organs."""
base_skill = character.bio.get(skill_id, 0)
effective_skill = base_skill * (1.0 - character.total_check_penalty(skill_id, registry))
roll = rng.randint(1, SKILL_CHECK_DIE)
return roll, effective_skill
def choose_hit_slot(defender: Character, precision_check: float) -> str | None:
"""Which body part gets hit is itself driven by a skill check: `precision_check` (the
attacker's roll + effective skill from the main hit) selects how far down the
easiest-to-hardest target list the attacker was precise enough to reach.
"""
candidates = [slot for slot in BODY_PART_DIFFICULTY_ORDER if defender.get_body_part(slot) is not None]
extra_slots = [p.slot for p in defender.default_body_parts if p.slot not in BODY_PART_DIFFICULTY_ORDER]
candidates += [slot for slot in extra_slots if defender.get_body_part(slot) is not None]
if not candidates:
return None
index = min(len(candidates) - 1, max(0, int(precision_check // PRECISION_PER_SLOT)))
return candidates[index]
def defender_armor(defender: Character, registry: DefRegistry) -> float:
"""Flat damage reduction summed across all currently-worn clothing."""
total = 0.0
for piece in defender.resolved_clothing().values():
if piece is None:
continue
total += registry.get_item_def(piece.item_def_id).armor_reduction
return total
def resolve_hit(
attacker: Character,
defender: Character,
registry: DefRegistry,
weapon_def: ItemDef | None,
target_slot: str | None,
rng: DiceRoller,
) -> AttackResult:
skill_id = (weapon_def.weapon_skill if weapon_def else None) or UNARMED_SKILL
roll, effective_skill = roll_skill_check(attacker, skill_id, registry, rng)
weapon_bonus = weapon_def.weapon_damage if weapon_def else 0.0
attacker_check = roll + effective_skill
blocked_check = defender.is_blocking
if blocked_check:
_block_roll, block_effective = roll_skill_check(defender, BLOCK_SKILL, registry, rng)
shield = defender.wielded_shield(registry)
block_bonus = shield.shield_block_bonus if shield else 0.0
difficulty = _block_roll + block_effective + block_bonus
else:
difficulty = BASE_DIFFICULTY
margin = attacker_check - difficulty
hit = margin > 0
armor = defender_armor(registry=registry, defender=defender)
damage = max(0.0, margin + weapon_bonus - armor) if hit else 0.0
hit_slot = None
if hit:
hit_slot = target_slot if target_slot is not None else choose_hit_slot(defender, attacker_check)
if hit_slot is not None:
apply_body_part_damage(defender, hit_slot, damage)
return AttackResult(
hit=hit,
roll=roll,
effective_skill=effective_skill,
weapon_bonus=weapon_bonus,
damage=damage,
skill_id=skill_id,
target_slot=hit_slot,
blocked_check=blocked_check,
armor_reduction=armor,
)
def perform_attack(
attacker: Character,
defender: Character,
registry: DefRegistry,
weapon_def: ItemDef | None = None,
target_slot: str | None = None,
rng: DiceRoller | None = None,
) -> AttackResult:
"""A live melee attack: skill check (weapon's governing skill, or unarmed) + weapon bonus,
minus the defender's armor, vs either a flat difficulty or - if the defender is blocking -
an opposed check (boosted by a wielded shield). On a hit, damage lands on a body part
(random if target_slot is unset).
"""
return resolve_hit(attacker, defender, registry, weapon_def, target_slot, rng or random.Random())
def find_ranged_target(
world: World, map_id: str, x: int, y: int, z: int, dx: int, dy: int, dz: int, max_range: int
) -> tuple[Character | None, tuple[int, int, int], int]:
"""Walks a straight line up to max_range tiles; stops at the first entity, wall, or map edge.
Returns (target_or_None, impact_position, steps_traveled).
"""
map_ = world.maps[map_id]
cx, cy, cz = x, y, z
steps = 0
for step in range(1, max_range + 1):
nx, ny, nz = cx + dx, cy + dy, cz + dz
if not map_.in_bounds(nx, ny, nz):
return None, (cx, cy, cz), steps
cx, cy, cz = nx, ny, nz
steps = step
target = world.entity_at(map_id, cx, cy, cz)
if isinstance(target, Character):
return target, (cx, cy, cz), steps
if not map_.get_tile(cx, cy, cz).is_walkable(world.registry):
return None, (cx, cy, cz), steps
return None, (cx, cy, cz), steps
def _is_blocked_by_shield(target: Character, projectile_type: str | None, registry: DefRegistry) -> bool:
if not target.is_blocking:
return False
shield = target.wielded_shield(registry)
return shield is not None and projectile_type is not None and projectile_type in shield.blockable_projectile_types
def perform_shoot(
shooter: Character,
world: World,
registry: DefRegistry,
weapon_def: ItemDef,
direction: tuple[int, int, int],
rng: DiceRoller | None = None,
cone_degrees: float = 0.0,
projectile_type: str | None = None,
) -> ShotResult:
"""A live ranged shot along `direction` (unit vector) up to the weapon's range.
If `cone_degrees` > 0, the actual direction is randomized within that cone first (see
engine/aim.py) - callers determine the cone from the weapon's base spread minus skill and
attachment reductions (see resources/logic/ranged_combat.py's effective_aim_cone). If it
reaches a blocking defender whose wielded shield covers `projectile_type` (defaults to the
weapon's own projectile_type, e.g. for simple non-ammo weapons; ammo-based weapons should
pass the loaded ammo's ammo_type here instead), the shot is blocked and ricochets off at a
+/-90 degree deflection for whatever range is left; anything else just breaks through and
resolves as a normal hit. A ricocheted shot that gets blocked again simply stops (one bounce).
"""
assert shooter.position is not None
rng = rng or random.Random()
effective_projectile_type = projectile_type if projectile_type is not None else weapon_def.projectile_type
dx, dy, dz = direction
if cone_degrees > 0:
dx, dy = apply_cone_deviation(dx, dy, cone_degrees, rng)
target, impact, steps = find_ranged_target(
world, shooter.position.map_id, shooter.position.x, shooter.position.y, shooter.position.z,
dx, dy, dz, weapon_def.weapon_range,
)
if target is None:
return ShotResult(hit=False, blocked=False, ricocheted=False, attack=None, impact=impact, target_entity_id=None)
if not _is_blocked_by_shield(target, effective_projectile_type, registry):
attack = resolve_hit(shooter, target, registry, weapon_def, None, rng)
return ShotResult(
hit=attack.hit, blocked=False, ricocheted=False, attack=attack, impact=impact, target_entity_id=target.entity_id
)
remaining_range = weapon_def.weapon_range - steps
if remaining_range <= 0:
return ShotResult(
hit=False, blocked=True, ricocheted=True, attack=None, impact=impact, target_entity_id=target.entity_id
)
bounce_dx, bounce_dy = rotate_vector(dx, dy, rng.choice([1, 3]))
bounced_target, bounced_impact, _steps = find_ranged_target(
world, target.position.map_id, impact[0], impact[1], impact[2], bounce_dx, bounce_dy, 0, remaining_range
)
if bounced_target is None:
return ShotResult(hit=False, blocked=True, ricocheted=True, attack=None, impact=bounced_impact, target_entity_id=None)
if _is_blocked_by_shield(bounced_target, effective_projectile_type, registry):
return ShotResult(
hit=False, blocked=True, ricocheted=True, attack=None, impact=bounced_impact, target_entity_id=bounced_target.entity_id
)
attack = resolve_hit(shooter, bounced_target, registry, weapon_def, None, rng)
return ShotResult(
hit=attack.hit,
blocked=True,
ricocheted=True,
attack=attack,
impact=bounced_impact,
target_entity_id=bounced_target.entity_id,
)