157 lines
7.1 KiB
Python
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),
|
|
},
|
|
),
|
|
)
|