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