diff --git a/game/db.py b/game/db.py index f2b94b54..003bbcc4 100644 --- a/game/db.py +++ b/game/db.py @@ -1142,7 +1142,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..29deb1f4 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1,28 +1,47 @@ 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 import ControlPoint, FrontLine, MissionTarget, 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,249 +53,155 @@ 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()) - self.generate_frontline_cap(flight, flight.from_cp, enemy_cp) + location = random.choice(self._get_cas_locations()) + self.generate_frontline_cap(flight, location) 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, 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: + self.generate_strike(flight, location) + self.plan_legacy_mission(flight, location) - 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 - - def _get_cas_locations(self): + def _get_cas_locations(self) -> List[FrontLine]: return self._get_cas_locations_for_cp(self.from_cp) - def _get_cas_locations_for_cp(self, for_cp): + @staticmethod + def _get_cas_locations_for_cp(for_cp: ControlPoint) -> List[FrontLine]: cas_locations = [] for cp in for_cp.connected_points: if cp.captured != for_cp.captured: - cas_locations.append(cp) + cas_locations.append(FrontLine(for_cp, cp)) return cas_locations def compute_strike_targets(self): @@ -351,18 +276,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) @@ -512,17 +426,17 @@ class FlightPlanner: rtb = self.generate_rtb_waypoint(flight.from_cp) flight.points.append(rtb) + def generate_frontline_cap(self, flight: Flight, + front_line: FrontLine) -> None: + """Generate a CAP flight plan for the given front line. - def generate_frontline_cap(self, flight, ally_cp, enemy_cp): - """ - Generate a cap flight for the frontline between ally_cp and enemy cp in order to ensure air superiority and - protect friendly CAP airbase :param flight: Flight to setup - :param ally_cp: CP to protect - :param enemy_cp: Enemy connected cp + :param front_line: Front line to protect. """ + ally_cp, enemy_cp = front_line.control_points flight.flight_type = FlightType.CAP - patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0], self.doctrine["PATROL_ALT_RANGE"][1]) + patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0], + self.doctrine["PATROL_ALT_RANGE"][1]) # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector(ally_cp, enemy_cp, self.game.theater) @@ -663,19 +577,21 @@ class FlightPlanner: rtb = self.generate_rtb_waypoint(flight.from_cp) flight.points.append(rtb) + def generate_cas(self, flight: Flight, front_line: FrontLine) -> None: + """Generate a CAS flight plan for the given target. - def generate_cas(self, flight, from_cp, location): - """ - Generate a CAS flight at a given location :param flight: Flight to setup - :param location: Location of the CAS targets + :param front_line: Front line containing CAS targets. """ + from_cp, location = front_line.control_points is_helo = hasattr(flight.unit_type, "helicopter") and flight.unit_type.helicopter cap_alt = 1000 flight.points = [] flight.flight_type = FlightType.CAS - ingress, heading, distance = Conflict.frontline_vector(from_cp, location, self.game.theater) + ingress, heading, distance = Conflict.frontline_vector( + from_cp, location, self.game.theater + ) center = ingress.point_from_heading(heading, distance / 2) egress = ingress.point_from_heading(heading, distance) 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..8da41217 --- /dev/null +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -0,0 +1,105 @@ +"""Combo box for selecting a flight's task type.""" +import logging +from typing import Iterator + +from PySide2.QtWidgets import QComboBox + +from gen.flights.flight import FlightType +from theater import ( + ConflictTheater, + ControlPoint, + FrontLine, + MissionTarget, + TheaterGroundObject, +) + + +class QFlightTypeComboBox(QComboBox): + """Combo box for selecting a flight task type.""" + + COMMON_ENEMY_MISSIONS = [ + FlightType.TARCAP, + FlightType.SEAD, + FlightType.DEAD, + # TODO: FlightType.ELINT, + # TODO: FlightType.ESCORT, + # TODO: FlightType.EWAR, + # TODO: FlightType.RECON, + ] + + FRIENDLY_AIRBASE_MISSIONS = [ + FlightType.CAP, + # TODO: FlightType.INTERCEPTION + # TODO: FlightType.LOGISTICS + ] + + FRIENDLY_CARRIER_MISSIONS = [ + FlightType.BARCAP, + # TODO: FlightType.INTERCEPTION + # TODO: Buddy tanking for the A-4? + # TODO: Rescue chopper? + # TODO: Inter-ship logistics? + ] + + ENEMY_CARRIER_MISSIONS = [ + FlightType.TARCAP, + # TODO: FlightType.ANTISHIP + # TODO: FlightType.ESCORT, + ] + + ENEMY_AIRBASE_MISSIONS = [ + # TODO: FlightType.STRIKE + ] + COMMON_ENEMY_MISSIONS + + FRIENDLY_GROUND_OBJECT_MISSIONS = [ + FlightType.CAP, + # TODO: FlightType.LOGISTICS + # TODO: FlightType.TROOP_TRANSPORT + ] + + ENEMY_GROUND_OBJECT_MISSIONS = [ + FlightType.STRIKE, + ] + COMMON_ENEMY_MISSIONS + + FRONT_LINE_MISSIONS = [ + FlightType.CAS, + # TODO: FlightType.TROOP_TRANSPORT + # TODO: FlightType.EVAC + ] + COMMON_ENEMY_MISSIONS + + # TODO: Add BAI missions after we have useful BAI targets. + + def __init__(self, theater: ConflictTheater, target: MissionTarget) -> None: + super().__init__() + self.theater = theater + self.target = target + for mission_type in self.mission_types_for_target(): + self.addItem(mission_type.name, userData=mission_type) + + def mission_types_for_target(self) -> Iterator[FlightType]: + if isinstance(self.target, ControlPoint): + friendly = self.target.captured + fleet = self.target.is_fleet + if friendly: + if fleet: + yield from self.FRIENDLY_CARRIER_MISSIONS + else: + yield from self.FRIENDLY_AIRBASE_MISSIONS + else: + if fleet: + yield from self.ENEMY_CARRIER_MISSIONS + else: + yield from self.ENEMY_AIRBASE_MISSIONS + elif isinstance(self.target, TheaterGroundObject): + # TODO: Filter more based on the category. + friendly = self.target.parent_control_point(self.theater).captured + if friendly: + yield from self.FRIENDLY_GROUND_OBJECT_MISSIONS + else: + yield from self.ENEMY_GROUND_OBJECT_MISSIONS + elif isinstance(self.target, FrontLine): + yield from self.FRONT_LINE_MISSIONS + else: + logging.error( + f"Unhandled target type: {self.target.__class__.__name__}" + ) 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/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py new file mode 100644 index 00000000..f1425893 --- /dev/null +++ b/qt_ui/widgets/map/QFrontLine.py @@ -0,0 +1,82 @@ +"""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 qt_ui.dialogs import Dialog +from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog +from theater.missiontarget import MissionTarget + + +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: MissionTarget) -> None: + super().__init__(x1, y1, x2, y2) + self.mission_target = mission_target + 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) + + 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/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 41194ca0..a36382b5 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,26 +1,33 @@ import typing -from typing import Dict +from typing import Dict, Tuple -from PySide2 import QtCore -from PySide2.QtCore import Qt, QRect, QPointF -from PySide2.QtGui import QPixmap, QBrush, QColor, QWheelEvent, QPen, QFont -from PySide2.QtWidgets import QGraphicsView, QFrame, QGraphicsOpacityEffect +from PySide2.QtCore import Qt +from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent +from PySide2.QtWidgets import ( + QFrame, + QGraphicsOpacityEffect, + QGraphicsScene, + QGraphicsView, +) from dcs import Point from dcs.mapping import point_from_heading import qt_ui.uiconstants as CONST from game import Game, db from game.data.radar_db import UNITS_WITH_RADAR -from game.event import UnitsDeliveryEvent, Event, ControlPointType 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 +from qt_ui.widgets.map.QFrontLine import QFrontLine from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from theater import ControlPoint +from theater import ControlPoint, FrontLine class QLiberationMap(QGraphicsView): + WAYPOINT_SIZE = 4 instance = None display_rules: Dict[str, bool] = { @@ -32,11 +39,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.frontline_vector_cache = {} + self.game_model = game_model self.setMinimumSize(800,600) self.setMaximumHeight(2160) @@ -45,7 +51,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) @@ -124,8 +130,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]) @@ -168,38 +176,8 @@ class QLiberationMap(QGraphicsView): if self.get_display_rule("lines"): self.scene_create_lines_for_cp(cp, playerColor, enemyColor) - for cp in self.game.theater.controlpoints: - - if cp.captured: - pen = QPen(brush=CONST.COLORS[playerColor]) - brush = CONST.COLORS[playerColor+"_transparent"] - - flight_path_pen = QPen(brush=CONST.COLORS[playerColor]) - flight_path_pen.setColor(CONST.COLORS[playerColor]) - - else: - pen = QPen(brush=CONST.COLORS[enemyColor]) - brush = CONST.COLORS[enemyColor+"_transparent"] - - flight_path_pen = QPen(brush=CONST.COLORS[enemyColor]) - flight_path_pen.setColor(CONST.COLORS[enemyColor]) - - flight_path_pen.setWidth(1) - flight_path_pen.setStyle(Qt.DashDotLine) - - pos = self._transform_point(cp.position) - if self.get_display_rule("flight_paths"): - if cp.id in self.game.planners.keys(): - planner = self.game.planners[cp.id] - for flight in planner.flights: - scene.addEllipse(pos[0], pos[1], 4, 4) - prev_pos = list(pos) - for point in flight.points: - new_pos = self._transform_point(Point(point.x, point.y)) - scene.addLine(prev_pos[0]+2, prev_pos[1]+2, new_pos[0]+2, new_pos[1]+2, flight_path_pen) - scene.addEllipse(new_pos[0], new_pos[1], 4, 4, pen, brush) - prev_pos = list(new_pos) - scene.addLine(prev_pos[0] + 2, prev_pos[1] + 2, pos[0] + 2, pos[1] + 2, flight_path_pen) + if self.get_display_rule("flight_paths"): + self.draw_flight_plans(scene) for cp in self.game.theater.controlpoints: pos = self._transform_point(cp.position) @@ -209,6 +187,40 @@ class QLiberationMap(QGraphicsView): text.setDefaultTextColor(Qt.white) text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) + def draw_flight_plans(self, scene) -> None: + 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 + pos = self._transform_point(flight.from_cp.position) + + self.draw_waypoint(scene, pos, is_player) + prev_pos = tuple(pos) + for point in flight.points: + new_pos = self._transform_point(Point(point.x, point.y)) + self.draw_flight_path(scene, prev_pos, new_pos, is_player) + self.draw_waypoint(scene, new_pos, is_player) + prev_pos = tuple(new_pos) + self.draw_flight_path(scene, prev_pos, pos, is_player) + + def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], + player: bool) -> None: + waypoint_pen = self.waypoint_pen(player) + waypoint_brush = self.waypoint_brush(player) + scene.addEllipse(position[0], position[1], self.WAYPOINT_SIZE, + self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush) + + def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], + pos1: Tuple[int, int], player: bool): + flight_path_pen = self.flight_path_pen(player) + # Draw the line to the *middle* of the waypoint. + offset = self.WAYPOINT_SIZE // 2 + scene.addLine(pos0[0] + offset, pos0[1] + offset, + pos1[0] + offset, pos1[1] + offset, + flight_path_pen) + def scene_create_lines_for_cp(self, cp: ControlPoint, playerColor, enemyColor): scene = self.scene() pos = self._transform_point(cp.position) @@ -234,31 +246,12 @@ class QLiberationMap(QGraphicsView): p1 = point_from_heading(pos2[0], pos2[1], h+180, 25) p2 = point_from_heading(pos2[0], pos2[1], h, 25) - frontline_pen = QPen(brush=CONST.COLORS["bright_red"]) - frontline_pen.setColor(CONST.COLORS["orange"]) - frontline_pen.setWidth(8) - scene.addLine(p1[0], p1[1], p2[0], p2[1], pen=frontline_pen) + scene.addItem(QFrontLine(p1[0], p1[1], p2[0], p2[1], + FrontLine(cp, connected_cp))) else: scene.addLine(pos[0], pos[1], pos2[0], pos2[1], pen=pen) - def _frontline_vector(self, from_cp: ControlPoint, to_cp: ControlPoint): - # Cache mechanism to avoid performing frontline vector computation on every frame - key = str(from_cp.id) + "_" + str(to_cp.id) - if key in self.frontline_vector_cache: - return self.frontline_vector_cache[key] - else: - frontline = Conflict.frontline_vector(from_cp, to_cp, self.game.theater) - self.frontline_vector_cache[key] = frontline - return frontline - - def _frontline_center(self, from_cp: ControlPoint, to_cp: ControlPoint) -> typing.Optional[Point]: - frontline_vector = self._frontline_vector(from_cp, to_cp) - if frontline_vector: - return frontline_vector[0].point_from_heading(frontline_vector[1], frontline_vector[2]/2) - else: - return None - def wheelEvent(self, event: QWheelEvent): if event.angleDelta().y() > 0: @@ -308,6 +301,29 @@ class QLiberationMap(QGraphicsView): return X > treshold and X or treshold, Y > treshold and Y or treshold + 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) -> QPen: + name = self.base_faction_color_name(player) + return QPen(brush=CONST.COLORS[name]) + + def waypoint_brush(self, player: bool) -> QColor: + name = self.base_faction_color_name(player) + return CONST.COLORS[f"{name}_transparent"] + + def flight_path_pen(self, player: bool) -> QPen: + 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() diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index 09061e16..f5b2e1c4 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -1,100 +1,65 @@ -from PySide2.QtCore import QRect, Qt +from typing import Optional + from PySide2.QtGui import QColor, QPainter -from PySide2.QtWidgets import QGraphicsRectItem, QGraphicsSceneHoverEvent, QGraphicsSceneContextMenuEvent, QMenu, \ - QAction, QGraphicsSceneMouseEvent -import qt_ui.uiconstants as CONST -from game import Game +import qt_ui.uiconstants as const +from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 -from theater import ControlPoint, db +from theater import ControlPoint +from .QMapObject import QMapObject -class QMapControlPoint(QGraphicsRectItem): - - def __init__(self, parent, x: float, y: float, w: float, h: float, model: ControlPoint, game: Game): - super(QMapControlPoint, self).__init__(x, y, w, h) - self.model = model - self.game = game +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.setAcceptHoverEvents(True) self.setZValue(1) - self.setToolTip(self.model.name) - - - def paint(self, painter, option, widget=None): - #super(QMapControlPoint, self).paint(painter, option, widget) + self.setToolTip(self.control_point.name) + self.base_details_dialog: Optional[QBaseMenu2] = None + def paint(self, painter, option, widget=None) -> None: if self.parent.get_display_rule("cp"): painter.save() painter.setRenderHint(QPainter.Antialiasing) 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.setBrush(const.COLORS["white"]) painter.setPen(self.pen_color) r = option.rect painter.drawEllipse(r.x(), r.y(), r.width(), r.height()) - - #gauge = QRect(r.x(), - # r.y()+CONST.CP_SIZE/2 + 2, - # r.width(), - # CONST.CP_SIZE / 4) - - #painter.setBrush(CONST.COLORS["bright_red"]) - #painter.setPen(CONST.COLORS["black"]) - #painter.drawRect(gauge) - - #gauge2 = QRect(r.x(), - # r.y() + CONST.CP_SIZE / 2 + 2, - # r.width()*self.model.base.strength, - # CONST.CP_SIZE / 4) - - #painter.setBrush(CONST.COLORS["green"]) - #painter.drawRect(gauge2) - else: - # TODO : not drawing sunk carriers. Can be improved to display sunk carrier. - pass + # TODO: Draw sunk carriers differently. + # Either don't draw them at all, or perhaps use a sunk ship icon. painter.restore() - def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - self.setCursor(Qt.PointingHandCursor) - - def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): - self.update() - - def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - - def mousePressEvent(self, event:QGraphicsSceneMouseEvent): - self.openBaseMenu() - #self.contextMenuEvent(event) - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): - - if self.model.captured: - openBaseMenu = QAction("Open base menu") - else: - openBaseMenu = QAction("Open intel menu") - - openBaseMenu.triggered.connect(self.openBaseMenu) - - menu = QMenu("Menu", self.parent) - menu.addAction(openBaseMenu) - menu.exec_(event.screenPos()) - - @property - def brush_color(self)->QColor: - return self.model.captured and CONST.COLORS["blue"] or CONST.COLORS["super_red"] + 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 self.model.captured and CONST.COLORS["white"] or CONST.COLORS["white"] + return const.COLORS["white"] - def openBaseMenu(self): - self.baseMenu = QBaseMenu2(self.window(), self.model, self.game) - self.baseMenu.show() \ No newline at end of file + @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() diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index a79ce1ab..1ed9f3d2 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -1,86 +1,86 @@ -from PySide2.QtCore import QPoint, QRect, QPointF, Qt -from PySide2.QtGui import QPainter, QBrush -from PySide2.QtWidgets import QGraphicsRectItem, QGraphicsItem, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent +from typing import List, Optional -import qt_ui.uiconstants as CONST -from game import db, Game +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 qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu from theater import TheaterGroundObject, ControlPoint +from .QMapObject import QMapObject -class QMapGroundObject(QGraphicsRectItem): - - def __init__(self, parent, x: float, y: float, w: float, h: float, cp: ControlPoint, model: TheaterGroundObject, game:Game, buildings=[]): - super(QMapGroundObject, self).__init__(x, y, w, h) - self.model = model - self.cp = cp +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.setAcceptHoverEvents(True) self.setZValue(2) - self.buildings = buildings + self.buildings = buildings if buildings is not None else [] 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.keys(): + 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" self.setToolTip(tooltip[:-1]) - def mousePressEvent(self, event:QGraphicsSceneMouseEvent): - self.openEditionMenu() - - def paint(self, painter, option, widget=None): - #super(QMapControlPoint, self).paint(painter, option, widget) - - playerIcons = "_blue" - enemyIcons = "" + def paint(self, painter, option, widget=None) -> None: + player_icons = "_blue" + enemy_icons = "" 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()) + 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: - painter.drawPixmap(rect, CONST.ICONS[cat + enemyIcons]) + 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 + playerIcons]) + painter.drawPixmap(rect, const.ICONS[cat + player_icons]) else: - painter.drawPixmap(rect, CONST.ICONS["destroyed"]) + painter.drawPixmap(rect, const.ICONS["destroyed"]) - self.drawHealthGauge(painter, option) + self.draw_health_gauge(painter, option) painter.restore() - def drawHealthGauge(self, painter, option): + def draw_health_gauge(self, painter, option) -> None: 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(QGraphicsRectItem): 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) @@ -97,22 +97,18 @@ class QMapGroundObject(QGraphicsRectItem): 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 hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - self.setCursor(Qt.PointingHandCursor) - - def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): - self.update() - self.setCursor(Qt.PointingHandCursor) - - def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent): - self.update() - - def openEditionMenu(self): - self.editionMenu = QGroundObjectMenu(self.window(), self.model, self.buildings, self.cp, self.game) - self.editionMenu.show() + 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 new file mode 100644 index 00000000..c98cce5e --- /dev/null +++ b/qt_ui/widgets/map/QMapObject.py @@ -0,0 +1,75 @@ +"""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. + + 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 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 bf086c5d..9543ab66 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 = "

DCS Liberation " + CONST.VERSION_STRING + "

" + \ diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 678f6098..597c2a32 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -1,9 +1,9 @@ from PySide2.QtCore import Qt from PySide2.QtGui import QCloseEvent, QPixmap -from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget, QDialog, QGridLayout +from PySide2.QtWidgets import QDialog, QGridLayout, QHBoxLayout, QLabel, QWidget -from game import Game from game.event import ControlPointType +from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs @@ -13,19 +13,20 @@ from theater import ControlPoint class QBaseMenu2(QDialog): - def __init__(self, parent, cp: ControlPoint, game: Game): + def __init__(self, parent, cp: ControlPoint, game_model: GameModel): super(QBaseMenu2, self).__init__(parent) # Attrs self.cp = cp - self.game = game + self.game_model = game_model self.is_carrier = self.cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP] self.objectName = "menuDialogue" # Widgets - self.qbase_menu_tab = QBaseMenuTabs(cp, game) + self.qbase_menu_tab = QBaseMenuTabs(cp, self.game_model) try: + game = self.game_model.game self.airport = game.theater.terrain.airport_by_id(self.cp.id) except: self.airport = None @@ -70,7 +71,9 @@ class QBaseMenu2(QDialog): self.mainLayout.addWidget(header, 0, 0) self.mainLayout.addWidget(self.topLayoutWidget, 1, 0) self.mainLayout.addWidget(self.qbase_menu_tab, 2, 0) - totalBudget = QLabel(QRecruitBehaviour.BUDGET_FORMAT.format(self.game.budget)) + totalBudget = QLabel( + QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget) + ) totalBudget.setObjectName("budgetField") totalBudget.setAlignment(Qt.AlignRight | Qt.AlignBottom) totalBudget.setProperty("style", "budget-label") @@ -78,7 +81,7 @@ class QBaseMenu2(QDialog): self.setLayout(self.mainLayout) def closeEvent(self, closeEvent:QCloseEvent): - GameUpdateSignal.get_instance().updateGame(self.game) + GameUpdateSignal.get_instance().updateGame(self.game_model.game) def get_base_image(self): if self.cp.cptype == ControlPointType.AIRCRAFT_CARRIER_GROUP: diff --git a/qt_ui/windows/basemenu/QBaseMenuTabs.py b/qt_ui/windows/basemenu/QBaseMenuTabs.py index dcb05ee6..11846bda 100644 --- a/qt_ui/windows/basemenu/QBaseMenuTabs.py +++ b/qt_ui/windows/basemenu/QBaseMenuTabs.py @@ -1,6 +1,6 @@ -from PySide2.QtWidgets import QTabWidget, QFrame, QGridLayout, QLabel +from PySide2.QtWidgets import QFrame, QGridLayout, QLabel, QTabWidget -from game import Game +from qt_ui.models import GameModel 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 @@ -10,29 +10,29 @@ from theater import ControlPoint class QBaseMenuTabs(QTabWidget): - def __init__(self, cp: ControlPoint, game: Game): + def __init__(self, cp: ControlPoint, game_model: GameModel): super(QBaseMenuTabs, self).__init__() self.cp = cp if cp: if not cp.captured: - self.intel = QIntelInfo(cp, game) + self.intel = QIntelInfo(cp, game_model.game) self.addTab(self.intel, "Intel") if not cp.is_carrier: - self.base_defenses_hq = QBaseDefensesHQ(cp, game) + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) self.addTab(self.base_defenses_hq, "Base Defenses") else: if cp.has_runway(): - self.airfield_command = QAirfieldCommand(cp, game) + self.airfield_command = QAirfieldCommand(cp, game_model) self.addTab(self.airfield_command, "Airfield Command") if not cp.is_carrier: - self.ground_forces_hq = QGroundForcesHQ(cp, game) + self.ground_forces_hq = QGroundForcesHQ(cp, game_model) self.addTab(self.ground_forces_hq, "Ground Forces HQ") - self.base_defenses_hq = QBaseDefensesHQ(cp, game) + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) self.addTab(self.base_defenses_hq, "Base Defenses") else: - self.base_defenses_hq = QBaseDefensesHQ(cp, game) + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) self.addTab(self.base_defenses_hq, "Fleet") else: diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index f180dd58..b41ac68a 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -1,13 +1,19 @@ +from PySide2.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSpacerItem, +) import logging - -from PySide2.QtWidgets import QLabel, QPushButton, \ - QSizePolicy, QSpacerItem, QGroupBox, QHBoxLayout from dcs.unittype import UnitType from theater import db -class QRecruitBehaviour: + +class QRecruitBehaviour: game = None cp = None deliveryEvent = None @@ -17,14 +23,22 @@ class QRecruitBehaviour: recruitable_types = [] BUDGET_FORMAT = "Available Budget: ${}M" - def __init__(self): + def __init__(self) -> None: + self.deliveryEvent = None self.bought_amount_labels = {} self.existing_units_labels = {} self.recruitable_types = [] self.update_available_budget() - def add_purchase_row(self, unit_type, layout, row): + @property + def budget(self) -> int: + return self.game_model.game.budget + @budget.setter + def budget(self, value: int) -> None: + self.game_model.game.budget = value + + def add_purchase_row(self, unit_type, layout, row): exist = QGroupBox() exist.setProperty("style", "buy-box") exist.setMaximumHeight(36) @@ -102,7 +116,8 @@ class QRecruitBehaviour: parent = parent.parent() for child in parent.children(): if child.objectName() == "budgetField": - child.setText(QRecruitBehaviour.BUDGET_FORMAT.format(self.game.budget)) + child.setText( + QRecruitBehaviour.BUDGET_FORMAT.format(self.budget)) def buy(self, unit_type): @@ -113,9 +128,9 @@ class QRecruitBehaviour: return price = db.PRICES[unit_type] - if self.game.budget >= price: + if self.budget >= price: self.deliveryEvent.deliver({unit_type: 1}) - self.game.budget -= price + self.budget -= price else: # TODO : display modal warning logging.info("Not enough money !") @@ -125,13 +140,13 @@ class QRecruitBehaviour: def sell(self, unit_type): if self.deliveryEvent.units.get(unit_type, 0) > 0: price = db.PRICES[unit_type] - self.game.budget += price + self.budget += price self.deliveryEvent.units[unit_type] = self.deliveryEvent.units[unit_type] - 1 if self.deliveryEvent.units[unit_type] == 0: del self.deliveryEvent.units[unit_type] elif self.cp.base.total_units_of_type(unit_type) > 0: price = db.PRICES[unit_type] - self.game.budget += price + self.budget += price self.cp.base.commit_losses({unit_type: 1}) self._update_count_label(unit_type) diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index a4abf404..9ce843d7 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -1,25 +1,35 @@ -from PySide2.QtCore import Qt -from PySide2.QtWidgets import QVBoxLayout, QGridLayout, QGroupBox, QScrollArea, QFrame, QWidget, QHBoxLayout, QLabel +from typing import Optional -from game.event import UnitsDeliveryEvent -from qt_ui.uiconstants import ICONS +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QFrame, + QGridLayout, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from game.event.event import UnitsDeliveryEvent +from qt_ui.models import GameModel from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour -from theater import ControlPoint, CAP, CAS, db, ControlPointType -from game import Game +from theater import CAP, CAS, ControlPoint, db class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): - - def __init__(self, cp: ControlPoint, game: Game): + def __init__(self, cp: ControlPoint, game_model: GameModel) -> None: QFrame.__init__(self) self.cp = cp - self.game = game + self.game_model = game_model + self.deliveryEvent: Optional[UnitsDeliveryEvent] = None - for event in self.game.events: + self.bought_amount_labels = {} + self.existing_units_labels = {} + + for event in self.game_model.game.events: if event.__class__ == UnitsDeliveryEvent and event.from_cp == self.cp: self.deliveryEvent = event if not self.deliveryEvent: - self.deliveryEvent = self.game.units_delivery_event(self.cp) + self.deliveryEvent = self.game_model.game.units_delivery_event(self.cp) # Determine maximum number of aircrafts that can be bought self.set_maximum_units(self.cp.available_aircraft_slots) @@ -36,8 +46,8 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): main_layout = QVBoxLayout() units = { - CAP: db.find_unittype(CAP, self.game.player_name), - CAS: db.find_unittype(CAS, self.game.player_name), + CAP: db.find_unittype(CAP, self.game_model.game.player_name), + CAS: db.find_unittype(CAS, self.game_model.game.player_name), } scroll_content = QWidget() @@ -46,7 +56,8 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): for task_type in units.keys(): units_column = list(set(units[task_type])) - if len(units_column) == 0: continue + if len(units_column) == 0: + continue units_column.sort(key=lambda x: db.PRICES[x]) for unit_type in units_column: if self.cp.is_carrier and not unit_type in db.CARRIER_CAPABLE: diff --git a/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py b/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py index 74b3c973..5274640d 100644 --- a/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py +++ b/qt_ui/windows/basemenu/airfield/QAirfieldCommand.py @@ -1,27 +1,30 @@ -from PySide2.QtWidgets import QFrame, QGridLayout, QLabel, QHBoxLayout, QGroupBox, QVBoxLayout -from game import Game -from qt_ui.widgets.base.QAirportInformation import QAirportInformation -from qt_ui.windows.basemenu.airfield.QAircraftRecruitmentMenu import QAircraftRecruitmentMenu +from PySide2.QtWidgets import QFrame, QGridLayout, QGroupBox, QVBoxLayout + +from qt_ui.models import GameModel +from qt_ui.windows.basemenu.airfield.QAircraftRecruitmentMenu import \ + QAircraftRecruitmentMenu from qt_ui.windows.mission.QPlannedFlightsView import QPlannedFlightsView from theater import ControlPoint class QAirfieldCommand(QFrame): - def __init__(self, cp:ControlPoint, game:Game): + def __init__(self, cp:ControlPoint, game_model: GameModel): super(QAirfieldCommand, self).__init__() self.cp = cp - self.game = game + self.game_model = game_model self.init_ui() def init_ui(self): layout = QGridLayout() - layout.addWidget(QAircraftRecruitmentMenu(self.cp, self.game), 0, 0) + layout.addWidget(QAircraftRecruitmentMenu(self.cp, self.game_model), 0, 0) try: planned = QGroupBox("Planned Flights") planned_layout = QVBoxLayout() - planned_layout.addWidget(QPlannedFlightsView(self.game.planners[self.cp.id])) + planned_layout.addWidget( + QPlannedFlightsView(self.game_model, self.cp) + ) planned.setLayout(planned_layout) layout.addWidget(planned, 0, 1) except: diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py index e260d64c..ec1cabf6 100644 --- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py @@ -1,27 +1,33 @@ from PySide2.QtCore import Qt -from PySide2.QtWidgets import QVBoxLayout, QGridLayout, QGroupBox, QFrame, QWidget, QScrollArea +from PySide2.QtWidgets import ( + QFrame, + QGridLayout, + QScrollArea, + QVBoxLayout, + QWidget, +) -from game import Game from game.event import UnitsDeliveryEvent +from qt_ui.models import GameModel from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour from theater import ControlPoint, PinpointStrike, db class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): - def __init__(self, cp:ControlPoint, game:Game): + def __init__(self, cp: ControlPoint, game_model: GameModel): QFrame.__init__(self) self.cp = cp - self.game = game + self.game_model = game_model self.bought_amount_labels = {} self.existing_units_labels = {} - for event in self.game.events: + for event in self.game_model.game.events: if event.__class__ == UnitsDeliveryEvent and event.from_cp == self.cp: self.deliveryEvent = event if not self.deliveryEvent: - self.deliveryEvent = self.game.units_delivery_event(self.cp) + self.deliveryEvent = self.game_model.game.units_delivery_event(self.cp) self.init_ui() @@ -29,7 +35,8 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): main_layout = QVBoxLayout() units = { - PinpointStrike: db.find_unittype(PinpointStrike, self.game.player_name), + PinpointStrike: db.find_unittype(PinpointStrike, + self.game_model.game.player_name), } scroll_content = QWidget() diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py index 1ea116e3..bb18594f 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesHQ.py @@ -1,21 +1,24 @@ from PySide2.QtWidgets import QFrame, QGridLayout -from game import Game -from qt_ui.windows.basemenu.ground_forces.QArmorRecruitmentMenu import QArmorRecruitmentMenu -from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategy import QGroundForcesStrategy +from qt_ui.models import GameModel +from qt_ui.windows.basemenu.ground_forces.QArmorRecruitmentMenu import \ + QArmorRecruitmentMenu +from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategy import \ + QGroundForcesStrategy from theater import ControlPoint class QGroundForcesHQ(QFrame): - def __init__(self, cp:ControlPoint, game:Game): + def __init__(self, cp: ControlPoint, game_model: GameModel) -> None: super(QGroundForcesHQ, self).__init__() self.cp = cp - self.game = game + self.game_model = game_model self.init_ui() def init_ui(self): layout = QGridLayout() - layout.addWidget(QArmorRecruitmentMenu(self.cp, self.game), 0, 0) - layout.addWidget(QGroundForcesStrategy(self.cp, self.game), 0, 1) + layout.addWidget(QArmorRecruitmentMenu(self.cp, self.game_model), 0, 0) + layout.addWidget(QGroundForcesStrategy(self.cp, self.game_model.game), + 0, 1) self.setLayout(layout) diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py new file mode 100644 index 00000000..29e4eafb --- /dev/null +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -0,0 +1,29 @@ +"""Dialog window for editing flights.""" +from PySide2.QtWidgets import ( + QDialog, + QVBoxLayout, +) + +from game import Game +from gen.flights.flight import Flight +from qt_ui.uiconstants import EVENT_ICONS +from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner + + +class QEditFlightDialog(QDialog): + """Dialog window for editing flight plans and loadouts.""" + + def __init__(self, game: Game, flight: Flight) -> None: + super().__init__() + + self.game = game + + self.setWindowTitle("Create flight") + self.setWindowIcon(EVENT_ICONS["strike"]) + + layout = QVBoxLayout() + + self.flight_planner = QFlightPlanner(flight, game) + layout.addWidget(self.flight_planner) + + self.setLayout(layout) diff --git a/qt_ui/windows/mission/QMissionPlanning.py b/qt_ui/windows/mission/QMissionPlanning.py deleted file mode 100644 index 04ffdeb3..00000000 --- a/qt_ui/windows/mission/QMissionPlanning.py +++ /dev/null @@ -1,159 +0,0 @@ -from PySide2.QtCore import Qt, Slot, QItemSelectionModel, QPoint -from PySide2.QtWidgets import QDialog, QGridLayout, QScrollArea, QVBoxLayout, QPushButton, QHBoxLayout, QMessageBox -from game import Game -from game.event import CAP, CAS, FrontlineAttackEvent -from qt_ui.uiconstants import EVENT_ICONS -from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow -from qt_ui.windows.mission.QPlannedFlightsView import QPlannedFlightsView -from qt_ui.windows.mission.QChooseAirbase import QChooseAirbase -from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator -from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner - - -class QMissionPlanning(QDialog): - - def __init__(self, game: Game): - super(QMissionPlanning, self).__init__() - self.game = game - self.setWindowFlags(Qt.WindowStaysOnTopHint) - self.setMinimumSize(1000, 440) - self.setWindowTitle("Mission Preparation") - self.setWindowIcon(EVENT_ICONS["strike"]) - self.init_ui() - print("DONE") - - def init_ui(self): - - self.captured_cp = [cp for cp in self.game.theater.controlpoints if cp.captured] - - self.layout = QGridLayout() - self.left_bar_layout = QVBoxLayout() - - self.select_airbase = QChooseAirbase(self.game) - self.select_airbase.selected_airbase_changed.connect(self.on_departure_cp_changed) - self.planned_flight_view = QPlannedFlightsView(None) - self.available_aircraft_at_selected_location = {} - if self.captured_cp[0].id in self.game.planners.keys(): - self.planner = self.game.planners[self.captured_cp[0].id] - self.planned_flight_view.set_flight_planner(self.planner) - self.selected_cp = self.captured_cp[0] - self.available_aircraft_at_selected_location = self.planner.get_available_aircraft() - - self.planned_flight_view.selectionModel().setCurrentIndex(self.planned_flight_view.indexAt(QPoint(1, 1)), QItemSelectionModel.Rows) - self.planned_flight_view.selectionModel().selectionChanged.connect(self.on_flight_selection_change) - - if len(self.planned_flight_view.flight_planner.flights) > 0: - self.flight_planner = QFlightPlanner(self.planned_flight_view.flight_planner.flights[0], self.game, self.planned_flight_view.flight_planner, 0) - self.flight_planner.on_planned_flight_changed.connect(self.update_planned_flight_view) - else: - self.flight_planner = QFlightPlanner(None, self.game, self.planned_flight_view.flight_planner, 0) - self.flight_planner.on_planned_flight_changed.connect(self.update_planned_flight_view) - - self.add_flight_button = QPushButton("Add Flight") - self.add_flight_button.clicked.connect(self.on_add_flight) - self.delete_flight_button = QPushButton("Delete Selected") - self.delete_flight_button.setProperty("style", "btn-danger") - self.delete_flight_button.clicked.connect(self.on_delete_flight) - - self.button_layout = QHBoxLayout() - self.button_layout.addStretch() - self.button_layout.addWidget(self.delete_flight_button) - self.button_layout.addWidget(self.add_flight_button) - - self.mission_start_button = QPushButton("Take Off") - self.mission_start_button.setProperty("style", "start-button") - self.mission_start_button.clicked.connect(self.on_start) - - self.left_bar_layout.addWidget(self.select_airbase) - self.left_bar_layout.addWidget(self.planned_flight_view) - self.left_bar_layout.addLayout(self.button_layout) - - self.layout.addLayout(self.left_bar_layout, 0, 0) - self.layout.addWidget(self.flight_planner, 0, 1) - self.layout.addWidget(self.mission_start_button, 1, 1, alignment=Qt.AlignRight) - - self.setLayout(self.layout) - - @Slot(str) - def on_departure_cp_changed(self, cp_name): - cps = [cp for cp in self.game.theater.controlpoints if cp.name == cp_name] - - print(cps) - - if len(cps) == 1: - self.selected_cp = cps[0] - self.planner = self.game.planners[cps[0].id] - self.available_aircraft_at_selected_location = self.planner.get_available_aircraft() - self.planned_flight_view.set_flight_planner(self.planner) - else: - self.available_aircraft_at_selected_location = {} - self.planned_flight_view.set_flight_planner(None) - - def on_flight_selection_change(self): - - print("On flight selection change") - - index = self.planned_flight_view.selectionModel().currentIndex().row() - self.planned_flight_view.repaint() - - if self.flight_planner is not None: - self.flight_planner.on_planned_flight_changed.disconnect() - self.flight_planner.clearTabs() - - try: - flight = self.planner.flights[index] - except IndexError: - flight = None - self.flight_planner = QFlightPlanner(flight, self.game, self.planner, self.flight_planner.currentIndex()) - self.flight_planner.on_planned_flight_changed.connect(self.update_planned_flight_view) - self.layout.addWidget(self.flight_planner, 0, 1) - - def update_planned_flight_view(self): - self.planned_flight_view.update_content() - - def on_add_flight(self): - possible_aircraft_type = list(self.selected_cp.base.aircraft.keys()) - - if len(possible_aircraft_type) == 0: - msg = QMessageBox() - msg.setIcon(QMessageBox.Information) - msg.setText("No more aircraft are available on " + self.selected_cp.name + " airbase.") - msg.setWindowTitle("No more aircraft") - msg.setStandardButtons(QMessageBox.Ok) - msg.setWindowFlags(Qt.WindowStaysOnTopHint) - msg.exec_() - else: - self.subwindow = QFlightCreator(self.game, self.selected_cp, possible_aircraft_type, self.planned_flight_view) - self.subwindow.show() - - def on_delete_flight(self): - index = self.planned_flight_view.selectionModel().currentIndex().row() - self.planner.remove_flight(index) - self.planned_flight_view.set_flight_planner(self.planner, index) - - - def on_start(self): - - # TODO : refactor this nonsense - self.gameEvent = None - for event in self.game.events: - if isinstance(event, FrontlineAttackEvent) and event.is_player_attacking: - self.gameEvent = event - if self.gameEvent is None: - self.gameEvent = 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) - #if self.awacs_checkbox.isChecked() == 1: - # self.gameEvent.is_awacs_enabled = True - # self.game.awacs_expense_commit() - #else: - # self.gameEvent.is_awacs_enabled = False - self.gameEvent.is_awacs_enabled = True - self.gameEvent.ca_slots = 1 - self.gameEvent.departure_cp = self.game.theater.controlpoints[0] - self.gameEvent.player_attacking({CAS:{}, CAP:{}}) - self.gameEvent.depart_from = self.game.theater.controlpoints[0] - - self.game.initiate_event(self.gameEvent) - waiting = QWaitingForMissionResultWindow(self.gameEvent, self.game) - waiting.show() - self.close() diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py new file mode 100644 index 00000000..2b27a035 --- /dev/null +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -0,0 +1,198 @@ +"""Dialogs for creating and editing ATO packages.""" +import logging +from typing import Optional + +from PySide2.QtCore import QItemSelection, Signal +from PySide2.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) + +from game.game import Game +from gen.ato import Package +from gen.flights.flight import Flight +from qt_ui.models import AtoModel, PackageModel +from qt_ui.uiconstants import EVENT_ICONS +from qt_ui.widgets.ato import QFlightList +from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator +from theater.missiontarget import MissionTarget + + +class QPackageDialog(QDialog): + """Base package management dialog. + + The dialogs for creating a new package and editing an existing dialog are + very similar, and this implements the shared behavior. + """ + + #: Emitted when a change is made to the package. + package_changed = Signal() + + #: Emitted when a flight is added to the package. + flight_added = Signal(Flight) + + #: Emitted when a flight is removed from the package. + flight_removed = Signal(Flight) + + def __init__(self, game: Game, model: PackageModel) -> None: + super().__init__() + self.game = game + self.package_model = model + self.add_flight_dialog: Optional[QFlightCreator] = None + + self.setMinimumSize(1000, 440) + self.setWindowTitle( + f"Mission Package: {self.package_model.mission_target.name}" + ) + self.setWindowIcon(EVENT_ICONS["strike"]) + + self.layout = QVBoxLayout() + + self.summary_row = QHBoxLayout() + self.layout.addLayout(self.summary_row) + + self.package_type_label = QLabel("Package Type:") + self.package_type_text = QLabel(self.package_model.description) + # noinspection PyUnresolvedReferences + self.package_changed.connect(lambda: self.package_type_text.setText( + self.package_model.description + )) + self.summary_row.addWidget(self.package_type_label) + self.summary_row.addWidget(self.package_type_text) + + self.package_view = QFlightList(self.package_model) + self.package_view.selectionModel().selectionChanged.connect( + self.on_selection_changed + ) + self.layout.addWidget(self.package_view) + + self.button_layout = QHBoxLayout() + self.layout.addLayout(self.button_layout) + + self.add_flight_button = QPushButton("Add Flight") + self.add_flight_button.clicked.connect(self.on_add_flight) + self.button_layout.addWidget(self.add_flight_button) + + self.delete_flight_button = QPushButton("Delete Selected") + self.delete_flight_button.setProperty("style", "btn-danger") + self.delete_flight_button.clicked.connect(self.on_delete_flight) + self.delete_flight_button.setEnabled(False) + self.button_layout.addWidget(self.delete_flight_button) + + self.button_layout.addStretch() + + self.setLayout(self.layout) + + def on_selection_changed(self, selected: QItemSelection, + _deselected: QItemSelection) -> None: + """Updates the state of the delete button.""" + self.delete_flight_button.setEnabled(not selected.empty()) + + def on_add_flight(self) -> None: + """Opens the new flight dialog.""" + self.add_flight_dialog = QFlightCreator( + self.game, self.package_model.package + ) + self.add_flight_dialog.created.connect(self.add_flight) + self.add_flight_dialog.show() + + def add_flight(self, flight: Flight) -> None: + """Adds the new flight to the package.""" + self.package_model.add_flight(flight) + # noinspection PyUnresolvedReferences + self.package_changed.emit() + # noinspection PyUnresolvedReferences + self.flight_added.emit(flight) + + def on_delete_flight(self) -> None: + """Removes the selected flight from the package.""" + flight = self.package_view.selected_item + if flight is None: + logging.error(f"Cannot delete flight when no flight is selected.") + return + self.package_model.delete_flight(flight) + # noinspection PyUnresolvedReferences + self.package_changed.emit() + # noinspection PyUnresolvedReferences + self.flight_removed.emit(flight) + + +class QNewPackageDialog(QPackageDialog): + """Dialog window for creating a new package. + + New packages do not affect the ATO model until they are saved. + """ + + def __init__(self, game: Game, model: AtoModel, + target: MissionTarget) -> None: + super().__init__(game, PackageModel(Package(target))) + self.ato_model = model + + self.save_button = QPushButton("Save") + self.save_button.setProperty("style", "start-button") + self.save_button.clicked.connect(self.on_save) + self.button_layout.addWidget(self.save_button) + + self.delete_flight_button.clicked.connect(self.on_delete_flight) + + def on_save(self) -> None: + """Saves the created package. + + Empty packages may be created. They can be modified later, and will have + no effect if empty when the mission is generated. + """ + self.ato_model.add_package(self.package_model.package) + for flight in self.package_model.package.flights: + self.game.aircraft_inventory.claim_for_flight(flight) + self.close() + + +class QEditPackageDialog(QPackageDialog): + """Dialog window for editing an existing package. + + Changes to existing packages occur immediately. + """ + + def __init__(self, game: Game, model: AtoModel, + package: PackageModel) -> None: + super().__init__(game, package) + self.ato_model = model + + self.delete_button = QPushButton("Delete package") + self.delete_button.setProperty("style", "btn-danger") + self.delete_button.clicked.connect(self.on_delete) + self.button_layout.addWidget(self.delete_button) + + self.done_button = QPushButton("Done") + self.done_button.setProperty("style", "start-button") + self.done_button.clicked.connect(self.on_done) + self.button_layout.addWidget(self.done_button) + + # noinspection PyUnresolvedReferences + self.flight_added.connect(self.on_flight_added) + # noinspection PyUnresolvedReferences + self.flight_removed.connect(self.on_flight_removed) + + # TODO: Make the new package dialog do this too, return on cancel. + # Not claiming the aircraft when they are added to the planner means that + # inventory counts are not updated until after the new package is updated, + # so you can add an infinite number of aircraft to a new package in the UI, + # which will crash when the flight package is saved. + def on_flight_added(self, flight: Flight) -> None: + self.game.aircraft_inventory.claim_for_flight(flight) + + def on_flight_removed(self, flight: Flight) -> None: + self.game.aircraft_inventory.return_from_flight(flight) + + def on_done(self) -> None: + """Closes the window.""" + self.close() + + def on_delete(self) -> None: + """Removes the viewed package from the ATO.""" + # The ATO model returns inventory for us when deleting a package. + self.ato_model.delete_package(self.package_model.package) + self.close() diff --git a/qt_ui/windows/mission/QPlannedFlightsView.py b/qt_ui/windows/mission/QPlannedFlightsView.py index 0dcc8a81..a7c45e51 100644 --- a/qt_ui/windows/mission/QPlannedFlightsView.py +++ b/qt_ui/windows/mission/QPlannedFlightsView.py @@ -1,37 +1,36 @@ -from PySide2.QtCore import QSize, QItemSelectionModel, QPoint +from PySide2.QtCore import QItemSelectionModel, QSize from PySide2.QtGui import QStandardItemModel -from PySide2.QtWidgets import QListView, QAbstractItemView +from PySide2.QtWidgets import QAbstractItemView, QListView -from gen.flights.ai_flight_planner import FlightPlanner +from qt_ui.models import GameModel from qt_ui.windows.mission.QFlightItem import QFlightItem +from theater.controlpoint import ControlPoint class QPlannedFlightsView(QListView): - def __init__(self, flight_planner: FlightPlanner): + def __init__(self, game_model: GameModel, cp: ControlPoint) -> None: super(QPlannedFlightsView, self).__init__() + self.game_model = game_model + self.cp = cp self.model = QStandardItemModel(self) self.setModel(self.model) - self.flightitems = [] + self.flight_items = [] self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) - if flight_planner: - self.set_flight_planner(flight_planner) + self.set_flight_planner() - def update_content(self): - for i, f in enumerate(self.flight_planner.flights): - self.flightitems[i].update(f) + def setup_content(self): + self.flight_items = [] + for package in self.game_model.ato_model.packages: + for flight in package.flights: + if flight.from_cp == self.cp: + item = QFlightItem(flight) + self.model.appendRow(item) + self.flight_items.append(item) + self.set_selected_flight(0) - def setup_content(self, row=0): - self.flightitems = [] - for i, f in enumerate(self.flight_planner.flights): - item = QFlightItem(f) - self.model.appendRow(item) - self.flightitems.append(item) - self.setSelectedFlight(row) - self.repaint() - - def setSelectedFlight(self, row): + def set_selected_flight(self, row): self.selectionModel().clearSelection() index = self.model.index(row, 0) if not index.isValid(): @@ -42,8 +41,6 @@ class QPlannedFlightsView(QListView): def clear_layout(self): self.model.removeRows(0, self.model.rowCount()) - def set_flight_planner(self, flight_planner: FlightPlanner, row=0): + def set_flight_planner(self) -> None: self.clear_layout() - self.flight_planner = flight_planner - if self.flight_planner: - self.setup_content(row) + self.setup_content() diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 293ba75f..f1041071 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -1,122 +1,178 @@ -from typing import List +import logging +from typing import Optional -from PySide2.QtCore import Qt -from PySide2.QtWidgets import QDialog, QGridLayout, QLabel, QComboBox, QHBoxLayout, QVBoxLayout, QPushButton, QSpinBox, \ - QMessageBox -from dcs import Point -from dcs.unittype import UnitType +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import ( + QDialog, + QPushButton, + QVBoxLayout, +) +from dcs.planes import PlaneType from game import Game +from gen.ato import Package from gen.flights.ai_flight_planner import FlightPlanner -from gen.flights.flight import Flight, FlightWaypoint, FlightType +from gen.flights.flight import Flight, FlightType from qt_ui.uiconstants import EVENT_ICONS -from qt_ui.windows.mission.flight.waypoints.QFlightWaypointInfoBox import QFlightWaypointInfoBox -from theater import ControlPoint - -PREDEFINED_WAYPOINT_CATEGORIES = [ - "Frontline (CAS AREA)", - "Building", - "Units", - "Airbase" -] +from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner +from qt_ui.widgets.QLabeledWidget import QLabeledWidget +from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector +from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox +from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector +from theater import ControlPoint, FrontLine, TheaterGroundObject class QFlightCreator(QDialog): + created = Signal(Flight) + + def __init__(self, game: Game, package: Package) -> None: + super().__init__() - def __init__(self, game: Game, from_cp:ControlPoint, possible_aircraft_type:List[UnitType], flight_view=None): - super(QFlightCreator, self).__init__() self.game = game - self.from_cp = from_cp - self.flight_view = flight_view - self.planner = self.game.planners[from_cp.id] - self.available = self.planner.get_available_aircraft() + self.package = package - self.setWindowFlags(Qt.WindowStaysOnTopHint) - self.setModal(True) self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) - self.select_type_aircraft = QComboBox() - for aircraft_type in self.planner.get_available_aircraft().keys(): - print(aircraft_type) - print(aircraft_type.name) - if self.available[aircraft_type] > 0: - self.select_type_aircraft.addItem(aircraft_type.id, userData=aircraft_type) - self.select_type_aircraft.setCurrentIndex(0) - - self.select_flight_type = QComboBox() - self.select_flight_type.addItem("CAP [Combat Air Patrol]", userData=FlightType.CAP) - self.select_flight_type.addItem("BARCAP [Barrier Combat Air Patrol]", userData=FlightType.BARCAP) - self.select_flight_type.addItem("TARCAP [Target Combat Air Patrol]", userData=FlightType.TARCAP) - self.select_flight_type.addItem("INTERCEPT [Interception]", userData=FlightType.INTERCEPTION) - self.select_flight_type.addItem("CAS [Close Air Support]", userData=FlightType.CAS) - self.select_flight_type.addItem("BAI [Battlefield Interdiction]", userData=FlightType.BAI) - self.select_flight_type.addItem("SEAD [Suppression of Enemy Air Defenses]", userData=FlightType.SEAD) - self.select_flight_type.addItem("DEAD [Destruction of Enemy Air Defenses]", userData=FlightType.DEAD) - self.select_flight_type.addItem("STRIKE [Strike]", userData=FlightType.STRIKE) - self.select_flight_type.addItem("ANTISHIP [Antiship Attack]", userData=FlightType.ANTISHIP) - self.select_flight_type.setCurrentIndex(0) - - self.select_count_of_aircraft = QSpinBox() - self.select_count_of_aircraft.setMinimum(1) - self.select_count_of_aircraft.setMaximum(4) - self.select_count_of_aircraft.setValue(2) - - aircraft_type = self.select_type_aircraft.currentData() - if aircraft_type is not None: - self.select_count_of_aircraft.setValue(min(self.available[aircraft_type], 2)) - self.select_count_of_aircraft.setMaximum(min(self.available[aircraft_type], 4)) - - self.add_button = QPushButton("Add") - self.add_button.clicked.connect(self.create_flight) - - self.init_ui() - - - def init_ui(self): layout = QVBoxLayout() - type_layout = QHBoxLayout() - type_layout.addWidget(QLabel("Type of Aircraft : ")) - type_layout.addStretch() - type_layout.addWidget(self.select_type_aircraft, alignment=Qt.AlignRight) + self.task_selector = QFlightTypeComboBox( + self.game.theater, self.package.target + ) + self.task_selector.setCurrentIndex(0) + layout.addLayout(QLabeledWidget("Task:", self.task_selector)) - count_layout = QHBoxLayout() - count_layout.addWidget(QLabel("Count : ")) - count_layout.addStretch() - count_layout.addWidget(self.select_count_of_aircraft, alignment=Qt.AlignRight) + self.aircraft_selector = QAircraftTypeSelector( + self.game.aircraft_inventory.available_types_for_player + ) + self.aircraft_selector.setCurrentIndex(0) + self.aircraft_selector.currentIndexChanged.connect( + self.on_aircraft_changed) + layout.addLayout(QLabeledWidget("Aircraft:", self.aircraft_selector)) - flight_type_layout = QHBoxLayout() - flight_type_layout.addWidget(QLabel("Task : ")) - flight_type_layout.addStretch() - flight_type_layout.addWidget(self.select_flight_type, alignment=Qt.AlignRight) + self.airfield_selector = QOriginAirfieldSelector( + self.game.aircraft_inventory, + [cp for cp in game.theater.controlpoints if cp.captured], + self.aircraft_selector.currentData() + ) + layout.addLayout(QLabeledWidget("Airfield:", self.airfield_selector)) + + self.flight_size_spinner = QFlightSizeSpinner() + layout.addLayout(QLabeledWidget("Count:", self.flight_size_spinner)) - layout.addLayout(type_layout) - layout.addLayout(count_layout) - layout.addLayout(flight_type_layout) layout.addStretch() - layout.addWidget(self.add_button, alignment=Qt.AlignRight) + + self.create_button = QPushButton("Create") + self.create_button.clicked.connect(self.create_flight) + layout.addWidget(self.create_button, alignment=Qt.AlignRight) self.setLayout(layout) - def create_flight(self): - aircraft_type = self.select_type_aircraft.currentData() - count = self.select_count_of_aircraft.value() + def verify_form(self) -> Optional[str]: + aircraft: PlaneType = self.aircraft_selector.currentData() + origin: ControlPoint = self.airfield_selector.currentData() + size: int = self.flight_size_spinner.value() + if not origin.captured: + return f"{origin.name} is not owned by your coalition." + available = origin.base.aircraft.get(aircraft, 0) + if not available: + return f"{origin.name} has no {aircraft.id} available." + if size > available: + return f"{origin.name} has only {available} {aircraft.id} available." + return None - if self.available[aircraft_type] < count: - msg = QMessageBox() - msg.setIcon(QMessageBox.Information) - msg.setText("Not enough aircraft of this type are available. Only " + str(self.available[aircraft_type]) + " available.") - msg.setWindowTitle("Not enough aircraft") - msg.setStandardButtons(QMessageBox.Ok) - msg.setWindowFlags(Qt.WindowStaysOnTopHint) - msg.exec_() + def create_flight(self) -> None: + error = self.verify_form() + if error is not None: + self.error_box("Could not create flight", error) return - else: - flight = Flight(aircraft_type, count, self.from_cp, self.select_flight_type.currentData()) - self.planner.flights.append(flight) - self.planner.custom_flights.append(flight) - if self.flight_view is not None: - self.flight_view.set_flight_planner(self.planner, len(self.planner.flights)-1) - self.close() + task = self.task_selector.currentData() + aircraft = self.aircraft_selector.currentData() + origin = self.airfield_selector.currentData() + size = self.flight_size_spinner.value() + + flight = Flight(aircraft, size, origin, task) + self.populate_flight_plan(flight, task) + + # noinspection PyUnresolvedReferences + self.created.emit(flight) + self.close() + + def on_aircraft_changed(self, index: int) -> None: + new_aircraft = self.aircraft_selector.itemData(index) + self.airfield_selector.change_aircraft(new_aircraft) + + @property + def planner(self) -> FlightPlanner: + return self.game.planners[self.airfield_selector.currentData().id] + + def populate_flight_plan(self, flight: Flight, task: FlightType) -> None: + # TODO: Flesh out mission types. + if task == FlightType.ANTISHIP: + logging.error("Anti-ship flight plan generation not implemented") + elif task == FlightType.BAI: + logging.error("BAI flight plan generation not implemented") + elif task == FlightType.BARCAP: + self.generate_cap(flight) + elif task == FlightType.CAP: + self.generate_cap(flight) + elif task == FlightType.CAS: + self.generate_cas(flight) + elif task == FlightType.DEAD: + self.generate_sead(flight) + elif task == FlightType.ELINT: + logging.error("ELINT flight plan generation not implemented") + elif task == FlightType.EVAC: + logging.error("Evac flight plan generation not implemented") + elif task == FlightType.EWAR: + logging.error("EWar flight plan generation not implemented") + elif task == FlightType.INTERCEPTION: + logging.error("Intercept flight plan generation not implemented") + elif task == FlightType.LOGISTICS: + logging.error("Logistics flight plan generation not implemented") + elif task == FlightType.RECON: + logging.error("Recon flight plan generation not implemented") + elif task == FlightType.SEAD: + self.generate_sead(flight) + elif task == FlightType.STRIKE: + self.generate_strike(flight) + elif task == FlightType.TARCAP: + self.generate_cap(flight) + elif task == FlightType.TROOP_TRANSPORT: + logging.error( + "Troop transport flight plan generation not implemented" + ) + + def generate_cas(self, flight: Flight) -> None: + if not isinstance(self.package.target, FrontLine): + logging.error( + "Could not create flight plan: CAS missions only valid for " + "front lines" + ) + return + self.planner.generate_cas(flight, self.package.target) + + def generate_cap(self, flight: Flight) -> None: + if isinstance(self.package.target, TheaterGroundObject): + logging.error( + "Could not create flight plan: CAP missions for strike targets " + "not implemented" + ) + return + if isinstance(self.package.target, FrontLine): + self.planner.generate_frontline_cap(flight, self.package.target) + else: + self.planner.generate_barcap(flight, self.package.target) + + def generate_sead(self, flight: Flight) -> None: + self.planner.generate_sead(flight, self.package.target) + + def generate_strike(self, flight: Flight) -> None: + if not isinstance(self.package.target, TheaterGroundObject): + logging.error( + "Could not create flight plan: strike missions for capture " + "points not implemented" + ) + return + self.planner.generate_strike(flight, self.package.target) diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py index 6e422b93..4eed4754 100644 --- a/qt_ui/windows/mission/flight/QFlightPlanner.py +++ b/qt_ui/windows/mission/flight/QFlightPlanner.py @@ -1,42 +1,31 @@ from PySide2.QtCore import Signal -from PySide2.QtWidgets import QTabWidget, QFrame, QGridLayout, QLabel +from PySide2.QtWidgets import QTabWidget -from gen.flights.flight import Flight from game import Game -from qt_ui.windows.mission.flight.payload.QFlightPayloadTab import QFlightPayloadTab -from qt_ui.windows.mission.flight.settings.QGeneralFlightSettingsTab import QGeneralFlightSettingsTab -from qt_ui.windows.mission.flight.waypoints.QFlightWaypointTab import QFlightWaypointTab +from gen.flights.flight import Flight +from qt_ui.windows.mission.flight.payload.QFlightPayloadTab import \ + QFlightPayloadTab +from qt_ui.windows.mission.flight.settings.QGeneralFlightSettingsTab import \ + QGeneralFlightSettingsTab +from qt_ui.windows.mission.flight.waypoints.QFlightWaypointTab import \ + QFlightWaypointTab class QFlightPlanner(QTabWidget): on_planned_flight_changed = Signal() - def __init__(self, flight: Flight, game: Game, planner, selected_tab): - super(QFlightPlanner, self).__init__() + def __init__(self, flight: Flight, game: Game): + super().__init__() - print(selected_tab) - - self.tabCount = 0 - if flight: - self.general_settings_tab = QGeneralFlightSettingsTab(flight, game, planner) - self.general_settings_tab.on_flight_settings_changed.connect(lambda: self.on_planned_flight_changed.emit()) - self.payload_tab = QFlightPayloadTab(flight, game) - self.waypoint_tab = QFlightWaypointTab(game, flight) - self.waypoint_tab.on_flight_changed.connect(lambda: self.on_planned_flight_changed.emit()) - self.addTab(self.general_settings_tab, "General Flight settings") - self.addTab(self.payload_tab, "Payload") - self.addTab(self.waypoint_tab, "Waypoints") - self.tabCount = 3 - self.setCurrentIndex(selected_tab) - else: - tabError = QFrame() - l = QGridLayout() - l.addWidget(QLabel("No flight selected")) - tabError.setLayout(l) - self.addTab(tabError, "No flight") - self.tabCount = 1 - - def clearTabs(self): - for i in range(self.tabCount): - self.removeTab(i) + self.general_settings_tab = QGeneralFlightSettingsTab(game, flight) + self.general_settings_tab.on_flight_settings_changed.connect( + lambda: self.on_planned_flight_changed.emit()) + self.payload_tab = QFlightPayloadTab(flight, game) + self.waypoint_tab = QFlightWaypointTab(game, flight) + self.waypoint_tab.on_flight_changed.connect( + lambda: self.on_planned_flight_changed.emit()) + self.addTab(self.general_settings_tab, "General Flight settings") + self.addTab(self.payload_tab, "Payload") + self.addTab(self.waypoint_tab, "Waypoints") + self.setCurrentIndex(0) diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index 36a72dc4..4c8f3bac 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py @@ -6,12 +6,14 @@ class QFlightSlotEditor(QGroupBox): changed = Signal() - def __init__(self, flight, game, planner): + def __init__(self, flight, game): super(QFlightSlotEditor, self).__init__("Slots") self.flight = flight self.game = game - self.planner = planner - self.available = self.planner.get_available_aircraft() + inventory = self.game.aircraft_inventory.for_control_point( + flight.from_cp + ) + self.available = inventory.all_aircraft if self.flight.unit_type not in self.available: max = self.flight.count else: diff --git a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py index cabd99cf..99f2b63f 100644 --- a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py +++ b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py @@ -12,18 +12,15 @@ from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import QFlightTyp class QGeneralFlightSettingsTab(QFrame): on_flight_settings_changed = Signal() - def __init__(self, flight: Flight, game: Game, planner): + def __init__(self, game: Game, flight: Flight): super(QGeneralFlightSettingsTab, self).__init__() self.flight = flight self.game = game - self.planner = planner - self.init_ui() - def init_ui(self): layout = QGridLayout() flight_info = QFlightTypeTaskInfo(self.flight) flight_departure = QFlightDepartureEditor(self.flight) - flight_slots = QFlightSlotEditor(self.flight, self.game, self.planner) + flight_slots = QFlightSlotEditor(self.flight, self.game) flight_start_type = QFlightStartType(self.flight) layout.addWidget(flight_info, 0, 0) layout.addWidget(flight_departure, 1, 0) @@ -35,5 +32,7 @@ class QGeneralFlightSettingsTab(QFrame): self.setLayout(layout) flight_start_type.setEnabled(self.flight.client_count > 0) - flight_slots.changed.connect(lambda: flight_start_type.setEnabled(self.flight.client_count > 0)) - flight_departure.changed.connect(lambda: self.on_flight_settings_changed.emit()) + flight_slots.changed.connect( + lambda: flight_start_type.setEnabled(self.flight.client_count > 0)) + flight_departure.changed.connect( + lambda: self.on_flight_settings_changed.emit()) diff --git a/theater/__init__.py b/theater/__init__.py index 282ea4f3..209a6646 100644 --- a/theater/__init__.py +++ b/theater/__init__.py @@ -1,3 +1,5 @@ -from .controlpoint import * -from .conflicttheater import * from .base import * +from .conflicttheater import * +from .controlpoint import * +from .frontline import FrontLine +from .missiontarget import MissionTarget diff --git a/theater/conflicttheater.py b/theater/conflicttheater.py index 621f106a..b796ed3e 100644 --- a/theater/conflicttheater.py +++ b/theater/conflicttheater.py @@ -46,7 +46,7 @@ COAST_DR_W = [135, 180, 225, 315] class ConflictTheater: terrain = None # type: dcs.terrain.Terrain - controlpoints = None # type: typing.Collection[ControlPoint] + controlpoints = None # type: typing.List[ControlPoint] reference_points = None # type: typing.Dict overview_image = None # type: str diff --git a/theater/controlpoint.py b/theater/controlpoint.py index 2aba30ca..a35cd698 100644 --- a/theater/controlpoint.py +++ b/theater/controlpoint.py @@ -3,11 +3,17 @@ import typing from enum import Enum from dcs.mapping import * -from dcs.terrain import Airport -from dcs.ships import CVN_74_John_C__Stennis, LHA_1_Tarawa, CV_1143_5_Admiral_Kuznetsov, Type_071_Amphibious_Transport_Dock +from dcs.ships import ( + CVN_74_John_C__Stennis, + CV_1143_5_Admiral_Kuznetsov, + LHA_1_Tarawa, + Type_071_Amphibious_Transport_Dock, +) +from dcs.terrain.terrain import Airport from game import db from gen.ground_forces.combat_stance import CombatStance +from .missiontarget import MissionTarget from .theatergroundobject import TheaterGroundObject @@ -19,7 +25,7 @@ class ControlPointType(Enum): FOB = 5 # A FOB (ground units only) -class ControlPoint: +class ControlPoint(MissionTarget): id = 0 position = None # type: Point @@ -206,4 +212,3 @@ class ControlPoint: if g.obj_name == obj_name: found.append(g) return found - diff --git a/theater/frontline.py b/theater/frontline.py new file mode 100644 index 00000000..6350e1ab --- /dev/null +++ b/theater/frontline.py @@ -0,0 +1,27 @@ +"""Battlefield front lines.""" +from typing import Tuple + +from . import ControlPoint, MissionTarget + + +class FrontLine(MissionTarget): + """Defines a front line location between two control points. + + Front lines are the area where ground combat happens. + """ + + def __init__(self, control_point_a: ControlPoint, + control_point_b: ControlPoint) -> None: + self.control_point_a = control_point_a + self.control_point_b = control_point_b + + @property + def control_points(self) -> Tuple[ControlPoint, ControlPoint]: + """Returns a tuple of the two control points.""" + return self.control_point_a, self.control_point_b + + @property + def name(self) -> str: + a = self.control_point_a.name + b = self.control_point_b.name + return f"Front line {a}/{b}" diff --git a/theater/missiontarget.py b/theater/missiontarget.py new file mode 100644 index 00000000..41c90ef9 --- /dev/null +++ b/theater/missiontarget.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class MissionTarget(ABC): + # TODO: These should just be required objects to the constructor + # The TheatherGroundObject class is difficult to modify because it's + # generated data that's pickled ahead of time. + @property + @abstractmethod + def name(self) -> str: + """The name of the mission target.""" diff --git a/theater/theatergroundobject.py b/theater/theatergroundobject.py index 42fc69f1..3ffa86f2 100644 --- a/theater/theatergroundobject.py +++ b/theater/theatergroundobject.py @@ -1,6 +1,9 @@ -from dcs.mapping import Point import uuid +from dcs.mapping import Point + +from .missiontarget import MissionTarget + NAME_BY_CATEGORY = { "power": "Power plant", "ammo": "Ammo depot", @@ -59,7 +62,7 @@ CATEGORY_MAP = { } -class TheaterGroundObject: +class TheaterGroundObject(MissionTarget): cp_id = 0 group_id = 0 object_id = 0 @@ -93,3 +96,15 @@ class TheaterGroundObject: def matches_string_identifier(self, id): return self.string_identifier == id + + @property + def name(self) -> str: + return self.obj_name + + def parent_control_point( + self, theater: "ConflictTheater") -> "ControlPoint": + """Searches the theater for the parent control point.""" + for cp in theater.controlpoints: + if cp.id == self.cp_id: + return cp + raise RuntimeError("Could not find matching control point in theater")