diff --git a/changelog.md b/changelog.md index ef259872..56449a10 100644 --- a/changelog.md +++ b/changelog.md @@ -46,6 +46,7 @@ * **[UX]** Reduce size of save-file by loading landmap data on the fly, which also implies no new campaign needs to be started to benefit from an updated landmap * **[New Game Wizard]** Ability to save an edited faction during new game creation * **[Options]** New option to make AI helicopters prefer vertical takeoff and landing +* **[Campaign Design/Mission Generation]** Introduction of "rebel zones" which randomly spawn units according to the campaign's definitions. ## Fixes * **[UI/UX]** A-10A flights can be edited again diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py index 76737a33..ebcdab5c 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -595,9 +595,18 @@ class MizCampaignLoader: self.add_preset_locations() self.add_supply_routes() self.add_shipping_lanes() + self.add_rebel_zones() def get_ctld_zones(self, prefix: str) -> List[Tuple[Point, float]]: zones = [t for t in self.mission.triggers.zones() if prefix + " CTLD" in t.name] for z in zones: self.mission.triggers.zones().remove(z) return [(z.position, z.radius) for z in zones] + + def add_rebel_zones(self) -> None: + zones = [ + t for t in self.mission.triggers.zones() if t.name.startswith("Rebels") + ] + for z in zones: + self.mission.triggers.zones().remove(z) + self.theater.add_rebel_zones(zones) diff --git a/game/migrator.py b/game/migrator.py index 5fed94e7..b0ad5539 100644 --- a/game/migrator.py +++ b/game/migrator.py @@ -42,6 +42,7 @@ class Migrator: self._update_weather() self._update_tgos() self._reload_terrain() + self._update_theather() # TODO: remove in due time as this is supposedly fixed self.game.settings.nevatim_parking_fix = False @@ -257,3 +258,7 @@ class Migrator: t = self.game.theater.terrain if issubclass(t.__class__, Terrain): self.game.theater.terrain = type(t)() # type: ignore + + def _update_theather(self) -> None: + if not hasattr(self.game.theater, "rebel_zones"): + self.game.theater.rebel_zones = [] diff --git a/game/missiongenerator/missiongenerator.py b/game/missiongenerator/missiongenerator.py index daa90484..b3ca7930 100644 --- a/game/missiongenerator/missiongenerator.py +++ b/game/missiongenerator/missiongenerator.py @@ -36,6 +36,7 @@ from .frontlineconflictdescription import FrontLineConflictDescription from .kneeboard import KneeboardGenerator from .luagenerator import LuaGenerator from .missiondata import MissionData +from .rebelliongenerator import RebellionGenerator from .tgogenerator import TgoGenerator from .triggergenerator import TriggerGenerator from .visualsgenerator import VisualsGenerator @@ -111,6 +112,7 @@ class MissionGenerator: self.generate_ground_conflicts() self.generate_air_units(tgo_generator) + RebellionGenerator(self.mission, self.game).generate() TriggerGenerator(self.mission, self.game).generate() ForcedOptionsGenerator(self.mission, self.game).generate() VisualsGenerator(self.mission, self.game).generate() diff --git a/game/missiongenerator/rebelliongenerator.py b/game/missiongenerator/rebelliongenerator.py new file mode 100644 index 00000000..c664aafa --- /dev/null +++ b/game/missiongenerator/rebelliongenerator.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import logging +import random +from typing import TYPE_CHECKING + +import numpy as np +import shapely.geometry +from dcs import Mission, Point +from dcs.country import Country +from dcs.triggers import TriggerZone, TriggerZoneCircular, TriggerZoneQuadPoint +from dcs.vehicles import vehicle_map + +from game.dcs.groundunittype import GroundUnitType +from game.naming import namegen + +if TYPE_CHECKING: + from game import Game + + +class RebellionGenerator: + def __init__(self, mission: Mission, game: Game) -> None: + self.mission = mission + self.game = game + + def generate(self) -> None: + ownfor_country = self.mission.country( + self.game.coalition_for(player=True).faction.country.name + ) + for rz in self.game.theater.ownfor_rebel_zones: + self._generate_rebel_zone(ownfor_country, rz) + opfor_country = self.mission.country( + self.game.coalition_for(player=False).faction.country.name + ) + for rz in self.game.theater.opfor_rebel_zones: + self._generate_rebel_zone(opfor_country, rz) + + def _generate_rebel_zone(self, ownfor_country: Country, rz: TriggerZone) -> None: + for i, key_value_dict in rz.properties.items(): + unit_id = key_value_dict["key"] + count_range = key_value_dict["value"] + if unit_id not in vehicle_map: + logging.warning( + f"Invalid unit_id '{unit_id}' in rebel zone '{rz.name}'" + ) + continue + + count, success = self._get_random_count_for_type(count_range) + if not success: + logging.warning( + f"Invalid count/range ({count_range}) for '{unit_id}' in rebel-zone '{rz.name}'" + ) + continue + unit_type = vehicle_map[unit_id] + for _ in range(count): + location = self.get_random_point_in_zone(rz) + group = self.mission.vehicle_group( + ownfor_country, + namegen.next_unit_name( + ownfor_country, next(GroundUnitType.for_dcs_type(unit_type)) + ), + unit_type, + location, + heading=random.random() * 360, + ) + group.hidden_on_mfd = True + group.hidden_on_planner = True + + def get_random_point_in_zone(self, zone: TriggerZone) -> Point: + if isinstance(zone, TriggerZoneCircular): + shape = shapely.geometry.Point(zone.position.x, zone.position.y).buffer( + zone.radius + ) + elif isinstance(zone, TriggerZoneQuadPoint): + shape = shapely.geometry.Polygon([[p.x, p.y] for p in zone.verticies]) + else: + raise RuntimeError("Incompatible trigger-zone") + minx, miny, maxx, maxy = shape.bounds + p = self._random_shapely_point(maxx, maxy, minx, miny) + while not shape.contains(p): + p = self._random_shapely_point(maxx, maxy, minx, miny) + return zone.position.new_in_same_map(p.x, p.y) + + @staticmethod + def _random_shapely_point( + maxx: float, maxy: float, minx: float, miny: float + ) -> shapely.geometry.Point: + x = np.random.uniform(minx, maxx) + y = np.random.uniform(miny, maxy) + p = shapely.geometry.Point(x, y) + return p + + @staticmethod + def _get_random_count_for_type(bounds: str) -> tuple[int, bool]: + parts = bounds.split("-") + if len(parts) == 1 and parts[0].isdigit(): + return int(parts[0]), True + elif len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit(): + return random.randint(int(parts[0]), int(parts[1])), True + else: + return 0, False diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index ff460b98..c71af9fa 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -8,6 +8,7 @@ from uuid import UUID from dcs.mapping import Point from dcs.terrain.terrain import Terrain +from dcs.triggers import TriggerZone from shapely import geometry, ops from .daytimemap import DaytimeMap @@ -43,6 +44,7 @@ class ConflictTheater: self.seasonal_conditions = seasonal_conditions self.daytime_map = daytime_map self.controlpoints: list[ControlPoint] = [] + self.rebel_zones: list[TriggerZone] = [] def __setstate__(self, state: dict[str, Any]) -> None: if "landmap_path" not in state: @@ -66,6 +68,25 @@ class ConflictTheater: return theater_dir / "landmap.p" raise RuntimeError(f"Could not determine landmap path for {terrain_name}") + def add_rebel_zones(self, zones: List[TriggerZone]) -> None: + self.rebel_zones.extend(zones) + + @property + def opfor_rebel_zones(self) -> Iterator[TriggerZone]: + for rz in self.rebel_zones: + if {1: 1, 2: 0, 3: 0} == { + k: v for k, v in rz.color.items() if k in [1, 2, 3] + }: + yield rz + + @property + def ownfor_rebel_zones(self) -> Iterator[TriggerZone]: + for rz in self.rebel_zones: + if {1: 0, 2: 0, 3: 1} == { + k: v for k, v in rz.color.items() if k in [1, 2, 3] + }: + yield rz + def add_controlpoint(self, point: ControlPoint) -> None: self.controlpoints.append(point)