diff --git a/game/ato/flightstate/navigating.py b/game/ato/flightstate/navigating.py index b9870dae..ff0c15ec 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) + events.update_flight(self.flight, self.estimate_position()) def progress(self) -> float: return ( diff --git a/game/server/__init__.py b/game/server/__init__.py index 6c5d5e6b..24ce56db 100644 --- a/game/server/__init__.py +++ b/game/server/__init__.py @@ -1,2 +1,3 @@ from .dependencies import GameContext +from .eventstream import EventStream from .server import Server diff --git a/game/server/app.py b/game/server/app.py index be862def..0def220a 100644 --- a/game/server/app.py +++ b/game/server/app.py @@ -1,6 +1,7 @@ from fastapi import FastAPI -from . import debuggeometries +from . import debuggeometries, eventstream app = FastAPI() app.include_router(debuggeometries.router) +app.include_router(eventstream.router) diff --git a/game/server/eventstream/__init__.py b/game/server/eventstream/__init__.py new file mode 100644 index 00000000..e06a9df8 --- /dev/null +++ b/game/server/eventstream/__init__.py @@ -0,0 +1,2 @@ +from .eventstream import EventStream +from .routes import router diff --git a/game/server/eventstream/eventstream.py b/game/server/eventstream/eventstream.py new file mode 100644 index 00000000..96494720 --- /dev/null +++ b/game/server/eventstream/eventstream.py @@ -0,0 +1,29 @@ +from asyncio import Queue + +from game.sim.gameupdateevents import GameUpdateEvents + + +class EventStream: + _queue: Queue[GameUpdateEvents] = Queue() + + @classmethod + def drain(cls) -> None: + while not cls._queue.empty(): + cls._queue.get_nowait() + cls._queue.task_done() + + @classmethod + async def put(cls, events: GameUpdateEvents) -> None: + await cls._queue.put(events) + + @classmethod + def put_nowait(cls, events: GameUpdateEvents) -> None: + # The queue has infinite size so this should never need to block anyway. If for + # some reason the queue is full this will throw QueueFull. + cls._queue.put_nowait(events) + + @classmethod + async def get(cls) -> GameUpdateEvents: + events = await cls._queue.get() + cls._queue.task_done() + return events diff --git a/game/server/eventstream/models.py b/game/server/eventstream/models.py new file mode 100644 index 00000000..faaa6273 --- /dev/null +++ b/game/server/eventstream/models.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from pydantic import BaseModel + +from game.server.leaflet import LeafletLatLon + +if TYPE_CHECKING: + from game import Game + from game.sim.gameupdateevents import GameUpdateEvents + + +class GameUpdateEventsJs(BaseModel): + updated_flights: dict[UUID, LeafletLatLon] + + @classmethod + def from_events(cls, events: GameUpdateEvents, game: Game) -> GameUpdateEventsJs: + return GameUpdateEventsJs( + updated_flights={ + f[0].id: game.theater.point_to_ll(f[1]).as_list() + for f in events.updated_flights + } + ) diff --git a/game/server/eventstream/routes.py b/game/server/eventstream/routes.py new file mode 100644 index 00000000..8bfadc72 --- /dev/null +++ b/game/server/eventstream/routes.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from starlette.websockets import WebSocket + +from .eventstream import EventStream +from .models import GameUpdateEventsJs +from .. import GameContext + +router = APIRouter() + + +@router.websocket("/eventstream") +async def event_stream(websocket: WebSocket) -> None: + await websocket.accept() + while True: + if not (events := await EventStream.get()).empty: + await websocket.send_json( + jsonable_encoder( + GameUpdateEventsJs.from_events(events, GameContext.get()) + ) + ) diff --git a/game/sim/gameloop.py b/game/sim/gameloop.py index 207d481e..78ae2ef4 100644 --- a/game/sim/gameloop.py +++ b/game/sim/gameloop.py @@ -72,6 +72,11 @@ class GameLoop: self.completed = True def send_update(self, rate_limit: bool) -> None: + # We don't skip empty events because we still want the tick in the Qt part of + # the UI, which will update things like the current simulation time. The time + # probably be an "event" of its own. For now the websocket endpoint will filter + # out empty events to avoid the map handling unnecessary events, but we still + # pass the events through to Qt. now = datetime.now() time_since_update = now - self.last_update_time if not rate_limit or time_since_update >= timedelta(seconds=1 / 60): diff --git a/game/sim/gameupdateevents.py b/game/sim/gameupdateevents.py index 48230385..02f49dbd 100644 --- a/game/sim/gameupdateevents.py +++ b/game/sim/gameupdateevents.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +from dcs import Point + if TYPE_CHECKING: from game.ato import Flight from game.sim.combat import FrozenCombat @@ -12,7 +14,18 @@ class GameUpdateEvents: self.simulation_complete = False self.new_combats: list[FrozenCombat] = [] self.updated_combats: list[FrozenCombat] = [] - self.updated_flights: list[Flight] = [] + self.updated_flights: list[tuple[Flight, Point]] = [] + + @property + def empty(self) -> bool: + return not any( + [ + self.simulation_complete, + self.new_combats, + self.updated_combats, + self.updated_flights, + ] + ) def complete_simulation(self) -> None: self.simulation_complete = True @@ -23,5 +36,5 @@ class GameUpdateEvents: def update_combat(self, combat: FrozenCombat) -> None: self.updated_combats.append(combat) - def update_flight(self, flight: Flight) -> None: - self.updated_flights.append(flight) + def update_flight(self, flight: Flight, new_position: Point) -> None: + self.updated_flights.append((flight, new_position)) diff --git a/qt_ui/main.py b/qt_ui/main.py index e777a974..deaf0a07 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -18,7 +18,7 @@ from game.data.weapons import Pylon, Weapon, WeaponGroup from game.db import FACTIONS from game.dcs.aircrafttype import AircraftType from game.profiling import logged_duration -from game.server import GameContext, Server +from game.server import EventStream, GameContext, Server from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from qt_ui import ( @@ -137,6 +137,7 @@ def run_ui(game: Optional[Game]) -> None: # Apply CSS (need works) GameUpdateSignal() GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set) + GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain) # Start window window = QLiberationWindow(game) diff --git a/qt_ui/widgets/map/model/flightjs.py b/qt_ui/widgets/map/model/flightjs.py index 6cf87e1a..272fb39d 100644 --- a/qt_ui/widgets/map/model/flightjs.py +++ b/qt_ui/widgets/map/model/flightjs.py @@ -17,6 +17,7 @@ from .waypointjs import WaypointJs class FlightJs(QObject): + idChanged = Signal() positionChanged = Signal() flightPlanChanged = Signal() blueChanged = Signal() @@ -50,6 +51,10 @@ class FlightJs(QObject): self.ato_model = ato_model self._waypoints = self.make_waypoints() + @Property(str, notify=idChanged) + def id(self) -> str: + return str(self.flight.id) + def update_waypoints(self) -> None: for waypoint in self._waypoints: waypoint.timingChanged.emit() diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py index ad93d635..1f71c3c4 100644 --- a/qt_ui/widgets/map/model/mapmodel.py +++ b/qt_ui/widgets/map/model/mapmodel.py @@ -125,12 +125,6 @@ class MapModel(QObject): self.cleared.emit() def on_sim_update(self, events: GameUpdateEvents) -> None: - # TODO: Only update flights with changes. - # We have the signal of which flights have updates, but no fast lookup for - # Flight -> FlightJs since Flight isn't hashable. Faster to update every flight - # than do do the O(n^2) filtered update. - for flight in self._flights.values(): - flight.positionChanged.emit() for combat in events.new_combats: self.on_add_combat(combat) for combat in events.updated_combats: diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index d62e2265..c733d769 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -20,6 +20,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, VERSION, persistency from game.debriefing import Debriefing +from game.server import EventStream from qt_ui import liberation_install from qt_ui.dialogs import Dialog from qt_ui.models import GameModel @@ -50,6 +51,7 @@ class QLiberationWindow(QMainWindow): self.game = game self.sim_controller = SimController(self.game) + self.sim_controller.sim_update.connect(EventStream.put_nowait) self.game_model = GameModel(game, self.sim_controller) Dialog.set_game(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model) diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index c587742b..a68f8e93 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1,6 +1,7 @@ const ENABLE_EXPENSIVE_DEBUG_TOOLS = false; // Must be kept in sync with game.server.settings.ServerSettings. const HTTP_BACKEND = "http://[::1]:5000"; +const WS_BACKEND = "ws://[::1]:5000/eventstream"; function getJson(endpoint) { return fetch(`${HTTP_BACKEND}${endpoint}`).then((response) => @@ -343,6 +344,17 @@ L.control let game; new QWebChannel(qt.webChannelTransport, function (channel) { + const ws = new WebSocket(WS_BACKEND); + ws.addEventListener("message", (event) => { + handleStreamedEvents(JSON.parse(event.data)); + }); + ws.addEventListener("close", (event) => { + console.log(`Websocket closed: ${event}`); + }); + ws.addEventListener("error", (error) => { + console.log(`Websocket error: ${error}`); + }); + game = channel.objects.game; drawInitialMap(); game.cleared.connect(clearAllLayers); @@ -362,6 +374,12 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.selectedFlightChanged.connect(updateSelectedFlight); }); +function handleStreamedEvents(events) { + for (const [flightId, position] of Object.entries(events.updated_flights)) { + Flight.withId(flightId).drawAircraftLocation(position); + } +} + function recenterMap(center) { map.setView(center, 8, { animate: true, duration: 1 }); } @@ -825,17 +843,32 @@ class Waypoint { } class Flight { + static registeredFlights = {}; + constructor(flight) { this.flight = flight; + this.id = flight.id; this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this)); this.aircraft = null; this.path = null; this.markers = []; this.commitBoundary = null; this.flight.selectedChanged.connect(() => this.draw()); - this.flight.positionChanged.connect(() => this.drawAircraftLocation()); this.flight.flightPlanChanged.connect(() => this.drawFlightPlan()); this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary()); + Flight.registerFlight(this); + } + + static clearRegisteredFlights() { + Flight.registeredFlights = {}; + } + + static registerFlight(flight) { + Flight.registeredFlights[flight.id] = flight; + } + + static withId(id) { + return Flight.registeredFlights[id]; } shouldMark(waypoint) { @@ -876,12 +909,14 @@ class Flight { this.drawCommitBoundary(); } - drawAircraftLocation() { + drawAircraftLocation(position = null) { if (this.aircraft != null) { this.aircraft.removeFrom(aircraftLayer); this.aircraft = null; } - const position = this.flight.position; + if (position == null) { + position = this.flight.position; + } if (position.length > 0) { this.aircraft = L.marker(position, { icon: Icons.AirIcons.icon( @@ -966,6 +1001,7 @@ class Flight { } function drawAircraft() { + Flight.clearRegisteredFlights(); aircraftLayer.clearLayers(); blueFlightPlansLayer.clearLayers(); redFlightPlansLayer.clearLayers();