diff --git a/.github/ISSUE_TEMPLATE/campaign_update.md b/.github/ISSUE_TEMPLATE/campaign_update.md new file mode 100644 index 00000000..6e0882ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/campaign_update.md @@ -0,0 +1,28 @@ +--- +name: Campaign update submission +about: Submit an update to a campaign you maintain. +title: 'Update for ' +labels: campaign-update-submission +assignees: '' + +--- + +This form should only be used for submitted updated miz/json files for campaigns +distributed with Liberation. If you are _requesting_ an update to a campaign, see +https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance. If the +campaign has an owner, it will be updated before release. If it does not, you can +volunteer to own it. + +If you are not the owner of the campaign listed on +https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance, please start +there. + +Otherwise, delete everything above the line below and fill out the following form. Note: +GitHub does not accept .miz files. You can either rename the file to .miz.txt or add the +file to a .zip file. + +--- + +* Campaign name: +* Files: +* Update summary (optional): \ No newline at end of file diff --git a/changelog.md b/changelog.md index 34de24eb..f2d8f945 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +# 4.0.0 + +Saves from 3.x are not compatible with 4.0. + +## Features/Improvements + +## Fixes + # 3.0.0 Saves from 2.5 are not compatible with 3.0. @@ -9,9 +17,14 @@ Saves from 2.5 are not compatible with 3.0. * **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn. * **[Campaign]** Non-control point FOBs will no longer spawn. * **[Campaign]** Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information. +* **[Campaign]** Capturing a base now depopulates all of its attached objectives with units: air defenses, EWRs, ships, armor groups, etc. Buildings are captured. +* **[Campaign]** Ammunition Depots determine how many ground units can be deployed on the frontline by a control point. * **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions. * **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP. * **[Campaign AI]** AI now considers the range to the SAM's threat zone rather than the range to the SAM itself when determining target priorities. +* **[Campaign AI]** Auto purchase of ground units will now maintain unit composition instead of buying randomly. The unit composition is predefined. +* **[Campaign AI]** Auto purchase will aim to purchase enough ground units to support the frontline, plus 30% reserve units. +* **[Campaign AI]** Auto purchase will now adjust its air/ground balance to favor whichever is under-funded. * **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time. * **[Flight Planner]** Flight plans now include bullseye waypoints. * **[Flight Planner]** Differentiated SEAD and SEAD escort. SEAD is tasked with suppressing the package target, SEAD escort is tasked with protecting the package from all SAMs along its route. @@ -22,12 +35,15 @@ Saves from 2.5 are not compatible with 3.0. * **[Flight Planner]** Automatic ATO generation for the player's coalition can now be disabled in the settings. * **[Payloads]** AI flights for most air to ground mission types (CAS excluded) will have their guns emptied to prevent strafing fully armed and operational battle stations. Gun-reliant airframes like A-10s and warbirds will keep their bullets. * **[Kneeboard]** ATC table overflow alleviated by wrapping long airfield names and splitting ATC frequency and channel into separate rows. -* **[UI]** Added new web based map UI. This is mostly functional but many of the old display options are a WIP. Revert to the old map with --old-map. +* **[UI]** Overhauled the map implementation. Now uses satellite imagery instead of low res map images. Display options have moved from the toolbar to panels in the map. * **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present. * **[UI]** DCS loadouts are now selectable in the loadout setup menu. +* **[UI]** Added global aircraft inventory view under Air Wing dialog. +* **[UI]** Base menu now shows information about ground unit deployment limits. * **[Modding]** Campaigns now choose locations for factories to spawn. +* **[Modding]** Campaigns now choose locations for ammunition depots to spawn. * **[Modding]** Campaigns now use map structures as strike targets. -* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance. +* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance. Support for random objective generation was removed. * **[Modding]** Campaigns may now place AAA objectives. * **[Modding]** Can now install custom factions to /Liberation/Factions instead of the Liberation install directory. * **[Performance Settings]** Added a settings to lower the number of smoke effects generated on frontlines. Lowered default settings for frontline smoke generators, so less smoke should be generated by default. @@ -41,8 +57,10 @@ Saves from 2.5 are not compatible with 3.0. * **[Campaign AI]** Auto planner will no longer attempt to plan missions for which the faction has no compatible aircraft. * **[Campaign AI]** Stop purchasing aircraft after the first unaffordable package to attempt to complete more packages rather than filling airfields with cheap escorts that will never be used. * **[Campaign]** Fixed bug where offshore strike locations were being used to spawn ship objectives. +* **[Campaign]** EWR sites are now purchasable. * **[Flight Planner]** AI strike flight plans now include the correct target actions for building groups. * **[Flight Planner]** AI BAI/DEAD/SEAD flights now have tasks to attack all groups at the target location, not just the primary group (for multi-group SAM sites). +* **[Flight Planner]** Fixed some contexts where damaged runways would be used. Destroying a carrier will no longer break the game. # 2.5.1 diff --git a/game/data/__init__.py b/game/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/data/building_data.py b/game/data/building_data.py index c3bd62d9..9b0dd2a4 100644 --- a/game/data/building_data.py +++ b/game/data/building_data.py @@ -11,7 +11,7 @@ DEFAULT_AVAILABLE_BUILDINGS = [ "derrick", ] -WW2_FREE = ["fuel", "ware", "fob"] +WW2_FREE = ["fuel", "ware"] WW2_GERMANY_BUILDINGS = [ "fuel", "ww2bunker", diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 31b0a03b..262d5fa5 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,7 +1,20 @@ from dataclasses import dataclass from datetime import timedelta +from dcs.task import Reconnaissance from game.utils import Distance, feet, nautical_miles +from game.data.groundunitclass import GroundUnitClass + + +@dataclass +class GroundUnitProcurementRatios: + ratios: dict[GroundUnitClass, float] + + def for_unit_class(self, unit_class: GroundUnitClass) -> float: + try: + return self.ratios[unit_class] / sum(self.ratios.values()) + except KeyError: + return 0.0 @dataclass(frozen=True) @@ -50,6 +63,8 @@ class Doctrine: sweep_distance: Distance + ground_unit_procurement_ratios: GroundUnitProcurementRatios + MODERN_DOCTRINE = Doctrine( cap=True, @@ -76,6 +91,17 @@ MODERN_DOCTRINE = Doctrine( cap_engagement_range=nautical_miles(50), cas_duration=timedelta(minutes=30), sweep_distance=nautical_miles(60), + ground_unit_procurement_ratios=GroundUnitProcurementRatios( + { + GroundUnitClass.Tank: 3, + GroundUnitClass.Atgm: 2, + GroundUnitClass.Apc: 2, + GroundUnitClass.Ifv: 3, + GroundUnitClass.Artillery: 1, + GroundUnitClass.Shorads: 2, + GroundUnitClass.Recon: 1, + } + ), ) COLDWAR_DOCTRINE = Doctrine( @@ -103,6 +129,17 @@ COLDWAR_DOCTRINE = Doctrine( cap_engagement_range=nautical_miles(35), cas_duration=timedelta(minutes=30), sweep_distance=nautical_miles(40), + ground_unit_procurement_ratios=GroundUnitProcurementRatios( + { + GroundUnitClass.Tank: 4, + GroundUnitClass.Atgm: 2, + GroundUnitClass.Apc: 3, + GroundUnitClass.Ifv: 2, + GroundUnitClass.Artillery: 1, + GroundUnitClass.Shorads: 2, + GroundUnitClass.Recon: 1, + } + ), ) WWII_DOCTRINE = Doctrine( @@ -130,4 +167,14 @@ WWII_DOCTRINE = Doctrine( cap_engagement_range=nautical_miles(20), cas_duration=timedelta(minutes=30), sweep_distance=nautical_miles(10), + ground_unit_procurement_ratios=GroundUnitProcurementRatios( + { + GroundUnitClass.Tank: 3, + GroundUnitClass.Atgm: 3, + GroundUnitClass.Apc: 3, + GroundUnitClass.Artillery: 1, + GroundUnitClass.Shorads: 3, + GroundUnitClass.Recon: 1, + } + ), ) diff --git a/game/data/groundunitclass.py b/game/data/groundunitclass.py new file mode 100644 index 00000000..4b2f6e58 --- /dev/null +++ b/game/data/groundunitclass.py @@ -0,0 +1,239 @@ +from enum import unique, Enum +from typing import Type + +from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor +from dcs.unittype import VehicleType + +from pydcs_extensions.frenchpack import frenchpack + + +@unique +class GroundUnitClass(Enum): + Tank = ( + "Tank", + ( + Armor.MBT_T_55, + Armor.MBT_T_72B, + Armor.MBT_T_72B3, + Armor.MBT_T_80U, + Armor.MBT_T_90, + Armor.MBT_Leopard_2A4, + Armor.MBT_Leopard_2A4_Trs, + Armor.MBT_Leopard_2A5, + Armor.MBT_Leopard_2A6M, + Armor.MBT_Leopard_1A3, + Armor.MBT_Leclerc, + Armor.MBT_Challenger_II, + Armor.MBT_Chieftain_Mk_3, + Armor.MBT_M1A2_Abrams, + Armor.MBT_M60A3_Patton, + Armor.MBT_Merkava_IV, + Armor.ZTZ_96B, + # WW2 + # Axis + Armor.Tk_PzIV_H, + Armor.SPG_Sturmpanzer_IV_Brummbar, + Armor.MT_Pz_Kpfw_V_Panther_Ausf_G, + Armor.HT_Pz_Kpfw_VI_Tiger_I, + Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II, + # Allies + Armor.Tk_M4_Sherman, + Armor.CT_Centaur_IV, + Armor.CT_Cromwell_IV, + Armor.HIT_Churchill_VII, + # Mods + frenchpack.DIM__TOYOTA_BLUE, + frenchpack.DIM__TOYOTA_GREEN, + frenchpack.DIM__TOYOTA_DESERT, + frenchpack.DIM__KAMIKAZE, + frenchpack.AMX_30B2, + frenchpack.Leclerc_Serie_XXI, + ), + ) + + Atgm = ( + "ATGM", + ( + Armor.ATGM_HMMWV, + Armor.ATGM_VAB_Mephisto, + Armor.ATGM_Stryker, + Armor.IFV_BMP_2, + # WW2 (Tank Destroyers) + # Axxis + Armor.SPG_StuG_III_Ausf__G, + Armor.SPG_StuG_IV, + Armor.SPG_Jagdpanzer_IV, + Armor.SPG_Jagdpanther_G1, + Armor.SPG_Sd_Kfz_184_Elefant, + # Allies + Armor.SPG_M10_GMC, + Armor.MT_M4A4_Sherman_Firefly, + # Mods + frenchpack.VBAE_CRAB_MMP, + frenchpack.VAB_MEPHISTO, + frenchpack.TRM_2000_PAMELA, + ), + ) + + Ifv = ( + "IFV", + ( + Armor.IFV_BMP_3, + Armor.IFV_BMP_2, + Armor.IFV_BMP_1, + Armor.IFV_Marder, + Armor.IFV_Warrior, + Armor.SPG_Stryker_MGS, + Armor.IFV_M2A2_Bradley, + Armor.IFV_BMD_1, + Armor.ZBD_04A, + # Mods + frenchpack.VBAE_CRAB, + frenchpack.VAB_T20_13, + ), + ) + + Apc = ( + "APC", + ( + Armor.IFV_M1126_Stryker_ICV, + Armor.APC_M113, + Armor.APC_BTR_80, + Armor.IFV_BTR_82A, + Armor.APC_MTLB, + Armor.APC_AAV_7_Amphibious, + Armor.APC_TPz_Fuchs, + Armor.APC_BTR_RD, + # WW2 + Armor.APC_M2A1_Halftrack, + Armor.APC_Sd_Kfz_251_Halftrack, + # Mods + frenchpack.VAB__50, + frenchpack.VBL__50, + frenchpack.VBL_AANF1, + ), + ) + + Artillery = ( + "Artillery", + ( + Artillery.Grad_MRL_FDDM__FC, + Artillery.MLRS_9A52_Smerch_HE_300mm, + Artillery.SPH_2S1_Gvozdika_122mm, + Artillery.SPH_2S3_Akatsia_152mm, + Artillery.MLRS_BM_21_Grad_122mm, + Artillery.MLRS_9K57_Uragan_BM_27_220mm, + Artillery.SPH_M109_Paladin_155mm, + Artillery.MLRS_M270_227mm, + Artillery.SPM_2S9_Nona_120mm_M, + Artillery.SPH_Dana_vz77_152mm, + Artillery.SPH_T155_Firtina_155mm, + Artillery.PLZ_05, + Artillery.SPH_2S19_Msta_152mm, + Artillery.MLRS_9A52_Smerch_CM_300mm, + # WW2 + Artillery.SPG_M12_GMC_155mm, + ), + ) + + Logistics = ( + "Logistics", + ( + Unarmed.Carrier_M30_Cargo, + Unarmed.Truck_M818_6x6, + Unarmed.Truck_KAMAZ_43101, + Unarmed.Truck_Ural_375, + Unarmed.Truck_GAZ_66, + Unarmed.Truck_GAZ_3307, + Unarmed.Truck_GAZ_3308, + Unarmed.Truck_Ural_4320_31_Arm_d, + Unarmed.Truck_Ural_4320T, + Unarmed.Truck_Opel_Blitz, + Unarmed.LUV_Kubelwagen_82, + Unarmed.Carrier_Sd_Kfz_7_Tractor, + Unarmed.LUV_Kettenrad, + Unarmed.Car_Willys_Jeep, + Unarmed.LUV_Land_Rover_109, + Unarmed.Truck_Land_Rover_101_FC, + # Mods + frenchpack.VBL, + frenchpack.VAB, + ), + ) + + Recon = ( + "Recon", + ( + Armor.Scout_HMMWV, + Armor.Scout_Cobra, + Armor.LT_PT_76, + Armor.IFV_LAV_25, + Armor.Scout_BRDM_2, + # WW2 + Armor.LT_Mk_VII_Tetrarch, + Armor.IFV_Sd_Kfz_234_2_Puma, + Armor.Car_M8_Greyhound_Armored, + Armor.Car_Daimler_Armored, + # Mods + frenchpack.ERC_90, + frenchpack.AMX_10RCR, + frenchpack.AMX_10RCR_SEPAR, + ), + ) + + Infantry = ( + "Infantry", + ( + Infantry.Insurgent_AK_74, + Infantry.Infantry_AK_74, + Infantry.Infantry_M1_Garand, + Infantry.Infantry_Mauser_98, + Infantry.Infantry_SMLE_No_4_Mk_1, + Infantry.Infantry_M4_Georgia, + Infantry.Infantry_AK_74_Rus, + Infantry.Paratrooper_AKS, + Infantry.Paratrooper_RPG_16, + Infantry.Infantry_M249, + Infantry.Infantry_M4, + Infantry.Infantry_RPG, + ), + ) + + Shorads = ( + "SHORADS", + ( + AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375, + AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375, + AirDefence.SPAAA_ZSU_57_2, + AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish, + AirDefence.SAM_SA_8_Osa_Gecko_TEL, + AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL, + AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL, + AirDefence.SAM_SA_15_Tor_Gauntlet, + AirDefence.SAM_SA_19_Tunguska_Grison, + AirDefence.SPAAA_Gepard, + AirDefence.SPAAA_Vulcan_M163, + AirDefence.SAM_Linebacker___Bradley_M6, + AirDefence.SAM_Chaparral_M48, + AirDefence.SAM_Avenger__Stinger, + AirDefence.SAM_Roland_ADS, + AirDefence.HQ_7_Self_Propelled_LN, + AirDefence.AAA_8_8cm_Flak_18, + AirDefence.AAA_8_8cm_Flak_36, + AirDefence.AAA_8_8cm_Flak_37, + AirDefence.AAA_8_8cm_Flak_41, + AirDefence.AAA_Bofors_40mm, + AirDefence.AAA_S_60_57mm, + AirDefence.AAA_M1_37mm, + AirDefence.AAA_QF_3_7, + ), + ) + + def __init__( + self, class_name: str, unit_list: tuple[Type[VehicleType], ...] + ) -> None: + self.class_name = class_name + self.unit_list = unit_list + + def __contains__(self, unit_type: Type[VehicleType]) -> bool: + return unit_type in self.unit_list diff --git a/game/db.py b/game/db.py index c88d1aa7..d4142bcc 100644 --- a/game/db.py +++ b/game/db.py @@ -1251,6 +1251,7 @@ REWARDS = { "ammo": 2, "farp": 1, # TODO: Should generate no cash once they generate units. + # https://github.com/dcs-liberation/dcs_liberation/issues/1036 "factory": 10, "comms": 10, "oil": 10, diff --git a/game/debriefing.py b/game/debriefing.py index 857c4143..88c9f8ae 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -24,7 +24,7 @@ from game import db from game.theater import Airfield, ControlPoint from game.transfers import CargoShip from game.unitmap import ( - AirliftUnit, + AirliftUnits, Building, ConvoyUnit, FrontLineUnit, @@ -75,8 +75,8 @@ class GroundLosses: player_cargo_ships: List[CargoShip] = field(default_factory=list) enemy_cargo_ships: List[CargoShip] = field(default_factory=list) - player_airlifts: List[AirliftUnit] = field(default_factory=list) - enemy_airlifts: List[AirliftUnit] = field(default_factory=list) + player_airlifts: List[AirliftUnits] = field(default_factory=list) + enemy_airlifts: List[AirliftUnits] = field(default_factory=list) player_ground_objects: List[GroundObjectUnit] = field(default_factory=list) enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list) @@ -160,7 +160,7 @@ class Debriefing: yield from self.ground_losses.enemy_cargo_ships @property - def airlift_losses(self) -> Iterator[AirliftUnit]: + def airlift_losses(self) -> Iterator[AirliftUnits]: yield from self.ground_losses.player_airlifts yield from self.ground_losses.enemy_airlifts @@ -220,7 +220,8 @@ class Debriefing: else: losses = self.ground_losses.enemy_airlifts for loss in losses: - losses_by_type[loss.unit_type] += 1 + for unit_type in loss.cargo: + losses_by_type[unit_type] += 1 return losses_by_type def building_losses_by_type(self, player: bool) -> Dict[str, int]: diff --git a/game/event/event.py b/game/event/event.py index d3bc1adb..ae1c3a4a 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -144,7 +144,7 @@ class Event: def _commit_pilot_experience(ato: AirTaskingOrder) -> None: for package in ato.packages: for flight in package.flights: - for idx, pilot in enumerate(flight.pilots): + for idx, pilot in enumerate(flight.roster.pilots): if pilot is None: logging.error( f"Cannot award experience to pilot #{idx} of {flight} " @@ -202,19 +202,17 @@ class Event: @staticmethod def commit_airlift_losses(debriefing: Debriefing) -> None: for loss in debriefing.airlift_losses: - unit_type = loss.unit_type transfer = loss.transfer - available = loss.transfer.units.get(unit_type, 0) airlift_name = f"airlift from {transfer.origin} to {transfer.destination}" - if available <= 0: - logging.error( - f"Found killed {unit_type} in {airlift_name} but that airlift has " - "none available." - ) - continue - - logging.info(f"{unit_type} destroyed in {airlift_name}") - transfer.kill_unit(unit_type) + for unit_type in loss.cargo: + try: + transfer.kill_unit(unit_type) + logging.info(f"{unit_type} destroyed in {airlift_name}") + except KeyError: + logging.exception( + f"Found killed {unit_type} in {airlift_name} but that airlift " + "has none available." + ) @staticmethod def commit_ground_object_losses(debriefing: Debriefing) -> None: diff --git a/game/factions/faction.py b/game/factions/faction.py index 147b7c08..ef06ef0d 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -1,4 +1,5 @@ from __future__ import annotations +from game.data.groundunitclass import GroundUnitClass import logging from dataclasses import dataclass, field @@ -133,6 +134,16 @@ class Faction: #: both will use it. unrestricted_satnav: bool = False + def has_access_to_unittype(self, unitclass: GroundUnitClass) -> bool: + has_access = False + for vehicle in unitclass.unit_list: + if vehicle in self.frontline_units: + return True + if vehicle in self.artillery_units: + return True + + return has_access + @classmethod def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction: faction = Faction(locales=json.get("locales")) diff --git a/game/game.py b/game/game.py index 7ddf3733..a6d3c97b 100644 --- a/game/game.py +++ b/game/game.py @@ -113,8 +113,6 @@ class Game: self.informations.append(Information("Game Start", "-" * 40, 0)) # Culling Zones are for areas around points of interest that contain things we may not wish to cull. self.__culling_zones: List[Point] = [] - # Culling Points are for individual theater ground objects that we don't wish to cull. - self.__culling_points: List[Point] = [] self.__destroyed_units: List[str] = [] self.savepath = "" self.budget = player_budget @@ -124,8 +122,8 @@ class Game: self.conditions = self.generate_conditions() - self.blue_transit_network = self.compute_transit_network_for(player=True) - self.red_transit_network = self.compute_transit_network_for(player=False) + self.blue_transit_network = TransitNetwork() + self.red_transit_network = TransitNetwork() self.blue_procurement_requests: List[AircraftProcurementRequest] = [] self.red_procurement_requests: List[AircraftProcurementRequest] = [] @@ -148,7 +146,7 @@ class Game: self.blue_air_wing = AirWing(self, player=True) self.red_air_wing = AirWing(self, player=False) - self.on_load() + self.on_load(game_still_initializing=True) def __getstate__(self) -> Dict[str, Any]: state = self.__dict__.copy() @@ -301,11 +299,12 @@ class Game: else: raise RuntimeError(f"{event} was passed when an Event type was expected") - def on_load(self) -> None: + def on_load(self, game_still_initializing: bool = False) -> None: LuaPluginManager.load_settings(self.settings) ObjectiveDistanceCache.set_theater(self.theater) self.compute_conflicts_position() - self.compute_threat_zones() + if not game_still_initializing: + self.compute_threat_zones() self.blue_faker = Faker(self.faction_for(player=True).locales) self.red_faker = Faker(self.faction_for(player=False).locales) @@ -439,8 +438,8 @@ class Game: # gets much more of the budget that turn. Otherwise budget (after # repairs) is split evenly between air and ground. For the default # starting budget of 2000 this gives 600 to ground forces and 1400 to - # aircraft. - ground_portion = 0.3 if self.turn == 0 else 0.5 + # aircraft. After that the budget will be spend proportionally based on how much is already invested + self.budget = ProcurementAi( self, for_player=True, @@ -448,7 +447,6 @@ class Game: manage_runways=self.settings.automate_runway_repair, manage_front_line=self.settings.automate_front_line_reinforcements, manage_aircraft=self.settings.automate_aircraft_reinforcements, - front_line_budget_share=ground_portion, ).spend_budget(self.budget) self.enemy_budget = ProcurementAi( @@ -458,7 +456,6 @@ class Game: manage_runways=True, manage_front_line=True, manage_aircraft=True, - front_line_budget_share=ground_portion, ).spend_budget(self.enemy_budget) def message(self, text: str) -> None: @@ -519,7 +516,6 @@ class Game: :return: List of points of interests """ zones = [] - points = [] # By default, use the existing frontline conflict position for front_line in self.theater.conflicts(): @@ -529,11 +525,6 @@ class Game: zones.append(front_line.red_cp.position) for cp in self.theater.controlpoints: - # Don't cull missile sites - their range is long enough to make them - # easily culled despite being a threat. - for tgo in cp.ground_objects: - if isinstance(tgo, MissileSiteGroundObject): - points.append(tgo.position) # If do_not_cull_carrier is enabled, add carriers as culling point if self.settings.perf_do_not_cull_carrier: if cp.is_carrier or cp.is_lha: @@ -577,7 +568,6 @@ class Game: zones.append(Point(0, 0)) self.__culling_zones = zones - self.__culling_points = points def add_destroyed_units(self, data): pos = Point(data["x"], data["z"]) @@ -593,19 +583,12 @@ class Game: :param pos: Position you are tryng to spawn stuff at :return: True if units can not be added at given position """ - if self.settings.perf_culling == False: + if not self.settings.perf_culling: return False - else: - for z in self.__culling_zones: - if ( - z.distance_to_point(pos) - < self.settings.perf_culling_distance * 1000 - ): - return False - for p in self.__culling_points: - if p.distance_to_point(pos) < 2500: - return False - return True + for z in self.__culling_zones: + if z.distance_to_point(pos) < self.settings.perf_culling_distance * 1000: + return False + return True def get_culling_zones(self): """ @@ -614,13 +597,6 @@ class Game: """ return self.__culling_zones - def get_culling_points(self): - """ - Check culling points - :return: List of culling points - """ - return self.__culling_points - # 1 = red, 2 = blue def get_player_coalition_id(self): return 2 diff --git a/game/procurement.py b/game/procurement.py index b7222dc4..021014ce 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -8,17 +8,19 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple, Type from dcs.unittype import FlyingType, VehicleType from game import db +from game.data.groundunitclass import GroundUnitClass from game.factions.faction import Faction from game.theater import ControlPoint, MissionTarget from game.utils import Distance from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType -from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD if TYPE_CHECKING: from game import Game +FRONTLINE_RESERVES_FACTOR = 1.3 + @dataclass(frozen=True) class AircraftProcurementRequest: @@ -43,10 +45,7 @@ class ProcurementAi: manage_runways: bool, manage_front_line: bool, manage_aircraft: bool, - front_line_budget_share: float, ) -> None: - if front_line_budget_share > 1.0: - raise ValueError self.game = game self.is_player = for_player @@ -55,14 +54,34 @@ class ProcurementAi: self.manage_runways = manage_runways self.manage_front_line = manage_front_line self.manage_aircraft = manage_aircraft - self.front_line_budget_share = front_line_budget_share self.threat_zones = self.game.threat_zone_for(not self.is_player) + def calculate_ground_unit_budget_share(self) -> float: + armor_investment = 0 + aircraft_investment = 0 + for cp in self.owned_points: + cp_ground_units = cp.allocated_ground_units(self.game.transfers) + armor_investment += cp_ground_units.total_value + cp_aircraft = cp.allocated_aircraft(self.game) + aircraft_investment += cp_aircraft.total_value + + total_investment = aircraft_investment + armor_investment + if total_investment == 0: + # Turn 0 or all units were destroyed. Either way, split 30/70. + return 0.3 + + # the more planes we have, the more ground units we want and vice versa + ground_unit_share = aircraft_investment / total_investment + if ground_unit_share > 1.0: + raise ValueError + + return ground_unit_share + def spend_budget(self, budget: float) -> float: if self.manage_runways: budget = self.repair_runways(budget) if self.manage_front_line: - armor_budget = math.ceil(budget * self.front_line_budget_share) + armor_budget = budget * self.calculate_ground_unit_budget_share() budget -= armor_budget budget += self.reinforce_front_line(armor_budget) @@ -114,28 +133,14 @@ class ProcurementAi: ) return budget - def random_affordable_ground_unit( - self, budget: float, cp: ControlPoint + def affordable_ground_unit_of_class( + self, budget: float, unit_class: GroundUnitClass ) -> Optional[Type[VehicleType]]: - affordable_units = [ - u - for u in self.faction.frontline_units + self.faction.artillery_units - if db.PRICES[u] <= budget - ] - - total_number_aa = ( - cp.base.total_frontline_aa + cp.pending_frontline_aa_deliveries_count + faction_units = set(self.faction.frontline_units) | set( + self.faction.artillery_units ) - total_non_aa = ( - cp.base.total_armor + cp.pending_deliveries_count - total_number_aa - ) - max_aa = math.ceil(total_non_aa / 8) - - # Limit the number of AA units the AI will buy - if not total_number_aa < max_aa: - for unit in [u for u in affordable_units if u in TYPE_SHORAD]: - affordable_units.remove(unit) - + of_class = set(unit_class.unit_list) & faction_units + affordable_units = [u for u in of_class if db.PRICES[u] <= budget] if not affordable_units: return None return random.choice(affordable_units) @@ -147,12 +152,12 @@ class ProcurementAi: # TODO: Attempt to transfer from reserves. while budget > 0: - candidates = self.front_line_candidates() - if not candidates: + cp = self.ground_reinforcement_candidate() + if cp is None: break - cp = random.choice(candidates) - unit = self.random_affordable_ground_unit(budget, cp) + most_needed_type = self.most_needed_unit_class(cp) + unit = self.affordable_ground_unit_of_class(budget, most_needed_type) if unit is None: # Can't afford any more units. break @@ -162,6 +167,31 @@ class ProcurementAi: return budget + def most_needed_unit_class(self, cp: ControlPoint) -> GroundUnitClass: + worst_balanced: Optional[GroundUnitClass] = None + worst_fulfillment = math.inf + for unit_class in GroundUnitClass: + if not self.faction.has_access_to_unittype(unit_class): + continue + + current_ratio = self.cost_ratio_of_ground_unit(cp, unit_class) + desired_ratio = ( + self.faction.doctrine.ground_unit_procurement_ratios.for_unit_class( + unit_class + ) + ) + if not desired_ratio: + continue + if current_ratio >= desired_ratio: + continue + fulfillment = current_ratio / desired_ratio + if fulfillment < worst_fulfillment: + worst_fulfillment = fulfillment + worst_balanced = unit_class + if worst_balanced is None: + return GroundUnitClass.Tank + return worst_balanced + def _affordable_aircraft_for_task( self, task: FlightType, @@ -179,7 +209,7 @@ class ProcurementAi: continue for squadron in self.air_wing.squadrons_for(unit): - if task in squadron.mission_types: + if task in squadron.auto_assignable_mission_types: break else: continue @@ -244,11 +274,9 @@ class ProcurementAi: ) -> Iterator[ControlPoint]: distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) threatened = [] - for cp in distance_cache.airfields_within(request.range): + for cp in distance_cache.operational_airfields_within(request.range): if not cp.is_friendly(self.is_player): continue - if not cp.runway_is_operational(): - continue if cp.unclaimed_parking(self.game) < request.number: continue if self.threat_zones.threatened(cp.position): @@ -256,56 +284,69 @@ class ProcurementAi: yield cp yield from threatened - def front_line_candidates(self) -> List[ControlPoint]: - candidates = [] + def ground_reinforcement_candidate(self) -> Optional[ControlPoint]: + worst_supply = math.inf + understaffed: Optional[ControlPoint] = None # Prefer to buy front line units at active front lines that are not # already overloaded. for cp in self.owned_points: - - total_ground_units_allocated_to_this_control_point = ( - self.total_ground_units_allocated_to(cp) - ) + if not cp.has_active_frontline: + continue if not cp.has_ground_unit_source(self.game): + # No source of ground units, so can't buy anything. continue - if ( - total_ground_units_allocated_to_this_control_point >= 50 - or total_ground_units_allocated_to_this_control_point - >= cp.frontline_unit_count_limit - ): + purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR + allocated = cp.allocated_ground_units(self.game.transfers) + if allocated.total >= purchase_target: # Control point is already sufficiently defended. continue - for connected in cp.connected_points: - if not connected.is_friendly(to_player=self.is_player): - candidates.append(cp) + if allocated.total < worst_supply: + worst_supply = allocated.total + understaffed = cp - if not candidates: - # Otherwise buy reserves, but don't exceed 10 reserve units per CP. - # These units do not exist in the world until the CP becomes - # connected to an active front line, at which point all these units - # will suddenly appear at the gates of the newly captured CP. - # - # To avoid sudden overwhelming numbers of units we avoid buying - # many. - # - # Also, do not bother buying units at bases that will never connect - # to a front line. - for cp in self.owned_points: - if not cp.can_recruit_ground_units(self.game): - continue - if self.total_ground_units_allocated_to(cp) >= 10: - continue - if cp.is_global: - continue - candidates.append(cp) + if understaffed is not None: + return understaffed - return candidates + # Otherwise buy reserves, but don't exceed the amount defined in the settings. + # These units do not exist in the world until the CP becomes + # connected to an active front line, at which point all these units + # will suddenly appear at the gates of the newly captured CP. + # + # To avoid sudden overwhelming numbers of units we avoid buying + # many. + # + # Also, do not bother buying units at bases that will never connect + # to a front line. + for cp in self.owned_points: + if cp.is_global: + continue + if not cp.can_recruit_ground_units(self.game): + continue - def total_ground_units_allocated_to(self, control_point: ControlPoint) -> int: - total = control_point.expected_ground_units_next_turn.total - for transfer in self.game.transfers: - if transfer.destination == control_point: - total += sum(transfer.units.values()) - return total + allocated = cp.allocated_ground_units(self.game.transfers) + if allocated.total >= self.game.settings.reserves_procurement_target: + continue + + if allocated.total < worst_supply: + worst_supply = allocated.total + understaffed = cp + + return understaffed + + def cost_ratio_of_ground_unit( + self, control_point: ControlPoint, unit_class: GroundUnitClass + ) -> float: + allocations = control_point.allocated_ground_units(self.game.transfers) + class_cost = 0 + total_cost = 0 + for unit_type, count in allocations.all.items(): + cost = db.PRICES[unit_type] * count + total_cost += cost + if unit_type in unit_class: + class_cost += cost + if not total_cost: + return 0 + return class_cost / total_cost diff --git a/game/settings.py b/game/settings.py index 247ae10b..e869283e 100644 --- a/game/settings.py +++ b/game/settings.py @@ -43,11 +43,11 @@ class Settings: automate_front_line_reinforcements: bool = False automate_aircraft_reinforcements: bool = False restrict_weapons_by_date: bool = False - disable_legacy_aewc: bool = False + disable_legacy_aewc: bool = True generate_dark_kneeboard: bool = False invulnerable_player_pilots: bool = True auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default - auto_ato_player_missions_asap: bool = False + auto_ato_player_missions_asap: bool = True # Performance oriented perf_red_alert_state: bool = True @@ -57,6 +57,7 @@ class Settings: perf_moving_units: bool = True perf_infantry: bool = True perf_destroyed_units: bool = True + reserves_procurement_target: int = 10 # Performance culling perf_culling: bool = False diff --git a/game/squadrons.py b/game/squadrons.py index c73e5bed..4e550465 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -10,10 +10,8 @@ from pathlib import Path from typing import ( Type, Tuple, - List, TYPE_CHECKING, Optional, - Iterable, Iterator, Sequence, ) @@ -83,9 +81,12 @@ class Squadron: role: str aircraft: Type[FlyingType] livery: Optional[str] - mission_types: Tuple[FlightType, ...] - pilots: List[Pilot] - available_pilots: List[Pilot] = field(init=False, hash=False, compare=False) + mission_types: tuple[FlightType, ...] + pilots: list[Pilot] + available_pilots: list[Pilot] = field(init=False, hash=False, compare=False) + auto_assignable_mission_types: set[FlightType] = field( + init=False, hash=False, compare=False + ) # We need a reference to the Game so that we can access the Faker without needing to # persist it to the save game, or having to reconstruct it (it's not cheap) each @@ -95,6 +96,7 @@ class Squadron: def __post_init__(self) -> None: self.available_pilots = list(self.active_pilots) + self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: return f'{self.name} "{self.nickname}"' @@ -142,8 +144,12 @@ class Squadron: def return_pilot(self, pilot: Pilot) -> None: self.available_pilots.append(pilot) - def return_pilots(self, pilots: Iterable[Pilot]) -> None: - self.available_pilots.extend(pilots) + def return_pilots(self, pilots: Sequence[Pilot]) -> None: + # Return in reverse so that returning two pilots and then getting two more + # results in the same ordering. This happens commonly when resetting rosters in + # the UI, when we clear the roster because the UI is updating, then end up + # repopulating the same size flight from the same squadron. + self.available_pilots.extend(reversed(pilots)) def enlist_new_pilots(self, count: int) -> None: new_pilots = [Pilot(self.faker.name()) for _ in range(count)] @@ -160,6 +166,9 @@ class Squadron: def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: return [p for p in self.pilots if p.status == status] + def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]: + return [p for p in self.pilots if p.status != status] + @property def active_pilots(self) -> list[Pilot]: return self._pilots_with_status(PilotStatus.Active) @@ -169,8 +178,12 @@ class Squadron: return self._pilots_with_status(PilotStatus.OnLeave) @property - def size(self) -> int: - return len(self.active_pilots) + len(self.pilots_on_leave) + def number_of_pilots_including_dead(self) -> int: + return len(self.pilots) + + @property + def number_of_living_pilots(self) -> int: + return len(self._pilots_without_status(PilotStatus.Dead)) def pilot_at_index(self, index: int) -> Pilot: return self.pilots[index] @@ -213,6 +226,12 @@ class Squadron: player=player, ) + def __setstate__(self, state) -> None: + # TODO: Remove save compat. + if "auto_assignable_mission_types" not in state: + state["auto_assignable_mission_types"] = set(state["mission_types"]) + self.__dict__.update(state) + class SquadronLoader: def __init__(self, game: Game, player: bool) -> None: diff --git a/game/theater/base.py b/game/theater/base.py index fa329531..27390b2d 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -10,7 +10,6 @@ from dcs.vehicles import AirDefence, Armor from game import db from game.db import PRICES -from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD STRENGTH_AA_ASSEMBLE_MIN = 0.2 PLANES_SCRAMBLE_MIN_BASE = 2 @@ -25,6 +24,7 @@ class Base: def __init__(self): self.aircraft: Dict[Type[FlyingType], int] = {} self.armor: Dict[Type[VehicleType], int] = {} + # TODO: Appears unused. self.aa: Dict[AirDefence, int] = {} self.commision_points: Dict[Type, float] = {} self.strength = 1 @@ -47,10 +47,6 @@ class Base: logging.exception(f"No price found for {unit_type.id}") return total - @property - def total_frontline_aa(self) -> int: - return sum([v for k, v in self.armor.items() if k in TYPE_SHORAD]) - @property def total_aa(self) -> int: return sum(self.aa.values()) diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index c00a8c2c..0f941009 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -1,7 +1,6 @@ from __future__ import annotations import itertools -import logging import math from dataclasses import dataclass from functools import cached_property @@ -40,10 +39,6 @@ from dcs.unitgroup import ( VehicleGroup, ) from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed - -from .latlon import LatLon -from ..helipad import Helipad -from ..scenery_group import SceneryGroup from pyproj import CRS, Transformer from shapely import geometry, ops @@ -58,10 +53,12 @@ from .controlpoint import ( ) from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains +from .latlon import LatLon from .projections import TransverseMercator from ..point_with_heading import PointWithHeading from ..profiling import logged_duration -from ..utils import Distance, meters, nautical_miles +from ..scenery_group import SceneryGroup +from ..utils import Distance, meters SIZE_TINY = 150 SIZE_SMALL = 600 @@ -88,42 +85,39 @@ class MizCampaignLoader: FOB_UNIT_TYPE = Unarmed.Truck_SKP_11_Mobile_ATC.id FARP_HELIPAD = "SINGLE_HELIPAD" - EWR_UNIT_TYPE = AirDefence.EWR_55G6.id - SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR.id - GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_Grison.id OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id SHIP_UNIT_TYPE = DDG_Arleigh_Burke_IIa.id MISSILE_SITE_UNIT_TYPE = MissilesSS.SSM_SS_1C_Scud_B.id COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.AShM_SS_N_2_Silkworm.id - # Multiple options for the required SAMs so campaign designers can more - # accurately see the coverage of their IADS for the expected type. - REQUIRED_LONG_RANGE_SAM_UNIT_TYPES = { + # Multiple options for air defenses so campaign designers can more accurately see + # the coverage of their IADS for the expected type. + LONG_RANGE_SAM_UNIT_TYPES = { AirDefence.SAM_Patriot_LN.id, AirDefence.SAM_SA_10_S_300_Grumble_TEL_C.id, AirDefence.SAM_SA_10_S_300_Grumble_TEL_D.id, } - REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES = { + MEDIUM_RANGE_SAM_UNIT_TYPES = { AirDefence.SAM_Hawk_LN_M192.id, AirDefence.SAM_SA_2_S_75_Guideline_LN.id, AirDefence.SAM_SA_3_S_125_Goa_LN.id, } - REQUIRED_SHORT_RANGE_SAM_UNIT_TYPES = { + SHORT_RANGE_SAM_UNIT_TYPES = { AirDefence.SAM_Avenger__Stinger.id, AirDefence.SAM_Rapier_LN.id, AirDefence.SAM_SA_19_Tunguska_Grison.id, AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL.id, } - REQUIRED_AAA_UNIT_TYPES = { + AAA_UNIT_TYPES = { AirDefence.AAA_8_8cm_Flak_18.id, AirDefence.SPAAA_Vulcan_M163.id, AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish.id, } - REQUIRED_EWR_UNIT_TYPE = AirDefence.EWR_1L13.id + EWR_UNIT_TYPE = AirDefence.EWR_1L13.id ARMOR_GROUP_UNIT_TYPE = Armor.MBT_M1A2_Abrams.id @@ -131,9 +125,7 @@ class MizCampaignLoader: AMMUNITION_DEPOT_UNIT_TYPE = Warehouse.Ammunition_depot.id - REQUIRED_STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id - - BASE_DEFENSE_RADIUS = nautical_miles(2) + STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id def __init__(self, miz: Path, theater: ConflictTheater) -> None: self.theater = theater @@ -211,98 +203,56 @@ class MizCampaignLoader: @property def ships(self) -> Iterator[ShipGroup]: - for group in self.blue.ship_group: - if group.units[0].type == self.SHIP_UNIT_TYPE: - yield group - - @property - def required_ships(self) -> Iterator[ShipGroup]: for group in self.red.ship_group: if group.units[0].type == self.SHIP_UNIT_TYPE: yield group - @property - def ewrs(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.EWR_UNIT_TYPE: - yield group - - @property - def sams(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.SAM_UNIT_TYPE: - yield group - - @property - def garrisons(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.GARRISON_UNIT_TYPE: - yield group - @property def offshore_strike_targets(self) -> Iterator[StaticGroup]: - for group in self.blue.static_group: - if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE: - yield group - - @property - def required_offshore_strike_targets(self) -> Iterator[StaticGroup]: for group in self.red.static_group: if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE: yield group @property def missile_sites(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE: - yield group - - @property - def required_missile_sites(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE: yield group @property def coastal_defenses(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE: - yield group - - @property - def required_coastal_defenses(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE: yield group @property - def required_long_range_sams(self) -> Iterator[VehicleGroup]: + def long_range_sams(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_LONG_RANGE_SAM_UNIT_TYPES: + if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES: yield group @property - def required_medium_range_sams(self) -> Iterator[VehicleGroup]: + def medium_range_sams(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES: + if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES: yield group @property - def required_short_range_sams(self) -> Iterator[VehicleGroup]: + def short_range_sams(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_SHORT_RANGE_SAM_UNIT_TYPES: + if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES: yield group @property - def required_aaa(self) -> Iterator[VehicleGroup]: + def aaa(self) -> Iterator[VehicleGroup]: for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): - if group.units[0].type in self.REQUIRED_AAA_UNIT_TYPES: + if group.units[0].type in self.AAA_UNIT_TYPES: yield group @property - def required_ewrs(self) -> Iterator[VehicleGroup]: + def ewrs(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_EWR_UNIT_TYPE: + if group.units[0].type in self.EWR_UNIT_TYPE: yield group @property @@ -330,9 +280,9 @@ class MizCampaignLoader: yield group @property - def required_strike_targets(self) -> Iterator[StaticGroup]: + def strike_targets(self) -> Iterator[StaticGroup]: for group in itertools.chain(self.blue.static_group, self.red.static_group): - if group.units[0].type in self.REQUIRED_STRIKE_TARGET_UNIT_TYPE: + if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE: yield group @property @@ -441,112 +391,57 @@ class MizCampaignLoader: return closest, distance def add_preset_locations(self) -> None: - for group in self.garrisons: - closest, distance = self.objective_info(group) - if distance < self.BASE_DEFENSE_RADIUS: - closest.preset_locations.base_garrisons.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - else: - logging.warning(f"Found garrison unit too far from base: {group.name}") - - for group in self.sams: - closest, distance = self.objective_info(group) - if distance < self.BASE_DEFENSE_RADIUS: - closest.preset_locations.base_air_defense.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - else: - closest.preset_locations.strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - - for group in self.ewrs: - closest, distance = self.objective_info(group) - if distance < self.BASE_DEFENSE_RADIUS: - closest.preset_locations.base_ewrs.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - else: - closest.preset_locations.ewrs.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - for group in self.offshore_strike_targets: closest, distance = self.objective_info(group) closest.preset_locations.offshore_strike_locations.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_offshore_strike_targets: - closest, distance = self.objective_info(group) - closest.preset_locations.required_offshore_strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - for group in self.ships: closest, distance = self.objective_info(group) closest.preset_locations.ships.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_ships: - closest, distance = self.objective_info(group) - closest.preset_locations.required_ships.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - for group in self.missile_sites: closest, distance = self.objective_info(group) closest.preset_locations.missile_sites.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_missile_sites: - closest, distance = self.objective_info(group) - closest.preset_locations.required_missile_sites.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - for group in self.coastal_defenses: closest, distance = self.objective_info(group) closest.preset_locations.coastal_defenses.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_coastal_defenses: + for group in self.long_range_sams: closest, distance = self.objective_info(group) - closest.preset_locations.required_coastal_defenses.append( + closest.preset_locations.long_range_sams.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_long_range_sams: + for group in self.medium_range_sams: closest, distance = self.objective_info(group) - closest.preset_locations.required_long_range_sams.append( + closest.preset_locations.medium_range_sams.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_medium_range_sams: + for group in self.short_range_sams: closest, distance = self.objective_info(group) - closest.preset_locations.required_medium_range_sams.append( + closest.preset_locations.short_range_sams.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_short_range_sams: + for group in self.aaa: closest, distance = self.objective_info(group) - closest.preset_locations.required_short_range_sams.append( + closest.preset_locations.aaa.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_aaa: + for group in self.ewrs: closest, distance = self.objective_info(group) - closest.preset_locations.required_aaa.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - - for group in self.required_ewrs: - closest, distance = self.objective_info(group) - closest.preset_locations.required_ewrs.append( + closest.preset_locations.ewrs.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) @@ -574,9 +469,9 @@ class MizCampaignLoader: PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_strike_targets: + for group in self.strike_targets: closest, distance = self.objective_info(group) - closest.preset_locations.required_strike_locations.append( + closest.preset_locations.strike_locations.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 4b2b1449..d1ab1827 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -3,11 +3,11 @@ from __future__ import annotations import heapq import itertools import logging -import random from abc import ABC, abstractmethod +from collections import defaultdict from dataclasses import dataclass, field -from enum import Enum -from functools import total_ordering +from enum import Enum, unique, auto, IntEnum +from functools import total_ordering, cached_property from typing import ( Any, Dict, @@ -33,24 +33,19 @@ from dcs.ships import ( ) from dcs.terrain.terrain import Airport, ParkingSlot from dcs.unit import Unit -from dcs.unittype import FlyingType +from dcs.unittype import FlyingType, VehicleType from game import db from game.point_with_heading import PointWithHeading from game.scenery_group import SceneryGroup from gen.flights.closestairfields import ObjectiveDistanceCache -from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD from gen.ground_forces.combat_stance import CombatStance from gen.runways import RunwayAssigner, RunwayData from .base import Base from .missiontarget import MissionTarget from .theatergroundobject import ( - BaseDefenseGroundObject, - EwrGroundObject, GenericCarrierGroundObject, - SamGroundObject, TheaterGroundObject, - VehicleGroupGroundObject, ) from ..db import PRICES from ..helipad import Helipad @@ -60,6 +55,7 @@ from ..weather import Conditions if TYPE_CHECKING: from game import Game from gen.flights.flight import FlightType + from ..transfers import PendingTransfers FREE_FRONTLINE_UNIT_SUPPLY: int = 15 AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION: int = 12 @@ -79,149 +75,133 @@ class ControlPointType(Enum): OFF_MAP = 6 -class LocationType(Enum): - BaseAirDefense = "base air defense" - Coastal = "coastal defense" - Ewr = "EWR" - BaseEwr = "Base EWR" - Garrison = "garrison" - MissileSite = "missile site" - OffshoreStrikeTarget = "offshore strike target" - Sam = "SAM" - Ship = "ship" - Shorad = "SHORAD" - StrikeTarget = "strike target" - - @dataclass class PresetLocations: """Defines the preset locations loaded from the campaign mission file.""" - #: Locations used for spawning ground defenses for bases. - base_garrisons: List[PointWithHeading] = field(default_factory=list) - - #: Locations used for spawning air defenses for bases. Used by SAMs, AAA, - #: and SHORADs. - base_air_defense: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by EWRs. - ewrs: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by Base EWRs. - base_ewrs: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by non-carrier ships. Carriers and LHAs are not random. + #: Locations used by non-carrier ships that will be spawned unless the faction has + #: no navy or the player has disabled ship generation for the owning side. ships: List[PointWithHeading] = field(default_factory=list) - #: Locations used by non-carrier ships that will be spawned unless the faction has - #: no navy or the player has disable ship generation for the original owning side. - required_ships: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by coastal defenses. + #: Locations used by coastal defenses that are generated if the faction is capable. coastal_defenses: List[PointWithHeading] = field(default_factory=list) - #: Locations used by coastal defenses that are always generated if the faction is - #: capable. - required_coastal_defenses: List[PointWithHeading] = field(default_factory=list) - #: Locations used by ground based strike objectives. strike_locations: List[PointWithHeading] = field(default_factory=list) - #: Locations used by ground based strike objectives that will always be spawned. - required_strike_locations: List[PointWithHeading] = field(default_factory=list) - #: Locations used by offshore strike objectives. offshore_strike_locations: List[PointWithHeading] = field(default_factory=list) - #: Locations used by offshore strike objectives that will always be spawned. - required_offshore_strike_locations: List[PointWithHeading] = field( - default_factory=list - ) - - #: Locations used by missile sites like scuds and V-2s. + #: Locations used by missile sites like scuds and V-2s that are generated if the + #: faction is capable. missile_sites: List[PointWithHeading] = field(default_factory=list) - #: Locations used by missile sites like scuds and V-2s that are always generated if - #: the faction is capable. - required_missile_sites: List[PointWithHeading] = field(default_factory=list) + #: Locations of long range SAMs. + long_range_sams: List[PointWithHeading] = field(default_factory=list) - #: Locations of long range SAMs which should always be spawned. - required_long_range_sams: List[PointWithHeading] = field(default_factory=list) + #: Locations of medium range SAMs. + medium_range_sams: List[PointWithHeading] = field(default_factory=list) - #: Locations of medium range SAMs which should always be spawned. - required_medium_range_sams: List[PointWithHeading] = field(default_factory=list) + #: Locations of short range SAMs. + short_range_sams: List[PointWithHeading] = field(default_factory=list) - #: Locations of short range SAMs which should always be spawned. - required_short_range_sams: List[PointWithHeading] = field(default_factory=list) + #: Locations of AAA groups. + aaa: List[PointWithHeading] = field(default_factory=list) - #: Locations of AAA groups which should always be spawned. - required_aaa: List[PointWithHeading] = field(default_factory=list) - - #: Locations of EWRs which should always be spawned. - required_ewrs: List[PointWithHeading] = field(default_factory=list) + #: Locations of EWRs. + 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. + #: Locations of factories for producing ground units. factories: List[PointWithHeading] = field(default_factory=list) - #: Locations of ammo depots for controlling number of units on the front line at a control point. + #: Locations of ammo depots for controlling number of units on the front line at a + #: control point. ammunition_depots: List[PointWithHeading] = field(default_factory=list) - #: Locations of stationary armor groups. These will always be spawned. + #: Locations of stationary armor groups. armor_groups: List[PointWithHeading] = field(default_factory=list) - @staticmethod - def _random_from(points: List[PointWithHeading]) -> Optional[PointWithHeading]: - """Finds, removes, and returns a random position from the given list.""" - if not points: - return None - point = random.choice(points) - points.remove(point) - return point - - def random_for(self, location_type: LocationType) -> Optional[PointWithHeading]: - """Returns a position suitable for the given location type. - - The location, if found, will be claimed by the caller and not available - to subsequent calls. - """ - if location_type == LocationType.BaseAirDefense: - return self._random_from(self.base_air_defense) - if location_type == LocationType.Coastal: - return self._random_from(self.coastal_defenses) - if location_type == LocationType.Ewr: - return self._random_from(self.ewrs) - if location_type == LocationType.BaseEwr: - return self._random_from(self.base_ewrs) - if location_type == LocationType.Garrison: - return self._random_from(self.base_garrisons) - if location_type == LocationType.MissileSite: - return self._random_from(self.missile_sites) - if location_type == LocationType.OffshoreStrikeTarget: - return self._random_from(self.offshore_strike_locations) - if location_type == LocationType.Sam: - return self._random_from(self.strike_locations) - if location_type == LocationType.Ship: - return self._random_from(self.ships) - if location_type == LocationType.Shorad: - return self._random_from(self.base_garrisons) - if location_type == LocationType.StrikeTarget: - return self._random_from(self.strike_locations) - logging.error(f"Unknown location type: {location_type}") - return None - @dataclass(frozen=True) -class PendingOccupancy: - present: int - ordered: int - transferring: int +class AircraftAllocations: + present: dict[Type[FlyingType], int] + ordered: dict[Type[FlyingType], int] + transferring: dict[Type[FlyingType], int] + + @property + def total_value(self) -> int: + total: int = 0 + for unit_type, count in self.present.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.ordered.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.transferring.items(): + total += PRICES[unit_type] * count + + return total @property def total(self) -> int: - return self.present + self.ordered + self.transferring + return self.total_present + self.total_ordered + self.total_transferring + + @property + def total_present(self) -> int: + return sum(self.present.values()) + + @property + def total_ordered(self) -> int: + return sum(self.ordered.values()) + + @property + def total_transferring(self) -> int: + return sum(self.transferring.values()) + + +@dataclass(frozen=True) +class GroundUnitAllocations: + present: dict[Type[VehicleType], int] + ordered: dict[Type[VehicleType], int] + transferring: dict[Type[VehicleType], int] + + @property + def all(self) -> dict[Type[VehicleType], int]: + combined: dict[Type[VehicleType], int] = defaultdict(int) + for unit_type, count in itertools.chain( + self.present.items(), self.ordered.items(), self.transferring.items() + ): + combined[unit_type] += count + return dict(combined) + + @property + def total_value(self) -> int: + total: int = 0 + for unit_type, count in self.present.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.ordered.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.transferring.items(): + total += PRICES[unit_type] * count + + return total + + @cached_property + def total(self) -> int: + return self.total_present + self.total_ordered + self.total_transferring + + @cached_property + def total_present(self) -> int: + return sum(self.present.values()) + + @cached_property + def total_ordered(self) -> int: + return sum(self.ordered.values()) + + @cached_property + def total_transferring(self) -> int: + return sum(self.transferring.values()) @dataclass @@ -285,6 +265,13 @@ class GroundUnitDestination: return self.total_value < other.total_value +@unique +class ControlPointStatus(IntEnum): + Functional = auto() + Damaged = auto() + Destroyed = auto() + + class ControlPoint(MissionTarget, ABC): position = None # type: Point @@ -315,7 +302,6 @@ class ControlPoint(MissionTarget, ABC): self.full_name = name self.at = at self.connected_objectives: List[TheaterGroundObject] = [] - self.base_defenses: List[BaseDefenseGroundObject] = [] self.preset_locations = PresetLocations() self.helipads: List[Helipad] = [] @@ -344,7 +330,7 @@ class ControlPoint(MissionTarget, ABC): @property def ground_objects(self) -> List[TheaterGroundObject]: - return list(itertools.chain(self.connected_objectives, self.base_defenses)) + return list(self.connected_objectives) @property @abstractmethod @@ -553,24 +539,6 @@ class ControlPoint(MissionTarget, ABC): def is_friendly_to(self, control_point: ControlPoint) -> bool: return control_point.is_friendly(self.captured) - # TODO: Should be Airbase specific. - def clear_base_defenses(self) -> None: - for base_defense in self.base_defenses: - p = PointWithHeading.from_point(base_defense.position, base_defense.heading) - if isinstance(base_defense, EwrGroundObject): - self.preset_locations.base_ewrs.append(p) - elif isinstance(base_defense, SamGroundObject): - self.preset_locations.base_air_defense.append(p) - elif isinstance(base_defense, VehicleGroupGroundObject): - self.preset_locations.base_garrisons.append(p) - else: - logging.error( - "Could not determine preset location type for " - f"{base_defense}. Assuming garrison type." - ) - self.preset_locations.base_garrisons.append(p) - self.base_defenses = [] - def capture_equipment(self, game: Game) -> None: total = self.base.total_armor_value self.base.armor.clear() @@ -625,7 +593,7 @@ class ControlPoint(MissionTarget, ABC): max_retreat_distance = nautical_miles(200) # Skip the first airbase because that's the airbase we're retreating # from. - airfields = list(closest.airfields_within(max_retreat_distance))[1:] + airfields = list(closest.operational_airfields_within(max_retreat_distance))[1:] for airbase in airfields: if not airbase.can_operate(airframe): continue @@ -655,11 +623,17 @@ class ControlPoint(MissionTarget, ABC): airframe, count = self.base.aircraft.popitem() self._retreat_air_units(game, airframe, count) + def depopulate_uncapturable_tgos(self) -> None: + for tgo in self.connected_objectives: + if not tgo.capturable: + tgo.clear() + # TODO: Should be Airbase specific. def capture(self, game: Game, for_player: bool) -> None: self.pending_unit_deliveries.refund_all(game) self.retreat_ground_units(game) self.retreat_air_units(game) + self.depopulate_uncapturable_tgos() if for_player: self.captured = True @@ -668,46 +642,29 @@ class ControlPoint(MissionTarget, ABC): self.base.set_strength_to_minimum() - self.clear_base_defenses() - from .start_generator import BaseDefenseGenerator - - BaseDefenseGenerator(game, self).generate() - @abstractmethod def can_operate(self, aircraft: Type[FlyingType]) -> bool: ... - def aircraft_transferring(self, game: Game) -> int: + def aircraft_transferring(self, game: Game) -> dict[Type[FlyingType], int]: if self.captured: ato = game.blue_ato else: ato = game.red_ato - total = 0 + transferring: defaultdict[Type[FlyingType], int] = defaultdict(int) for package in ato.packages: for flight in package.flights: if flight.departure == flight.arrival: continue if flight.departure == self: - total -= flight.count + transferring[flight.unit_type] -= flight.count elif flight.arrival == self: - total += flight.count - return total - - def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy: - on_order = 0 - for unit_bought in self.pending_unit_deliveries.units: - if issubclass(unit_bought, FlyingType): - on_order += self.pending_unit_deliveries.units[unit_bought] - - return PendingOccupancy( - self.base.total_aircraft, on_order, self.aircraft_transferring(game) - ) + transferring[flight.unit_type] += flight.count + return transferring def unclaimed_parking(self, game: Game) -> int: - return ( - self.total_aircraft_parking - self.expected_aircraft_next_turn(game).total - ) + return self.total_aircraft_parking - self.allocated_aircraft(game).total @abstractmethod def active_runway( @@ -757,47 +714,34 @@ class ControlPoint(MissionTarget, ABC): u.position.x = u.position.x + delta.x u.position.y = u.position.y + delta.y - @property - def pending_frontline_aa_deliveries_count(self): - """ - Get number of pending frontline aa units - """ - if self.pending_unit_deliveries: - return sum( - [ - v - for k, v in self.pending_unit_deliveries.units.items() - if k in TYPE_SHORAD - ] - ) - else: - return 0 - - @property - def pending_deliveries_count(self): - """ - Get number of pending units - """ - if self.pending_unit_deliveries: - return sum([v for k, v in self.pending_unit_deliveries.units.items()]) - else: - return 0 - - @property - def expected_ground_units_next_turn(self) -> PendingOccupancy: - on_order = 0 - for unit_bought in self.pending_unit_deliveries.units: + def allocated_aircraft(self, game: Game) -> AircraftAllocations: + on_order = {} + for unit_bought, count in self.pending_unit_deliveries.units.items(): if issubclass(unit_bought, FlyingType): - continue - if unit_bought in TYPE_SHORAD: - continue - on_order += self.pending_unit_deliveries.units[unit_bought] + on_order[unit_bought] = count - return PendingOccupancy( - self.base.total_armor, + return AircraftAllocations( + self.base.aircraft, on_order, self.aircraft_transferring(game) + ) + + def allocated_ground_units( + self, transfers: PendingTransfers + ) -> GroundUnitAllocations: + on_order = {} + for unit_bought, count in self.pending_unit_deliveries.units.items(): + if issubclass(unit_bought, VehicleType): + on_order[unit_bought] = count + + transferring: dict[Type[VehicleType], int] = defaultdict(int) + for transfer in transfers: + if transfer.destination == self: + for unit_type, count in transfer.units.items(): + transferring[unit_type] += count + + return GroundUnitAllocations( + self.base.armor, on_order, - # Ground unit transfers not yet implemented. - transferring=0, + transferring, ) @property @@ -816,18 +760,27 @@ class ControlPoint(MissionTarget, ABC): @property def frontline_unit_count_limit(self) -> int: - - tally_connected_ammo_depots = 0 - - for cp_objective in self.connected_objectives: - if cp_objective.category == "ammo" and not cp_objective.is_dead: - tally_connected_ammo_depots += 1 - return ( FREE_FRONTLINE_UNIT_SUPPLY - + tally_connected_ammo_depots * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION + + self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION ) + @property + def active_ammo_depots_count(self) -> int: + """Return the number of available ammo depots""" + return len( + [ + obj + for obj in self.connected_objectives + if obj.category == "ammo" and not obj.is_dead + ] + ) + + @property + def total_ammo_depots_count(self) -> int: + """Return the number of ammo depots, including dead ones""" + return len([obj for obj in self.connected_objectives if obj.category == "ammo"]) + @property def strike_targets(self) -> List[Union[MissionTarget, Unit]]: return [] @@ -837,6 +790,11 @@ class ControlPoint(MissionTarget, ABC): def category(self) -> str: ... + @property + @abstractmethod + def status(self) -> ControlPointStatus: + ... + class Airfield(ControlPoint): def __init__( @@ -921,6 +879,15 @@ class Airfield(ControlPoint): def category(self) -> str: return "airfield" + @property + def status(self) -> ControlPointStatus: + runway_staus = self.runway_status + if runway_staus.needs_repair: + return ControlPointStatus.Destroyed + elif runway_staus.damaged: + return ControlPointStatus.Damaged + return ControlPointStatus.Functional + class NavalControlPoint(ControlPoint, ABC): @property @@ -945,20 +912,24 @@ class NavalControlPoint(ControlPoint, ABC): def heading(self) -> int: return 0 # TODO compute heading + def find_main_tgo(self) -> TheaterGroundObject: + for g in self.ground_objects: + if g.dcs_identifier in ["CARRIER", "LHA"]: + return g + raise RuntimeError(f"Found no carrier/LHA group for {self.name}") + def runway_is_operational(self) -> bool: # Necessary because it's possible for the carrier itself to have sunk # while its escorts are still alive. - for g in self.ground_objects: - if g.dcs_identifier in ["CARRIER", "LHA"]: - for group in g.groups: - for u in group.units: - if db.unit_type_from_name(u.type) in [ - CVN_74_John_C__Stennis, - LHA_1_Tarawa, - CV_1143_5_Admiral_Kuznetsov, - Type_071_Amphibious_Transport_Dock, - ]: - return True + for group in self.find_main_tgo().groups: + for u in group.units: + if db.unit_type_from_name(u.type) in [ + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + CV_1143_5_Admiral_Kuznetsov, + Type_071_Amphibious_Transport_Dock, + ]: + return True return False def active_runway( @@ -984,6 +955,14 @@ class NavalControlPoint(ControlPoint, ABC): def can_deploy_ground_units(self) -> bool: return False + @property + def status(self) -> ControlPointStatus: + if not self.runway_is_operational(): + return ControlPointStatus.Destroyed + if self.find_main_tgo().dead_units: + return ControlPointStatus.Damaged + return ControlPointStatus.Functional + class Carrier(NavalControlPoint): def __init__(self, name: str, at: Point, cp_id: int): @@ -1113,6 +1092,10 @@ class OffMapSpawn(ControlPoint): def category(self) -> str: return "offmap" + @property + def status(self) -> ControlPointStatus: + return ControlPointStatus.Functional + class Fob(ControlPoint): def __init__(self, name: str, at: Point, cp_id: int): @@ -1176,3 +1159,7 @@ class Fob(ControlPoint): @property def category(self) -> str: return "fob" + + @property + def status(self) -> ControlPointStatus: + return ControlPointStatus.Functional diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 88ad2bcd..2f31fecf 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -1,12 +1,11 @@ from __future__ import annotations -from game.scenery_group import SceneryGroup import logging import pickle import random from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, Iterable, List, Optional, Set +from typing import Any, Dict, Iterable, List, Set from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike @@ -14,7 +13,8 @@ from dcs.vehicles import AirDefence from game import Game, db from game.factions.faction import Faction -from game.theater import Carrier, Lha, LocationType, PointWithHeading +from game.scenery_group import SceneryGroup +from game.theater import Carrier, Lha, PointWithHeading from game.theater.theatergroundobject import ( BuildingGroundObject, CarrierGroundObject, @@ -39,8 +39,8 @@ from gen.fleet.ship_group_generator import ( ) from gen.missiles.missiles_group_generator import generate_missile_group from gen.sam.airdefensegroupgenerator import AirDefenseRange -from gen.sam.sam_group_generator import generate_anti_air_group from gen.sam.ewr_group_generator import generate_ewr_group +from gen.sam.sam_group_generator import generate_anti_air_group from . import ( ConflictTheater, ControlPoint, @@ -145,24 +145,6 @@ class GameGenerator: cp.captured = True -class LocationFinder: - def __init__(self, control_point: ControlPoint) -> None: - self.control_point = control_point - - def location_for(self, location_type: LocationType) -> Optional[PointWithHeading]: - position = self.control_point.preset_locations.random_for(location_type) - if position is not None: - logging.warning( - f"Campaign relies on random generation of %s at %s. Support for random " - "objectives will be removed soon.", - location_type.value, - self.control_point, - ) - return position - - return None - - class ControlPointGroundObjectGenerator: def __init__( self, @@ -173,7 +155,6 @@ class ControlPointGroundObjectGenerator: self.game = game self.generator_settings = generator_settings self.control_point = control_point - self.location_finder = LocationFinder(control_point) @property def faction_name(self) -> str: @@ -203,19 +184,9 @@ class ControlPointGroundObjectGenerator: if not self.control_point.captured and skip_enemy_navy: return - self.generate_required_ships() - for _ in range(self.faction.navy_group_count): - self.generate_ship() - - def generate_required_ships(self) -> None: - for position in self.control_point.preset_locations.required_ships: + for position in self.control_point.preset_locations.ships: self.generate_ship_at(position) - def generate_ship(self) -> None: - point = self.location_finder.location_for(LocationType.Ship) - if point is not None: - self.generate_ship_at(point) - def generate_ship_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() @@ -289,159 +260,6 @@ class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator): return True -class BaseDefenseGenerator: - def __init__(self, game: Game, control_point: ControlPoint) -> None: - self.game = game - self.control_point = control_point - self.location_finder = LocationFinder(control_point) - - @property - def faction_name(self) -> str: - if self.control_point.captured: - return self.game.player_name - else: - return self.game.enemy_name - - @property - def faction(self) -> Faction: - return db.FACTIONS[self.faction_name] - - def generate(self) -> None: - self.generate_ewr() - self.generate_garrison() - self.generate_base_defenses() - - def generate_ewr(self) -> None: - position = self.location_finder.location_for(LocationType.BaseEwr) - if position is None: - return - - group_id = self.game.next_group_id() - - g = EwrGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - True, - ) - - group = generate_ewr_group(self.game, g, self.faction) - if group is None: - logging.error(f"Could not generate EWR at {self.control_point}") - return - - g.groups = [group] - self.control_point.base_defenses.append(g) - - def generate_base_defenses(self) -> None: - # First group has a 1/2 chance of being a SAM, 1/6 chance of SHORAD, - # and a 1/6 chance of a garrison. - # - # Further groups have a 1/3 chance of being SHORAD and 2/3 chance of - # being a garrison. - for i in range(random.randint(2, 5)): - if i == 0 and random.randint(0, 1) == 0: - self.generate_sam() - elif random.randint(0, 2) == 1: - self.generate_shorad() - else: - self.generate_garrison() - - def generate_garrison(self) -> None: - position = self.location_finder.location_for(LocationType.Garrison) - if position is None: - return - - group_id = self.game.next_group_id() - - g = VehicleGroupGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - for_airbase=True, - ) - - group = generate_armor_group(self.faction_name, self.game, g) - if group is None: - logging.error(f"Could not generate garrison at {self.control_point}") - return - g.groups.append(group) - self.control_point.base_defenses.append(g) - - def generate_sam(self) -> None: - position = self.location_finder.location_for(LocationType.BaseAirDefense) - if position is None: - return - - group_id = self.game.next_group_id() - - g = SamGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - for_airbase=True, - ) - - groups = generate_anti_air_group(self.game, g, self.faction) - if not groups: - logging.error(f"Could not generate SAM at {self.control_point}") - return - g.groups = groups - self.control_point.base_defenses.append(g) - - def generate_shorad(self) -> None: - position = self.location_finder.location_for(LocationType.BaseAirDefense) - if position is None: - return - - group_id = self.game.next_group_id() - - g = SamGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - for_airbase=True, - ) - - groups = generate_anti_air_group( - self.game, - g, - self.faction, - ranges=[{AirDefenseRange.Short, AirDefenseRange.AAA}], - ) - if not groups: - logging.error(f"Could not generate SHORAD group at {self.control_point}") - return - g.groups = groups - self.control_point.base_defenses.append(g) - - -class FobDefenseGenerator(BaseDefenseGenerator): - def generate(self) -> None: - self.generate_garrison() - self.generate_fob_defenses() - - def generate_fob_defenses(self): - # First group has a 1/2 chance of being a SHORAD, - # and a 1/2 chance of a garrison. - # - # Further groups have a 1/3 chance of being SHORAD and 2/3 chance of - # being a garrison. - for i in range(random.randint(2, 5)): - if i == 0 and random.randint(0, 1) == 0: - self.generate_shorad() - elif i == 0 and random.randint(0, 1) == 0: - self.generate_garrison() - elif random.randint(0, 2) == 1: - self.generate_shorad() - else: - self.generate_garrison() - - class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): def __init__( self, @@ -457,16 +275,14 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): if not super().generate(): return False - BaseDefenseGenerator(self.game, self.control_point).generate() self.generate_ground_points() - return True def generate_ground_points(self) -> None: """Generate ground objects and AA sites for the control point.""" self.generate_armor_groups() - skip_sams = self.generate_required_aa() - skip_ewrs = self.generate_required_ewr() + self.generate_aa() + self.generate_ewrs() self.generate_scenery_sites() self.generate_strike_targets() self.generate_offshore_strike_targets() @@ -475,35 +291,9 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): if self.faction.missiles: self.generate_missile_sites() - self.generate_required_missile_sites() if self.faction.coastal_defenses: self.generate_coastal_sites() - self.generate_required_coastal_sites() - - if self.control_point.is_global: - return - - # Always generate at least one AA point. - self.generate_aa_site() - - # And between 2 and 7 other objectives. - amount = random.randrange(2, 7) - for i in range(amount): - # 1 in 4 additional objectives are AA. - if random.randint(0, 3) == 0: - if skip_sams > 0: - skip_sams -= 1 - else: - self.generate_aa_site() - # 1 in 4 additional objectives are EWR. - elif random.randint(0, 3) == 0: - if skip_ewrs > 0: - skip_ewrs -= 1 - else: - self.generate_ewr_site() - else: - self.generate_ground_point() def generate_armor_groups(self) -> None: for position in self.control_point.preset_locations.armor_groups: @@ -517,7 +307,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, position, self.control_point, - for_airbase=False, ) group = generate_armor_group(self.faction_name, self.game, g) @@ -531,14 +320,9 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): g.groups = [group] self.control_point.connected_objectives.append(g) - def generate_required_aa(self) -> int: - """Generates the AA sites that are required by the campaign. - - Returns: - The number of AA sites that were generated. - """ + def generate_aa(self) -> None: presets = self.control_point.preset_locations - for position in presets.required_long_range_sams: + for position in presets.long_range_sams: self.generate_aa_at( position, ranges=[ @@ -548,7 +332,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): {AirDefenseRange.AAA}, ], ) - for position in presets.required_medium_range_sams: + for position in presets.medium_range_sams: self.generate_aa_at( position, ranges=[ @@ -557,52 +341,21 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): {AirDefenseRange.AAA}, ], ) - for position in presets.required_short_range_sams: + for position in presets.short_range_sams: self.generate_aa_at( position, ranges=[{AirDefenseRange.Short}, {AirDefenseRange.AAA}], ) - for position in presets.required_aaa: + for position in presets.aaa: self.generate_aa_at( position, ranges=[{AirDefenseRange.AAA}], ) - return ( - len(presets.required_long_range_sams) - + len(presets.required_medium_range_sams) - + len(presets.required_short_range_sams) - + len(presets.required_aaa) - ) - def generate_required_ewr(self) -> int: - """Generates the EWR sites that are required by the campaign. - - Returns: - The number of EWR sites that were generated. - """ + def generate_ewrs(self) -> None: presets = self.control_point.preset_locations - for position in presets.required_ewrs: + for position in presets.ewrs: self.generate_ewr_at(position) - return len(presets.required_ewrs) - - def generate_ground_point(self) -> None: - try: - category = random.choice(self.faction.building_set) - except IndexError: - logging.exception("Faction has no buildings defined") - return - - if category == "oil": - location_type = LocationType.OffshoreStrikeTarget - else: - location_type = LocationType.StrikeTarget - - # Pick from preset locations - point = self.location_finder.location_for(location_type) - if point is None: - return - - self.generate_strike_target_at(category, point) def generate_strike_target_at(self, category: str, position: Point) -> None: @@ -635,7 +388,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.generate_strike_target_at(category="ammo", position=position) def generate_factories(self) -> None: - """Generates the factories that are required by the campaign.""" for position in self.control_point.preset_locations.factories: self.generate_factory_at(position) @@ -653,19 +405,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.control_point.connected_objectives.append(g) - def generate_aa_site(self) -> None: - position = self.location_finder.location_for(LocationType.Sam) - if position is None: - return - self.generate_aa_at( - position, - ranges=[ - # Prefer to use proper SAMs, but fall back to SHORADs if needed. - {AirDefenseRange.Long, AirDefenseRange.Medium}, - {AirDefenseRange.Short}, - ], - ) - def generate_aa_at( self, position: Point, ranges: Iterable[Set[AirDefenseRange]] ) -> None: @@ -676,7 +415,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, position, self.control_point, - for_airbase=False, ) groups = generate_anti_air_group(self.game, g, self.faction, ranges) if not groups: @@ -689,12 +427,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): g.groups = groups self.control_point.connected_objectives.append(g) - def generate_ewr_site(self) -> None: - position = self.location_finder.location_for(LocationType.Ewr) - if position is None: - return - self.generate_ewr_at(position) - def generate_ewr_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() @@ -703,7 +435,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, position, self.control_point, - for_airbase=False, ) group = generate_ewr_group(self.game, g, self.faction) if group is None: @@ -750,18 +481,9 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): return - def generate_required_missile_sites(self) -> None: - for position in self.control_point.preset_locations.required_missile_sites: - self.generate_missile_site_at(position) - def generate_missile_sites(self) -> None: - for i in range(self.faction.missiles_group_count): - self.generate_missile_site() - - def generate_missile_site(self) -> None: - position = self.location_finder.location_for(LocationType.MissileSite) - if position is not None: - return self.generate_missile_site_at(position) + for position in self.control_point.preset_locations.missile_sites: + self.generate_missile_site_at(position) def generate_missile_site_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() @@ -776,17 +498,8 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.control_point.connected_objectives.append(g) return - def generate_required_coastal_sites(self) -> None: - for position in self.control_point.preset_locations.required_coastal_defenses: - self.generate_coastal_site_at(position) - def generate_coastal_sites(self) -> None: - for i in range(self.faction.coastal_group_count): - self.generate_coastal_site() - - def generate_coastal_site(self) -> None: - position = self.location_finder.location_for(LocationType.Coastal) - if position is not None: + for position in self.control_point.preset_locations.coastal_defenses: self.generate_coastal_site_at(position) def generate_coastal_site_at(self, position: PointWithHeading) -> None: @@ -807,46 +520,39 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): return def generate_strike_targets(self) -> None: - """Generates the strike targets that are required by the campaign.""" building_set = list(set(self.faction.building_set) - {"oil"}) if not building_set: logging.error("Faction has no buildings defined") return - for position in self.control_point.preset_locations.required_strike_locations: + for position in self.control_point.preset_locations.strike_locations: category = random.choice(building_set) self.generate_strike_target_at(category, position) def generate_offshore_strike_targets(self) -> None: - """Generates the offshore strike targets that are required by the campaign.""" if "oil" not in self.faction.building_set: logging.error("Faction does not support offshore strike targets") return - for ( - position - ) in self.control_point.preset_locations.required_offshore_strike_locations: + for position in self.control_point.preset_locations.offshore_strike_locations: self.generate_strike_target_at("oil", position) class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): def generate(self) -> bool: self.generate_fob() - FobDefenseGenerator(self.game, self.control_point).generate() self.generate_armor_groups() self.generate_factories() self.generate_ammunition_depots() - self.generate_required_aa() - self.generate_required_ewr() + self.generate_aa() + self.generate_ewrs() self.generate_scenery_sites() self.generate_strike_targets() self.generate_offshore_strike_targets() if self.faction.missiles: self.generate_missile_sites() - self.generate_required_missile_sites() if self.faction.coastal_defenses: self.generate_coastal_sites() - self.generate_required_coastal_sites() return True @@ -873,7 +579,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): unit["heading"], self.control_point, unit["type"], - airbase_group=True, + is_fob_structure=True, ) self.control_point.connected_objectives.append(g) diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 2484ec71..e4ff3bca 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -12,7 +12,6 @@ from dcs.unittype import VehicleType from .. import db from ..data.radar_db import ( - UNITS_WITH_RADAR, TRACK_RADARS, TELARS, LAUNCHER_TRACKER_PAIRS, @@ -58,7 +57,6 @@ class TheaterGroundObject(MissionTarget): heading: int, control_point: ControlPoint, dcs_identifier: str, - airbase_group: bool, sea_object: bool, ) -> None: super().__init__(name, position) @@ -67,7 +65,6 @@ class TheaterGroundObject(MissionTarget): self.heading = heading self.control_point = control_point self.dcs_identifier = dcs_identifier - self.airbase_group = airbase_group self.sea_object = sea_object self.groups: List[Group] = [] @@ -193,6 +190,21 @@ class TheaterGroundObject(MissionTarget): def strike_targets(self) -> List[Union[MissionTarget, Unit]]: return self.units + @property + def mark_locations(self) -> Iterator[Point]: + yield self.position + + def clear(self) -> None: + self.groups = [] + + @property + def capturable(self) -> bool: + raise NotImplementedError + + @property + def purchasable(self) -> bool: + raise NotImplementedError + class BuildingGroundObject(TheaterGroundObject): def __init__( @@ -205,7 +217,7 @@ class BuildingGroundObject(TheaterGroundObject): heading: int, control_point: ControlPoint, dcs_identifier: str, - airbase_group=False, + is_fob_structure=False, ) -> None: super().__init__( name=name, @@ -215,9 +227,9 @@ class BuildingGroundObject(TheaterGroundObject): heading=heading, control_point=control_point, dcs_identifier=dcs_identifier, - airbase_group=airbase_group, sea_object=False, ) + self.is_fob_structure = is_fob_structure self.object_id = object_id # Other TGOs track deadness based on the number of alive units, but # buildings don't have groups assigned to the TGO. @@ -250,6 +262,23 @@ class BuildingGroundObject(TheaterGroundObject): def strike_targets(self) -> List[Union[MissionTarget, Unit]]: return list(self.iter_building_group()) + @property + def mark_locations(self) -> Iterator[Point]: + for building in self.iter_building_group(): + yield building.position + + @property + def is_control_point(self) -> bool: + return self.is_fob_structure + + @property + def capturable(self) -> bool: + return True + + @property + def purchasable(self) -> bool: + return False + class SceneryGroundObject(BuildingGroundObject): def __init__( @@ -272,7 +301,7 @@ class SceneryGroundObject(BuildingGroundObject): heading=0, control_point=control_point, dcs_identifier=dcs_identifier, - airbase_group=False, + is_fob_structure=False, ) self.zone = zone try: @@ -305,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject): heading=heading, control_point=control_point, dcs_identifier="Workshop A", - airbase_group=False, + is_fob_structure=False, ) @@ -321,6 +350,14 @@ class NavalGroundObject(TheaterGroundObject): def might_have_aa(self) -> bool: return True + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return False + class GenericCarrierGroundObject(NavalGroundObject): @property @@ -339,7 +376,6 @@ class CarrierGroundObject(GenericCarrierGroundObject): heading=0, control_point=control_point, dcs_identifier="CARRIER", - airbase_group=True, sea_object=True, ) @@ -361,7 +397,6 @@ class LhaGroundObject(GenericCarrierGroundObject): heading=0, control_point=control_point, dcs_identifier="LHA", - airbase_group=True, sea_object=True, ) @@ -384,10 +419,17 @@ class MissileSiteGroundObject(TheaterGroundObject): heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=False, sea_object=False, ) + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return False + class CoastalSiteGroundObject(TheaterGroundObject): def __init__( @@ -406,26 +448,28 @@ class CoastalSiteGroundObject(TheaterGroundObject): heading=heading, control_point=control_point, dcs_identifier="AA", - airbase_group=False, sea_object=False, ) + @property + def capturable(self) -> bool: + return False -class BaseDefenseGroundObject(TheaterGroundObject): - """Base type for all base defenses.""" + @property + def purchasable(self) -> bool: + return False # TODO: Differentiate types. # This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each # be split into their own types. -class SamGroundObject(BaseDefenseGroundObject): +class SamGroundObject(TheaterGroundObject): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - for_airbase: bool, ) -> None: super().__init__( name=name, @@ -435,7 +479,6 @@ class SamGroundObject(BaseDefenseGroundObject): heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=for_airbase, sea_object=False, ) # Set by the SAM unit generator if the generated group is compatible @@ -495,15 +538,22 @@ class SamGroundObject(BaseDefenseGroundObject): else: return max(max_tel_range, max_telar_range, max_non_radar) + @property + def capturable(self) -> bool: + return False -class VehicleGroupGroundObject(BaseDefenseGroundObject): + @property + def purchasable(self) -> bool: + return True + + +class VehicleGroupGroundObject(TheaterGroundObject): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - for_airbase: bool, ) -> None: super().__init__( name=name, @@ -513,19 +563,25 @@ class VehicleGroupGroundObject(BaseDefenseGroundObject): heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=for_airbase, sea_object=False, ) + @property + def capturable(self) -> bool: + return False -class EwrGroundObject(BaseDefenseGroundObject): + @property + def purchasable(self) -> bool: + return True + + +class EwrGroundObject(TheaterGroundObject): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - for_airbase: bool, ) -> None: super().__init__( name=name, @@ -535,7 +591,6 @@ class EwrGroundObject(BaseDefenseGroundObject): heading=0, control_point=control_point, dcs_identifier="EWR", - airbase_group=for_airbase, sea_object=False, ) @@ -555,6 +610,14 @@ class EwrGroundObject(BaseDefenseGroundObject): def might_have_aa(self) -> bool: return True + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return True + class ShipGroundObject(NavalGroundObject): def __init__( @@ -568,7 +631,6 @@ class ShipGroundObject(NavalGroundObject): heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=False, sea_object=True, ) diff --git a/game/threatzones.py b/game/threatzones.py index c7207a74..4d29c6c3 100644 --- a/game/threatzones.py +++ b/game/threatzones.py @@ -40,6 +40,10 @@ class ThreatZones: ) return DcsPoint(boundary.x, boundary.y) + def distance_to_threat(self, point: DcsPoint) -> Distance: + boundary = self.closest_boundary(point) + return meters(boundary.distance_to_point(point)) + @singledispatchmethod def threatened(self, position) -> bool: raise NotImplementedError @@ -124,7 +128,7 @@ class ThreatZones: cls, location: ControlPoint, max_distance: Distance ) -> Optional[ControlPoint]: airfields = ObjectiveDistanceCache.get_closest_airfields(location) - for airfield in airfields.airfields_within(max_distance): + for airfield in airfields.all_airfields_within(max_distance): if airfield.captured != location.captured: return airfield return None diff --git a/game/transfers.py b/game/transfers.py index 25935d9c..988c1e84 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math from collections import defaultdict from dataclasses import dataclass, field from functools import singledispatchmethod @@ -89,10 +90,9 @@ class TransferOrder: self.units.clear() def kill_unit(self, unit_type: Type[VehicleType]) -> None: - if unit_type in self.units: - self.units[unit_type] -= 1 - return - raise KeyError + if unit_type not in self.units or not self.units[unit_type]: + raise KeyError(f"{self.destination} has no {unit_type} remaining") + self.units[unit_type] -= 1 @property def size(self) -> int: @@ -238,7 +238,7 @@ class AirliftPlanner: for s in self.game.air_wing_for(self.for_player).squadrons_for( unit_type ) - if FlightType.TRANSPORT in s.mission_types + if FlightType.TRANSPORT in s.auto_assignable_mission_types ] if not squadrons: continue @@ -254,11 +254,13 @@ class AirliftPlanner: self, squadron: Squadron, inventory: ControlPointAircraftInventory ) -> int: available = inventory.available(squadron.aircraft) - # 4 is the max flight size in DCS. - flight_size = min(self.transfer.size, available, 4) + capacity_each = 1 if squadron.aircraft.helicopter else 2 + required = math.ceil(self.transfer.size / capacity_each) + flight_size = min(required, available, squadron.aircraft.group_size_max) + capacity = flight_size * capacity_each - if flight_size < self.transfer.size: - transfer = self.game.transfers.split_transfer(self.transfer, flight_size) + if capacity < self.transfer.size: + transfer = self.game.transfers.split_transfer(self.transfer, capacity) else: transfer = self.transfer @@ -530,33 +532,35 @@ class PendingTransfers: return new_transfer @singledispatchmethod - def cancel_transport(self, transfer: TransferOrder, transport) -> None: + def cancel_transport(self, transport, transfer: TransferOrder) -> None: pass @cancel_transport.register def _cancel_transport_air( - self, _transfer: TransferOrder, transport: Airlift + self, transport: Airlift, _transfer: TransferOrder ) -> None: flight = transport.flight flight.package.remove_flight(flight) + if not flight.package.flights: + self.game.ato_for(transport.player_owned).remove_package(flight.package) self.game.aircraft_inventory.return_from_flight(flight) flight.clear_roster() @cancel_transport.register def _cancel_transport_convoy( - self, transfer: TransferOrder, transport: Convoy + self, transport: Convoy, transfer: TransferOrder ) -> None: self.convoys.remove(transport, transfer) @cancel_transport.register def _cancel_transport_cargo_ship( - self, transfer: TransferOrder, transport: CargoShip + self, transport: CargoShip, transfer: TransferOrder ) -> None: self.cargo_ships.remove(transport, transfer) def cancel_transfer(self, transfer: TransferOrder) -> None: if transfer.transport is not None: - self.cancel_transport(transfer, transfer.transport) + self.cancel_transport(transfer.transport, transfer) self.pending_transfers.remove(transfer) transfer.origin.base.commision_units(transfer.units) diff --git a/game/unitmap.py b/game/unitmap.py index 296d5f06..c1778091 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -1,4 +1,6 @@ """Maps generated units back to their Liberation types.""" +import itertools +import math from dataclasses import dataclass from typing import Dict, Optional, Type @@ -40,8 +42,8 @@ class ConvoyUnit: @dataclass(frozen=True) -class AirliftUnit: - unit_type: Type[VehicleType] +class AirliftUnits: + cargo: tuple[Type[VehicleType], ...] transfer: TransferOrder @@ -59,10 +61,10 @@ class UnitMap: self.buildings: Dict[str, Building] = {} self.convoys: Dict[str, ConvoyUnit] = {} self.cargo_ships: Dict[str, CargoShip] = {} - self.airlifts: Dict[str, AirliftUnit] = {} + self.airlifts: Dict[str, AirliftUnits] = {} def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None: - for pilot, unit in zip(flight.pilots, group.units): + for pilot, unit in zip(flight.roster.pilots, group.units): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. name = str(unit.name) @@ -177,15 +179,26 @@ class UnitMap: return self.cargo_ships.get(name, None) def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None: - for transport, cargo_type in zip(group.units, transfer.iter_units()): + capacity_each = math.ceil(transfer.size / len(group.units)) + for idx, transport in enumerate(group.units): + # Slice the units in groups based on the capacity of each unit. Cargo is + # assigned arbitrarily to units in the order of the group. The last unit in + # the group will receive a partial load if there is not enough cargo to fill + # every transport. + base_idx = idx * capacity_each + cargo = tuple( + itertools.islice( + transfer.iter_units(), base_idx, base_idx + capacity_each + ) + ) # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. name = str(transport.name) if name in self.airlifts: raise RuntimeError(f"Duplicate airlift unit: {name}") - self.airlifts[name] = AirliftUnit(cargo_type, transfer) + self.airlifts[name] = AirliftUnits(cargo, transfer) - def airlift_unit(self, name: str) -> Optional[AirliftUnit]: + def airlift_unit(self, name: str) -> Optional[AirliftUnits]: return self.airlifts.get(name, None) def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None: diff --git a/game/version.py b/game/version.py index 564c43e3..3da26c96 100644 --- a/game/version.py +++ b/game/version.py @@ -2,7 +2,7 @@ from pathlib import Path def _build_version_string() -> str: - components = ["3.0"] + components = ["4.0"] build_number_path = Path("resources/buildnumber") if build_number_path.exists(): with build_number_path.open("r") as build_number_file: @@ -75,10 +75,16 @@ VERSION = _build_version_string() #: * SPAAA_ZSU_23_4_Shilka_Gun_Dish, #: #: Version 5.0 -#: * Ammunition Depots objective locations are now predetermined using the "Ammunition Depot" -#: Warehouse object, and through trigger zone based scenery objects. -#: * The number of alive Ammunition Depot objective buildings connected to a control point -#: directly influences how many ground units can be supported on the front line. -#: * The number of supported ground units at any control point is artificially capped at 50, -#: even if the number of alive Ammunition Depot objectives can support more. -CAMPAIGN_FORMAT_VERSION = (5, 0) +#: * Ammunition Depots objective locations are now predetermined using the "Ammunition +# Depot" Warehouse object, and through trigger zone based scenery objects. +#: * The number of alive Ammunition Depot objective buildings connected to a control +#: point directly influences how many ground units can be supported on the front +#: line. +#: * The number of supported ground units at any control point is artificially +#: capped at 50, even if the number of alive Ammunition Depot objectives can +#: support more. +#: +#: Version 6.0 +#: * Random objective generation no is longer supported. Fixed objective locations were +#: added in 4.1. +CAMPAIGN_FORMAT_VERSION = (6, 0) diff --git a/gen/aircraft.py b/gen/aircraft.py index 36aafd20..3d275dd3 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -803,7 +803,7 @@ class AircraftConflictGenerator: self._setup_payload(flight, group) self._setup_livery(flight, group) - for unit, pilot in zip(group.units, flight.pilots): + for unit, pilot in zip(group.units, flight.roster.pilots): player = pilot is not None and pilot.player self.set_skill(unit, pilot, blue=flight.departure.captured) # Do not generate player group with late activation. diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 9004f5b1..58260dfd 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -162,7 +162,7 @@ class AircraftAllocator: self, flight: ProposedFlight, task: FlightType ) -> Optional[Tuple[ControlPoint, Squadron]]: types = aircraft_for_task(task) - airfields_in_range = self.closest_airfields.airfields_within( + airfields_in_range = self.closest_airfields.operational_airfields_within( flight.max_distance ) @@ -180,7 +180,7 @@ class AircraftAllocator: # Valid location with enough aircraft available. Find a squadron to fit # the role. for squadron in self.air_wing.squadrons_for(aircraft): - if task not in squadron.mission_types: + if task not in squadron.auto_assignable_mission_types: continue if len(squadron.available_pilots) >= flight.num_aircraft: inventory.remove_aircraft(aircraft, flight.num_aircraft) @@ -258,7 +258,9 @@ class PackageBuilder: self, aircraft: Type[FlyingType], arrival: ControlPoint ) -> Optional[ControlPoint]: divert_limit = nautical_miles(150) - for airfield in self.closest_airfields.airfields_within(divert_limit): + for airfield in self.closest_airfields.operational_airfields_within( + divert_limit + ): if airfield.captured != self.is_player: continue if airfield == arrival: @@ -433,7 +435,7 @@ class ObjectiveFinder: is_building = isinstance(ground_object, BuildingGroundObject) is_fob = isinstance(enemy_cp, Fob) - if is_building and is_fob and ground_object.airbase_group: + if is_building and is_fob and ground_object.is_control_point: # This is the FOB structure itself. Can't be repaired or # targeted by the player, so shouldn't be targetable by the # AI. @@ -467,8 +469,10 @@ class ObjectiveFinder: # Off-map spawn locations don't need protection. continue airfields_in_proximity = self.closest_airfields_to(cp) - airfields_in_threat_range = airfields_in_proximity.airfields_within( - self.AIRFIELD_THREAT_RANGE + airfields_in_threat_range = ( + airfields_in_proximity.operational_airfields_within( + self.AIRFIELD_THREAT_RANGE + ) ) for airfield in airfields_in_threat_range: if not airfield.is_friendly(self.is_player): @@ -502,31 +506,23 @@ class ObjectiveFinder: c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player) ) - def farthest_friendly_control_point(self) -> Optional[ControlPoint]: - """ - Iterates over all friendly control points and find the one farthest away from the frontline - BUT! prefer Cvs. Everybody likes CVs! - """ - from_frontline = 0 - cp = None - first_friendly_cp = None + def farthest_friendly_control_point(self) -> ControlPoint: + """Finds the friendly control point that is farthest from any threats.""" + threat_zones = self.game.threat_zone_for(not self.is_player) - for c in self.game.theater.controlpoints: - if c.is_friendly(self.is_player): - if first_friendly_cp is None: - first_friendly_cp = c - if c.is_carrier: - return c - if c.has_active_frontline: - if c.distance_to(self.front_lines().__next__()) > from_frontline: - from_frontline = c.distance_to(self.front_lines().__next__()) - cp = c + farthest = None + max_distance = meters(0) + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + continue + distance = threat_zones.distance_to_threat(cp.position) + if distance > max_distance: + farthest = cp + max_distance = distance - # If no frontlines on the map, return the first friendly cp - if cp is None: - return first_friendly_cp - else: - return cp + if farthest is None: + raise RuntimeError("Found no friendly control points. You probably lost.") + return farthest def enemy_control_points(self) -> Iterator[ControlPoint]: """Iterates over all enemy control points.""" @@ -608,7 +604,7 @@ class CoalitionMissionPlanner: for squadron in self.game.air_wing_for(self.is_player).iter_squadrons(): if ( squadron.aircraft in all_compatible - and mission_type in squadron.mission_types + and mission_type in squadron.auto_assignable_mission_types ): return True return False @@ -624,15 +620,13 @@ class CoalitionMissionPlanner: eliminated this turn. """ - # Find farthest, friendly CP for AEWC - cp = self.objective_finder.farthest_friendly_control_point() - if cp is not None: - yield ProposedMission( - cp, - [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)], - # Supports all the early CAP flights, so should be in the air ASAP. - asap=True, - ) + # Find farthest, friendly CP for AEWC. + yield ProposedMission( + self.objective_finder.farthest_friendly_control_point(), + [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)], + # Supports all the early CAP flights, so should be in the air ASAP. + asap=True, + ) # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. for cp in self.objective_finder.vulnerable_control_points(): @@ -1012,7 +1006,7 @@ class CoalitionMissionPlanner: interval = (latest - earliest) // count for time in range(earliest, latest, interval): error = random.randint(-margin, margin) - yield timedelta(minutes=max(0, time + error)) + yield timedelta(seconds=max(0, time + error)) dca_types = { FlightType.BARCAP, @@ -1026,11 +1020,11 @@ class CoalitionMissionPlanner: start_time = start_time_generator( count=len(non_dca_packages), - earliest=5, + earliest=5 * 60, latest=int( - self.game.settings.desired_player_mission_duration.total_seconds() / 60 + self.game.settings.desired_player_mission_duration.total_seconds() ), - margin=5, + margin=5 * 60, ) for package in self.ato.packages: tot = TotEstimator(package).earliest_tot() diff --git a/gen/flights/closestairfields.py b/gen/flights/closestairfields.py index 6c5c3bfc..d4c4de25 100644 --- a/gen/flights/closestairfields.py +++ b/gen/flights/closestairfields.py @@ -31,17 +31,35 @@ class ClosestAirfields: if c.runway_is_operational() or c.has_helipads ) - def airfields_within(self, distance: Distance) -> Iterator[ControlPoint]: + def _airfields_within( + self, distance: Distance, operational: bool + ) -> Iterator[ControlPoint]: + airfields = ( + self.operational_airfields if operational else self.closest_airfields + ) + for cp in airfields: + if cp.distance_to(self.target) < distance.meters: + yield cp + else: + break + + def operational_airfields_within( + self, distance: Distance + ) -> Iterator[ControlPoint]: """Iterates over all airfields within the given range of the target. Note that this iterates over *all* airfields, not just friendly airfields. """ - for cp in self.closest_airfields: - if cp.distance_to(self.target) < distance.meters: - yield cp - else: - break + return self._airfields_within(distance, operational=True) + + def all_airfields_within(self, distance: Distance) -> Iterator[ControlPoint]: + """Iterates over all airfields within the given range of the target. + + Note that this iterates over *all* airfields, not just friendly + airfields. + """ + return self._airfields_within(distance, operational=False) class ObjectiveDistanceCache: diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 441b6809..e2821f6b 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from enum import Enum from typing import List, Optional, TYPE_CHECKING, Type, Union @@ -202,6 +203,49 @@ class FlightWaypoint: return waypoint +class FlightRoster: + def __init__(self, squadron: Squadron, initial_size: int = 0) -> None: + self.squadron = squadron + self.pilots: list[Optional[Pilot]] = [] + self.resize(initial_size) + + @property + def max_size(self) -> int: + return len(self.pilots) + + @property + def player_count(self) -> int: + return len([p for p in self.pilots if p is not None and p.player]) + + @property + def missing_pilots(self) -> int: + return len([p for p in self.pilots if p is None]) + + def resize(self, new_size: int) -> None: + if self.max_size > new_size: + self.squadron.return_pilots( + [p for p in self.pilots[new_size:] if p is not None] + ) + self.pilots = self.pilots[:new_size] + return + self.pilots.extend( + [ + self.squadron.claim_available_pilot() + for _ in range(new_size - self.max_size) + ] + ) + + def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: + if pilot is not None: + self.squadron.claim_pilot(pilot) + if (current_pilot := self.pilots[index]) is not None: + self.squadron.return_pilot(current_pilot) + self.pilots[index] = pilot + + def clear(self) -> None: + self.squadron.return_pilots([p for p in self.pilots if p is not None]) + + class Flight: def __init__( self, @@ -216,11 +260,15 @@ class Flight: divert: Optional[ControlPoint], custom_name: Optional[str] = None, cargo: Optional[TransferOrder] = None, + roster: Optional[FlightRoster] = None, ) -> None: self.package = package self.country = country self.squadron = squadron - self.pilots = [squadron.claim_available_pilot() for _ in range(count)] + if roster is None: + self.roster = FlightRoster(self.squadron, initial_size=count) + else: + self.roster = roster self.departure = departure self.arrival = arrival self.divert = divert @@ -246,11 +294,11 @@ class Flight: @property def count(self) -> int: - return len(self.pilots) + return self.roster.max_size @property def client_count(self) -> int: - return len([p for p in self.pilots if p is not None and p.player]) + return self.roster.player_count @property def unit_type(self) -> Type[FlyingType]: @@ -265,32 +313,17 @@ class Flight: return self.flight_plan.waypoints[1:] def resize(self, new_size: int) -> None: - if self.count > new_size: - self.squadron.return_pilots( - p for p in self.pilots[new_size:] if p is not None - ) - self.pilots = self.pilots[:new_size] - return - self.pilots.extend( - [ - self.squadron.claim_available_pilot() - for _ in range(new_size - self.count) - ] - ) + self.roster.resize(new_size) def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: - if pilot is not None: - self.squadron.claim_pilot(pilot) - if (current_pilot := self.pilots[index]) is not None: - self.squadron.return_pilot(current_pilot) - self.pilots[index] = pilot + self.roster.set_pilot(index, pilot) @property def missing_pilots(self) -> int: - return len([p for p in self.pilots if p is None]) + return self.roster.missing_pilots def clear_roster(self) -> None: - self.squadron.return_pilots([p for p in self.pilots if p is not None]) + self.roster.clear() def __repr__(self): name = db.unit_type_name(self.unit_type) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 8be0c90f..90fa276c 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1057,7 +1057,7 @@ class FlightPlanBuilder: """ location = self.package.target - start = self.aewc_orbit(location) + orbit_location = self.aewc_orbit(location) # As high as possible to maximize detection and on-station time. if flight.unit_type == E_2C: @@ -1072,22 +1072,22 @@ class FlightPlanBuilder: patrol_alt = feet(25000) builder = WaypointBuilder(flight, self.game, self.is_player) - start = builder.orbit(start, patrol_alt) + orbit_location = builder.orbit(orbit_location, patrol_alt) return AwacsFlightPlan( package=self.package, flight=flight, takeoff=builder.takeoff(flight.departure), nav_to=builder.nav_path( - flight.departure.position, start.position, patrol_alt + flight.departure.position, orbit_location.position, patrol_alt ), nav_from=builder.nav_path( - start.position, flight.arrival.position, patrol_alt + orbit_location.position, flight.arrival.position, patrol_alt ), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), bullseye=builder.bullseye(), - hold=start, + hold=orbit_location, hold_duration=timedelta(hours=4), ) @@ -1339,20 +1339,24 @@ class FlightPlanBuilder: return start, end def aewc_orbit(self, location: MissionTarget) -> Point: - # in threat zone + closest_boundary = self.threat_zones.closest_boundary(location.position) + heading_to_threat_boundary = location.position.heading_between_point( + closest_boundary + ) + distance_to_threat = meters( + location.position.distance_to_point(closest_boundary) + ) + orbit_heading = heading_to_threat_boundary + # Station 100nm outside the threat zone. + threat_buffer = nautical_miles(100) if self.threat_zones.threatened(location.position): - # Borderpoint - closest_boundary = self.threat_zones.closest_boundary(location.position) - - # Heading + Distance to border point - heading = location.position.heading_between_point(closest_boundary) - distance = location.position.distance_to_point(closest_boundary) - - return location.position.point_from_heading(heading, distance) - - # this Part is fine. No threat zone, just use our point + orbit_distance = distance_to_threat + threat_buffer else: - return location.position + orbit_distance = distance_to_threat - threat_buffer + + return location.position.point_from_heading( + orbit_heading, orbit_distance.meters + ) def racetrack_for_frontline( self, origin: Point, front_line: FrontLine @@ -1807,7 +1811,7 @@ class FlightPlanBuilder: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. if not self.package.flights: - raise RuntimeError( + raise PlanningError( "Cannot determine source airfield for package with no flights" ) @@ -1819,5 +1823,4 @@ class FlightPlanBuilder: for flight in self.package.flights: if flight.departure == airfield: return airfield - - raise RuntimeError("Could not find any airfield assigned to this package") + raise PlanningError("Could not find any airfield assigned to this package") diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 761bf76b..075b5a05 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -1,3 +1,4 @@ +import logging import random from enum import Enum from typing import Dict, List @@ -5,7 +6,8 @@ from typing import Dict, List from dcs.unittype import VehicleType from game.theater import ControlPoint -from gen.ground_forces.ai_ground_planner_db import * + +from game.data.groundunitclass import GroundUnitClass from gen.ground_forces.combat_stance import CombatStance MAX_COMBAT_GROUP_PER_CP = 10 @@ -91,37 +93,35 @@ class GroundPlanner: group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE] # Create combat groups and assign them randomly to each enemy CP - for key in self.cp.base.armor.keys(): - - role = None - collection = None - if key in TYPE_TANKS: + for unit_type in self.cp.base.armor: + if unit_type in GroundUnitClass.Tank: collection = self.tank_groups role = CombatGroupRole.TANK - elif key in TYPE_APC: + elif unit_type in GroundUnitClass.Apc: collection = self.apc_group role = CombatGroupRole.APC - elif key in TYPE_ARTILLERY: + elif unit_type in GroundUnitClass.Artillery: collection = self.art_group role = CombatGroupRole.ARTILLERY - elif key in TYPE_IFV: + elif unit_type in GroundUnitClass.Ifv: collection = self.ifv_group role = CombatGroupRole.IFV - elif key in TYPE_LOGI: + elif unit_type in GroundUnitClass.Logistics: collection = self.logi_groups role = CombatGroupRole.LOGI - elif key in TYPE_ATGM: + elif unit_type in GroundUnitClass.Atgm: collection = self.atgm_group role = CombatGroupRole.ATGM - elif key in TYPE_SHORAD: + elif unit_type in GroundUnitClass.Shorads: collection = self.shorad_groups role = CombatGroupRole.SHORAD else: - print("Warning unit type not handled by ground generator") - print(key) + logging.warning( + f"Unused front line vehicle at base {unit_type}: unknown unit class" + ) continue - available = self.cp.base.armor[key] + available = self.cp.base.armor[unit_type] if available > remaining_available_frontline_units: available = remaining_available_frontline_units @@ -151,7 +151,7 @@ class GroundPlanner: group.assigned_enemy_cp = "__reserve__" for i in range(n): - group.units.append(key) + group.units.append(unit_type) collection.append(group) if remaining_available_frontline_units == 0: @@ -161,7 +161,7 @@ class GroundPlanner: print("Ground Planner : ") print(self.cp.name) print("------------------") - for key in self.units_per_cp.keys(): - print("For : #" + str(key)) - for group in self.units_per_cp[key]: + for unit_type in self.units_per_cp.keys(): + print("For : #" + str(unit_type)) + for group in self.units_per_cp[unit_type]: print(str(group)) diff --git a/gen/ground_forces/ai_ground_planner_db.py b/gen/ground_forces/ai_ground_planner_db.py deleted file mode 100644 index fe200fbd..00000000 --- a/gen/ground_forces/ai_ground_planner_db.py +++ /dev/null @@ -1,189 +0,0 @@ -from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor - -from pydcs_extensions.frenchpack import frenchpack - -TYPE_TANKS = [ - Armor.MBT_T_55, - Armor.MBT_T_72B, - Armor.MBT_T_72B3, - Armor.MBT_T_80U, - Armor.MBT_T_90, - Armor.MBT_Leopard_2A4, - Armor.MBT_Leopard_2A4_Trs, - Armor.MBT_Leopard_2A5, - Armor.MBT_Leopard_2A6M, - Armor.MBT_Leopard_1A3, - Armor.MBT_Leclerc, - Armor.MBT_Challenger_II, - Armor.MBT_Chieftain_Mk_3, - Armor.MBT_M1A2_Abrams, - Armor.MBT_M60A3_Patton, - Armor.MBT_Merkava_IV, - Armor.ZTZ_96B, - Armor.LT_PT_76, - # WW2 - Armor.MT_Pz_Kpfw_V_Panther_Ausf_G, - Armor.Tk_PzIV_H, - Armor.HT_Pz_Kpfw_VI_Tiger_I, - Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II, - Armor.Tk_M4_Sherman, - Armor.MT_M4A4_Sherman_Firefly, - Armor.SPG_StuG_IV, - Armor.CT_Centaur_IV, - Armor.CT_Cromwell_IV, - Armor.HIT_Churchill_VII, - Armor.LT_Mk_VII_Tetrarch, - Armor.SPG_Sturmpanzer_IV_Brummbar, - # Mods - frenchpack.DIM__TOYOTA_BLUE, - frenchpack.DIM__TOYOTA_GREEN, - frenchpack.DIM__TOYOTA_DESERT, - frenchpack.DIM__KAMIKAZE, - frenchpack.AMX_10RCR, - frenchpack.AMX_10RCR_SEPAR, - frenchpack.AMX_30B2, - frenchpack.Leclerc_Serie_XXI, -] - -TYPE_ATGM = [ - Armor.ATGM_HMMWV, - Armor.ATGM_VAB_Mephisto, - Armor.ATGM_Stryker, - Armor.IFV_BMP_2, - # WW2 (Tank Destroyers) - Unarmed.Carrier_M30_Cargo, - Armor.SPG_Jagdpanzer_IV, - Armor.SPG_Jagdpanther_G1, - Armor.SPG_M10_GMC, - # Mods - frenchpack.VBAE_CRAB_MMP, - frenchpack.VAB_MEPHISTO, - frenchpack.TRM_2000_PAMELA, -] - -TYPE_IFV = [ - Armor.IFV_BMP_3, - Armor.IFV_BMP_2, - Armor.IFV_BMP_1, - Armor.IFV_Marder, - Armor.IFV_Warrior, - Armor.IFV_LAV_25, - Armor.SPG_Stryker_MGS, - Armor.IFV_Sd_Kfz_234_2_Puma, - Armor.IFV_M2A2_Bradley, - Armor.IFV_BMD_1, - Armor.ZBD_04A, - # WW2 - Armor.IFV_Sd_Kfz_234_2_Puma, - Armor.Car_M8_Greyhound_Armored, - Armor.Car_Daimler_Armored, - # Mods - frenchpack.ERC_90, - frenchpack.VBAE_CRAB, - frenchpack.VAB_T20_13, -] - -TYPE_APC = [ - Armor.Scout_HMMWV, - Armor.IFV_M1126_Stryker_ICV, - Armor.APC_M113, - Armor.APC_BTR_80, - Armor.IFV_BTR_82A, - Armor.APC_MTLB, - Armor.APC_M2A1_Halftrack, - Armor.Scout_Cobra, - Armor.APC_Sd_Kfz_251_Halftrack, - Armor.APC_AAV_7_Amphibious, - Armor.APC_TPz_Fuchs, - Armor.Scout_BRDM_2, - Armor.APC_BTR_RD, - Artillery.Grad_MRL_FDDM__FC, - # WW2 - Armor.APC_M2A1_Halftrack, - Armor.APC_Sd_Kfz_251_Halftrack, - # Mods - frenchpack.VAB__50, - frenchpack.VBL__50, - frenchpack.VBL_AANF1, -] - -TYPE_ARTILLERY = [ - Artillery.MLRS_9A52_Smerch_HE_300mm, - Artillery.SPH_2S1_Gvozdika_122mm, - Artillery.SPH_2S3_Akatsia_152mm, - Artillery.MLRS_BM_21_Grad_122mm, - Artillery.MLRS_9K57_Uragan_BM_27_220mm, - Artillery.SPH_M109_Paladin_155mm, - Artillery.MLRS_M270_227mm, - Artillery.SPM_2S9_Nona_120mm_M, - Artillery.SPH_Dana_vz77_152mm, - Artillery.SPH_T155_Firtina_155mm, - Artillery.PLZ_05, - Artillery.SPH_2S19_Msta_152mm, - Artillery.MLRS_9A52_Smerch_CM_300mm, - # WW2 - Artillery.SPG_M12_GMC_155mm, -] - -TYPE_LOGI = [ - Unarmed.Truck_M818_6x6, - Unarmed.Truck_KAMAZ_43101, - Unarmed.Truck_Ural_375, - Unarmed.Truck_GAZ_66, - Unarmed.Truck_GAZ_3307, - Unarmed.Truck_GAZ_3308, - Unarmed.Truck_Ural_4320_31_Arm_d, - Unarmed.Truck_Ural_4320T, - Unarmed.Truck_Opel_Blitz, - Unarmed.LUV_Kubelwagen_82, - Unarmed.Carrier_Sd_Kfz_7_Tractor, - Unarmed.LUV_Kettenrad, - Unarmed.Car_Willys_Jeep, - Unarmed.LUV_Land_Rover_109, - Unarmed.Truck_Land_Rover_101_FC, - # Mods - frenchpack.VBL, - frenchpack.VAB, -] - -TYPE_INFANTRY = [ - Infantry.Insurgent_AK_74, - Infantry.Infantry_AK_74, - Infantry.Infantry_M1_Garand, - Infantry.Infantry_Mauser_98, - Infantry.Infantry_SMLE_No_4_Mk_1, - Infantry.Infantry_M4_Georgia, - Infantry.Infantry_AK_74_Rus, - Infantry.Paratrooper_AKS, - Infantry.Paratrooper_RPG_16, - Infantry.Infantry_M249, - Infantry.Infantry_M4, - Infantry.Infantry_RPG, -] - -TYPE_SHORAD = [ - AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375, - AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375, - AirDefence.SPAAA_ZSU_57_2, - AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish, - AirDefence.SAM_SA_8_Osa_Gecko_TEL, - AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL, - AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL, - AirDefence.SAM_SA_15_Tor_Gauntlet, - AirDefence.SAM_SA_19_Tunguska_Grison, - AirDefence.SPAAA_Gepard, - AirDefence.SPAAA_Vulcan_M163, - AirDefence.SAM_Linebacker___Bradley_M6, - AirDefence.SAM_Chaparral_M48, - AirDefence.SAM_Avenger__Stinger, - AirDefence.SAM_Roland_ADS, - AirDefence.HQ_7_Self_Propelled_LN, - AirDefence.AAA_8_8cm_Flak_18, - AirDefence.AAA_8_8cm_Flak_36, - AirDefence.AAA_8_8cm_Flak_37, - AirDefence.AAA_8_8cm_Flak_41, - AirDefence.AAA_Bofors_40mm, - AirDefence.AAA_S_60_57mm, - AirDefence.AAA_M1_37mm, - AirDefence.AAA_QF_3_7, -] diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 62f289e5..111fd1b9 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -15,7 +15,7 @@ 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 +from dcs.statics import Fortification, fortification_map, warehouse_map from dcs.task import ( ActivateBeaconCommand, ActivateICLSCommand, @@ -24,7 +24,7 @@ from dcs.task import ( FireAtPoint, ) from dcs.triggers import TriggerStart, TriggerZone -from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad, Static +from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup from dcs.unittype import StaticType, UnitType from dcs.vehicles import vehicle_map @@ -76,8 +76,12 @@ class GenericGroundObjectGenerator: self.m = mission self.unit_map = unit_map + @property + def culled(self) -> bool: + return self.game.position_culled(self.ground_object.position) + def generate(self) -> None: - if self.game.position_culled(self.ground_object.position): + if self.culled: return for group in self.ground_object.groups: @@ -130,6 +134,12 @@ class GenericGroundObjectGenerator: class MissileSiteGenerator(GenericGroundObjectGenerator): + @property + def culled(self) -> bool: + # Don't cull missile sites - their range is long enough to make them easily + # culled despite being a threat. + return False + def generate(self) -> None: super(MissileSiteGenerator, self).generate() # Note : Only the SCUD missiles group can fire (V1 site cannot fire in game right now) diff --git a/gen/naming.py b/gen/naming.py index 3c1b140f..dad364dc 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -312,10 +312,6 @@ class NameGenerator: db.unit_type_name(unit_type), ) - @staticmethod - def next_basedefense_name(): - return "basedefense_aa|0|0|" - @classmethod def next_awacs_name(cls, country: Country): cls.number += 1 @@ -352,7 +348,7 @@ class NameGenerator: for _ in range(10): alpha = random.choice(ALPHA_MILITARY).upper() - number = str(random.randint(0, 100)) + number = random.randint(0, 100) alpha_mil_name = f"{alpha} #{number:02}" if alpha_mil_name not in cls.existing_alphas: cls.existing_alphas.append(alpha_mil_name) diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index 3a25a2dd..a62a5f11 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -21,6 +21,8 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC): This is the base for all SAM group generators """ + price: int + def __init__(self, game: Game, ground_object: SamGroundObject) -> None: ground_object.skynet_capable = True super().__init__(game, ground_object) diff --git a/gen/sam/ewr_group_generator.py b/gen/sam/ewr_group_generator.py index e576f5a8..81ede492 100644 --- a/gen/sam/ewr_group_generator.py +++ b/gen/sam/ewr_group_generator.py @@ -17,8 +17,8 @@ from gen.sam.ewrs import ( SnowDriftGenerator, StraightFlushGenerator, TallRackGenerator, + EwrGenerator, ) -from gen.sam.group_generator import GroupGenerator EWR_MAP = { "BoxSpringGenerator": BoxSpringGenerator, @@ -36,7 +36,7 @@ EWR_MAP = { def get_faction_possible_ewrs_generator( faction: Faction, -) -> List[Type[GroupGenerator]]: +) -> List[Type[EwrGenerator]]: """ Return the list of possible EWR generators for the given faction :param faction: Faction name to search units for diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index d69f2de7..0c529cf8 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -5,9 +5,16 @@ from gen.sam.group_generator import GroupGenerator class EwrGenerator(GroupGenerator): - @property - def unit_type(self) -> VehicleType: - raise NotImplementedError + unit_type: VehicleType + + @classmethod + def name(cls) -> str: + return cls.unit_type.name + + @staticmethod + def price() -> int: + # TODO: Differentiate sites. + return 20 def generate(self) -> None: self.add_unit( diff --git a/gen/triggergen.py b/gen/triggergen.py index ddba0361..a8e29a42 100644 --- a/gen/triggergen.py +++ b/gen/triggergen.py @@ -10,14 +10,12 @@ from dcs.condition import ( FlagIsFalse, FlagIsTrue, ) -from dcs.unitgroup import FlyingGroup from dcs.mission import Mission from dcs.task import Option from dcs.translation import String from dcs.triggers import ( Event, TriggerOnce, - TriggerZone, TriggerCondition, ) from dcs.unit import Skill @@ -25,7 +23,6 @@ from dcs.unit import Skill from game.theater import Airfield from game.theater.controlpoint import Fob - if TYPE_CHECKING: from game.game import Game @@ -115,19 +112,22 @@ class TriggersGenerator: mark_trigger.add_condition(TimeAfter(1)) v = 10 for cp in self.game.theater.controlpoints: - added = [] + seen = set() for ground_object in cp.ground_objects: - if ground_object.obj_name not in added: + if ground_object.obj_name in seen: + continue + + seen.add(ground_object.obj_name) + for location in ground_object.mark_locations: zone = self.mission.triggers.add_triggerzone( - ground_object.position, radius=10, hidden=True, name="MARK" + location, radius=10, hidden=True, name="MARK" ) if cp.captured: name = ground_object.obj_name + " [ALLY]" else: name = ground_object.obj_name + " [ENEMY]" mark_trigger.add_action(MarkToAll(v, zone.id, String(name))) - v = v + 1 - added.append(ground_object.obj_name) + v += 1 self.mission.triggerrules.triggers.append(mark_trigger) def _generate_capture_triggers( diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py deleted file mode 100644 index 2abf369c..00000000 --- a/qt_ui/displayoptions.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Visibility options for the game map.""" -from dataclasses import dataclass, field -from typing import Iterator, Optional, Union - - -@dataclass -class DisplayRule: - name: str - _value: bool - debug_only: bool = field(default=False) - - @property - def menu_text(self) -> str: - return self.name - - @property - def value(self) -> bool: - return self._value - - @value.setter - def value(self, value: bool) -> None: - from qt_ui.widgets.map.QLiberationMap import QLiberationMap - - self._value = value - if QLiberationMap.instance is not None: - QLiberationMap.instance.reload_scene() - QLiberationMap.instance.update() - - def __bool__(self) -> bool: - return self.value - - -class DisplayGroup: - def __init__(self, name: Optional[str], debug_only: bool = False) -> None: - self.name = name - self.debug_only = debug_only - - def __iter__(self) -> Iterator[DisplayRule]: - # Python 3.6 enforces that __dict__ is order preserving by default. - for value in self.__dict__.values(): - if isinstance(value, DisplayRule): - yield value - - -class FlightPathOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Flight Paths") - self.hide = DisplayRule("Hide Flight Paths", False) - self.only_selected = DisplayRule("Show Selected Flight Path", False) - self.all = DisplayRule("Show All Flight Paths", True) - - -class ThreatZoneOptions(DisplayGroup): - def __init__(self, coalition_name: str) -> None: - super().__init__(f"{coalition_name} Threat Zones") - self.none = DisplayRule(f"Hide {coalition_name.lower()} threat zones", True) - self.all = DisplayRule( - f"Show full {coalition_name.lower()} threat zones", False - ) - self.aircraft = DisplayRule( - f"Show {coalition_name.lower()} aircraft threat tones", False - ) - self.air_defenses = DisplayRule( - f"Show {coalition_name.lower()} air defenses threat zones", False - ) - - -class NavMeshOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Navmeshes", debug_only=True) - self.hide = DisplayRule("DEBUG Hide Navmeshes", True) - self.blue_navmesh = DisplayRule("DEBUG Show blue navmesh", False) - self.red_navmesh = DisplayRule("DEBUG Show red navmesh", False) - - -class PathDebugFactionOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Faction for path debugging", debug_only=True) - self.blue = DisplayRule("Debug blue paths", True) - self.red = DisplayRule("Debug red paths", False) - - -class PathDebugOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Shortest paths", debug_only=True) - self.hide = DisplayRule("DEBUG Hide paths", True) - self.shortest_path = DisplayRule("DEBUG Show shortest path", False) - self.barcap = DisplayRule("DEBUG Show BARCAP plan", False) - self.cas = DisplayRule("DEBUG Show CAS plan", False) - self.sweep = DisplayRule("DEBUG Show fighter sweep plan", False) - self.strike = DisplayRule("DEBUG Show strike plan", False) - self.tarcap = DisplayRule("DEBUG Show TARCAP plan", False) - - -class DisplayOptions: - ground_objects = DisplayRule("Ground Objects", True) - control_points = DisplayRule("Control Points", True) - lines = DisplayRule("Lines", True) - sam_ranges = DisplayRule("Ally SAM Threat Range", False) - enemy_sam_ranges = DisplayRule("Enemy SAM Threat Range", True) - detection_range = DisplayRule("SAM Detection Range", False) - map_poly = DisplayRule("Map Polygon Debug Mode", False) - waypoint_info = DisplayRule("Waypoint Information", True) - culling = DisplayRule("Display Culling Zones", False) - actual_frontline_pos = DisplayRule("Display Actual Frontline Location", False) - patrol_engagement_range = DisplayRule( - "Display selected patrol engagement range", True - ) - flight_paths = FlightPathOptions() - blue_threat_zones = ThreatZoneOptions("Blue") - red_threat_zones = ThreatZoneOptions("Red") - navmeshes = NavMeshOptions() - path_debug_faction = PathDebugFactionOptions() - path_debug = PathDebugOptions() - - @classmethod - def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: - debug = False # Set to True to enable debug options. - # Python 3.6 enforces that __dict__ is order preserving by default. - for value in cls.__dict__.values(): - if isinstance(value, DisplayRule): - if value.debug_only and not debug: - continue - yield value - elif isinstance(value, DisplayGroup): - if value.debug_only and not debug: - continue - yield value diff --git a/qt_ui/main.py b/qt_ui/main.py index cbc3f08c..46b7fc1c 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -57,7 +57,7 @@ def inject_custom_payloads(user_path: Path) -> None: PayloadDirectories.set_preferred(user_path / "MissionEditor" / "UnitPayloads") -def run_ui(game: Optional[Game], new_map: bool) -> None: +def run_ui(game: Optional[Game]) -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens app = QApplication(sys.argv) @@ -111,7 +111,7 @@ def run_ui(game: Optional[Game], new_map: bool) -> None: GameUpdateSignal() # Start window - window = QLiberationWindow(game, new_map) + window = QLiberationWindow(game) window.showMaximized() splash.finish(window) qt_execution_code = app.exec_() @@ -139,16 +139,8 @@ def parse_args() -> argparse.Namespace: help="Emits a warning for weapons without date or fallback information.", ) - parser.add_argument( - "--new-map", - action="store_true", - default=True, - help="Use the new map. Functional but missing many display options.", - ) - - parser.add_argument( - "--old-map", dest="new_map", action="store_false", help="Use the old map." - ) + parser.add_argument("--new-map", help="Deprecated. Does nothing.") + parser.add_argument("--old-map", help="Deprecated. Does nothing.") new_game = subparsers.add_parser("new-game") @@ -267,7 +259,7 @@ def main(): args.cheats, ) - run_ui(game, args.new_map) + run_ui(game) if __name__ == "__main__": diff --git a/qt_ui/models.py b/qt_ui/models.py index 9887aa05..0bf5920f 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -18,7 +18,7 @@ from game.squadrons import Squadron, Pilot from game.theater.missiontarget import MissionTarget from game.transfers import TransferOrder from gen.ato import AirTaskingOrder, Package -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightType from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS @@ -424,7 +424,7 @@ class SquadronModel(QAbstractListModel): self.squadron = squadron def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - return self.squadron.size + return self.squadron.number_of_pilots_including_dead def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: if not index.isValid(): @@ -467,6 +467,15 @@ class SquadronModel(QAbstractListModel): pilot.send_on_leave() self.endResetModel() + def is_auto_assignable(self, task: FlightType) -> bool: + return task in self.squadron.auto_assignable_mission_types + + def set_auto_assignable(self, task: FlightType, auto_assignable: bool) -> None: + if auto_assignable: + self.squadron.auto_assignable_mission_types.add(task) + else: + self.squadron.auto_assignable_mission_types.remove(task) + class GameModel: """A model for the Game object. diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index b8d4f36c..19bc1945 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -1,7 +1,7 @@ import os from typing import Dict -from PySide2.QtGui import QColor, QFont, QPixmap +from PySide2.QtGui import QPixmap from game.theater.theatergroundobject import NAME_BY_CATEGORY from .liberation_theme import get_theme_icons @@ -16,51 +16,6 @@ URLS: Dict[str, str] = { LABELS_OPTIONS = ["Full", "Abbreviated", "Dot Only", "Off"] SKILL_OPTIONS = ["Average", "Good", "High", "Excellent"] -FONT_SIZE = 8 -FONT_NAME = "Arial" -# FONT = QFont("Arial", 12, weight=5, italic=True) -FONT_PRIMARY = QFont(FONT_NAME, FONT_SIZE, weight=5, italic=False) -FONT_PRIMARY_I = QFont(FONT_NAME, FONT_SIZE, weight=5, italic=True) -FONT_PRIMARY_B = QFont(FONT_NAME, FONT_SIZE, weight=75, italic=False) -FONT_MAP = QFont(FONT_NAME, 10, weight=75, italic=False) - -COLORS: Dict[str, QColor] = { - "white": QColor(255, 255, 255), - "white_transparent": QColor(255, 255, 255, 35), - "light_red": QColor(231, 92, 83, 90), - "red": QColor(200, 80, 80), - "dark_red": QColor(140, 20, 20), - "red_transparent": QColor(227, 32, 0, 20), - "transparent": QColor(255, 255, 255, 0), - "light_blue": QColor(105, 182, 240, 90), - "blue": QColor(0, 132, 255), - "dark_blue": QColor(45, 62, 80), - "sea_blue": QColor(52, 68, 85), - "sea_blue_transparent": QColor(52, 68, 85, 150), - "blue_transparent": QColor(0, 132, 255, 20), - "purple": QColor(187, 137, 255), - "yellow": QColor(238, 225, 123), - "bright_red": QColor(150, 80, 80), - "super_red": QColor(227, 32, 0), - "green": QColor(128, 186, 128), - "light_green": QColor(223, 255, 173), - "light_green_transparent": QColor(180, 255, 140, 50), - "bright_green": QColor(64, 200, 64), - "black": QColor(0, 0, 0), - "black_transparent": QColor(0, 0, 0, 5), - "orange": QColor(254, 125, 10), - "night_overlay": QColor(12, 20, 69), - "dawn_dust_overlay": QColor(46, 38, 85), - "grey": QColor(150, 150, 150), - "grey_transparent": QColor(150, 150, 150, 150), - "dark_grey": QColor(75, 75, 75), - "dark_grey_transparent": QColor(75, 75, 75, 150), - "dark_dark_grey": QColor(48, 48, 48), - "dark_dark_grey_transparent": QColor(48, 48, 48, 150), -} - -CP_SIZE = 12 - AIRCRAFT_BANNERS: Dict[str, QPixmap] = {} AIRCRAFT_ICONS: Dict[str, QPixmap] = {} VEHICLE_BANNERS: Dict[str, QPixmap] = {} @@ -138,17 +93,6 @@ def load_icons(): "./resources/ui/misc/" + get_theme_icons() + "/ordnance_icon.png" ) - ICONS["target"] = QPixmap("./resources/ui/ground_assets/target.png") - ICONS["cleared"] = QPixmap("./resources/ui/ground_assets/cleared.png") - for category in NAME_BY_CATEGORY.keys(): - ICONS[category] = QPixmap("./resources/ui/ground_assets/" + category + ".png") - ICONS[category + "_blue"] = QPixmap( - "./resources/ui/ground_assets/" + category + "_blue.png" - ) - ICONS["destroyed"] = QPixmap("./resources/ui/ground_assets/destroyed.png") - ICONS["nothreat"] = QPixmap("./resources/ui/ground_assets/nothreat.png") - ICONS["nothreat_blue"] = QPixmap("./resources/ui/ground_assets/nothreat_blue.png") - ICONS["Generator"] = QPixmap( "./resources/ui/misc/" + get_theme_icons() + "/generator.png" ) diff --git a/qt_ui/widgets/QLiberationCalendar.py b/qt_ui/widgets/QLiberationCalendar.py index 43e0d469..c33d8810 100644 --- a/qt_ui/widgets/QLiberationCalendar.py +++ b/qt_ui/widgets/QLiberationCalendar.py @@ -1,8 +1,6 @@ -from PySide2 import QtCore, QtGui, QtWidgets +from PySide2 import QtCore, QtGui from PySide2.QtWidgets import QCalendarWidget -from qt_ui.uiconstants import COLORS - class QLiberationCalendar(QCalendarWidget): def __init__(self, parent=None): @@ -29,7 +27,7 @@ class QLiberationCalendar(QCalendarWidget): painter.save() painter.fillRect(rect, QtGui.QColor("#D3D3D3")) painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(COLORS["sea_blue"])) + painter.setBrush(QtGui.QColor(52, 68, 85)) r = QtCore.QRect( QtCore.QPoint(), min(rect.width(), rect.height()) * QtCore.QSize(1, 1) ) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index d3701e0c..7292cec4 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -25,8 +25,6 @@ from qt_ui.windows.AirWingDialog import AirWingDialog from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.PendingTransfersDialog import PendingTransfersDialog from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow -from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow -from qt_ui.windows.stats.QStatsWindow import QStatsWindow class QTopPanel(QFrame): @@ -74,26 +72,12 @@ class QTopPanel(QFrame): self.transfers.setProperty("style", "btn-primary") self.transfers.clicked.connect(self.open_transfers) - self.settings = QPushButton("Settings") - self.settings.setDisabled(True) - self.settings.setIcon(CONST.ICONS["Settings"]) - self.settings.setProperty("style", "btn-primary") - self.settings.clicked.connect(self.openSettings) - - self.statistics = QPushButton("Statistics") - self.statistics.setDisabled(True) - self.statistics.setIcon(CONST.ICONS["Statistics"]) - self.statistics.setProperty("style", "btn-primary") - self.statistics.clicked.connect(self.openStatisticsWindow) - self.intel_box = QIntelBox(self.game) self.buttonBox = QGroupBox("Misc") self.buttonBoxLayout = QHBoxLayout() self.buttonBoxLayout.addWidget(self.air_wing) self.buttonBoxLayout.addWidget(self.transfers) - self.buttonBoxLayout.addWidget(self.settings) - self.buttonBoxLayout.addWidget(self.statistics) self.buttonBox.setLayout(self.buttonBoxLayout) self.proceedBox = QGroupBox("Proceed") @@ -123,8 +107,6 @@ class QTopPanel(QFrame): self.air_wing.setEnabled(True) self.transfers.setEnabled(True) - self.settings.setEnabled(True) - self.statistics.setEnabled(True) self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) self.intel_box.set_game(game) @@ -146,14 +128,6 @@ class QTopPanel(QFrame): self.dialog = PendingTransfersDialog(self.game_model) self.dialog.show() - def openSettings(self): - self.dialog = QSettingsWindow(self.game) - self.dialog.show() - - def openStatisticsWindow(self): - self.dialog = QStatsWindow(self.game) - self.dialog.show() - def passTurn(self): with logged_duration("Skipping turn"): self.game.pass_turn(no_action=True) diff --git a/qt_ui/widgets/map/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py deleted file mode 100644 index 0e886d5d..00000000 --- a/qt_ui/widgets/map/QFrontLine.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Common base for objects drawn on the game map.""" -from typing import Optional - -from PySide2.QtCore import Qt -from PySide2.QtGui import QPen -from PySide2.QtWidgets import ( - QAction, - QGraphicsLineItem, - QGraphicsSceneContextMenuEvent, - QGraphicsSceneHoverEvent, - QGraphicsSceneMouseEvent, - QMenu, -) - -import qt_ui.uiconstants as const -from game.theater import FrontLine -from qt_ui.dialogs import Dialog -from qt_ui.models import GameModel -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog - - -class QFrontLine(QGraphicsLineItem): - """Base class for objects drawn on the game map. - - Game map objects have an on_click behavior that triggers on left click, and - change the mouse cursor on hover. - """ - - def __init__( - self, - x1: float, - y1: float, - x2: float, - y2: float, - mission_target: FrontLine, - game_model: GameModel, - ) -> None: - super().__init__(x1, y1, x2, y2) - self.mission_target = mission_target - self.game_model = game_model - self.new_package_dialog: Optional[QNewPackageDialog] = None - self.setAcceptHoverEvents(True) - - pen = QPen(brush=const.COLORS["bright_red"]) - pen.setColor(const.COLORS["orange"]) - pen.setWidth(8) - self.setPen(pen) - - def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.setCursor(Qt.PointingHandCursor) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() == Qt.LeftButton: - self.on_click() - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: - menu = QMenu("Menu") - - object_details_action = QAction(self.object_dialog_text) - object_details_action.triggered.connect(self.on_click) - menu.addAction(object_details_action) - - new_package_action = QAction(f"New package") - new_package_action.triggered.connect(self.open_new_package_dialog) - menu.addAction(new_package_action) - - if self.game_model.game.settings.enable_frontline_cheats: - cheat_forward = QAction(f"CHEAT: Advance Frontline") - cheat_forward.triggered.connect(self.cheat_forward) - menu.addAction(cheat_forward) - - cheat_backward = QAction(f"CHEAT: Retreat Frontline") - cheat_backward.triggered.connect(self.cheat_backward) - menu.addAction(cheat_backward) - - menu.exec_(event.screenPos()) - - @property - def object_dialog_text(self) -> str: - """Text to for the object's dialog in the context menu. - - Right clicking a map object will open a context menu and the first item - will open the details dialog for this object. This menu action has the - same behavior as the on_click event. - - Return: - The text that should be displayed for the menu item. - """ - return "Details" - - def on_click(self) -> None: - """The action to take when this map object is left-clicked. - - Typically this should open a details view of the object. - """ - raise NotImplementedError - - def open_new_package_dialog(self) -> None: - """Opens the dialog for planning a new mission package.""" - Dialog.open_new_package_dialog(self.mission_target) - - def cheat_forward(self) -> None: - self.mission_target.blue_cp.base.affect_strength(0.1) - self.mission_target.red_cp.base.affect_strength(-0.1) - # Clear the ATO to replan missions affected by the front line. - self.game_model.game.reset_ato() - self.game_model.game.initialize_turn() - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - - def cheat_backward(self) -> None: - self.mission_target.blue_cp.base.affect_strength(-0.1) - self.mission_target.red_cp.base.affect_strength(0.1) - # Clear the ATO to replan missions affected by the front line. - self.game_model.game.reset_ato() - self.game_model.game.initialize_turn() - GameUpdateSignal.get_instance().updateGame(self.game_model.game) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 51a00d18..5d7f2d7d 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,131 +1,21 @@ from __future__ import annotations -import datetime import logging -import math -from functools import singledispatchmethod from pathlib import Path from typing import ( - Iterable, - Iterator, - List, Optional, - Sequence, - Tuple, ) -from PySide2 import QtCore, QtWidgets -from PySide2.QtCore import QLineF, QPointF, QRectF, Qt, QUrl -from PySide2.QtGui import ( - QBrush, - QColor, - QFont, - QPen, - QPixmap, - QPolygonF, - QWheelEvent, -) +from PySide2.QtCore import QUrl from PySide2.QtWebChannel import QWebChannel from PySide2.QtWebEngineWidgets import ( QWebEnginePage, QWebEngineView, ) -from PySide2.QtWidgets import ( - QFrame, - QGraphicsItem, - QGraphicsOpacityEffect, - QGraphicsScene, - QGraphicsSceneMouseEvent, - QGraphicsView, -) -from dcs import Point -from dcs.mapping import point_from_heading -from dcs.unitgroup import Group -from shapely.geometry import ( - LineString, - MultiPolygon, - Point as ShapelyPoint, - Polygon, -) -import qt_ui.uiconstants as CONST from game import Game -from game.navmesh import NavMesh -from game.theater import ControlPoint, Enum -from game.theater.conflicttheater import ( - FrontLine, - ReferencePoint, -) -from game.theater.theatergroundobject import ( - TheaterGroundObject, -) -from game.transfers import Convoy -from game.utils import Distance, meters, nautical_miles, pairwise -from game.weather import TimeOfDay -from gen import Conflict, Package -from gen.flights.flight import ( - Flight, - FlightType, - FlightWaypoint, - FlightWaypointType, -) -from gen.flights.flightplan import ( - FlightPlan, - FlightPlanBuilder, - InvalidObjectiveLocation, - PatrollingFlightPlan, -) -from gen.flights.traveltime import TotEstimator -from qt_ui.displayoptions import DisplayOptions, ThreatZoneOptions from qt_ui.models import GameModel -from qt_ui.widgets.map.QFrontLine import QFrontLine -from qt_ui.widgets.map.QLiberationScene import QLiberationScene -from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint -from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject -from qt_ui.widgets.map.ShippingLaneSegment import ShippingLaneSegment -from qt_ui.widgets.map.SupplyRouteSegment import SupplyRouteSegment from qt_ui.widgets.map.mapmodel import MapModel -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal - -MAX_SHIP_DISTANCE = nautical_miles(80) - - -MapPoint = Tuple[float, float] - - -def binomial(i: int, n: int) -> float: - """Binomial coefficient""" - return math.factorial(n) / float(math.factorial(i) * math.factorial(n - i)) - - -def bernstein(t: float, i: int, n: int) -> float: - """Bernstein polynom""" - return binomial(i, n) * (t ** i) * ((1 - t) ** (n - i)) - - -def bezier(t: float, points: Sequence[Tuple[float, float]]) -> Tuple[float, float]: - """Calculate coordinate of a point in the bezier curve""" - n = len(points) - 1 - x = y = 0 - for i, pos in enumerate(points): - bern = bernstein(t, i, n) - x += pos[0] * bern - y += pos[1] * bern - return x, y - - -def bezier_curve_range( - n: int, points: Sequence[Tuple[float, float]] -) -> Iterator[Tuple[float, float]]: - """Range of points in a curve bezier""" - for i in range(n): - t = i / float(n - 1) - yield bezier(t, points) - - -class QLiberationMapState(Enum): - NORMAL = 0 - MOVING_UNIT = 1 class LoggingWebPage(QWebEnginePage): @@ -144,12 +34,7 @@ class LoggingWebPage(QWebEnginePage): logging.info(message) -class LiberationMap: - def set_game(self, game: Optional[Game]) -> None: - raise NotImplementedError - - -class LeafletMap(QWebEngineView, LiberationMap): +class QLiberationMap(QWebEngineView): def __init__(self, game_model: GameModel, parent) -> None: super().__init__(parent) self.game_model = game_model @@ -171,1290 +56,3 @@ class LeafletMap(QWebEngineView, LiberationMap): self.map_model.clear() else: self.map_model.reset() - - -class QLiberationMap(QGraphicsView, LiberationMap): - - WAYPOINT_SIZE = 4 - reference_point_setup_mode = False - instance: Optional[QLiberationMap] = None - - def __init__(self, game_model: GameModel) -> None: - super().__init__() - QLiberationMap.instance = self - self.game_model = game_model - self.game: Optional[Game] = None # Setup by set_game below. - self.state = QLiberationMapState.NORMAL - - self.waypoint_info_font = QFont() - self.waypoint_info_font.setPointSize(12) - - self.flight_path_items: List[QGraphicsItem] = [] - # A tuple of (package index, flight index), or none. - self.selected_flight: Optional[Tuple[int, int]] = None - - self.setMinimumSize(800, 600) - self.setMaximumHeight(2160) - self._zoom = 0 - self.factor = 1 - self.factorized = 1 - self.init_scene() - self.set_game(game_model.game) - - # Object displayed when unit is selected - self.movement_line = QtWidgets.QGraphicsLineItem( - QtCore.QLineF(QPointF(0, 0), QPointF(0, 0)) - ) - self.movement_line.setPen(QPen(CONST.COLORS["orange"], width=10.0)) - self.selected_cp: Optional[QMapControlPoint] = None - - GameUpdateSignal.get_instance().flight_paths_changed.connect( - lambda: self.draw_flight_plans(self.scene()) - ) - - def update_package_selection(index: int) -> None: - # Optional[int] isn't a valid type for a Qt signal. None will be - # converted to zero automatically. We use -1 to indicate no - # selection. - if index == -1: - self.selected_flight = None - else: - self.selected_flight = index, 0 - self.draw_flight_plans(self.scene()) - - GameUpdateSignal.get_instance().package_selection_changed.connect( - update_package_selection - ) - - def update_flight_selection(index: int) -> None: - if self.selected_flight is None: - if index != -1: - # We don't know what order update_package_selection and - # update_flight_selection will be called in when the last - # package is removed. If no flight is selected, it's not a - # problem to also have no package selected. - logging.error("Flight was selected with no package selected") - return - - # Optional[int] isn't a valid type for a Qt signal. None will be - # converted to zero automatically. We use -1 to indicate no - # selection. - if index == -1: - self.selected_flight = self.selected_flight[0], None - self.selected_flight = self.selected_flight[0], index - self.draw_flight_plans(self.scene()) - - GameUpdateSignal.get_instance().flight_selection_changed.connect( - update_flight_selection - ) - - self.nm_to_pixel_ratio: int = 0 - - self.navmesh_highlight: Optional[QPolygonF] = None - self.shortest_path_segments: List[QLineF] = [] - - def init_scene(self): - scene = QLiberationScene(self) - self.setScene(scene) - self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) - self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) - self.setBackgroundBrush(QBrush(QColor(30, 30, 30))) - self.setFrameShape(QFrame.NoFrame) - self.setDragMode(QGraphicsView.ScrollHandDrag) - - def set_game(self, game: Optional[Game]): - should_recenter = self.game is None - self.game = game - if self.game is not None: - logging.debug("Reloading Map Canvas") - self.nm_to_pixel_ratio = self.distance_to_pixels(nautical_miles(1)) - self.reload_scene(should_recenter) - - """ - - Uncomment to set up theather reference points""" - - def keyPressEvent(self, event): - modifiers = QtWidgets.QApplication.keyboardModifiers() - if not self.reference_point_setup_mode: - if modifiers == QtCore.Qt.ShiftModifier and event.key() == QtCore.Qt.Key_R: - self.reference_point_setup_mode = True - self.reload_scene() - else: - super(QLiberationMap, self).keyPressEvent(event) - else: - if modifiers == QtCore.Qt.ShiftModifier and event.key() == QtCore.Qt.Key_R: - self.reference_point_setup_mode = False - self.reload_scene() - else: - distance = 1 - modifiers = int(event.modifiers()) - if modifiers & QtCore.Qt.ShiftModifier: - distance *= 10 - elif modifiers & QtCore.Qt.ControlModifier: - distance *= 100 - - if event.key() == QtCore.Qt.Key_Down: - self.update_reference_point( - self.game.theater.reference_points[0], Point(0, distance) - ) - if event.key() == QtCore.Qt.Key_Up: - self.update_reference_point( - self.game.theater.reference_points[0], Point(0, -distance) - ) - if event.key() == QtCore.Qt.Key_Left: - self.update_reference_point( - self.game.theater.reference_points[0], Point(-distance, 0) - ) - if event.key() == QtCore.Qt.Key_Right: - self.update_reference_point( - self.game.theater.reference_points[0], Point(distance, 0) - ) - - if event.key() == QtCore.Qt.Key_S: - self.update_reference_point( - self.game.theater.reference_points[1], Point(0, distance) - ) - if event.key() == QtCore.Qt.Key_W: - self.update_reference_point( - self.game.theater.reference_points[1], Point(0, -distance) - ) - if event.key() == QtCore.Qt.Key_A: - self.update_reference_point( - self.game.theater.reference_points[1], Point(-distance, 0) - ) - if event.key() == QtCore.Qt.Key_D: - self.update_reference_point( - self.game.theater.reference_points[1], Point(distance, 0) - ) - - logging.debug(f"Reference points: {self.game.theater.reference_points}") - self.reload_scene() - - @staticmethod - def update_reference_point(point: ReferencePoint, change: Point) -> None: - point.image_coordinates += change - - def display_culling(self, scene: QGraphicsScene) -> None: - """Draws the culling distance rings on the map""" - culling_points = self.game_model.game.get_culling_points() - culling_zones = self.game_model.game.get_culling_zones() - culling_distance = self.game_model.game.settings.perf_culling_distance - for point in culling_points: - culling_distance_point = Point(point.x + 2500, point.y + 2500) - distance_point = self._transform_point(culling_distance_point) - transformed = self._transform_point(point) - radius = distance_point[0] - transformed[0] - scene.addEllipse( - transformed[0] - radius, - transformed[1] - radius, - 2 * radius, - 2 * radius, - CONST.COLORS["transparent"], - CONST.COLORS["light_green_transparent"], - ) - for zone in culling_zones: - culling_distance_zone = Point( - zone.x + culling_distance * 1000, zone.y + culling_distance * 1000 - ) - distance_zone = self._transform_point(culling_distance_zone) - transformed = self._transform_point(zone) - radius = distance_zone[0] - transformed[0] - scene.addEllipse( - transformed[0] - radius, - transformed[1] - radius, - 2 * radius, - 2 * radius, - CONST.COLORS["transparent"], - CONST.COLORS["light_green_transparent"], - ) - - def draw_shapely_poly( - self, scene: QGraphicsScene, poly: Polygon, pen: QPen, brush: QBrush - ) -> Optional[QPolygonF]: - if poly.is_empty: - return None - points = [] - for x, y in poly.exterior.coords: - x, y = self._transform_point(Point(x, y)) - points.append(QPointF(x, y)) - return scene.addPolygon(QPolygonF(points), pen, brush) - - def draw_threat_zone( - self, scene: QGraphicsScene, poly: Polygon, player: bool - ) -> None: - if player: - brush = QColor(0, 132, 255, 100) - else: - brush = QColor(227, 32, 0, 100) - self.draw_shapely_poly(scene, poly, CONST.COLORS["transparent"], brush) - - def display_threat_zones( - self, scene: QGraphicsScene, options: ThreatZoneOptions, player: bool - ) -> None: - """Draws the threat zones on the map.""" - threat_zones = self.game.threat_zone_for(player) - if options.all: - threat_poly = threat_zones.all - elif options.aircraft: - threat_poly = threat_zones.airbases - elif options.air_defenses: - threat_poly = threat_zones.air_defenses - else: - return - - if isinstance(threat_poly, MultiPolygon): - polys = threat_poly.geoms - else: - polys = [threat_poly] - for poly in polys: - self.draw_threat_zone(scene, poly, player) - - def draw_navmesh_neighbor_line( - self, scene: QGraphicsScene, poly: Polygon, begin: ShapelyPoint - ) -> None: - vertex = Point(begin.x, begin.y) - centroid = poly.centroid - direction = Point(centroid.x, centroid.y) - end = vertex.point_from_heading( - vertex.heading_between_point(direction), nautical_miles(2).meters - ) - - scene.addLine( - QLineF( - QPointF(*self._transform_point(vertex)), - QPointF(*self._transform_point(end)), - ), - CONST.COLORS["yellow"], - ) - - @singledispatchmethod - def draw_navmesh_border( - self, intersection, scene: QGraphicsScene, poly: Polygon - ) -> None: - raise NotImplementedError( - "draw_navmesh_border not implemented for %s", - intersection.__class__.__name__, - ) - - @draw_navmesh_border.register - def draw_navmesh_point_border( - self, intersection: ShapelyPoint, scene: QGraphicsScene, poly: Polygon - ) -> None: - # Draw a line from the vertex toward the center of the polygon. - self.draw_navmesh_neighbor_line(scene, poly, intersection) - - @draw_navmesh_border.register - def draw_navmesh_edge_border( - self, intersection: LineString, scene: QGraphicsScene, poly: Polygon - ) -> None: - # Draw a line from the center of the edge toward the center of the - # polygon. - edge_center = intersection.interpolate(0.5, normalized=True) - self.draw_navmesh_neighbor_line(scene, poly, edge_center) - - def display_navmesh(self, scene: QGraphicsScene, player: bool) -> None: - for navpoly in self.game.navmesh_for(player).polys: - self.draw_shapely_poly( - scene, navpoly.poly, CONST.COLORS["black"], CONST.COLORS["transparent"] - ) - - position = self._transform_point( - Point(navpoly.poly.centroid.x, navpoly.poly.centroid.y) - ) - text = scene.addSimpleText( - f"Navmesh {navpoly.ident}", self.waypoint_info_font - ) - text.setBrush(QColor(255, 255, 255)) - text.setPen(QColor(255, 255, 255)) - text.moveBy(position[0] + 8, position[1]) - text.setZValue(2) - - for border in navpoly.neighbors.values(): - self.draw_navmesh_border(border, scene, navpoly.poly) - - def highlight_mouse_navmesh( - self, scene: QGraphicsScene, navmesh: NavMesh, mouse_position: Point - ) -> None: - if self.navmesh_highlight is not None: - try: - scene.removeItem(self.navmesh_highlight) - except RuntimeError: - pass - navpoly = navmesh.localize(mouse_position) - if navpoly is None: - return - self.navmesh_highlight = self.draw_shapely_poly( - scene, - navpoly.poly, - CONST.COLORS["transparent"], - CONST.COLORS["light_green_transparent"], - ) - - def draw_shortest_path( - self, scene: QGraphicsScene, navmesh: NavMesh, destination: Point, player: bool - ) -> None: - for line in self.shortest_path_segments: - try: - scene.removeItem(line) - except RuntimeError: - pass - - if player: - origin = self.game.theater.player_points()[0] - else: - origin = self.game.theater.enemy_points()[0] - - prev_pos = self._transform_point(origin.position) - try: - path = navmesh.shortest_path(origin.position, destination) - except ValueError: - return - for waypoint in path[1:]: - new_pos = self._transform_point(waypoint) - flight_path_pen = self.flight_path_pen(player, selected=True) - # Draw the line to the *middle* of the waypoint. - offset = self.WAYPOINT_SIZE // 2 - self.shortest_path_segments.append( - scene.addLine( - prev_pos[0] + offset, - prev_pos[1] + offset, - new_pos[0] + offset, - new_pos[1] + offset, - flight_path_pen, - ) - ) - - self.shortest_path_segments.append( - scene.addEllipse( - new_pos[0], - new_pos[1], - self.WAYPOINT_SIZE, - self.WAYPOINT_SIZE, - flight_path_pen, - flight_path_pen, - ) - ) - - prev_pos = new_pos - - def draw_test_flight_plan( - self, - scene: QGraphicsScene, - task: FlightType, - point_near_target: Point, - player: bool, - ) -> None: - for line in self.shortest_path_segments: - try: - scene.removeItem(line) - except RuntimeError: - pass - - self.clear_flight_paths(scene) - - target = self.game.theater.closest_target(point_near_target) - - if player: - origin = self.game.theater.player_points()[0] - else: - origin = self.game.theater.enemy_points()[0] - - package = Package(target) - for squadron_list in self.game.air_wing_for(player=True).squadrons.values(): - squadron = squadron_list[0] - break - else: - logging.error("Player has no squadrons?") - return - - flight = Flight( - package, - self.game.country_for(player), - squadron, - 2, - task, - start_type="Warm", - departure=origin, - arrival=origin, - divert=None, - ) - package.add_flight(flight) - planner = FlightPlanBuilder(self.game, package, is_player=player) - try: - planner.populate_flight_plan(flight) - except InvalidObjectiveLocation: - return - - package.time_over_target = TotEstimator(package).earliest_tot() - self.draw_flight_plan(scene, flight, selected=True) - - @staticmethod - def should_display_ground_objects_at(cp: ControlPoint) -> bool: - return (DisplayOptions.sam_ranges and cp.captured) or ( - DisplayOptions.enemy_sam_ranges and not cp.captured - ) - - def draw_threat_range( - self, - scene: QGraphicsScene, - group: Group, - ground_object: TheaterGroundObject, - cp: ControlPoint, - ) -> None: - go_pos = self._transform_point(ground_object.position) - detection_range = ground_object.detection_range(group) - threat_range = ground_object.threat_range(group) - if threat_range: - threat_pos = self._transform_point( - ground_object.position + Point(threat_range.meters, threat_range.meters) - ) - threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) - - # Add threat range circle - scene.addEllipse( - go_pos[0] - threat_radius / 2 + 7, - go_pos[1] - threat_radius / 2 + 6, - threat_radius, - threat_radius, - self.threat_pen(cp.captured), - ) - - if detection_range and DisplayOptions.detection_range: - # Add detection range circle - detection_pos = self._transform_point( - ground_object.position - + Point(detection_range.meters, detection_range.meters) - ) - detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) - scene.addEllipse( - go_pos[0] - detection_radius / 2 + 7, - go_pos[1] - detection_radius / 2 + 6, - detection_radius, - detection_radius, - self.detection_pen(cp.captured), - ) - - def draw_ground_objects(self, scene: QGraphicsScene, cp: ControlPoint) -> None: - added_objects = [] - for ground_object in cp.ground_objects: - if ground_object.obj_name in added_objects: - continue - - go_pos = self._transform_point(ground_object.position) - if not ground_object.airbase_group: - buildings = self.game.theater.find_ground_objects_by_obj_name( - ground_object.obj_name - ) - scene.addItem( - QMapGroundObject( - self, - go_pos[0], - go_pos[1], - 14, - 12, - cp, - ground_object, - self.game, - buildings, - ) - ) - - should_display = self.should_display_ground_objects_at(cp) - if ground_object.might_have_aa and should_display: - for group in ground_object.groups: - self.draw_threat_range(scene, group, ground_object, cp) - added_objects.append(ground_object.obj_name) - - def recenter(self) -> None: - center = self._transform_point( - self.game.theater.terrain.map_view_default.position - ) - self.centerOn(QPointF(center[0], center[1])) - - def reload_scene(self, recenter: bool = False) -> None: - scene = self.scene() - scene.clear() - - playerColor = self.game.get_player_color() - enemyColor = self.game.get_enemy_color() - - self.addBackground() - if recenter: - self.recenter() - - # Display Culling - if DisplayOptions.culling and self.game.settings.perf_culling: - self.display_culling(scene) - - self.display_threat_zones(scene, DisplayOptions.blue_threat_zones, player=True) - self.display_threat_zones(scene, DisplayOptions.red_threat_zones, player=False) - - if DisplayOptions.navmeshes.blue_navmesh: - self.display_navmesh(scene, player=True) - if DisplayOptions.navmeshes.red_navmesh: - self.display_navmesh(scene, player=False) - - for cp in self.game.theater.controlpoints: - - pos = self._transform_point(cp.position) - - scene.addItem( - QMapControlPoint( - self, - pos[0] - CONST.CP_SIZE / 2, - pos[1] - CONST.CP_SIZE / 2, - CONST.CP_SIZE, - CONST.CP_SIZE, - cp, - self.game_model, - ) - ) - - if cp.captured: - pen = QPen(brush=CONST.COLORS[playerColor]) - brush = CONST.COLORS[playerColor + "_transparent"] - else: - pen = QPen(brush=CONST.COLORS[enemyColor]) - brush = CONST.COLORS[enemyColor + "_transparent"] - - self.draw_ground_objects(scene, cp) - - if cp.target_position is not None: - proj = self._transform_point(cp.target_position) - scene.addLine( - QLineF(QPointF(pos[0], pos[1]), QPointF(proj[0], proj[1])), - QPen(CONST.COLORS["green"], width=10, s=Qt.DashDotLine), - ) - - self.draw_supply_routes() - self.draw_flight_plans(scene) - - for cp in self.game.theater.controlpoints: - pos = self._transform_point(cp.position) - text = scene.addText(cp.name, font=CONST.FONT_MAP) - text.setPos(pos[0] + CONST.CP_SIZE, pos[1] - CONST.CP_SIZE / 2) - text = scene.addText(cp.name, font=CONST.FONT_MAP) - text.setDefaultTextColor(Qt.white) - text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) - - def clear_flight_paths(self, scene: QGraphicsScene) -> None: - for item in self.flight_path_items: - try: - scene.removeItem(item) - except RuntimeError: - # Something may have caused those items to already be removed. - pass - self.flight_path_items.clear() - - def draw_flight_plans(self, scene: QGraphicsScene) -> None: - self.clear_flight_paths(scene) - if DisplayOptions.flight_paths.hide: - return - packages = list(self.game_model.ato_model.packages) - if self.game.settings.show_red_ato: - packages.extend(self.game_model.red_ato_model.packages) - for p_idx, package_model in enumerate(packages): - for f_idx, flight in enumerate(package_model.flights): - if self.selected_flight is None: - selected = False - else: - selected = (p_idx, f_idx) == self.selected_flight - if DisplayOptions.flight_paths.only_selected and not selected: - continue - self.draw_flight_plan(scene, flight, selected) - - def draw_flight_plan( - self, scene: QGraphicsScene, flight: Flight, selected: bool - ) -> None: - is_player = flight.from_cp.captured - pos = self._transform_point(flight.from_cp.position) - - self.draw_waypoint(scene, pos, is_player, selected) - prev_pos = tuple(pos) - drew_target = False - target_types = ( - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) - for idx, point in enumerate(flight.flight_plan.waypoints[1:]): - if point.waypoint_type == FlightWaypointType.DIVERT: - # Don't clutter the map showing divert points. - continue - - new_pos = self._transform_point(Point(point.x, point.y)) - self.draw_flight_path(scene, prev_pos, new_pos, is_player, selected) - self.draw_waypoint(scene, new_pos, is_player, selected) - if selected and DisplayOptions.waypoint_info: - if point.waypoint_type in target_types: - if drew_target: - # Don't draw dozens of targets over each other. - continue - drew_target = True - self.draw_waypoint_info( - scene, idx + 1, point, new_pos, flight.flight_plan - ) - prev_pos = tuple(new_pos) - - if selected and DisplayOptions.patrol_engagement_range: - self.draw_patrol_commit_range(scene, flight) - - def draw_patrol_commit_range(self, scene: QGraphicsScene, flight: Flight) -> None: - if not isinstance(flight.flight_plan, PatrollingFlightPlan): - return - start = flight.flight_plan.patrol_start - end = flight.flight_plan.patrol_end - line = LineString( - [ - ShapelyPoint(start.x, start.y), - ShapelyPoint(end.x, end.y), - ] - ) - doctrine = self.game.faction_for(flight.departure.captured).doctrine - bubble = line.buffer(doctrine.cap_engagement_range.meters) - self.flight_path_items.append( - self.draw_shapely_poly( - scene, bubble, CONST.COLORS["yellow"], CONST.COLORS["transparent"] - ) - ) - - def draw_waypoint( - self, - scene: QGraphicsScene, - position: Tuple[float, float], - player: bool, - selected: bool, - ) -> None: - waypoint_pen = self.waypoint_pen(player, selected) - waypoint_brush = self.waypoint_brush(player, selected) - self.flight_path_items.append( - scene.addEllipse( - position[0], - position[1], - self.WAYPOINT_SIZE, - self.WAYPOINT_SIZE, - waypoint_pen, - waypoint_brush, - ) - ) - - def draw_waypoint_info( - self, - scene: QGraphicsScene, - number: int, - waypoint: FlightWaypoint, - position: Tuple[float, float], - flight_plan: FlightPlan, - ) -> None: - - altitude = int(waypoint.alt.feet) - altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" - - prefix = "TOT" - time = flight_plan.tot_for_waypoint(waypoint) - if time is None: - prefix = "Depart" - time = flight_plan.depart_time_for_waypoint(waypoint) - if time is None: - tot = "" - else: - time = datetime.timedelta(seconds=int(time.total_seconds())) - tot = f"{prefix} T+{time}" - - pen = QPen(QColor("black"), 0.3) - brush = QColor("white") - - text = "\n".join( - [ - f"{number} {waypoint.name}", - f"{altitude} ft {altitude_type}", - tot, - ] - ) - - item = scene.addSimpleText(text, self.waypoint_info_font) - item.setFlag(QGraphicsItem.ItemIgnoresTransformations) - item.setBrush(brush) - item.setPen(pen) - item.moveBy(position[0] + 8, position[1]) - item.setZValue(2) - self.flight_path_items.append(item) - - def draw_flight_path( - self, - scene: QGraphicsScene, - pos0: Tuple[float, float], - pos1: Tuple[float, float], - player: bool, - selected: bool, - ) -> None: - flight_path_pen = self.flight_path_pen(player, selected) - # Draw the line to the *middle* of the waypoint. - offset = self.WAYPOINT_SIZE // 2 - self.flight_path_items.append( - scene.addLine( - pos0[0] + offset, - pos0[1] + offset, - pos1[0] + offset, - pos1[1] + offset, - flight_path_pen, - ) - ) - - def bezier_points( - self, points: Iterable[Point] - ) -> Iterator[Tuple[MapPoint, MapPoint]]: - # Thanks to Alquimista for sharing a python implementation of the bezier - # algorithm this is adapted from. - # https://gist.github.com/Alquimista/1274149#file-bezdraw-py - bezier_fixed_points = [] - for a, b in pairwise(points): - bezier_fixed_points.append(self._transform_point(a)) - bezier_fixed_points.append(self._transform_point(b)) - - old_point = bezier_fixed_points[0] - for point in bezier_curve_range( - int(len(bezier_fixed_points) * 2), bezier_fixed_points - ): - yield old_point, point - old_point = point - - def draw_bezier_frontline( - self, - scene: QGraphicsScene, - frontline: FrontLine, - convoys: List[Convoy], - ) -> None: - for a, b in self.bezier_points(frontline.points): - scene.addItem( - SupplyRouteSegment( - a[0], - a[1], - b[0], - b[1], - frontline.blue_cp, - frontline.red_cp, - convoys, - ) - ) - - def draw_supply_routes(self) -> None: - if not DisplayOptions.lines: - return - - seen = set() - for cp in self.game.theater.controlpoints: - seen.add(cp) - for connected in cp.connected_points: - if connected in seen: - continue - self.draw_supply_route_between(cp, connected) - for destination, shipping_lane in cp.shipping_lanes.items(): - if destination in seen: - continue - if cp.is_friendly(destination.captured): - self.draw_shipping_lane_between(cp, destination) - - def draw_shipping_lane_between(self, a: ControlPoint, b: ControlPoint) -> None: - ship_map = self.game.transfers.cargo_ships - ships = [] - ship = ship_map.find_transport(a, b) - if ship is not None: - ships.append(ship) - ship = ship_map.find_transport(b, a) - if ship is not None: - ships.append(ship) - - scene = self.scene() - for pa, pb in self.bezier_points(a.shipping_lanes[b]): - scene.addItem(ShippingLaneSegment(pa[0], pa[1], pb[0], pb[1], a, b, ships)) - - def draw_supply_route_between(self, a: ControlPoint, b: ControlPoint) -> None: - scene = self.scene() - - convoy_map = self.game.transfers.convoys - convoys = [] - convoy = convoy_map.find_transport(a, b) - if convoy is not None: - convoys.append(convoy) - convoy = convoy_map.find_transport(b, a) - if convoy is not None: - convoys.append(convoy) - - if a.captured: - frontline = FrontLine(a, b) - else: - frontline = FrontLine(b, a) - if a.front_is_active(b): - if DisplayOptions.actual_frontline_pos: - self.draw_actual_frontline(scene, frontline, convoys) - else: - self.draw_frontline_approximation(scene, frontline, convoys) - else: - self.draw_bezier_frontline(scene, frontline, convoys) - - def draw_frontline_approximation( - self, - scene: QGraphicsScene, - frontline: FrontLine, - convoys: List[Convoy], - ) -> None: - posx = frontline.position - h = frontline.attack_heading - pos2 = self._transform_point(posx) - self.draw_bezier_frontline(scene, frontline, convoys) - p1 = point_from_heading(pos2[0], pos2[1], h + 180, 25) - p2 = point_from_heading(pos2[0], pos2[1], h, 25) - scene.addItem( - QFrontLine(p1[0], p1[1], p2[0], p2[1], frontline, self.game_model) - ) - - def draw_actual_frontline( - self, - scene: QGraphicsScene, - frontline: FrontLine, - convoys: List[Convoy], - ) -> None: - self.draw_bezier_frontline(scene, frontline, convoys) - vector = Conflict.frontline_vector(frontline, self.game.theater) - left_pos = self._transform_point(vector[0]) - right_pos = self._transform_point( - vector[0].point_from_heading(vector[1], vector[2]) - ) - scene.addItem( - QFrontLine( - left_pos[0], - left_pos[1], - right_pos[0], - right_pos[1], - frontline, - self.game_model, - ) - ) - - def draw_scale(self, scale_distance_nm=20, number_of_points=4): - - PADDING = 14 - POS_X = 0 - POS_Y = 10 - BIG_LINE = 5 - SMALL_LINE = 2 - - dist = self.distance_to_pixels(nautical_miles(scale_distance_nm)) - l = self.scene().addLine( - POS_X + PADDING, - POS_Y + BIG_LINE * 2, - POS_X + PADDING + dist, - POS_Y + BIG_LINE * 2, - ) - l.setPen(CONST.COLORS["black"]) - - lw = self.scene().addLine( - POS_X + PADDING + 1, - POS_Y + BIG_LINE * 2 + 1, - POS_X + PADDING + dist + 1, - POS_Y + BIG_LINE * 2 + 1, - ) - lw.setPen(CONST.COLORS["white"]) - - text = self.scene().addText( - "0nm", font=QFont("Trebuchet MS", 6, weight=5, italic=False) - ) - text.setPos(POS_X, POS_Y + BIG_LINE * 2) - text.setDefaultTextColor(Qt.black) - - text_white = self.scene().addText( - "0nm", font=QFont("Trebuchet MS", 6, weight=5, italic=False) - ) - text_white.setPos(POS_X + 1, POS_Y + BIG_LINE * 2) - text_white.setDefaultTextColor(Qt.white) - - text2 = self.scene().addText( - str(scale_distance_nm) + "nm", - font=QFont("Trebuchet MS", 6, weight=5, italic=False), - ) - text2.setPos(POS_X + dist, POS_Y + BIG_LINE * 2) - text2.setDefaultTextColor(Qt.black) - - text2_white = self.scene().addText( - str(scale_distance_nm) + "nm", - font=QFont("Trebuchet MS", 6, weight=5, italic=False), - ) - text2_white.setPos(POS_X + dist + 1, POS_Y + BIG_LINE * 2) - text2_white.setDefaultTextColor(Qt.white) - - for i in range(number_of_points + 1): - d = float(i) / float(number_of_points) - if i == 0 or i == number_of_points: - h = BIG_LINE - else: - h = SMALL_LINE - - l = self.scene().addLine( - POS_X + PADDING + d * dist, - POS_Y + BIG_LINE * 2, - POS_X + PADDING + d * dist, - POS_Y + BIG_LINE - h, - ) - l.setPen(CONST.COLORS["black"]) - - lw = self.scene().addLine( - POS_X + PADDING + d * dist + 1, - POS_Y + BIG_LINE * 2, - POS_X + PADDING + d * dist + 1, - POS_Y + BIG_LINE - h, - ) - lw.setPen(CONST.COLORS["white"]) - - def wheelEvent(self, event: QWheelEvent): - if event.angleDelta().y() > 0: - factor = 1.25 - self._zoom += 1 - if self._zoom < 10: - self.scale(factor, factor) - self.factorized *= factor - else: - self._zoom = 9 - else: - factor = 0.8 - self._zoom -= 1 - if self._zoom > -5: - self.scale(factor, factor) - self.factorized *= factor - else: - self._zoom = -4 - - @staticmethod - def _transpose_point(p: Point) -> Point: - return Point(p.y, p.x) - - def _scaling_factor(self) -> Point: - point_a = self.game.theater.reference_points[0] - point_b = self.game.theater.reference_points[1] - - world_distance = self._transpose_point( - point_b.world_coordinates - point_a.world_coordinates - ) - image_distance = point_b.image_coordinates - point_a.image_coordinates - - x_scale = image_distance.x / world_distance.x - y_scale = image_distance.y / world_distance.y - return Point(x_scale, y_scale) - - # TODO: Move this and its inverse into ConflictTheater. - def _transform_point(self, world_point: Point) -> Tuple[float, float]: - """Transforms world coordinates to image coordinates. - - World coordinates are transposed. X increases toward the North, Y - increases toward the East. The origin point depends on the map. - - Image coordinates originate from the top left. X increases to the right, - Y increases toward the bottom. - - The two points should be as distant as possible in both latitude and - logitude, and tuning the reference points will be simpler if they are in - geographically recognizable locations. For example, the Caucasus map is - aligned using the first point on Gelendzhik and the second on Batumi. - - The distances between each point are computed and a scaling factor is - determined from that. The given point is then offset from the first - point using the scaling factor. - - X is latitude, increasing northward. - Y is longitude, increasing eastward. - """ - point_a = self.game.theater.reference_points[0] - scale = self._scaling_factor() - - offset = self._transpose_point(point_a.world_coordinates - world_point) - scaled = Point(offset.x * scale.x, offset.y * scale.y) - transformed = point_a.image_coordinates - scaled - return transformed.x, transformed.y - - def _scene_to_dcs_coords(self, scene_point: Point) -> Point: - point_a = self.game.theater.reference_points[0] - scale = self._scaling_factor() - - offset = point_a.image_coordinates - scene_point - scaled = self._transpose_point(Point(offset.x / scale.x, offset.y / scale.y)) - return point_a.world_coordinates - scaled - - def distance_to_pixels(self, distance: Distance) -> int: - p1 = Point(0, 0) - p2 = Point(0, distance.meters) - p1a = Point(*self._transform_point(p1)) - p2a = Point(*self._transform_point(p2)) - return int(p1a.distance_to_point(p2a)) - - def highlight_color(self, transparent: Optional[bool] = False) -> QColor: - return QColor(255, 255, 0, 20 if transparent else 255) - - def base_faction_color_name(self, player: bool) -> str: - if player: - return self.game.get_player_color() - else: - return self.game.get_enemy_color() - - def waypoint_pen(self, player: bool, selected: bool) -> QColor: - if selected and DisplayOptions.flight_paths.all: - return self.highlight_color() - name = self.base_faction_color_name(player) - return CONST.COLORS[name] - - def waypoint_brush(self, player: bool, selected: bool) -> QColor: - if selected and DisplayOptions.flight_paths.all: - return self.highlight_color(transparent=True) - name = self.base_faction_color_name(player) - return CONST.COLORS[f"{name}_transparent"] - - def threat_pen(self, player: bool) -> QPen: - color = "blue" if player else "red" - return QPen(CONST.COLORS[color]) - - def detection_pen(self, player: bool) -> QPen: - color = "purple" if player else "yellow" - qpen = QPen(CONST.COLORS[color]) - qpen.setStyle(Qt.DotLine) - return qpen - - def flight_path_pen(self, player: bool, selected: bool) -> QPen: - if selected and DisplayOptions.flight_paths.all: - return self.highlight_color() - - name = self.base_faction_color_name(player) - color = CONST.COLORS[name] - pen = QPen(brush=color) - pen.setColor(color) - pen.setWidth(1) - pen.setStyle(Qt.DashDotLine) - return pen - - def addBackground(self): - scene = self.scene() - - if not DisplayOptions.map_poly: - bg = QPixmap("./resources/" + self.game.theater.overview_image) - scene.addPixmap(bg) - - # Apply graphical effects to simulate current daytime - if self.game.current_turn_time_of_day == TimeOfDay.Day: - pass - elif self.game.current_turn_time_of_day == TimeOfDay.Night: - ov = QPixmap(bg.width(), bg.height()) - ov.fill(CONST.COLORS["night_overlay"]) - overlay = scene.addPixmap(ov) - effect = QGraphicsOpacityEffect() - effect.setOpacity(0.7) - overlay.setGraphicsEffect(effect) - else: - ov = QPixmap(bg.width(), bg.height()) - ov.fill(CONST.COLORS["dawn_dust_overlay"]) - overlay = scene.addPixmap(ov) - effect = QGraphicsOpacityEffect() - effect.setOpacity(0.3) - overlay.setGraphicsEffect(effect) - - if DisplayOptions.map_poly or self.reference_point_setup_mode: - # Polygon display mode - if self.game.theater.landmap is not None: - - for sea_zone in self.game.theater.landmap.sea_zones: - print(sea_zone) - poly = QPolygonF( - [ - QPointF(*self._transform_point(Point(point[0], point[1]))) - for point in sea_zone.exterior.coords - ] - ) - if self.reference_point_setup_mode: - color = "sea_blue_transparent" - else: - color = "sea_blue" - scene.addPolygon(poly, CONST.COLORS[color], CONST.COLORS[color]) - - for inclusion_zone in self.game.theater.landmap.inclusion_zones: - poly = QPolygonF( - [ - QPointF(*self._transform_point(Point(point[0], point[1]))) - for point in inclusion_zone.exterior.coords - ] - ) - if self.reference_point_setup_mode: - scene.addPolygon( - poly, - CONST.COLORS["grey_transparent"], - CONST.COLORS["dark_grey_transparent"], - ) - else: - scene.addPolygon( - poly, CONST.COLORS["grey"], CONST.COLORS["dark_grey"] - ) - - for exclusion_zone in self.game.theater.landmap.exclusion_zones: - poly = QPolygonF( - [ - QPointF(*self._transform_point(Point(point[0], point[1]))) - for point in exclusion_zone.exterior.coords - ] - ) - if self.reference_point_setup_mode: - scene.addPolygon( - poly, - CONST.COLORS["grey_transparent"], - CONST.COLORS["dark_dark_grey_transparent"], - ) - else: - scene.addPolygon( - poly, CONST.COLORS["grey"], CONST.COLORS["dark_dark_grey"] - ) - - # Uncomment to display plan projection test - # self.projection_test() - self.draw_scale() - - if self.reference_point_setup_mode: - for i, point in enumerate(self.game.theater.reference_points): - self.scene().addRect( - QRectF( - point.image_coordinates.x, point.image_coordinates.y, 25, 25 - ), - pen=CONST.COLORS["red"], - brush=CONST.COLORS["red"], - ) - text = self.scene().addText( - f"P{i} = {point.image_coordinates}", - font=QFont("Trebuchet MS", 14, weight=8, italic=False), - ) - text.setDefaultTextColor(CONST.COLORS["red"]) - text.setPos(point.image_coordinates.x + 26, point.image_coordinates.y) - - # Set to True to visually debug _transform_point. - draw_transformed = False - if draw_transformed: - x, y = self._transform_point(point.world_coordinates) - self.scene().addRect( - QRectF(x, y, 25, 25), - pen=CONST.COLORS["red"], - brush=CONST.COLORS["red"], - ) - text = self.scene().addText( - f"P{i}' = {x}, {y}", - font=QFont("Trebuchet MS", 14, weight=8, italic=False), - ) - text.setDefaultTextColor(CONST.COLORS["red"]) - text.setPos(x + 26, y) - - def projection_test(self): - for i in range(100): - for j in range(100): - x = i * 100.0 - y = j * 100.0 - original = Point(x, y) - proj = self._scene_to_dcs_coords(original) - unproj = self._transform_point(proj) - converted = Point(*unproj) - assert math.isclose(original.x, converted.x, abs_tol=0.00000001) - assert math.isclose(original.y, converted.y, abs_tol=0.00000001) - - def setSelectedUnit(self, selected_cp: QMapControlPoint): - self.state = QLiberationMapState.MOVING_UNIT - self.selected_cp = selected_cp - position = self._transform_point(selected_cp.control_point.position) - self.movement_line = QtWidgets.QGraphicsLineItem( - QLineF(QPointF(*position), QPointF(*position)) - ) - self.scene().addItem(self.movement_line) - - def is_valid_ship_pos(self, scene_position: Point) -> bool: - world_destination = self._scene_to_dcs_coords(scene_position) - distance = self.selected_cp.control_point.position.distance_to_point( - world_destination - ) - if meters(distance) > MAX_SHIP_DISTANCE: - return False - return self.game.theater.is_in_sea(world_destination) - - def sceneMouseMovedEvent(self, event: QGraphicsSceneMouseEvent): - if self.game is None: - return - - mouse_position = Point(event.scenePos().x(), event.scenePos().y()) - if self.state == QLiberationMapState.MOVING_UNIT: - self.setCursor(Qt.PointingHandCursor) - self.movement_line.setLine( - QLineF(self.movement_line.line().p1(), event.scenePos()) - ) - - if self.is_valid_ship_pos(mouse_position): - self.movement_line.setPen(CONST.COLORS["green"]) - else: - self.movement_line.setPen(CONST.COLORS["red"]) - - mouse_world_pos = self._scene_to_dcs_coords(mouse_position) - if DisplayOptions.navmeshes.blue_navmesh: - self.highlight_mouse_navmesh( - self.scene(), - self.game.blue_navmesh, - self._scene_to_dcs_coords(mouse_position), - ) - if DisplayOptions.path_debug.shortest_path: - self.draw_shortest_path( - self.scene(), self.game.blue_navmesh, mouse_world_pos, player=True - ) - - if DisplayOptions.navmeshes.red_navmesh: - self.highlight_mouse_navmesh( - self.scene(), self.game.red_navmesh, mouse_world_pos - ) - - debug_blue = DisplayOptions.path_debug_faction.blue - if DisplayOptions.path_debug.shortest_path: - self.draw_shortest_path( - self.scene(), - self.game.navmesh_for(player=debug_blue), - mouse_world_pos, - player=False, - ) - elif not DisplayOptions.path_debug.hide: - if DisplayOptions.path_debug.barcap: - task = FlightType.BARCAP - elif DisplayOptions.path_debug.cas: - task = FlightType.CAS - elif DisplayOptions.path_debug.sweep: - task = FlightType.SWEEP - elif DisplayOptions.path_debug.strike: - task = FlightType.STRIKE - elif DisplayOptions.path_debug.tarcap: - task = FlightType.TARCAP - else: - raise ValueError("Unexpected value for DisplayOptions.path_debug") - self.draw_test_flight_plan( - self.scene(), task, mouse_world_pos, player=debug_blue - ) - - def sceneMousePressEvent(self, event: QGraphicsSceneMouseEvent): - if self.state == QLiberationMapState.MOVING_UNIT: - if event.buttons() == Qt.RightButton: - pass - elif event.buttons() == Qt.LeftButton: - if self.selected_cp is not None: - # Set movement position for the cp - pos = event.scenePos() - point = Point(int(pos.x()), int(pos.y())) - proj = self._scene_to_dcs_coords(point) - - if self.is_valid_ship_pos(point): - self.selected_cp.control_point.target_position = proj - else: - self.selected_cp.control_point.target_position = None - - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - else: - return - self.state = QLiberationMapState.NORMAL - try: - self.scene().removeItem(self.movement_line) - except: - pass - self.selected_cp = None diff --git a/qt_ui/widgets/map/QLiberationScene.py b/qt_ui/widgets/map/QLiberationScene.py deleted file mode 100644 index fff8c379..00000000 --- a/qt_ui/widgets/map/QLiberationScene.py +++ /dev/null @@ -1,21 +0,0 @@ -from PySide2.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent - -import qt_ui.uiconstants as CONST - - -class QLiberationScene(QGraphicsScene): - def __init__(self, parent): - super().__init__(parent) - item = self.addText( - 'Go to "File/New Game" to setup a new campaign or go to "File/Open" to load an existing save game.', - CONST.FONT_PRIMARY, - ) - item.setDefaultTextColor(CONST.COLORS["white"]) - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): - super(QLiberationScene, self).mouseMoveEvent(event) - self.parent().sceneMouseMovedEvent(event) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - super(QLiberationScene, self).mousePressEvent(event) - self.parent().sceneMousePressEvent(event) diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py deleted file mode 100644 index b7016536..00000000 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import Optional - -from PySide2.QtGui import QColor, QPainter -from PySide2.QtWidgets import QAction, QMenu - -import qt_ui.uiconstants as const -from game.theater import ControlPoint, NavalControlPoint -from qt_ui.models import GameModel -from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 -from .QMapObject import QMapObject -from ...displayoptions import DisplayOptions -from ...windows.GameUpdateSignal import GameUpdateSignal - - -class QMapControlPoint(QMapObject): - def __init__( - self, - parent, - x: float, - y: float, - w: float, - h: float, - control_point: ControlPoint, - game_model: GameModel, - ) -> None: - super().__init__(x, y, w, h, mission_target=control_point) - self.game_model = game_model - self.control_point = control_point - self.parent = parent - self.setZValue(1) - self.setToolTip(self.control_point.name) - self.base_details_dialog: Optional[QBaseMenu2] = None - self.capture_action = QAction(f"CHEAT: Capture {self.control_point.name}") - self.capture_action.triggered.connect(self.cheat_capture) - - self.move_action = QAction("Move") - self.move_action.triggered.connect(self.move) - - self.cancel_move_action = QAction("Cancel Move") - self.cancel_move_action.triggered.connect(self.cancel_move) - - def paint(self, painter, option, widget=None) -> None: - if DisplayOptions.control_points: - painter.save() - painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(self.brush_color) - painter.setPen(self.pen_color) - - if not self.control_point.runway_is_operational(): - painter.setBrush(const.COLORS["black"]) - painter.setPen(self.brush_color) - - r = option.rect - painter.drawEllipse(r.x(), r.y(), r.width(), r.height()) - # TODO: Draw sunk carriers differently. - # Either don't draw them at all, or perhaps use a sunk ship icon. - painter.restore() - - @property - def brush_color(self) -> QColor: - if self.control_point.captured: - return const.COLORS["blue"] - else: - return const.COLORS["super_red"] - - @property - def pen_color(self) -> QColor: - return const.COLORS["white"] - - @property - def object_dialog_text(self) -> str: - if self.control_point.captured: - return "Open base menu" - else: - return "Open intel menu" - - def on_click(self) -> None: - self.base_details_dialog = QBaseMenu2( - self.window(), self.control_point, self.game_model - ) - self.base_details_dialog.show() - - def add_context_menu_actions(self, menu: QMenu) -> None: - - if self.control_point.moveable and self.control_point.captured: - menu.addAction(self.move_action) - if self.control_point.target_position is not None: - menu.addAction(self.cancel_move_action) - - if self.control_point.is_fleet: - return - - if self.control_point.captured: - return - - for connected in self.control_point.connected_points: - if ( - connected.captured - and self.game_model.game.settings.enable_base_capture_cheat - ): - menu.addAction(self.capture_action) - break - - def cheat_capture(self) -> None: - self.control_point.capture(self.game_model.game, for_player=True) - # Reinitialized ground planners and the like. The ATO needs to be reset because - # missions planned against the flipped base are no longer valid. - self.game_model.game.reset_ato() - self.game_model.game.initialize_turn() - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - - def move(self): - self.parent.setSelectedUnit(self) - - def cancel_move(self): - self.control_point.target_position = None - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - - def open_new_package_dialog(self) -> None: - """Extends the default packagedialog to redirect to base menu for red air base.""" - is_navy = isinstance(self.control_point, NavalControlPoint) - if self.control_point.captured or is_navy: - super().open_new_package_dialog() - return - self.on_click() diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py deleted file mode 100644 index a93a566c..00000000 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ /dev/null @@ -1,167 +0,0 @@ -from typing import List, Optional - -from PySide2.QtCore import QRect -from PySide2.QtGui import QBrush -from PySide2.QtWidgets import QGraphicsItem - -import qt_ui.uiconstants as const -from game import Game -from game.data.building_data import FORTIFICATION_BUILDINGS -from game.db import REWARDS -from game.theater import ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import ( - MissileSiteGroundObject, - CoastalSiteGroundObject, -) -from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from .QMapObject import QMapObject -from ...displayoptions import DisplayOptions - - -class QMapGroundObject(QMapObject): - def __init__( - self, - parent, - x: float, - y: float, - w: float, - h: float, - control_point: ControlPoint, - ground_object: TheaterGroundObject, - game: Game, - buildings: Optional[List[TheaterGroundObject]] = None, - ) -> None: - super().__init__(x, y, w, h, mission_target=ground_object) - self.ground_object = ground_object - self.control_point = control_point - self.parent = parent - self.game = game - self.setZValue(2) - self.buildings = buildings if buildings is not None else [] - self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) - self.ground_object_dialog: Optional[QGroundObjectMenu] = None - self.setToolTip(self.tooltip) - - @property - def tooltip(self) -> str: - lines = [ - f"[{self.ground_object.obj_name}]", - f"${self.production_per_turn} per turn", - ] - if self.ground_object.groups: - units = {} - for g in self.ground_object.groups: - for u in g.units: - if u.type in units: - units[u.type] = units[u.type] + 1 - else: - units[u.type] = 1 - - for unit in units.keys(): - lines.append(f"{unit} x {units[unit]}") - else: - for building in self.buildings: - if not building.is_dead: - lines.append(f"{building.dcs_identifier}") - - return "\n".join(lines) - - @property - def production_per_turn(self) -> int: - production = 0 - for building in self.buildings: - if building.is_dead: - continue - if building.category in REWARDS.keys(): - production += REWARDS[building.category] - return production - - def paint(self, painter, option, widget=None) -> None: - player_icons = "_blue" - enemy_icons = "" - - if DisplayOptions.ground_objects: - painter.save() - - cat = self.ground_object.category - - rect = QRect( - option.rect.x() + 2, - option.rect.y(), - option.rect.width() - 2, - option.rect.height(), - ) - - is_dead = self.ground_object.is_dead - for building in self.buildings: - if not building.is_dead: - is_dead = False - break - - if cat == "aa": - has_threat = False - for group in self.ground_object.groups: - if self.ground_object.threat_range(group).distance_in_meters > 0: - has_threat = True - - if not is_dead and not self.control_point.captured: - if cat == "aa" and not has_threat: - painter.drawPixmap(rect, const.ICONS["nothreat" + enemy_icons]) - else: - painter.drawPixmap(rect, const.ICONS[cat + enemy_icons]) - elif not is_dead: - if cat == "aa" and not has_threat: - painter.drawPixmap(rect, const.ICONS["nothreat" + player_icons]) - else: - painter.drawPixmap(rect, const.ICONS[cat + player_icons]) - else: - painter.drawPixmap(rect, const.ICONS["destroyed"]) - - self.draw_health_gauge(painter, option) - painter.restore() - - def draw_health_gauge(self, painter, option) -> None: - units_alive = 0 - units_dead = 0 - - if len(self.ground_object.groups) == 0: - for building in self.buildings: - if building.dcs_identifier in FORTIFICATION_BUILDINGS: - continue - if building.is_dead: - units_dead += 1 - else: - units_alive += 1 - - for g in self.ground_object.groups: - units_alive += len(g.units) - if hasattr(g, "units_losts"): - units_dead += len(g.units_losts) - - if units_dead + units_alive > 0: - ratio = float(units_alive) / (float(units_dead) + float(units_alive)) - bar_height = ratio * option.rect.height() - painter.fillRect( - option.rect.x(), - option.rect.y(), - 2, - option.rect.height(), - QBrush(const.COLORS["dark_red"]), - ) - painter.fillRect( - option.rect.x(), - option.rect.y(), - 2, - bar_height, - QBrush(const.COLORS["green"]), - ) - - def on_click(self) -> None: - self.ground_object_dialog = QGroundObjectMenu( - self.window(), - self.ground_object, - self.buildings, - self.control_point, - self.game, - ) - self.ground_object_dialog.show() diff --git a/qt_ui/widgets/map/QMapObject.py b/qt_ui/widgets/map/QMapObject.py deleted file mode 100644 index f4b0bfb6..00000000 --- a/qt_ui/widgets/map/QMapObject.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Common base for objects drawn on the game map.""" -from typing import Optional - -from PySide2.QtCore import Qt -from PySide2.QtWidgets import ( - QAction, - QGraphicsRectItem, - QGraphicsSceneContextMenuEvent, - QGraphicsSceneHoverEvent, - QGraphicsSceneMouseEvent, - QMenu, -) - -from qt_ui.dialogs import Dialog -from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog -from game.theater.missiontarget import MissionTarget - - -class QMapObject(QGraphicsRectItem): - """Base class for objects drawn on the game map. - - Game map objects have an on_click behavior that triggers on left click, and - change the mouse cursor on hover. - """ - - def __init__( - self, x: float, y: float, w: float, h: float, mission_target: MissionTarget - ) -> None: - super().__init__(x, y, w, h) - self.mission_target = mission_target - self.new_package_dialog: Optional[QNewPackageDialog] = None - self.setAcceptHoverEvents(True) - - def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.setCursor(Qt.PointingHandCursor) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() == Qt.LeftButton: - self.on_click() - - def add_context_menu_actions(self, menu: QMenu) -> None: - pass - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: - menu = QMenu("Menu", self.parent) - - object_details_action = QAction(self.object_dialog_text) - object_details_action.triggered.connect(self.on_click) - menu.addAction(object_details_action) - - # Not all locations have valid objectives. Off-map spawns, for example, - # have no mission types. - if list(self.mission_target.mission_types(for_player=True)): - new_package_action = QAction(f"New package") - new_package_action.triggered.connect(self.open_new_package_dialog) - menu.addAction(new_package_action) - - self.add_context_menu_actions(menu) - - menu.exec_(event.screenPos()) - - @property - def object_dialog_text(self) -> str: - """Text to for the object's dialog in the context menu. - - Right clicking a map object will open a context menu and the first item - will open the details dialog for this object. This menu action has the - same behavior as the on_click event. - - Return: - The text that should be displayed for the menu item. - """ - return "Details" - - def on_click(self) -> None: - """The action to take when this map object is left-clicked. - - Typically this should open a details view of the object. - """ - raise NotImplementedError - - def open_new_package_dialog(self) -> None: - """Opens the dialog for planning a new mission package.""" - Dialog.open_new_package_dialog(self.mission_target) diff --git a/qt_ui/widgets/map/ShippingLaneSegment.py b/qt_ui/widgets/map/ShippingLaneSegment.py deleted file mode 100644 index 02d445a4..00000000 --- a/qt_ui/widgets/map/ShippingLaneSegment.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import List, Optional - -from PySide2.QtCore import Qt -from PySide2.QtGui import QColor, QPen -from PySide2.QtWidgets import ( - QGraphicsItem, - QGraphicsLineItem, -) - -from game.theater import ControlPoint -from game.transfers import CargoShip -from qt_ui.uiconstants import COLORS - - -class ShippingLaneSegment(QGraphicsLineItem): - def __init__( - self, - x0: float, - y0: float, - x1: float, - y1: float, - control_point_a: ControlPoint, - control_point_b: ControlPoint, - ships: List[CargoShip], - parent: Optional[QGraphicsItem] = None, - ) -> None: - super().__init__(x0, y0, x1, y1, parent) - self.control_point_a = control_point_a - self.control_point_b = control_point_b - self.ships = ships - self.setPen(self.make_pen()) - self.setToolTip(self.make_tooltip()) - self.setAcceptHoverEvents(True) - - @property - def has_ships(self) -> bool: - return bool(self.ships) - - def make_tooltip(self) -> str: - if not self.has_ships: - return "No ships present in this shipping lane." - - ships = [] - for ship in self.ships: - units = "units" if ship.size > 1 else "unit" - ships.append( - f"{ship.size} {units} transferring from {ship.origin} to " - f"{ship.destination}." - ) - return "\n".join(ships) - - @property - def line_color(self) -> QColor: - if self.control_point_a.captured: - return COLORS["dark_blue"] - else: - return COLORS["dark_red"] - - @property - def line_style(self) -> Qt.PenStyle: - if self.has_ships: - return Qt.PenStyle.SolidLine - return Qt.PenStyle.DotLine - - def make_pen(self) -> QPen: - pen = QPen(brush=self.line_color) - pen.setColor(self.line_color) - pen.setStyle(self.line_style) - pen.setWidth(2) - return pen diff --git a/qt_ui/widgets/map/SupplyRouteSegment.py b/qt_ui/widgets/map/SupplyRouteSegment.py deleted file mode 100644 index 78401bc2..00000000 --- a/qt_ui/widgets/map/SupplyRouteSegment.py +++ /dev/null @@ -1,76 +0,0 @@ -from functools import cached_property -from typing import List, Optional - -from PySide2.QtCore import Qt -from PySide2.QtGui import QColor, QPen -from PySide2.QtWidgets import ( - QGraphicsItem, - QGraphicsLineItem, -) - -from game.theater import ControlPoint -from game.transfers import Convoy -from qt_ui.uiconstants import COLORS - - -class SupplyRouteSegment(QGraphicsLineItem): - def __init__( - self, - x0: float, - y0: float, - x1: float, - y1: float, - control_point_a: ControlPoint, - control_point_b: ControlPoint, - convoys: List[Convoy], - parent: Optional[QGraphicsItem] = None, - ) -> None: - super().__init__(x0, y0, x1, y1, parent) - self.control_point_a = control_point_a - self.control_point_b = control_point_b - self.convoys = convoys - self.setPen(self.make_pen()) - self.setToolTip(self.make_tooltip()) - self.setAcceptHoverEvents(True) - - @property - def has_convoys(self) -> bool: - return bool(self.convoys) - - def make_tooltip(self) -> str: - if not self.has_convoys: - return "No convoys present on this supply route." - - convoys = [] - for convoy in self.convoys: - units = "units" if convoy.size > 1 else "unit" - convoys.append( - f"{convoy.size} {units} transferring from {convoy.origin} to " - f"{convoy.destination}" - ) - return "\n".join(convoys) - - @property - def line_color(self) -> QColor: - if self.control_point_a.front_is_active(self.control_point_b): - return COLORS["red"] - elif self.control_point_a.captured: - return COLORS["dark_blue"] - else: - return COLORS["dark_red"] - - @property - def line_style(self) -> Qt.PenStyle: - if ( - self.control_point_a.front_is_active(self.control_point_b) - or self.has_convoys - ): - return Qt.PenStyle.SolidLine - return Qt.PenStyle.DotLine - - def make_pen(self) -> QPen: - pen = QPen(brush=self.line_color) - pen.setColor(self.line_color) - pen.setStyle(self.line_style) - pen.setWidth(6) - return pen diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 6686458a..2e69240c 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from datetime import timedelta -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union, Iterator from PySide2.QtCore import Property, QObject, Signal, Slot from dcs import Point @@ -20,6 +20,7 @@ from game.theater import ( TheaterGroundObject, FrontLine, LatLon, + ControlPointStatus, ) from game.threatzones import ThreatZones from game.transfers import MultiGroupTransport, TransportMap @@ -36,6 +37,8 @@ from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu LeafletLatLon = list[float] LeafletPoly = list[LeafletLatLon] +MAX_SHIP_DISTANCE = nautical_miles(80) + # **EVERY PROPERTY NEEDS A NOTIFY SIGNAL** # # https://bugreports.qt.io/browse/PYSIDE-1426 @@ -60,6 +63,16 @@ def shapely_poly_to_leaflet_points( return [theater.point_to_ll(Point(x, y)).as_list() for x, y in poly.exterior.coords] +def shapely_to_leaflet_polys( + poly: Union[Polygon, MultiPolygon], theater: ConflictTheater +) -> list[LeafletPoly]: + if isinstance(poly, MultiPolygon): + polys = poly.geoms + else: + polys = [poly] + return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -67,6 +80,7 @@ class ControlPointJs(QObject): mobileChanged = Signal() destinationChanged = Signal(list) categoryChanged = Signal() + statusChanged = Signal() def __init__( self, @@ -92,6 +106,17 @@ class ControlPointJs(QObject): def category(self) -> str: return self.control_point.category + @Property(str, notify=statusChanged) + def status(self) -> str: + status = self.control_point.status + if status is ControlPointStatus.Functional: + return "alive" + elif status is ControlPointStatus.Damaged: + return "damaged" + elif status is ControlPointStatus.Destroyed: + return "destroyed" + raise ValueError(f"Unhandled ControlPointStatus: {status.name}") + @Property(list, notify=positionChanged) def position(self) -> LeafletLatLon: ll = self.theater.point_to_ll(self.control_point.position) @@ -109,8 +134,6 @@ class ControlPointJs(QObject): return self.theater.point_to_ll(self.control_point.target_position).as_list() def destination_in_range(self, destination: Point) -> bool: - from qt_ui.widgets.map.QLiberationMap import MAX_SHIP_DISTANCE - move_distance = meters( destination.distance_to_point(self.control_point.position) ) @@ -122,8 +145,6 @@ class ControlPointJs(QObject): @Slot(list, result=str) def setDestination(self, destination: LeafletLatLon) -> str: - from qt_ui.widgets.map.QLiberationMap import MAX_SHIP_DISTANCE - if not self.control_point.moveable: return f"{self.control_point} is not mobile" if not self.control_point.captured: @@ -581,23 +602,13 @@ class ThreatZonesJs(QObject): def radarSams(self) -> list[LeafletPoly]: return self._radar_sams - @staticmethod - def polys_to_leaflet( - poly: Union[Polygon, MultiPolygon], theater: ConflictTheater - ) -> list[LeafletPoly]: - if isinstance(poly, MultiPolygon): - polys = poly.geoms - else: - polys = [poly] - return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys] - @classmethod def from_zones(cls, zones: ThreatZones, theater: ConflictTheater) -> ThreatZonesJs: return ThreatZonesJs( - cls.polys_to_leaflet(zones.all, theater), - cls.polys_to_leaflet(zones.airbases, theater), - cls.polys_to_leaflet(zones.air_defenses, theater), - cls.polys_to_leaflet(zones.radar_sam_threats, theater), + shapely_to_leaflet_polys(zones.all, theater), + shapely_to_leaflet_polys(zones.airbases, theater), + shapely_to_leaflet_polys(zones.air_defenses, theater), + shapely_to_leaflet_polys(zones.radar_sam_threats, theater), ) @classmethod @@ -658,6 +669,70 @@ class NavMeshJs(QObject): ) +class MapZonesJs(QObject): + inclusionZonesChanged = Signal() + exclusionZonesChanged = Signal() + seaZonesChanged = Signal() + + def __init__( + self, + inclusion_zones: list[LeafletPoly], + exclusion_zones: list[LeafletPoly], + sea_zones: list[LeafletPoly], + ) -> None: + super().__init__() + self._inclusion_zones = inclusion_zones + self._exclusion_zones = exclusion_zones + self._sea_zones = sea_zones + + @Property(list, notify=inclusionZonesChanged) + def inclusionZones(self) -> list[LeafletPoly]: + return self._inclusion_zones + + @Property(list, notify=exclusionZonesChanged) + def exclusionZones(self) -> list[LeafletPoly]: + return self._exclusion_zones + + @Property(list, notify=seaZonesChanged) + def seaZones(self) -> list[LeafletPoly]: + return self._sea_zones + + @classmethod + def from_game(cls, game: Game) -> MapZonesJs: + zones = game.theater.landmap + return MapZonesJs( + shapely_to_leaflet_polys(zones.inclusion_zones, game.theater), + shapely_to_leaflet_polys(zones.exclusion_zones, game.theater), + shapely_to_leaflet_polys(zones.sea_zones, game.theater), + ) + + +class UnculledZone(QObject): + positionChanged = Signal() + radiusChanged = Signal() + + def __init__(self, position: LeafletLatLon, radius: float) -> None: + super().__init__() + self._position = position + self._radius = radius + + @Property(list, notify=positionChanged) + def position(self) -> LeafletLatLon: + return self._position + + @Property(float, notify=radiusChanged) + def radius(self) -> float: + return self._radius + + @classmethod + def each_from_game(cls, game: Game) -> Iterator[UnculledZone]: + for zone in game.get_culling_zones(): + ll = game.theater.point_to_ll(zone) + yield UnculledZone( + [ll.latitude, ll.longitude], game.settings.perf_culling_distance * 1000 + ) + + class MapModel(QObject): cleared = Signal() @@ -669,6 +744,8 @@ class MapModel(QObject): frontLinesChanged = Signal() threatZonesChanged = Signal() navmeshesChanged = Signal() + mapZonesChanged = Signal() + unculledZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -683,6 +760,8 @@ class MapModel(QObject): ThreatZonesJs.empty(), ThreatZonesJs.empty() ) self._navmeshes = NavMeshJs([], []) + self._map_zones = MapZonesJs([], [], []) + self._unculled_zones = [] self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -704,6 +783,8 @@ class MapModel(QObject): ThreatZonesJs.empty(), ThreatZonesJs.empty() ) self._navmeshes = NavMeshJs([], []) + self._map_zones = MapZonesJs([], [], []) + self._unculled_zones = [] self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -749,6 +830,8 @@ class MapModel(QObject): self.reset_front_lines() self.reset_threat_zones() self.reset_navmeshes() + self.reset_map_zones() + self.reset_unculled_zones() def on_game_load(self, game: Optional[Game]) -> None: if game is not None: @@ -895,6 +978,22 @@ class MapModel(QObject): def navmeshes(self) -> NavMeshJs: return self._navmeshes + def reset_map_zones(self) -> None: + self._map_zones = MapZonesJs.from_game(self.game) + self.mapZonesChanged.emit() + + @Property(MapZonesJs, notify=mapZonesChanged) + def mapZones(self) -> NavMeshJs: + return self._map_zones + + def reset_unculled_zones(self) -> None: + self._unculled_zones = list(UnculledZone.each_from_game(self.game)) + self.unculledZonesChanged.emit() + + @Property(list, notify=unculledZonesChanged) + def unculledZones(self) -> list[UnculledZone]: + return self._unculled_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index 17e4dcc1..80c6443a 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -1,4 +1,7 @@ -from typing import Optional +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Type, Iterator from PySide2.QtCore import ( QItemSelectionModel, @@ -8,13 +11,21 @@ from PySide2.QtCore import ( ) from PySide2.QtWidgets import ( QAbstractItemView, + QCheckBox, QDialog, QListView, QVBoxLayout, + QTabWidget, + QTableWidget, + QTableWidgetItem, + QWidget, ) +from dcs.unittype import FlyingType from game import db +from game.inventory import ControlPointAircraftInventory from game.squadrons import Squadron +from gen.flights.flight import Flight from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.models import GameModel, AirWingModel, SquadronModel from qt_ui.windows.SquadronDialog import SquadronDialog @@ -41,9 +52,10 @@ class SquadronDelegate(TwoColumnRowDelegate): return self.squadron(index).nickname elif (row, column) == (1, 1): squadron = self.squadron(index) + alive = squadron.number_of_living_pilots active = len(squadron.active_pilots) available = len(squadron.available_pilots) - return f"{squadron.size} pilots, {active} active, {available} unassigned" + return f"{alive} pilots, {active} active, {available} unassigned" return "" @@ -75,6 +87,138 @@ class SquadronList(QListView): self.dialog.show() +@dataclass(frozen=True) +class AircraftInventoryData: + location: str + unit_type: str + task: str + target: str + pilot: str + player: str + + @classmethod + def headers(cls) -> list[str]: + return ["Base", "Type", "Flight Type", "Target", "Pilot", "Player"] + + @property + def columns(self) -> Iterator[str]: + yield self.location + yield self.unit_type + yield self.task + yield self.target + yield self.pilot + yield self.player + + @classmethod + def from_flight(cls, flight: Flight) -> Iterator[AircraftInventoryData]: + unit_type_name = cls.format_unit_type(flight.unit_type, flight.country) + num_units = flight.count + flight_type = flight.flight_type.value + target = flight.package.target.name + for idx in range(0, num_units): + pilot = flight.roster.pilots[idx] + if pilot is None: + pilot_name = "Unassigned" + player = "" + else: + pilot_name = pilot.name + player = "Player" if pilot.player else "AI" + yield AircraftInventoryData( + flight.departure.name, + unit_type_name, + flight_type, + target, + pilot_name, + player, + ) + + @classmethod + def each_from_inventory( + cls, inventory: ControlPointAircraftInventory, country: str + ) -> Iterator[AircraftInventoryData]: + for unit_type, num_units in inventory.all_aircraft: + unit_type_name = cls.format_unit_type(unit_type, country) + for _ in range(0, num_units): + yield AircraftInventoryData( + inventory.control_point.name, + unit_type_name, + "Idle", + "N/A", + "N/A", + "N/A", + ) + + @staticmethod + def format_unit_type(aircraft: Type[FlyingType], country: str) -> str: + return db.unit_get_expanded_info(country, aircraft, "name") + + +class AirInventoryView(QWidget): + def __init__(self, game_model: GameModel) -> None: + super().__init__() + + self.game_model = game_model + self.country = self.game_model.game.country_for(player=True) + + layout = QVBoxLayout() + self.setLayout(layout) + + self.only_unallocated_cb = QCheckBox("Unallocated Only?") + self.only_unallocated_cb.toggled.connect(self.update_table) + + layout.addWidget(self.only_unallocated_cb) + + self.table = QTableWidget() + layout.addWidget(self.table) + + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.verticalHeader().setVisible(False) + self.update_table(False) + + def update_table(self, only_unallocated: bool) -> None: + self.table.setSortingEnabled(False) + self.table.clear() + + inventory_rows = list(self.get_data(only_unallocated)) + self.table.setRowCount(len(inventory_rows)) + headers = AircraftInventoryData.headers() + self.table.setColumnCount(len(headers)) + self.table.setHorizontalHeaderLabels(headers) + + for row, data in enumerate(inventory_rows): + for column, value in enumerate(data.columns): + self.table.setItem(row, column, QTableWidgetItem(value)) + + self.table.resizeColumnsToContents() + self.table.setSortingEnabled(True) + + def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]: + for package in self.game_model.game.blue_ato.packages: + for flight in package.flights: + yield from AircraftInventoryData.from_flight(flight) + + def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]: + game = self.game_model.game + for control_point, inventory in game.aircraft_inventory.inventories.items(): + if control_point.captured: + yield from AircraftInventoryData.each_from_inventory( + inventory, game.country_for(player=True) + ) + + def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]: + yield from self.iter_unallocated_aircraft() + if not only_unallocated: + yield from self.iter_allocated_aircraft() + + +class AirWingTabs(QTabWidget): + def __init__(self, game_model: GameModel) -> None: + super().__init__() + + self.addTab(SquadronList(game_model.blue_air_wing_model), "Squadrons") + self.addTab(AirInventoryView(game_model), "Inventory") + + class AirWingDialog(QDialog): """Dialog window showing the player's air wing.""" @@ -89,4 +233,4 @@ class AirWingDialog(QDialog): layout = QVBoxLayout() self.setLayout(layout) - layout.addWidget(SquadronList(self.air_wing_model)) + layout.addWidget(AirWingTabs(game_model)) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index bfad8870..c2a96a4d 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -22,12 +22,11 @@ from game import Game, VERSION, persistency from game.debriefing import Debriefing from qt_ui import liberation_install from qt_ui.dialogs import Dialog -from qt_ui.displayoptions import DisplayGroup, DisplayOptions, DisplayRule from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel from qt_ui.widgets.ato import QAirTaskingOrderPanel -from qt_ui.widgets.map.QLiberationMap import LeafletMap, QLiberationMap, LiberationMap +from qt_ui.widgets.map.QLiberationMap import QLiberationMap from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QDebriefingWindow import QDebriefingWindow from qt_ui.windows.infos.QInfoPanel import QInfoPanel @@ -35,10 +34,12 @@ from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard from qt_ui.windows.preferences.QLiberationPreferencesWindow import ( QLiberationPreferencesWindow, ) +from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow +from qt_ui.windows.stats.QStatsWindow import QStatsWindow class QLiberationWindow(QMainWindow): - def __init__(self, game: Optional[Game], new_map: bool) -> None: + def __init__(self, game: Optional[Game]) -> None: super(QLiberationWindow, self).__init__() self.game = game @@ -46,7 +47,7 @@ class QLiberationWindow(QMainWindow): Dialog.set_game(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.info_panel = QInfoPanel(self.game) - self.liberation_map: LiberationMap = self.create_map(new_map) + self.liberation_map = QLiberationMap(self.game_model, self) self.setGeometry(300, 100, 270, 100) self.setWindowTitle(f"DCS Liberation - v{VERSION}") @@ -148,6 +149,14 @@ class QLiberationWindow(QMainWindow): ) ) + self.openSettingsAction = QAction("Settings", self) + self.openSettingsAction.setIcon(CONST.ICONS["Settings"]) + self.openSettingsAction.triggered.connect(self.showSettingsDialog) + + self.openStatsAction = QAction("Stats", self) + self.openStatsAction.setIcon(CONST.ICONS["Statistics"]) + self.openStatsAction.triggered.connect(self.showStatsDialog) + def initToolbar(self): self.tool_bar = self.addToolBar("File") self.tool_bar.addAction(self.newGameAction) @@ -158,7 +167,9 @@ class QLiberationWindow(QMainWindow): self.links_bar.addAction(self.openDiscordAction) self.links_bar.addAction(self.openGithubAction) - self.display_bar = self.addToolBar("Display") + self.actions_bar = self.addToolBar("Actions") + self.actions_bar.addAction(self.openSettingsAction) + self.actions_bar.addAction(self.openStatsAction) def initMenuBar(self): self.menu = self.menuBar() @@ -174,30 +185,6 @@ class QLiberationWindow(QMainWindow): file_menu.addSeparator() file_menu.addAction("E&xit", self.close) - displayMenu = self.menu.addMenu("&Display") - - last_was_group = False - for item in DisplayOptions.menu_items(): - if isinstance(item, DisplayRule): - if last_was_group: - displayMenu.addSeparator() - self.display_bar.addSeparator() - action = self.make_display_rule_action(item) - displayMenu.addAction(action) - if action.icon(): - self.display_bar.addAction(action) - last_was_group = False - elif isinstance(item, DisplayGroup): - displayMenu.addSeparator() - self.display_bar.addSeparator() - group = QActionGroup(displayMenu) - for display_rule in item: - action = self.make_display_rule_action(display_rule, group) - displayMenu.addAction(action) - if action.icon(): - self.display_bar.addAction(action) - last_was_group = True - help_menu = self.menu.addMenu("&Help") help_menu.addAction(self.openDiscordAction) help_menu.addAction(self.openGithubAction) @@ -284,11 +271,6 @@ class QLiberationWindow(QMainWindow): self.game = game GameUpdateSignal.get_instance().game_loaded.emit(self.game) - def create_map(self, new_map: bool) -> LiberationMap: - if new_map: - return LeafletMap(self.game_model, self) - return QLiberationMap(self.game_model) - def setGame(self, game: Optional[Game]): try: self.game = game @@ -337,6 +319,14 @@ class QLiberationWindow(QMainWindow): self.subwindow = QLiberationPreferencesWindow() self.subwindow.show() + def showSettingsDialog(self) -> None: + self.dialog = QSettingsWindow(self.game) + self.dialog.show() + + def showStatsDialog(self): + self.dialog = QStatsWindow(self.game) + self.dialog.show() + def onDebriefing(self, debrief: Debriefing): logging.info("On Debriefing") self.debriefing = QDebriefingWindow(debrief) diff --git a/qt_ui/windows/QWaitingForMissionResultWindow.py b/qt_ui/windows/QWaitingForMissionResultWindow.py index b4addd4d..f4ce44fb 100644 --- a/qt_ui/windows/QWaitingForMissionResultWindow.py +++ b/qt_ui/windows/QWaitingForMissionResultWindow.py @@ -133,10 +133,10 @@ class QWaitingForMissionResultWindow(QDialog): self.setLayout(self.layout) @staticmethod - def add_update_row(description: str, count: Sized, layout: QGridLayout) -> None: + def add_update_row(description: str, count: int, layout: QGridLayout) -> None: row = layout.rowCount() layout.addWidget(QLabel(f"{description}"), row, 0) - layout.addWidget(QLabel(f"{len(count)}"), row, 1) + layout.addWidget(QLabel(f"{count}"), row, 1) def updateLayout(self, debriefing: Debriefing) -> None: updateBox = QGroupBox("Mission status") @@ -145,34 +145,36 @@ class QWaitingForMissionResultWindow(QDialog): self.debriefing = debriefing self.add_update_row( - "Aircraft destroyed", list(debriefing.air_losses.losses), update_layout + "Aircraft destroyed", len(list(debriefing.air_losses.losses)), update_layout ) self.add_update_row( "Front line units destroyed", - list(debriefing.front_line_losses), + len(list(debriefing.front_line_losses)), update_layout, ) self.add_update_row( - "Convoy units destroyed", list(debriefing.convoy_losses), update_layout + "Convoy units destroyed", len(list(debriefing.convoy_losses)), update_layout ) self.add_update_row( "Shipping cargo destroyed", - list(debriefing.cargo_ship_losses), + len(list(debriefing.cargo_ship_losses)), update_layout, ) self.add_update_row( - "Airlift cargo destroyed", list(debriefing.airlift_losses), update_layout + "Airlift cargo destroyed", + sum(len(loss.cargo) for loss in debriefing.airlift_losses), + update_layout, ) self.add_update_row( "Ground units lost at objective areas", - list(debriefing.ground_object_losses), + len(list(debriefing.ground_object_losses)), update_layout, ) self.add_update_row( - "Buildings destroyed", list(debriefing.building_losses), update_layout + "Buildings destroyed", len(list(debriefing.building_losses)), update_layout ) self.add_update_row( - "Base capture events", debriefing.base_captures, update_layout + "Base capture events", len(debriefing.base_captures), update_layout ) # Clear previous content of the window diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index dc6d560d..31cf5587 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -1,4 +1,5 @@ import logging +from typing import Callable from PySide2.QtCore import ( QItemSelectionModel, @@ -13,9 +14,13 @@ from PySide2.QtWidgets import ( QVBoxLayout, QPushButton, QHBoxLayout, + QGridLayout, + QLabel, + QCheckBox, ) from game.squadrons import Pilot +from gen.flights.flight import FlightType from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.models import SquadronModel @@ -61,6 +66,31 @@ class PilotList(QListView): self.setSelectionBehavior(QAbstractItemView.SelectItems) +class AutoAssignedTaskControls(QVBoxLayout): + def __init__(self, squadron_model: SquadronModel) -> None: + super().__init__() + self.squadron_model = squadron_model + + self.addWidget(QLabel("Auto-assignable mission types")) + + def make_callback(toggled_task: FlightType) -> Callable[[bool], None]: + def callback(checked: bool) -> None: + self.on_toggled(toggled_task, checked) + + return callback + + for task in squadron_model.squadron.mission_types: + checkbox = QCheckBox(text=task.value) + checkbox.setChecked(squadron_model.is_auto_assignable(task)) + checkbox.toggled.connect(make_callback(task)) + self.addWidget(checkbox) + + self.addStretch() + + def on_toggled(self, task: FlightType, checked: bool) -> None: + self.squadron_model.set_auto_assignable(task, checked) + + class SquadronDialog(QDialog): """Dialog window showing a squadron.""" @@ -75,11 +105,17 @@ class SquadronDialog(QDialog): layout = QVBoxLayout() self.setLayout(layout) + columns = QHBoxLayout() + layout.addLayout(columns) + + auto_assigned_tasks = AutoAssignedTaskControls(squadron_model) + columns.addLayout(auto_assigned_tasks) + self.pilot_list = PilotList(squadron_model) self.pilot_list.selectionModel().selectionChanged.connect( self.on_selection_changed ) - layout.addWidget(self.pilot_list) + columns.addWidget(self.pilot_list) button_panel = QHBoxLayout() button_panel.addStretch() diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 448e89e4..4b8265c8 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -11,7 +11,12 @@ from PySide2.QtWidgets import ( ) from game import Game, db -from game.theater import ControlPoint, ControlPointType +from game.theater import ( + ControlPoint, + ControlPointType, + FREE_FRONTLINE_UNIT_SUPPLY, + AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION, +) from gen.flights.flight import FlightType from qt_ui.dialogs import Dialog from qt_ui.models import GameModel @@ -44,8 +49,8 @@ class QBaseMenu2(QDialog): self.setWindowFlags(Qt.WindowStaysOnTopHint) self.setMinimumSize(300, 200) - self.setMinimumWidth(800) - self.setMaximumWidth(800) + self.setMinimumWidth(1024) + self.setMaximumWidth(1024) self.setModal(True) self.setWindowTitle(self.cp.name) @@ -62,6 +67,7 @@ class QBaseMenu2(QDialog): title.setAlignment(Qt.AlignLeft | Qt.AlignTop) title.setProperty("style", "base-title") self.intel_summary = QLabel() + self.intel_summary.setToolTip(self.generate_intel_tooltip()) self.update_intel_summary() top_layout.addWidget(title) top_layout.addWidget(self.intel_summary) @@ -195,16 +201,49 @@ class QBaseMenu2(QDialog): def update_intel_summary(self) -> None: aircraft = self.cp.base.total_aircraft parking = self.cp.total_aircraft_parking + ground_unit_limit = self.cp.frontline_unit_count_limit + deployable_unit_info = "" + + allocated = self.cp.allocated_ground_units(self.game_model.game.transfers) + unit_overage = max( + allocated.total_present - self.cp.frontline_unit_count_limit, 0 + ) + if self.cp.has_active_frontline: + deployable_unit_info = ( + f" (Up to {ground_unit_limit} deployable, {unit_overage} reserve)" + ) + self.intel_summary.setText( "\n".join( [ f"{aircraft}/{parking} aircraft", - f"{self.cp.base.total_armor} ground units", + f"{self.cp.base.total_armor} ground units" + deployable_unit_info, + f"{allocated.total_transferring} more ground units en route, {allocated.total_ordered} ordered", str(self.cp.runway_status), + f"{self.cp.active_ammo_depots_count}/{self.cp.total_ammo_depots_count} ammo depots", + f"{'Factory can produce units' if self.cp.has_factory else 'Does not have a factory'}", ] ) ) + def generate_intel_tooltip(self) -> str: + tooltip = ( + f"Deployable unit limit ({self.cp.frontline_unit_count_limit}) = {FREE_FRONTLINE_UNIT_SUPPLY} (base) + " + f" {AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION} (per connected ammo depot) * {self.cp.total_ammo_depots_count} " + f"(depots)" + ) + + if self.cp.has_active_frontline: + unit_overage = max( + self.cp.base.total_armor - self.cp.frontline_unit_count_limit, 0 + ) + tooltip += ( + f"\n{unit_overage} units will be held in reserve and will not be deployed to " + f"connected frontlines for this turn" + ) + + return tooltip + def closeEvent(self, close_event: QCloseEvent): GameUpdateSignal.get_instance().updateGame(self.game_model.game) diff --git a/qt_ui/windows/basemenu/QBaseMenuTabs.py b/qt_ui/windows/basemenu/QBaseMenuTabs.py index fe80dbe3..a8389e95 100644 --- a/qt_ui/windows/basemenu/QBaseMenuTabs.py +++ b/qt_ui/windows/basemenu/QBaseMenuTabs.py @@ -4,7 +4,6 @@ from game.theater import ControlPoint, OffMapSpawn, Fob from qt_ui.models import GameModel from qt_ui.windows.basemenu.DepartingConvoysMenu import DepartingConvoysMenu from qt_ui.windows.basemenu.airfield.QAirfieldCommand import QAirfieldCommand -from qt_ui.windows.basemenu.base_defenses.QBaseDefensesHQ import QBaseDefensesHQ from qt_ui.windows.basemenu.ground_forces.QGroundForcesHQ import QGroundForcesHQ from qt_ui.windows.basemenu.intel.QIntelInfo import QIntelInfo @@ -14,9 +13,6 @@ class QBaseMenuTabs(QTabWidget): super(QBaseMenuTabs, self).__init__() if not cp.captured: - if not cp.is_carrier and not isinstance(cp, OffMapSpawn): - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") self.intel = QIntelInfo(cp, game_model.game) self.addTab(self.intel, "Intel") @@ -30,17 +26,9 @@ class QBaseMenuTabs(QTabWidget): if cp.helipads: self.airfield_command = QAirfieldCommand(cp, game_model) self.addTab(self.airfield_command, "Heliport") - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") else: self.airfield_command = QAirfieldCommand(cp, game_model) self.addTab(self.airfield_command, "Airfield Command") - - if cp.is_carrier: - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Fleet") - elif not isinstance(cp, OffMapSpawn): + if not isinstance(cp, OffMapSpawn): self.ground_forces_hq = QGroundForcesHQ(cp, game_model) self.addTab(self.ground_forces_hq, "Ground Forces HQ") - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 3dd2bb0e..ce0b9e2a 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -164,16 +164,16 @@ class QHangarStatus(QHBoxLayout): self.setAlignment(Qt.AlignLeft) def update_label(self) -> None: - next_turn = self.control_point.expected_aircraft_next_turn(self.game_model.game) + next_turn = self.control_point.allocated_aircraft(self.game_model.game) max_amount = self.control_point.total_aircraft_parking components = [f"{next_turn.present} present"] - if next_turn.ordered > 0: - components.append(f"{next_turn.ordered} purchased") - elif next_turn.ordered < 0: - components.append(f"{-next_turn.ordered} sold") + if next_turn.total_ordered > 0: + components.append(f"{next_turn.total_ordered} purchased") + elif next_turn.total_ordered < 0: + components.append(f"{-next_turn.total_ordered} sold") - transferring = next_turn.transferring + transferring = next_turn.total_transferring if transferring > 0: components.append(f"{transferring} transferring in") if transferring < 0: diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py b/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py deleted file mode 100644 index 618e62dd..00000000 --- a/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py +++ /dev/null @@ -1,106 +0,0 @@ -from PySide2.QtCore import Qt -from PySide2.QtWidgets import ( - QGridLayout, - QGroupBox, - QLabel, - QPushButton, - QVBoxLayout, -) - -from game.theater import ControlPoint, TheaterGroundObject -from qt_ui.dialogs import Dialog -from qt_ui.uiconstants import VEHICLES_ICONS -from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from game import db -from dcs import vehicles - - -class QBaseDefenseGroupInfo(QGroupBox): - def __init__(self, cp: ControlPoint, ground_object: TheaterGroundObject, game): - super(QBaseDefenseGroupInfo, self).__init__("Group : " + ground_object.obj_name) - self.ground_object = ground_object - self.cp = cp - self.game = game - self.buildings = game.theater.find_ground_objects_by_obj_name( - self.ground_object.obj_name - ) - - self.main_layout = QVBoxLayout() - self.unit_layout = QGridLayout() - - self.init_ui() - - def init_ui(self): - - self.buildLayout() - self.main_layout.addLayout(self.unit_layout) - if not self.cp.captured and not self.ground_object.is_dead: - attack_button = QPushButton("Attack") - attack_button.setProperty("style", "btn-danger") - attack_button.setMaximumWidth(180) - attack_button.clicked.connect(self.onAttack) - self.main_layout.addWidget(attack_button, 0, Qt.AlignLeft) - - if self.cp.captured: - manage_button = QPushButton("Manage") - manage_button.setProperty("style", "btn-success") - manage_button.setMaximumWidth(180) - manage_button.clicked.connect(self.onManage) - self.main_layout.addWidget(manage_button, 0, Qt.AlignLeft) - - self.setLayout(self.main_layout) - - def buildLayout(self): - unit_dict = {} - for i in range(self.unit_layout.rowCount()): - for j in range(self.unit_layout.columnCount()): - item = self.unit_layout.itemAtPosition(i, j) - if item is not None and item.widget() is not None: - item.widget().setParent(None) - print("Remove " + str(i) + ", " + str(j)) - - for g in self.ground_object.groups: - for u in g.units: - if u.type in unit_dict.keys(): - unit_dict[u.type] = unit_dict[u.type] + 1 - else: - unit_dict[u.type] = 1 - i = 0 - for k, v in unit_dict.items(): - icon = QLabel() - if k in VEHICLES_ICONS.keys(): - icon.setPixmap(VEHICLES_ICONS[k]) - else: - icon.setText("" + k[:8] + "") - icon.setProperty("style", "icon-armor") - self.unit_layout.addWidget(icon, i, 0) - unit_display_name = k - unit_type = vehicles.vehicle_map.get(k) - if unit_type is not None: - unit_display_name = db.unit_get_expanded_info( - self.game.enemy_country, unit_type, "name" - ) - self.unit_layout.addWidget( - QLabel(str(v) + " x " + "" + unit_display_name + ""), - i, - 1, - ) - i = i + 1 - - if len(unit_dict.items()) == 0: - self.unit_layout.addWidget(QLabel("/"), 0, 0) - - self.setLayout(self.main_layout) - - def onAttack(self): - Dialog.open_new_package_dialog(self.ground_object, parent=self.window()) - - def onManage(self): - self.edition_menu = QGroundObjectMenu( - self.window(), self.ground_object, self.buildings, self.cp, self.game - ) - self.edition_menu.show() - self.edition_menu.changed.connect(self.onEdition) - - def onEdition(self): - self.buildLayout() diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py b/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py deleted file mode 100644 index f1d99d67..00000000 --- a/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py +++ /dev/null @@ -1,19 +0,0 @@ -from PySide2.QtWidgets import QFrame, QGridLayout - -from game import Game -from game.theater import ControlPoint -from qt_ui.windows.basemenu.base_defenses.QBaseInformation import QBaseInformation - - -class QBaseDefensesHQ(QFrame): - def __init__(self, cp: ControlPoint, game: Game): - super(QBaseDefensesHQ, self).__init__() - self.cp = cp - self.game = game - self.init_ui() - - def init_ui(self): - airport = self.game.theater.terrain.airport_by_id(self.cp.id) - layout = QGridLayout() - layout.addWidget(QBaseInformation(self.cp, airport, self.game)) - self.setLayout(layout) diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py b/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py deleted file mode 100644 index f2b874dd..00000000 --- a/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py +++ /dev/null @@ -1,56 +0,0 @@ -from PySide2.QtGui import Qt -from PySide2.QtWidgets import ( - QFrame, - QGridLayout, - QScrollArea, - QVBoxLayout, - QWidget, -) - -from game.theater import Airport, ControlPoint, Fob -from game.theater.theatergroundobject import BuildingGroundObject -from qt_ui.windows.basemenu.base_defenses.QBaseDefenseGroupInfo import ( - QBaseDefenseGroupInfo, -) - - -class QBaseInformation(QFrame): - def __init__(self, cp: ControlPoint, airport: Airport, game): - super(QBaseInformation, self).__init__() - self.cp = cp - self.airport = airport - self.game = game - self.setMinimumWidth(500) - self.init_ui() - - def init_ui(self): - self.mainLayout = QVBoxLayout() - - scroll_content = QWidget() - task_box_layout = QGridLayout() - scroll_content.setLayout(task_box_layout) - - for g in self.cp.ground_objects: - # Airbase groups are the objects that are hidden on the map because - # they're shown in the base menu. - if not g.airbase_group: - continue - - # Of these, we need to ignore the FOB structure itself since that's - # not supposed to be targetable. - if isinstance(self.cp, Fob) and isinstance(g, BuildingGroundObject): - continue - - group_info = QBaseDefenseGroupInfo(self.cp, g, self.game) - task_box_layout.addWidget(group_info) - - scroll_content.setLayout(task_box_layout) - scroll = QScrollArea() - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) - scroll.setWidgetResizable(True) - scroll.setWidget(scroll_content) - - self.mainLayout.addWidget(scroll) - - self.setLayout(self.mainLayout) diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py index c903413b..ec467b92 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py @@ -1,7 +1,10 @@ -from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout +from collections import Callable + +from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout, QPushButton from game import Game from game.theater import ControlPoint +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategySelector import ( QGroundForcesStrategySelector, ) @@ -15,10 +18,44 @@ class QGroundForcesStrategy(QGroupBox): self.init_ui() def init_ui(self): + def make_cheat_callback( + enemy_point: ControlPoint, advance: bool + ) -> Callable[[], None]: + def cheat() -> None: + self.cheat_alter_front_line(enemy_point, advance) + + return cheat + layout = QVBoxLayout() for enemy_cp in self.cp.connected_points: if not enemy_cp.captured: layout.addWidget(QLabel(enemy_cp.name)) layout.addWidget(QGroundForcesStrategySelector(self.cp, enemy_cp)) + if self.game.settings.enable_frontline_cheats: + advance_button = QPushButton("CHEAT: Advance") + advance_button.setProperty("style", "btn-danger") + layout.addWidget(advance_button) + advance_button.clicked.connect( + make_cheat_callback(enemy_cp, advance=True) + ) + + retreat_button = QPushButton("CHEAT: Retreat") + retreat_button.setProperty("style", "btn-danger") + layout.addWidget(retreat_button) + retreat_button.clicked.connect( + make_cheat_callback(enemy_cp, advance=False) + ) + layout.addStretch() self.setLayout(layout) + + def cheat_alter_front_line(self, enemy_point: ControlPoint, advance: bool) -> None: + amount = 0.2 + if not advance: + amount *= -1 + self.cp.base.affect_strength(amount) + enemy_point.base.affect_strength(-amount) + # Clear the ATO to replan missions affected by the front line. + self.game.reset_ato() + self.game.initialize_turn() + GameUpdateSignal.get_instance().updateGame(self.game) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 13abb5b3..9df4b454 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -21,8 +21,15 @@ from game import Game, db from game.data.building_data import FORTIFICATION_BUILDINGS from game.db import PRICES, PinpointStrike, REWARDS, unit_type_of from game.theater import ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import NavalGroundObject +from game.theater.theatergroundobject import ( + NavalGroundObject, + VehicleGroupGroundObject, + SamGroundObject, + EwrGroundObject, + BuildingGroundObject, +) from gen.defenses.armor_group_generator import generate_armor_group_of_type_and_size +from gen.sam.ewr_group_generator import get_faction_possible_ewrs_generator from gen.sam.sam_group_generator import get_faction_possible_sams_generator from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QBudgetBox import QBudgetBox @@ -32,9 +39,6 @@ from dcs import vehicles class QGroundObjectMenu(QDialog): - - changed = QtCore.Signal() - def __init__( self, parent, @@ -70,12 +74,12 @@ class QGroundObjectMenu(QDialog): self.doLayout() - if self.ground_object.dcs_identifier == "AA": - self.mainLayout.addWidget(self.intelBox) - else: + if isinstance(self.ground_object, BuildingGroundObject): self.mainLayout.addWidget(self.buildingBox) if self.cp.captured: self.mainLayout.addWidget(self.financesBox) + else: + self.mainLayout.addWidget(self.intelBox) self.actionLayout = QHBoxLayout() @@ -87,12 +91,12 @@ class QGroundObjectMenu(QDialog): self.buy_replace.clicked.connect(self.buy_group) self.buy_replace.setProperty("style", "btn-success") - if not isinstance(self.ground_object, NavalGroundObject): + if self.ground_object.purchasable: if self.total_value > 0: self.actionLayout.addWidget(self.sell_all_button) self.actionLayout.addWidget(self.buy_replace) - if self.cp.captured and self.ground_object.dcs_identifier == "AA": + if self.cp.captured and self.ground_object.purchasable: self.mainLayout.addLayout(self.actionLayout) self.setLayout(self.mainLayout) @@ -196,23 +200,21 @@ class QGroundObjectMenu(QDialog): self.actionLayout.setParent(None) self.doLayout() - if self.ground_object.dcs_identifier == "AA": - self.mainLayout.addWidget(self.intelBox) - else: + if isinstance(self.ground_object, BuildingGroundObject): self.mainLayout.addWidget(self.buildingBox) + else: + self.mainLayout.addWidget(self.intelBox) self.actionLayout = QHBoxLayout() if self.total_value > 0: self.actionLayout.addWidget(self.sell_all_button) self.actionLayout.addWidget(self.buy_replace) - if self.cp.captured and self.ground_object.dcs_identifier == "AA": + if self.cp.captured and self.ground_object.purchasable: self.mainLayout.addLayout(self.actionLayout) - except Exception as e: - print(e) + logging.exception(e) self.update_total_value() - self.changed.emit() def update_total_value(self): total_value = 0 @@ -244,7 +246,6 @@ class QGroundObjectMenu(QDialog): logging.info("Repaired unit : " + str(unit.id) + " " + str(unit.type)) self.do_refresh_layout() - self.changed.emit() def sell_all(self): self.update_total_value() @@ -294,9 +295,6 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buySamBox = QGroupBox("Buy SAM site :") self.buyArmorBox = QGroupBox("Buy defensive position :") - self.init_ui() - - def init_ui(self): faction = self.game.player_faction # Sams @@ -317,6 +315,30 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buySamButton.clicked.connect(self.buySam) + # EWRs + + buy_ewr_box = QGroupBox("Buy EWR:") + buy_ewr_layout = QGridLayout() + buy_ewr_box.setLayout(buy_ewr_layout) + + buy_ewr_layout.addWidget(QLabel("Radar type:"), 0, 0, Qt.AlignLeft) + + self.ewr_selector = QComboBox() + buy_ewr_layout.addWidget(self.ewr_selector, 0, 1, alignment=Qt.AlignRight) + ewr_types = get_faction_possible_ewrs_generator(faction) + for ewr_type in ewr_types: + self.ewr_selector.addItem( + f"{ewr_type.name()} [${ewr_type.price()}M]", ewr_type + ) + self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed) + + self.buy_ewr_button = QPushButton("Buy") + self.buy_ewr_button.clicked.connect(self.buy_ewr) + buy_ewr_layout.addWidget(self.buy_ewr_button, 1, 1, alignment=Qt.AlignRight) + stretch = QVBoxLayout() + stretch.addStretch() + buy_ewr_layout.addLayout(stretch, 2, 0) + # Armored units armored_units = db.find_unittype( @@ -354,16 +376,20 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buyArmorBox.setLayout(self.buyArmorLayout) self.mainLayout = QHBoxLayout() - self.mainLayout.addWidget(self.buySamBox) - if self.ground_object.airbase_group: + if isinstance(self.ground_object, SamGroundObject): + self.mainLayout.addWidget(self.buySamBox) + elif isinstance(self.ground_object, VehicleGroupGroundObject): self.mainLayout.addWidget(self.buyArmorBox) + elif isinstance(self.ground_object, EwrGroundObject): + self.mainLayout.addWidget(buy_ewr_box) self.setLayout(self.mainLayout) try: self.samComboChanged(0) self.armorComboChanged(0) + self.on_ewr_selection_changed(0) except: pass @@ -376,6 +402,12 @@ class QBuyGroupForGroundObjectDialog(QDialog): + "M]" ) + def on_ewr_selection_changed(self, index): + ewr = self.ewr_selector.itemData(index) + self.buy_ewr_button.setText( + f"Buy [${ewr.price()}M][-${self.current_group_value}M]" + ) + def armorComboChanged(self, index): self.buyArmorButton.setText( "Buy [$" @@ -441,6 +473,24 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.changed.emit() self.close() + def buy_ewr(self): + ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex()) + price = ewr_generator.price() - self.current_group_value + if price > self.game.budget: + self.error_money() + return + else: + self.game.budget -= price + + generator = ewr_generator(self.game, self.ground_object) + generator.generate() + self.ground_object.groups = [generator.vg] + + GameUpdateSignal.get_instance().updateBudget(self.game) + + self.changed.emit() + self.close() + def error_money(self): msg = QMessageBox() msg.setIcon(QMessageBox.Information) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 32204745..19634847 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -221,6 +221,19 @@ class QNewPackageDialog(QPackageDialog): ) self.ato_model = model + # In the *new* package dialog, a package has been created and may have aircraft + # assigned to it, but it is not a part of the ATO until the user saves it. + # + # Other actions (modifying settings, closing some other dialogs like the base + # menu) can cause a Game update which will forcibly close this window without + # either accepting or rejecting it, so we neither save the package nor release + # any allocated units. + # + # While it would be preferable to be able to update this dialog as needed in the + # event of game updates, the quick fix is to just not allow interaction with + # other UI elements until the new package has either been finalized or canceled. + self.setModal(True) + self.save_button = QPushButton("Save") self.save_button.setProperty("style", "start-button") self.save_button.clicked.connect(self.accept) diff --git a/qt_ui/windows/mission/QPlannedFlightsView.py b/qt_ui/windows/mission/QPlannedFlightsView.py index 302003ad..42ac4202 100644 --- a/qt_ui/windows/mission/QPlannedFlightsView.py +++ b/qt_ui/windows/mission/QPlannedFlightsView.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from PySide2.QtCore import QItemSelectionModel, QSize from PySide2.QtGui import QStandardItemModel from PySide2.QtWidgets import QAbstractItemView, QListView @@ -5,6 +7,7 @@ from PySide2.QtWidgets import QAbstractItemView, QListView from qt_ui.models import GameModel from qt_ui.windows.mission.QFlightItem import QFlightItem from game.theater.controlpoint import ControlPoint +from gen.flights.traveltime import TotEstimator class QPlannedFlightsView(QListView): @@ -25,8 +28,11 @@ class QPlannedFlightsView(QListView): for flight in package.flights: if flight.from_cp == self.cp: item = QFlightItem(package.package, flight) - self.model.appendRow(item) self.flight_items.append(item) + + self.flight_items.sort(key=self.mission_start_for_flight) + for item in self.flight_items: + self.model.appendRow(item) self.set_selected_flight(0) def set_selected_flight(self, row): @@ -43,3 +49,7 @@ class QPlannedFlightsView(QListView): def set_flight_planner(self) -> None: self.clear_layout() self.setup_content() + + @staticmethod + def mission_start_for_flight(flight_item: QFlightItem) -> timedelta: + return TotEstimator(flight_item.package).mission_start_time(flight_item.flight) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index acb991cd..0e8293a1 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -10,6 +10,7 @@ from PySide2.QtWidgets import ( QPushButton, QVBoxLayout, QLineEdit, + QHBoxLayout, ) from dcs.unittype import FlyingType @@ -17,7 +18,7 @@ from game import Game from game.squadrons import Squadron from game.theater import ControlPoint, OffMapSpawn from gen.ato import Package -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightRoster from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner from qt_ui.widgets.QLabeledWidget import QLabeledWidget @@ -26,6 +27,7 @@ from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelect from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector +from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor class QFlightCreator(QDialog): @@ -46,7 +48,7 @@ class QFlightCreator(QDialog): self.task_selector = QFlightTypeComboBox(self.game.theater, package.target) self.task_selector.setCurrentIndex(0) - self.task_selector.currentTextChanged.connect(self.on_task_changed) + self.task_selector.currentIndexChanged.connect(self.on_task_changed) layout.addLayout(QLabeledWidget("Task:", self.task_selector)) self.aircraft_selector = QAircraftTypeSelector( @@ -93,13 +95,20 @@ class QFlightCreator(QDialog): self.update_max_size(self.departure.available) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) - self.client_slots_spinner = QFlightSizeSpinner( - min_size=0, max_size=self.flight_size_spinner.value(), default_size=0 - ) - self.flight_size_spinner.valueChanged.connect( - lambda v: self.client_slots_spinner.setMaximum(v) - ) - layout.addLayout(QLabeledWidget("Client Slots:", self.client_slots_spinner)) + squadron = self.squadron_selector.currentData() + if squadron is None: + roster = None + else: + roster = FlightRoster( + squadron, initial_size=self.flight_size_spinner.value() + ) + self.roster_editor = FlightRosterEditor(roster) + self.flight_size_spinner.valueChanged.connect(self.resize_roster) + self.squadron_selector.currentIndexChanged.connect(self.on_squadron_changed) + roster_layout = QHBoxLayout() + layout.addLayout(roster_layout) + roster_layout.addWidget(QLabel("Assigned pilots:")) + roster_layout.addLayout(self.roster_editor) # When an off-map spawn overrides the start type to in-flight, we save # the selected type into this value. If a non-off-map spawn is selected @@ -142,6 +151,10 @@ class QFlightCreator(QDialog): def set_custom_name_text(self, text: str): self.custom_name_text = text + def resize_roster(self, new_size: int) -> None: + self.roster_editor.roster.resize(new_size) + self.roster_editor.resize(new_size) + def verify_form(self) -> Optional[str]: aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData() squadron: Optional[Squadron] = self.squadron_selector.currentData() @@ -181,7 +194,7 @@ class QFlightCreator(QDialog): origin = self.departure.currentData() arrival = self.arrival.currentData() divert = self.divert.currentData() - size = self.flight_size_spinner.value() + roster = self.roster_editor.roster if arrival is None: arrival = origin @@ -190,22 +203,17 @@ class QFlightCreator(QDialog): self.package, self.country, squadron, - size, + # A bit of a hack to work around the old API. Not actually relevant because + # the roster is passed explicitly. Needs a refactor. + roster.max_size, task, self.start_type.currentText(), origin, arrival, divert, custom_name=self.custom_name_text, + roster=roster, ) - for pilot, idx in zip(flight.pilots, range(self.client_slots_spinner.value())): - if pilot is None: - logging.error( - f"Cannot create client slot because {flight} has no pilot for " - f"aircraft {idx}" - ) - continue - pilot.player = True # noinspection PyUnresolvedReferences self.created.emit(flight) @@ -234,14 +242,22 @@ class QFlightCreator(QDialog): self.start_type.setCurrentText(self.restore_start_type) self.restore_start_type = None - def on_task_changed(self) -> None: + def on_task_changed(self, index: int) -> None: + task = self.task_selector.itemData(index) self.aircraft_selector.update_items( - self.task_selector.currentData(), - self.game.aircraft_inventory.available_types_for_player, - ) - self.squadron_selector.update_items( - self.task_selector.currentData(), self.aircraft_selector.currentData() + task, self.game.aircraft_inventory.available_types_for_player ) + self.squadron_selector.update_items(task, self.aircraft_selector.currentData()) + + def on_squadron_changed(self, index: int) -> None: + squadron = self.squadron_selector.itemData(index) + # Clear the roster first so we return the pilots to the pool. This way if we end + # up repopulating from the same squadron we'll get the same pilots back. + self.roster_editor.replace(None) + if squadron is not None: + self.roster_editor.replace( + FlightRoster(squadron, self.flight_size_spinner.value()) + ) def update_max_size(self, available: int) -> None: self.flight_size_spinner.setMaximum(min(available, 4)) diff --git a/qt_ui/windows/mission/flight/SquadronSelector.py b/qt_ui/windows/mission/flight/SquadronSelector.py index b8cbd750..e9b7ae7f 100644 --- a/qt_ui/windows/mission/flight/SquadronSelector.py +++ b/qt_ui/windows/mission/flight/SquadronSelector.py @@ -28,7 +28,11 @@ class SquadronSelector(QComboBox): self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]] ) -> None: current_squadron = self.currentData() - self.clear() + self.blockSignals(True) + try: + self.clear() + finally: + self.blockSignals(False) if task is None: self.addItem("No task selected", None) return diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py index 17ca1cca..5cf5b370 100644 --- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py +++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py @@ -13,7 +13,11 @@ class DcsLoadoutSelector(QComboBox): for loadout in Loadout.iter_for(flight): self.addItem(loadout.name, loadout) self.model().sort(0) - self.setCurrentText(flight.loadout.name) + self.setDisabled(flight.loadout.is_custom) + if flight.loadout.is_custom: + self.setCurrentText(Loadout.default_for(flight).name) + else: + self.setCurrentText(flight.loadout.name) class QFlightPayloadTab(QFrame): diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index d3465cb5..ec4530c2 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py @@ -15,16 +15,16 @@ from PySide2.QtWidgets import ( from game import Game from game.squadrons import Pilot -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightRoster from qt_ui.models import PackageModel class PilotSelector(QComboBox): available_pilots_changed = Signal() - def __init__(self, flight: Flight, idx: int) -> None: + def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: super().__init__() - self.flight = flight + self.roster = roster self.pilot_index = idx self.rebuild() @@ -34,15 +34,15 @@ class PilotSelector(QComboBox): def _do_rebuild(self) -> None: self.clear() - if self.pilot_index >= self.flight.count: + if self.roster is None or self.pilot_index >= self.roster.max_size: self.addItem("No aircraft", None) self.setDisabled(True) return self.setEnabled(True) self.addItem("Unassigned", None) - choices = list(self.flight.squadron.available_pilots) - current_pilot = self.flight.pilots[self.pilot_index] + choices = list(self.roster.squadron.available_pilots) + current_pilot = self.roster.pilots[self.pilot_index] if current_pilot is not None: choices.append(current_pilot) # Put players first, otherwise alphabetically. @@ -70,19 +70,23 @@ class PilotSelector(QComboBox): # The roster resize is handled separately, so we have no pilots to remove. return pilot = self.itemData(index) - if pilot == self.flight.pilots[self.pilot_index]: + if pilot == self.roster.pilots[self.pilot_index]: return - self.flight.set_pilot(self.pilot_index, pilot) + self.roster.set_pilot(self.pilot_index, pilot) self.available_pilots_changed.emit() + def replace(self, new_roster: Optional[FlightRoster]) -> None: + self.roster = new_roster + self.rebuild() + class PilotControls(QHBoxLayout): - def __init__(self, flight: Flight, idx: int) -> None: + def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: super().__init__() - self.flight = flight + self.roster = roster self.pilot_index = idx - self.selector = PilotSelector(flight, idx) + self.selector = PilotSelector(roster, idx) self.selector.currentIndexChanged.connect(self.on_pilot_changed) self.addWidget(self.selector) @@ -95,9 +99,9 @@ class PilotControls(QHBoxLayout): @property def pilot(self) -> Optional[Pilot]: - if self.pilot_index >= self.flight.count: + if self.roster is None or self.pilot_index >= self.roster.max_size: return None - return self.flight.pilots[self.pilot_index] + return self.roster.pilots[self.pilot_index] def on_player_toggled(self, checked: bool) -> None: pilot = self.pilot @@ -130,12 +134,21 @@ class PilotControls(QHBoxLayout): finally: self.player_checkbox.blockSignals(False) + def replace(self, new_roster: Optional[FlightRoster]) -> None: + self.roster = new_roster + if self.roster is None or self.pilot_index >= self.roster.max_size: + self.disable_and_clear() + else: + self.enable_and_reset() + self.selector.replace(new_roster) + class FlightRosterEditor(QVBoxLayout): MAX_PILOTS = 4 - def __init__(self, flight: Flight) -> None: + def __init__(self, roster: Optional[FlightRoster]) -> None: super().__init__() + self.roster = roster self.pilot_controls = [] for pilot_idx in range(self.MAX_PILOTS): @@ -146,7 +159,7 @@ class FlightRosterEditor(QVBoxLayout): return callback - controls = PilotControls(flight, pilot_idx) + controls = PilotControls(roster, pilot_idx) controls.selector.available_pilots_changed.connect( make_reset_callback(pilot_idx) ) @@ -167,6 +180,13 @@ class FlightRosterEditor(QVBoxLayout): for controls in self.pilot_controls[new_size:]: controls.disable_and_clear() + def replace(self, new_roster: Optional[FlightRoster]) -> None: + if self.roster is not None: + self.roster.clear() + self.roster = new_roster + for controls in self.pilot_controls: + controls.replace(new_roster) + class QFlightSlotEditor(QGroupBox): def __init__(self, package_model: PackageModel, flight: Flight, game: Game): @@ -196,14 +216,16 @@ class QFlightSlotEditor(QGroupBox): layout.addWidget(QLabel(str(self.flight.squadron)), 1, 1) layout.addWidget(QLabel("Assigned pilots:"), 2, 0) - self.roster_editor = FlightRosterEditor(flight) + self.roster_editor = FlightRosterEditor(flight.roster) layout.addLayout(self.roster_editor, 2, 1) self.setLayout(layout) def _changed_aircraft_count(self): - self.game.aircraft_inventory.return_from_flight(self.flight) + old_count = self.flight.count new_count = int(self.aircraft_count_spinner.value()) + self.game.aircraft_inventory.return_from_flight(self.flight) + self.flight.resize(new_count) try: self.game.aircraft_inventory.claim_for_flight(self.flight) except ValueError: @@ -217,7 +239,6 @@ class QFlightSlotEditor(QGroupBox): f"{available} {self.flight.unit_type} remaining" ) self.game.aircraft_inventory.claim_for_flight(self.flight) + self.flight.resize(old_count) return - - self.flight.resize(new_count) self.roster_editor.resize(new_count) diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py index 85d3f0d1..1d74e64a 100644 --- a/qt_ui/windows/newgame/QCampaignList.py +++ b/qt_ui/windows/newgame/QCampaignList.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Union, Tuple import packaging.version from PySide2 import QtGui -from PySide2.QtCore import QItemSelectionModel +from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import QAbstractItemView, QListView @@ -116,6 +116,7 @@ def load_campaigns() -> List[Campaign]: class QCampaignItem(QStandardItem): def __init__(self, campaign: Campaign) -> None: super(QCampaignItem, self).__init__() + self.setData(campaign, QCampaignList.CampaignRole) self.setIcon(QtGui.QIcon(CONST.ICONS[campaign.icon_name])) self.setEditable(False) if campaign.is_compatible: @@ -126,31 +127,33 @@ class QCampaignItem(QStandardItem): class QCampaignList(QListView): - def __init__(self, campaigns: List[Campaign]) -> None: + CampaignRole = Qt.UserRole + + def __init__(self, campaigns: list[Campaign], show_incompatible: bool) -> None: super(QCampaignList, self).__init__() - self.model = QStandardItemModel(self) - self.setModel(self.model) + self.campaign_model = QStandardItemModel(self) + self.setModel(self.campaign_model) self.setMinimumWidth(250) self.setMinimumHeight(350) - self.campaigns = [] + self.campaigns = campaigns self.setSelectionBehavior(QAbstractItemView.SelectItems) - self.setup_content(campaigns) + self.setup_content(show_incompatible) - def setup_content(self, campaigns: List[Campaign]) -> None: - for campaign in campaigns: - self.campaigns.append(campaign) - item = QCampaignItem(campaign) - self.model.appendRow(item) - self.setSelectedCampaign(0) - self.repaint() + @property + def selected_campaign(self) -> Campaign: + return self.currentIndex().data(QCampaignList.CampaignRole) - def setSelectedCampaign(self, row): - self.selectionModel().clearSelection() - index = self.model.index(row, 0) - if not index.isValid(): - index = self.model.index(0, 0) - self.selectionModel().setCurrentIndex(index, QItemSelectionModel.Select) - self.repaint() + def setup_content(self, show_incompatible: bool) -> None: + self.selectionModel().blockSignals(True) + try: + self.campaign_model.clear() + for campaign in self.campaigns: + if show_incompatible or campaign.is_compatible: + item = QCampaignItem(campaign) + self.campaign_model.appendRow(item) + finally: + self.selectionModel().blockSignals(False) - def clear_layout(self): - self.model.removeRows(0, self.model.rowCount()) + self.selectionModel().setCurrentIndex( + self.campaign_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select + ) diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index cd08a4a0..f2e2e0c4 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -6,7 +6,7 @@ from typing import List from PySide2 import QtGui, QtWidgets from PySide2.QtCore import QItemSelectionModel, QPoint, Qt, QDate -from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel +from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox from jinja2 import Environment, FileSystemLoader, select_autoescape from game import db @@ -319,7 +319,16 @@ class TheaterConfiguration(QtWidgets.QWizardPage): ) # List of campaigns - campaignList = QCampaignList(campaigns) + show_incompatible_campaigns_checkbox = QCheckBox( + text="Show incompatible campaigns" + ) + show_incompatible_campaigns_checkbox.setChecked(False) + campaignList = QCampaignList( + campaigns, show_incompatible_campaigns_checkbox.isChecked() + ) + show_incompatible_campaigns_checkbox.toggled.connect( + lambda checked: campaignList.setup_content(show_incompatible=checked) + ) self.registerField("selectedCampaign", campaignList) # Faction description @@ -380,8 +389,7 @@ class TheaterConfiguration(QtWidgets.QWizardPage): template_perf = jinja_env.get_template( "campaign_performance_template_EN.j2" ) - index = campaignList.selectionModel().currentIndex().row() - campaign = campaignList.campaigns[index] + campaign = campaignList.selected_campaign self.setField("selectedCampaign", campaign) self.campaignMapDescription.setText(template.render({"campaign": campaign})) self.faction_selection.setDefaultFactions(campaign) @@ -396,9 +404,12 @@ class TheaterConfiguration(QtWidgets.QWizardPage): campaignList.selectionModel().selectionChanged.connect(on_campaign_selected) on_campaign_selected() - # Docs Link docsText = QtWidgets.QLabel( - 'How to create your own theater' + "

