diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py index e1c63dc9..fdd7e7ec 100644 --- a/game/sim/aircraftsimulation.py +++ b/game/sim/aircraftsimulation.py @@ -18,14 +18,16 @@ from game.ato.flightstate import ( from game.ato.starttype import StartType from gen.flights.traveltime import TotEstimator from .combat import CombatInitiator, FrozenCombat +from .gameupdatecallbacks import GameUpdateCallbacks if TYPE_CHECKING: from game import Game class AircraftSimulation: - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None: self.game = game + self.callbacks = callbacks self.combats: list[FrozenCombat] = [] def begin_simulation(self) -> None: @@ -38,7 +40,7 @@ class AircraftSimulation: # Finish updating all flights before checking for combat so that the new # positions are used. - CombatInitiator(self.game, self.combats).update_active_combats() + CombatInitiator(self.game, self.combats, self.callbacks).update_active_combats() # After updating all combat states, check for halts. for flight in self.iter_flights(): diff --git a/game/sim/combat/combatinitiator.py b/game/sim/combat/combatinitiator.py index 78d91aef..aed628ce 100644 --- a/game/sim/combat/combatinitiator.py +++ b/game/sim/combat/combatinitiator.py @@ -12,6 +12,7 @@ from .atip import AtIp from .defendingsam import DefendingSam from .joinablecombat import JoinableCombat from .samengagementzones import SamEngagementZones +from ..gameupdatecallbacks import GameUpdateCallbacks if TYPE_CHECKING: from game import Game @@ -20,9 +21,12 @@ if TYPE_CHECKING: class CombatInitiator: - def __init__(self, game: Game, combats: list[FrozenCombat]) -> None: + def __init__( + self, game: Game, combats: list[FrozenCombat], callbacks: GameUpdateCallbacks + ) -> None: self.game = game self.combats = combats + self.callbacks = callbacks def update_active_combats(self) -> None: blue_a2a = AircraftEngagementZones.from_ato(self.game.blue.ato) @@ -60,6 +64,7 @@ class CombatInitiator: logging.info(f"{flight} is joining existing combat {joined}") joined.join(flight) own_a2a.remove_flight(flight) + self.callbacks.on_combat_changed(joined) elif (combat := self.check_flight_for_new_combat(flight, a2a, sam)) is not None: logging.info(f"Interrupting simulation because {combat.because()}") combat.update_flight_states() @@ -70,6 +75,7 @@ class CombatInitiator: a2a.update_for_combat(combat) own_a2a.update_for_combat(combat) self.combats.append(combat) + self.callbacks.on_add_combat(combat) def check_flight_for_joined_combat( self, flight: Flight diff --git a/game/sim/gameloop.py b/game/sim/gameloop.py index c746cdf8..e97db6f1 100644 --- a/game/sim/gameloop.py +++ b/game/sim/gameloop.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging from datetime import datetime, timedelta from pathlib import Path -from typing import Callable, TYPE_CHECKING +from typing import TYPE_CHECKING from .gamelooptimer import GameLoopTimer +from .gameupdatecallbacks import GameUpdateCallbacks from .missionsimulation import MissionSimulation, SimulationAlreadyCompletedError from .simspeedsetting import SimSpeedSetting @@ -15,11 +16,11 @@ if TYPE_CHECKING: class GameLoop: - def __init__(self, game: Game, on_complete: Callable[[], None]) -> None: + def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None: self.game = game - self.on_complete = on_complete + self.callbacks = callbacks self.timer = GameLoopTimer(self.tick) - self.sim = MissionSimulation(self.game) + self.sim = MissionSimulation(self.game, self.callbacks) self.started = False self.completed = False @@ -75,6 +76,6 @@ class GameLoop: if self.completed: self.pause() logging.info(f"Simulation completed at {self.sim.time}") - self.on_complete() + self.callbacks.on_simulation_complete() except SimulationAlreadyCompletedError: logging.exception("Attempted to tick already completed sim") diff --git a/game/sim/gameupdatecallbacks.py b/game/sim/gameupdatecallbacks.py new file mode 100644 index 00000000..0208b50d --- /dev/null +++ b/game/sim/gameupdatecallbacks.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from game.sim.combat import FrozenCombat + + +# Ought to be frozen but mypy can't handle that: +# https://github.com/python/mypy/issues/5485 +@dataclass +class GameUpdateCallbacks: + on_simulation_complete: Callable[[], None] + on_add_combat: Callable[[FrozenCombat], None] + on_combat_changed: Callable[[FrozenCombat], None] diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index 2a1a536d..49676d79 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -9,6 +9,7 @@ from game.debriefing import Debriefing from game.missiongenerator import MissionGenerator from game.unitmap import UnitMap from .aircraftsimulation import AircraftSimulation +from .gameupdatecallbacks import GameUpdateCallbacks from .missionresultsprocessor import MissionResultsProcessor if TYPE_CHECKING: @@ -24,10 +25,10 @@ class SimulationAlreadyCompletedError(RuntimeError): class MissionSimulation: - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None: self.game = game self.unit_map: Optional[UnitMap] = None - self.aircraft_simulation = AircraftSimulation(self.game) + self.aircraft_simulation = AircraftSimulation(self.game, callbacks) self.completed = False self.time = self.game.conditions.start_time diff --git a/qt_ui/simcontroller.py b/qt_ui/simcontroller.py index 9df07aa1..4c8b0afd 100644 --- a/qt_ui/simcontroller.py +++ b/qt_ui/simcontroller.py @@ -8,7 +8,9 @@ from typing import Callable, Optional, TYPE_CHECKING from PySide2.QtCore import QObject, Signal from game.polldebriefingfilethread import PollDebriefingFileThread +from game.sim.combat import FrozenCombat from game.sim.gameloop import GameLoop +from game.sim.gameupdatecallbacks import GameUpdateCallbacks from game.sim.simspeedsetting import SimSpeedSetting from qt_ui.simupdatethread import SimUpdateThread @@ -21,6 +23,8 @@ class SimController(QObject): sim_update = Signal() sim_speed_reset = Signal(SimSpeedSetting) simulation_complete = Signal() + on_add_combat = Signal(FrozenCombat) + on_combat_changed = Signal(FrozenCombat) def __init__(self, game: Optional[Game]) -> None: super().__init__() @@ -59,7 +63,14 @@ class SimController(QObject): self.game_loop.pause() self.game_loop = None if game is not None: - self.game_loop = GameLoop(game, self.on_simulation_complete) + self.game_loop = GameLoop( + game, + GameUpdateCallbacks( + self.on_simulation_complete, + self.on_add_combat.emit, + self.on_combat_changed.emit, + ), + ) self.started = False def set_simulation_speed(self, simulation_speed: SimSpeedSetting) -> None: diff --git a/qt_ui/widgets/map/model/aircombatjs.py b/qt_ui/widgets/map/model/aircombatjs.py new file mode 100644 index 00000000..38c48186 --- /dev/null +++ b/qt_ui/widgets/map/model/aircombatjs.py @@ -0,0 +1,27 @@ +from PySide2.QtCore import Property, QObject, Signal + +from game.sim.combat.aircombat import AirCombat +from game.theater import ConflictTheater +from .leaflet import LeafletPoly +from .shapelyutil import ShapelyUtil + + +class AirCombatJs(QObject): + footprintChanged = Signal() + + def __init__(self, combat: AirCombat, theater: ConflictTheater) -> None: + super().__init__() + self.combat = combat + self.theater = theater + self._footprint = self._make_footprint() + + @Property(type=list, notify=footprintChanged) + def footprint(self) -> list[LeafletPoly]: + return self._footprint + + def refresh(self) -> None: + self._footprint = self._make_footprint() + self.footprintChanged.emit() + + def _make_footprint(self) -> list[LeafletPoly]: + return ShapelyUtil.polys_to_leaflet(self.combat.footprint, self.theater) diff --git a/qt_ui/widgets/map/model/ipcombatjs.py b/qt_ui/widgets/map/model/ipcombatjs.py new file mode 100644 index 00000000..70a28dbe --- /dev/null +++ b/qt_ui/widgets/map/model/ipcombatjs.py @@ -0,0 +1,28 @@ +from PySide2.QtCore import Property, QObject, Signal + +from game.sim.combat.atip import AtIp +from qt_ui.models import GameModel +from .flightjs import FlightJs + + +class IpCombatJs(QObject): + flightChanged = Signal() + + def __init__(self, combat: AtIp, game_model: GameModel) -> None: + super().__init__() + assert game_model.game is not None + self.combat = combat + self.theater = game_model.game.theater + self._flight = FlightJs( + combat.flight, + selected=False, + theater=game_model.game.theater, + ato_model=game_model.ato_model_for(combat.flight.squadron.player), + ) + + @Property(FlightJs, notify=flightChanged) + def flight(self) -> FlightJs: + return self._flight + + def refresh(self) -> None: + pass diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py index 856d04fd..7d144287 100644 --- a/qt_ui/widgets/map/model/mapmodel.py +++ b/qt_ui/widgets/map/model/mapmodel.py @@ -9,22 +9,29 @@ from dcs import Point from game import Game from game.ato.airtaaskingorder import AirTaskingOrder from game.profiling import logged_duration +from game.sim.combat import FrozenCombat +from game.sim.combat.aircombat import AirCombat +from game.sim.combat.atip import AtIp +from game.sim.combat.defendingsam import DefendingSam from game.theater import ( ConflictTheater, ) from qt_ui.models import GameModel from qt_ui.simcontroller import SimController from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from .aircombatjs import AirCombatJs from .controlpointjs import ControlPointJs from .flightjs import FlightJs from .frontlinejs import FrontLineJs from .groundobjectjs import GroundObjectJs from .holdzonesjs import HoldZonesJs +from .ipcombatjs import IpCombatJs from .ipzonesjs import IpZonesJs from .joinzonesjs import JoinZonesJs from .leaflet import LeafletLatLon from .mapzonesjs import MapZonesJs from .navmeshjs import NavMeshJs +from .samcombatjs import SamCombatJs from .supplyroutejs import SupplyRouteJs from .threatzonecontainerjs import ThreatZoneContainerJs from .threatzonesjs import ThreatZonesJs @@ -63,6 +70,9 @@ class MapModel(QObject): ipZonesChanged = Signal() joinZonesChanged = Signal() holdZonesChanged = Signal() + airCombatsChanged = Signal() + samCombatsChanged = Signal() + ipCombatsChanged = Signal() def __init__(self, game_model: GameModel, sim_controller: SimController) -> None: super().__init__() @@ -83,6 +93,10 @@ class MapModel(QObject): self._join_zones = JoinZonesJs.empty() self._hold_zones = HoldZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None + self._air_combats = [] + self._sam_combats = [] + self._ip_combats = [] + GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) GameUpdateSignal.get_instance().package_selection_changed.connect( @@ -92,6 +106,8 @@ class MapModel(QObject): self.set_flight_selection ) sim_controller.sim_update.connect(self.on_sim_update) + sim_controller.on_add_combat.connect(self.on_add_combat) + sim_controller.on_combat_changed.connect(self.on_combat_changed) self.reset() def clear(self) -> None: @@ -107,6 +123,9 @@ class MapModel(QObject): self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] self._ip_zones = IpZonesJs.empty() + self._air_combats = [] + self._sam_combats = [] + self._ip_combats = [] self.cleared.emit() def on_sim_update(self) -> None: @@ -180,6 +199,7 @@ class MapModel(QObject): self.reset_navmeshes() self.reset_map_zones() self.reset_unculled_zones() + self.reset_combats() def on_game_load(self, game: Optional[Game]) -> None: if game is not None: @@ -367,6 +387,79 @@ class MapModel(QObject): def holdZones(self) -> HoldZonesJs: return self._hold_zones + def reset_combats(self) -> None: + self._air_combats = [] + self._sam_combats = [] + self._ip_combats = [] + self.airCombatsChanged.emit() + self.samCombatsChanged.emit() + self.ipCombatsChanged.emit() + + def on_add_combat(self, combat: FrozenCombat) -> None: + if isinstance(combat, AirCombat): + self.add_air_combat(combat) + elif isinstance(combat, DefendingSam): + self.add_sam_combat(combat) + elif isinstance(combat, AtIp): + self.add_ip_combat(combat) + else: + logging.error(f"Unhandled FrozenCombat type: {combat.__class__}") + + def add_air_combat(self, combat: AirCombat) -> None: + self._air_combats.append(AirCombatJs(combat, self.game.theater)) + self.airCombatsChanged.emit() + + def add_sam_combat(self, combat: DefendingSam) -> None: + self._sam_combats.append(SamCombatJs(combat, self.game_model)) + self.samCombatsChanged.emit() + + def add_ip_combat(self, combat: AtIp) -> None: + self._ip_combats.append(IpCombatJs(combat, self.game_model)) + self.ipCombatsChanged.emit() + + def on_combat_changed(self, combat: FrozenCombat) -> None: + if isinstance(combat, AirCombat): + self.refresh_air_combat(combat) + elif isinstance(combat, DefendingSam): + self.refresh_sam_combat(combat) + elif isinstance(combat, AtIp): + self.refresh_ip_combat(combat) + else: + logging.error(f"Unhandled FrozenCombat type: {combat.__class__}") + + def refresh_air_combat(self, combat: AirCombat) -> None: + for js in self._air_combats: + if js.combat == combat: + js.refresh() + return + logging.error(f"Could not find existing combat model to update for {combat}") + + def refresh_sam_combat(self, combat: DefendingSam) -> None: + for js in self._sam_combats: + if js.combat == combat: + js.refresh() + return + logging.error(f"Could not find existing combat model to update for {combat}") + + def refresh_ip_combat(self, combat: AtIp) -> None: + for js in self._ip_combats: + if js.combat == combat: + js.refresh() + return + logging.error(f"Could not find existing combat model to update for {combat}") + + @Property(list, notify=airCombatsChanged) + def airCombats(self) -> list[AirCombatJs]: + return self._air_combats + + @Property(list, notify=samCombatsChanged) + def samCombats(self) -> list[SamCombatJs]: + return self._sam_combats + + @Property(list, notify=ipCombatsChanged) + def ipCombats(self) -> list[IpCombatJs]: + return self._ip_combats + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/qt_ui/widgets/map/model/samcombatjs.py b/qt_ui/widgets/map/model/samcombatjs.py new file mode 100644 index 00000000..027f45e5 --- /dev/null +++ b/qt_ui/widgets/map/model/samcombatjs.py @@ -0,0 +1,37 @@ +from PySide2.QtCore import Property, QObject, Signal + +from game.sim.combat.defendingsam import DefendingSam +from qt_ui.models import GameModel +from .flightjs import FlightJs +from .groundobjectjs import GroundObjectJs + + +class SamCombatJs(QObject): + flightChanged = Signal() + airDefensesChanged = Signal() + + def __init__(self, combat: DefendingSam, game_model: GameModel) -> None: + super().__init__() + assert game_model.game is not None + self.combat = combat + self.theater = game_model.game.theater + self._flight = FlightJs( + combat.flight, + selected=False, + theater=game_model.game.theater, + ato_model=game_model.ato_model_for(combat.flight.squadron.player), + ) + self._air_defenses = [ + GroundObjectJs(tgo, game_model.game) for tgo in self.combat.air_defenses + ] + + @Property(FlightJs, notify=flightChanged) + def flight(self) -> FlightJs: + return self._flight + + @Property(list, notify=airDefensesChanged) + def airDefenses(self) -> list[GroundObjectJs]: + return self._air_defenses + + def refresh(self) -> None: + pass diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 52445e74..4498c631 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -206,6 +206,7 @@ const supplyRoutesLayer = L.layerGroup().addTo(map); const frontLinesLayer = L.layerGroup().addTo(map); const redSamThreatLayer = L.layerGroup().addTo(map); const blueFlightPlansLayer = L.layerGroup().addTo(map); +const combatLayer = L.layerGroup().addTo(map); // Added to map by the user via layer controls. const blueSamThreatLayer = L.layerGroup(); @@ -287,6 +288,7 @@ L.control "Units and locations": { "Control points": controlPointsLayer, Aircraft: aircraftLayer, + "Active combat": combatLayer, "Air defenses": airDefensesLayer, Factories: factoriesLayer, Ships: shipsLayer, @@ -351,6 +353,9 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.ipZonesChanged.connect(drawIpZones); game.joinZonesChanged.connect(drawJoinZones); game.holdZonesChanged.connect(drawHoldZones); + game.airCombatsChanged.connect(drawCombat); + game.samCombatsChanged.connect(drawCombat); + game.ipCombatsChanged.connect(drawCombat); }); function recenterMap(center) { @@ -1226,6 +1231,34 @@ function drawHoldZones() { } } +function drawCombat() { + combatLayer.clearLayers(); + + for (const airCombat of game.airCombats) { + L.polygon(airCombat.footprint, { + color: Colors.Red, + interactive: false, + fillOpacity: 0.2, + }).addTo(combatLayer); + } + + for (const samCombat of game.samCombats) { + for (const airDefense of samCombat.airDefenses) { + L.polyline([samCombat.flight.position, airDefense.position], { + color: Colors.Red, + interactive: false, + }).addTo(combatLayer); + } + } + + for (const ipCombat of game.ipCombats) { + L.polyline([ipCombat.flight.position, ipCombat.flight.target], { + color: Colors.Red, + interactive: false, + }).addTo(combatLayer); + } +} + function drawInitialMap() { recenterMap(game.mapCenter); drawControlPoints(); @@ -1240,6 +1273,7 @@ function drawInitialMap() { drawIpZones(); drawJoinZones(); drawHoldZones(); + drawCombat(); } function clearAllLayers() {