mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Add websocket event stream for sim updates.
As a proof of concept this only covers the flight positions.
This commit is contained in:
parent
350f08be2f
commit
21f7912458
@ -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 (
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
from .dependencies import GameContext
|
||||
from .eventstream import EventStream
|
||||
from .server import Server
|
||||
|
||||
@ -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)
|
||||
|
||||
2
game/server/eventstream/__init__.py
Normal file
2
game/server/eventstream/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .eventstream import EventStream
|
||||
from .routes import router
|
||||
29
game/server/eventstream/eventstream.py
Normal file
29
game/server/eventstream/eventstream.py
Normal 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
|
||||
25
game/server/eventstream/models.py
Normal file
25
game/server/eventstream/models.py
Normal 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
|
||||
}
|
||||
)
|
||||
21
game/server/eventstream/routes.py
Normal file
21
game/server/eventstream/routes.py
Normal 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())
|
||||
)
|
||||
)
|
||||
@ -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):
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user