Want more campaigns? You can " + 'offer to help, ' + 'play a community campaign, ' + 'or create your own.' + "

" ) docsText.setAlignment(Qt.AlignCenter) docsText.setOpenExternalLinks(True) @@ -418,7 +429,8 @@ class TheaterConfiguration(QtWidgets.QWizardPage): layout = QtWidgets.QGridLayout() layout.setColumnMinimumWidth(0, 20) layout.addWidget(campaignList, 0, 0, 5, 1) - layout.addWidget(docsText, 5, 0, 1, 1) + layout.addWidget(show_incompatible_campaigns_checkbox, 5, 0, 1, 1) + layout.addWidget(docsText, 6, 0, 1, 1) layout.addWidget(self.campaignMapDescription, 0, 1, 1, 1) layout.addWidget(self.performanceText, 1, 1, 1, 1) layout.addWidget(mapSettingsGroup, 2, 1, 1, 1) diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 7f406e17..88101b28 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -473,20 +473,22 @@ class QSettingsWindow(QDialog): general_layout.addWidget(restrict_weapons, 0, 1, Qt.AlignRight) def set_old_awec(value: bool) -> None: - self.game.settings.disable_legacy_aewc = value + self.game.settings.disable_legacy_aewc = not value old_awac = QCheckBox() - old_awac.setChecked(self.game.settings.disable_legacy_aewc) + old_awac.setChecked(not self.game.settings.disable_legacy_aewc) old_awac.toggled.connect(set_old_awec) old_awec_info = ( - "If checked, the invulnerable friendly AEW&C aircraft that begins " - "the mission in the air will not be spawned. AEW&C missions must " - "be planned in the ATO and will take time to arrive on-station." + "If checked, an invulnerable friendly AEW&C aircraft that begins the " + "mission on station will be be spawned. This behavior will be removed in a " + "future release." ) old_awac.setToolTip(old_awec_info) - old_awac_label = QLabel("Disable invulnerable, always-available AEW&C (WIP)") + old_awac_label = QLabel( + "Spawn invulnerable, always-available AEW&C aircraft (deprecated)" + ) old_awac_label.setToolTip(old_awec_info) general_layout.addWidget(old_awac_label, 1, 0) diff --git a/resources/campaigns/Battle_for_the_UAE.json b/resources/campaigns/Battle_for_the_UAE.json new file mode 100644 index 00000000..87d1aa15 --- /dev/null +++ b/resources/campaigns/Battle_for_the_UAE.json @@ -0,0 +1,11 @@ +{ + "name": "Persian Gulf - Battle for the UAE", + "theater": "Persian Gulf", + "authors": "Mustang25", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Iran 2015", + "description": "

