diff --git a/game/db.py b/game/db.py index 15024422..d1a1cd31 100644 --- a/game/db.py +++ b/game/db.py @@ -1132,7 +1132,7 @@ ShipDict = typing.Dict[ShipType, int] AirDefenseDict = typing.Dict[AirDefence, int] AssignedUnitsDict = typing.Dict[typing.Type[UnitType], typing.Tuple[int, int]] -TaskForceDict = typing.Dict[typing.Type[Task], AssignedUnitsDict] +TaskForceDict = typing.Dict[typing.Type[MainTask], AssignedUnitsDict] StartingPosition = typing.Optional[typing.Union[ShipGroup, StaticGroup, Airport, Point]] diff --git a/game/game.py b/game/game.py index 385261b8..affcea24 100644 --- a/game/game.py +++ b/game/game.py @@ -1,7 +1,9 @@ from datetime import datetime, timedelta from game.db import REWARDS, PLAYER_BUDGET_BASE, sys +from game.inventory import GlobalAircraftInventory from game.models.game_stats import GameStats +from gen.ato import AirTaskingOrder from gen.flights.ai_flight_planner import FlightPlanner from gen.ground_forces.ai_ground_planner import GroundPlanner from .event import * @@ -78,6 +80,13 @@ class Game: self.jtacs = [] self.savepath = "" + self.blue_ato = AirTaskingOrder() + self.red_ato = AirTaskingOrder() + + self.aircraft_inventory = GlobalAircraftInventory( + self.theater.controlpoints + ) + self.sanitize_sides() @@ -229,10 +238,16 @@ class Game: # Update statistics self.game_stats.update(self) + self.aircraft_inventory.reset() + for cp in self.theater.controlpoints: + self.aircraft_inventory.set_from_control_point(cp) + # Plan flights & combat for next turn self.__culling_points = self.compute_conflicts_position() self.planners = {} self.ground_planners = {} + self.blue_ato.clear() + self.red_ato.clear() for cp in self.theater.controlpoints: if cp.has_runway(): planner = FlightPlanner(cp, self) diff --git a/game/inventory.py b/game/inventory.py new file mode 100644 index 00000000..1d05a473 --- /dev/null +++ b/game/inventory.py @@ -0,0 +1,129 @@ +"""Inventory management APIs.""" +from collections import defaultdict +from typing import Dict, Iterable, Iterator, Set, Tuple + +from dcs.unittype import UnitType + +from gen.flights.flight import Flight + + +class ControlPointAircraftInventory: + """Aircraft inventory for a single control point.""" + + def __init__(self, control_point: "ControlPoint") -> None: + self.control_point = control_point + self.inventory: Dict[UnitType, int] = defaultdict(int) + + def add_aircraft(self, aircraft: UnitType, count: int) -> None: + """Adds aircraft to the inventory. + + Args: + aircraft: The type of aircraft to add. + count: The number of aircraft to add. + """ + self.inventory[aircraft] += count + + def remove_aircraft(self, aircraft: UnitType, count: int) -> None: + """Removes aircraft from the inventory. + + Args: + aircraft: The type of aircraft to remove. + count: The number of aircraft to remove. + + Raises: + ValueError: The control point cannot fulfill the requested number of + aircraft. + """ + available = self.inventory[aircraft] + if available < count: + raise ValueError( + f"Cannot remove {count} {aircraft.id} from " + f"{self.control_point.name}. Only have {available}." + ) + self.inventory[aircraft] -= count + + def available(self, aircraft: UnitType) -> int: + """Returns the number of available aircraft of the given type. + + Args: + aircraft: The type of aircraft to query. + """ + return self.inventory[aircraft] + + @property + def types_available(self) -> Iterator[UnitType]: + """Iterates over all available aircraft types.""" + for aircraft, count in self.inventory.items(): + if count > 0: + yield aircraft + + @property + def all_aircraft(self) -> Iterator[Tuple[UnitType, int]]: + """Iterates over all available aircraft types, including amounts.""" + for aircraft, count in self.inventory.items(): + if count > 0: + yield aircraft, count + + @property + def total_available(self) -> int: + """Returns the total number of aircraft available.""" + # TODO: Remove? + # This probably isn't actually useful. It's used by the AI flight + # planner to determine how many flights of a given type it should + # allocate, but it should probably be making that decision based on the + # number of aircraft available to perform a particular role. + return sum(self.inventory.values()) + + def clear(self) -> None: + """Clears all aircraft from the inventory.""" + self.inventory.clear() + + +class GlobalAircraftInventory: + """Game-wide aircraft inventory.""" + def __init__(self, control_points: Iterable["ControlPoint"]) -> None: + self.inventories: Dict["ControlPoint", ControlPointAircraftInventory] = { + cp: ControlPointAircraftInventory(cp) for cp in control_points + } + + def reset(self) -> None: + """Clears all control points and their inventories.""" + for inventory in self.inventories.values(): + inventory.clear() + + def set_from_control_point(self, control_point: "ControlPoint") -> None: + """Set the control point's aircraft inventory. + + If the inventory for the given control point has already been set for + the turn, it will be overwritten. + """ + inventory = self.inventories[control_point] + for aircraft, count in control_point.base.aircraft.items(): + inventory.add_aircraft(aircraft, count) + + def for_control_point( + self, + control_point: "ControlPoint") -> ControlPointAircraftInventory: + """Returns the inventory specific to the given control point.""" + return self.inventories[control_point] + + @property + def available_types_for_player(self) -> Iterator[UnitType]: + """Iterates over all aircraft types available to the player.""" + seen: Set[UnitType] = set() + for control_point, inventory in self.inventories.items(): + if control_point.captured: + for aircraft in inventory.types_available: + if aircraft not in seen: + seen.add(aircraft) + yield aircraft + + def claim_for_flight(self, flight: Flight) -> None: + """Removes aircraft from the inventory for the given flight.""" + inventory = self.for_control_point(flight.from_cp) + inventory.remove_aircraft(flight.unit_type, flight.count) + + def return_from_flight(self, flight: Flight) -> None: + """Returns a flight's aircraft to the inventory.""" + inventory = self.for_control_point(flight.from_cp) + inventory.add_aircraft(flight.unit_type, flight.count) diff --git a/gen/ato.py b/gen/ato.py new file mode 100644 index 00000000..e82930fe --- /dev/null +++ b/gen/ato.py @@ -0,0 +1,117 @@ +"""Air Tasking Orders. + +The classes of the Air Tasking Order (ATO) define all of the missions that have +been planned, and which aircraft have been assigned to them. Each planned +mission, or "package" is composed of individual flights. The package may contain +dissimilar aircraft performing different roles, but all for the same goal. For +example, the package to strike an enemy airfield may contain an escort flight, +a SEAD flight, and the strike aircraft themselves. CAP packages may contain only +the single CAP flight. +""" +from collections import defaultdict +from dataclasses import dataclass, field +import logging +from typing import Dict, List + +from .flights.flight import Flight, FlightType +from theater.missiontarget import MissionTarget + + +@dataclass(frozen=True) +class Task: + """The main task of a flight or package.""" + + #: The type of task. + task_type: FlightType + + #: The location of the objective. + location: str + + +@dataclass +class Package: + """A mission package.""" + + #: The mission target. Currently can be either a ControlPoint or a + #: TheaterGroundObject (non-ControlPoint map objectives). + target: MissionTarget + + #: The set of flights in the package. + flights: List[Flight] = field(default_factory=list) + + def add_flight(self, flight: Flight) -> None: + """Adds a flight to the package.""" + self.flights.append(flight) + + def remove_flight(self, flight: Flight) -> None: + """Removes a flight from the package.""" + self.flights.remove(flight) + + @property + def package_description(self) -> str: + """Generates a package description based on flight composition.""" + if not self.flights: + return "No mission" + + flight_counts: Dict[FlightType, int] = defaultdict(lambda: 0) + for flight in self.flights: + flight_counts[flight.flight_type] += 1 + + # The package will contain a mix of mission types, but in general we can + # determine the goal of the mission because some mission types are more + # likely to be the main task than others. For example, a package with + # only CAP flights is a CAP package, a flight with CAP and strike is a + # strike package, a flight with CAP and DEAD is a DEAD package, and a + # flight with strike and SEAD is an OCA/Strike package. The type of + # package is determined by the highest priority flight in the package. + task_priorities = [ + FlightType.CAS, + FlightType.STRIKE, + FlightType.ANTISHIP, + FlightType.BAI, + FlightType.EVAC, + FlightType.TROOP_TRANSPORT, + FlightType.RECON, + FlightType.ELINT, + FlightType.DEAD, + FlightType.SEAD, + FlightType.LOGISTICS, + FlightType.INTERCEPTION, + FlightType.TARCAP, + FlightType.CAP, + FlightType.BARCAP, + FlightType.EWAR, + ] + for task in task_priorities: + if flight_counts[task]: + return task.name + + # If we get here, our task_priorities list above is incomplete. Log the + # issue and return the type of *any* flight in the package. + some_mission = next(iter(self.flights)).flight_type + logging.warning(f"Unhandled mission type: {some_mission}") + return some_mission.name + + def __hash__(self) -> int: + # TODO: Far from perfect. Number packages? + return hash(self.target.name) + + +@dataclass +class AirTaskingOrder: + """The entire ATO for one coalition.""" + + #: The set of all planned packages in the ATO. + packages: List[Package] = field(default_factory=list) + + def add_package(self, package: Package) -> None: + """Adds a package to the ATO.""" + self.packages.append(package) + + def remove_package(self, package: Package) -> None: + """Removes a package from the ATO.""" + self.packages.remove(package) + + def clear(self) -> None: + """Removes all packages from the ATO.""" + self.packages.clear() diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 99cf8427..f6a665e4 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1,28 +1,49 @@ import math import operator import random +from typing import Iterable, Iterator, List, Tuple + +from dcs.unittype import FlyingType from game import db from game.data.doctrine import MODERN_DOCTRINE from game.data.radar_db import UNITS_WITH_RADAR -from game.utils import meter_to_feet, nm_to_meter +from game.utils import nm_to_meter from gen import Conflict -from gen.flights.ai_flight_planner_db import INTERCEPT_CAPABLE, CAP_CAPABLE, CAS_CAPABLE, SEAD_CAPABLE, STRIKE_CAPABLE, \ - DRONES -from gen.flights.flight import Flight, FlightType, FlightWaypoint, FlightWaypointType - +from gen.ato import Package +from gen.flights.ai_flight_planner_db import ( + CAP_CAPABLE, + CAS_CAPABLE, + DRONES, + SEAD_CAPABLE, + STRIKE_CAPABLE, +) +from gen.flights.flight import ( + Flight, + FlightType, + FlightWaypoint, + FlightWaypointType, +) +from theater.controlpoint import ControlPoint +from theater.missiontarget import MissionTarget +from theater.theatergroundobject import TheaterGroundObject MISSION_DURATION = 80 +# TODO: Should not be per-control point. +# Packages can frag flights from individual airfields, so we should be planning +# coalition wide rather than per airfield. class FlightPlanner: - def __init__(self, from_cp, game): + def __init__(self, from_cp: ControlPoint, game: "Game") -> None: # TODO : have the flight planner depend on a 'stance' setting : [Defensive, Aggresive... etc] and faction doctrine # TODO : the flight planner should plan package and operations self.from_cp = from_cp self.game = game - self.aircraft_inventory = {} # local copy of the airbase inventory + self.flights: List[Flight] = [] + self.potential_sead_targets: List[Tuple[TheaterGroundObject, int]] = [] + self.potential_strike_targets: List[Tuple[TheaterGroundObject, int]] = [] if from_cp.captured: self.faction = self.game.player_faction @@ -34,240 +55,146 @@ class FlightPlanner: else: self.doctrine = MODERN_DOCTRINE + @property + def aircraft_inventory(self) -> "GlobalAircraftInventory": + return self.game.aircraft_inventory - def reset(self): - """ - Reset the planned flights and available units - """ - self.aircraft_inventory = dict({k: v for k, v in self.from_cp.base.aircraft.items()}) - self.interceptor_flights = [] - self.cap_flights = [] - self.cas_flights = [] - self.strike_flights = [] - self.sead_flights = [] - self.custom_flights = [] + def reset(self) -> None: + """Reset the planned flights and available units.""" self.flights = [] self.potential_sead_targets = [] self.potential_strike_targets = [] - def plan_flights(self): - + def plan_flights(self) -> None: self.reset() self.compute_sead_targets() self.compute_strike_targets() - # The priority is to assign air-superiority fighter or interceptor to interception roles, so they can scramble if there is an attacker - # self.commision_interceptors() + self.commission_cap() + self.commission_cas() + self.commission_sead() + self.commission_strike() + # TODO: Commission anti-ship and intercept. - # Then some CAP patrol for the next 2 hours - self.commision_cap() + def plan_legacy_mission(self, flight: Flight, + location: MissionTarget) -> None: + package = Package(location) + package.add_flight(flight) + if flight.from_cp.captured: + self.game.blue_ato.add_package(package) + else: + self.game.red_ato.add_package(package) + self.flights.append(flight) + self.aircraft_inventory.claim_for_flight(flight) - # Then setup cas - self.commision_cas() + def get_compatible_aircraft(self, candidates: Iterable[FlyingType], + minimum: int) -> List[FlyingType]: + inventory = self.aircraft_inventory.for_control_point(self.from_cp) + return [k for k, v in inventory.all_aircraft if + k in candidates and v >= minimum] - # Then prepare some sead flights if required - self.commision_sead() - - self.commision_strike() - - # TODO : commision ANTISHIP - - def remove_flight(self, index): - try: - flight = self.flights[index] - if flight in self.interceptor_flights: self.interceptor_flights.remove(flight) - if flight in self.cap_flights: self.cap_flights.remove(flight) - if flight in self.cas_flights: self.cas_flights.remove(flight) - if flight in self.strike_flights: self.strike_flights.remove(flight) - if flight in self.sead_flights: self.sead_flights.remove(flight) - if flight in self.custom_flights: self.custom_flights.remove(flight) - self.flights.remove(flight) - except IndexError: + def alloc_aircraft( + self, num_flights: int, flight_size: int, + allowed_types: Iterable[FlyingType]) -> Iterator[FlyingType]: + aircraft = self.get_compatible_aircraft(allowed_types, flight_size) + if not aircraft: return + for _ in range(num_flights): + yield random.choice(aircraft) + aircraft = self.get_compatible_aircraft(allowed_types, flight_size) + if not aircraft: + return - def commision_interceptors(self): - """ - Pick some aircraft to assign them to interception roles - """ + def commission_cap(self) -> None: + """Pick some aircraft to assign them to defensive CAP roles (BARCAP).""" + offset = random.randint(0, 5) + num_caps = MISSION_DURATION // self.doctrine["CAP_EVERY_X_MINUTES"] + for i, aircraft in enumerate(self.alloc_aircraft(num_caps, 2, CAP_CAPABLE)): + flight = Flight(aircraft, 2, self.from_cp, FlightType.CAP) - # At least try to generate one interceptor group - number_of_interceptor_groups = min(max(sum([v for k, v in self.aircraft_inventory.items()]) / 4, self.doctrine["MAX_NUMBER_OF_INTERCEPTION_GROUP"]), 1) - possible_interceptors = [k for k in self.aircraft_inventory.keys() if k in INTERCEPT_CAPABLE] - - if len(possible_interceptors) <= 0: - possible_interceptors = [k for k,v in self.aircraft_inventory.items() if k in CAP_CAPABLE and v >= 2] - - if number_of_interceptor_groups > 0: - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_interceptors}) - for i in range(number_of_interceptor_groups): - try: - unit = random.choice([k for k,v in inventory.items() if v >= 2]) - except IndexError: - break - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, FlightType.INTERCEPTION) - flight.scheduled_in = 1 - flight.points = [] - - self.interceptor_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - def commision_cap(self): - """ - Pick some aircraft to assign them to defensive CAP roles (BARCAP) - """ - - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in CAP_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["CAP_EVERY_X_MINUTES"])): - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, FlightType.CAP) - - flight.points = [] - flight.scheduled_in = offset + i*random.randint(self.doctrine["CAP_EVERY_X_MINUTES"] - 5, self.doctrine["CAP_EVERY_X_MINUTES"] + 5) + flight.scheduled_in = offset + i * random.randint( + self.doctrine["CAP_EVERY_X_MINUTES"] - 5, + self.doctrine["CAP_EVERY_X_MINUTES"] + 5 + ) if len(self._get_cas_locations()) > 0: enemy_cp = random.choice(self._get_cas_locations()) + location = enemy_cp self.generate_frontline_cap(flight, flight.from_cp, enemy_cp) else: + location = flight.from_cp self.generate_barcap(flight, flight.from_cp) - self.cap_flights.append(flight) - self.flights.append(flight) + self.plan_legacy_mission(flight, location) - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v + def commission_cas(self) -> None: + """Pick some aircraft to assign them to CAS.""" + cas_locations = self._get_cas_locations() + if not cas_locations: + return - def commision_cas(self): - """ - Pick some aircraft to assign them to CAS - """ + offset = random.randint(0,5) + num_cas = MISSION_DURATION // self.doctrine["CAS_EVERY_X_MINUTES"] + for i, aircraft in enumerate(self.alloc_aircraft(num_cas, 2, CAS_CAPABLE)): + flight = Flight(aircraft, 2, self.from_cp, FlightType.CAS) + flight.scheduled_in = offset + i * random.randint( + self.doctrine["CAS_EVERY_X_MINUTES"] - 5, + self.doctrine["CAS_EVERY_X_MINUTES"] + 5) + location = random.choice(cas_locations) - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in CAS_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - cas_location = self._get_cas_locations() + self.generate_cas(flight, flight.from_cp, location) + self.plan_legacy_mission(flight, location) - if len(cas_location) > 0: + def commission_sead(self) -> None: + """Pick some aircraft to assign them to SEAD tasks.""" - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["CAS_EVERY_X_MINUTES"])): + if not self.potential_sead_targets: + return - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break + offset = random.randint(0, 5) + num_sead = max( + MISSION_DURATION // self.doctrine["SEAD_EVERY_X_MINUTES"], + len(self.potential_sead_targets)) + for i, aircraft in enumerate(self.alloc_aircraft(num_sead, 2, SEAD_CAPABLE)): + flight = Flight(aircraft, 2, self.from_cp, + random.choice([FlightType.SEAD, FlightType.DEAD])) + flight.scheduled_in = offset + i * random.randint( + self.doctrine["SEAD_EVERY_X_MINUTES"] - 5, + self.doctrine["SEAD_EVERY_X_MINUTES"] + 5) - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, FlightType.CAS) - flight.points = [] - flight.scheduled_in = offset + i * random.randint(self.doctrine["CAS_EVERY_X_MINUTES"] - 5, self.doctrine["CAS_EVERY_X_MINUTES"] + 5) - location = random.choice(cas_location) + location = self.potential_sead_targets[0][0] + self.potential_sead_targets.pop() - self.generate_cas(flight, flight.from_cp, location) + self.generate_sead(flight, location, []) + self.plan_legacy_mission(flight, location) - self.cas_flights.append(flight) - self.flights.append(flight) + def commission_strike(self) -> None: + """Pick some aircraft to assign them to STRIKE tasks.""" + if not self.potential_strike_targets: + return - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v + offset = random.randint(0,5) + num_strike = max( + MISSION_DURATION / self.doctrine["STRIKE_EVERY_X_MINUTES"], + len(self.potential_strike_targets) + ) + for i, aircraft in enumerate(self.alloc_aircraft(num_strike, 2, STRIKE_CAPABLE)): + if aircraft in DRONES: + count = 1 + else: + count = 2 - def commision_sead(self): - """ - Pick some aircraft to assign them to SEAD tasks - """ + flight = Flight(aircraft, count, self.from_cp, FlightType.STRIKE) + flight.scheduled_in = offset + i * random.randint( + self.doctrine["STRIKE_EVERY_X_MINUTES"] - 5, + self.doctrine["STRIKE_EVERY_X_MINUTES"] + 5) - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in SEAD_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) + location = self.potential_strike_targets[0][0] + self.potential_strike_targets.pop(0) - if len(self.potential_sead_targets) > 0: - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["SEAD_EVERY_X_MINUTES"])): - - if len(self.potential_sead_targets) <= 0: - break - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - inventory[unit] = inventory[unit] - 2 - flight = Flight(unit, 2, self.from_cp, random.choice([FlightType.SEAD, FlightType.DEAD])) - - flight.points = [] - flight.scheduled_in = offset + i*random.randint(self.doctrine["SEAD_EVERY_X_MINUTES"] - 5, self.doctrine["SEAD_EVERY_X_MINUTES"] + 5) - - location = self.potential_sead_targets[0][0] - self.potential_sead_targets.pop(0) - - self.generate_sead(flight, location, []) - - self.sead_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v - - - def commision_strike(self): - """ - Pick some aircraft to assign them to STRIKE tasks - """ - possible_aircraft = [k for k, v in self.aircraft_inventory.items() if k in STRIKE_CAPABLE and v >= 2] - inventory = dict({k: v for k, v in self.aircraft_inventory.items() if k in possible_aircraft}) - - if len(self.potential_strike_targets) > 0: - - offset = random.randint(0,5) - for i in range(int(MISSION_DURATION/self.doctrine["STRIKE_EVERY_X_MINUTES"])): - - if len(self.potential_strike_targets) <= 0: - break - - try: - unit = random.choice([k for k, v in inventory.items() if v >= 2]) - except IndexError: - break - - if unit in DRONES: - count = 1 - else: - count = 2 - - inventory[unit] = inventory[unit] - count - flight = Flight(unit, count, self.from_cp, FlightType.STRIKE) - - flight.points = [] - flight.scheduled_in = offset + i*random.randint(self.doctrine["STRIKE_EVERY_X_MINUTES"] - 5, self.doctrine["STRIKE_EVERY_X_MINUTES"] + 5) - - location = self.potential_strike_targets[0][0] - self.potential_strike_targets.pop(0) - - self.generate_strike(flight, location) - - self.strike_flights.append(flight) - self.flights.append(flight) - - # Update inventory - for k, v in inventory.items(): - self.aircraft_inventory[k] = v + self.generate_strike(flight, location) + self.plan_legacy_mission(flight, location) def _get_cas_locations(self): return self._get_cas_locations_for_cp(self.from_cp) @@ -351,18 +278,7 @@ class FlightPlanner: return "-"*40 + "\n" + self.from_cp.name + " planned flights :\n"\ + "-"*40 + "\n" + "\n".join([repr(f) for f in self.flights]) + "\n" + "-"*40 - def get_available_aircraft(self): - base_aircraft_inventory = dict({k: v for k, v in self.from_cp.base.aircraft.items()}) - for f in self.flights: - if f.unit_type in base_aircraft_inventory.keys(): - base_aircraft_inventory[f.unit_type] = base_aircraft_inventory[f.unit_type] - f.count - if base_aircraft_inventory[f.unit_type] <= 0: - del base_aircraft_inventory[f.unit_type] - return base_aircraft_inventory - - - def generate_strike(self, flight, location): - + def generate_strike(self, flight: Flight, location: TheaterGroundObject): flight.flight_type = FlightType.STRIKE ascend = self.generate_ascend_point(flight.from_cp) flight.points.append(ascend) diff --git a/qt_ui/dialogs.py b/qt_ui/dialogs.py new file mode 100644 index 00000000..16920a15 --- /dev/null +++ b/qt_ui/dialogs.py @@ -0,0 +1,60 @@ +"""Application-wide dialog management.""" +from typing import Optional + +from gen.flights.flight import Flight +from theater.missiontarget import MissionTarget +from .models import GameModel, PackageModel +from .windows.mission.QEditFlightDialog import QEditFlightDialog +from .windows.mission.QPackageDialog import ( + QEditPackageDialog, + QNewPackageDialog, +) + + +class Dialog: + """Dialog management singleton. + + Opens dialogs and keeps references to dialog windows so that their creators + do not need to worry about the lifetime of the dialog object, and can open + dialogs without needing to have their own reference to common data like the + game model. + """ + + #: The game model. Is only None before initialization, as the game model + #: itself is responsible for handling the case where no game is loaded. + game_model: Optional[GameModel] = None + + new_package_dialog: Optional[QNewPackageDialog] = None + edit_package_dialog: Optional[QEditPackageDialog] = None + edit_flight_dialog: Optional[QEditFlightDialog] = None + + @classmethod + def set_game(cls, game_model: GameModel) -> None: + """Sets the game model.""" + cls.game_model = game_model + + @classmethod + def open_new_package_dialog(cls, mission_target: MissionTarget): + """Opens the dialog to create a new package with the given target.""" + cls.new_package_dialog = QNewPackageDialog( + cls.game_model.game, + cls.game_model.ato_model, + mission_target + ) + cls.new_package_dialog.show() + + @classmethod + def open_edit_package_dialog(cls, package_model: PackageModel): + """Opens the dialog to edit the given package.""" + cls.edit_package_dialog = QEditPackageDialog( + cls.game_model.game, + cls.game_model.ato_model, + package_model + ) + cls.edit_package_dialog.show() + + @classmethod + def open_edit_flight_dialog(cls, flight: Flight): + """Opens the dialog to edit the given flight.""" + cls.edit_flight_dialog = QEditFlightDialog(cls.game_model.game, flight) + cls.edit_flight_dialog.show() diff --git a/qt_ui/models.py b/qt_ui/models.py new file mode 100644 index 00000000..87d52538 --- /dev/null +++ b/qt_ui/models.py @@ -0,0 +1,268 @@ +"""Qt data models for game objects.""" +from typing import Any, Callable, Dict, Iterator, TypeVar, Optional + +from PySide2.QtCore import ( + QAbstractListModel, + QModelIndex, + Qt, + Signal, +) +from PySide2.QtGui import QIcon + +from game import db +from game.game import Game +from gen.ato import AirTaskingOrder, Package +from gen.flights.flight import Flight +from qt_ui.uiconstants import AIRCRAFT_ICONS +from theater.missiontarget import MissionTarget + + +class DeletableChildModelManager: + """Manages lifetimes for child models. + + Qt's data models don't have a good way of modeling related data aside from + lists, tables, or trees of similar objects. We could build one monolithic + GameModel that tracks all of the data in the game and use the parent/child + relationships of that model to index down into the ATO, packages, flights, + etc, but doing so is error prone because it requires us to manually manage + that relationship tree and keep our own mappings from row/column into + specific members. + + However, creating child models outside of the tree means that removing an + item from the parent will not signal the child's deletion to any views, so + we must track this explicitly. + + Any model which has child data types should use this class to track the + deletion of child models. All child model types must define a signal named + `deleted`. This signal will be emitted when the child model is being + deleted. Any views displaying such data should subscribe to those events and + update their display accordingly. + """ + + #: The type of data owned by models created by this class. + DataType = TypeVar("DataType") + + #: The type of model managed by this class. + ModelType = TypeVar("ModelType") + + ModelDict = Dict[DataType, ModelType] + + def __init__(self, create_model: Callable[[DataType], ModelType]) -> None: + self.create_model = create_model + self.models: DeletableChildModelManager.ModelDict = {} + + def acquire(self, data: DataType) -> ModelType: + """Returns a model for the given child data. + + If a model has already been created for the given data, it will be + returned. The data type must be hashable. + """ + if data in self.models: + return self.models[data] + model = self.create_model(data) + self.models[data] = model + return model + + def release(self, data: DataType) -> None: + """Releases the model matching the given data, if one exists. + + If the given data has had a model created for it, that model will be + deleted and its `deleted` signal will be emitted. + """ + if data in self.models: + model = self.models[data] + del self.models[data] + model.deleted.emit() + + def clear(self) -> None: + """Deletes all managed models.""" + for data in list(self.models.keys()): + self.release(data) + + +class NullListModel(QAbstractListModel): + """Generic empty list model.""" + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return 0 + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + return None + + +class PackageModel(QAbstractListModel): + """The model for an ATO package.""" + + #: Emitted when this package is being deleted from the ATO. + deleted = Signal() + + def __init__(self, package: Package) -> None: + super().__init__() + self.package = package + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.package.flights) + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + flight = self.flight_at_index(index) + if role == Qt.DisplayRole: + return self.text_for_flight(flight) + if role == Qt.DecorationRole: + return self.icon_for_flight(flight) + return None + + @staticmethod + def text_for_flight(flight: Flight) -> str: + """Returns the text that should be displayed for the flight.""" + task = flight.flight_type.name + count = flight.count + name = db.unit_type_name(flight.unit_type) + delay = flight.scheduled_in + origin = flight.from_cp.name + return f"[{task}] {count} x {name} from {origin} in {delay} minutes" + + @staticmethod + def icon_for_flight(flight: Flight) -> Optional[QIcon]: + """Returns the icon that should be displayed for the flight.""" + name = db.unit_type_name(flight.unit_type) + if name in AIRCRAFT_ICONS: + return QIcon(AIRCRAFT_ICONS[name]) + return None + + def add_flight(self, flight: Flight) -> None: + """Adds the given flight to the package.""" + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.package.add_flight(flight) + self.endInsertRows() + + def delete_flight_at_index(self, index: QModelIndex) -> None: + """Removes the flight at the given index from the package.""" + self.delete_flight(self.flight_at_index(index)) + + def delete_flight(self, flight: Flight) -> None: + """Removes the given flight from the package. + + If the flight is using claimed inventory, the caller is responsible for + returning that inventory. + """ + index = self.package.flights.index(flight) + self.beginRemoveRows(QModelIndex(), index, index) + self.package.remove_flight(flight) + self.endRemoveRows() + + def flight_at_index(self, index: QModelIndex) -> Flight: + """Returns the flight located at the given index.""" + return self.package.flights[index.row()] + + @property + def mission_target(self) -> MissionTarget: + """Returns the mission target of the package.""" + package = self.package + target = package.target + return target + + @property + def description(self) -> str: + """Returns the description of the package.""" + return self.package.package_description + + @property + def flights(self) -> Iterator[Flight]: + """Iterates over the flights in the package.""" + for flight in self.package.flights: + yield flight + + +class AtoModel(QAbstractListModel): + """The model for an AirTaskingOrder.""" + + def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: + super().__init__() + self.game = game + self.ato = ato + self.package_models = DeletableChildModelManager(PackageModel) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.ato.packages) + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + if role == Qt.DisplayRole: + package = self.ato.packages[index.row()] + return f"{package.package_description} {package.target.name}" + return None + + def add_package(self, package: Package) -> None: + """Adds a package to the ATO.""" + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.ato.add_package(package) + self.endInsertRows() + + def delete_package_at_index(self, index: QModelIndex) -> None: + """Removes the package at the given index from the ATO.""" + self.delete_package(self.package_at_index(index)) + + def delete_package(self, package: Package) -> None: + """Removes the given package from the ATO.""" + self.package_models.release(package) + index = self.ato.packages.index(package) + self.beginRemoveRows(QModelIndex(), index, index) + self.ato.remove_package(package) + for flight in package.flights: + self.game.aircraft_inventory.return_from_flight(flight) + self.endRemoveRows() + + def package_at_index(self, index: QModelIndex) -> Package: + """Returns the package at the given index.""" + return self.ato.packages[index.row()] + + def replace_from_game(self, game: Optional[Game]) -> None: + """Updates the ATO object to match the updated game object. + + If the game is None (as is the case when no game has been loaded), an + empty ATO will be used. + """ + self.beginResetModel() + self.game = game + self.package_models.clear() + if self.game is not None: + self.ato = game.blue_ato + else: + self.ato = AirTaskingOrder() + self.endResetModel() + + def get_package_model(self, index: QModelIndex) -> PackageModel: + """Returns a model for the package at the given index.""" + return self.package_models.acquire(self.package_at_index(index)) + + @property + def packages(self) -> Iterator[PackageModel]: + """Iterates over all the packages in the ATO.""" + for package in self.ato.packages: + yield self.package_models.acquire(package) + + +class GameModel: + """A model for the Game object. + + This isn't a real Qt data model, but simplifies management of the game and + its ATO objects. + """ + def __init__(self) -> None: + self.game: Optional[Game] = None + # TODO: Add red ATO model, add cheat option to show red flight plan. + self.ato_model = AtoModel(self.game, AirTaskingOrder()) + + def set(self, game: Optional[Game]) -> None: + """Updates the managed Game object. + + The argument will be None when no game has been loaded. In this state, + much of the UI is still visible and needs to handle that behavior. To + simplify that case, the AtoModel will model an empty ATO when no game is + loaded. + """ + self.game = game + self.ato_model.replace_from_game(self.game) diff --git a/qt_ui/widgets/QFlightSizeSpinner.py b/qt_ui/widgets/QFlightSizeSpinner.py new file mode 100644 index 00000000..a2619507 --- /dev/null +++ b/qt_ui/widgets/QFlightSizeSpinner.py @@ -0,0 +1,13 @@ +"""Spin box for selecting the number of aircraft in a flight.""" +from PySide2.QtWidgets import QSpinBox + + +class QFlightSizeSpinner(QSpinBox): + """Spin box for selecting the number of aircraft in a flight.""" + + def __init__(self, min_size: int = 1, max_size: int = 4, + default_size: int = 2) -> None: + super().__init__() + self.setMinimum(min_size) + self.setMaximum(max_size) + self.setValue(default_size) diff --git a/qt_ui/widgets/QLabeledWidget.py b/qt_ui/widgets/QLabeledWidget.py new file mode 100644 index 00000000..88459896 --- /dev/null +++ b/qt_ui/widgets/QLabeledWidget.py @@ -0,0 +1,17 @@ +"""A layout containing a widget with an associated label.""" +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget + + +class QLabeledWidget(QHBoxLayout): + """A layout containing a widget with an associated label. + + Best used for vertical forms, where the given widget is the input and the + label is used to name the input. + """ + + def __init__(self, text: str, widget: QWidget) -> None: + super().__init__() + self.addWidget(QLabel(text)) + self.addStretch() + self.addWidget(widget, alignment=Qt.AlignRight) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 30725095..f2f73b4f 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,16 +1,15 @@ -from PySide2.QtWidgets import QFrame, QHBoxLayout, QPushButton, QVBoxLayout, QGroupBox - -from game import Game -from qt_ui.widgets.QBudgetBox import QBudgetBox -from qt_ui.widgets.QFactionsInfos import QFactionsInfos -from qt_ui.windows.finances.QFinancesMenu import QFinancesMenu -from qt_ui.windows.stats.QStatsWindow import QStatsWindow -from qt_ui.widgets.QTurnCounter import QTurnCounter +from PySide2.QtWidgets import QFrame, QGroupBox, QHBoxLayout, QPushButton import qt_ui.uiconstants as CONST +from game import Game +from game.event import CAP, CAS, FrontlineAttackEvent +from qt_ui.widgets.QBudgetBox import QBudgetBox +from qt_ui.widgets.QFactionsInfos import QFactionsInfos +from qt_ui.widgets.QTurnCounter import QTurnCounter from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from qt_ui.windows.mission.QMissionPlanning import QMissionPlanning from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow +from qt_ui.windows.stats.QStatsWindow import QStatsWindow +from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow class QTopPanel(QFrame): @@ -33,10 +32,10 @@ class QTopPanel(QFrame): self.passTurnButton.setProperty("style", "btn-primary") self.passTurnButton.clicked.connect(self.passTurn) - self.proceedButton = QPushButton("Mission Planning") + self.proceedButton = QPushButton("Take off") self.proceedButton.setIcon(CONST.ICONS["Proceed"]) - self.proceedButton.setProperty("style", "btn-success") - self.proceedButton.clicked.connect(self.proceed) + self.proceedButton.setProperty("style", "start-button") + self.proceedButton.clicked.connect(self.launch_mission) if self.game and self.game.turn == 0: self.proceedButton.setEnabled(False) @@ -100,9 +99,31 @@ class QTopPanel(QFrame): GameUpdateSignal.get_instance().updateGame(self.game) self.proceedButton.setEnabled(True) - def proceed(self): - self.subwindow = QMissionPlanning(self.game) - self.subwindow.show() + def launch_mission(self): + """Finishes planning and waits for mission completion.""" + # TODO: Refactor this nonsense. + game_event = None + for event in self.game.events: + if isinstance(event, + FrontlineAttackEvent) and event.is_player_attacking: + game_event = event + if game_event is None: + game_event = FrontlineAttackEvent( + self.game, + self.game.theater.controlpoints[0], + self.game.theater.controlpoints[0], + self.game.theater.controlpoints[0].position, + self.game.player_name, + self.game.enemy_name) + game_event.is_awacs_enabled = True + game_event.ca_slots = 1 + game_event.departure_cp = self.game.theater.controlpoints[0] + game_event.player_attacking({CAS: {}, CAP: {}}) + game_event.depart_from = self.game.theater.controlpoints[0] + + self.game.initiate_event(game_event) + waiting = QWaitingForMissionResultWindow(game_event, self.game) + waiting.show() def budget_update(self, game:Game): self.budgetBox.setGame(game) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py new file mode 100644 index 00000000..bee61a43 --- /dev/null +++ b/qt_ui/widgets/ato.py @@ -0,0 +1,249 @@ +"""Widgets for displaying air tasking orders.""" +import logging +from typing import Optional + +from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize, Qt +from PySide2.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QListView, + QPushButton, + QSplitter, + QVBoxLayout, +) + +from gen.ato import Package +from gen.flights.flight import Flight +from ..models import AtoModel, GameModel, NullListModel, PackageModel + + +class QFlightList(QListView): + """List view for displaying the flights of a package.""" + + def __init__(self, model: Optional[PackageModel]) -> None: + super().__init__() + self.package_model = model + self.set_package(model) + self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + + def set_package(self, model: Optional[PackageModel]) -> None: + """Sets the package model to display.""" + if model is None: + self.disconnect_model() + else: + self.package_model = model + self.setModel(model) + # noinspection PyUnresolvedReferences + model.deleted.connect(self.disconnect_model) + self.selectionModel().setCurrentIndex( + model.index(0, 0, QModelIndex()), + QItemSelectionModel.Select + ) + + def disconnect_model(self) -> None: + """Clears the listview of any model attachments. + + Displays an empty list until set_package is called with a valid model. + """ + model = self.model() + if model is not None and isinstance(model, PackageModel): + model.deleted.disconnect(self.disconnect_model) + self.setModel(NullListModel()) + + @property + def selected_item(self) -> Optional[Flight]: + """Returns the selected flight, if any.""" + index = self.currentIndex() + if not index.isValid(): + return None + return self.package_model.flight_at_index(index) + + +class QFlightPanel(QGroupBox): + """The flight display portion of the ATO panel. + + Displays the flights assigned to the selected package, and includes edit and + delete buttons for flight management. + """ + + def __init__(self, game_model: GameModel, + package_model: Optional[PackageModel] = None) -> None: + super().__init__("Flights") + self.game_model = game_model + self.package_model = package_model + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.flight_list = QFlightList(package_model) + self.vbox.addWidget(self.flight_list) + + self.button_row = QHBoxLayout() + self.vbox.addLayout(self.button_row) + + self.edit_button = QPushButton("Edit") + self.edit_button.clicked.connect(self.on_edit) + self.button_row.addWidget(self.edit_button) + + self.delete_button = QPushButton("Delete") + # noinspection PyTypeChecker + self.delete_button.setProperty("style", "btn-danger") + self.delete_button.clicked.connect(self.on_delete) + self.button_row.addWidget(self.delete_button) + + self.selection_changed.connect(self.on_selection_changed) + self.on_selection_changed() + + def set_package(self, model: Optional[PackageModel]) -> None: + """Sets the package model to display.""" + self.package_model = model + self.flight_list.set_package(model) + self.on_selection_changed() + + @property + def selection_changed(self): + """Returns the signal emitted when the flight selection changes.""" + return self.flight_list.selectionModel().selectionChanged + + def on_selection_changed(self) -> None: + """Updates the status of the edit and delete buttons.""" + index = self.flight_list.currentIndex() + enabled = index.isValid() + self.edit_button.setEnabled(enabled) + self.delete_button.setEnabled(enabled) + + def on_edit(self) -> None: + """Opens the flight edit dialog.""" + index = self.flight_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot edit flight when no flight is selected.") + return + from qt_ui.dialogs import Dialog + Dialog.open_edit_flight_dialog( + self.package_model.flight_at_index(index) + ) + + def on_delete(self) -> None: + """Removes the selected flight from the package.""" + index = self.flight_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot delete flight when no flight is selected.") + return + self.game_model.game.aircraft_inventory.return_from_flight( + self.flight_list.selected_item) + self.package_model.delete_flight_at_index(index) + + +class QPackageList(QListView): + """List view for displaying the packages of an ATO.""" + + def __init__(self, model: AtoModel) -> None: + super().__init__() + self.ato_model = model + self.setModel(model) + self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + + @property + def selected_item(self) -> Optional[Package]: + """Returns the selected package, if any.""" + index = self.currentIndex() + if not index.isValid(): + return None + return self.ato_model.package_at_index(index) + + +class QPackagePanel(QGroupBox): + """The package display portion of the ATO panel. + + Displays the package assigned to the player's ATO, and includes edit and + delete buttons for package management. + """ + + def __init__(self, model: AtoModel) -> None: + super().__init__("Packages") + self.ato_model = model + self.ato_model.layoutChanged.connect(self.on_selection_changed) + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.package_list = QPackageList(self.ato_model) + self.vbox.addWidget(self.package_list) + + self.button_row = QHBoxLayout() + self.vbox.addLayout(self.button_row) + + self.edit_button = QPushButton("Edit") + self.edit_button.clicked.connect(self.on_edit) + self.button_row.addWidget(self.edit_button) + + self.delete_button = QPushButton("Delete") + # noinspection PyTypeChecker + self.delete_button.setProperty("style", "btn-danger") + self.delete_button.clicked.connect(self.on_delete) + self.button_row.addWidget(self.delete_button) + + self.selection_changed.connect(self.on_selection_changed) + self.on_selection_changed() + + @property + def selection_changed(self): + """Returns the signal emitted when the flight selection changes.""" + return self.package_list.selectionModel().selectionChanged + + def on_selection_changed(self) -> None: + """Updates the status of the edit and delete buttons.""" + index = self.package_list.currentIndex() + enabled = index.isValid() + self.edit_button.setEnabled(enabled) + self.delete_button.setEnabled(enabled) + + def on_edit(self) -> None: + """Opens the package edit dialog.""" + index = self.package_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot edit package when no package is selected.") + return + from qt_ui.dialogs import Dialog + Dialog.open_edit_package_dialog(self.ato_model.get_package_model(index)) + + def on_delete(self) -> None: + """Removes the package from the ATO.""" + index = self.package_list.currentIndex() + if not index.isValid(): + logging.error(f"Cannot delete package when no package is selected.") + return + self.ato_model.delete_package_at_index(index) + + +class QAirTaskingOrderPanel(QSplitter): + """A split panel for displaying the packages and flights of an ATO. + + Used as the left-bar of the main UI. The top half of the panel displays the + packages of the player's ATO, and the bottom half displays the flights of + the selected package. + """ + def __init__(self, game_model: GameModel) -> None: + super().__init__(Qt.Vertical) + self.ato_model = game_model.ato_model + + self.package_panel = QPackagePanel(self.ato_model) + self.package_panel.selection_changed.connect(self.on_package_change) + self.ato_model.rowsInserted.connect(self.on_package_change) + self.addWidget(self.package_panel) + + self.flight_panel = QFlightPanel(game_model) + self.addWidget(self.flight_panel) + + def on_package_change(self) -> None: + """Sets the newly selected flight for display in the bottom panel.""" + index = self.package_panel.package_list.currentIndex() + if index.isValid(): + self.flight_panel.set_package( + self.ato_model.get_package_model(index) + ) + else: + self.flight_panel.set_package(None) diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py new file mode 100644 index 00000000..1f490e4d --- /dev/null +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -0,0 +1,16 @@ +"""Combo box for selecting aircraft types.""" +from typing import Iterable + +from PySide2.QtWidgets import QComboBox + +from dcs.planes import PlaneType + + +class QAircraftTypeSelector(QComboBox): + """Combo box for selecting among the given aircraft types.""" + + def __init__(self, aircraft_types: Iterable[PlaneType]) -> None: + super().__init__() + for aircraft in aircraft_types: + self.addItem(f"{aircraft.id}", userData=aircraft) + self.model().sort(0) diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py new file mode 100644 index 00000000..9a04df68 --- /dev/null +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -0,0 +1,22 @@ +"""Combo box for selecting a flight's task type.""" +from PySide2.QtWidgets import QComboBox + +from gen.flights.flight import FlightType + + +class QFlightTypeComboBox(QComboBox): + """Combo box for selecting a flight task type.""" + + def __init__(self) -> None: + super().__init__() + self.addItem("CAP [Combat Air Patrol]", userData=FlightType.CAP) + self.addItem("BARCAP [Barrier Combat Air Patrol]", userData=FlightType.BARCAP) + self.addItem("TARCAP [Target Combat Air Patrol]", userData=FlightType.TARCAP) + self.addItem("INTERCEPT [Interception]", userData=FlightType.INTERCEPTION) + self.addItem("CAS [Close Air Support]", userData=FlightType.CAS) + self.addItem("BAI [Battlefield Interdiction]", userData=FlightType.BAI) + self.addItem("SEAD [Suppression of Enemy Air Defenses]", userData=FlightType.SEAD) + self.addItem("DEAD [Destruction of Enemy Air Defenses]", userData=FlightType.DEAD) + self.addItem("STRIKE [Strike]", userData=FlightType.STRIKE) + self.addItem("ANTISHIP [Antiship Attack]", userData=FlightType.ANTISHIP) + self.model().sort(0) diff --git a/qt_ui/widgets/combos/QOriginAirfieldSelector.py b/qt_ui/widgets/combos/QOriginAirfieldSelector.py new file mode 100644 index 00000000..b0995a6b --- /dev/null +++ b/qt_ui/widgets/combos/QOriginAirfieldSelector.py @@ -0,0 +1,41 @@ +"""Combo box for selecting a departure airfield.""" +from typing import Iterable + +from PySide2.QtWidgets import QComboBox + +from dcs.planes import PlaneType +from game.inventory import GlobalAircraftInventory +from theater.controlpoint import ControlPoint + + +class QOriginAirfieldSelector(QComboBox): + """A combo box for selecting a flight's departure airfield. + + The combo box will automatically be populated with all departure airfields + that have unassigned inventory of the given aircraft type. + """ + + def __init__(self, global_inventory: GlobalAircraftInventory, + origins: Iterable[ControlPoint], + aircraft: PlaneType) -> None: + super().__init__() + self.global_inventory = global_inventory + self.origins = list(origins) + self.aircraft = aircraft + self.rebuild_selector() + + def change_aircraft(self, aircraft: PlaneType) -> None: + if self.aircraft == aircraft: + return + self.aircraft = aircraft + self.rebuild_selector() + + def rebuild_selector(self) -> None: + self.clear() + for origin in self.origins: + inventory = self.global_inventory.for_control_point(origin) + available = inventory.available(self.aircraft) + if available: + self.addItem(f"{origin.name} ({available} available)", origin) + self.model().sort(0) + self.update() diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 40109381..69723619 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -17,6 +17,7 @@ from game import Game, db from game.data.radar_db import UNITS_WITH_RADAR from gen import Conflict from gen.flights.flight import Flight +from qt_ui.models import GameModel from qt_ui.widgets.map.QLiberationScene import QLiberationScene from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject @@ -37,9 +38,10 @@ class QLiberationMap(QGraphicsView): "flight_paths": False } - def __init__(self, game: Game): + def __init__(self, game_model: GameModel): super(QLiberationMap, self).__init__() QLiberationMap.instance = self + self.game_model = game_model self.frontline_vector_cache = {} @@ -50,7 +52,7 @@ class QLiberationMap(QGraphicsView): self.factorized = 1 self.init_scene() self.connectSignals() - self.setGame(game) + self.setGame(game_model.game) def init_scene(self): scene = QLiberationScene(self) @@ -129,8 +131,10 @@ class QLiberationMap(QGraphicsView): 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)) + 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]) @@ -185,11 +189,9 @@ class QLiberationMap(QGraphicsView): text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) def draw_flight_plans(self, scene) -> None: - for cp in self.game.theater.controlpoints: - if cp.id in self.game.planners: - planner = self.game.planners[cp.id] - for flight in planner.flights: - self.draw_flight_plan(scene, flight) + for package in self.game_model.ato_model.packages: + for flight in package.flights: + self.draw_flight_plan(scene, flight) def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight) -> None: is_player = flight.from_cp.captured diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index a006cb5a..f5b2e1c4 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -1,29 +1,23 @@ from typing import Optional from PySide2.QtGui import QColor, QPainter -from PySide2.QtWidgets import ( - QAction, - QGraphicsSceneContextMenuEvent, - QMenu, -) import qt_ui.uiconstants as const -from game import Game +from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 from theater import ControlPoint from .QMapObject import QMapObject class QMapControlPoint(QMapObject): - def __init__(self, parent, x: float, y: float, w: float, h: float, - model: ControlPoint, game: Game) -> None: - super().__init__(x, y, w, h) - self.model = model - self.game = game + 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.model.name) + self.setToolTip(self.control_point.name) self.base_details_dialog: Optional[QBaseMenu2] = None def paint(self, painter, option, widget=None) -> None: @@ -33,7 +27,7 @@ class QMapControlPoint(QMapObject): painter.setBrush(self.brush_color) painter.setPen(self.pen_color) - if self.model.has_runway(): + if self.control_point.has_runway(): if self.isUnderMouse(): painter.setBrush(const.COLORS["white"]) painter.setPen(self.pen_color) @@ -44,22 +38,9 @@ class QMapControlPoint(QMapObject): # Either don't draw them at all, or perhaps use a sunk ship icon. painter.restore() - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: - if self.model.captured: - text = "Open base menu" - else: - text = "Open intel menu" - - open_menu = QAction(text) - open_menu.triggered.connect(self.on_click) - - menu = QMenu("Menu", self.parent) - menu.addAction(open_menu) - menu.exec_(event.screenPos()) - @property def brush_color(self) -> QColor: - if self.model.captured: + if self.control_point.captured: return const.COLORS["blue"] else: return const.COLORS["super_red"] @@ -68,10 +49,17 @@ class QMapControlPoint(QMapObject): 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.model, - self.game + self.control_point, + self.game_model ) self.base_details_dialog.show() diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index 89d1bf07..1ed9f3d2 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -14,11 +14,12 @@ from .QMapObject import QMapObject class QMapGroundObject(QMapObject): def __init__(self, parent, x: float, y: float, w: float, h: float, - cp: ControlPoint, model: TheaterGroundObject, game: Game, + control_point: ControlPoint, + ground_object: TheaterGroundObject, game: Game, buildings: Optional[List[TheaterGroundObject]] = None) -> None: - super().__init__(x, y, w, h) - self.model = model - self.cp = cp + 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) @@ -26,21 +27,20 @@ class QMapGroundObject(QMapObject): self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) self.ground_object_dialog: Optional[QGroundObjectMenu] = None - if len(self.model.groups) > 0: + if self.ground_object.groups: units = {} - for g in self.model.groups: - print(g) + 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 - tooltip = "[" + self.model.obj_name + "]" + "\n" + tooltip = "[" + self.ground_object.obj_name + "]" + "\n" for unit in units.keys(): tooltip = tooltip + str(unit) + "x" + str(units[unit]) + "\n" self.setToolTip(tooltip[:-1]) else: - tooltip = "[" + self.model.obj_name + "]" + "\n" + tooltip = "[" + self.ground_object.obj_name + "]" + "\n" for building in buildings: if not building.is_dead: tooltip = tooltip + str(building.dcs_identifier) + "\n" @@ -53,20 +53,20 @@ class QMapGroundObject(QMapObject): if self.parent.get_display_rule("go"): painter.save() - cat = self.model.category - if cat == "aa" and self.model.sea_object: + cat = self.ground_object.category + if cat == "aa" and self.ground_object.sea_object: cat = "ship" rect = QRect(option.rect.x() + 2, option.rect.y(), option.rect.width() - 2, option.rect.height()) - is_dead = self.model.is_dead + is_dead = self.ground_object.is_dead for building in self.buildings: if not building.is_dead: is_dead = False break - if not is_dead and not self.cp.captured: + if not is_dead and not self.control_point.captured: painter.drawPixmap(rect, const.ICONS[cat + enemy_icons]) elif not is_dead: painter.drawPixmap(rect, const.ICONS[cat + player_icons]) @@ -80,7 +80,7 @@ class QMapGroundObject(QMapObject): units_alive = 0 units_dead = 0 - if len(self.model.groups) == 0: + if len(self.ground_object.groups) == 0: for building in self.buildings: if building.dcs_identifier in FORTIFICATION_BUILDINGS: continue @@ -89,7 +89,7 @@ class QMapGroundObject(QMapObject): else: units_alive += 1 - for g in self.model.groups: + for g in self.ground_object.groups: units_alive += len(g.units) if hasattr(g, "units_losts"): units_dead += len(g.units_losts) @@ -106,9 +106,9 @@ class QMapGroundObject(QMapObject): def on_click(self) -> None: self.ground_object_dialog = QGroundObjectMenu( self.window(), - self.model, + self.ground_object, self.buildings, - self.cp, + 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 index 1e29c7dd..c98cce5e 100644 --- a/qt_ui/widgets/map/QMapObject.py +++ b/qt_ui/widgets/map/QMapObject.py @@ -1,11 +1,20 @@ """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 theater.missiontarget import MissionTarget + class QMapObject(QGraphicsRectItem): """Base class for objects drawn on the game map. @@ -13,8 +22,12 @@ class QMapObject(QGraphicsRectItem): 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): + + 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): @@ -24,5 +37,39 @@ class QMapObject(QGraphicsRectItem): if event.button() == Qt.LeftButton: self.on_click() + 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) + + new_package_action = QAction(f"New package") + new_package_action.triggered.connect(self.open_new_package_dialog) + menu.addAction(new_package_action) + + 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/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index da50e03f..f9daed62 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -1,22 +1,36 @@ import logging import sys import webbrowser +from typing import Optional from PySide2.QtCore import Qt from PySide2.QtGui import QIcon -from PySide2.QtWidgets import QWidget, QVBoxLayout, QMainWindow, QAction, QMessageBox, QDesktopWidget, \ - QSplitter, QFileDialog +from PySide2.QtWidgets import ( + QAction, + QDesktopWidget, + QFileDialog, + QMainWindow, + QMessageBox, + QSplitter, + QVBoxLayout, + QWidget, +) import qt_ui.uiconstants as CONST from game import Game +from game.inventory import GlobalAircraftInventory +from qt_ui.dialogs import Dialog +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 QLiberationMap -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal, DebriefingSignal +from qt_ui.windows.GameUpdateSignal import DebriefingSignal, GameUpdateSignal from qt_ui.windows.QDebriefingWindow import QDebriefingWindow -from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard from qt_ui.windows.infos.QInfoPanel import QInfoPanel -from qt_ui.windows.preferences.QLiberationPreferencesWindow import QLiberationPreferencesWindow +from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard +from qt_ui.windows.preferences.QLiberationPreferencesWindow import \ + QLiberationPreferencesWindow from userdata import persistency @@ -25,6 +39,10 @@ class QLiberationWindow(QMainWindow): def __init__(self): super(QLiberationWindow, self).__init__() + self.game: Optional[Game] = None + self.game_model = GameModel() + Dialog.set_game(self.game_model) + self.ato_panel = None self.info_panel = None self.setGame(persistency.restore_game()) @@ -44,16 +62,19 @@ class QLiberationWindow(QMainWindow): self.setGeometry(0, 0, screen.width(), screen.height()) self.setWindowState(Qt.WindowMaximized) - def initUi(self): - - self.liberation_map = QLiberationMap(self.game) + self.ato_panel = QAirTaskingOrderPanel(self.game_model) + self.liberation_map = QLiberationMap(self.game_model) self.info_panel = QInfoPanel(self.game) hbox = QSplitter(Qt.Horizontal) - hbox.addWidget(self.info_panel) - hbox.addWidget(self.liberation_map) - hbox.setSizes([2, 8]) + vbox = QSplitter(Qt.Vertical) + hbox.addWidget(self.ato_panel) + hbox.addWidget(vbox) + vbox.addWidget(self.liberation_map) + vbox.addWidget(self.info_panel) + hbox.setSizes([100, 600]) + vbox.setSizes([600, 100]) vbox = QVBoxLayout() vbox.setMargin(0) @@ -210,10 +231,11 @@ class QLiberationWindow(QMainWindow): def exit(self): sys.exit(0) - def setGame(self, game: Game): + def setGame(self, game: Optional[Game]): self.game = game if self.info_panel: self.info_panel.setGame(game) + self.game_model.set(self.game) def showAboutDialog(self): text = "