diff --git a/game/debriefing.py b/game/debriefing.py index fcfbca9f..ce473ef4 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -117,7 +117,10 @@ class StateData: killed_aircraft=data["killed_aircrafts"], # Airfields emit a new "dead" event every time a bomb is dropped on # them when they've already dead. Dedup. - killed_ground_units=list(set(data["killed_ground_units"])), + # + # Also normalize dead map objects (which are ints) to strings. The unit map + # only stores strings. + killed_ground_units=list({str(u) for u in data["killed_ground_units"]}), destroyed_statics=data["destroyed_objects_positions"], base_capture_events=data["base_capture_events"], ) diff --git a/game/scenery_group.py b/game/scenery_group.py new file mode 100644 index 00000000..0015ab8f --- /dev/null +++ b/game/scenery_group.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from game.theater.theatergroundobject import NAME_BY_CATEGORY +from dcs.triggers import TriggerZone + +from typing import Iterable, List + + +class SceneryGroupError(RuntimeError): + """Error for when there are insufficient conditions to create a SceneryGroup.""" + + pass + + +class SceneryGroup: + """Store information about a scenery objective.""" + + def __init__( + self, zone_def: TriggerZone, zones: Iterable[TriggerZone], category: str + ) -> None: + + self.zone_def = zone_def + self.zones = zones + self.position = zone_def.position + self.category = category + + @staticmethod + def from_trigger_zones(trigger_zones: Iterable[TriggerZone]) -> List[SceneryGroup]: + """Define scenery objectives based on their encompassing blue/red circle.""" + zone_definitions = [] + white_zones = [] + + scenery_groups = [] + + # Aggregate trigger zones into different groups based on color. + for zone in trigger_zones: + if SceneryGroup.is_blue(zone): + zone_definitions.append(zone) + if SceneryGroup.is_white(zone): + white_zones.append(zone) + + # For each objective definition. + for zone_def in zone_definitions: + + zone_def_radius = zone_def.radius + zone_def_position = zone_def.position + zone_def_name = zone_def.name + + if len(zone_def.properties) == 0: + raise SceneryGroupError( + "Undefined SceneryGroup category in TriggerZone: " + zone_def_name + ) + + # Arbitrary campaign design requirement: First property must define the category. + zone_def_category = zone_def.properties[1].get("value").lower() + + valid_white_zones = [] + + for zone in list(white_zones): + if zone.position.distance_to_point(zone_def_position) < zone_def_radius: + valid_white_zones.append(zone) + white_zones.remove(zone) + + if len(valid_white_zones) > 0 and zone_def_category in NAME_BY_CATEGORY: + scenery_groups.append( + SceneryGroup(zone_def, valid_white_zones, zone_def_category) + ) + elif len(valid_white_zones) == 0: + raise SceneryGroupError( + "No white triggerzones found in: " + zone_def_name + ) + elif zone_def_category not in NAME_BY_CATEGORY: + raise SceneryGroupError( + "Incorrect TriggerZone category definition for: " + + zone_def_name + + " in campaign definition. TriggerZone category: " + + zone_def_category + ) + + return scenery_groups + + @staticmethod + def is_blue(zone: TriggerZone) -> bool: + # Blue in RGB is [0 Red], [0 Green], [1 Blue]. Ignore the fourth position: Transparency. + return zone.color[1] == 0 and zone.color[2] == 0 and zone.color[3] == 1 + + @staticmethod + def is_white(zone: TriggerZone) -> bool: + # White in RGB is [1 Red], [1 Green], [1 Blue]. Ignore the fourth position: Transparency. + return zone.color[1] == 1 and zone.color[2] == 1 and zone.color[3] == 1 diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 8a1257ee..f9c928ca 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -40,6 +40,10 @@ from dcs.unitgroup import ( VehicleGroup, ) from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed +from dcs.triggers import Triggers +from dcs.triggers import TriggerZone + +from ..scenery_group import SceneryGroup from pyproj import CRS, Transformer from shapely import geometry, ops @@ -258,6 +262,10 @@ class MizCampaignLoader: if group.units[0].type in self.FACTORY_UNIT_TYPE: yield group + @property + def scenery(self) -> List[SceneryGroup]: + return SceneryGroup.from_trigger_zones(self.mission.triggers._zones) + @cached_property def control_points(self) -> Dict[int, ControlPoint]: control_points = {} @@ -445,6 +453,10 @@ class MizCampaignLoader: PointWithHeading.from_point(group.position, group.units[0].heading) ) + for group in self.scenery: + closest, distance = self.objective_info(group) + closest.preset_locations.scenery.append(group) + def populate_theater(self) -> None: for control_point in self.control_points.values(): self.theater.add_controlpoint(control_point) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index f7714b72..bd6f45f6 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1,4 +1,5 @@ from __future__ import annotations +from game.scenery_group import SceneryGroup import heapq import itertools @@ -114,6 +115,9 @@ class PresetLocations: #: Locations of EWRs which should always be spawned. required_ewrs: List[PointWithHeading] = field(default_factory=list) + #: Locations of map scenery to create zones for. + scenery: List[SceneryGroup] = field(default_factory=list) + #: Locations of factories for producing ground units. These will always be spawned. factories: List[PointWithHeading] = field(default_factory=list) diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 0cb11d08..7b68a4ea 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -1,4 +1,5 @@ from __future__ import annotations +from game.scenery_group import SceneryGroup import logging import pickle @@ -23,6 +24,7 @@ from game.theater.theatergroundobject import ( MissileSiteGroundObject, SamGroundObject, ShipGroundObject, + SceneryGroundObject, VehicleGroupGroundObject, CoastalSiteGroundObject, ) @@ -463,6 +465,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): """Generate ground objects and AA sites for the control point.""" skip_sams = self.generate_required_aa() skip_ewrs = self.generate_required_ewr() + self.generate_scenery_sites() self.generate_factories() if self.control_point.is_global: @@ -651,6 +654,40 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): g.groups = [group] self.control_point.connected_objectives.append(g) + def generate_scenery_sites(self) -> None: + presets = self.control_point.preset_locations + for scenery_group in presets.scenery: + self.generate_tgo_for_scenery(scenery_group) + + def generate_tgo_for_scenery(self, scenery: SceneryGroup) -> None: + + obj_name = namegen.random_objective_name() + category = scenery.category + group_id = self.game.next_group_id() + object_id = 0 + + # Each nested trigger zone is a target/building/unit for an objective. + for zone in scenery.zones: + + object_id += 1 + local_position = zone.position + local_dcs_identifier = zone.name + + g = SceneryGroundObject( + obj_name, + category, + group_id, + object_id, + local_position, + self.control_point, + local_dcs_identifier, + zone, + ) + + self.control_point.connected_objectives.append(g) + + return + def generate_missile_sites(self) -> None: for i in range(self.faction.missiles_group_count): self.generate_missile_site() diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index b084b614..2922089c 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -7,6 +7,7 @@ from typing import Iterator, List, TYPE_CHECKING from dcs.mapping import Point from dcs.unit import Unit from dcs.unitgroup import Group +from dcs.triggers import TriggerZone, Triggers from .. import db from ..data.radar_db import UNITS_WITH_RADAR @@ -281,6 +282,42 @@ class BuildingGroundObject(TheaterGroundObject): self._dead = True +class SceneryGroundObject(BuildingGroundObject): + def __init__( + self, + name: str, + category: str, + group_id: int, + object_id: int, + position: Point, + control_point: ControlPoint, + dcs_identifier: str, + zone: TriggerZone, + ) -> None: + super().__init__( + name=name, + category=category, + group_id=group_id, + object_id=object_id, + position=position, + heading=0, + control_point=control_point, + dcs_identifier=dcs_identifier, + airbase_group=False, + ) + self.zone = zone + try: + # In the default TriggerZone using "assign as..." in the DCS Mission Editor, + # property three has the scenery's object ID as its value. + self.map_object_id = self.zone.properties[3]["value"] + except (IndexError, KeyError): + logging.exception( + "Invalid TriggerZone for Scenery definition. The third property must " + "be the map object ID." + ) + raise + + class FactoryGroundObject(BuildingGroundObject): def __init__( self, diff --git a/game/unitmap.py b/game/unitmap.py index dd1f527b..e8b6ac28 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -8,7 +8,7 @@ from dcs.unittype import VehicleType from game import db from game.theater import Airfield, ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import BuildingGroundObject +from game.theater.theatergroundobject import BuildingGroundObject, SceneryGroundObject from game.transfers import CargoShip, Convoy, TransferOrder from gen.flights.flight import Flight @@ -202,5 +202,15 @@ class UnitMap: raise RuntimeError(f"Duplicate TGO unit: {name}") self.buildings[name] = Building(ground_object) + def add_scenery(self, ground_object: SceneryGroundObject) -> None: + name = str(ground_object.map_object_id) + if name in self.buildings: + raise RuntimeError( + f"Duplicate TGO unit: {name}. TriggerZone name: " + f"{ground_object.dcs_identifier}" + ) + + self.buildings[name] = Building(ground_object) + def building_or_fortification(self, name: str) -> Optional[Building]: return self.buildings.get(name, None) diff --git a/game/version.py b/game/version.py index b48f3056..2c886538 100644 --- a/game/version.py +++ b/game/version.py @@ -44,4 +44,13 @@ VERSION = _build_version_string() #: * Bulker Handy Winds define shipping lanes. They should be placed in port areas that #: are navigable by ships and have a route to another port area. DCS ships *will not* #: avoid driving into islands, so ensure that their waypoints plot a navigable route. -CAMPAIGN_FORMAT_VERSION = 3 +#: +#: Version 4 +#: * TriggerZones define map based building targets. White TriggerZones created by right +#: clicking an object and using "assign as..." define the buildings within an objective. +#: Blue circular TriggerZones created normally must surround groups of one or more +#: white TriggerZones to define an objective. If a white TriggerZone is not surrounded +#: by a blue circular TriggerZone, campaign creation will fail. Blue circular +#: TriggerZones must also have their first property's value field define the type of +#: objective (a valid value for a building TGO category, from `game.db.PRICES`). +CAMPAIGN_FORMAT_VERSION = 4 diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index b397fdad..d6349c8e 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -12,6 +12,7 @@ import random from typing import Dict, Iterator, Optional, TYPE_CHECKING, Type, List from dcs import Mission, Point, unitgroup +from dcs.action import SceneryDestructionZone from dcs.country import Country from dcs.point import StaticPoint from dcs.statics import Fortification, fortification_map, warehouse_map, Warehouse @@ -22,6 +23,7 @@ from dcs.task import ( OptAlarmState, FireAtPoint, ) +from dcs.triggers import TriggerStart, TriggerZone from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad, Static from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup from dcs.unittype import StaticType, UnitType @@ -39,9 +41,10 @@ from game.theater.theatergroundobject import ( LhaGroundObject, ShipGroundObject, MissileSiteGroundObject, + SceneryGroundObject, ) from game.unitmap import UnitMap -from game.utils import knots, mps +from game.utils import feet, knots, mps from .radios import RadioFrequency, RadioRegistry from .runways import RunwayData from .tacan import TacanBand, TacanChannel, TacanRegistry @@ -259,6 +262,58 @@ class FactoryGenerator(BuildingSiteGenerator): self.generate_static(Fortification.Workshop_A) +class SceneryGenerator(BuildingSiteGenerator): + def generate(self) -> None: + assert isinstance(self.ground_object, SceneryGroundObject) + + trigger_zone = self.generate_trigger_zone(self.ground_object) + + # DCS only visually shows a scenery object is dead when + # this trigger rule is applied. Otherwise you can kill a + # structure twice. + if self.ground_object.is_dead: + self.generate_dead_trigger_rule(trigger_zone) + + # Tell Liberation to manage this groundobjectsgen as part of the campaign. + self.register_scenery() + + def generate_trigger_zone(self, scenery: SceneryGroundObject) -> TriggerZone: + + zone = scenery.zone + + # Align the trigger zones to the faction color on the DCS briefing/F10 map. + if scenery.is_friendly(to_player=True): + color = {1: 0.2, 2: 0.7, 3: 1, 4: 0.15} + else: + color = {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 + + return self.m.triggers.add_triggerzone( + zone.position, + smallest_valid_radius, + zone.hidden, + zone.name, + color, + zone.properties, + ) + + def generate_dead_trigger_rule(self, trigger_zone: TriggerZone) -> None: + # Add destruction zone trigger + t = TriggerStart(comment="Destruction") + t.actions.append( + SceneryDestructionZone(destruction_level=100, zone=trigger_zone.id) + ) + self.m.triggerrules.triggers.append(t) + + def register_scenery(self) -> None: + scenery = self.ground_object + assert isinstance(scenery, SceneryGroundObject) + self.unit_map.add_scenery(scenery) + + class GenericCarrierGenerator(GenericGroundObjectGenerator): """Base type for carrier group generation. @@ -576,6 +631,10 @@ class GroundObjectsGenerator: generator = FactoryGenerator( ground_object, country, self.game, self.m, self.unit_map ) + elif isinstance(ground_object, SceneryGroundObject): + generator = SceneryGenerator( + ground_object, country, self.game, self.m, self.unit_map + ) elif isinstance(ground_object, BuildingGroundObject): generator = BuildingSiteGenerator( ground_object, country, self.game, self.m, self.unit_map diff --git a/resources/campaigns/inherent_resolve.json b/resources/campaigns/inherent_resolve.json index 7d9143a6..256216a4 100644 --- a/resources/campaigns/inherent_resolve.json +++ b/resources/campaigns/inherent_resolve.json @@ -5,7 +5,7 @@ "recommended_player_faction": "USA 2005", "recommended_enemy_faction": "Insurgents (Hard)", "description": "
In this scenario, you start from Jordan, and have to fight your way through eastern Syria.
", - "version": 3, + "version": 4, "miz": "inherent_resolve.miz", "performance": 1 } \ No newline at end of file diff --git a/resources/campaigns/inherent_resolve.miz b/resources/campaigns/inherent_resolve.miz index 9fe585f8..5f3ac702 100644 Binary files a/resources/campaigns/inherent_resolve.miz and b/resources/campaigns/inherent_resolve.miz differ