diff --git a/game/ato/flight.py b/game/ato/flight.py index 8c352691..f860bce7 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -4,6 +4,7 @@ import uuid from datetime import datetime, timedelta from typing import Any, List, Optional, TYPE_CHECKING +from dcs import Point from dcs.planes import C_101CC, C_101EB, Su_33 from gen.flights.loadouts import Loadout @@ -125,6 +126,9 @@ class Flight: def points(self) -> List[FlightWaypoint]: return self.flight_plan.waypoints[1:] + def position(self) -> Point: + return self.state.estimate_position() + def resize(self, new_size: int) -> None: self.squadron.claim_inventory(new_size - self.count) self.roster.resize(new_size) diff --git a/game/ato/flightstate/atdeparture.py b/game/ato/flightstate/atdeparture.py new file mode 100644 index 00000000..3a724e42 --- /dev/null +++ b/game/ato/flightstate/atdeparture.py @@ -0,0 +1,10 @@ +from abc import ABC + +from dcs import Point + +from game.ato.flightstate import FlightState + + +class AtDeparture(FlightState, ABC): + def estimate_position(self) -> Point: + return self.flight.departure.position diff --git a/game/ato/flightstate/completed.py b/game/ato/flightstate/completed.py index 8be426ca..fffb5f67 100644 --- a/game/ato/flightstate/completed.py +++ b/game/ato/flightstate/completed.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING +from dcs import Point + from .flightstate import FlightState from ..starttype import StartType @@ -20,6 +22,9 @@ class Completed(FlightState): def is_waiting_for_start(self) -> bool: return False + def estimate_position(self) -> Point: + return self.flight.arrival.position + @property def spawn_type(self) -> StartType: # TODO: May want to do something different to make these uncontrolled? diff --git a/game/ato/flightstate/flightstate.py b/game/ato/flightstate/flightstate.py index 0a6040f2..200ce688 100644 --- a/game/ato/flightstate/flightstate.py +++ b/game/ato/flightstate/flightstate.py @@ -4,6 +4,8 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta from typing import Optional, TYPE_CHECKING +from dcs import Point + from game.ato.starttype import StartType if TYPE_CHECKING: @@ -44,6 +46,10 @@ class FlightState(ABC): def is_waiting_for_start(self) -> bool: ... + @abstractmethod + def estimate_position(self) -> Point: + ... + @property @abstractmethod def spawn_type(self) -> StartType: diff --git a/game/ato/flightstate/startup.py b/game/ato/flightstate/startup.py index 53a602ed..f9f7c25d 100644 --- a/game/ato/flightstate/startup.py +++ b/game/ato/flightstate/startup.py @@ -4,7 +4,7 @@ import logging from datetime import datetime, timedelta from typing import TYPE_CHECKING -from .flightstate import FlightState +from .atdeparture import AtDeparture from .taxi import Taxi from ..starttype import StartType @@ -14,7 +14,7 @@ if TYPE_CHECKING: from game.sim.gameupdateevents import GameUpdateEvents -class StartUp(FlightState): +class StartUp(AtDeparture): def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None: super().__init__(flight, settings) self.completion_time = now + flight.flight_plan.estimate_startup() diff --git a/game/ato/flightstate/takeoff.py b/game/ato/flightstate/takeoff.py index 336c9874..119f0d99 100644 --- a/game/ato/flightstate/takeoff.py +++ b/game/ato/flightstate/takeoff.py @@ -4,7 +4,7 @@ import logging from datetime import datetime, timedelta from typing import TYPE_CHECKING -from .flightstate import FlightState +from .atdeparture import AtDeparture from .navigating import Navigating from ..starttype import StartType from ...utils import LBS_TO_KG @@ -15,7 +15,7 @@ if TYPE_CHECKING: from game.sim.gameupdateevents import GameUpdateEvents -class Takeoff(FlightState): +class Takeoff(AtDeparture): def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None: super().__init__(flight, settings) # TODO: Not accounted for in FlightPlan, can cause discrepancy without loiter. diff --git a/game/ato/flightstate/taxi.py b/game/ato/flightstate/taxi.py index 2b88135c..96f7a430 100644 --- a/game/ato/flightstate/taxi.py +++ b/game/ato/flightstate/taxi.py @@ -4,7 +4,7 @@ import logging from datetime import datetime, timedelta from typing import TYPE_CHECKING -from .flightstate import FlightState +from .atdeparture import AtDeparture from .takeoff import Takeoff from ..starttype import StartType @@ -14,7 +14,7 @@ if TYPE_CHECKING: from game.sim.gameupdateevents import GameUpdateEvents -class Taxi(FlightState): +class Taxi(AtDeparture): def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None: super().__init__(flight, settings) self.completion_time = now + flight.flight_plan.estimate_ground_ops() diff --git a/game/ato/flightstate/uninitialized.py b/game/ato/flightstate/uninitialized.py index 0493c30d..ef89e021 100644 --- a/game/ato/flightstate/uninitialized.py +++ b/game/ato/flightstate/uninitialized.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING +from dcs import Point + from gen.flights.traveltime import TotEstimator from .flightstate import FlightState from ..starttype import StartType @@ -21,6 +23,9 @@ class Uninitialized(FlightState): def is_waiting_for_start(self) -> bool: raise RuntimeError("Attempted to simulate flight that is not fully initialized") + def estimate_position(self) -> Point: + raise RuntimeError("Attempted to simulate flight that is not fully initialized") + @property def spawn_type(self) -> StartType: raise RuntimeError("Attempted to simulate flight that is not fully initialized") diff --git a/game/ato/flightstate/waitingforstart.py b/game/ato/flightstate/waitingforstart.py index ff0cf106..da218715 100644 --- a/game/ato/flightstate/waitingforstart.py +++ b/game/ato/flightstate/waitingforstart.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING from game.ato.starttype import StartType +from .atdeparture import AtDeparture from .flightstate import FlightState from .navigating import Navigating from .startup import StartUp @@ -16,7 +17,7 @@ if TYPE_CHECKING: from game.sim.gameupdateevents import GameUpdateEvents -class WaitingForStart(FlightState): +class WaitingForStart(AtDeparture): def __init__( self, flight: Flight, diff --git a/game/server/combat/__init__.py b/game/server/combat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/server/combat/models.py b/game/server/combat/models.py new file mode 100644 index 00000000..716cb6fa --- /dev/null +++ b/game/server/combat/models.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel + +from game.server.leaflet import LeafletLatLon, LeafletPoly, ShapelyUtil +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 + + +class FrozenCombatJs(BaseModel): + id: UUID + flight_position: LeafletLatLon | None + target_positions: list[LeafletLatLon] | None + footprint: list[LeafletPoly] | None + + @staticmethod + def for_combat(combat: FrozenCombat, theater: ConflictTheater) -> FrozenCombatJs: + if isinstance(combat, AirCombat): + return FrozenCombatJs( + id=combat.id, + flight_position=None, + target_positions=None, + footprint=ShapelyUtil.polys_to_leaflet(combat.footprint, theater), + ) + if isinstance(combat, AtIp): + return FrozenCombatJs( + id=combat.id, + flight_position=theater.point_to_ll(combat.flight.position()).as_list(), + target_positions=[ + theater.point_to_ll(combat.flight.package.target.position).as_list() + ], + footprint=None, + ) + if isinstance(combat, DefendingSam): + return FrozenCombatJs( + id=combat.id, + flight_position=theater.point_to_ll(combat.flight.position()).as_list(), + target_positions=[ + theater.point_to_ll(sam.position).as_list() + for sam in combat.air_defenses + ], + footprint=None, + ) + raise NotImplementedError(f"Unhandled FrozenCombat type: {combat.__class__}") diff --git a/game/server/eventstream/models.py b/game/server/eventstream/models.py index faaa6273..3c843282 100644 --- a/game/server/eventstream/models.py +++ b/game/server/eventstream/models.py @@ -5,6 +5,7 @@ from uuid import UUID from pydantic import BaseModel +from game.server.combat.models import FrozenCombatJs from game.server.leaflet import LeafletLatLon if TYPE_CHECKING: @@ -14,6 +15,8 @@ if TYPE_CHECKING: class GameUpdateEventsJs(BaseModel): updated_flights: dict[UUID, LeafletLatLon] + new_combats: list[FrozenCombatJs] = [] + updated_combats: list[FrozenCombatJs] = [] @classmethod def from_events(cls, events: GameUpdateEvents, game: Game) -> GameUpdateEventsJs: @@ -21,5 +24,12 @@ class GameUpdateEventsJs(BaseModel): updated_flights={ f[0].id: game.theater.point_to_ll(f[1]).as_list() for f in events.updated_flights - } + }, + new_combats=[ + FrozenCombatJs.for_combat(c, game.theater) for c in events.new_combats + ], + updated_combats=[ + FrozenCombatJs.for_combat(c, game.theater) + for c in events.updated_combats + ], ) diff --git a/game/sim/combat/frozencombat.py b/game/sim/combat/frozencombat.py index 7673ec47..b2f0fc7b 100644 --- a/game/sim/combat/frozencombat.py +++ b/game/sim/combat/frozencombat.py @@ -1,5 +1,6 @@ from __future__ import annotations +import uuid from abc import ABC, abstractmethod from collections.abc import Iterator from typing import TYPE_CHECKING @@ -11,6 +12,9 @@ if TYPE_CHECKING: class FrozenCombat(ABC): + def __init__(self) -> None: + self.id = uuid.uuid4() + @abstractmethod def because(self) -> str: ... diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index b57fb286..bea5d0d2 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -16,7 +16,6 @@ from PySide2.QtWebEngineWidgets import ( from game import Game from qt_ui.models import GameModel -from qt_ui.simcontroller import SimController from .model import MapModel @@ -37,13 +36,11 @@ class LoggingWebPage(QWebEnginePage): class QLiberationMap(QWebEngineView): - def __init__( - self, game_model: GameModel, sim_controller: SimController, parent - ) -> None: + def __init__(self, game_model: GameModel, parent) -> None: super().__init__(parent) self.game_model = game_model self.setMinimumSize(800, 600) - self.map_model = MapModel(game_model, sim_controller) + self.map_model = MapModel(game_model) self.channel = QWebChannel() self.channel.registerObject("game", self.map_model) diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py index 1f71c3c4..1ee2b375 100644 --- a/qt_ui/widgets/map/model/mapmodel.py +++ b/qt_ui/widgets/map/model/mapmodel.py @@ -10,26 +10,17 @@ from game import Game from game.ato.airtaaskingorder import AirTaskingOrder from game.profiling import logged_duration from game.server.leaflet import LeafletLatLon -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.sim.gameupdateevents import GameUpdateEvents 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 .ipcombatjs import IpCombatJs from .mapzonesjs import MapZonesJs from .navmeshjs import NavMeshJs -from .samcombatjs import SamCombatJs from .supplyroutejs import SupplyRouteJs from .threatzonecontainerjs import ThreatZoneContainerJs from .threatzonesjs import ThreatZonesJs @@ -70,7 +61,7 @@ class MapModel(QObject): ipCombatsChanged = Signal() selectedFlightChanged = Signal(str) - def __init__(self, game_model: GameModel, sim_controller: SimController) -> None: + def __init__(self, game_model: GameModel) -> None: super().__init__() self.game_model = game_model self._map_center = [0, 0] @@ -86,9 +77,6 @@ class MapModel(QObject): self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] 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) @@ -104,7 +92,6 @@ class MapModel(QObject): self.game_model.ato_model_for(False).packages_changed.connect( self.on_package_change ), - sim_controller.sim_update.connect(self.on_sim_update) self.reset() def clear(self) -> None: @@ -119,17 +106,8 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._air_combats = [] - self._sam_combats = [] - self._ip_combats = [] self.cleared.emit() - def on_sim_update(self, events: GameUpdateEvents) -> None: - for combat in events.new_combats: - self.on_add_combat(combat) - for combat in events.updated_combats: - self.on_combat_changed(combat) - def set_package_selection(self, index: int) -> None: self.deselect_current_flight() # Optional[int] isn't a valid type for a Qt signal. None will be converted to @@ -199,7 +177,6 @@ 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: @@ -364,79 +341,6 @@ class MapModel(QObject): def unculledZones(self) -> list[UnculledZone]: return self._unculled_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/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index c733d769..abd1d471 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -56,7 +56,7 @@ class QLiberationWindow(QMainWindow): Dialog.set_game(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.info_panel = QInfoPanel(self.game) - self.liberation_map = QLiberationMap(self.game_model, self.sim_controller, self) + self.liberation_map = QLiberationMap(self.game_model, self) self.setGeometry(300, 100, 270, 100) self.updateWindowTitle() diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index a68f8e93..e86940e5 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -368,9 +368,6 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); - game.airCombatsChanged.connect(drawCombat); - game.samCombatsChanged.connect(drawCombat); - game.ipCombatsChanged.connect(drawCombat); game.selectedFlightChanged.connect(updateSelectedFlight); }); @@ -378,6 +375,12 @@ function handleStreamedEvents(events) { for (const [flightId, position] of Object.entries(events.updated_flights)) { Flight.withId(flightId).drawAircraftLocation(position); } + for (const combat of events.new_combats) { + redrawCombat(combat); + } + for (const combat of events.updated_combats) { + redrawCombat(combat); + } } function recenterMap(center) { @@ -1290,32 +1293,39 @@ function drawHoldZones(id) { }); } -function drawCombat() { - combatLayer.clearLayers(); +var COMBATS = {}; - 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); +function redrawCombat(combat) { + if (combat.id in COMBATS) { + for (layer in COMBATS[combat.id]) { + layer.removeFrom(combatLayer); } } - for (const ipCombat of game.ipCombats) { - L.polyline([ipCombat.flight.position, ipCombat.flight.target], { - color: Colors.Red, - interactive: false, - }).addTo(combatLayer); + const layers = []; + + if (combat.footprint) { + layers.push( + L.polygon(airCombat.footprint, { + color: Colors.Red, + interactive: false, + fillOpacity: 0.2, + }).addTo(combatLayer) + ); } + + if (combat.flight_position) { + for (target_position of combat.target_positions) { + layers.push( + L.polyline([combat.flight_position, target_position], { + color: Colors.Red, + interactive: false, + }).addTo(combatLayer) + ); + } + } + + COMBATS[combat.id] = layers; } function drawInitialMap() { @@ -1329,7 +1339,6 @@ function drawInitialMap() { drawNavmeshes(); drawMapZones(); drawUnculledZones(); - drawCombat(); } function clearAllLayers() {