From a9fcfe60f4f7befd5e2ec1667b021ae897f49c1f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 20 Nov 2020 15:36:19 -0800 Subject: [PATCH] Add arrival/divert airfield selection. Breaks save compat because it adds new fields to `Flight` that have no constant default. Removing all of our other save compat at the same time. Note that player flights with a divert point will have a nav point for their actual landing point. This is because we place the divert point last, and DCS won't let us have a land point anywhere but the final waypoint. It would allow a LandingReFuAr point, but they're only generated for player flights anyway so it doesn't really matter. Fixes https://github.com/Khopa/dcs_liberation/issues/342 --- gen/aircraft.py | 35 +++---- gen/flights/ai_flight_planner.py | 6 +- gen/flights/flight.py | 14 ++- gen/flights/flightplan.py | 96 +++++++++++-------- gen/flights/waypointbuilder.py | 34 +++++++ .../combos/QArrivalAirfieldSelector.py | 47 +++++++++ qt_ui/widgets/map/QLiberationMap.py | 5 +- .../windows/mission/flight/QFlightCreator.py | 46 +++++++-- 8 files changed, 213 insertions(+), 70 deletions(-) create mode 100644 qt_ui/widgets/combos/QArrivalAirfieldSelector.py diff --git a/gen/aircraft.py b/gen/aircraft.py index 9edc52f4..13de2c05 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -695,6 +695,18 @@ class AircraftConflictGenerator: return StartType.Cold return StartType.Warm + def determine_runway(self, cp: ControlPoint, dynamic_runways) -> RunwayData: + fallback = RunwayData(cp.full_name, runway_heading=0, runway_name="") + if cp.cptype == ControlPointType.AIRBASE: + assigner = RunwayAssigner(self.game.conditions) + return assigner.get_preferred_runway(cp.airport) + elif cp.is_fleet: + return dynamic_runways.get(cp.name, fallback) + else: + logging.warning( + f"Unhandled departure/arrival control point: {cp.cptype}") + return fallback + def _setup_group(self, group: FlyingGroup, for_task: Type[Task], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData]) -> None: @@ -752,19 +764,9 @@ class AircraftConflictGenerator: channel = self.get_intra_flight_channel(unit_type) group.set_frequency(channel.mhz) - # TODO: Support for different departure/arrival airfields. - cp = flight.from_cp - fallback_runway = RunwayData(cp.full_name, runway_heading=0, - runway_name="") - if cp.cptype == ControlPointType.AIRBASE: - assigner = RunwayAssigner(self.game.conditions) - departure_runway = assigner.get_preferred_runway( - flight.from_cp.airport) - elif cp.is_fleet: - departure_runway = dynamic_runways.get(cp.name, fallback_runway) - else: - logging.warning(f"Unhandled departure control point: {cp.cptype}") - departure_runway = fallback_runway + divert = None + if flight.divert is not None: + divert = self.determine_runway(flight.divert, dynamic_runways) self.flights.append(FlightData( package=package, @@ -774,10 +776,9 @@ class AircraftConflictGenerator: friendly=flight.from_cp.captured, # Set later. departure_delay=timedelta(), - departure=departure_runway, - arrival=departure_runway, - # TODO: Support for divert airfields. - divert=None, + departure=self.determine_runway(flight.departure, dynamic_runways), + arrival=self.determine_runway(flight.arrival, dynamic_runways), + divert=divert, # Waypoints are added later, after they've had their TOTs set. waypoints=[], intra_flight_channel=channel diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 7d152efa..1cd48ed7 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -236,8 +236,10 @@ class PackageBuilder: start_type = "In Flight" else: start_type = self.start_type - flight = Flight(self.package, aircraft, plan.num_aircraft, airfield, - plan.task, start_type) + + flight = Flight(self.package, aircraft, plan.num_aircraft, plan.task, + start_type, departure=airfield, arrival=airfield, + divert=None) self.package.add_flight(flight) return True diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 2b5e35ea..56d9d04a 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -65,6 +65,7 @@ class FlightWaypointType(Enum): INGRESS_DEAD = 20 INGRESS_SWEEP = 21 INGRESS_BAI = 22 + DIVERT = 23 class FlightWaypoint: @@ -133,12 +134,15 @@ class FlightWaypoint: class Flight: def __init__(self, package: Package, unit_type: FlyingType, count: int, - from_cp: ControlPoint, flight_type: FlightType, - start_type: str) -> None: + flight_type: FlightType, start_type: str, + departure: ControlPoint, arrival: ControlPoint, + divert: Optional[ControlPoint]) -> None: self.package = package self.unit_type = unit_type self.count = count - self.from_cp = from_cp + self.departure = departure + self.arrival = arrival + self.divert = divert self.flight_type = flight_type # TODO: Replace with FlightPlan. self.targets: List[MissionTarget] = [] @@ -157,6 +161,10 @@ class Flight: custom_waypoints=[] ) + @property + def from_cp(self) -> ControlPoint: + return self.departure + @property def points(self) -> List[FlightWaypoint]: return self.flight_plan.waypoints[1:] diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 918861e2..8df8dc5f 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -68,6 +68,10 @@ class FlightPlan: @property def waypoints(self) -> List[FlightWaypoint]: """A list of all waypoints in the flight plan, in order.""" + return list(self.iter_waypoints()) + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + """Iterates over all waypoints in the flight plan, in order.""" raise NotImplementedError @property @@ -166,8 +170,7 @@ class FlightPlan: class LoiterFlightPlan(FlightPlan): hold: FlightWaypoint - @property - def waypoints(self) -> List[FlightWaypoint]: + def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @property @@ -193,8 +196,7 @@ class FormationFlightPlan(LoiterFlightPlan): join: FlightWaypoint split: FlightWaypoint - @property - def waypoints(self) -> List[FlightWaypoint]: + def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @property @@ -295,8 +297,7 @@ class PatrollingFlightPlan(FlightPlan): return self.patrol_end_time return None - @property - def waypoints(self) -> List[FlightWaypoint]: + def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @property @@ -312,15 +313,17 @@ class PatrollingFlightPlan(FlightPlan): class BarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.patrol_start, self.patrol_end, self.land, ] + if self.divert is not None: + yield self.divert @dataclass(frozen=True) @@ -328,16 +331,18 @@ class CasFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint target: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.patrol_start, self.target, self.patrol_end, self.land, ] + if self.divert is not None: + yield self.divert def request_escort_at(self) -> Optional[FlightWaypoint]: return self.patrol_start @@ -350,16 +355,18 @@ class CasFlightPlan(PatrollingFlightPlan): class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] lead_time: timedelta - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.patrol_start, self.patrol_end, self.land, ] + if self.divert is not None: + yield self.divert @property def tot_offset(self) -> timedelta: @@ -400,19 +407,23 @@ class StrikeFlightPlan(FormationFlightPlan): egress: FlightWaypoint split: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.hold, self.join, self.ingress - ] + self.targets + [ + ] + yield from self.targets + yield from[ self.egress, self.split, self.land, ] + if self.divert is not None: + yield self.divert @property def package_speed_waypoints(self) -> Set[FlightWaypoint]: @@ -511,17 +522,19 @@ class SweepFlightPlan(LoiterFlightPlan): sweep_start: FlightWaypoint sweep_end: FlightWaypoint land: FlightWaypoint + divert: Optional[FlightWaypoint] lead_time: timedelta - @property - def waypoints(self) -> List[FlightWaypoint]: - return [ + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from [ self.takeoff, self.hold, self.sweep_start, self.sweep_end, self.land, ] + if self.divert is not None: + yield self.divert @property def tot_waypoint(self) -> Optional[FlightWaypoint]: @@ -567,9 +580,8 @@ class SweepFlightPlan(LoiterFlightPlan): class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] - @property - def waypoints(self) -> List[FlightWaypoint]: - return self.custom_waypoints + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from self.custom_waypoints @property def tot_waypoint(self) -> Optional[FlightWaypoint]: @@ -774,10 +786,11 @@ class FlightPlanBuilder: package=self.package, flight=flight, patrol_duration=self.doctrine.cap_duration, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), patrol_start=start, patrol_end=end, - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def generate_sweep(self, flight: Flight) -> SweepFlightPlan: @@ -800,11 +813,12 @@ class FlightPlanBuilder: package=self.package, flight=flight, lead_time=timedelta(minutes=5), - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), sweep_start=start, sweep_end=end, - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def racetrack_for_objective(self, @@ -900,10 +914,11 @@ class FlightPlanBuilder: # requests an escort the CAP flight will remain on station for the # duration of the escorted mission, or until it is winchester/bingo. patrol_duration=self.doctrine.cap_duration, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), patrol_start=start, patrol_end=end, - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def generate_dead(self, flight: Flight, @@ -965,14 +980,15 @@ class FlightPlanBuilder: return StrikeFlightPlan( package=self.package, flight=flight, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=[target], egress=egress, split=builder.split(self.package.waypoints.split), - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def generate_cas(self, flight: Flight) -> CasFlightPlan: @@ -999,11 +1015,12 @@ class FlightPlanBuilder: package=self.package, flight=flight, patrol_duration=self.doctrine.cas_duration, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), patrol_start=builder.ingress_cas(ingress, location), target=builder.cas(center), patrol_end=builder.egress(egress, location), - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) @staticmethod @@ -1030,7 +1047,7 @@ class FlightPlanBuilder: def _hold_point(self, flight: Flight) -> Point: assert self.package.waypoints is not None - origin = flight.from_cp.position + origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join origin_to_target = origin.distance_to_point(target) @@ -1118,14 +1135,15 @@ class FlightPlanBuilder: return StrikeFlightPlan( package=self.package, flight=flight, - takeoff=builder.takeoff(flight.from_cp), + takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=target_waypoints, egress=builder.egress(self.package.waypoints.egress, location), split=builder.split(self.package.waypoints.split), - land=builder.land(flight.from_cp) + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert) ) def _retreating_rendezvous_point(self, attack_transition: Point) -> Point: @@ -1201,7 +1219,7 @@ class FlightPlanBuilder: ) for airfield in cache.closest_airfields: for flight in self.package.flights: - if flight.from_cp == airfield: + if flight.departure == airfield: return airfield raise RuntimeError( "Could not find any airfield assigned to this package" diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 346a4498..fa992419 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -104,6 +104,40 @@ class WaypointBuilder: waypoint.pretty_name = "Land" return waypoint + def divert(self, + divert: Optional[ControlPoint]) -> Optional[FlightWaypoint]: + """Create divert waypoint for the given arrival airfield or carrier. + + Args: + divert: Divert airfield or carrier. + """ + if divert is None: + return None + + position = divert.position + if isinstance(divert, OffMapSpawn): + if self.is_helo: + altitude = 500 + else: + altitude = self.doctrine.rendezvous_altitude + altitude_type = "BARO" + else: + altitude = 0 + altitude_type = "RADIO" + + waypoint = FlightWaypoint( + FlightWaypointType.DIVERT, + position.x, + position.y, + altitude + ) + waypoint.alt_type = altitude_type + waypoint.name = "DIVERT" + waypoint.description = "Divert" + waypoint.pretty_name = "Divert" + waypoint.only_for_player = True + return waypoint + def hold(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.LOITER, diff --git a/qt_ui/widgets/combos/QArrivalAirfieldSelector.py b/qt_ui/widgets/combos/QArrivalAirfieldSelector.py new file mode 100644 index 00000000..a1fe88bb --- /dev/null +++ b/qt_ui/widgets/combos/QArrivalAirfieldSelector.py @@ -0,0 +1,47 @@ +"""Combo box for selecting a departure airfield.""" +from typing import Iterable + +from PySide2.QtWidgets import QComboBox +from dcs.planes import PlaneType + +from game import db +from game.theater.controlpoint import ControlPoint + + +class QArrivalAirfieldSelector(QComboBox): + """A combo box for selecting a flight's arrival or divert airfield. + + The combo box will automatically be populated with all airfields the given + aircraft type is able to land at. + """ + + def __init__(self, destinations: Iterable[ControlPoint], + aircraft: PlaneType, optional_text: str) -> None: + super().__init__() + self.destinations = list(destinations) + self.aircraft = aircraft + self.optional_text = optional_text + self.rebuild_selector() + self.setCurrentIndex(0) + + def change_aircraft(self, aircraft: PlaneType) -> None: + if self.aircraft == aircraft: + return + self.aircraft = aircraft + self.rebuild_selector() + + def valid_destination(self, destination: ControlPoint) -> bool: + if destination.is_carrier and self.aircraft not in db.CARRIER_CAPABLE: + return False + if destination.is_lha and self.aircraft not in db.LHA_CAPABLE: + return False + return True + + def rebuild_selector(self) -> None: + self.clear() + for destination in self.destinations: + if self.valid_destination(destination): + self.addItem(destination.name, destination) + self.model().sort(0) + self.insertItem(0, self.optional_text, None) + self.update() diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index fb5802c3..d0189203 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -373,6 +373,10 @@ class QLiberationMap(QGraphicsView): FlightWaypointType.TARGET_SHIP, ) for idx, point in enumerate(flight.flight_plan.waypoints[1:]): + if point.waypoint_type == FlightWaypointType.DIVERT: + # Don't clutter the map showing divert points. + continue + new_pos = self._transform_point(Point(point.x, point.y)) self.draw_flight_path(scene, prev_pos, new_pos, is_player, selected) @@ -386,7 +390,6 @@ class QLiberationMap(QGraphicsView): self.draw_waypoint_info(scene, idx + 1, point, new_pos, flight.flight_plan) prev_pos = tuple(new_pos) - self.draw_flight_path(scene, prev_pos, pos, is_player, selected) def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], player: bool, selected: bool) -> None: diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 80fbf219..604bcd57 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -16,6 +16,8 @@ from qt_ui.uiconstants import EVENT_ICONS 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.QArrivalAirfieldSelector import \ + QArrivalAirfieldSelector from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector from theater import ControlPoint, OffMapSpawn @@ -49,16 +51,30 @@ class QFlightCreator(QDialog): self.on_aircraft_changed) layout.addLayout(QLabeledWidget("Aircraft:", self.aircraft_selector)) - self.airfield_selector = QOriginAirfieldSelector( + self.departure = QOriginAirfieldSelector( self.game.aircraft_inventory, [cp for cp in game.theater.controlpoints if cp.captured], self.aircraft_selector.currentData() ) - self.airfield_selector.availability_changed.connect(self.update_max_size) - layout.addLayout(QLabeledWidget("Airfield:", self.airfield_selector)) + self.departure.availability_changed.connect(self.update_max_size) + layout.addLayout(QLabeledWidget("Departure:", self.departure)) + + self.arrival = QArrivalAirfieldSelector( + [cp for cp in game.theater.controlpoints if cp.captured], + self.aircraft_selector.currentData(), + "Same as departure" + ) + layout.addLayout(QLabeledWidget("Arrival:", self.arrival)) + + self.divert = QArrivalAirfieldSelector( + [cp for cp in game.theater.controlpoints if cp.captured], + self.aircraft_selector.currentData(), + "None" + ) + layout.addLayout(QLabeledWidget("Divert:", self.divert)) self.flight_size_spinner = QFlightSizeSpinner() - self.update_max_size(self.airfield_selector.available) + self.update_max_size(self.departure.available) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) self.client_slots_spinner = QFlightSizeSpinner( @@ -82,10 +98,16 @@ class QFlightCreator(QDialog): def verify_form(self) -> Optional[str]: aircraft: PlaneType = self.aircraft_selector.currentData() - origin: ControlPoint = self.airfield_selector.currentData() + origin: ControlPoint = self.departure.currentData() + arrival: ControlPoint = self.arrival.currentData() + divert: ControlPoint = self.divert.currentData() size: int = self.flight_size_spinner.value() if not origin.captured: return f"{origin.name} is not owned by your coalition." + if arrival is not None and not arrival.captured: + return f"{arrival.name} is not owned by your coalition." + if divert is not None and not divert.captured: + return f"{divert.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." @@ -104,16 +126,22 @@ class QFlightCreator(QDialog): task = self.task_selector.currentData() aircraft = self.aircraft_selector.currentData() - origin = self.airfield_selector.currentData() + origin = self.departure.currentData() + arrival = self.arrival.currentData() + divert = self.divert.currentData() size = self.flight_size_spinner.value() + if arrival is None: + arrival = origin + if isinstance(origin, OffMapSpawn): start_type = "In Flight" elif self.game.settings.perf_ai_parking_start: start_type = "Cold" else: start_type = "Warm" - flight = Flight(self.package, aircraft, size, origin, task, start_type) + flight = Flight(self.package, aircraft, size, task, start_type, origin, + arrival, divert) flight.client_count = self.client_slots_spinner.value() # noinspection PyUnresolvedReferences @@ -122,7 +150,9 @@ class QFlightCreator(QDialog): def on_aircraft_changed(self, index: int) -> None: new_aircraft = self.aircraft_selector.itemData(index) - self.airfield_selector.change_aircraft(new_aircraft) + self.departure.change_aircraft(new_aircraft) + self.arrival.change_aircraft(new_aircraft) + self.divert.change_aircraft(new_aircraft) def update_max_size(self, available: int) -> None: self.flight_size_spinner.setMaximum(min(available, 4))