235 lines
9.0 KiB
Python
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
|