Following the Battle of Abu Dhabi, Iran's invasion of the UAE has been halted approximately 20 miles Northeast of Liwa Airbase by coalition forces.

After weeks of stalemate, coalition forces have consolidated their position and are ready to launch their counterattack to push Iranian forces off the peninsula.

", + "version": "6.0", + "miz": "Battle_for_the_UAE_v3.0.2.miz", + "performance": 2 +} diff --git a/resources/campaigns/Battle_for_the_UAE_v3.0.2.miz b/resources/campaigns/Battle_for_the_UAE_v3.0.2.miz new file mode 100644 index 00000000..8327345c Binary files /dev/null and b/resources/campaigns/Battle_for_the_UAE_v3.0.2.miz differ diff --git a/resources/campaigns/Operation_Mole_Cricket_2010.json b/resources/campaigns/Operation_Mole_Cricket_2010.json new file mode 100644 index 00000000..cf50a1b0 --- /dev/null +++ b/resources/campaigns/Operation_Mole_Cricket_2010.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - Operation Mole Cricket 2010", + "theater": "Syria", + "authors": "Mustang25", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Syria 2011", + "description": "

In a scenario reminescent of the First Lebanon War, hostile Syrian-backed forces have flooded into the Bekaa Valley.

The objective of this operation is twofold: drive the enemy out of the Bekaa Valley and push past the Golan Heights into Syrian territory to capture Tiyas Airbase.

