Mi2dRPGamEng/resources/logic/turrets.py

157 lines
7.1 KiB
Python

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),
},
),
)