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