", + "version": "6.0", + "miz": "Operation_Mole_Cricket_2010_v3.0.2.miz", + "performance": 2 +} diff --git a/resources/campaigns/Operation_Mole_Cricket_2010_v3.0.2.miz b/resources/campaigns/Operation_Mole_Cricket_2010_v3.0.2.miz new file mode 100644 index 00000000..815a460b Binary files /dev/null and b/resources/campaigns/Operation_Mole_Cricket_2010_v3.0.2.miz differ diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json index 505d19af..a551607b 100644 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -7,5 +7,5 @@ "description": "

You have managed to establish a foothold at Khasab. Continue pushing south.

", "miz": "battle_of_abu_dhabi.miz", "performance": 2, - "version": "5.0" + "version": "6.0" } \ No newline at end of file diff --git a/resources/campaigns/black_sea.json b/resources/campaigns/black_sea.json index 83cbe20b..29217f5c 100644 --- a/resources/campaigns/black_sea.json +++ b/resources/campaigns/black_sea.json @@ -4,5 +4,6 @@ "authors": "Colonel Panic", "description": "

A medium sized theater with bases along the coast of the Black Sea.

", "miz": "black_sea.miz", - "performance": 2 + "performance": 2, + "version": "6.0" } \ No newline at end of file diff --git a/resources/campaigns/black_sea.miz b/resources/campaigns/black_sea.miz index 278f14cf..e32a1fb2 100644 Binary files a/resources/campaigns/black_sea.miz and b/resources/campaigns/black_sea.miz differ diff --git a/resources/campaigns/exercise_vegas_nerve.json b/resources/campaigns/exercise_vegas_nerve.json index 010a0bb2..0179641f 100644 --- a/resources/campaigns/exercise_vegas_nerve.json +++ b/resources/campaigns/exercise_vegas_nerve.json @@ -2,8 +2,10 @@ "name": "Nevada - Exercise Vegas Nerve", "theater": "Nevada", "authors": "Starfire", - "description": "

