Add websocket event stream for sim updates.

As a proof of concept this only covers the flight positions.
This commit is contained in:
Dan Albert 2022-02-16 01:28:10 -08:00
parent 350f08be2f
commit 21f7912458
14 changed files with 150 additions and 15 deletions

View File

@ -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 (

View File

@ -1,2 +1,3 @@
from .dependencies import GameContext
from .eventstream import EventStream
from .server import Server

View File

@ -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)

View File

@ -0,0 +1,2 @@
from .eventstream import EventStream
from .routes import router

View File

@ -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

View File

@ -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
}
)

View File

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

View File

@ -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):

View File

@ -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))

View File

@ -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)

View File

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

View File

@ -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:

View File

@ -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)

View File

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