diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py index c8a28d83..88130368 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -13,7 +13,6 @@ from dcs.planes import F_15C from dcs.ships import HandyWind, LHA_Tarawa, Stennis, USS_Arleigh_Burke_IIa from dcs.statics import Fortification, Warehouse from dcs.terrain import Airport -from dcs.triggers import TriggerZoneCircular from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed @@ -237,11 +236,7 @@ class MizCampaignLoader: @property def scenery(self) -> List[SceneryGroup]: - return SceneryGroup.from_trigger_zones( - z - for z in self.mission.triggers._zones - if isinstance(z, TriggerZoneCircular) - ) + return SceneryGroup.from_trigger_zones(z for z in self.mission.triggers.zones()) @cached_property def control_points(self) -> dict[UUID, ControlPoint]: diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index b647cd14..7b43e3e3 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -9,15 +9,13 @@ from __future__ import annotations import logging import random -from collections import defaultdict from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type import dcs.vehicles -from dcs import Mission, Point, unitgroup +from dcs import Mission, Point from dcs.action import DoScript, SceneryDestructionZone from dcs.condition import MapObjectIsDead from dcs.country import Country -from dcs.point import StaticPoint from dcs.ships import ( CVN_71, CVN_72, @@ -27,22 +25,29 @@ from dcs.ships import ( ) from dcs.statics import Fortification from dcs.task import ( + ActivateACLSCommand, ActivateBeaconCommand, ActivateICLSCommand, ActivateLink4Command, - ActivateACLSCommand, EPLRS, FireAtPoint, OptAlarmState, ) from dcs.translation import String -from dcs.triggers import Event, TriggerOnce, TriggerStart, TriggerZone -from dcs.unit import Unit, InvisibleFARP +from dcs.triggers import ( + Event, + TriggerOnce, + TriggerStart, + TriggerZone, + TriggerZoneCircular, + TriggerZoneQuadPoint, +) +from dcs.unit import InvisibleFARP, Unit from dcs.unitgroup import MovingGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.unittype import ShipType, VehicleType from dcs.vehicles import vehicle_map -from game.missiongenerator.missiondata import CarrierInfo, MissionData +from game.missiongenerator.missiondata import CarrierInfo, MissionData from game.radio.radios import RadioFrequency, RadioRegistry from game.radio.tacan import TacanBand, TacanChannel, TacanRegistry, TacanUsage from game.runways import RunwayData @@ -53,7 +58,7 @@ from game.theater.theatergroundobject import ( LhaGroundObject, MissileSiteGroundObject, ) -from game.theater.theatergroup import SceneryUnit, TheaterGroup, IadsGroundGroup +from game.theater.theatergroup import IadsGroundGroup, SceneryUnit from game.unitmap import UnitMap from game.utils import Heading, feet, knots, mps @@ -217,18 +222,17 @@ class GroundObjectGenerator: else {1: 1, 2: 0.2, 3: 0.2, 4: 0.15} ) - # Create the smallest valid size trigger zone (16 feet) so that risk of overlap - # is minimized. As long as the triggerzone is over the scenery object, we're ok. - smallest_valid_radius = feet(16).meters + trigger_zone: TriggerZone + if isinstance(scenery.zone, TriggerZoneCircular): + trigger_zone = self.create_circular_scenery_trigger(scenery.zone, color) + elif isinstance(scenery.zone, TriggerZoneQuadPoint): + trigger_zone = self.create_quad_scenery_trigger(scenery.zone, color) + else: + raise ValueError( + f"Invalid trigger zone type found for {scenery.name} in " + f"{self.ground_object.name}: {scenery.zone.__class__.__name__}" + ) - trigger_zone = self.m.triggers.add_triggerzone( - scenery.zone.position, - smallest_valid_radius, - scenery.zone.hidden, - scenery.zone.name, - color, - scenery.zone.properties, - ) # DCS only visually shows a scenery object is dead when # this trigger rule is applied. Otherwise you can kill a # structure twice. @@ -239,6 +243,34 @@ class GroundObjectGenerator: self.unit_map.add_scenery(scenery, trigger_zone) + def create_circular_scenery_trigger( + self, zone: TriggerZoneCircular, color: dict[int, float] + ) -> TriggerZoneCircular: + # Create the smallest valid size trigger zone (16 feet) so that risk of overlap + # is minimized. As long as the triggerzone is over the scenery object, we're ok. + smallest_valid_radius = feet(16).meters + + return self.m.triggers.add_triggerzone( + zone.position, + smallest_valid_radius, + zone.hidden, + zone.name, + color, + zone.properties, + ) + + def create_quad_scenery_trigger( + self, zone: TriggerZoneQuadPoint, color: dict[int, float] + ) -> TriggerZoneQuadPoint: + return self.m.triggers.add_triggerzone_quad( + zone.position, + zone.verticies, + zone.hidden, + zone.name, + color, + zone.properties, + ) + def generate_destruction_trigger_rule(self, trigger_zone: TriggerZone) -> None: # Add destruction zone trigger t = TriggerStart(comment="Destruction") diff --git a/game/scenery_group.py b/game/scenery_group.py index 200b6fca..64ea5301 100644 --- a/game/scenery_group.py +++ b/game/scenery_group.py @@ -3,7 +3,8 @@ from __future__ import annotations from typing import Iterable from dcs import Point -from dcs.triggers import TriggerZoneCircular +from dcs.triggers import TriggerZone, TriggerZoneCircular, TriggerZoneQuadPoint +from shapely.geometry import Point as ShapelyPoint, Polygon from game.theater.theatergroundobject import NAME_BY_CATEGORY @@ -16,7 +17,7 @@ class SceneryGroup: name: str, centroid: Point, category: str, - target_zones: Iterable[TriggerZoneCircular], + target_zones: Iterable[TriggerZone], ) -> None: if not target_zones: raise ValueError(f"{name} has no valid target zones") @@ -33,7 +34,7 @@ class SceneryGroup: self.target_zones = list(target_zones) @staticmethod - def category_of(group_zone: TriggerZoneCircular) -> str: + def category_of(group_zone: TriggerZone) -> str: try: # The first (1-indexed because lua) property of the group zone defines the # TGO category. @@ -46,8 +47,8 @@ class SceneryGroup: @staticmethod def from_group_zone( - group_zone: TriggerZoneCircular, - unclaimed_target_zones: list[TriggerZoneCircular], + group_zone: TriggerZone, + unclaimed_target_zones: list[TriggerZone], ) -> SceneryGroup: return SceneryGroup( group_zone.name, @@ -58,7 +59,7 @@ class SceneryGroup: @staticmethod def from_trigger_zones( - trigger_zones: Iterable[TriggerZoneCircular], + trigger_zones: Iterable[TriggerZone], ) -> list[SceneryGroup]: """Define scenery objectives based on their encompassing blue circle.""" group_zones, target_zones = SceneryGroup.collect_scenery_zones(trigger_zones) @@ -66,20 +67,28 @@ class SceneryGroup: @staticmethod def claim_targets_for( - group_zone: TriggerZoneCircular, - unclaimed_target_zones: list[TriggerZoneCircular], - ) -> list[TriggerZoneCircular]: + group_zone: TriggerZone, + unclaimed_target_zones: list[TriggerZone], + ) -> list[TriggerZone]: claimed_zones = [] - for zone in list(unclaimed_target_zones): - if zone.position.distance_to_point(group_zone.position) < group_zone.radius: - claimed_zones.append(zone) - unclaimed_target_zones.remove(zone) + group_poly = SceneryGroup.poly_for_zone(group_zone) + for target in list(unclaimed_target_zones): + # If the target zone is a quad point, the position is arbitrary but visible + # to the designer. It is the "X" that marks the trigger zone in the ME. The + # ME seems to place this at the centroid of the zone. If that X is in the + # group zone, that's claimed. + # + # See https://github.com/pydcs/dcs/pull/243#discussion_r1001369516 for more + # info. + if group_poly.contains(ShapelyPoint(target.position.x, target.position.y)): + claimed_zones.append(target) + unclaimed_target_zones.remove(target) return claimed_zones @staticmethod def collect_scenery_zones( - zones: Iterable[TriggerZoneCircular], - ) -> tuple[list[TriggerZoneCircular], list[TriggerZoneCircular]]: + zones: Iterable[TriggerZone], + ) -> tuple[list[TriggerZone], list[TriggerZone]]: group_zones = [] target_zones = [] for zone in zones: @@ -92,18 +101,36 @@ class SceneryGroup: return group_zones, target_zones @staticmethod - def zone_has_color_rgb( - zone: TriggerZoneCircular, r: float, g: float, b: float - ) -> bool: + def zone_has_color_rgb(zone: TriggerZone, r: float, g: float, b: float) -> bool: # TriggerZone.color is a dict with keys 1 through 4, each being a component of # RGBA. It's absurd that it's a dict, but that's a lua quirk that's leaking from # pydcs. return (zone.color[1], zone.color[2], zone.color[3]) == (r, g, b) @staticmethod - def is_group_zone(zone: TriggerZoneCircular) -> bool: + def is_group_zone(zone: TriggerZone) -> bool: return SceneryGroup.zone_has_color_rgb(zone, r=0, g=0, b=1) @staticmethod - def is_target_zone(zone: TriggerZoneCircular) -> bool: + def is_target_zone(zone: TriggerZone) -> bool: return SceneryGroup.zone_has_color_rgb(zone, r=1, g=1, b=1) + + @staticmethod + def poly_for_zone(zone: TriggerZone) -> Polygon: + if isinstance(zone, TriggerZoneCircular): + return SceneryGroup.poly_for_circular_zone(zone) + elif isinstance(zone, TriggerZoneQuadPoint): + return SceneryGroup.poly_for_quad_point_zone(zone) + else: + raise ValueError( + f"Invalid trigger zone type found for {zone.name}: " + f"{zone.__class__.__name__}" + ) + + @staticmethod + def poly_for_circular_zone(zone: TriggerZoneCircular) -> Polygon: + return ShapelyPoint(zone.position.x, zone.position.y).buffer(zone.radius) + + @staticmethod + def poly_for_quad_point_zone(zone: TriggerZoneQuadPoint) -> Polygon: + return Polygon([(p.x, p.y) for p in zone.verticies]) diff --git a/game/version.py b/game/version.py index 838c9066..8f02170c 100644 --- a/game/version.py +++ b/game/version.py @@ -166,4 +166,7 @@ VERSION = _build_version_string() #: #: Version 10.4 #: * Support for the Falklands. -CAMPAIGN_FORMAT_VERSION = (10, 4) +#: +#: Version 10.5 +#: * Support for scenery objectives defined by quad zones. +CAMPAIGN_FORMAT_VERSION = (10, 5)