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()