A Red Flag Exercise scenario for the NTTR comprising 4 control points.

", - "version": 3, + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Redfor (China) 2010", + "description": "

This is an asymmetrical Red Flag Exercise scenario for the NTTR comprising 4 control points. You start off in control of the two Tonopah airports, and will push south to capture Groom Lake and Nellis AFBs. Taking down Nellis AFB's IADS and striking their resource sites ASAP once Groom Lake has been captured is recommended to offset their resource advantage.

", + "version": "6.0", "miz": "exercise_vegas_nerve.miz", "performance": 0 } \ No newline at end of file diff --git a/resources/campaigns/exercise_vegas_nerve.miz b/resources/campaigns/exercise_vegas_nerve.miz index 32029cd1..4cc74abd 100644 Binary files a/resources/campaigns/exercise_vegas_nerve.miz and b/resources/campaigns/exercise_vegas_nerve.miz differ diff --git a/resources/campaigns/golan_heights_lite.json b/resources/campaigns/golan_heights_lite.json index f9923616..45d3c728 100644 --- a/resources/campaigns/golan_heights_lite.json +++ b/resources/campaigns/golan_heights_lite.json @@ -1,5 +1,5 @@ { - "name": "Syria - Battle for Golan Heights - Lite", + "name": "Syria - Battle for Golan Heights", "theater": "Syria", "authors": "Khopa", "recommended_player_faction": "Israel 2000", @@ -7,5 +7,5 @@ "description": "

