diff --git a/changelog.md b/changelog.md index 028b2f9d..6c31edd2 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,7 @@ * **[Mission Planning]** Avoid helicopters being assigned as escort to planes and vice-versa * **[Mission Planning]** Allow attack helicopters to escort other helicopters * **[UI]** Allow changing waypoint names in FlightEdit's waypoints tab +* **[Waypoints]** Allow user to add navigation waypoints where possible without degrading to a custom flight-plan ## Fixes * **[Mission Generation]** Anti-ship strikes should use "group attack" in their attack-task diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py index c4f8d53c..73e5ccbb 100644 --- a/game/ato/flightplans/airlift.py +++ b/game/ato/flightplans/airlift.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterator from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING, Type +from typing import TYPE_CHECKING, Type, Optional from game.theater.missiontarget import MissionTarget from game.utils import feet @@ -12,6 +12,7 @@ from .ibuilder import IBuilder from .planningerror import PlanningError from .standard import StandardFlightPlan, StandardLayout from .waypointbuilder import WaypointBuilder +from ..flightwaypointtype import FlightWaypointType from ...theater.interfaces.CTLD import CTLD if TYPE_CHECKING: @@ -21,7 +22,6 @@ if TYPE_CHECKING: @dataclass class AirliftLayout(StandardLayout): - nav_to_pickup: list[FlightWaypoint] # There will not be a pickup waypoint when the pickup airfield is the departure # airfield for cargo planes, as the cargo is pre-loaded. Helicopters will still pick # up the cargo near the airfield. @@ -36,25 +36,30 @@ class AirliftLayout(StandardLayout): drop_off: FlightWaypoint | None # drop_off_zone will be used for player flights to create the CTLD stuff ctld_drop_off_zone: FlightWaypoint | None - nav_to_home: list[FlightWaypoint] + + def add_waypoint( + self, wpt: FlightWaypoint, next_wpt: Optional[FlightWaypoint] + ) -> bool: + new_wpt = self.get_midpoint(wpt, next_wpt) + if wpt.waypoint_type in [ + FlightWaypointType.PICKUP_ZONE, + FlightWaypointType.CARGO_STOP, + ]: + self.nav_to_drop_off.insert(0, new_wpt) + return True + return super().add_waypoint(wpt, next_wpt) def delete_waypoint(self, waypoint: FlightWaypoint) -> bool: - if super().delete_waypoint(waypoint): - return True - if waypoint in self.nav_to_pickup: - self.nav_to_pickup.remove(waypoint) - return True - elif waypoint in self.nav_to_drop_off: + if waypoint in self.nav_to_drop_off: self.nav_to_drop_off.remove(waypoint) return True - elif waypoint in self.nav_to_home: - self.nav_to_home.remove(waypoint) + elif super().delete_waypoint(waypoint): return True return False def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure - yield from self.nav_to_pickup + yield from self.nav_to if self.pickup is not None: yield self.pickup if self.ctld_pickup_zone is not None: @@ -64,7 +69,7 @@ class AirliftLayout(StandardLayout): yield self.drop_off if self.ctld_drop_off_zone is not None: yield self.ctld_drop_off_zone - yield from self.nav_to_home + yield from self.nav_from yield self.arrival if self.divert is not None: yield self.divert @@ -141,7 +146,7 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): return AirliftLayout( departure=builder.takeoff(self.flight.departure), - nav_to_pickup=nav_to_pickup, + nav_to=nav_to_pickup, pickup=pickup, ctld_pickup_zone=pickup_zone, nav_to_drop_off=builder.nav_path( @@ -152,7 +157,7 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): ), drop_off=drop_off, ctld_drop_off_zone=drop_off_zone, - nav_to_home=builder.nav_path( + nav_from=builder.nav_path( cargo.origin.position, self.flight.arrival.position, altitude, diff --git a/game/ato/flightplans/custom.py b/game/ato/flightplans/custom.py index 82e8ff3b..54c5a2bd 100644 --- a/game/ato/flightplans/custom.py +++ b/game/ato/flightplans/custom.py @@ -29,6 +29,10 @@ class CustomFlightPlan(FlightPlan[CustomLayout]): def builder_type() -> Type[Builder]: return Builder + @property + def is_custom(self) -> bool: + return True + @property def tot_waypoint(self) -> FlightWaypoint: target_types = ( diff --git a/game/ato/flightplans/ferry.py b/game/ato/flightplans/ferry.py index de55cced..93579fba 100644 --- a/game/ato/flightplans/ferry.py +++ b/game/ato/flightplans/ferry.py @@ -17,19 +17,9 @@ if TYPE_CHECKING: @dataclass class FerryLayout(StandardLayout): - nav_to_destination: list[FlightWaypoint] - - def delete_waypoint(self, waypoint: FlightWaypoint) -> bool: - if super().delete_waypoint(waypoint): - return True - if waypoint in self.nav_to_destination: - self.nav_to_destination.remove(waypoint) - return True - return False - def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure - yield from self.nav_to_destination + yield from self.nav_to yield self.arrival if self.divert is not None: yield self.divert @@ -76,7 +66,7 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]): builder = WaypointBuilder(self.flight, self.coalition) return FerryLayout( departure=builder.takeoff(self.flight.departure), - nav_to_destination=builder.nav_path( + nav_to=builder.nav_path( self.flight.departure.position, self.flight.arrival.position, altitude, @@ -85,6 +75,7 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]): arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), + nav_from=[], ) def build(self) -> FerryFlightPlan: diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py index 4a2ac509..7627cd8f 100644 --- a/game/ato/flightplans/flightplan.py +++ b/game/ato/flightplans/flightplan.py @@ -317,6 +317,10 @@ class FlightPlan(ABC, Generic[LayoutT]): def is_airassault(self) -> bool: return False + @property + def is_custom(self) -> bool: + return False + @property def mission_departure_time(self) -> timedelta: """The time that the mission is complete and the flight RTBs.""" diff --git a/game/ato/flightplans/formation.py b/game/ato/flightplans/formation.py index b71aac8b..0aa4e338 100644 --- a/game/ato/flightplans/formation.py +++ b/game/ato/flightplans/formation.py @@ -18,24 +18,16 @@ if TYPE_CHECKING: @dataclass class FormationLayout(LoiterLayout, ABC): - nav_to: list[FlightWaypoint] join: Optional[FlightWaypoint] split: FlightWaypoint refuel: Optional[FlightWaypoint] - nav_from: list[FlightWaypoint] def delete_waypoint(self, waypoint: FlightWaypoint) -> bool: - if super().delete_waypoint(waypoint): - return True - if waypoint in self.nav_to: - self.nav_to.remove(waypoint) - return True - elif waypoint in self.nav_from: - self.nav_from.remove(waypoint) - return True - elif waypoint == self.refuel: + if waypoint == self.refuel: self.refuel = None return True + elif super().delete_waypoint(waypoint): + return True return False diff --git a/game/ato/flightplans/patrolling.py b/game/ato/flightplans/patrolling.py index a1c20ff9..67d0bb16 100644 --- a/game/ato/flightplans/patrolling.py +++ b/game/ato/flightplans/patrolling.py @@ -18,21 +18,8 @@ if TYPE_CHECKING: @dataclass class PatrollingLayout(StandardLayout): - nav_to: list[FlightWaypoint] patrol_start: FlightWaypoint patrol_end: FlightWaypoint - nav_from: list[FlightWaypoint] - - def delete_waypoint(self, waypoint: FlightWaypoint) -> bool: - if super().delete_waypoint(waypoint): - return True - if waypoint in self.nav_to: - self.nav_to.remove(waypoint) - return True - elif waypoint in self.nav_from: - self.nav_from.remove(waypoint) - return True - return False def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure diff --git a/game/ato/flightplans/rtb.py b/game/ato/flightplans/rtb.py index b5fd5a11..291d575a 100644 --- a/game/ato/flightplans/rtb.py +++ b/game/ato/flightplans/rtb.py @@ -18,12 +18,11 @@ if TYPE_CHECKING: @dataclass class RtbLayout(StandardLayout): abort_location: FlightWaypoint - nav_to_destination: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure yield self.abort_location - yield from self.nav_to_destination + yield from self.nav_to yield self.arrival if self.divert is not None: yield self.divert @@ -78,7 +77,7 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]): return RtbLayout( departure=builder.takeoff(self.flight.departure), abort_location=abort_point, - nav_to_destination=builder.nav_path( + nav_to=builder.nav_path( current_position, self.flight.arrival.position, altitude, @@ -87,6 +86,7 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]): arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), + nav_from=[], ) def build(self) -> RtbFlightPlan: diff --git a/game/ato/flightplans/standard.py b/game/ato/flightplans/standard.py index 89c5354c..4bdfbdd9 100644 --- a/game/ato/flightplans/standard.py +++ b/game/ato/flightplans/standard.py @@ -1,10 +1,14 @@ from __future__ import annotations from abc import ABC +from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, TypeVar, Optional from game.ato.flightplans.flightplan import FlightPlan, Layout +from .waypointbuilder import WaypointBuilder +from ..flightwaypointtype import FlightWaypointType +from ...utils import feet if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint @@ -15,11 +19,57 @@ class StandardLayout(Layout, ABC): arrival: FlightWaypoint divert: FlightWaypoint | None bullseye: FlightWaypoint + nav_to: list[FlightWaypoint] + nav_from: list[FlightWaypoint] + + def add_waypoint( + self, wpt: FlightWaypoint, next_wpt: Optional[FlightWaypoint] + ) -> bool: + new_wpt = self.get_midpoint(wpt, next_wpt) + if wpt.waypoint_type in [FlightWaypointType.TAKEOFF, FlightWaypointType.LOITER]: + self.nav_to.insert(0, new_wpt) + return True + elif wpt.waypoint_type in [ + FlightWaypointType.SPLIT, + FlightWaypointType.REFUEL, + FlightWaypointType.PATROL, + FlightWaypointType.EGRESS, + ]: + self.nav_from.insert(0, new_wpt) + return True + elif wpt.waypoint_type is FlightWaypointType.NAV: + if wpt in self.nav_to: + index = self.nav_to.index(wpt) + 1 + self.nav_to.insert(index, new_wpt) + return True + elif wpt in self.nav_from: + index = self.nav_from.index(wpt) + 1 + self.nav_from.insert(index, new_wpt) + return True + return False + + @staticmethod + def get_midpoint( + wpt: FlightWaypoint, next_wpt: Optional[FlightWaypoint] + ) -> FlightWaypoint: + new_pos = deepcopy(wpt.position) + next_alt = feet(20000) + if next_wpt: + new_pos = wpt.position.lerp(next_wpt.position, 0.5) + next_alt = next_wpt.alt + new_wpt = WaypointBuilder.nav(new_pos, max(wpt.alt, next_alt)) + return new_wpt def delete_waypoint(self, waypoint: FlightWaypoint) -> bool: if waypoint is self.divert: self.divert = None return True + elif waypoint in self.nav_to: + self.nav_to.remove(waypoint) + return True + elif waypoint in self.nav_from: + self.nav_from.remove(waypoint) + return True return False diff --git a/game/ato/flightplans/tarcap.py b/game/ato/flightplans/tarcap.py index cf8bfc9b..880c7e29 100644 --- a/game/ato/flightplans/tarcap.py +++ b/game/ato/flightplans/tarcap.py @@ -32,6 +32,14 @@ class TarCapLayout(PatrollingLayout): yield self.divert yield self.bullseye + def delete_waypoint(self, waypoint: FlightWaypoint) -> bool: + if waypoint == self.refuel: + self.refuel = None + return True + elif super().delete_waypoint(waypoint): + return True + return False + class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]): @property diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointItem.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointItem.py index e0c0e7c1..228fb9d2 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointItem.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointItem.py @@ -1,4 +1,4 @@ -from PySide2.QtGui import QStandardItem +from PySide2.QtGui import QStandardItem, Qt from game.ato.flightwaypoint import FlightWaypoint @@ -6,6 +6,7 @@ from game.ato.flightwaypoint import FlightWaypoint class QWaypointItem(QStandardItem): def __init__(self, point: FlightWaypoint, number): super(QWaypointItem, self).__init__() + self.setData(point, Qt.UserRole) self.number = number self.setText("{:<16}".format(point.pretty_name)) self.setEditable(True) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 42e488f9..4305554b 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -78,6 +78,7 @@ class QFlightWaypointList(QTableView): finally: # stop ignoring signals self.model.blockSignals(False) + self.update() def _add_waypoint_row( self, row: int, flight: Flight, waypoint: FlightWaypoint diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index f075ffc0..ebda1ebc 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -1,7 +1,7 @@ import logging from typing import Iterable, List, Optional -from PySide2.QtCore import Signal +from PySide2.QtCore import Signal, Qt, QModelIndex from PySide2.QtWidgets import ( QFrame, QGridLayout, @@ -9,6 +9,7 @@ from PySide2.QtWidgets import ( QMessageBox, QPushButton, QVBoxLayout, + QWidget, ) from game import Game @@ -42,6 +43,7 @@ class QFlightWaypointTab(QFrame): self.flight_waypoint_list: Optional[QFlightWaypointList] = None self.rtb_waypoint: Optional[QPushButton] = None self.delete_selected: Optional[QPushButton] = None + self.add_nav_waypoint: Optional[QPushButton] = None self.open_fast_waypoint_button: Optional[QPushButton] = None self.recreate_buttons: List[QPushButton] = [] self.init_ui() @@ -77,6 +79,10 @@ class QFlightWaypointTab(QFrame): rlayout.addWidget(button) self.recreate_buttons.append(button) + self.add_nav_waypoint = QPushButton("Insert NAV point") + self.add_nav_waypoint.clicked.connect(self.on_add_nav) + rlayout.addWidget(self.add_nav_waypoint) + rlayout.addWidget(QLabel("Advanced : ")) rlayout.addWidget(QLabel("Do not use for AI flights")) @@ -94,6 +100,31 @@ class QFlightWaypointTab(QFrame): rlayout.addStretch() self.setLayout(layout) + def on_add_nav(self): + selected = self.flight_waypoint_list.selectedIndexes() + if not selected: + return + index: QModelIndex = selected[0] + self.flight_waypoint_list.setCurrentIndex(index) + wpt: FlightWaypoint = self.flight_waypoint_list.model.data(index, Qt.UserRole) + next_wpt: Optional[FlightWaypoint] = None + if index.row() + 1 < self.flight_waypoint_list.model.rowCount(): + next_wpt = self.flight_waypoint_list.model.data( + index.siblingAtRow(index.row() + 1), Qt.UserRole + ) + if not self.flight.flight_plan.layout.add_waypoint(wpt, next_wpt): + QMessageBox.critical( + QWidget(), + "Failed to add NAV waypoint", + "Could not insert a new waypoint given the currently selected waypoint.\n" + "Please select a different waypoint to insert the new NAV waypoint.", + ) + else: + self.flight_waypoint_list.model.insertRow( + self.flight_waypoint_list.model.rowCount() + ) + self.on_change() + def on_delete_waypoint(self): waypoints = [] selection = self.flight_waypoint_list.selectionModel() @@ -102,7 +133,6 @@ class QFlightWaypointTab(QFrame): waypoints.append(self.flight.flight_plan.waypoints[selected_row.row()]) for waypoint in waypoints: self.delete_waypoint(waypoint) - self.flight_waypoint_list.update_list() self.on_change() def delete_waypoint(self, waypoint: FlightWaypoint) -> None: @@ -116,10 +146,21 @@ class QFlightWaypointTab(QFrame): if is_target and count > 1: fp.target_area_waypoint.targets.remove(waypoint) return - + model = self.flight_waypoint_list.model if fp.layout.delete_waypoint(waypoint): + model.removeRow(model.rowCount() - 1) return + if not self.flight.flight_plan.is_custom: + confirmed = self.confirm_degrade() + if not confirmed: + return + model.removeRow(model.rowCount() - 1) + self.degrade_to_custom_flight_plan() + assert isinstance(self.flight.flight_plan, CustomFlightPlan) + self.flight.flight_plan.layout.custom_waypoints.remove(waypoint) + + def confirm_degrade(self) -> bool: result = QMessageBox.warning( self, "Degrade flight-plan?", @@ -129,11 +170,7 @@ class QFlightWaypointTab(QFrame): QMessageBox.Yes, QMessageBox.No, ) - if result == QMessageBox.No: - return - self.degrade_to_custom_flight_plan() - assert isinstance(self.flight.flight_plan, CustomFlightPlan) - self.flight.flight_plan.layout.custom_waypoints.remove(waypoint) + return result == QMessageBox.Yes def on_fast_waypoint(self): self.subwindow = QPredefinedWaypointSelectionWindow( @@ -145,10 +182,15 @@ class QFlightWaypointTab(QFrame): def on_waypoints_added(self, waypoints: Iterable[FlightWaypoint]) -> None: if not waypoints: return + if not self.flight.flight_plan.is_custom: + confirmed = self.confirm_degrade() + if not confirmed: + return self.degrade_to_custom_flight_plan() assert isinstance(self.flight.flight_plan, CustomFlightPlan) self.flight.flight_plan.layout.custom_waypoints.extend(waypoints) - self.flight_waypoint_list.update_list() + rc = self.flight_waypoint_list.model.rowCount() + self.flight_waypoint_list.model.insertRows(rc, len(list(waypoints))) self.on_change() def on_rtb_waypoint(self): @@ -156,7 +198,6 @@ class QFlightWaypointTab(QFrame): self.degrade_to_custom_flight_plan() assert isinstance(self.flight.flight_plan, CustomFlightPlan) self.flight.flight_plan.layout.custom_waypoints.append(rtb) - self.flight_waypoint_list.update_list() self.on_change() def degrade_to_custom_flight_plan(self) -> None: @@ -188,8 +229,9 @@ class QFlightWaypointTab(QFrame): if not self.flight.loadout.is_custom: self.flight.loadout = Loadout.default_for(self.flight) self.loadout_changed.emit() - self.flight_waypoint_list.update_list() self.on_change() def on_change(self): self.flight_waypoint_list.update_list() + self.flight_waypoint_list.on_changed() + self.update()