diff --git a/game/sim/gameloop.py b/game/sim/gameloop.py index a2cb3ff5..21a733de 100644 --- a/game/sim/gameloop.py +++ b/game/sim/gameloop.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime from pathlib import Path from typing import Callable, TYPE_CHECKING @@ -22,6 +23,10 @@ class GameLoop: self.started = False self.completed = False + @property + def current_time_in_sim(self) -> datetime: + return self.sim.time + def start(self) -> None: if self.started: raise RuntimeError("Cannot start game loop because it has already started") @@ -67,7 +72,5 @@ class GameLoop: self.pause() logging.info(f"Simulation completed at {self.sim.time}") self.on_complete() - else: - logging.info(f"Simulation continued at {self.sim.time}") except SimulationAlreadyCompletedError: logging.exception("Attempted to tick already completed sim") diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index 86b4e95f..2a1a536d 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -32,6 +32,7 @@ class MissionSimulation: self.time = self.game.conditions.start_time def begin_simulation(self) -> None: + self.time = self.game.conditions.start_time self.aircraft_simulation.begin_simulation() def tick(self) -> bool: diff --git a/qt_ui/simcontroller.py b/qt_ui/simcontroller.py index 6105e4c0..33af32cb 100644 --- a/qt_ui/simcontroller.py +++ b/qt_ui/simcontroller.py @@ -1,14 +1,16 @@ from __future__ import annotations import logging +from datetime import datetime from pathlib import Path from typing import Callable, Optional, TYPE_CHECKING from PySide2.QtCore import QObject, Signal +from game.polldebriefingfilethread import PollDebriefingFileThread from game.sim.gameloop import GameLoop from game.sim.simspeedsetting import SimSpeedSetting -from game.polldebriefingfilethread import PollDebriefingFileThread +from qt_ui.simupdatethread import SimUpdateThread if TYPE_CHECKING: from game import Game @@ -16,6 +18,7 @@ if TYPE_CHECKING: class SimController(QObject): + sim_update = Signal() sim_speed_reset = Signal(SimSpeedSetting) simulation_complete = Signal() @@ -24,18 +27,28 @@ class SimController(QObject): self.game_loop: Optional[GameLoop] = None self.recreate_game_loop(game) self.started = False + self._sim_update_thread = SimUpdateThread(self.sim_update.emit) + self._sim_update_thread.start() @property def completed(self) -> bool: return self.game_loop.completed + @property + def current_time_in_sim(self) -> Optional[datetime]: + if self.game_loop is None: + return None + return self.game_loop.current_time_in_sim + def set_game(self, game: Optional[Game]) -> None: self.recreate_game_loop(game) self.sim_speed_reset.emit(SimSpeedSetting.PAUSED) def recreate_game_loop(self, game: Optional[Game]) -> None: if self.game_loop is not None: + self._sim_update_thread.on_sim_pause() self.game_loop.pause() + self.game_loop = None if game is not None: self.game_loop = GameLoop(game, self.on_simulation_complete) self.started = False @@ -48,11 +61,17 @@ class SimController(QObject): self.game_loop.start() self.started = True self.game_loop.set_simulation_speed(simulation_speed) + if simulation_speed is SimSpeedSetting.PAUSED: + self._sim_update_thread.on_sim_pause() + else: + self._sim_update_thread.on_sim_unpause() def run_to_first_contact(self) -> None: self.game_loop.run_to_first_contact() + self.sim_update.emit() def generate_miz(self, output: Path) -> None: + self._sim_update_thread.on_sim_pause() self.game_loop.pause_and_generate_miz(output) def wait_for_debriefing( @@ -65,11 +84,14 @@ class SimController(QObject): def debrief_current_state( self, state_path: Path, force_end: bool = False ) -> Debriefing: + self._sim_update_thread.on_sim_pause() return self.game_loop.pause_and_debrief(state_path, force_end) def process_results(self, debriefing: Debriefing) -> None: + self._sim_update_thread.on_sim_pause() return self.game_loop.complete_with_results(debriefing) def on_simulation_complete(self) -> None: logging.debug("Simulation complete") + self._sim_update_thread.on_sim_pause() self.simulation_complete.emit() diff --git a/qt_ui/simupdatethread.py b/qt_ui/simupdatethread.py new file mode 100644 index 00000000..8cae1306 --- /dev/null +++ b/qt_ui/simupdatethread.py @@ -0,0 +1,45 @@ +from threading import Event, Thread, Timer +from typing import Callable + + +class SimUpdateThread(Thread): + def __init__(self, update_callback: Callable[[], None]) -> None: + super().__init__() + self.update_callback = update_callback + self.running = False + self.should_shutdown = False + self._interrupt = Event() + self._timer = self._make_timer() + + def run(self) -> None: + while True: + self._interrupt.wait() + self._interrupt.clear() + if self.should_shutdown: + return + if self.running: + self.update_callback() + self._timer = self._make_timer() + self._timer.start() + + def on_sim_pause(self) -> None: + self._timer.cancel() + self._timer = self._make_timer() + self.running = False + + def on_sim_unpause(self) -> None: + if not self.running: + self.running = True + self._timer.start() + + def stop(self) -> None: + self.should_shutdown = True + self._interrupt.set() + + def on_timer_elapsed(self) -> None: + self._timer = self._make_timer() + self._timer.start() + self._interrupt.set() + + def _make_timer(self) -> Timer: + return Timer(1 / 60, lambda: self._interrupt.set()) diff --git a/qt_ui/widgets/QConditionsWidget.py b/qt_ui/widgets/QConditionsWidget.py index b0e9f630..355c076f 100644 --- a/qt_ui/widgets/QConditionsWidget.py +++ b/qt_ui/widgets/QConditionsWidget.py @@ -1,3 +1,5 @@ +from datetime import datetime + from PySide2.QtGui import QPixmap from PySide2.QtWidgets import ( QFrame, @@ -12,6 +14,7 @@ from dcs.weather import CloudPreset, Weather as PydcsWeather import qt_ui.uiconstants as CONST from game.utils import mps from game.weather import Conditions, TimeOfDay +from qt_ui.simcontroller import SimController class QTimeTurnWidget(QGroupBox): @@ -19,8 +22,9 @@ class QTimeTurnWidget(QGroupBox): UI Component to display current turn and time info """ - def __init__(self): + def __init__(self, sim_controller: SimController) -> None: super(QTimeTurnWidget, self).__init__("Turn") + self.sim_controller = sim_controller self.setStyleSheet( "padding: 0px; margin-left: 5px; margin-right: 0px; margin-top: 1ex; margin-bottom: 5px; border-right: 0px" ) @@ -49,17 +53,30 @@ class QTimeTurnWidget(QGroupBox): self.time_display = QLabel() self.time_column.addWidget(self.time_display) - def setCurrentTurn(self, turn: int, conditions: Conditions) -> None: + sim_controller.sim_update.connect(self.on_sim_update) + + def on_sim_update(self) -> None: + time = self.sim_controller.current_time_in_sim + if time is None: + self.date_display.setText("") + self.time_display.setText("") + else: + self.set_date_and_time(time) + + def set_current_turn(self, turn: int, conditions: Conditions) -> None: """Sets the turn information display. :arg turn Current turn number. :arg conditions Current time and weather conditions. """ self.daytime_icon.setPixmap(self.icons[conditions.time_of_day]) - self.date_display.setText(conditions.start_time.strftime("%d %b %Y")) - self.time_display.setText(conditions.start_time.strftime("%H:%M:%S Local")) + self.set_date_and_time(conditions.start_time) self.setTitle(f"Turn {turn}") + def set_date_and_time(self, time: datetime) -> None: + self.date_display.setText(time.strftime("%d %b %Y")) + self.time_display.setText(time.strftime("%H:%M:%S Local")) + class QWeatherWidget(QGroupBox): """ @@ -265,7 +282,7 @@ class QConditionsWidget(QFrame): UI Component to display Turn Number, Day Time & Hour and weather combined. """ - def __init__(self): + def __init__(self, sim_controller: SimController) -> None: super(QConditionsWidget, self).__init__() self.setProperty("style", "QConditionsWidget") @@ -275,7 +292,7 @@ class QConditionsWidget(QFrame): self.layout.setVerticalSpacing(0) self.setLayout(self.layout) - self.time_turn_widget = QTimeTurnWidget() + self.time_turn_widget = QTimeTurnWidget(sim_controller) self.time_turn_widget.setStyleSheet("QGroupBox { margin-right: 0px; }") self.layout.addWidget(self.time_turn_widget, 0, 0) @@ -292,6 +309,6 @@ class QConditionsWidget(QFrame): :arg turn Current turn number. :arg conditions Current time and weather conditions. """ - self.time_turn_widget.setCurrentTurn(turn, conditions) + self.time_turn_widget.set_current_turn(turn, conditions) self.weather_widget.setCurrentTurn(turn, conditions) self.weather_widget.show() diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index eda3b093..f57b53ca 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -38,7 +38,7 @@ class QTopPanel(QFrame): self.setMaximumHeight(70) - self.conditionsWidget = QConditionsWidget() + self.conditionsWidget = QConditionsWidget(sim_controller) self.budgetBox = QBudgetBox(self.game) pass_turn_text = "Pass Turn" diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 5d7f2d7d..8576ef6d 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -15,6 +15,7 @@ from PySide2.QtWebEngineWidgets import ( from game import Game from qt_ui.models import GameModel +from qt_ui.simcontroller import SimController from qt_ui.widgets.map.mapmodel import MapModel @@ -35,11 +36,13 @@ class LoggingWebPage(QWebEnginePage): class QLiberationMap(QWebEngineView): - def __init__(self, game_model: GameModel, parent) -> None: + def __init__( + self, game_model: GameModel, sim_controller: SimController, parent + ) -> None: super().__init__(parent) self.game_model = game_model self.setMinimumSize(800, 600) - self.map_model = MapModel(game_model) + self.map_model = MapModel(game_model, sim_controller) self.channel = QWebChannel() self.channel.registerObject("game", self.map_model) diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 3d725e34..2615cb38 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from datetime import timedelta -from typing import List, Optional, Tuple, Union, Iterator +from typing import Iterator, List, Optional, Tuple, Union from PySide2.QtCore import Property, QObject, Signal, Slot from dcs import Point @@ -10,40 +10,38 @@ from dcs.unit import Unit from dcs.vehicles import vehicle_map from shapely.geometry import ( LineString, + MultiLineString, + MultiPolygon, Point as ShapelyPoint, Polygon, - MultiPolygon, - MultiLineString, ) from game import Game +from game.ato.airtaaskingorder import AirTaskingOrder +from game.ato.flight import Flight +from game.ato.flightstate import InFlight +from game.ato.flightwaypoint import FlightWaypoint +from game.ato.flightwaypointtype import FlightWaypointType from game.dcs.groundunittype import GroundUnitType -from game.flightplan import JoinZoneGeometry, HoldZoneGeometry +from game.flightplan import HoldZoneGeometry, JoinZoneGeometry +from game.flightplan.ipzonegeometry import IpZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( ConflictTheater, ControlPoint, - TheaterGroundObject, + ControlPointStatus, FrontLine, LatLon, - ControlPointStatus, + TheaterGroundObject, ) from game.threatzones import ThreatZones from game.transfers import MultiGroupTransport, TransportMap from game.utils import meters, nautical_miles -from game.ato.airtaaskingorder import AirTaskingOrder -from game.ato.flightwaypointtype import FlightWaypointType -from game.ato.flightwaypoint import FlightWaypoint -from game.ato.flight import Flight -from gen.flights.flightplan import ( - FlightPlan, - PatrollingFlightPlan, - CasFlightPlan, -) -from game.flightplan.ipzonegeometry import IpZoneGeometry +from gen.flights.flightplan import CasFlightPlan, FlightPlan, PatrollingFlightPlan from qt_ui.dialogs import Dialog -from qt_ui.models import GameModel, AtoModel +from qt_ui.models import AtoModel, GameModel +from qt_ui.simcontroller import SimController from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu @@ -537,6 +535,7 @@ class WaypointJs(QObject): class FlightJs(QObject): + positionChanged = Signal() flightPlanChanged = Signal() blueChanged = Signal() selectedChanged = Signal() @@ -588,6 +587,13 @@ class FlightJs(QObject): waypoints.append(waypoint) return waypoints + @Property(list, notify=positionChanged) + def position(self) -> LeafletLatLon: + if isinstance(self.flight.state, InFlight): + ll = self.theater.point_to_ll(self.flight.state.estimate_position()) + return [ll.latitude, ll.longitude] + return [] + @Property(list, notify=flightPlanChanged) def flightPlan(self) -> List[WaypointJs]: return self._waypoints @@ -1032,7 +1038,7 @@ class MapModel(QObject): joinZonesChanged = Signal() holdZonesChanged = Signal() - def __init__(self, game_model: GameModel) -> None: + def __init__(self, game_model: GameModel, sim_controller: SimController) -> None: super().__init__() self.game_model = game_model self._map_center = [0, 0] @@ -1059,6 +1065,7 @@ class MapModel(QObject): GameUpdateSignal.get_instance().flight_selection_changed.connect( self.set_flight_selection ) + sim_controller.sim_update.connect(self.on_sim_update) self.reset() def clear(self) -> None: @@ -1076,6 +1083,10 @@ class MapModel(QObject): self._ip_zones = IpZonesJs.empty() self.cleared.emit() + def on_sim_update(self) -> None: + for flight in self._flights: + flight.positionChanged.emit() + def set_package_selection(self, index: int) -> None: # Optional[int] isn't a valid type for a Qt signal. None will be converted to # zero automatically. We use -1 to indicate no selection. diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 957d3f33..bc9ae7b8 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -54,7 +54,7 @@ class QLiberationWindow(QMainWindow): self.sim_controller = SimController(self.game) self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.info_panel = QInfoPanel(self.game) - self.liberation_map = QLiberationMap(self.game_model, self) + self.liberation_map = QLiberationMap(self.game_model, self.sim_controller, self) self.setGeometry(300, 100, 270, 100) self.updateWindowTitle() diff --git a/resources/ui/air_assets/unspecified_blue.svg b/resources/ui/air_assets/unspecified_blue.svg new file mode 100644 index 00000000..f735dd46 --- /dev/null +++ b/resources/ui/air_assets/unspecified_blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/air_assets/unspecified_red.svg b/resources/ui/air_assets/unspecified_red.svg new file mode 100644 index 00000000..4a7befcb --- /dev/null +++ b/resources/ui/air_assets/unspecified_red.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 4e4ff59e..7197bd5e 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -104,9 +104,31 @@ class TgoIcons { } } +class AirIcons { + constructor() { + this.icons = {}; + for (const player of [true, false]) { + this.icons[player] = this.loadIcon("unspecified", player); + } + } + + icon(_category, player, _state) { + return this.icons[player]; + } + + loadIcon(category, player) { + const color = player ? "blue" : "red"; + return new L.Icon({ + iconUrl: `../air_assets/${category}_${color}.svg`, + iconSize: [24, 24], + }); + } +} + const Icons = Object.freeze({ ControlPoints: new CpIcons(), Objectives: new TgoIcons(), + AirIcons: new AirIcons(), }); function metersToNauticalMiles(meters) { @@ -163,6 +185,7 @@ defaultBaseMap.addTo(map); // Enabled by default, so addTo(map). const controlPointsLayer = L.layerGroup().addTo(map); +const aircraftLayer = L.layerGroup().addTo(map); const airDefensesLayer = L.layerGroup().addTo(map); const factoriesLayer = L.layerGroup().addTo(map); const shipsLayer = L.layerGroup().addTo(map); @@ -249,8 +272,9 @@ L.control .groupedLayers( baseLayers, { - "Points of Interest": { + "Units and locations": { "Control points": controlPointsLayer, + Aircraft: aircraftLayer, "Air defenses": airDefensesLayer, Factories: factoriesLayer, Ships: shipsLayer, @@ -307,7 +331,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.groundObjectsChanged.connect(drawGroundObjects); game.supplyRoutesChanged.connect(drawSupplyRoutes); game.frontLinesChanged.connect(drawFrontLines); - game.flightsChanged.connect(drawFlightPlans); + game.flightsChanged.connect(drawAircraft); game.threatZonesChanged.connect(drawThreatZones); game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); @@ -770,9 +794,11 @@ class Flight { constructor(flight) { this.flight = flight; this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this)); + this.aircraft = null; this.path = null; this.commitBoundary = null; - this.flight.flightPlanChanged.connect(() => this.draw()); + this.flight.positionChanged.connect(() => this.drawAircraftLocation()); + this.flight.flightPlanChanged.connect(() => this.drawFlightPlan()); this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary()); } @@ -808,6 +834,25 @@ class Flight { } } + draw() { + this.drawAircraftLocation(); + this.drawFlightPlan(); + this.drawCommitBoundary(); + } + + drawAircraftLocation() { + if (this.aircraft != null) { + this.aircraft.removeFrom(aircraftLayer); + this.aircraft = null; + } + const position = this.flight.position; + if (position.length > 0) { + this.aircraft = L.marker(position, { + icon: Icons.AirIcons.icon("fighter", this.flight.blue), + }).addTo(aircraftLayer); + } + } + drawCommitBoundary() { if (this.commitBoundary != null) { this.commitBoundary @@ -829,7 +874,7 @@ class Flight { } } - draw() { + drawFlightPlan() { const path = []; this.flightPlan.forEach((waypoint) => { if (waypoint.includeInPath()) { @@ -844,11 +889,11 @@ class Flight { }); this.drawPath(path); - this.drawCommitBoundary(); } } -function drawFlightPlans() { +function drawAircraft() { + aircraftLayer.clearLayers(); blueFlightPlansLayer.clearLayers(); redFlightPlansLayer.clearLayers(); selectedFlightPlansLayer.clearLayers(); @@ -1136,7 +1181,7 @@ function drawInitialMap() { drawGroundObjects(); drawSupplyRoutes(); drawFrontLines(); - drawFlightPlans(); + drawAircraft(); drawThreatZones(); drawNavmeshes(); drawMapZones();