from __future__ import annotations import math from engine.ai import AIController from engine.aim import snap_to_8way from engine.character import Character from engine.defs import DefRegistry from engine.entity import EntityPosition from engine.world import World from resources.logic.combat import DiceRoller, perform_shoot TURRET_BEHAVIOR_ID = "turret" DEFAULT_TURN_RATE_DEGREES_PER_TICK = 20.0 # The turret's own body parts (see resources/defs/body_parts.json's turret_* entries) - which # ones gate which function. A missing/destroyed part (Character.get_body_part returns None, # whether it was never there or was shot off) disables exactly that capability, nothing more: # psu is the one part that gates everything else (no power, no turret at all). def is_hostile(turret: Character, target: Character) -> bool: """Whether `target` is something `turret` should engage, per its targeting config (see spawn_turret) - individual entity_id overrides win over faction membership, which wins over the default of "not a target" (an untagged/unlisted character is left alone). """ assert turret.ai is not None state = turret.ai.state if target.entity_id in state.get("allied_entity_ids", ()): return False if target.entity_id in state.get("hostile_entity_ids", ()): return True target_factions = set(target.factions) if target_factions & set(state.get("allied_factions", ())): return False if target_factions & set(state.get("hostile_factions", ())): return True return False def find_closest_hostile(turret: Character, world: World, registry: DefRegistry) -> Character | None: """The nearest hostile Character on the turret's own map, within its weapon's range - "just tries to follow the closest enemy" starts with picking which one that is. None if nothing hostile is in range (or the turret has no weapon assigned at all). """ assert turret.position is not None and turret.ai is not None weapon_item_id = turret.ai.state.get("weapon_item_id") if weapon_item_id is None: return None max_range = registry.get_item_def(weapon_item_id).weapon_range closest: Character | None = None closest_distance = math.inf for entity in world.entities_on_map(turret.position.map_id): if not isinstance(entity, Character) or entity is turret or entity.position is None: continue if entity.position.z != turret.position.z: continue if not is_hostile(turret, entity): continue distance = math.hypot(entity.position.x - turret.position.x, entity.position.y - turret.position.y) if distance > max_range: continue if distance < closest_distance: closest, closest_distance = entity, distance return closest def rotate_toward(current_degrees: float, target_degrees: float, max_turn_degrees: float) -> float: """Turns `current_degrees` toward `target_degrees` by at most `max_turn_degrees`, via the shorter direction around the circle - the turn-rate limit every turret is bound by. """ diff = (target_degrees - current_degrees + 180.0) % 360.0 - 180.0 if abs(diff) <= max_turn_degrees: return target_degrees % 360.0 return (current_degrees + math.copysign(max_turn_degrees, diff)) % 360.0 def turret_ai_behavior(turret: Character, world: World, registry: DefRegistry, now: float, rng: DiceRoller) -> None: """A simple sentry AI: every tick, track whichever hostile is currently closest, turning to face it at no more than the turret's own turn rate, and fire once a weapon and gun are both available. Each of the turret's 5 body parts gates a specific capability - see the module docstring-equivalent comment above - so shooting one off degrades the turret exactly the way it looks like it should (shoot the camera, it goes blind; the motors, it freezes facing whichever way it was last pointed; the gun, it keeps tracking but never fires again; the psu, it goes completely dead). """ assert turret.ai is not None and turret.position is not None state = turret.ai.state if turret.get_body_part("psu") is None: return camera_ok = turret.get_body_part("camera") is not None cpu_ok = turret.get_body_part("cpu") is not None motors_ok = turret.get_body_part("motors") is not None gun_ok = turret.get_body_part("gun") is not None target = find_closest_hostile(turret, world, registry) if (camera_ok and cpu_ok) else None state["target_entity_id"] = target.entity_id if target is not None else None facing = state.get("facing_degrees", 0.0) if target is not None and motors_ok: assert target.position is not None dx = target.position.x - turret.position.x dy = target.position.y - turret.position.y target_degrees = math.degrees(math.atan2(dy, dx)) % 360.0 turn_rate = state.get("turn_rate_degrees_per_tick", DEFAULT_TURN_RATE_DEGREES_PER_TICK) facing = rotate_toward(facing, target_degrees, turn_rate) state["facing_degrees"] = facing if target is not None and gun_ok: weapon_item_id = state.get("weapon_item_id") if weapon_item_id is not None: weapon_def = registry.get_item_def(weapon_item_id) aim_dx, aim_dy = snap_to_8way(math.cos(math.radians(facing)), math.sin(math.radians(facing))) perform_shoot(turret, world, registry, weapon_def, (aim_dx, aim_dy, 0), rng=rng) def spawn_turret( registry: DefRegistry, entity_def_id: str, position: EntityPosition, hostile_factions: tuple[str, ...] = (), allied_factions: tuple[str, ...] = (), hostile_entity_ids: tuple[str, ...] = (), allied_entity_ids: tuple[str, ...] = (), ) -> Character: """Builds a stationary turret from a turret entity template (see entities.json's turret_pistol/turret_autocannon) at `position`, with its AI slot (see engine/ai.py:: AIController) preloaded to run turret_ai_behavior and its targeting rules set from the given faction/individual lists (see is_hostile) - callers still need world.add_entity(...) themselves, same as every other spawn helper. """ entity_def = registry.get_entity_def(entity_def_id) turn_rate = entity_def.turret_turn_rate_degrees_per_tick return Character( name=entity_def.name, def_id=entity_def.id, position=position, species_id=entity_def.species_id, default_body_parts=registry.new_default_body_parts(entity_def.species_id), ai=AIController( behavior_id=TURRET_BEHAVIOR_ID, state={ "weapon_item_id": entity_def.turret_weapon_item_id, "turn_rate_degrees_per_tick": turn_rate if turn_rate is not None else DEFAULT_TURN_RATE_DEGREES_PER_TICK, "facing_degrees": 0.0, "target_entity_id": None, "hostile_factions": list(hostile_factions), "allied_factions": list(allied_factions), "hostile_entity_ids": list(hostile_entity_ids), "allied_entity_ids": list(allied_entity_ids), }, ), )