mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +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
|
self, events: GameUpdateEvents, time: datetime, duration: timedelta
|
||||||
) -> None:
|
) -> None:
|
||||||
super().on_game_tick(events, time, duration)
|
super().on_game_tick(events, time, duration)
|
||||||
events.update_flight(self.flight)
|
events.update_flight(self.flight, self.estimate_position())
|
||||||
|
|
||||||
def progress(self) -> float:
|
def progress(self) -> float:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
from .dependencies import GameContext
|
from .dependencies import GameContext
|
||||||
|
from .eventstream import EventStream
|
||||||
from .server import Server
|
from .server import Server
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from . import debuggeometries
|
from . import debuggeometries, eventstream
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(debuggeometries.router)
|
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
|
self.completed = True
|
||||||
|
|
||||||
def send_update(self, rate_limit: bool) -> None:
|
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()
|
now = datetime.now()
|
||||||
time_since_update = now - self.last_update_time
|
time_since_update = now - self.last_update_time
|
||||||
if not rate_limit or time_since_update >= timedelta(seconds=1 / 60):
|
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 typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from dcs import Point
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from game.ato import Flight
|
from game.ato import Flight
|
||||||
from game.sim.combat import FrozenCombat
|
from game.sim.combat import FrozenCombat
|
||||||
@ -12,7 +14,18 @@ class GameUpdateEvents:
|
|||||||
self.simulation_complete = False
|
self.simulation_complete = False
|
||||||
self.new_combats: list[FrozenCombat] = []
|
self.new_combats: list[FrozenCombat] = []
|
||||||
self.updated_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:
|
def complete_simulation(self) -> None:
|
||||||
self.simulation_complete = True
|
self.simulation_complete = True
|
||||||
@ -23,5 +36,5 @@ class GameUpdateEvents:
|
|||||||
def update_combat(self, combat: FrozenCombat) -> None:
|
def update_combat(self, combat: FrozenCombat) -> None:
|
||||||
self.updated_combats.append(combat)
|
self.updated_combats.append(combat)
|
||||||
|
|
||||||
def update_flight(self, flight: Flight) -> None:
|
def update_flight(self, flight: Flight, new_position: Point) -> None:
|
||||||
self.updated_flights.append(flight)
|
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.db import FACTIONS
|
||||||
from game.dcs.aircrafttype import AircraftType
|
from game.dcs.aircrafttype import AircraftType
|
||||||
from game.profiling import logged_duration
|
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.settings import Settings
|
||||||
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
|
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
|
||||||
from qt_ui import (
|
from qt_ui import (
|
||||||
@ -137,6 +137,7 @@ def run_ui(game: Optional[Game]) -> None:
|
|||||||
# Apply CSS (need works)
|
# Apply CSS (need works)
|
||||||
GameUpdateSignal()
|
GameUpdateSignal()
|
||||||
GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set)
|
GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set)
|
||||||
|
GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain)
|
||||||
|
|
||||||
# Start window
|
# Start window
|
||||||
window = QLiberationWindow(game)
|
window = QLiberationWindow(game)
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from .waypointjs import WaypointJs
|
|||||||
|
|
||||||
|
|
||||||
class FlightJs(QObject):
|
class FlightJs(QObject):
|
||||||
|
idChanged = Signal()
|
||||||
positionChanged = Signal()
|
positionChanged = Signal()
|
||||||
flightPlanChanged = Signal()
|
flightPlanChanged = Signal()
|
||||||
blueChanged = Signal()
|
blueChanged = Signal()
|
||||||
@ -50,6 +51,10 @@ class FlightJs(QObject):
|
|||||||
self.ato_model = ato_model
|
self.ato_model = ato_model
|
||||||
self._waypoints = self.make_waypoints()
|
self._waypoints = self.make_waypoints()
|
||||||
|
|
||||||
|
@Property(str, notify=idChanged)
|
||||||
|
def id(self) -> str:
|
||||||
|
return str(self.flight.id)
|
||||||
|
|
||||||
def update_waypoints(self) -> None:
|
def update_waypoints(self) -> None:
|
||||||
for waypoint in self._waypoints:
|
for waypoint in self._waypoints:
|
||||||
waypoint.timingChanged.emit()
|
waypoint.timingChanged.emit()
|
||||||
|
|||||||
@ -125,12 +125,6 @@ class MapModel(QObject):
|
|||||||
self.cleared.emit()
|
self.cleared.emit()
|
||||||
|
|
||||||
def on_sim_update(self, events: GameUpdateEvents) -> None:
|
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:
|
for combat in events.new_combats:
|
||||||
self.on_add_combat(combat)
|
self.on_add_combat(combat)
|
||||||
for combat in events.updated_combats:
|
for combat in events.updated_combats:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from PySide2.QtWidgets import (
|
|||||||
import qt_ui.uiconstants as CONST
|
import qt_ui.uiconstants as CONST
|
||||||
from game import Game, VERSION, persistency
|
from game import Game, VERSION, persistency
|
||||||
from game.debriefing import Debriefing
|
from game.debriefing import Debriefing
|
||||||
|
from game.server import EventStream
|
||||||
from qt_ui import liberation_install
|
from qt_ui import liberation_install
|
||||||
from qt_ui.dialogs import Dialog
|
from qt_ui.dialogs import Dialog
|
||||||
from qt_ui.models import GameModel
|
from qt_ui.models import GameModel
|
||||||
@ -50,6 +51,7 @@ class QLiberationWindow(QMainWindow):
|
|||||||
|
|
||||||
self.game = game
|
self.game = game
|
||||||
self.sim_controller = SimController(self.game)
|
self.sim_controller = SimController(self.game)
|
||||||
|
self.sim_controller.sim_update.connect(EventStream.put_nowait)
|
||||||
self.game_model = GameModel(game, self.sim_controller)
|
self.game_model = GameModel(game, self.sim_controller)
|
||||||
Dialog.set_game(self.game_model)
|
Dialog.set_game(self.game_model)
|
||||||
self.ato_panel = QAirTaskingOrderPanel(self.game_model)
|
self.ato_panel = QAirTaskingOrderPanel(self.game_model)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
|
const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
|
||||||
// Must be kept in sync with game.server.settings.ServerSettings.
|
// Must be kept in sync with game.server.settings.ServerSettings.
|
||||||
const HTTP_BACKEND = "http://[::1]:5000";
|
const HTTP_BACKEND = "http://[::1]:5000";
|
||||||
|
const WS_BACKEND = "ws://[::1]:5000/eventstream";
|
||||||
|
|
||||||
function getJson(endpoint) {
|
function getJson(endpoint) {
|
||||||
return fetch(`${HTTP_BACKEND}${endpoint}`).then((response) =>
|
return fetch(`${HTTP_BACKEND}${endpoint}`).then((response) =>
|
||||||
@ -343,6 +344,17 @@ L.control
|
|||||||
|
|
||||||
let game;
|
let game;
|
||||||
new QWebChannel(qt.webChannelTransport, function (channel) {
|
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;
|
game = channel.objects.game;
|
||||||
drawInitialMap();
|
drawInitialMap();
|
||||||
game.cleared.connect(clearAllLayers);
|
game.cleared.connect(clearAllLayers);
|
||||||
@ -362,6 +374,12 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
|
|||||||
game.selectedFlightChanged.connect(updateSelectedFlight);
|
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) {
|
function recenterMap(center) {
|
||||||
map.setView(center, 8, { animate: true, duration: 1 });
|
map.setView(center, 8, { animate: true, duration: 1 });
|
||||||
}
|
}
|
||||||
@ -825,17 +843,32 @@ class Waypoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Flight {
|
class Flight {
|
||||||
|
static registeredFlights = {};
|
||||||
|
|
||||||
constructor(flight) {
|
constructor(flight) {
|
||||||
this.flight = flight;
|
this.flight = flight;
|
||||||
|
this.id = flight.id;
|
||||||
this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this));
|
this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this));
|
||||||
this.aircraft = null;
|
this.aircraft = null;
|
||||||
this.path = null;
|
this.path = null;
|
||||||
this.markers = [];
|
this.markers = [];
|
||||||
this.commitBoundary = null;
|
this.commitBoundary = null;
|
||||||
this.flight.selectedChanged.connect(() => this.draw());
|
this.flight.selectedChanged.connect(() => this.draw());
|
||||||
this.flight.positionChanged.connect(() => this.drawAircraftLocation());
|
|
||||||
this.flight.flightPlanChanged.connect(() => this.drawFlightPlan());
|
this.flight.flightPlanChanged.connect(() => this.drawFlightPlan());
|
||||||
this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary());
|
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) {
|
shouldMark(waypoint) {
|
||||||
@ -876,12 +909,14 @@ class Flight {
|
|||||||
this.drawCommitBoundary();
|
this.drawCommitBoundary();
|
||||||
}
|
}
|
||||||
|
|
||||||
drawAircraftLocation() {
|
drawAircraftLocation(position = null) {
|
||||||
if (this.aircraft != null) {
|
if (this.aircraft != null) {
|
||||||
this.aircraft.removeFrom(aircraftLayer);
|
this.aircraft.removeFrom(aircraftLayer);
|
||||||
this.aircraft = null;
|
this.aircraft = null;
|
||||||
}
|
}
|
||||||
const position = this.flight.position;
|
if (position == null) {
|
||||||
|
position = this.flight.position;
|
||||||
|
}
|
||||||
if (position.length > 0) {
|
if (position.length > 0) {
|
||||||
this.aircraft = L.marker(position, {
|
this.aircraft = L.marker(position, {
|
||||||
icon: Icons.AirIcons.icon(
|
icon: Icons.AirIcons.icon(
|
||||||
@ -966,6 +1001,7 @@ class Flight {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawAircraft() {
|
function drawAircraft() {
|
||||||
|
Flight.clearRegisteredFlights();
|
||||||
aircraftLayer.clearLayers();
|
aircraftLayer.clearLayers();
|
||||||
blueFlightPlansLayer.clearLayers();
|
blueFlightPlansLayer.clearLayers();
|
||||||
redFlightPlansLayer.clearLayers();
|
redFlightPlansLayer.clearLayers();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user