In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.

This scenario is designed to be performance friendly.

", "miz": "golan_heights_lite.miz", "performance": 1, - "version": "5.0" + "version": "6.0" } diff --git a/resources/campaigns/golan_heights_lite.miz b/resources/campaigns/golan_heights_lite.miz index f304bec2..43810730 100644 Binary files a/resources/campaigns/golan_heights_lite.miz and b/resources/campaigns/golan_heights_lite.miz differ diff --git a/resources/campaigns/inherent_resolve.json b/resources/campaigns/inherent_resolve.json index 110736b7..66f74d5e 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": "5.0", + "version": "6.0", "miz": "inherent_resolve.miz", "performance": 2 } \ No newline at end of file diff --git a/resources/campaigns/operation_peace_spring.json b/resources/campaigns/operation_peace_spring.json index 8b01906e..eb028ac5 100644 --- a/resources/campaigns/operation_peace_spring.json +++ b/resources/campaigns/operation_peace_spring.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Turkey 2005", "description": "

This is a semi-fictional what-if scenario for Operation Peace Spring, during which Turkish forces that crossed into Syria on an offensive against Kurdish militias were emboldened by early successes to continue pushing further southward. Attempts to broker a ceasefire have failed. Members of Operation Inherent Resolve have gathered at Ramat David Airbase in Israel to launch a counter-offensive. Campaign inversion is available if you wish to play as Turkey.

