From 5d038e8247f37169a0e1f069a3eb80041042f0e7 Mon Sep 17 00:00:00 2001 From: Druss99 <42724070+Druss99@users.noreply.github.com> Date: Sat, 2 Aug 2025 12:47:55 -0400 Subject: [PATCH] Add support for Control Point Influence Zones (#540) --- game/campaignloader/mizcampaignloader.py | 67 +++++++++++++++++++++++- game/controlpoint_influenceradius.py | 65 +++++++++++++++++++++++ game/theater/controlpoint.py | 7 +++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 game/controlpoint_influenceradius.py diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py index be722e02..7584ad3d 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -16,6 +16,7 @@ from dcs.terrain import Airport from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed +from game.controlpoint_influenceradius import ControlPointInfluenceRadius, point_in_zone from game.point_with_heading import PointWithHeading from game.positioned import Positioned from game.profiling import logged_duration @@ -24,6 +25,7 @@ from game.theater.controlpoint import ( Airfield, Carrier, ControlPoint, + ControlPointType, Fob, Lha, OffMapSpawn, @@ -278,6 +280,12 @@ class MizCampaignLoader: def scenery(self) -> List[SceneryGroup]: return SceneryGroup.from_trigger_zones(self.mission.triggers._zones) + @cached_property + def cp_influence_zones(self) -> List[ControlPointInfluenceRadius]: + return ControlPointInfluenceRadius.from_trigger_zones( + self.mission.triggers._zones + ) + @cached_property def control_points(self) -> dict[UUID, ControlPoint]: control_points = {} @@ -331,6 +339,11 @@ class MizCampaignLoader: control_point.captured_invert = fob.late_activation control_points[control_point.id] = control_point + if self.cp_influence_zones: + for cp in control_points.values(): + for influence_radius in self.cp_influence_zones: + if cp.full_name == influence_radius.cp_name: + cp.influence_radius = influence_radius return control_points @property @@ -490,7 +503,59 @@ class MizCampaignLoader: def objective_info( self, near: Positioned, allow_naval: bool = False ) -> Tuple[ControlPoint, Distance]: - closest = self.theater.closest_control_point(near.position, allow_naval) + zones_containing_point = [ + z + for z in self.cp_influence_zones + if point_in_zone(z.zone_def, near.position) + ] + + # Ensure we only consider naval control points if allow_naval is True + candidates = [ + self.theater.control_point_named(z.cp_name) for z in zones_containing_point + ] + if not allow_naval: + candidates = [ + cp + for cp in candidates + if cp.cptype + not in [ + ControlPointType.AIRCRAFT_CARRIER_GROUP, + ControlPointType.LHA_GROUP, + ] + ] + + if candidates: + closest = min( + candidates, key=lambda cp: cp.position.distance_to_point(near.position) + ) + distance = meters(closest.position.distance_to_point(near.position)) + return closest, distance + + # If no zones contain the point, find the closest control point without an influence radius + if not allow_naval: + fallback_candidates = [ + cp + for cp in self.theater.controlpoints + if cp.cptype + not in [ + ControlPointType.AIRCRAFT_CARRIER_GROUP, + ControlPointType.LHA_GROUP, + ] + ] + else: + fallback_candidates = self.theater.controlpoints + + fallback_candidates = [ + cp for cp in fallback_candidates if not cp.influence_radius + ] + if not fallback_candidates: + raise RuntimeError( + f"All control points have an influence zone but no zones contain {near} at {near.position}" + ) + closest = min( + fallback_candidates, + key=lambda cp: cp.position.distance_to_point(near.position), + ) distance = meters(closest.position.distance_to_point(near.position)) return closest, distance diff --git a/game/controlpoint_influenceradius.py b/game/controlpoint_influenceradius.py new file mode 100644 index 00000000..26625602 --- /dev/null +++ b/game/controlpoint_influenceradius.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Iterable, List, TYPE_CHECKING +from functools import cached_property + +from dcs.mapping import Polygon +from dcs.triggers import TriggerZone, TriggerZoneCircular, TriggerZoneQuadPoint + +from game.theater.theatergroundobject import NAME_BY_CATEGORY + +if TYPE_CHECKING: + from dcs.mapping import Point + + +class ControlPointInfluenceRadiusError(RuntimeError): + """Error for when there are insufficient conditions to create a ControlPointInfluenceRadius.""" + + pass + + +class ControlPointInfluenceRadius: + """Store information about a scenery objective.""" + + def __init__(self, zone_def: TriggerZone, cp_name: str) -> None: + self.zone_def = zone_def + self.position = zone_def.position + self.cp_name = cp_name + + @staticmethod + def from_trigger_zones( + trigger_zones: Iterable[TriggerZone], + ) -> List[ControlPointInfluenceRadius]: + """Define scenery objectives based on their encompassing blue/red circle.""" + zone_definitions = [] + cp_influence_zones = [] + + for zone in trigger_zones: + if ControlPointInfluenceRadius.is_red(zone): + zone_definitions.append(zone) + + # For each objective definition. + for zone_def in zone_definitions: + if len(zone_def.properties) == 0: + raise ControlPointInfluenceRadiusError( + "Undefined ControlPointInfluenceRadius category in TriggerZone: " + + zone_def.name + ) + zone_def_cp_name = zone_def.properties[1].get("value") + cp_influence_zones.append( + ControlPointInfluenceRadius(zone_def, zone_def_cp_name) + ) + return cp_influence_zones + + @staticmethod + def is_red(zone: TriggerZone) -> bool: + # Red in RGB is [1 Red], [0 Green], [0 Blue]. Ignore the fourth position: Transparency. + return zone.color[1] == 1 and zone.color[2] == 0 and zone.color[3] == 0 + + +def point_in_zone(zone: TriggerZone, pos: Point) -> bool: + if isinstance(zone, TriggerZoneCircular): + return zone.position.distance_to_point(pos) < zone.radius + elif isinstance(zone, TriggerZoneQuadPoint): + return Polygon(pos._terrain, zone.verticies).point_in_poly(pos) + raise RuntimeError(f"Invalid trigger-zone: {zone.name}") diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 7644d813..347587c5 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -47,6 +47,7 @@ from dcs.unitgroup import ShipGroup, StaticGroup from dcs.unittype import ShipType from game.ato.closestairfields import ObjectiveDistanceCache +from game.controlpoint_influenceradius import ControlPointInfluenceRadius from game.ground_forces.combat_stance import CombatStance from game.point_with_heading import PointWithHeading from game.runways import RunwayAssigner, RunwayData @@ -417,6 +418,8 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): # Initialized late because ControlPoints are constructed before the game is. self._front_line_db: Database[FrontLine] | None = None + self.influence_radius: ControlPointInfluenceRadius | None = None + def __repr__(self) -> str: return f"<{self.__class__}: {self.name}>" @@ -1210,6 +1213,7 @@ class Airfield(ControlPoint, CTLD): theater: ConflictTheater, starts_blue: bool, ctld_zones: Optional[List[Tuple[Point, float]]] = None, + influence_zone: Optional[List[Tuple[Point, float]]] = None, ) -> None: super().__init__( airport.name, @@ -1222,6 +1226,7 @@ class Airfield(ControlPoint, CTLD): self.airport = airport self._runway_status = RunwayStatus() self.ctld_zones = ctld_zones + self.influence_zone = influence_zone @property def dcs_airport(self) -> Airport: @@ -1628,6 +1633,7 @@ class Fob(ControlPoint, RadioFrequencyContainer, CTLD): starts_blue: bool, ctld_zones: Optional[List[Tuple[Point, float]]] = None, is_invisible: bool = False, + influence_zone: Optional[List[Tuple[Point, float]]] = None, ) -> None: super().__init__( name, at, at, theater, starts_blue, cptype=ControlPointType.FOB @@ -1635,6 +1641,7 @@ class Fob(ControlPoint, RadioFrequencyContainer, CTLD): self.name = name self.ctld_zones = ctld_zones self.is_invisible = is_invisible + self.influence_zone = influence_zone @property def symbol_set_and_entity(self) -> tuple[SymbolSet, Entity]: