Mi2dRPGamEng/resources/logic/ranged_combat.py

235 lines
9.0 KiB
Python

from __future__ import annotations
import random
from dataclasses import dataclass, replace
from engine.aim import apply_cone_deviation
from engine.character import Character
from engine.defs import DefRegistry
from engine.item import ItemDef
from engine.world import World
from resources.logic.combat import (
DiceRoller,
ShotResult,
UNARMED_SKILL,
apply_body_part_damage,
choose_hit_slot,
find_ranged_target,
perform_shoot,
)
from resources.logic.construction import damage_tile_layer
from resources.logic.knockback import resolve_explosion_knockback, resolve_ranged_knockback
DEGREES_PER_SKILL_POINT = 0.3 # how much a skill point narrows the aim cone
MIN_CONE_DEGREES = 1.0
PELLET_SPREAD_FRACTION = 0.5 # "blast fills about half the cone"
SLUG_CONE_FRACTION = 0.8 # slugshot: "slightly thinner aimcone" than the weapon's base cone
def effective_aim_cone(character: Character, hand_slot: str, registry: DefRegistry) -> float:
"""The actual cone a shot is randomized within: base spread minus skill and mod reductions."""
held = character.get_held_item(hand_slot)
if held is None:
return 0.0
weapon_def = registry.get_item_def(held.item_def_id)
if weapon_def.aim_cone_degrees <= 0:
return 0.0
skill_id = weapon_def.weapon_skill or UNARMED_SKILL
effective_skill = character.bio.get(skill_id, 0) * (1.0 - character.skill_penalty(skill_id, registry))
mod_reduction = sum(registry.get_item_def(mod_id).aimcone_reduction_degrees for mod_id in held.attachments)
cone = weapon_def.aim_cone_degrees - effective_skill * DEGREES_PER_SKILL_POINT - mod_reduction
return max(MIN_CONE_DEGREES, cone)
def _with_ammo_bonus(weapon_def: ItemDef, ammo_def: ItemDef) -> ItemDef:
if ammo_def.ammo_damage_bonus == 0.0:
return weapon_def
return replace(weapon_def, weapon_damage=weapon_def.weapon_damage + ammo_def.ammo_damage_bonus)
@dataclass
class ExplosionResult:
impact: tuple[int, int, int]
hit_entities: list[tuple[Character, float]]
@dataclass
class BlastResult:
"""A shotgun's independent pellets, each its own ShotResult."""
pellets: list[ShotResult]
def perform_explosion(
world: World,
map_id: str,
center: tuple[int, int, int],
radius: int,
base_damage: float,
) -> ExplosionResult:
"""AoE: damages every entity and every 'room' tile layer within `radius` (Chebyshev
distance), with linear falloff from the impact point. Ties into the same body-part-damage
and tile-integrity mechanisms combat/deconstruction already use.
"""
map_ = world.maps[map_id]
cx, cy, cz = center
hit_entities: list[tuple[Character, float]] = []
for dx in range(-radius, radius + 1):
for dy in range(-radius, radius + 1):
distance = max(abs(dx), abs(dy))
if distance > radius:
continue
x, y, z = cx + dx, cy + dy, cz
if not map_.in_bounds(x, y, z):
continue
falloff = 1.0 - (distance / (radius + 1))
damage = base_damage * falloff
tile = map_.get_tile(x, y, z)
if tile.room is not None:
damage_tile_layer(tile, "room", damage)
target = world.entity_at(map_id, x, y, z)
if isinstance(target, Character):
slot = choose_hit_slot(target, precision_check=damage)
if slot is not None:
apply_body_part_damage(target, slot, damage)
hit_entities.append((target, damage))
return ExplosionResult(impact=center, hit_entities=hit_entities)
def perform_rocket_shot(
shooter: Character,
world: World,
registry: DefRegistry,
weapon_def: ItemDef,
ammo_def: ItemDef,
aim_direction: tuple[int, int, int],
cone_degrees: float,
rng: DiceRoller,
) -> ExplosionResult:
"""Travels until it hits something (entity or wall) or runs out of range, then explodes.
Knockback radiates outward from the impact point (no recoil - see resolve_explosion_knockback).
"""
assert shooter.position is not None
dx, dy, dz = aim_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,
)
effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def)
explosion = perform_explosion(world, shooter.position.map_id, impact, weapon_def.blast_radius, effective_weapon_def.weapon_damage)
resolve_explosion_knockback(world, ammo_def, impact, explosion.hit_entities)
return explosion
def _apply_shot_knockback(
world: World, shooter: Character, shot_result: ShotResult, ammo_def: ItemDef, aim_direction: tuple[int, int, int]
) -> None:
"""Knockback lands on whoever the shot ultimately engages (direct hit, or a ricochet's
eventual target), regardless of the hit/miss/opposed-check outcome - see
resolve_ranged_knockback. Uses the original aim direction as an approximation of the
shot's travel direction (pellets/cone deviation aren't tracked precisely enough to recover
their exact path after the fact, which is a fine approximation for "basic" knockback feel).
"""
if shot_result.target_entity_id is None:
return
target = world.entities.get(shot_result.target_entity_id)
if isinstance(target, Character):
resolve_ranged_knockback(world, shooter, target, ammo_def, aim_direction)
def perform_shotgun_blast(
shooter: Character,
world: World,
registry: DefRegistry,
weapon_def: ItemDef,
ammo_def: ItemDef,
aim_direction: tuple[int, int, int],
cone_degrees: float,
rng: DiceRoller,
) -> BlastResult:
"""Shell: pellet_count independent pellets spread across ~half the weapon's cone. Slug:
a single, higher-damage projectile in a slightly-thinner-than-base cone instead.
"""
effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def)
is_slug = ammo_def.ammo_type == "shotgun_slug"
if is_slug:
slug_cone = cone_degrees * SLUG_CONE_FRACTION
pellets = [
perform_shoot(
shooter, world, registry, effective_weapon_def, aim_direction, rng=rng,
cone_degrees=slug_cone, projectile_type=ammo_def.ammo_type,
)
]
else:
pellet_cone = cone_degrees * PELLET_SPREAD_FRACTION
pellets = [
perform_shoot(
shooter, world, registry, effective_weapon_def, aim_direction, rng=rng,
cone_degrees=pellet_cone, projectile_type=ammo_def.ammo_type,
)
for _ in range(weapon_def.pellet_count)
]
first_hit = next((p for p in pellets if p.target_entity_id is not None), None)
if first_hit is not None:
_apply_shot_knockback(world, shooter, first_hit, ammo_def, aim_direction)
return BlastResult(pellets=pellets)
def fire_held_weapon(
character: Character,
world: World,
registry: DefRegistry,
hand_slot: str,
aim_direction: tuple[int, int, int],
rng: DiceRoller | None = None,
):
"""The ammo-aware firing path: requires a loaded, compatible ammo-fed weapon (see
Character.reload). Routes to a rocket AoE, a shotgun blast, or a normal aimed shot,
consuming one round either way. Returns None if there's nothing to fire (unarmed, not
ammo-fed, or empty - use perform_shoot/perform_attack directly for simple weapons).
"""
rng = rng or random.Random()
held = character.get_held_item(hand_slot)
if held is None:
return None
weapon_def = registry.get_item_def(held.item_def_id)
if weapon_def.magazine_size <= 0 or held.loaded_ammo_count <= 0 or held.loaded_ammo_type is None:
return None
# look up the actual ammo ItemDef by its ammo_type (the loaded type), not by item id -
# any item whose ammo_type matches is an equally valid stand-in for "what's loaded"
ammo_def = _find_ammo_def(registry, held.loaded_ammo_type)
if ammo_def is None:
return None
held.loaded_ammo_count -= 1
cone = effective_aim_cone(character, hand_slot, registry)
if weapon_def.blast_radius > 0:
return perform_rocket_shot(character, world, registry, weapon_def, ammo_def, aim_direction, cone, rng)
if weapon_def.pellet_count > 1 or ammo_def.ammo_type == "shotgun_slug":
return perform_shotgun_blast(character, world, registry, weapon_def, ammo_def, aim_direction, cone, rng)
effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def)
result = perform_shoot(
character, world, registry, effective_weapon_def, aim_direction, rng=rng,
cone_degrees=cone, projectile_type=ammo_def.ammo_type,
)
_apply_shot_knockback(world, character, result, ammo_def, aim_direction)
return result
def _find_ammo_def(registry: DefRegistry, ammo_type: str) -> ItemDef | None:
for item_def in registry.item_defs.values():
if item_def.ammo_type == ammo_type:
return item_def
return None