", - "version": 3, + "version": "6.0", "miz": "operation_peace_spring.miz", "performance": 1 } \ No newline at end of file diff --git a/resources/campaigns/operation_peace_spring.miz b/resources/campaigns/operation_peace_spring.miz index 67a01d7e..8fdd5687 100644 Binary files a/resources/campaigns/operation_peace_spring.miz and b/resources/campaigns/operation_peace_spring.miz differ diff --git a/resources/campaigns/operation_vectrons_claw.json b/resources/campaigns/operation_vectrons_claw.json new file mode 100644 index 00000000..1b4e7ae9 --- /dev/null +++ b/resources/campaigns/operation_vectrons_claw.json @@ -0,0 +1,11 @@ +{ + "name": "Caucasus - Operation Vectron's Claw", + "theater": "Caucasus", + "authors": "Starfire", + "recommended_player_faction": "USA 2005", + "recommended_enemy_faction": "Russia 1990", + "description": "

United Nations Observer Mission in Georgia (UNOMIG) observers stationed in Georgia to monitor the ceasefire between Georgia and Abkhazia have been cut off from friendly forces by Russian troops backing the separatist state. The UNOMIG HQ at Sukhumi has been taken, and a small contingent of observers and troops at the Zugdidi Sector HQ will have to make a run for the coast, supported by offshore US naval aircraft. The contingent is aware that their best shot at survival is to swiftly retake Sukhumi before Russian forces have a chance to dig in, so that friendly ground forces can land and reinforce them.

