From 45e76e12b63bc548e1b8dc5731d280b00d981d04 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 22 Feb 2022 20:40:58 -0800 Subject: [PATCH] Move FlightJs out of MapModel. --- game/ato/airtaaskingorder.py | 7 +- game/ato/flightstate/navigating.py | 2 +- game/ato/package.py | 3 + game/commander/packagebuilder.py | 1 - game/server/eventstream/models.py | 28 ++- game/server/flights/models.py | 26 +++ game/server/flights/routes.py | 21 +- game/sim/gameupdateevents.py | 92 +++++--- game/squadrons/squadron.py | 3 +- game/transfers.py | 7 +- qt_ui/models.py | 14 +- qt_ui/widgets/ato.py | 30 ++- qt_ui/widgets/map/model/flightjs.py | 51 ----- qt_ui/widgets/map/model/mapmodel.py | 94 +------- qt_ui/windows/GameUpdateSignal.py | 16 -- qt_ui/windows/mission/QEditFlightDialog.py | 6 +- qt_ui/windows/mission/QPackageDialog.py | 17 +- resources/ui/map/map.js | 241 +++++++++++++-------- 18 files changed, 333 insertions(+), 326 deletions(-) create mode 100644 game/server/flights/models.py delete mode 100644 qt_ui/widgets/map/model/flightjs.py diff --git a/game/ato/airtaaskingorder.py b/game/ato/airtaaskingorder.py index 264efc09..41a4424f 100644 --- a/game/ato/airtaaskingorder.py +++ b/game/ato/airtaaskingorder.py @@ -17,8 +17,13 @@ class AirTaskingOrder: def remove_package(self, package: Package) -> None: """Removes a package from the ATO.""" + # Remove all the flights individually so the database gets updated. + for flight in list(package.flights): + package.remove_flight(flight) self.packages.remove(package) def clear(self) -> None: """Removes all packages from the ATO.""" - self.packages.clear() + # Remove all packages individually so the database gets updated. + for package in self.packages: + self.remove_package(package) diff --git a/game/ato/flightstate/navigating.py b/game/ato/flightstate/navigating.py index 01644f07..4ea5a14c 100644 --- a/game/ato/flightstate/navigating.py +++ b/game/ato/flightstate/navigating.py @@ -22,7 +22,7 @@ class Navigating(InFlight): self, events: GameUpdateEvents, time: datetime, duration: timedelta ) -> None: super().on_game_tick(events, time, duration) - events.update_flight(self.flight, self.estimate_position()) + events.update_flight_position(self.flight, self.estimate_position()) def progress(self) -> float: return ( diff --git a/game/ato/package.py b/game/ato/package.py index ca4ccb0a..2f172c81 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -129,6 +129,9 @@ class Package: """Removes a flight from the package.""" self.flights.remove(flight) self._db.remove(flight.id) + flight.return_pilots_and_aircraft() + if flight.cargo is not None: + flight.cargo.transport = None if not self.flights: self.waypoints = None diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index b415a1e4..e0dcb1bb 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -92,5 +92,4 @@ class PackageBuilder: """Returns any planned flights to the inventory.""" flights = list(self.package.flights) for flight in flights: - flight.return_pilots_and_aircraft() self.package.remove_flight(flight) diff --git a/game/server/eventstream/models.py b/game/server/eventstream/models.py index b2aa1d0a..3f1d14c6 100644 --- a/game/server/eventstream/models.py +++ b/game/server/eventstream/models.py @@ -6,6 +6,7 @@ from uuid import UUID from pydantic import BaseModel from game.server.combat.models import FrozenCombatJs +from game.server.flights.models import FlightJs from game.server.leaflet import LeafletLatLon if TYPE_CHECKING: @@ -14,18 +15,24 @@ if TYPE_CHECKING: class GameUpdateEventsJs(BaseModel): - updated_flights: dict[UUID, LeafletLatLon] - new_combats: list[FrozenCombatJs] = [] - updated_combats: list[FrozenCombatJs] = [] - navmesh_updates: set[bool] = set() - unculled_zones_updated: bool = False - threat_zones_updated: bool = False + updated_flight_positions: dict[UUID, LeafletLatLon] + new_combats: list[FrozenCombatJs] + updated_combats: list[FrozenCombatJs] + navmesh_updates: set[bool] + unculled_zones_updated: bool + threat_zones_updated: bool + new_flights: list[FlightJs] + updated_flights: set[UUID] + deleted_flights: set[UUID] + selected_flight: UUID | None + deselected_flight: bool @classmethod def from_events(cls, events: GameUpdateEvents, game: Game) -> GameUpdateEventsJs: return GameUpdateEventsJs( - updated_flights={ - f[0].id: f[1].latlng().as_list() for f in events.updated_flights + updated_flight_positions={ + f[0].id: f[1].latlng().as_list() + for f in events.updated_flight_positions }, new_combats=[ FrozenCombatJs.for_combat(c, game.theater) for c in events.new_combats @@ -37,4 +44,9 @@ class GameUpdateEventsJs(BaseModel): navmesh_updates=events.navmesh_updates, unculled_zones_updated=events.unculled_zones_updated, threat_zones_updated=events.threat_zones_updated, + new_flights=[FlightJs.for_flight(f) for f in events.new_flights], + updated_flights=events.updated_flights, + deleted_flights=events.deleted_flights, + selected_flight=events.selected_flight, + deselected_flight=events.deselected_flight, ) diff --git a/game/server/flights/models.py b/game/server/flights/models.py new file mode 100644 index 00000000..91e3248d --- /dev/null +++ b/game/server/flights/models.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from uuid import UUID + +from dcs.mapping import LatLng +from pydantic import BaseModel + +from game.ato import Flight +from game.ato.flightstate import InFlight + + +class FlightJs(BaseModel): + id: UUID + blue: bool + position: LatLng | None + + @staticmethod + def for_flight(flight: Flight) -> FlightJs: + # Don't provide a location for aircraft that aren't in the air. Later we can + # expand the model to include the state data for the UI so that it can make its + # own decisions about whether or not to draw the aircraft, but for now we'll + # filter here. + position = None + if isinstance(flight.state, InFlight): + position = flight.position().latlng() + return FlightJs(id=flight.id, blue=flight.blue, position=position) diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py index 892807eb..e98fcb29 100644 --- a/game/server/flights/routes.py +++ b/game/server/flights/routes.py @@ -4,13 +4,30 @@ from fastapi import APIRouter, Depends from shapely.geometry import LineString, Point as ShapelyPoint from game import Game -from game.server import GameContext -from game.server.leaflet import LeafletPoly, ShapelyUtil from game.ato.flightplan import CasFlightPlan, PatrollingFlightPlan +from game.server import GameContext +from game.server.flights.models import FlightJs +from game.server.leaflet import LeafletPoly, ShapelyUtil router: APIRouter = APIRouter(prefix="/flights") +@router.get("/") +def list_flights(game: Game = Depends(GameContext.get)) -> list[FlightJs]: + flights = [] + for coalition in game.coalitions: + for package in coalition.ato.packages: + for flight in package.flights: + flights.append(FlightJs.for_flight(flight)) + return flights + + +@router.get("/{flight_id}") +def get_flight(flight_id: UUID, game: Game = Depends(GameContext.get)) -> FlightJs: + flight = game.db.flights.get(flight_id) + return FlightJs.for_flight(flight) + + @router.get("/{flight_id}/commit-boundary") def commit_boundary( flight_id: UUID, game: Game = Depends(GameContext.get) diff --git a/game/sim/gameupdateevents.py b/game/sim/gameupdateevents.py index c0b727de..b3b3ee63 100644 --- a/game/sim/gameupdateevents.py +++ b/game/sim/gameupdateevents.py @@ -1,55 +1,91 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import TYPE_CHECKING +from uuid import UUID from dcs import Point if TYPE_CHECKING: - from game.ato import Flight + from game.ato import Flight, Package from game.sim.combat import FrozenCombat +@dataclass class GameUpdateEvents: - def __init__(self) -> None: - self.simulation_complete = False - self.new_combats: list[FrozenCombat] = [] - self.updated_combats: list[FrozenCombat] = [] - self.updated_flights: list[tuple[Flight, Point]] = [] - self.navmesh_updates: set[bool] = set() - self.unculled_zones_updated: bool = False - self.threat_zones_updated: bool = False + simulation_complete = False + new_combats: list[FrozenCombat] = field(default_factory=list) + updated_combats: list[FrozenCombat] = field(default_factory=list) + updated_flight_positions: list[tuple[Flight, Point]] = field(default_factory=list) + navmesh_updates: set[bool] = field(default_factory=set) + unculled_zones_updated: bool = False + threat_zones_updated: bool = False + new_flights: set[Flight] = field(default_factory=set) + updated_flights: set[UUID] = field(default_factory=set) + deleted_flights: set[UUID] = field(default_factory=set) + selected_flight: UUID | None = None + deselected_flight: bool = False @property def empty(self) -> bool: - return not any( - [ - self.simulation_complete, - self.new_combats, - self.updated_combats, - self.updated_flights, - self.navmesh_updates, - self.unculled_zones_updated, - self.threat_zones_updated, - ] - ) + return self == GameUpdateEvents() - def complete_simulation(self) -> None: + def complete_simulation(self) -> GameUpdateEvents: self.simulation_complete = True + return self - def new_combat(self, combat: FrozenCombat) -> None: + def new_combat(self, combat: FrozenCombat) -> GameUpdateEvents: self.new_combats.append(combat) + return self - def update_combat(self, combat: FrozenCombat) -> None: + def update_combat(self, combat: FrozenCombat) -> GameUpdateEvents: self.updated_combats.append(combat) + return self - def update_flight(self, flight: Flight, new_position: Point) -> None: - self.updated_flights.append((flight, new_position)) + def update_flight_position( + self, flight: Flight, new_position: Point + ) -> GameUpdateEvents: + self.updated_flight_positions.append((flight, new_position)) + return self - def update_navmesh(self, player: bool) -> None: + def update_navmesh(self, player: bool) -> GameUpdateEvents: self.navmesh_updates.add(player) + return self - def update_unculled_zones(self) -> None: + def update_unculled_zones(self) -> GameUpdateEvents: self.unculled_zones_updated = True + return self - def update_threat_zones(self) -> None: + def update_threat_zones(self) -> GameUpdateEvents: self.threat_zones_updated = True + return self + + def new_flight(self, flight: Flight) -> GameUpdateEvents: + self.new_flights.add(flight) + return self + + def update_flight(self, flight: Flight) -> GameUpdateEvents: + self.updated_flights.add(flight.id) + return self + + def update_flights_in_package(self, package: Package) -> GameUpdateEvents: + self.updated_flights.update({f.id for f in package.flights}) + return self + + def delete_flight(self, flight: Flight) -> GameUpdateEvents: + self.deleted_flights.add(flight.id) + return self + + def delete_flights_in_package(self, package: Package) -> GameUpdateEvents: + self.deleted_flights.update({f.id for f in package.flights}) + return self + + def select_flight(self, flight: Flight) -> GameUpdateEvents: + self.selected_flight = flight.id + self.deselected_flight = False + return self + + def deselect_flight(self) -> GameUpdateEvents: + self.deselected_flight = True + self.selected_flight = None + return self diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index ffde9cd8..510f6ea3 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -9,8 +9,8 @@ from typing import Optional, Sequence, TYPE_CHECKING from faker import Faker from game.ato import Flight, FlightType, Package -from game.settings import AutoAtoBehavior, Settings from game.ato.flightplan import FlightPlanBuilder +from game.settings import AutoAtoBehavior, Settings from .pilot import Pilot, PilotStatus from ..db.database import Database from ..utils import meters @@ -381,7 +381,6 @@ class Squadron: for flight in list(package.flights): if flight.squadron == self and flight.flight_type is FlightType.FERRY: package.remove_flight(flight) - flight.return_pilots_and_aircraft() if not package.flights: self.coalition.ato.remove_package(package) diff --git a/game/transfers.py b/game/transfers.py index 290e940c..a783845c 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -40,7 +40,10 @@ from typing import Generic, Iterator, List, Optional, Sequence, TYPE_CHECKING, T from dcs.mapping import Point +from game.ato.ai_flight_planner_db import aircraft_for_task +from game.ato.closestairfields import ObjectiveDistanceCache from game.ato.flight import Flight +from game.ato.flightplan import FlightPlanBuilder from game.ato.flighttype import FlightType from game.ato.package import Package from game.dcs.aircrafttype import AircraftType @@ -53,9 +56,6 @@ from game.theater.transitnetwork import ( TransitNetwork, ) from game.utils import meters, nautical_miles -from game.ato.ai_flight_planner_db import aircraft_for_task -from game.ato.closestairfields import ObjectiveDistanceCache -from game.ato.flightplan import FlightPlanBuilder if TYPE_CHECKING: from game import Game @@ -635,7 +635,6 @@ class PendingTransfers: flight.package.remove_flight(flight) if not flight.package.flights: self.game.ato_for(self.player).remove_package(flight.package) - flight.return_pilots_and_aircraft() @cancel_transport.register def _cancel_transport_convoy( diff --git a/qt_ui/models.py b/qt_ui/models.py index 5e7f82e2..5e7eb6ec 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -169,9 +169,6 @@ class PackageModel(QAbstractListModel): """Removes the given flight from the package.""" index = self.package.flights.index(flight) self.beginRemoveRows(QModelIndex(), index, index) - if flight.cargo is not None: - flight.cargo.transport = None - flight.return_pilots_and_aircraft() self.package.remove_flight(flight) self.endRemoveRows() self.update_tot() @@ -253,6 +250,8 @@ class AtoModel(QAbstractListModel): """Adds a package to the ATO.""" self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) self.ato.add_package(package) + # We do not need to send events for new flights in the package here. Events were + # already sent when the flights were added to the in-progress package. self.endInsertRows() # noinspection PyUnresolvedReferences self.client_slots_changed.emit() @@ -264,14 +263,11 @@ class AtoModel(QAbstractListModel): def delete_package(self, package: Package) -> None: """Removes the given package from the ATO.""" + EventStream.put_nowait(GameUpdateEvents().delete_flights_in_package(package)) self.package_models.release(package) index = self.ato.packages.index(package) self.beginRemoveRows(QModelIndex(), index, index) self.ato.remove_package(package) - for flight in package.flights: - flight.return_pilots_and_aircraft() - if flight.cargo is not None: - flight.cargo.transport = None self.endRemoveRows() # noinspection PyUnresolvedReferences self.client_slots_changed.emit() @@ -280,9 +276,7 @@ class AtoModel(QAbstractListModel): def on_packages_changed(self) -> None: if self.game is not None: self.game.compute_unculled_zones() - events = GameUpdateEvents() - events.update_unculled_zones() - EventStream.put_nowait(events) + EventStream.put_nowait(GameUpdateEvents().update_unculled_zones()) def package_at_index(self, index: QModelIndex) -> Package: """Returns the package at the given index.""" diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index 8d72e47d..bd3e5608 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -27,7 +27,8 @@ from PySide2.QtWidgets import ( from game.ato.flight import Flight from game.ato.package import Package -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from game.server import EventStream +from game.sim import GameUpdateEvents from ..delegates import TwoColumnRowDelegate from ..models import AtoModel, GameModel, NullListModel, PackageModel @@ -128,8 +129,10 @@ class QFlightList(QListView): ) def delete_flight(self, index: QModelIndex) -> None: + EventStream.put_nowait( + GameUpdateEvents().delete_flight(self.package_model.flight_at_index(index)) + ) self.package_model.delete_flight_at_index(index) - GameUpdateSignal.get_instance().redraw_flight_paths() def contextMenuEvent(self, event: QContextMenuEvent) -> None: index = self.indexAt(event.pos()) @@ -209,13 +212,13 @@ class QFlightPanel(QGroupBox): self.delete_button.setEnabled(enabled) self.change_map_flight_selection(index) - @staticmethod - def change_map_flight_selection(index: QModelIndex) -> None: + def change_map_flight_selection(self, index: QModelIndex) -> None: + events = GameUpdateEvents() if not index.isValid(): - GameUpdateSignal.get_instance().select_flight(None) - return - - GameUpdateSignal.get_instance().select_flight(index.row()) + events.deselect_flight() + else: + events.select_flight(self.package_model.flight_at_index(index)) + EventStream.put_nowait(events) def on_edit(self) -> None: """Opens the flight edit dialog.""" @@ -303,7 +306,6 @@ class QPackageList(QListView): def delete_package(self, index: QModelIndex) -> None: self.ato_model.delete_package_at_index(index) - GameUpdateSignal.get_instance().redraw_flight_paths() def on_new_packages(self, _parent: QModelIndex, first: int, _last: int) -> None: # Select the newly created pacakges. This should only ever happen due to @@ -390,14 +392,18 @@ class QPackagePanel(QGroupBox): def change_map_package_selection(self, index: QModelIndex) -> None: if not index.isValid(): - GameUpdateSignal.get_instance().select_package(None) + EventStream.put_nowait(GameUpdateEvents().deselect_flight()) return package = self.ato_model.get_package_model(index) if package.rowCount() == 0: - GameUpdateSignal.get_instance().select_package(None) + EventStream.put_nowait(GameUpdateEvents().deselect_flight()) else: - GameUpdateSignal.get_instance().select_package(index.row()) + EventStream.put_nowait( + GameUpdateEvents().select_flight( + package.flight_at_index(package.index(0)) + ) + ) def on_edit(self) -> None: """Opens the package edit dialog.""" diff --git a/qt_ui/widgets/map/model/flightjs.py b/qt_ui/widgets/map/model/flightjs.py deleted file mode 100644 index 886293ca..00000000 --- a/qt_ui/widgets/map/model/flightjs.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from PySide2.QtCore import Property, QObject, Signal, Slot - -from game.ato import Flight -from game.ato.flightstate import InFlight -from game.server.leaflet import LeafletLatLon -from qt_ui.models import AtoModel - - -class FlightJs(QObject): - idChanged = Signal() - positionChanged = Signal() - blueChanged = Signal() - selectedChanged = Signal() - - def __init__(self, flight: Flight, selected: bool, ato_model: AtoModel) -> None: - super().__init__() - self.flight = flight - self._selected = selected - self.ato_model = ato_model - - @Property(str, notify=idChanged) - def id(self) -> str: - return str(self.flight.id) - - @Property(list, notify=positionChanged) - def position(self) -> LeafletLatLon: - if isinstance(self.flight.state, InFlight): - return self.flight.state.estimate_position().latlng().as_list() - return [] - - @Property(bool, notify=blueChanged) - def blue(self) -> bool: - return self.flight.departure.captured - - @Property(bool, notify=selectedChanged) - def selected(self) -> bool: - return self._selected - - @Slot(result=bool) - def flightIsInAto(self) -> bool: - if self.flight.package not in self.flight.squadron.coalition.ato.packages: - return False - if self.flight not in self.flight.package.flights: - return False - return True - - def set_selected(self, value: bool) -> None: - self._selected = value - self.selectedChanged.emit() diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py index 4f7f9efe..7a878771 100644 --- a/qt_ui/widgets/map/model/mapmodel.py +++ b/qt_ui/widgets/map/model/mapmodel.py @@ -1,13 +1,11 @@ from __future__ import annotations -import logging -from typing import List, Optional, Tuple +from typing import List, Optional from PySide2.QtCore import Property, QObject, Signal from dcs.mapping import LatLng from game import Game -from game.ato.airtaaskingorder import AirTaskingOrder from game.profiling import logged_duration from game.server.leaflet import LeafletLatLon from game.server.security import ApiKeyManager @@ -17,7 +15,6 @@ from game.theater import ( from qt_ui.models import GameModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from .controlpointjs import ControlPointJs -from .flightjs import FlightJs from .frontlinejs import FrontLineJs from .groundobjectjs import GroundObjectJs from .supplyroutejs import SupplyRouteJs @@ -47,9 +44,8 @@ class MapModel(QObject): controlPointsChanged = Signal() groundObjectsChanged = Signal() supplyRoutesChanged = Signal() - flightsChanged = Signal() frontLinesChanged = Signal() - selectedFlightChanged = Signal(str) + mapReset = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -58,79 +54,18 @@ class MapModel(QObject): self._control_points = [] self._ground_objects = [] self._supply_routes = [] - self._flights: dict[tuple[bool, int, int], FlightJs] = {} self._front_lines = [] - self._selected_flight_index: Optional[Tuple[int, int]] = None - 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( - self.set_package_selection - ) - GameUpdateSignal.get_instance().flight_selection_changed.connect( - self.set_flight_selection - ) self.reset() def clear(self) -> None: self._control_points = [] self._supply_routes = [] self._ground_objects = [] - self._flights = {} self._front_lines = [] self.cleared.emit() - 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 - # zero automatically. We use -1 to indicate no selection. - if index == -1: - self._selected_flight_index = None - else: - self._selected_flight_index = index, 0 - self.select_current_flight() - - def set_flight_selection(self, index: int) -> None: - self.deselect_current_flight() - if self._selected_flight_index is None: - if index != -1: - # We don't know what order update_package_selection and - # update_flight_selection will be called in when the last - # package is removed. If no flight is selected, it's not a - # problem to also have no package selected. - logging.error("Flight was selected with no package selected") - return - - # 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. - if index == -1: - self._selected_flight_index = self._selected_flight_index[0], None - self._selected_flight_index = self._selected_flight_index[0], index - self.select_current_flight() - - @property - def _selected_flight(self) -> Optional[FlightJs]: - if self._selected_flight_index is None: - return None - package_index, flight_index = self._selected_flight_index - blue = True - return self._flights.get((blue, package_index, flight_index)) - - def deselect_current_flight(self) -> None: - flight = self._selected_flight - if flight is None: - return None - flight.set_selected(False) - - def select_current_flight(self): - flight = self._selected_flight - if flight is None: - self.selectedFlightChanged.emit(None) - return None - flight.set_selected(True) - self.selectedFlightChanged.emit(str(flight.flight.id)) - def reset(self) -> None: if self.game_model.game is None: self.clear() @@ -139,8 +74,8 @@ class MapModel(QObject): self.reset_control_points() self.reset_ground_objects() self.reset_routes() - self.reset_atos() self.reset_front_lines() + self.mapReset.emit() def on_game_load(self, game: Optional[Game]) -> None: if game is not None: @@ -158,29 +93,6 @@ class MapModel(QObject): def mapCenter(self) -> LeafletLatLon: return self._map_center.as_list() - def _flights_in_ato( - self, ato: AirTaskingOrder, blue: bool - ) -> dict[tuple[bool, int, int], FlightJs]: - flights = {} - for p_idx, package in enumerate(ato.packages): - for f_idx, flight in enumerate(package.flights): - flights[blue, p_idx, f_idx] = FlightJs( - flight, - selected=blue and (p_idx, f_idx) == self._selected_flight_index, - ato_model=self.game_model.ato_model_for(blue), - ) - return flights - - def reset_atos(self) -> None: - self._flights = self._flights_in_ato( - self.game.blue.ato, blue=True - ) | self._flights_in_ato(self.game.red.ato, blue=False) - self.flightsChanged.emit() - - @Property(list, notify=flightsChanged) - def flights(self) -> list[FlightJs]: - return list(self._flights.values()) - def reset_control_points(self) -> None: self._control_points = [ ControlPointJs(c, self.game_model, self.game.theater) diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 5a310df9..bb3c8a71 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -17,28 +17,12 @@ class GameUpdateSignal(QObject): game_loaded = Signal(Game) - flight_paths_changed = Signal() - package_selection_changed = Signal(int) # -1 indicates no selection. - flight_selection_changed = Signal(int) # -1 indicates no selection. - def __init__(self): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self self.game_loaded.connect(self.updateGame) - def select_package(self, index: Optional[int]) -> None: - # noinspection PyUnresolvedReferences - self.package_selection_changed.emit(-1 if index is None else index) - - def select_flight(self, index: Optional[int]) -> None: - # noinspection PyUnresolvedReferences - self.flight_selection_changed.emit(-1 if index is None else index) - - def redraw_flight_paths(self) -> None: - # noinspection PyUnresolvedReferences - self.flight_paths_changed.emit() - def updateGame(self, game: Optional[Game]): # noinspection PyUnresolvedReferences self.gameupdated.emit(game) diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 4e0afcd3..40d43cf7 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -5,9 +5,10 @@ from PySide2.QtWidgets import ( ) from game.ato.flight import Flight +from game.server import EventStream +from game.sim import GameUpdateEvents from qt_ui.models import GameModel, PackageModel from qt_ui.uiconstants import EVENT_ICONS -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner @@ -24,6 +25,7 @@ class QEditFlightDialog(QDialog): super().__init__(parent=parent) self.game_model = game_model + self.flight = flight self.setWindowTitle("Edit flight") self.setWindowIcon(EVENT_ICONS["strike"]) @@ -37,5 +39,5 @@ class QEditFlightDialog(QDialog): self.finished.connect(self.on_close) def on_close(self, _result) -> None: - GameUpdateSignal.get_instance().redraw_flight_paths() + EventStream.put_nowait(GameUpdateEvents().update_flight(self.flight)) self.game_model.ato_model.client_slots_changed.emit() diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 0b564885..8f1f4139 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -16,14 +16,15 @@ from PySide2.QtWidgets import ( ) from game.ato.flight import Flight +from game.ato.flightplan import FlightPlanBuilder, PlanningError from game.ato.package import Package from game.game import Game +from game.server import EventStream +from game.sim import GameUpdateEvents from game.theater.missiontarget import MissionTarget -from game.ato.flightplan import FlightPlanBuilder, PlanningError from qt_ui.models import AtoModel, GameModel, PackageModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.ato import QFlightList -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator @@ -141,9 +142,10 @@ class QPackageDialog(QDialog): def on_cancel(self) -> None: pass - @staticmethod - def on_close(_result) -> None: - GameUpdateSignal.get_instance().redraw_flight_paths() + def on_close(self, _result) -> None: + EventStream.put_nowait( + GameUpdateEvents().update_flights_in_package(self.package_model.package) + ) def on_save(self) -> None: self.save_tot() @@ -183,13 +185,14 @@ class QPackageDialog(QDialog): ) try: planner.populate_flight_plan(flight) + self.package_model.update_tot() + EventStream.put_nowait(GameUpdateEvents().new_flight(flight)) except PlanningError as ex: self.package_model.delete_flight(flight) logging.exception("Could not create flight") QMessageBox.critical( self, "Could not create flight", str(ex), QMessageBox.Ok ) - self.package_model.update_tot() # noinspection PyUnresolvedReferences self.package_changed.emit() @@ -252,7 +255,7 @@ class QNewPackageDialog(QPackageDialog): def on_cancel(self) -> None: super().on_cancel() for flight in self.package_model.package.flights: - flight.return_pilots_and_aircraft() + self.package_model.delete_flight(flight) class QEditPackageDialog(QPackageDialog): diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 28984dd5..017799b9 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -382,48 +382,61 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.groundObjectsChanged.connect(drawGroundObjects); game.supplyRoutesChanged.connect(drawSupplyRoutes); game.frontLinesChanged.connect(drawFrontLines); - game.flightsChanged.connect(drawAircraft); - game.selectedFlightChanged.connect(updateSelectedFlight); + game.mapReset.connect(drawAircraft); }); function handleStreamedEvents(events) { - for (const [flightId, position] of Object.entries(events.updated_flights)) { - Flight.withId(flightId).drawAircraftLocation(position); + for (const [flightId, position] of Object.entries( + events.updated_flight_positions + )) { + Flight.withId(flightId).updatePosition(position); } + for (const combat of events.new_combats) { redrawCombat(combat); } + for (const combat of events.updated_combats) { redrawCombat(combat); } + for (const player of events.navmesh_updates) { drawNavmesh(player); } + if (events.unculled_zones_updated) { drawUnculledZones(); } + if (events.threat_zones_updated) { drawThreatZones(); } + + if (events.deselected_flight && Flight.selected != null) { + Flight.deselectCurrent(); + } + + if (events.selected_flight != null) { + Flight.select(events.selected_flight); + } + + for (const flight of events.new_flights) { + new Flight(flight).draw(); + } + + for (const flightId of events.updated_flights) { + Flight.withId(flightId).draw(); + } + + for (const flightId of events.deleted_flights) { + Flight.popId(flightId).clear(); + } } function recenterMap(center) { map.setView(center, 8, { animate: true, duration: 1 }); } -function updateSelectedFlight(id) { - if (id == null) { - holdZones.clearLayers(); - ipZones.clearLayers(); - joinZones.clearLayers(); - return; - } - - drawHoldZones(id); - drawIpZones(id); - drawJoinZones(id); -} - class ControlPoint { constructor(cp) { this.cp = cp; @@ -857,15 +870,17 @@ class Waypoint { class Flight { static registeredFlights = {}; + static selected = null; constructor(flight) { this.flight = flight; this.id = flight.id; + this.selected = false; + self.position = flight.position; this.aircraft = null; this.path = null; this.markers = []; this.commitBoundary = null; - this.flight.selectedChanged.connect(() => this.draw()); Flight.registerFlight(this); } @@ -877,18 +892,67 @@ class Flight { Flight.registeredFlights[flight.id] = flight; } + static unregisterFlight(id) { + if (Flight.selected != null && Flight.selected.id == id) { + Flight.clearSelected(); + } + delete Flight.registeredFlights[id]; + } + static withId(id) { return Flight.registeredFlights[id]; } + static popId(id) { + const flight = Flight.withId(id); + Flight.unregisterFlight(id); + return flight; + } + + static clearSelected() { + holdZones.clearLayers(); + ipZones.clearLayers(); + joinZones.clearLayers(); + Flight.selected = null; + } + + static deselectCurrent() { + const flight = Flight.selected; + Flight.clearSelected(); + if (flight != null) { + flight.selected = false; + flight.draw(); + } + } + + static select(id) { + if (Flight.selected != null && Flight.selected.id == id) { + return; + } + + Flight.deselectCurrent(); + const flight = Flight.withId(id); + Flight.selected = flight; + flight.selected = true; + flight.draw(); + drawHoldZones(id); + drawIpZones(id); + drawJoinZones(id); + } + shouldMark(waypoint) { - return this.flight.selected && waypoint.shouldMark(); + return this.selected && waypoint.shouldMark(); } flightPlanLayer() { return this.flight.blue ? blueFlightPlansLayer : redFlightPlansLayer; } + updatePosition(position) { + this.position = position; + this.drawAircraftLocation(); + } + updatePath(idx, position) { const points = this.path.getLatLngs(); points[idx] = position; @@ -898,7 +962,7 @@ class Flight { drawPath(path) { const color = this.flight.blue ? Colors.Blue : Colors.Red; const layer = this.flightPlanLayer(); - if (this.flight.selected) { + if (this.selected) { this.path = L.polyline(path, { color: Colors.Highlight, interactive: false, @@ -919,46 +983,69 @@ class Flight { this.drawCommitBoundary(); } - drawAircraftLocation(position = null) { + clear() { + this.clearAircraftLocation(); + this.clearFlightPlan(); + this.clearCommitBoundary(); + } + + clearAircraftLocation() { if (this.aircraft != null) { this.aircraft.removeFrom(aircraftLayer); this.aircraft = null; } - if (position == null) { - position = this.flight.position; - } - if (position.length > 0) { - this.aircraft = L.marker(position, { - icon: Icons.AirIcons.icon( - "fighter", - this.flight.blue, - this.flight.selected - ), + } + + drawAircraftLocation() { + this.clearAircraftLocation(); + if (this.position != null) { + this.aircraft = L.marker(this.position, { + icon: Icons.AirIcons.icon("fighter", this.flight.blue, this.selected), }).addTo(aircraftLayer); } } - drawCommitBoundary() { + clearCommitBoundary() { if (this.commitBoundary != null) { this.commitBoundary .removeFrom(selectedFlightPlansLayer) .removeFrom(this.flightPlanLayer()) .removeFrom(allFlightPlansLayer); } - if (this.flight.selected) { - getJson(`/flights/${this.flight.id}/commit-boundary`).then((boundary) => { - if (boundary) { - this.commitBoundary = L.polyline(boundary, { - color: Colors.Highlight, - weight: 1, - interactive: false, - }) - .addTo(selectedFlightPlansLayer) - .addTo(this.flightPlanLayer()) - .addTo(allFlightPlansLayer); - } - }); + } + + drawCommitBoundary() { + if (!this.selected) { + this.clearCommitBoundary(); + return; } + + getJson(`/flights/${this.flight.id}/commit-boundary`).then((boundary) => { + // For a selected flight we wait to clear the commit boundary until after + // the backend responds. Otherwise if the package is reselected while + // waiting we may have the following execution order, with selections A + // and B: + // + // 1. A: clear + // 2. A: wait for backend + // 3. B: wait for backend + // 4. A: Add boundary to map + // 5. B: Add boundary to map + // + // Similarly, we need to recheck that we're still selected before + // continuing. + this.clearCommitBoundary(); + if (boundary && this.selected) { + this.commitBoundary = L.polyline(boundary, { + color: Colors.Highlight, + weight: 1, + interactive: false, + }) + .addTo(selectedFlightPlansLayer) + .addTo(this.flightPlanLayer()) + .addTo(allFlightPlansLayer); + } + }); } clearFlightPlan() { @@ -978,39 +1065,23 @@ class Flight { } drawFlightPlan() { - this.clearFlightPlan(); - this.flight.flightIsInAto().then((inAto) => { - if (!inAto) { - // HACK: The signal to redraw the ATO following package/flight deletion - // and the signal to change flight/package selection due to UI selection - // change come in an arbitrary order. If redraw signal comes first the - // UI will clear the map and redraw, but then when the UI updates - // selection away from the (now deleted) flight/package it calls - // deselect, which redraws the deleted flight plan in its deselected - // state. - // - // Avoid this by checking that the flight is still in the coalition's - // ATO before drawing. - return; - } - - getJson(`/waypoints/${this.flight.id}`).then((waypoints) => { - const path = []; - waypoints.map((raw, idx) => { - const waypoint = new Waypoint(raw, idx, this); - if (waypoint.includeInPath()) { - path.push(waypoint.position()); - } - if (this.shouldMark(waypoint)) { - waypoint.marker - .addTo(selectedFlightPlansLayer) - .addTo(this.flightPlanLayer()) - .addTo(allFlightPlansLayer); - this.markers.push(waypoint.marker); - } - }); - this.drawPath(path); + getJson(`/waypoints/${this.flight.id}`).then((waypoints) => { + this.clearFlightPlan(); + const path = []; + waypoints.map((raw, idx) => { + const waypoint = new Waypoint(raw, idx, this); + if (waypoint.includeInPath()) { + path.push(waypoint.position()); + } + if (this.shouldMark(waypoint)) { + waypoint.marker + .addTo(selectedFlightPlansLayer) + .addTo(this.flightPlanLayer()) + .addTo(allFlightPlansLayer); + this.markers.push(waypoint.marker); + } }); + this.drawPath(path); }); } } @@ -1022,22 +1093,12 @@ function drawAircraft() { redFlightPlansLayer.clearLayers(); selectedFlightPlansLayer.clearLayers(); allFlightPlansLayer.clearLayers(); - let selected = null; - game.flights.forEach((flight) => { - // Draw the selected waypoint last so it's on top. bringToFront only brings - // it to the front of the *extant* elements, so any flights drawn later will - // be drawn on top. We could fight with manual Z-indexes but leaflet does a - // lot of that automatically so it'd be error prone. - if (flight.selected) { - selected = flight; - } else { + + getJson("/flights").then((flights) => { + for (const flight of flights) { new Flight(flight).draw(); } }); - - if (selected != null) { - new Flight(selected).draw(); - } } function _drawThreatZones(zones, layer, player) {