Plan waypoint TOTs.

Also fixes the CAP racetracks so the AI actually stays on station.

Waypoint TOT assignment happens at mission generation time for the
sake of the UI. It's a bit messy since we have the late-initialized
field in FlightWaypoint, but on the other hand we don't have to reset
every extant waypoint whenever the player adjusts the mission's TOT.

If we want to clean this up a bit more, we could have two distinct
types for waypoints: one for the planning stage and one with the
resolved TOTs. We already do some thing like this with Flight vs
FlightData.

Future improvements:

* Estimate the group's ground speed so we don't need such wide margins
  of error.
* Delay takeoff to cut loiter fuel cost.
* Plan mission TOT based on the aircraft in the package and their
  travel times to the objective.
* Tune target area time prediction. Flights often don't need to travel
  all the way to the target point, and probably won't be doing it
  slowly, so the current planning causes a lot of extra time spent in
  enemy territory.
* Per-flight TOT offsets from the package to allow a sweep to arrive
  before the rest, etc.
This commit is contained in:
Dan Albert
2020-10-05 23:07:37 -07:00
parent 7abe32be5c
commit b5e5a3b2da
12 changed files with 782 additions and 226 deletions

View File

@@ -1,4 +1,6 @@
"""Qt data models for game objects."""
import datetime
from enum import auto, IntEnum
from typing import Any, Callable, Dict, Iterator, TypeVar, Optional
from PySide2.QtCore import (
@@ -156,6 +158,9 @@ class PackageModel(QAbstractListModel):
"""Returns the flight located at the given index."""
return self.package.flights[index.row()]
def update_tot(self, tot: int) -> None:
self.package.time_over_target = tot
@property
def mission_target(self) -> MissionTarget:
"""Returns the mission target of the package."""
@@ -178,6 +183,8 @@ class PackageModel(QAbstractListModel):
class AtoModel(QAbstractListModel):
"""The model for an AirTaskingOrder."""
PackageRole = Qt.UserRole
def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None:
super().__init__()
self.game = game
@@ -190,9 +197,11 @@ class AtoModel(QAbstractListModel):
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
return None
package = self.ato.packages[index.row()]
if role == Qt.DisplayRole:
package = self.ato.packages[index.row()]
return f"{package.package_description} {package.target.name}"
elif role == AtoModel.PackageRole:
return package
return None
def add_package(self, package: Package) -> None:

View File

@@ -1,8 +1,16 @@
"""Widgets for displaying air tasking orders."""
import datetime
import logging
from typing import Optional
from contextlib import contextmanager
from typing import ContextManager, Optional
from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize, Qt
from PySide2.QtCore import (
QItemSelectionModel,
QModelIndex,
QSize,
Qt,
)
from PySide2.QtGui import QFont, QFontMetrics, QPainter
from PySide2.QtWidgets import (
QAbstractItemView,
QGroupBox,
@@ -10,13 +18,13 @@ from PySide2.QtWidgets import (
QListView,
QPushButton,
QSplitter,
QVBoxLayout,
QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout,
)
from gen.ato import Package
from gen.flights.flight import Flight
from ..models import AtoModel, GameModel, NullListModel, PackageModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from ..models import AtoModel, GameModel, NullListModel, PackageModel
class QFlightList(QListView):
@@ -138,6 +146,65 @@ class QFlightPanel(QGroupBox):
GameUpdateSignal.get_instance().redraw_flight_paths()
@contextmanager
def painter_context(painter: QPainter) -> ContextManager[None]:
try:
painter.save()
yield
finally:
painter.restore()
class PackageDelegate(QStyledItemDelegate):
FONT_SIZE = 12
HMARGIN = 4
VMARGIN = 4
def get_font(self, option: QStyleOptionViewItem) -> QFont:
font = QFont(option.font)
font.setPointSize(self.FONT_SIZE)
return font
@staticmethod
def package(index: QModelIndex) -> Package:
return index.data(AtoModel.PackageRole)
def left_text(self, index: QModelIndex) -> str:
package = self.package(index)
return f"{package.package_description} {package.target.name}"
def right_text(self, index: QModelIndex) -> str:
package = self.package(index)
if package.time_over_target is None:
return ""
tot = datetime.timedelta(seconds=package.time_over_target)
return f"TOT T+{tot}"
def paint(self, painter: QPainter, option: QStyleOptionViewItem,
index: QModelIndex) -> None:
# Draw the list item with all the default selection styling, but with an
# invalid index so text formatting is left to us.
super().paint(painter, option, QModelIndex())
rect = option.rect.adjusted(self.HMARGIN, self.VMARGIN, -self.HMARGIN,
-self.VMARGIN)
with painter_context(painter):
painter.setFont(self.get_font(option))
painter.drawText(rect, Qt.AlignLeft, self.left_text(index))
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
painter.drawText(line2, Qt.AlignLeft, self.right_text(index))
def sizeHint(self, option: QStyleOptionViewItem,
index: QModelIndex) -> QSize:
metrics = QFontMetrics(self.get_font(option))
left = metrics.size(0, self.left_text(index))
right = metrics.size(0, self.right_text(index))
return QSize(max(left.width(), right.width()) + 2 * self.HMARGIN,
left.height() + right.height() + 2 * self.VMARGIN)
class QPackageList(QListView):
"""List view for displaying the packages of an ATO."""
@@ -145,6 +212,7 @@ class QPackageList(QListView):
super().__init__()
self.ato_model = model
self.setModel(model)
self.setItemDelegate(PackageDelegate())
self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)

View File

@@ -2,12 +2,13 @@
import logging
from typing import Optional
from PySide2.QtCore import QItemSelection, Signal
from PySide2.QtCore import QItemSelection, QTime, Signal
from PySide2.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QTimeEdit,
QVBoxLayout,
)
@@ -56,14 +57,39 @@ class QPackageDialog(QDialog):
self.summary_row = QHBoxLayout()
self.layout.addLayout(self.summary_row)
self.package_type_column = QHBoxLayout()
self.summary_row.addLayout(self.package_type_column)
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_type_column.addWidget(self.package_type_label)
self.package_type_column.addWidget(self.package_type_text)
self.summary_row.addStretch(1)
self.tot_column = QHBoxLayout()
self.summary_row.addLayout(self.tot_column)
self.tot_label = QLabel("Time Over Target:")
self.tot_column.addWidget(self.tot_label)
if self.package_model.package.time_over_target is None:
time = None
else:
delay = self.package_model.package.time_over_target
hours = delay // 3600
minutes = delay // 60 % 60
seconds = delay % 60
time = QTime(hours, minutes, seconds)
self.tot_spinner = QTimeEdit(time)
self.tot_spinner.setMinimumTime(QTime(0, 0))
self.tot_spinner.setDisplayFormat("T+hh:mm:ss")
self.tot_column.addWidget(self.tot_spinner)
self.package_view = QFlightList(self.package_model)
self.package_view.selectionModel().selectionChanged.connect(
@@ -90,8 +116,10 @@ class QPackageDialog(QDialog):
self.finished.connect(self.on_close)
@staticmethod
def on_close(_result) -> None:
def on_close(self, _result) -> None:
time = self.tot_spinner.time()
seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
self.package_model.update_tot(seconds)
GameUpdateSignal.get_instance().redraw_flight_paths()
def on_selection_changed(self, selected: QItemSelection,

View File

@@ -1,26 +1,33 @@
import datetime
import itertools
from PySide2.QtCore import QItemSelectionModel, QPoint
from PySide2.QtGui import QStandardItemModel, QStandardItem
from PySide2.QtWidgets import QTableView, QHeaderView
from PySide2.QtGui import QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QHeaderView, QTableView
from game.utils import meter_to_feet
from gen.aircraft import PackageWaypointTiming
from gen.ato import Package
from gen.flights.flight import Flight, FlightWaypoint
from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import QWaypointItem
from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import \
QWaypointItem
class QFlightWaypointList(QTableView):
def __init__(self, flight: Flight):
super(QFlightWaypointList, self).__init__()
def __init__(self, package: Package, flight: Flight):
super().__init__()
self.package = package
self.flight = flight
self.model = QStandardItemModel(self)
self.setModel(self.model)
self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.model.setHorizontalHeaderLabels(["Name", "Alt"])
self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"])
header = self.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.flight = flight
if len(self.flight.points) > 0:
self.selectedPoint = self.flight.points[0]
self.update_list()
@@ -33,18 +40,49 @@ class QFlightWaypointList(QTableView):
def update_list(self):
self.model.clear()
self.model.setHorizontalHeaderLabels(["Name", "Alt"])
takeoff = FlightWaypoint(self.flight.from_cp.position.x, self.flight.from_cp.position.y, 0)
self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"])
timing = PackageWaypointTiming.for_package(self.package)
# The first waypoint is set up by pydcs at mission generation time, so
# we need to add that waypoint manually.
takeoff = FlightWaypoint(self.flight.from_cp.position.x,
self.flight.from_cp.position.y, 0)
takeoff.description = "Take Off"
takeoff.name = takeoff.pretty_name = "Take Off from " + self.flight.from_cp.name
self.model.appendRow(QWaypointItem(takeoff, 0))
item = QStandardItem("0 feet AGL")
item.setEditable(False)
self.model.setItem(0, 1, item)
for i, point in enumerate(self.flight.points):
self.model.insertRow(self.model.rowCount())
self.model.setItem(self.model.rowCount()-1, 0, QWaypointItem(point, i + 1))
item = QStandardItem(str(meter_to_feet(point.alt)) + " ft " + str(["AGL" if point.alt_type == "RADIO" else "MSL"][0]))
item.setEditable(False)
self.model.setItem(self.model.rowCount()-1, 1, item)
self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select)
takeoff.alt_type = "RADIO"
waypoints = itertools.chain([takeoff], self.flight.points)
for row, waypoint in enumerate(waypoints):
self.add_waypoint_row(row, waypoint, timing)
self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)),
QItemSelectionModel.Select)
def add_waypoint_row(self, row: int, waypoint: FlightWaypoint,
timing: PackageWaypointTiming) -> None:
self.model.insertRow(self.model.rowCount())
self.model.setItem(row, 0, QWaypointItem(waypoint, row))
altitude = meter_to_feet(waypoint.alt)
altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL"
altitude_item = QStandardItem(f"{altitude} ft {altitude_type}")
altitude_item.setEditable(False)
self.model.setItem(row, 1, altitude_item)
tot = self.tot_text(waypoint, timing)
tot_item = QStandardItem(tot)
tot_item.setEditable(False)
self.model.setItem(row, 2, tot_item)
def tot_text(self, waypoint: FlightWaypoint,
timing: PackageWaypointTiming) -> str:
prefix = ""
time = timing.tot_for_waypoint(waypoint)
if time is None:
prefix = "Depart "
time = timing.depart_time_for_waypoint(waypoint, self.flight)
if time is None:
return ""
return f"{prefix}T+{datetime.timedelta(seconds=time)}"

View File

@@ -44,7 +44,8 @@ class QFlightWaypointTab(QFrame):
def init_ui(self):
layout = QGridLayout()
self.flight_waypoint_list = QFlightWaypointList(self.flight)
self.flight_waypoint_list = QFlightWaypointList(self.package,
self.flight)
layout.addWidget(self.flight_waypoint_list, 0, 0)
rlayout = QVBoxLayout()