From 31d5e3151b74c2a8ae7297528d7fa50989f5ce20 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 14:14:32 -0700 Subject: [PATCH 01/13] Refactor display rules. --- qt_ui/displayoptions.py | 43 ++++++++++++++++++++++++ qt_ui/widgets/map/QLiberationMap.py | 41 ++++++----------------- qt_ui/widgets/map/QMapControlPoint.py | 3 +- qt_ui/widgets/map/QMapGroundObject.py | 5 +-- qt_ui/windows/QLiberationWindow.py | 48 ++++++--------------------- 5 files changed, 70 insertions(+), 70 deletions(-) create mode 100644 qt_ui/displayoptions.py diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py new file mode 100644 index 00000000..a9b6320c --- /dev/null +++ b/qt_ui/displayoptions.py @@ -0,0 +1,43 @@ +"""Visibility options for the game map.""" +from dataclasses import dataclass +from typing import Iterator + + +@dataclass +class DisplayRule: + name: str + _value: bool + + @property + def menu_text(self) -> str: + return self.name + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool) -> None: + from qt_ui.widgets.map.QLiberationMap import QLiberationMap + self._value = value + QLiberationMap.instance.reload_scene() + QLiberationMap.instance.update() + + def __bool__(self) -> bool: + return self.value + + +class DisplayOptions: + ground_objects = DisplayRule("Ground Objects", True) + control_points = DisplayRule("Control Points", True) + lines = DisplayRule("Lines", True) + events = DisplayRule("Events", True) + sam_ranges = DisplayRule("SAM Ranges", True) + flight_paths = DisplayRule("Flight Paths", False) + + @classmethod + def menu_items(cls) -> Iterator[DisplayRule]: + # Python 3.6 enforces that __dict__ is order preserving by default. + for value in cls.__dict__.values(): + if isinstance(value, DisplayRule): + yield value diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 3d775367..1f3fdd57 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import logging -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from PySide2.QtCore import Qt from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent @@ -18,11 +20,12 @@ from game import Game, db from game.data.radar_db import UNITS_WITH_RADAR from gen import Conflict from gen.flights.flight import Flight +from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel +from qt_ui.widgets.map.QFrontLine import QFrontLine 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, FrontLine @@ -30,15 +33,7 @@ from theater import ControlPoint, FrontLine class QLiberationMap(QGraphicsView): WAYPOINT_SIZE = 4 - instance = None - display_rules: Dict[str, bool] = { - "cp": True, - "go": True, - "lines": True, - "events": True, - "sam": True, - "flight_paths": False - } + instance: Optional[QLiberationMap] = None def __init__(self, game_model: GameModel): super(QLiberationMap, self).__init__() @@ -161,7 +156,8 @@ class QLiberationMap(QGraphicsView): buildings = self.game.theater.find_ground_objects_by_obj_name(ground_object.obj_name) scene.addItem(QMapGroundObject(self, go_pos[0], go_pos[1], 14, 12, cp, ground_object, self.game, buildings)) - if ground_object.category == "aa" and self.get_display_rule("sam"): + is_aa = ground_object.category == "aa" + if is_aa and DisplayOptions.sam_ranges: max_range = 0 has_radar = False if ground_object.groups: @@ -177,11 +173,11 @@ class QLiberationMap(QGraphicsView): added_objects.append(ground_object.obj_name) for cp in self.game.theater.enemy_points(): - if self.get_display_rule("lines"): + if DisplayOptions.lines: self.scene_create_lines_for_cp(cp, playerColor, enemyColor) for cp in self.game.theater.player_points(): - if self.get_display_rule("lines"): + if DisplayOptions.lines: self.scene_create_lines_for_cp(cp, playerColor, enemyColor) self.draw_flight_plans(scene) @@ -202,7 +198,7 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() - if not self.get_display_rule("flight_paths"): + if not DisplayOptions.flight_paths: return for package in self.game_model.ato_model.packages: for flight in package.flights: @@ -367,18 +363,3 @@ class QLiberationMap(QGraphicsView): effect = QGraphicsOpacityEffect() effect.setOpacity(0.3) overlay.setGraphicsEffect(effect) - - - @staticmethod - def set_display_rule(rule: str, value: bool): - QLiberationMap.display_rules[rule] = value - QLiberationMap.instance.reload_scene() - QLiberationMap.instance.update() - - @staticmethod - def get_display_rules() -> Dict[str, bool]: - return QLiberationMap.display_rules - - @staticmethod - def get_display_rule(rule) -> bool: - return QLiberationMap.display_rules[rule] diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index f5b2e1c4..ef9bf5c9 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -7,6 +7,7 @@ from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 from theater import ControlPoint from .QMapObject import QMapObject +from ...displayoptions import DisplayOptions class QMapControlPoint(QMapObject): @@ -21,7 +22,7 @@ class QMapControlPoint(QMapObject): self.base_details_dialog: Optional[QBaseMenu2] = None def paint(self, painter, option, widget=None) -> None: - if self.parent.get_display_rule("cp"): + if DisplayOptions.control_points: painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(self.brush_color) diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index 1ed9f3d2..af0789a8 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -8,8 +8,9 @@ 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 theater import ControlPoint, TheaterGroundObject from .QMapObject import QMapObject +from ...displayoptions import DisplayOptions class QMapGroundObject(QMapObject): @@ -50,7 +51,7 @@ class QMapGroundObject(QMapObject): player_icons = "_blue" enemy_icons = "" - if self.parent.get_display_rule("go"): + if DisplayOptions.ground_objects: painter.save() cat = self.ground_object.category diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 3933083c..acda1bb0 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -19,6 +19,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, persistency from qt_ui.dialogs import Dialog +from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel @@ -134,48 +135,21 @@ class QLiberationWindow(QMainWindow): file_menu.addSeparator() file_menu.addAction(self.showLiberationPrefDialogAction) file_menu.addSeparator() - #file_menu.addAction("Close Current Game", lambda: self.closeGame()) # Not working file_menu.addAction("E&xit" , lambda: self.exit()) displayMenu = self.menu.addMenu("&Display") - tg_cp_visibility = QAction('&Control Point', displayMenu) - tg_cp_visibility.setCheckable(True) - tg_cp_visibility.setChecked(True) - tg_cp_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("cp", tg_cp_visibility.isChecked())) + for display_rule in DisplayOptions.menu_items(): + def make_check_closure(): + def closure(): + display_rule.value = action.isChecked() + return closure - tg_go_visibility = QAction('&Ground Objects', displayMenu) - tg_go_visibility.setCheckable(True) - tg_go_visibility.setChecked(True) - tg_go_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("go", tg_go_visibility.isChecked())) - - tg_line_visibility = QAction('&Lines', displayMenu) - tg_line_visibility.setCheckable(True) - tg_line_visibility.setChecked(True) - tg_line_visibility.toggled.connect( - lambda: QLiberationMap.set_display_rule("lines", tg_line_visibility.isChecked())) - - tg_event_visibility = QAction('&Events', displayMenu) - tg_event_visibility.setCheckable(True) - tg_event_visibility.setChecked(True) - tg_event_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("events", tg_event_visibility.isChecked())) - - tg_sam_visibility = QAction('&SAM Range', displayMenu) - tg_sam_visibility.setCheckable(True) - tg_sam_visibility.setChecked(True) - tg_sam_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("sam", tg_sam_visibility.isChecked())) - - tg_flight_path_visibility = QAction('&Flight Paths', displayMenu) - tg_flight_path_visibility.setCheckable(True) - tg_flight_path_visibility.setChecked(False) - tg_flight_path_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("flight_paths", tg_flight_path_visibility.isChecked())) - - displayMenu.addAction(tg_go_visibility) - displayMenu.addAction(tg_cp_visibility) - displayMenu.addAction(tg_line_visibility) - displayMenu.addAction(tg_event_visibility) - displayMenu.addAction(tg_sam_visibility) - displayMenu.addAction(tg_flight_path_visibility) + action = QAction(f"&{display_rule.menu_text}", displayMenu) + action.setCheckable(True) + action.setChecked(display_rule.value) + action.toggled.connect(make_check_closure()) + displayMenu.addAction(action) help_menu = self.menu.addMenu("&Help") help_menu.addAction("&Discord Server", lambda: webbrowser.open_new_tab("https://" + "discord.gg" + "/" + "bKrt" + "rkJ")) From 2d8c8c63c91d7e66da5e08312ce8f7f90d5c1718 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 16:18:08 -0700 Subject: [PATCH 02/13] Improve flight path display options. Adds an option show only selected flight, and also changes the show all option to highlight the selected flight. --- qt_ui/displayoptions.py | 27 +++++++++-- qt_ui/widgets/ato.py | 22 +++++++++ qt_ui/widgets/map/QLiberationMap.py | 74 ++++++++++++++++++++++------- qt_ui/windows/GameUpdateSignal.py | 12 ++++- qt_ui/windows/QLiberationWindow.py | 47 ++++++++++++------ 5 files changed, 145 insertions(+), 37 deletions(-) diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py index a9b6320c..f6f2e6c6 100644 --- a/qt_ui/displayoptions.py +++ b/qt_ui/displayoptions.py @@ -1,6 +1,6 @@ """Visibility options for the game map.""" from dataclasses import dataclass -from typing import Iterator +from typing import Iterator, Optional, Union @dataclass @@ -27,17 +27,38 @@ class DisplayRule: return self.value +class DisplayGroup: + def __init__(self, name: Optional[str]) -> None: + self.name = name + + def __iter__(self) -> Iterator[DisplayRule]: + # Python 3.6 enforces that __dict__ is order preserving by default. + for value in self.__dict__.values(): + if isinstance(value, DisplayRule): + yield value + + +class FlightPathOptions(DisplayGroup): + def __init__(self) -> None: + super().__init__("Flight Paths") + self.hide = DisplayRule("Hide Flight Paths", True) + self.only_selected = DisplayRule("Show Selected Flight Path", False) + self.all = DisplayRule("Show All Flight Paths", False) + + class DisplayOptions: ground_objects = DisplayRule("Ground Objects", True) control_points = DisplayRule("Control Points", True) lines = DisplayRule("Lines", True) events = DisplayRule("Events", True) sam_ranges = DisplayRule("SAM Ranges", True) - flight_paths = DisplayRule("Flight Paths", False) + flight_paths = FlightPathOptions() @classmethod - def menu_items(cls) -> Iterator[DisplayRule]: + def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: # Python 3.6 enforces that __dict__ is order preserving by default. for value in cls.__dict__.values(): if isinstance(value, DisplayRule): yield value + elif isinstance(value, DisplayGroup): + yield value diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index b39f3063..8703f7e9 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -109,6 +109,7 @@ class QFlightPanel(QGroupBox): """Sets the package model to display.""" self.package_model = model self.flight_list.set_package(model) + self.selection_changed.connect(self.on_selection_changed) self.on_selection_changed() @property @@ -122,6 +123,15 @@ class QFlightPanel(QGroupBox): enabled = index.isValid() self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) + self.change_map_flight_selection(index) + + @staticmethod + def change_map_flight_selection(index: QModelIndex) -> None: + if not index.isValid(): + GameUpdateSignal.get_instance().select_flight(None) + return + + GameUpdateSignal.get_instance().select_flight(index.row()) def on_edit(self) -> None: """Opens the flight edit dialog.""" @@ -270,6 +280,18 @@ class QPackagePanel(QGroupBox): enabled = index.isValid() self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) + self.change_map_package_selection(index) + + def change_map_package_selection(self, index: QModelIndex) -> None: + if not index.isValid(): + GameUpdateSignal.get_instance().select_package(None) + return + + package = self.ato_model.get_package_model(index) + if package.rowCount() == 0: + GameUpdateSignal.get_instance().select_package(None) + else: + GameUpdateSignal.get_instance().select_package(index.row()) def on_edit(self) -> None: """Opens the package edit dialog.""" diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 1f3fdd57..227e6c5a 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -42,6 +42,8 @@ class QLiberationMap(QGraphicsView): self.game: Optional[Game] = game_model.game self.flight_path_items: List[QGraphicsItem] = [] + # A tuple of (package index, flight index), or none. + self.selected_flight: Optional[Tuple[int, int]] = None self.setMinimumSize(800,600) self.setMaximumHeight(2160) @@ -56,6 +58,25 @@ class QLiberationMap(QGraphicsView): lambda: self.draw_flight_plans(self.scene()) ) + def update_package_selection(index: Optional[int]) -> None: + self.selected_flight = index, 0 + self.draw_flight_plans(self.scene()) + + GameUpdateSignal.get_instance().package_selection_changed.connect( + update_package_selection + ) + + def update_flight_selection(index: Optional[int]) -> None: + if self.selected_flight is None: + logging.error("Flight was selected with no package selected") + return + self.selected_flight = self.selected_flight[0], index + self.draw_flight_plans(self.scene()) + + GameUpdateSignal.get_instance().flight_selection_changed.connect( + update_flight_selection + ) + def init_scene(self): scene = QLiberationScene(self) self.setScene(scene) @@ -198,37 +219,44 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() - if not DisplayOptions.flight_paths: + if DisplayOptions.flight_paths.hide: return - for package in self.game_model.ato_model.packages: - for flight in package.flights: - self.draw_flight_plan(scene, flight) + for p_idx, package in enumerate(self.game_model.ato_model.packages): + for f_idx, flight in enumerate(package.flights): + selected = (p_idx, f_idx) == self.selected_flight + if DisplayOptions.flight_paths.only_selected and not selected: + continue + highlight = selected and DisplayOptions.flight_paths.all + self.draw_flight_plan(scene, flight, highlight) - def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight) -> None: + def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight, + highlight: bool) -> None: is_player = flight.from_cp.captured pos = self._transform_point(flight.from_cp.position) - self.draw_waypoint(scene, pos, is_player) + self.draw_waypoint(scene, pos, is_player, highlight) 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) + self.draw_flight_path(scene, prev_pos, new_pos, is_player, + highlight) + self.draw_waypoint(scene, new_pos, is_player, highlight) prev_pos = tuple(new_pos) - self.draw_flight_path(scene, prev_pos, pos, is_player) + self.draw_flight_path(scene, prev_pos, pos, is_player, highlight) 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) + player: bool, highlight: bool) -> None: + waypoint_pen = self.waypoint_pen(player, highlight) + waypoint_brush = self.waypoint_brush(player, highlight) self.flight_path_items.append(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) + pos1: Tuple[int, int], player: bool, + highlight: bool) -> None: + flight_path_pen = self.flight_path_pen(player, highlight) # Draw the line to the *middle* of the waypoint. offset = self.WAYPOINT_SIZE // 2 self.flight_path_items.append(scene.addLine( @@ -317,21 +345,31 @@ class QLiberationMap(QGraphicsView): return X > treshold and X or treshold, Y > treshold and Y or treshold + def highlight_color(self, transparent: Optional[bool] = False) -> QColor: + return QColor(255, 255, 0, 20 if transparent else 255) + 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: + def waypoint_pen(self, player: bool, highlight: bool) -> QColor: + if highlight: + return self.highlight_color() name = self.base_faction_color_name(player) - return QPen(brush=CONST.COLORS[name]) + return CONST.COLORS[name] - def waypoint_brush(self, player: bool) -> QColor: + def waypoint_brush(self, player: bool, highlight: bool) -> QColor: + if highlight: + return self.highlight_color(transparent=True) name = self.base_faction_color_name(player) return CONST.COLORS[f"{name}_transparent"] - def flight_path_pen(self, player: bool) -> QPen: + def flight_path_pen(self, player: bool, highlight: bool) -> QPen: + if highlight: + return self.highlight_color() + name = self.base_faction_color_name(player) color = CONST.COLORS[name] pen = QPen(brush=color) diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 8a52d555..3c112952 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Tuple from PySide2.QtCore import QObject, Signal @@ -24,11 +24,21 @@ class GameUpdateSignal(QObject): debriefingReceived = Signal(DebriefingSignal) flight_paths_changed = Signal() + package_selection_changed = Signal(int) # Optional[int] + flight_selection_changed = Signal(int) # Optional[int] def __init__(self): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self + def select_package(self, index: Optional[int]) -> None: + # noinspection PyUnresolvedReferences + self.package_selection_changed.emit(index) + + def select_flight(self, index: Optional[int]) -> None: + # noinspection PyUnresolvedReferences + self.flight_selection_changed.emit(index) + def redraw_flight_paths(self) -> None: # noinspection PyUnresolvedReferences self.flight_paths_changed.emit() diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index acda1bb0..7e203b98 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -1,16 +1,16 @@ import logging import sys import webbrowser -from typing import Optional +from typing import Optional, Union from PySide2.QtCore import Qt from PySide2.QtGui import QIcon from PySide2.QtWidgets import ( QAction, - QDesktopWidget, + QActionGroup, QDesktopWidget, QFileDialog, QMainWindow, - QMessageBox, + QMenu, QMessageBox, QSplitter, QVBoxLayout, QWidget, @@ -19,7 +19,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, persistency from qt_ui.dialogs import Dialog -from qt_ui.displayoptions import DisplayOptions +from qt_ui.displayoptions import DisplayGroup, DisplayOptions, DisplayRule from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel @@ -139,17 +139,19 @@ class QLiberationWindow(QMainWindow): displayMenu = self.menu.addMenu("&Display") - for display_rule in DisplayOptions.menu_items(): - def make_check_closure(): - def closure(): - display_rule.value = action.isChecked() - return closure - - action = QAction(f"&{display_rule.menu_text}", displayMenu) - action.setCheckable(True) - action.setChecked(display_rule.value) - action.toggled.connect(make_check_closure()) - displayMenu.addAction(action) + last_was_group = True + for item in DisplayOptions.menu_items(): + if isinstance(item, DisplayRule): + displayMenu.addAction(self.make_display_rule_action(item)) + last_was_group = False + elif isinstance(item, DisplayGroup): + if not last_was_group: + displayMenu.addSeparator() + group = QActionGroup(displayMenu) + for display_rule in item: + displayMenu.addAction( + self.make_display_rule_action(display_rule, group)) + last_was_group = True help_menu = self.menu.addMenu("&Help") help_menu.addAction("&Discord Server", lambda: webbrowser.open_new_tab("https://" + "discord.gg" + "/" + "bKrt" + "rkJ")) @@ -162,6 +164,21 @@ class QLiberationWindow(QMainWindow): help_menu.addSeparator() help_menu.addAction(self.showAboutDialogAction) + @staticmethod + def make_display_rule_action( + display_rule, group: Optional[QActionGroup] = None) -> QAction: + def make_check_closure(): + def closure(): + display_rule.value = action.isChecked() + + return closure + + action = QAction(f"&{display_rule.menu_text}", group) + action.setCheckable(True) + action.setChecked(display_rule.value) + action.toggled.connect(make_check_closure()) + return action + def newGame(self): wizard = NewGameWizard(self) wizard.show() From 1fa18447e17c3aa128b9a27a9f156faa91cdf513 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 18:23:43 -0700 Subject: [PATCH 03/13] Show player slots in the overview. --- qt_ui/dialogs.py | 2 +- qt_ui/models.py | 6 ++ qt_ui/widgets/QLabeledWidget.py | 11 ++- qt_ui/widgets/QTopPanel.py | 31 +++--- qt_ui/widgets/ato.py | 108 ++++++++++++++++++++- qt_ui/widgets/clientslots.py | 28 ++++++ qt_ui/windows/QLiberationWindow.py | 2 +- qt_ui/windows/mission/QEditFlightDialog.py | 12 +-- 8 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 qt_ui/widgets/clientslots.py diff --git a/qt_ui/dialogs.py b/qt_ui/dialogs.py index e09dd92a..f88aa4b0 100644 --- a/qt_ui/dialogs.py +++ b/qt_ui/dialogs.py @@ -58,7 +58,7 @@ class Dialog: flight: Flight) -> None: """Opens the dialog to edit the given flight.""" cls.edit_flight_dialog = QEditFlightDialog( - cls.game_model.game, + cls.game_model, package_model.package, flight ) diff --git a/qt_ui/models.py b/qt_ui/models.py index 98515eab..428b4598 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -95,6 +95,8 @@ class NullListModel(QAbstractListModel): class PackageModel(QAbstractListModel): """The model for an ATO package.""" + FlightRole = Qt.UserRole + #: Emitted when this package is being deleted from the ATO. deleted = Signal() @@ -113,6 +115,8 @@ class PackageModel(QAbstractListModel): return self.text_for_flight(flight) if role == Qt.DecorationRole: return self.icon_for_flight(flight) + elif role == PackageModel.FlightRole: + return flight return None @staticmethod @@ -185,6 +189,8 @@ class AtoModel(QAbstractListModel): PackageRole = Qt.UserRole + client_slots_changed = Signal() + def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: super().__init__() self.game = game diff --git a/qt_ui/widgets/QLabeledWidget.py b/qt_ui/widgets/QLabeledWidget.py index 88459896..f258f458 100644 --- a/qt_ui/widgets/QLabeledWidget.py +++ b/qt_ui/widgets/QLabeledWidget.py @@ -1,4 +1,6 @@ """A layout containing a widget with an associated label.""" +from typing import Optional + from PySide2.QtCore import Qt from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget @@ -10,8 +12,13 @@ class QLabeledWidget(QHBoxLayout): label is used to name the input. """ - def __init__(self, text: str, widget: QWidget) -> None: + def __init__(self, text: str, widget: QWidget, + tooltip: Optional[str]) -> None: super().__init__() - self.addWidget(QLabel(text)) + label = QLabel(text) + self.addWidget(label) self.addStretch() self.addWidget(widget, alignment=Qt.AlignRight) + if tooltip is not None: + label.setToolTip(tooltip) + widget.setToolTip(tooltip) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index f75826ad..dabaecd7 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -11,9 +11,11 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game from game.event import CAP, CAS, FrontlineAttackEvent +from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QFactionsInfos import QFactionsInfos from qt_ui.widgets.QTurnCounter import QTurnCounter +from qt_ui.widgets.clientslots import MaxPlayerCount from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QWaitingForMissionResultWindow import \ QWaitingForMissionResultWindow @@ -23,14 +25,18 @@ from qt_ui.windows.stats.QStatsWindow import QStatsWindow class QTopPanel(QFrame): - def __init__(self, game: Game): + def __init__(self, game_model: GameModel): super(QTopPanel, self).__init__() - self.game = game + self.game_model = game_model self.setMaximumHeight(70) self.init_ui() GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) GameUpdateSignal.get_instance().budgetupdated.connect(self.budget_update) + @property + def game(self) -> Optional[Game]: + return self.game_model.game + def init_ui(self): self.turnCounter = QTurnCounter() @@ -68,6 +74,8 @@ class QTopPanel(QFrame): self.proceedBox = QGroupBox("Proceed") self.proceedBoxLayout = QHBoxLayout() + self.proceedBoxLayout.addLayout( + MaxPlayerCount(self.game_model.ato_model)) self.proceedBoxLayout.addWidget(self.passTurnButton) self.proceedBoxLayout.addWidget(self.proceedButton) self.proceedBox.setLayout(self.proceedBoxLayout) @@ -84,16 +92,17 @@ class QTopPanel(QFrame): self.setLayout(self.layout) def setGame(self, game: Optional[Game]): - self.game = game - if game is not None: - self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) - self.budgetBox.setGame(self.game) - self.factionsInfos.setGame(self.game) + if game is None: + return - if self.game and self.game.turn == 0: - self.proceedButton.setEnabled(False) - else: - self.proceedButton.setEnabled(True) + self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) + self.budgetBox.setGame(self.game) + self.factionsInfos.setGame(self.game) + + if self.game and self.game.turn == 0: + self.proceedButton.setEnabled(False) + else: + self.proceedButton.setEnabled(True) def openSettings(self): self.subwindow = QSettingsWindow(self.game) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index 8703f7e9..c978c1bc 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -10,7 +10,7 @@ from PySide2.QtCore import ( QSize, Qt, ) -from PySide2.QtGui import QFont, QFontMetrics, QPainter +from PySide2.QtGui import QFont, QFontMetrics, QIcon, QPainter from PySide2.QtWidgets import ( QAbstractItemView, QGroupBox, @@ -18,15 +18,109 @@ from PySide2.QtWidgets import ( QListView, QPushButton, QSplitter, - QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, + QStyle, QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, ) +from game import db from gen.ato import Package from gen.flights.flight import Flight from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from ..models import AtoModel, GameModel, NullListModel, PackageModel +class FlightDelegate(QStyledItemDelegate): + FONT_SIZE = 10 + HMARGIN = 4 + VMARGIN = 4 + + def get_font(self, option: QStyleOptionViewItem) -> QFont: + font = QFont(option.font) + font.setPointSize(self.FONT_SIZE) + return font + + @staticmethod + def flight(index: QModelIndex) -> Flight: + return index.data(PackageModel.FlightRole) + + def first_row_text(self, index: QModelIndex) -> str: + flight = self.flight(index) + task = flight.flight_type.name + count = flight.count + name = db.unit_type_name(flight.unit_type) + delay = flight.scheduled_in + return f"[{task}] {count} x {name} in {delay} minutes" + + def second_row_text(self, index: QModelIndex) -> str: + flight = self.flight(index) + origin = flight.from_cp.name + return f"From {origin}" + + 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)) + + icon: Optional[QIcon] = index.data(Qt.DecorationRole) + if icon is not None: + icon.paint(painter, rect, Qt.AlignLeft | Qt.AlignVCenter, + self.icon_mode(option), + self.icon_state(option)) + + rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, + 0, 0, 0) + painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index)) + line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) + painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index)) + + clients = self.num_clients(index) + if clients: + painter.drawText(rect, Qt.AlignRight, + f"Player Slots: {clients}") + + def num_clients(self, index: QModelIndex) -> int: + flight = self.flight(index) + return flight.client_count + + @staticmethod + def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode: + if not (option.state & QStyle.State_Enabled): + return QIcon.Disabled + elif option.state & QStyle.State_Selected: + return QIcon.Selected + elif option.state & QStyle.State_Active: + return QIcon.Active + return QIcon.Normal + + @staticmethod + def icon_state(option: QStyleOptionViewItem) -> QIcon.State: + return QIcon.On if option.state & QStyle.State_Open else QIcon.Off + + @staticmethod + def icon_size(option: QStyleOptionViewItem) -> QSize: + icon_size: Optional[QSize] = option.decorationSize + if icon_size is None: + return QSize(0, 0) + else: + return icon_size + + def sizeHint(self, option: QStyleOptionViewItem, + index: QModelIndex) -> QSize: + left = self.icon_size(option).width() + self.HMARGIN + metrics = QFontMetrics(self.get_font(option)) + first = metrics.size(0, self.first_row_text(index)) + second = metrics.size(0, self.second_row_text(index)) + text_width = max(first.width(), second.width()) + return QSize(left + text_width + 2 * self.HMARGIN, + first.height() + second.height() + 2 * self.VMARGIN) + + class QFlightList(QListView): """List view for displaying the flights of a package.""" @@ -34,6 +128,7 @@ class QFlightList(QListView): super().__init__() self.package_model = model self.set_package(model) + self.setItemDelegate(FlightDelegate()) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) @@ -206,6 +301,15 @@ class PackageDelegate(QStyledItemDelegate): line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) painter.drawText(line2, Qt.AlignLeft, self.right_text(index)) + clients = self.num_clients(index) + if clients: + painter.drawText(rect, Qt.AlignRight, + f"Player Slots: {clients}") + + def num_clients(self, index: QModelIndex) -> int: + package = self.package(index) + return sum(f.client_count for f in package.flights) + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: metrics = QFontMetrics(self.get_font(option)) diff --git a/qt_ui/widgets/clientslots.py b/qt_ui/widgets/clientslots.py new file mode 100644 index 00000000..1c9fff9b --- /dev/null +++ b/qt_ui/widgets/clientslots.py @@ -0,0 +1,28 @@ +"""Widgets for displaying client slots.""" +from PySide2.QtWidgets import QLabel + +from qt_ui.models import AtoModel +from qt_ui.widgets.QLabeledWidget import QLabeledWidget + + +class MaxPlayerCount(QLabeledWidget): + def __init__(self, ato_model: AtoModel) -> None: + self.ato_model = ato_model + self.slots_label = QLabel(str(self.count_client_slots)) + self.ato_model.client_slots_changed.connect(self.update_count) + super().__init__( + "Max Players:", self.slots_label, + ("Total number of client slots. To add client slots, edit a flight " + "using the panel on the left.") + ) + + @property + def count_client_slots(self) -> int: + slots = 0 + for package in self.ato_model.packages: + for flight in package.flights: + slots += flight.client_count + return slots + + def update_count(self) -> None: + self.slots_label.setText(str(self.count_client_slots)) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 7e203b98..db5caa10 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -77,7 +77,7 @@ class QLiberationWindow(QMainWindow): vbox = QVBoxLayout() vbox.setMargin(0) - vbox.addWidget(QTopPanel(self.game)) + vbox.addWidget(QTopPanel(self.game_model)) vbox.addWidget(hbox) central_widget = QWidget() diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 9f795b79..bfbcc5cb 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -4,9 +4,9 @@ from PySide2.QtWidgets import ( QVBoxLayout, ) -from game import Game from gen.ato import Package from gen.flights.flight import Flight +from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner @@ -15,22 +15,22 @@ 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, package: Package, flight: Flight) -> None: + def __init__(self, game_model: GameModel, package: Package, flight: Flight) -> None: super().__init__() - self.game = game + self.game_model = game_model self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) layout = QVBoxLayout() - self.flight_planner = QFlightPlanner(package, flight, game) + self.flight_planner = QFlightPlanner(package, flight, game_model.game) layout.addWidget(self.flight_planner) self.setLayout(layout) self.finished.connect(self.on_close) - @staticmethod - def on_close(_result) -> None: + def on_close(self, _result) -> None: GameUpdateSignal.get_instance().redraw_flight_paths() + self.game_model.ato_model.client_slots_changed.emit() From f22391855bddd702546584e74b616aeded770e15 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sat, 10 Oct 2020 15:43:58 +0200 Subject: [PATCH 04/13] Fixed SAM threat radius scale, added detection range, changed UI colors for SAMS range. --- qt_ui/uiconstants.py | 6 ++++ qt_ui/widgets/map/QLiberationMap.py | 50 ++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index 5c97dc72..4dff2c0a 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -31,14 +31,20 @@ COLORS: Dict[str, QColor] = { "white_transparent": QColor(255, 255, 255, 35), "grey_transparent": QColor(150, 150, 150, 30), + "light_red": QColor(231, 92, 83, 90), "red": QColor(200, 80, 80), "dark_red": QColor(140, 20, 20), "red_transparent": QColor(227, 32, 0, 20), + "transparent": QColor(255, 255, 255, 0), + "light_blue": QColor(105, 182, 240, 90), "blue": QColor(0, 132, 255), "dark_blue": QColor(45, 62, 80), "blue_transparent": QColor(0, 132, 255, 20), + "purple": QColor(187, 137, 255), + "yellow": QColor(238, 225, 123), + "bright_red": QColor(150, 80, 80), "super_red": QColor(227, 32, 0), diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 3d775367..84ef3776 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -15,6 +15,7 @@ from dcs.mapping import point_from_heading import qt_ui.uiconstants as CONST from game import Game, db +from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR from gen import Conflict from gen.flights.flight import Flight @@ -162,18 +163,34 @@ class QLiberationMap(QGraphicsView): scene.addItem(QMapGroundObject(self, go_pos[0], go_pos[1], 14, 12, cp, ground_object, self.game, buildings)) if ground_object.category == "aa" and self.get_display_rule("sam"): - max_range = 0 - has_radar = False + threat_range = 0 + detection_range = 0 + can_fire = False if ground_object.groups: for g in ground_object.groups: for u in g.units: unit = db.unit_type_from_name(u.type) - if unit in UNITS_WITH_RADAR: - has_radar = True - if unit.threat_range > max_range: - max_range = unit.threat_range - if has_radar: - scene.addEllipse(go_pos[0] - max_range/300.0 + 8, go_pos[1] - max_range/300.0 + 8, max_range/150.0, max_range/150.0, CONST.COLORS["white_transparent"], CONST.COLORS["grey_transparent"]) + if unit in UNITS_WITH_RADAR or unit in AAA_UNITS: + can_fire = True + if unit.detection_range > detection_range: + detection_range = unit.detection_range + if unit.threat_range > threat_range: + threat_range = unit.threat_range + if can_fire: + threat_pos = self._transform_point(Point(ground_object.position.x+threat_range, + ground_object.position.y+threat_range)) + detection_pos = self._transform_point(Point(ground_object.position.x+detection_range, + ground_object.position.y+detection_range)) + threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) + detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) + + # Add detection range circle + scene.addEllipse(go_pos[0] - detection_radius/2 + 7, go_pos[1] - detection_radius/2 + 6, + detection_radius, detection_radius, self.detection_pen(cp.captured)) + + # Add threat range circle + scene.addEllipse(go_pos[0] - threat_radius / 2 + 7, go_pos[1] - threat_radius / 2 + 6, + threat_radius, threat_radius, self.threat_pen(cp.captured)) added_objects.append(ground_object.obj_name) for cp in self.game.theater.enemy_points(): @@ -335,6 +352,23 @@ class QLiberationMap(QGraphicsView): name = self.base_faction_color_name(player) return CONST.COLORS[f"{name}_transparent"] + def threat_pen(self, player: bool) -> QPen: + if player: + color = "blue" + else: + color = "red" + qpen = QPen(CONST.COLORS[color]) + return qpen + + def detection_pen(self, player: bool) -> QPen: + if player: + color = "purple" + else: + color = "yellow" + qpen = QPen(CONST.COLORS[color]) + qpen.setStyle(Qt.DotLine) + return qpen + def flight_path_pen(self, player: bool) -> QPen: name = self.base_faction_color_name(player) color = CONST.COLORS[name] From f4b07cb518724b91384b309fc1b3e7fdb62dc89d Mon Sep 17 00:00:00 2001 From: Khopa Date: Sat, 10 Oct 2020 16:21:41 +0200 Subject: [PATCH 05/13] New game wizard : added to slider to choose starting money --- qt_ui/widgets/QTopPanel.py | 8 ++--- qt_ui/windows/newgame/QNewGameWizard.py | 42 ++++++++++++++++--------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index dabaecd7..74c9608f 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -95,11 +95,11 @@ class QTopPanel(QFrame): if game is None: return - self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) - self.budgetBox.setGame(self.game) - self.factionsInfos.setGame(self.game) + self.turnCounter.setCurrentTurn(game.turn, game.current_day) + self.budgetBox.setGame(game) + self.factionsInfos.setGame(game) - if self.game and self.game.turn == 0: + if game and game.turn == 0: self.proceedButton.setEnabled(False) else: self.proceedButton.setEnabled(True) diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 8a706ec7..16669b0d 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -5,7 +5,7 @@ import logging from typing import List from PySide2 import QtGui, QtWidgets -from PySide2.QtCore import QItemSelectionModel, QPoint +from PySide2.QtCore import QItemSelectionModel, QPoint, Qt from PySide2.QtWidgets import QVBoxLayout from dcs.task import CAP, CAS @@ -63,6 +63,7 @@ class NewGameWizard(QtWidgets.QWizard): no_player_navy = self.field("no_player_navy") no_enemy_navy = self.field("no_enemy_navy") invertMap = self.field("invertMap") + starting_money = int(self.field("starting_money")) player_name = blueFaction enemy_name = redFaction @@ -76,12 +77,12 @@ class NewGameWizard(QtWidgets.QWizard): settings.do_not_generate_enemy_navy = no_enemy_navy self.generatedGame = self.start_new_game(player_name, enemy_name, conflictTheater, midGame, multiplier, - timePeriod, settings) + timePeriod, settings, starting_money) super(NewGameWizard, self).accept() def start_new_game(self, player_name: str, enemy_name: str, conflictTheater: ConflictTheater, - midgame: bool, multiplier: float, period: datetime, settings:Settings): + midgame: bool, multiplier: float, period: datetime, settings:Settings, starting_money: int): # Reset name generator namegen.reset() @@ -102,14 +103,10 @@ class NewGameWizard(QtWidgets.QWizard): print("-- Game Object generated") start_generator.generate_groundobjects(conflictTheater, game) - game.budget = int(game.budget * multiplier) + game.budget = starting_money game.settings.multiplier = multiplier game.settings.sams = True game.settings.version = CONST.VERSION_STRING - - if midgame: - game.budget = game.budget * 4 * len(list(conflictTheater.conflicts())) - return game @@ -330,6 +327,23 @@ class MiscOptions(QtWidgets.QWizardPage): no_enemy_navy = QtWidgets.QCheckBox() self.registerField('no_enemy_navy', no_enemy_navy) + # Economy settings + economySettingsGroup = QtWidgets.QGroupBox("Economy") + starting_money_slider = QtWidgets.QSlider(Qt.Horizontal) + starting_money_slider.setMinimum(0) + starting_money_slider.setMaximum(5000) + starting_money_slider.setValue(650) + starting_money_label = QtWidgets.QLabel("$650M") + starting_money_slider.valueChanged.connect(lambda x: starting_money_label.setText("${}M".format(x))) + self.registerField('starting_money', starting_money_slider) + + layout = QtWidgets.QGridLayout() + layout.addWidget(QtWidgets.QLabel("Start at mid game"), 1, 0) + layout.addWidget(midGame, 1, 1) + layout.addWidget(QtWidgets.QLabel("Ennemy forces multiplier [Disabled for Now]"), 2, 0) + layout.addWidget(multiplier, 2, 1) + miscSettingsGroup.setLayout(layout) + generatorLayout = QtWidgets.QGridLayout() generatorLayout.addWidget(QtWidgets.QLabel("No Aircraft Carriers"), 1, 0) generatorLayout.addWidget(no_carrier, 1, 1) @@ -343,16 +357,16 @@ class MiscOptions(QtWidgets.QWizardPage): generatorLayout.addWidget(no_enemy_navy, 5, 1) generatorSettingsGroup.setLayout(generatorLayout) - layout = QtWidgets.QGridLayout() - layout.addWidget(QtWidgets.QLabel("Start at mid game"), 1, 0) - layout.addWidget(midGame, 1, 1) - layout.addWidget(QtWidgets.QLabel("Ennemy forces multiplier [Disabled for Now]"), 2, 0) - layout.addWidget(multiplier, 2, 1) - miscSettingsGroup.setLayout(layout) + economyLayout = QtWidgets.QGridLayout() + economyLayout.addWidget(QtWidgets.QLabel("Starting money"), 0, 0) + economyLayout.addWidget(starting_money_slider, 1, 0) + economyLayout.addWidget(starting_money_label, 1, 1) + economySettingsGroup.setLayout(economyLayout) mlayout = QVBoxLayout() mlayout.addWidget(miscSettingsGroup) mlayout.addWidget(generatorSettingsGroup) + mlayout.addWidget(economySettingsGroup) self.setLayout(mlayout) From b0a176a22cc750d2478450994048e7eaca366d3a Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 10 Oct 2020 12:43:51 -0700 Subject: [PATCH 06/13] Make "all" the default flight path display option. --- qt_ui/displayoptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py index f6f2e6c6..020c2b02 100644 --- a/qt_ui/displayoptions.py +++ b/qt_ui/displayoptions.py @@ -41,9 +41,9 @@ class DisplayGroup: class FlightPathOptions(DisplayGroup): def __init__(self) -> None: super().__init__("Flight Paths") - self.hide = DisplayRule("Hide Flight Paths", True) + self.hide = DisplayRule("Hide Flight Paths", False) self.only_selected = DisplayRule("Show Selected Flight Path", False) - self.all = DisplayRule("Show All Flight Paths", False) + self.all = DisplayRule("Show All Flight Paths", True) class DisplayOptions: From 1ac062653d116a686d94772a68576d1bd0b561e5 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 10 Oct 2020 13:00:59 -0700 Subject: [PATCH 07/13] Make the starting budget text editable. --- qt_ui/windows/newgame/QNewGameWizard.py | 59 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 16669b0d..9f49e8d1 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import datetime import logging -from typing import List +from typing import List, Optional from PySide2 import QtGui, QtWidgets from PySide2.QtCore import QItemSelectionModel, QPoint, Qt @@ -295,6 +295,44 @@ class TheaterConfiguration(QtWidgets.QWizardPage): self.setLayout(layout) +class CurrencySpinner(QtWidgets.QSpinBox): + def __init__(self, minimum: Optional[int] = None, + maximum: Optional[int] = None, + initial: Optional[int] = None) -> None: + super().__init__() + + if minimum is not None: + self.setMinimum(minimum) + if maximum is not None: + self.setMaximum(maximum) + if initial is not None: + self.setValue(initial) + + def textFromValue(self, val: int) -> str: + return f"${val}" + + +class BudgetInputs(QtWidgets.QGridLayout): + def __init__(self) -> None: + super().__init__() + self.addWidget(QtWidgets.QLabel("Starting money"), 0, 0) + + minimum = 0 + maximum = 5000 + initial = 650 + + slider = QtWidgets.QSlider(Qt.Horizontal) + slider.setMinimum(minimum) + slider.setMaximum(maximum) + slider.setValue(initial) + self.starting_money = CurrencySpinner(minimum, maximum, initial) + slider.valueChanged.connect(lambda x: self.starting_money.setValue(x)) + self.starting_money.valueChanged.connect(lambda x: slider.setValue(x)) + + self.addWidget(slider, 1, 0) + self.addWidget(self.starting_money, 1, 1) + + class MiscOptions(QtWidgets.QWizardPage): def __init__(self, parent=None): super(MiscOptions, self).__init__(parent) @@ -327,16 +365,6 @@ class MiscOptions(QtWidgets.QWizardPage): no_enemy_navy = QtWidgets.QCheckBox() self.registerField('no_enemy_navy', no_enemy_navy) - # Economy settings - economySettingsGroup = QtWidgets.QGroupBox("Economy") - starting_money_slider = QtWidgets.QSlider(Qt.Horizontal) - starting_money_slider.setMinimum(0) - starting_money_slider.setMaximum(5000) - starting_money_slider.setValue(650) - starting_money_label = QtWidgets.QLabel("$650M") - starting_money_slider.valueChanged.connect(lambda x: starting_money_label.setText("${}M".format(x))) - self.registerField('starting_money', starting_money_slider) - layout = QtWidgets.QGridLayout() layout.addWidget(QtWidgets.QLabel("Start at mid game"), 1, 0) layout.addWidget(midGame, 1, 1) @@ -357,11 +385,10 @@ class MiscOptions(QtWidgets.QWizardPage): generatorLayout.addWidget(no_enemy_navy, 5, 1) generatorSettingsGroup.setLayout(generatorLayout) - economyLayout = QtWidgets.QGridLayout() - economyLayout.addWidget(QtWidgets.QLabel("Starting money"), 0, 0) - economyLayout.addWidget(starting_money_slider, 1, 0) - economyLayout.addWidget(starting_money_label, 1, 1) - economySettingsGroup.setLayout(economyLayout) + budget_inputs = BudgetInputs() + economySettingsGroup = QtWidgets.QGroupBox("Economy") + economySettingsGroup.setLayout(budget_inputs) + self.registerField('starting_money', budget_inputs.starting_money) mlayout = QVBoxLayout() mlayout.addWidget(miscSettingsGroup) From 55f12f20c17895d6d84757bc5a22a08487712f89 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 10 Oct 2020 14:05:25 -0700 Subject: [PATCH 08/13] Fix missing default parameter. --- qt_ui/widgets/QLabeledWidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt_ui/widgets/QLabeledWidget.py b/qt_ui/widgets/QLabeledWidget.py index f258f458..91bb52bb 100644 --- a/qt_ui/widgets/QLabeledWidget.py +++ b/qt_ui/widgets/QLabeledWidget.py @@ -13,7 +13,7 @@ class QLabeledWidget(QHBoxLayout): """ def __init__(self, text: str, widget: QWidget, - tooltip: Optional[str]) -> None: + tooltip: Optional[str] = None) -> None: super().__init__() label = QLabel(text) self.addWidget(label) From c777204f505e0cac9ecd683e152d089c2acc9553 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 10 Oct 2020 14:09:10 -0700 Subject: [PATCH 09/13] Draw waypoint information on the map. --- qt_ui/displayoptions.py | 1 + qt_ui/widgets/map/QLiberationMap.py | 98 ++++++++++++++++++++++------- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py index 020c2b02..1efa13ae 100644 --- a/qt_ui/displayoptions.py +++ b/qt_ui/displayoptions.py @@ -52,6 +52,7 @@ class DisplayOptions: lines = DisplayRule("Lines", True) events = DisplayRule("Events", True) sam_ranges = DisplayRule("SAM Ranges", True) + waypoint_info = DisplayRule("Waypoint Information", True) flight_paths = FlightPathOptions() @classmethod diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 4ce8f41a..6f4eeed2 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import logging from typing import List, Optional, Tuple @@ -19,8 +20,9 @@ import qt_ui.uiconstants as CONST from game import Game, db from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR -from gen import Conflict -from gen.flights.flight import Flight +from game.utils import meter_to_feet +from gen import Conflict, Package, PackageWaypointTiming +from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel from qt_ui.widgets.map.QFrontLine import QFrontLine @@ -238,42 +240,90 @@ class QLiberationMap(QGraphicsView): self.flight_path_items.clear() if DisplayOptions.flight_paths.hide: return - for p_idx, package in enumerate(self.game_model.ato_model.packages): - for f_idx, flight in enumerate(package.flights): + packages = list(self.game_model.ato_model.packages) + for p_idx, package_model in enumerate(packages): + for f_idx, flight in enumerate(package_model.flights): selected = (p_idx, f_idx) == self.selected_flight if DisplayOptions.flight_paths.only_selected and not selected: continue - highlight = selected and DisplayOptions.flight_paths.all - self.draw_flight_plan(scene, flight, highlight) + self.draw_flight_plan(scene, package_model.package, flight, + selected) - def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight, - highlight: bool) -> None: + def draw_flight_plan(self, scene: QGraphicsScene, package: Package, + flight: Flight, selected: bool) -> None: is_player = flight.from_cp.captured pos = self._transform_point(flight.from_cp.position) - self.draw_waypoint(scene, pos, is_player, highlight) + self.draw_waypoint(scene, pos, is_player, selected) prev_pos = tuple(pos) - for point in flight.points: + drew_target = False + target_types = ( + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + for idx, point in enumerate(flight.points): new_pos = self._transform_point(Point(point.x, point.y)) self.draw_flight_path(scene, prev_pos, new_pos, is_player, - highlight) - self.draw_waypoint(scene, new_pos, is_player, highlight) + selected) + self.draw_waypoint(scene, new_pos, is_player, selected) + if selected and DisplayOptions.waypoint_info: + if point.waypoint_type in target_types: + if drew_target: + # Don't draw dozens of targets over each other. + continue + drew_target = True + self.draw_waypoint_info(scene, idx + 1, point, new_pos, package, + flight) prev_pos = tuple(new_pos) - self.draw_flight_path(scene, prev_pos, pos, is_player, highlight) + self.draw_flight_path(scene, prev_pos, pos, is_player, selected) def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], - player: bool, highlight: bool) -> None: - waypoint_pen = self.waypoint_pen(player, highlight) - waypoint_brush = self.waypoint_brush(player, highlight) + player: bool, selected: bool) -> None: + waypoint_pen = self.waypoint_pen(player, selected) + waypoint_brush = self.waypoint_brush(player, selected) self.flight_path_items.append(scene.addEllipse( position[0], position[1], self.WAYPOINT_SIZE, self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush )) + def draw_waypoint_info(self, scene: QGraphicsScene, number: int, + waypoint: FlightWaypoint, position: Tuple[int, int], + package: Package, flight: Flight) -> None: + timing = PackageWaypointTiming.for_package(package) + + altitude = meter_to_feet(waypoint.alt) + altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" + + prefix = "TOT" + time = timing.tot_for_waypoint(waypoint) + if time is None: + prefix = "Depart" + time = timing.depart_time_for_waypoint(waypoint, flight) + if time is None: + tot = "" + else: + tot = f"{prefix} T+{datetime.timedelta(seconds=time)}" + + line0 = scene.addSimpleText(f"{number} {waypoint.name}") + line0.moveBy(position[0] + 8, position[1] - 15) + line0.setZValue(2) + self.flight_path_items.append(line0) + + line1 = scene.addSimpleText(f"{altitude} ft {altitude_type}") + line1.moveBy(position[0] + 8, position[1] - 5) + line1.setZValue(2) + self.flight_path_items.append(line1) + + line2 = scene.addSimpleText(tot) + line2.moveBy(position[0] + 8, position[1] + 5) + line2.setZValue(2) + self.flight_path_items.append(line2) + def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], pos1: Tuple[int, int], player: bool, - highlight: bool) -> None: - flight_path_pen = self.flight_path_pen(player, highlight) + selected: bool) -> None: + flight_path_pen = self.flight_path_pen(player, selected) # Draw the line to the *middle* of the waypoint. offset = self.WAYPOINT_SIZE // 2 self.flight_path_items.append(scene.addLine( @@ -371,14 +421,14 @@ class QLiberationMap(QGraphicsView): else: return self.game.get_enemy_color() - def waypoint_pen(self, player: bool, highlight: bool) -> QColor: - if highlight: + def waypoint_pen(self, player: bool, selected: bool) -> QColor: + if selected and DisplayOptions.flight_paths.all: return self.highlight_color() name = self.base_faction_color_name(player) return CONST.COLORS[name] - def waypoint_brush(self, player: bool, highlight: bool) -> QColor: - if highlight: + def waypoint_brush(self, player: bool, selected: bool) -> QColor: + if selected and DisplayOptions.flight_paths.all: return self.highlight_color(transparent=True) name = self.base_faction_color_name(player) return CONST.COLORS[f"{name}_transparent"] @@ -400,8 +450,8 @@ class QLiberationMap(QGraphicsView): qpen.setStyle(Qt.DotLine) return qpen - def flight_path_pen(self, player: bool, highlight: bool) -> QPen: - if highlight: + def flight_path_pen(self, player: bool, selected: bool) -> QPen: + if selected and DisplayOptions.flight_paths.all: return self.highlight_color() name = self.base_faction_color_name(player) From edd56cb407f870fffa36595985be5a595b01eb05 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 10 Oct 2020 14:11:44 -0700 Subject: [PATCH 10/13] Fix bad import of THEMES. --- qt_ui/windows/preferences/QLiberationPreferences.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qt_ui/windows/preferences/QLiberationPreferences.py b/qt_ui/windows/preferences/QLiberationPreferences.py index bc392561..3e8db6a7 100644 --- a/qt_ui/windows/preferences/QLiberationPreferences.py +++ b/qt_ui/windows/preferences/QLiberationPreferences.py @@ -13,9 +13,8 @@ from PySide2.QtWidgets import ( QVBoxLayout, ) -import qt_ui.uiconstants as CONST from qt_ui import liberation_install, liberation_theme -from qt_ui.liberation_theme import get_theme_index, set_theme_index +from qt_ui.liberation_theme import THEMES, get_theme_index, set_theme_index class QLiberationPreferences(QFrame): @@ -39,7 +38,7 @@ class QLiberationPreferences(QFrame): self.browse_install_dir = QPushButton("Browse...") self.browse_install_dir.clicked.connect(self.on_browse_installation_dir) self.themeSelect = QComboBox() - [self.themeSelect.addItem(y['themeName']) for x, y in CONST.THEMES.items()] + [self.themeSelect.addItem(y['themeName']) for x, y in THEMES.items()] self.initUi() From 7b79d183eb368d6939d510a8d3f19e60b7323218 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sun, 11 Oct 2020 00:48:13 +0200 Subject: [PATCH 11/13] Changelog 2.1.5 did not make it on this branch somehow --- changelog.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/changelog.md b/changelog.md index c0ecb509..59c10b15 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ +# 2.1.5 + +## Features/Improvements : +* **[Units/Factions]** Enabled EPLRS for ground units that supports it (so they appear on A-10C II TAD and Helmet) + +## Fixes : +* **[UI]** Fixed an issue that prevent saving after aborting a mission +* **[Mission Generator]** Fixed aircraft landing point type being wrong + # 2.1.4 ## Fixes : From d414c00b74fd625f8b4a2678881357313007942a Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 10 Oct 2020 16:52:44 -0700 Subject: [PATCH 12/13] Make waypoint info more readable. --- qt_ui/widgets/map/QLiberationMap.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 6f4eeed2..829ea45e 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -305,20 +305,22 @@ class QLiberationMap(QGraphicsView): else: tot = f"{prefix} T+{datetime.timedelta(seconds=time)}" - line0 = scene.addSimpleText(f"{number} {waypoint.name}") - line0.moveBy(position[0] + 8, position[1] - 15) - line0.setZValue(2) - self.flight_path_items.append(line0) + pen = QPen(QColor("black"), 0.3) + brush = QColor("white") - line1 = scene.addSimpleText(f"{altitude} ft {altitude_type}") - line1.moveBy(position[0] + 8, position[1] - 5) - line1.setZValue(2) - self.flight_path_items.append(line1) + def draw_text(text: str, x: int, y: int) -> None: + item = scene.addSimpleText(text) + item.setBrush(brush) + item.setPen(pen) + item.moveBy(x, y) + item.setZValue(2) + self.flight_path_items.append(item) - line2 = scene.addSimpleText(tot) - line2.moveBy(position[0] + 8, position[1] + 5) - line2.setZValue(2) - self.flight_path_items.append(line2) + draw_text(f"{number} {waypoint.name}", position[0] + 8, + position[1] - 15) + draw_text(f"{altitude} ft {altitude_type}", position[0] + 8, + position[1] - 5) + draw_text(tot, position[0] + 8, position[1] + 5) def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], pos1: Tuple[int, int], player: bool, From 974b6590d8a6c0bac734e40f90e1c9b34026d03e Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Oct 2020 21:25:56 -0700 Subject: [PATCH 13/13] Estimate TOTs for packages. We estimate the longest possible time from mission start to TOT for all flights in a package and use that to set the TOT (plus any delay used to stagger flights). This both cuts down on loiter time for shorter flights and ensures that long flights will make it to the target in time. This is also used to compute the start time for the AI, so the explicit delay option is no longer needed. --- gen/aircraft.py | 405 ++++++------------ gen/ato.py | 2 +- gen/briefinggen.py | 4 +- gen/flights/ai_flight_planner.py | 35 +- gen/flights/flight.py | 19 +- gen/flights/traveltime.py | 285 ++++++++++++ qt_ui/models.py | 12 +- qt_ui/widgets/QTopPanel.py | 2 + qt_ui/widgets/ato.py | 14 +- qt_ui/widgets/map/QLiberationMap.py | 3 +- qt_ui/windows/mission/QFlightItem.py | 18 +- qt_ui/windows/mission/QPackageDialog.py | 9 +- .../windows/mission/flight/QFlightCreator.py | 6 +- .../flight/settings/QFlightDepartureEditor.py | 5 +- 14 files changed, 497 insertions(+), 322 deletions(-) create mode 100644 gen/flights/traveltime.py diff --git a/gen/aircraft.py b/gen/aircraft.py index cbb028fe..49c6e8cd 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -57,14 +57,14 @@ from dcs.task import ( ) from dcs.terrain.terrain import Airport, NoParkingSlotError from dcs.translation import String -from dcs.triggers import Event, TriggerOnce +from dcs.triggers import Event, TriggerOnce, TriggerRule from dcs.unitgroup import FlyingGroup, Group, ShipGroup, StaticGroup from dcs.unittype import FlyingType, UnitType from game import db from game.data.cap_capabilities_db import GUNFIGHTERS from game.settings import Settings -from game.utils import meter_to_nm, nm_to_meter +from game.utils import nm_to_meter from gen.airfields import RunwayData from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package @@ -76,9 +76,10 @@ from gen.flights.flight import ( FlightWaypointType, ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio -from theater import MissionTarget, TheaterGroundObject +from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict +from .flights.traveltime import PackageWaypointTiming, TotEstimator from .naming import namegen WARM_START_HELI_AIRSPEED = 120 @@ -86,8 +87,6 @@ WARM_START_HELI_ALT = 500 WARM_START_ALTITUDE = 3000 WARM_START_AIRSPEED = 550 -CAP_DURATION = 30 # minutes - RTB_ALTITUDE = 800 RTB_DISTANCE = 5000 HELI_ALT = 500 @@ -217,7 +216,7 @@ class FlightData: #: True if this flight belongs to the player's coalition. friendly: bool - #: Number of minutes after mission start the flight is set to depart. + #: Number of seconds after mission start the flight is set to depart. departure_delay: int #: Arrival airport. @@ -533,148 +532,6 @@ AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] -@dataclass(frozen=True) -class PackageWaypointTiming: - #: The package being scheduled. - package: Package - - #: The package join time. - join: int - - #: The ingress waypoint TOT. - ingress: int - - #: The egress waypoint TOT. - egress: int - - #: The package split time. - split: int - - @property - def target(self) -> int: - """The package time over target.""" - assert self.package.time_over_target is not None - return self.package.time_over_target - - @property - def race_track_start(self) -> Optional[int]: - cap_types = (FlightType.BARCAP, FlightType.CAP) - if self.package.primary_task in cap_types: - # CAP flights don't have hold points, and we don't calculate takeoff - # times yet or adjust the TOT based on when the flight can arrive, - # so if we set a TOT that gives the flight a lot of extra time it - # will just fly to the start point slowly, possibly slowly enough to - # stall and crash. Just don't set a TOT for these points and let the - # CAP get on station ASAP. - return None - else: - return self.ingress - - @property - def race_track_end(self) -> int: - cap_types = (FlightType.BARCAP, FlightType.CAP) - if self.package.primary_task in cap_types: - return self.target + CAP_DURATION * 60 - else: - return self.egress - - def push_time(self, flight: Flight, hold_point: Point) -> int: - assert self.package.waypoints is not None - return self.join - self.travel_time( - hold_point, - self.package.waypoints.join, - self.flight_ground_speed(flight) - ) - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: - target_types = ( - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) - - ingress_types = ( - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, - ) - - if waypoint.waypoint_type == FlightWaypointType.JOIN: - return self.join - elif waypoint.waypoint_type in ingress_types: - return self.ingress - elif waypoint.waypoint_type in target_types: - return self.target - elif waypoint.waypoint_type == FlightWaypointType.EGRESS: - return self.egress - elif waypoint.waypoint_type == FlightWaypointType.SPLIT: - return self.split - elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK: - return self.race_track_start - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint, - flight: Flight) -> Optional[int]: - if waypoint.waypoint_type == FlightWaypointType.LOITER: - return self.push_time(flight, Point(waypoint.x, waypoint.y)) - elif waypoint.waypoint_type == FlightWaypointType.PATROL: - return self.race_track_end - return None - - @classmethod - def for_package(cls, package: Package) -> PackageWaypointTiming: - assert package.time_over_target is not None - assert package.waypoints is not None - - group_ground_speed = cls.package_ground_speed(package) - - ingress = package.time_over_target - cls.travel_time( - package.waypoints.ingress, - package.target.position, - group_ground_speed - ) - - join = ingress - cls.travel_time( - package.waypoints.join, - package.waypoints.ingress, - group_ground_speed - ) - - egress = package.time_over_target + cls.travel_time( - package.target.position, - package.waypoints.egress, - group_ground_speed - ) - - split = egress + cls.travel_time( - package.waypoints.egress, - package.waypoints.split, - group_ground_speed - ) - - return cls(package, join, ingress, egress, split) - - @classmethod - def package_ground_speed(cls, package: Package) -> int: - speeds = [] - for flight in package.flights: - speeds.append(cls.flight_ground_speed(flight)) - return min(speeds) # knots - - @staticmethod - def flight_ground_speed(_flight: Flight) -> int: - # TODO: Gather data so this is useful. - return 400 # knots - - @staticmethod - def travel_time(a: Point, b: Point, speed: float) -> int: - error_factor = 1.1 - distance = meter_to_nm(a.distance_to_point(b)) - hours = distance / speed - seconds = hours * 3600 - return int(seconds * error_factor) - - class AircraftConflictGenerator: def __init__(self, mission: Mission, conflict: Conflict, settings: Settings, game, radio_registry: RadioRegistry): @@ -702,8 +559,13 @@ class AircraftConflictGenerator: except KeyError: return get_fallback_channel(airframe) - def _start_type(self) -> StartType: - return self.settings.cold_start and StartType.Cold or StartType.Warm + @staticmethod + def _start_type(start_type: str) -> StartType: + if start_type == "Runway": + return StartType.Runway + elif start_type == "Cold": + return StartType.Cold + return StartType.Warm def _setup_group(self, group: FlyingGroup, for_task: Type[Task], flight: Flight, dynamic_runways: Dict[str, RunwayData]): @@ -808,15 +670,10 @@ class AircraftConflictGenerator: return runways[0] def _generate_at_airport(self, name: str, side: Country, - unit_type: FlyingType, count: int, - client_count: int, - airport: Optional[Airport] = None, - start_type=None) -> FlyingGroup: + unit_type: FlyingType, count: int, start_type: str, + airport: Optional[Airport] = None) -> FlyingGroup: assert count > 0 - if start_type is None: - start_type = self._start_type() - logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport)) return self.m.flight_group_from_airport( country=side, @@ -824,11 +681,11 @@ class AircraftConflictGenerator: aircraft_type=unit_type, airport=airport, maintask=None, - start_type=start_type, + start_type=self._start_type(start_type), group_size=count, parking_slots=None) - def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: Point) -> FlyingGroup: + def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, at: Point) -> FlyingGroup: assert count > 0 if unit_type in helicopters.helicopter_map.values(): @@ -850,21 +707,16 @@ class AircraftConflictGenerator: altitude=alt, speed=speed, maintask=None, - start_type=self._start_type(), group_size=count) group.points[0].alt_type = "RADIO" return group def _generate_at_group(self, name: str, side: Country, - unit_type: FlyingType, count: int, client_count: int, - at: Union[ShipGroup, StaticGroup], - start_type=None) -> FlyingGroup: + unit_type: FlyingType, count: int, start_type: str, + at: Union[ShipGroup, StaticGroup]) -> FlyingGroup: assert count > 0 - if start_type is None: - start_type = self._start_type() - logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at)) return self.m.flight_group_from_unit( country=side, @@ -872,34 +724,9 @@ class AircraftConflictGenerator: aircraft_type=unit_type, pad_group=at, maintask=None, - start_type=start_type, + start_type=self._start_type(start_type), group_size=count) - def _generate_group(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: db.StartingPosition): - if isinstance(at, Point): - return self._generate_inflight(name, side, unit_type, count, client_count, at) - elif isinstance(at, Group): - takeoff_ban = unit_type in db.CARRIER_TAKEOFF_BAN - ai_ban = client_count == 0 and self.settings.only_player_takeoff - - if not takeoff_ban and not ai_ban: - return self._generate_at_group(name, side, unit_type, count, client_count, at) - else: - return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - elif isinstance(at, Airport): - takeoff_ban = unit_type in db.TAKEOFF_BAN - ai_ban = client_count == 0 and self.settings.only_player_takeoff - - if not takeoff_ban and not ai_ban: - try: - return self._generate_at_airport(name, side, unit_type, count, client_count, at) - except NoParkingSlotError: - logging.info("No parking slot found at " + at.name + ", switching to air start.") - pass - return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - else: - assert False - def _add_radio_waypoint(self, group: FlyingGroup, position, altitude: int, airspeed: int = 600): point = group.add_waypoint(position, altitude, airspeed) point.alt_type = "RADIO" @@ -970,110 +797,91 @@ class AircraftConflictGenerator: logging.info(f"Generating flight: {flight.unit_type}") group = self.generate_planned_flight(flight.from_cp, country, flight) - self.setup_flight_group(group, flight, timing, dynamic_runways) - self.setup_group_activation_trigger(flight, group) + self.setup_flight_group(group, package, flight, timing, + dynamic_runways) - def setup_group_activation_trigger(self, flight, group): - if flight.scheduled_in > 0 and flight.client_count == 0: + def set_activation_time(self, flight: Flight, group: FlyingGroup, + delay: int) -> None: + # Note: Late activation causes the waypoint TOTs to look *weird* in the + # mission editor. Waypoint times will be relative to the group + # activation time rather than in absolute local time. A flight delayed + # until 09:10 when the overall mission start time is 09:00, with a join + # time of 09:30 will show the join time as 00:30, not 09:30. + group.late_activation = True - if flight.start_type != "In Flight" and flight.from_cp.cptype not in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]: - group.late_activation = False - group.uncontrolled = True + activation_trigger = TriggerOnce( + Event.NoEvent, f"FlightLateActivationTrigger{group.id}") + activation_trigger.add_condition(TimeAfter(seconds=delay)) - activation_trigger = TriggerOnce(Event.NoEvent, "FlightStartTrigger" + str(group.id)) - activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in * 60)) - if (flight.from_cp.cptype == ControlPointType.AIRBASE): - if flight.from_cp.captured: - activation_trigger.add_condition( - CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id)) - else: - activation_trigger.add_condition( - CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id)) + self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) + activation_trigger.add_action(ActivateGroup(group.id)) + self.m.triggerrules.triggers.append(activation_trigger) - if flight.flight_type == FlightType.INTERCEPTION: - self.setup_interceptor_triggers(group, flight, activation_trigger) + def set_startup_time(self, flight: Flight, group: FlyingGroup, + delay: int) -> None: + # Uncontrolled causes the AI unit to spawn, but not begin startup. + group.uncontrolled = True - group.add_trigger_action(StartCommand()) - activation_trigger.add_action(AITaskPush(group.id, len(group.tasks))) + activation_trigger = TriggerOnce(Event.NoEvent, + f"FlightStartTrigger{group.id}") + activation_trigger.add_condition(TimeAfter(seconds=delay)) - self.m.triggerrules.triggers.append(activation_trigger) - else: - group.late_activation = True - activation_trigger = TriggerOnce(Event.NoEvent, "FlightLateActivationTrigger" + str(group.id)) - activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in*60)) + self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) + group.add_trigger_action(StartCommand()) + activation_trigger.add_action(AITaskPush(group.id, len(group.tasks))) + self.m.triggerrules.triggers.append(activation_trigger) - if(flight.from_cp.cptype == ControlPointType.AIRBASE): - if flight.from_cp.captured: - activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id)) - else: - activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id)) + def prevent_spawn_at_hostile_airbase(self, flight: Flight, + trigger: TriggerRule) -> None: + # Prevent delayed flights from spawning at airbases if they were + # captured before they've spawned. + if flight.from_cp.cptype != ControlPointType.AIRBASE: + return - if flight.flight_type == FlightType.INTERCEPTION: - self.setup_interceptor_triggers(group, flight, activation_trigger) - - activation_trigger.add_action(ActivateGroup(group.id)) - self.m.triggerrules.triggers.append(activation_trigger) - - def setup_interceptor_triggers(self, group, flight, activation_trigger): - - detection_zone = self.m.triggers.add_triggerzone(flight.from_cp.position, radius=25000, hidden=False, name="ITZ") if flight.from_cp.captured: - activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_enemy_color(), detection_zone.id)) # TODO : support unit type in part of coalition - activation_trigger.add_action(MessageToAll(String("WARNING : Enemy aircraft have been detected in the vicinity of " + flight.from_cp.name + ". Interceptors are taking off."), 20)) + coalition = self.game.get_player_coalition_id() else: - activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_player_color(), detection_zone.id)) - activation_trigger.add_action(MessageToAll(String("WARNING : We have detected that enemy aircraft are scrambling for an interception on " + flight.from_cp.name + " airbase."), 20)) + coalition = self.game.get_enemy_coalition_id() + + trigger.add_condition( + CoalitionHasAirdrome(coalition, flight.from_cp.id)) def generate_planned_flight(self, cp, country, flight:Flight): try: - if flight.client_count == 0 and self.game.settings.perf_ai_parking_start: - flight.start_type = "Cold" - if flight.start_type == "In Flight": - group = self._generate_group( + group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, unit_type=flight.unit_type, count=flight.count, - client_count=0, at=cp.position) + elif cp.is_fleet: + group_name = cp.get_carrier_group_name() + group = self._generate_at_group( + name=namegen.next_unit_name(country, cp.id, flight.unit_type), + side=country, + unit_type=flight.unit_type, + count=flight.count, + start_type=flight.start_type, + at=self.m.find_group(group_name)) else: - st = StartType.Runway - if flight.start_type == "Cold": - st = StartType.Cold - elif flight.start_type == "Warm": - st = StartType.Warm - - if cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]: - group_name = cp.get_carrier_group_name() - group = self._generate_at_group( - name=namegen.next_unit_name(country, cp.id, flight.unit_type), - side=country, - unit_type=flight.unit_type, - count=flight.count, - client_count=0, - at=self.m.find_group(group_name), - start_type=st) - else: - group = self._generate_at_airport( - name=namegen.next_unit_name(country, cp.id, flight.unit_type), - side=country, - unit_type=flight.unit_type, - count=flight.count, - client_count=0, - airport=cp.airport, - start_type=st) + group = self._generate_at_airport( + name=namegen.next_unit_name(country, cp.id, flight.unit_type), + side=country, + unit_type=flight.unit_type, + count=flight.count, + start_type=flight.start_type, + airport=cp.airport) except Exception as e: # Generated when there is no place on Runway or on Parking Slots logging.error(e) logging.warning("No room on runway or parking slots. Starting from the air.") flight.start_type = "In Flight" - group = self._generate_group( + group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, unit_type=flight.unit_type, count=flight.count, - client_count=0, at=cp.position) group.points[0].alt = 1500 @@ -1179,8 +987,8 @@ class AircraftConflictGenerator: logging.error(f"Unhandled flight type: {flight.flight_type.name}") self.configure_behavior(group) - def setup_flight_group(self, group: FlyingGroup, flight: Flight, - timing: PackageWaypointTiming, + def setup_flight_group(self, group: FlyingGroup, package: Package, + flight: Flight, timing: PackageWaypointTiming, dynamic_runways: Dict[str, RunwayData]) -> None: flight_type = flight.flight_type if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, @@ -1204,6 +1012,9 @@ class AircraftConflictGenerator: for waypoint in flight.points: waypoint.tot = None + takeoff_point = FlightWaypoint.from_pydcs(group.points[0], + flight.from_cp) + self.set_takeoff_time(takeoff_point, package, flight, group) for point in flight.points: if point.only_for_player and not flight.client_count: continue @@ -1214,9 +1025,53 @@ class AircraftConflictGenerator: # Set here rather than when the FlightData is created so they waypoints # have their TOTs set. - self.flights[-1].waypoints = flight.points + self.flights[-1].waypoints = [takeoff_point] + flight.points self._setup_custom_payload(flight, group) + def set_takeoff_time(self, waypoint: FlightWaypoint, package: Package, + flight: Flight, group: FlyingGroup) -> None: + estimator = TotEstimator(package) + start_time = estimator.mission_start_time(flight) + + if start_time > 0: + if self.should_activate_late(flight): + # Late activation causes the aircraft to not be spawned until + # triggered. + self.set_activation_time(flight, group, start_time) + elif flight.start_type == "Cold": + # Setting the start time causes the AI to wait until the + # specified time to begin their startup sequence. + self.set_startup_time(flight, group, start_time) + + # And setting *our* waypoint TOT causes the takeoff time to show up in + # the player's kneeboard. + waypoint.tot = estimator.takeoff_time_for_flight(flight) + + @staticmethod + def should_activate_late(flight: Flight) -> bool: + if flight.client_count: + # Never delay players. Note that cold start player flights with + # AI members will still be marked as uncontrolled until the start + # trigger fires to postpone engine start. + # + # Player flights that start on the runway or in the air will start + # immediately, and AI flight members will not be delayed. + return False + + if flight.start_type != "Cold": + # Avoid spawning aircraft in the air or on the runway until it's + # time for their mission. Also avoid burning through gas spawning + # hot aircraft hours before their takeoff time. + return True + + if flight.from_cp.is_fleet: + # Carrier spawns will crowd the carrier deck, especially without + # super carrier. + # TODO: Is there enough parking on the supercarrier? + return True + + return False + class PydcsWaypointBuilder: def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup, @@ -1243,10 +1098,8 @@ class PydcsWaypointBuilder: waypoint.speed_locked = False @classmethod - def for_waypoint(cls, waypoint: FlightWaypoint, - group: FlyingGroup, - flight: Flight, - timing: PackageWaypointTiming, + def for_waypoint(cls, waypoint: FlightWaypoint, group: FlyingGroup, + flight: Flight, timing: PackageWaypointTiming, mission: Mission) -> PydcsWaypointBuilder: builders = { FlightWaypointType.EGRESS: EgressPointBuilder, @@ -1391,9 +1244,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder): pattern=OrbitAction.OrbitPattern.RaceTrack )) - start = self.timing.race_track_start - if start is not None: - self.set_waypoint_tot(waypoint, start) + self.set_waypoint_tot(waypoint, self.timing.race_track_start) racetrack.stop_after_time(self.timing.race_track_end) waypoint.add_task(racetrack) return waypoint diff --git a/gen/ato.py b/gen/ato.py index e9c5393c..f4877125 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -52,7 +52,7 @@ class Package: delay: int = field(default=0) #: Desired TOT measured in seconds from mission start. - time_over_target: Optional[int] = field(default=None) + time_over_target: int = field(default=0) waypoints: Optional[PackageWaypoints] = field(default=None) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 82744a8a..d52f25cc 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -1,3 +1,4 @@ +import datetime import os from collections import defaultdict from dataclasses import dataclass @@ -116,9 +117,10 @@ class BriefingGenerator(MissionInfoGenerator): assert not flight.client_units aircraft = flight.aircraft_type flight_unit_name = db.unit_type_name(aircraft) + delay = datetime.timedelta(seconds=flight.departure_delay) self.description += ( f"{flight.flight_type.name} {flight_unit_name} x {flight.size}, " - f"departing in {flight.departure_delay} minutes\n" + f"departing in {delay}\n" ) def generate(self): diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 09be3773..01f5d1b4 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -33,6 +33,7 @@ from gen.flights.flight import ( FlightType, ) from gen.flights.flightplan import FlightPlanBuilder +from gen.flights.traveltime import TotEstimator from theater import ( ControlPoint, FrontLine, @@ -185,11 +186,13 @@ class PackageBuilder: def __init__(self, location: MissionTarget, closest_airfields: ClosestAirfields, global_inventory: GlobalAircraftInventory, - is_player: bool) -> None: + is_player: bool, + start_type: str) -> None: self.package = Package(location) self.allocator = AircraftAllocator(closest_airfields, global_inventory, is_player) self.global_inventory = global_inventory + self.start_type = start_type def plan_flight(self, plan: ProposedFlight) -> bool: """Allocates aircraft for the given flight and adds them to the package. @@ -203,7 +206,8 @@ class PackageBuilder: if assignment is None: return False airfield, aircraft = assignment - flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task) + flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task, + self.start_type) self.package.add_flight(flight) return True @@ -444,11 +448,18 @@ class CoalitionMissionPlanner: def plan_mission(self, mission: ProposedMission) -> None: """Allocates aircraft for a proposed mission and adds it to the ATO.""" + + if self.game.settings.perf_ai_parking_start: + start_type = "Cold" + else: + start_type = "Warm" + builder = PackageBuilder( mission.location, self.objective_finder.closest_airfields_to(mission.location), self.game.aircraft_inventory, - self.is_player + self.is_player, + start_type ) missing_types: Set[FlightType] = set() @@ -497,16 +508,18 @@ class CoalitionMissionPlanner: margin=5 ) for package in self.ato.packages: + tot = TotEstimator(package).earliest_tot() if package.primary_task in dca_types: - # All CAP missions should be on station in 15-25 minutes. - package.time_over_target = (20 + random.randint(-5, 5)) * 60 + # All CAP missions should be on station ASAP. + package.time_over_target = tot else: - # But other packages should be spread out a bit. - package.delay = next(start_time) - # TODO: Compute TOT based on package. - package.time_over_target = (package.delay + 40) * 60 - for flight in package.flights: - flight.scheduled_in = package.delay + # But other packages should be spread out a bit. Note that take + # times are delayed, but all aircraft will become active at + # mission start. This makes it more worthwhile to attack enemy + # airfields to hit grounded aircraft, since they're more likely + # to be present. Runway and air started aircraft will be + # delayed until their takeoff time by AirConflictGenerator. + package.time_over_target = next(start_time) * 60 + tot def message(self, title, text) -> None: """Emits a planning message to the player. diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 0c972723..c9031bb4 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -97,7 +97,6 @@ class FlightWaypoint: # flight's offset in the UI. self.tot: Optional[int] = None - @classmethod def from_pydcs(cls, point: MovingPoint, from_cp: ControlPoint) -> "FlightWaypoint": @@ -130,13 +129,10 @@ class Flight: client_count: int = 0 use_custom_loadout = False preset_loadout_name = "" - start_type = "Runway" group = False # Contains DCS Mission group data after mission has been generated - # How long before this flight should take off - scheduled_in = 0 - - def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, flight_type: FlightType): + def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, + flight_type: FlightType, start_type: str) -> None: self.unit_type = unit_type self.count = count self.from_cp = from_cp @@ -144,11 +140,16 @@ class Flight: self.points: List[FlightWaypoint] = [] self.targets: List[MissionTarget] = [] self.loadout: Dict[str, str] = {} - self.start_type = "Runway" + self.start_type = start_type + # Late activation delay in seconds from mission start. This is not + # the same as the flight's takeoff time. Takeoff time depends on the + # mission's TOT and the other flights in the package. Takeoff time is + # determined by AirConflictGenerator. + self.scheduled_in = 0 def __repr__(self): return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ - + " in " + str(self.scheduled_in) + " minutes (" + str(len(self.points)) + " wpt)" + + " (" + str(len(self.points)) + " wpt)" # Test @@ -157,6 +158,6 @@ if __name__ == '__main__': from theater import ControlPoint, Point, List from_cp = ControlPoint(0, "AA", Point(0, 0), Point(0, 0), [], 0, 0) - f = Flight(A_10C(), 4, from_cp, FlightType.CAS) + f = Flight(A_10C(), 4, from_cp, FlightType.CAS, "Cold") f.scheduled_in = 50 print(f) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py new file mode 100644 index 00000000..87d2817d --- /dev/null +++ b/gen/flights/traveltime.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable, Optional + +from dcs.mapping import Point + +from game.utils import meter_to_nm +from gen.ato import Package +from gen.flights.flight import ( + Flight, + FlightType, + FlightWaypoint, + FlightWaypointType, +) + + +CAP_DURATION = 30 # Minutes +CAP_TYPES = (FlightType.BARCAP, FlightType.CAP) + + +class GroundSpeed: + @classmethod + def for_package(cls, package: Package) -> int: + speeds = [] + for flight in package.flights: + speeds.append(cls.for_flight(flight)) + return min(speeds) # knots + + @staticmethod + def for_flight(_flight: Flight) -> int: + # TODO: Gather data so this is useful. + # TODO: Expose both a cruise speed and target speed. + # The cruise speed can be used for ascent, hold, join, and RTB to save + # on fuel, but mission speed will be fast enough to keep the flight + # safer. + return 400 # knots + + +class TravelTime: + @staticmethod + def between_points(a: Point, b: Point, speed: float) -> int: + error_factor = 1.1 + distance = meter_to_nm(a.distance_to_point(b)) + hours = distance / speed + seconds = hours * 3600 + return int(seconds * error_factor) + + +class TotEstimator: + # An extra five minutes given as wiggle room. Expected to be spent at the + # hold point performing any last minute configuration. + HOLD_TIME = 5 * 60 + + def __init__(self, package: Package) -> None: + self.package = package + self.timing = PackageWaypointTiming.for_package(package) + + def mission_start_time(self, flight: Flight) -> int: + takeoff_time = self.takeoff_time_for_flight(flight) + startup_time = self.estimate_startup(flight) + ground_ops_time = self.estimate_ground_ops(flight) + return takeoff_time - startup_time - ground_ops_time + + def takeoff_time_for_flight(self, flight: Flight) -> int: + stop_types = {FlightWaypointType.JOIN, FlightWaypointType.PATROL_TRACK} + travel_time = self.estimate_waypoints_to_target(flight, stop_types) + if travel_time is None: + logging.warning("Found no join point or patrol point. Cannot " + f"estimate takeoff time takeoff time for {flight}") + # Takeoff immediately. + return 0 + + if self.package.primary_task in CAP_TYPES: + start_time = self.timing.race_track_start + else: + start_time = self.timing.join + return start_time - travel_time - self.HOLD_TIME + + def earliest_tot(self) -> int: + return max(( + self.earliest_tot_for_flight(f) for f in self.package.flights + )) + self.HOLD_TIME + + def earliest_tot_for_flight(self, flight: Flight) -> int: + """Estimate fastest time from mission start to the target position. + + For CAP missions, this is time to race track start. + + For other mission types this is the time to the mission target. + + Args: + flight: The flight to get the earliest TOT time for. + + Returns: + The earliest possible TOT for the given flight in seconds. Returns 0 + if an ingress point cannot be found. + """ + stop_types = { + FlightWaypointType.PATROL_TRACK, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + } + time_to_ingress = self.estimate_waypoints_to_target(flight, stop_types) + if time_to_ingress is None: + logging.warning( + f"Found no ingress types. Cannot estimate TOT for {flight}") + # Return 0 so this flight's travel time does not affect the rest of + # the package. + return 0 + + if self.package.primary_task in CAP_TYPES: + # The racetrack start *is* the target. The package target is the + # protected objective. + time_to_target = 0 + else: + assert self.package.waypoints is not None + time_to_target = TravelTime.between_points( + self.package.waypoints.ingress, self.package.target.position, + GroundSpeed.for_package(self.package)) + return sum([ + self.estimate_startup(flight), + self.estimate_ground_ops(flight), + time_to_ingress, + time_to_target, + ]) + + @staticmethod + def estimate_startup(flight: Flight) -> int: + if flight.start_type == "Cold": + return 10 * 60 + return 0 + + @staticmethod + def estimate_ground_ops(flight: Flight) -> int: + if flight.start_type in ("Runway", "In Flight"): + return 0 + if flight.from_cp.is_fleet: + return 2 * 60 + else: + return 5 * 60 + + def estimate_waypoints_to_target( + self, flight: Flight, + stop_types: Iterable[FlightWaypointType]) -> Optional[int]: + total = 0 + previous_position = flight.from_cp.position + for waypoint in flight.points: + position = Point(waypoint.x, waypoint.y) + total += TravelTime.between_points( + previous_position, position, + self.speed_to_waypoint(flight, waypoint) + ) + previous_position = position + if waypoint.waypoint_type in stop_types: + return total + + return None + + def speed_to_waypoint(self, flight: Flight, + waypoint: FlightWaypoint) -> int: + pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN) + if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT: + # Flights that start airborne already have some altitude and a good + # amount of speed. + factor = 1.0 if flight.start_type == "In Flight" else 0.5 + return int(GroundSpeed.for_flight(flight) * factor) + elif waypoint.waypoint_type in pre_join: + return GroundSpeed.for_flight(flight) + return GroundSpeed.for_package(self.package) + + +@dataclass(frozen=True) +class PackageWaypointTiming: + #: The package being scheduled. + package: Package + + #: The package join time. + join: int + + #: The ingress waypoint TOT. + ingress: int + + #: The egress waypoint TOT. + egress: int + + #: The package split time. + split: int + + @property + def target(self) -> int: + """The package time over target.""" + assert self.package.time_over_target is not None + return self.package.time_over_target + + @property + def race_track_start(self) -> int: + if self.package.primary_task in CAP_TYPES: + return self.package.time_over_target + else: + return self.ingress + + @property + def race_track_end(self) -> int: + if self.package.primary_task in CAP_TYPES: + return self.target + CAP_DURATION * 60 + else: + return self.egress + + def push_time(self, flight: Flight, hold_point: Point) -> int: + assert self.package.waypoints is not None + return self.join - TravelTime.between_points( + hold_point, + self.package.waypoints.join, + GroundSpeed.for_flight(flight) + ) + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: + target_types = ( + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + + ingress_types = ( + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + ) + + if waypoint.waypoint_type == FlightWaypointType.JOIN: + return self.join + elif waypoint.waypoint_type in ingress_types: + return self.ingress + elif waypoint.waypoint_type in target_types: + return self.target + elif waypoint.waypoint_type == FlightWaypointType.EGRESS: + return self.egress + elif waypoint.waypoint_type == FlightWaypointType.SPLIT: + return self.split + elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK: + return self.race_track_start + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint, + flight: Flight) -> Optional[int]: + if waypoint.waypoint_type == FlightWaypointType.LOITER: + return self.push_time(flight, Point(waypoint.x, waypoint.y)) + elif waypoint.waypoint_type == FlightWaypointType.PATROL: + return self.race_track_end + return None + + @classmethod + def for_package(cls, package: Package) -> PackageWaypointTiming: + assert package.waypoints is not None + + group_ground_speed = GroundSpeed.for_package(package) + + ingress = package.time_over_target - TravelTime.between_points( + package.waypoints.ingress, + package.target.position, + group_ground_speed + ) + + join = ingress - TravelTime.between_points( + package.waypoints.join, + package.waypoints.ingress, + group_ground_speed + ) + + egress = package.time_over_target + TravelTime.between_points( + package.target.position, + package.waypoints.egress, + group_ground_speed + ) + + split = egress + TravelTime.between_points( + package.waypoints.egress, + package.waypoints.split, + group_ground_speed + ) + + return cls(package, join, ingress, egress, split) diff --git a/qt_ui/models.py b/qt_ui/models.py index 428b4598..ba816fd1 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -1,7 +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 typing import Any, Callable, Dict, Iterator, Optional, TypeVar from PySide2.QtCore import ( QAbstractListModel, @@ -15,6 +14,7 @@ from game import db from game.game import Game from gen.ato import AirTaskingOrder, Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS from theater.missiontarget import MissionTarget @@ -119,15 +119,15 @@ class PackageModel(QAbstractListModel): return flight return None - @staticmethod - def text_for_flight(flight: Flight) -> str: + def text_for_flight(self, 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 + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) origin = flight.from_cp.name - return f"[{task}] {count} x {name} from {origin} in {delay} minutes" + return f"[{task}] {count} x {name} from {origin} in {delay}" @staticmethod def icon_for_flight(flight: Flight) -> Optional[QIcon]: diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 74c9608f..99f0ac9f 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -147,6 +147,8 @@ class QTopPanel(QFrame): if not self.ato_has_clients() and not self.confirm_no_client_launch(): return + # TODO: Verify no negative start times. + # TODO: Refactor this nonsense. game_event = None for event in self.game.events: diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index c978c1bc..32178381 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -24,6 +24,7 @@ from PySide2.QtWidgets import ( from game import db from gen.ato import Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from ..models import AtoModel, GameModel, NullListModel, PackageModel @@ -33,6 +34,10 @@ class FlightDelegate(QStyledItemDelegate): HMARGIN = 4 VMARGIN = 4 + def __init__(self, package: Package) -> None: + super().__init__() + self.package = package + def get_font(self, option: QStyleOptionViewItem) -> QFont: font = QFont(option.font) font.setPointSize(self.FONT_SIZE) @@ -47,8 +52,9 @@ class FlightDelegate(QStyledItemDelegate): task = flight.flight_type.name count = flight.count name = db.unit_type_name(flight.unit_type) - delay = flight.scheduled_in - return f"[{task}] {count} x {name} in {delay} minutes" + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + return f"[{task}] {count} x {name} in {delay}" def second_row_text(self, index: QModelIndex) -> str: flight = self.flight(index) @@ -128,7 +134,8 @@ class QFlightList(QListView): super().__init__() self.package_model = model self.set_package(model) - self.setItemDelegate(FlightDelegate()) + if model is not None: + self.setItemDelegate(FlightDelegate(model.package)) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) @@ -138,6 +145,7 @@ class QFlightList(QListView): self.disconnect_model() else: self.package_model = model + self.setItemDelegate(FlightDelegate(model.package)) self.setModel(model) # noinspection PyUnresolvedReferences model.deleted.connect(self.disconnect_model) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 829ea45e..3c0064f9 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -21,7 +21,8 @@ from game import Game, db from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR from game.utils import meter_to_feet -from gen import Conflict, Package, PackageWaypointTiming +from gen import Conflict, PackageWaypointTiming +from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel diff --git a/qt_ui/windows/mission/QFlightItem.py b/qt_ui/windows/mission/QFlightItem.py index 5e4c4c11..7f006dbb 100644 --- a/qt_ui/windows/mission/QFlightItem.py +++ b/qt_ui/windows/mission/QFlightItem.py @@ -1,26 +1,28 @@ +import datetime + from PySide2.QtGui import QStandardItem, QIcon from game import db +from gen.ato import Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS +# TODO: Replace with QFlightList. class QFlightItem(QStandardItem): - def __init__(self, flight:Flight): + def __init__(self, package: Package, flight: Flight): super(QFlightItem, self).__init__() + self.package = package self.flight = flight if db.unit_type_name(self.flight.unit_type).replace("/", " ") in AIRCRAFT_ICONS.keys(): icon = QIcon((AIRCRAFT_ICONS[db.unit_type_name(self.flight.unit_type)])) self.setIcon(icon) self.setEditable(False) + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) self.setText("["+str(self.flight.flight_type.name[:6])+"] " + str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type) - + " in " + str(self.flight.scheduled_in) + " minutes") - - def update(self, flight): - self.flight = flight - self.setText("[" + str(self.flight.flight_type.name[:6]) + "] " - + str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type) - + " in " + str(self.flight.scheduled_in) + " minutes") \ No newline at end of file + + " in " + str(delay)) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index d9deb613..3c64c160 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -116,11 +116,14 @@ class QPackageDialog(QDialog): self.finished.connect(self.on_close) - def on_close(self, _result) -> None: + @staticmethod + def on_close(_result) -> None: + GameUpdateSignal.get_instance().redraw_flight_paths() + + def save_tot(self) -> 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, _deselected: QItemSelection) -> None: @@ -182,6 +185,7 @@ class QNewPackageDialog(QPackageDialog): Empty packages may be created. They can be modified later, and will have no effect if empty when the mission is generated. """ + self.save_tot() 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) @@ -227,6 +231,7 @@ class QEditPackageDialog(QPackageDialog): def on_done(self) -> None: """Closes the window.""" + self.save_tot() self.close() def on_delete(self) -> None: diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 2c8c7dfe..e514b9d7 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -90,7 +90,11 @@ class QFlightCreator(QDialog): origin = self.airfield_selector.currentData() size = self.flight_size_spinner.value() - flight = Flight(aircraft, size, origin, task) + if self.game.settings.perf_ai_parking_start: + start_type = "Cold" + else: + start_type = "Warm" + flight = Flight(aircraft, size, origin, task, start_type) flight.scheduled_in = self.package.delay # noinspection PyUnresolvedReferences diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py index 25e75e7c..abf429cf 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py @@ -1,6 +1,7 @@ from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox +# TODO: Remove? class QFlightDepartureEditor(QGroupBox): def __init__(self, flight): @@ -15,7 +16,7 @@ class QFlightDepartureEditor(QGroupBox): self.departure_delta = QSpinBox(self) self.departure_delta.setMinimum(0) self.departure_delta.setMaximum(120) - self.departure_delta.setValue(self.flight.scheduled_in) + self.departure_delta.setValue(self.flight.scheduled_in // 60) self.departure_delta.valueChanged.connect(self.change_scheduled) layout.addWidget(self.depart_from) @@ -27,4 +28,4 @@ class QFlightDepartureEditor(QGroupBox): self.changed = self.departure_delta.valueChanged def change_scheduled(self): - self.flight.scheduled_in = int(self.departure_delta.value()) + self.flight.scheduled_in = int(self.departure_delta.value() * 60)