Note: Ground unit purchase will not be available past Turn 0 until Sukhumi is retaken, so it is imperative you reach Sukhumi with at least one surviving ground unit to capture it. The player can either play the first leg of the scenario as an evacuation with a couple of light vehicles (e.g. Humvees) set on breakthrough (modifying waypoints in the mission editor so they are not charging head-on into enemy ground forces is suggested), or purchase heavier ground units if they wish to experience a more traditional ground war.

", + "version": "6.0", + "miz": "operation_vectrons_claw.miz", + "performance": 1 +} \ No newline at end of file diff --git a/resources/campaigns/operation_vectrons_claw.miz b/resources/campaigns/operation_vectrons_claw.miz new file mode 100644 index 00000000..e500b794 Binary files /dev/null and b/resources/campaigns/operation_vectrons_claw.miz differ diff --git a/resources/campaigns/russia_small.json b/resources/campaigns/russia_small.json index 0711c115..49a4faab 100644 --- a/resources/campaigns/russia_small.json +++ b/resources/campaigns/russia_small.json @@ -7,5 +7,5 @@ "description": "

A small theater in Russia, progress from Mozdok to Maykop.

This scenario is pretty simple, it is ideal if you want to run a short campaign. If your PC is not powerful, this is also the less performance heavy scenario.

", "miz": "russia_small.miz", "performance": 0, - "version": 3 + "version": "6.0" } diff --git a/resources/campaigns/russia_small.miz b/resources/campaigns/russia_small.miz index 1863b4a1..1441ec8a 100644 Binary files a/resources/campaigns/russia_small.miz and b/resources/campaigns/russia_small.miz differ diff --git a/resources/campaigns/syria_full_map.json b/resources/campaigns/syria_full_map.json new file mode 100644 index 00000000..99adafde --- /dev/null +++ b/resources/campaigns/syria_full_map.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - Full Map", + "theater": "Syria", + "authors": "Plob", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Syria 2011", + "description": "

Syria Full map, designed for groups of 4-12 players.

", + "miz": "syria_full_map.miz", + "performance": 3, + "version": "6.0" +} \ No newline at end of file diff --git a/resources/campaigns/syria_full_map.miz b/resources/campaigns/syria_full_map.miz new file mode 100644 index 00000000..01345dd1 Binary files /dev/null and b/resources/campaigns/syria_full_map.miz differ diff --git a/resources/campaigns/syria_full_map_remastered.json b/resources/campaigns/syria_full_map_remastered.json deleted file mode 100644 index e0c9a235..00000000 --- a/resources/campaigns/syria_full_map_remastered.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Syria - Full Map", - "theater": "Syria", - "authors": "Hawkmoon", - "description": "

Full map of Syria

Note : 

For a better early game experience, it is suggested to give the AI an high amount of starting money.

", - "miz": "syria_full_map_remastered.miz", - "performance": 3 -} \ No newline at end of file diff --git a/resources/campaigns/syria_full_map_remastered.miz b/resources/campaigns/syria_full_map_remastered.miz deleted file mode 100644 index eecdc253..00000000 Binary files a/resources/campaigns/syria_full_map_remastered.miz and /dev/null differ diff --git a/resources/factions/jordan_2010.json b/resources/factions/jordan_2010.json new file mode 100644 index 00000000..1f0d1c82 --- /dev/null +++ b/resources/factions/jordan_2010.json @@ -0,0 +1,61 @@ +{ + "country": "Jordan", + "name": "Jordan 2010", + "authors": "Starfire", + "description": "

Royal Jordanian Armed Forces early 21st century

", + "aircrafts": [ + "F_5E_3", + "C_101CC", + "SA342M", + "SA342L" + ], + "frontline_units": [ + "MBT_Challenger_II", + "MBT_M60A3_Patton", + "IFV_Marder", + "IFV_BMP_2", + "APC_M113", + "APC_M1043_HMMWV_Armament", + "ATGM_M1045_HMMWV_TOW" + ], + "artillery_units": [ + "MLRS_M270", + "SPH_M109_Paladin" + ], + "logistics_units": [ + "Transport_M818" + ], + "infantry_units": [ + "Infantry_M4", + "Soldier_M249", + "SAM_SA_18_Igla_S_MANPADS" + ], + "air_defenses": [ + "SA8Generator", + "SA13Generator", + "VulcanGenerator", + "ZU23Generator", + "HawkGenerator" + ], + "ewrs": [ + "HawkEwrGenerator" + ], + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "destroyers": [ + ], + "cruisers": [ + ], + "requirements": { + }, + "carrier_names": [ + ], + "helicopter_carrier_names": [ + ], + "navy_generators": [ + ], + "has_jtac": false, + "doctrine": "coldwar" +} diff --git a/resources/stylesheets/style-dcs.css b/resources/stylesheets/style-dcs.css index 22945244..43aabda5 100644 --- a/resources/stylesheets/style-dcs.css +++ b/resources/stylesheets/style-dcs.css @@ -498,7 +498,6 @@ QHeaderView::section { background: #4B5B74; padding: 4px; border-style: none; - border-bottom: 1px solid #1D2731; } QHeaderView::section:horizontal @@ -515,11 +514,6 @@ QHeaderView::section:vertical background: #4B5B74; } -QTableWidget { - gridline-color: red; - background: #4B5B74; -} - QTableView QTableCornerButton::section { background: #4B5B74; } diff --git a/resources/ui/ground_assets/fob_blue_alive.svg b/resources/ui/ground_assets/fob_blue_alive.svg index 01eed590..e78bbd81 100644 --- a/resources/ui/ground_assets/fob_blue_alive.svg +++ b/resources/ui/ground_assets/fob_blue_alive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_blue_damaged.svg b/resources/ui/ground_assets/fob_blue_damaged.svg index e9dacd53..c93ec0e6 100644 --- a/resources/ui/ground_assets/fob_blue_damaged.svg +++ b/resources/ui/ground_assets/fob_blue_damaged.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_blue_destroyed.svg b/resources/ui/ground_assets/fob_blue_destroyed.svg index f9b1e2a0..4cc0764f 100644 --- a/resources/ui/ground_assets/fob_blue_destroyed.svg +++ b/resources/ui/ground_assets/fob_blue_destroyed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_red_alive.svg b/resources/ui/ground_assets/fob_red_alive.svg index 68ee8a5e..f6e1e238 100644 --- a/resources/ui/ground_assets/fob_red_alive.svg +++ b/resources/ui/ground_assets/fob_red_alive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_red_damaged.svg b/resources/ui/ground_assets/fob_red_damaged.svg index bfb93b9a..72ff98f7 100644 --- a/resources/ui/ground_assets/fob_red_damaged.svg +++ b/resources/ui/ground_assets/fob_red_damaged.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_red_destroyed.svg b/resources/ui/ground_assets/fob_red_destroyed.svg index 4b150097..0a00635a 100644 --- a/resources/ui/ground_assets/fob_red_destroyed.svg +++ b/resources/ui/ground_assets/fob_red_destroyed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index b5dbec0a..5a8dee3d 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -16,7 +16,6 @@ const Categories = Object.freeze([ "ewr", "factory", "farp", - "fob", "fuel", "missile", "oil", @@ -42,9 +41,9 @@ class CpIcons { this.icons[player][state] = { airfield: this.loadIcon("airfield", player, state), cv: this.loadIcon("cv", player, state), - fob: this.loadLegacyIcon(player), + fob: this.loadIcon("fob", player, state), lha: this.loadIcon("lha", player, state), - offmap: this.loadLegacyIcon(player), + offmap: this.loadIcon("airfield", player, state), }; } } @@ -61,19 +60,6 @@ class CpIcons { iconSize: [32, 32], }); } - - loadLegacyIcon(player) { - const color = player ? "blue" : "red"; - return new L.Icon({ - iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-${color}.png`, - shadowUrl: - "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], - }); - } } class TgoIcons { @@ -179,6 +165,11 @@ const redRadarSamThreatZones = L.layerGroup(); const blueNavmesh = L.layerGroup(); const redNavmesh = L.layerGroup(); +const inclusionZones = L.layerGroup(); +const exclusionZones = L.layerGroup(); +const seaZones = L.layerGroup(); +const unculledZones = L.layerGroup(); + // Main map controls. These are the ones that we expect users to interact with. // These are always open, which unfortunately means that the scroll bar will not // appear if the menu doesn't fit. This fits in the smallest window size we @@ -241,11 +232,17 @@ L.control "Air Defenses": redAirDefenseThreatZones, "Radar SAMs": redRadarSamThreatZones, }, - "Navmeshes": { + Navmeshes: { Hide: L.layerGroup().addTo(map), Blue: blueNavmesh, Red: redNavmesh, }, + "Map Zones": { + "Inclusion zones": inclusionZones, + "Exclusion zones": exclusionZones, + "Sea zones": seaZones, + "Culling exclusion zones": unculledZones, + }, }, { position: "topleft", @@ -268,6 +265,8 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.flightsChanged.connect(drawFlightPlans); game.threatZonesChanged.connect(drawThreatZones); game.navmeshesChanged.connect(drawNavmeshes); + game.mapZonesChanged.connect(drawMapZones); + game.unculledZonesChanged.connect(drawUnculledZones); }); function recenterMap(center) { @@ -291,12 +290,10 @@ class ControlPoint { } icon() { - // TODO: Runway status. - // https://github.com/dcs-liberation/dcs_liberation/issues/1105 return Icons.ControlPoints.icon( this.cp.category, this.cp.blue, - UnitState.Alive + this.cp.status ); } @@ -889,6 +886,48 @@ function drawNavmeshes() { drawNavmesh(game.navmeshes.red, redNavmesh); } +function drawMapZones() { + seaZones.clearLayers(); + inclusionZones.clearLayers(); + exclusionZones.clearLayers(); + + for (const zone of game.mapZones.seaZones) { + L.polygon(zone, { + color: "#344455", + fillColor: "#344455", + fillOpacity: 1, + }).addTo(seaZones); + } + + for (const zone of game.mapZones.inclusionZones) { + L.polygon(zone, { + color: "#969696", + fillColor: "#4b4b4b", + fillOpacity: 1, + }).addTo(inclusionZones); + } + + for (const zone of game.mapZones.exclusionZones) { + L.polygon(zone, { + color: "#969696", + fillColor: "#303030", + fillOpacity: 1, + }).addTo(exclusionZones); + } +} + +function drawUnculledZones() { + unculledZones.clearLayers(); + + for (const zone of game.unculledZones) { + L.circle(zone.position, { + radius: zone.radius, + color: "#b4ff8c", + stroke: false, + }).addTo(unculledZones); + } +} + function drawInitialMap() { recenterMap(game.mapCenter); drawControlPoints(); @@ -898,6 +937,8 @@ function drawInitialMap() { drawFlightPlans(); drawThreatZones(); drawNavmeshes(); + drawMapZones(); + drawUnculledZones(); } function clearAllLayers() {