Draw frozen combat on the map.

Very basic display. Draws the engagement footprint for air-to-air
combat, a line from the flight to the target for IP, and lines from SAMs
to their target for air defense.

https://github.com/dcs-liberation/dcs_liberation/issues/1680
This commit is contained in:
Dan Albert 2021-12-21 14:06:04 -08:00
parent fb10a8d28e
commit 515efd0598
11 changed files with 268 additions and 11 deletions

View File

@ -18,14 +18,16 @@ from game.ato.flightstate import (
from game.ato.starttype import StartType
from gen.flights.traveltime import TotEstimator
from .combat import CombatInitiator, FrozenCombat
from .gameupdatecallbacks import GameUpdateCallbacks
if TYPE_CHECKING:
from game import Game
class AircraftSimulation:
def __init__(self, game: Game) -> None:
def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None:
self.game = game
self.callbacks = callbacks
self.combats: list[FrozenCombat] = []
def begin_simulation(self) -> None:
@ -38,7 +40,7 @@ class AircraftSimulation:
# Finish updating all flights before checking for combat so that the new
# positions are used.
CombatInitiator(self.game, self.combats).update_active_combats()
CombatInitiator(self.game, self.combats, self.callbacks).update_active_combats()
# After updating all combat states, check for halts.
for flight in self.iter_flights():

View File

@ -12,6 +12,7 @@ from .atip import AtIp
from .defendingsam import DefendingSam
from .joinablecombat import JoinableCombat
from .samengagementzones import SamEngagementZones
from ..gameupdatecallbacks import GameUpdateCallbacks
if TYPE_CHECKING:
from game import Game
@ -20,9 +21,12 @@ if TYPE_CHECKING:
class CombatInitiator:
def __init__(self, game: Game, combats: list[FrozenCombat]) -> None:
def __init__(
self, game: Game, combats: list[FrozenCombat], callbacks: GameUpdateCallbacks
) -> None:
self.game = game
self.combats = combats
self.callbacks = callbacks
def update_active_combats(self) -> None:
blue_a2a = AircraftEngagementZones.from_ato(self.game.blue.ato)
@ -60,6 +64,7 @@ class CombatInitiator:
logging.info(f"{flight} is joining existing combat {joined}")
joined.join(flight)
own_a2a.remove_flight(flight)
self.callbacks.on_combat_changed(joined)
elif (combat := self.check_flight_for_new_combat(flight, a2a, sam)) is not None:
logging.info(f"Interrupting simulation because {combat.because()}")
combat.update_flight_states()
@ -70,6 +75,7 @@ class CombatInitiator:
a2a.update_for_combat(combat)
own_a2a.update_for_combat(combat)
self.combats.append(combat)
self.callbacks.on_add_combat(combat)
def check_flight_for_joined_combat(
self, flight: Flight

View File

@ -3,9 +3,10 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, TYPE_CHECKING
from typing import TYPE_CHECKING
from .gamelooptimer import GameLoopTimer
from .gameupdatecallbacks import GameUpdateCallbacks
from .missionsimulation import MissionSimulation, SimulationAlreadyCompletedError
from .simspeedsetting import SimSpeedSetting
@ -15,11 +16,11 @@ if TYPE_CHECKING:
class GameLoop:
def __init__(self, game: Game, on_complete: Callable[[], None]) -> None:
def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None:
self.game = game
self.on_complete = on_complete
self.callbacks = callbacks
self.timer = GameLoopTimer(self.tick)
self.sim = MissionSimulation(self.game)
self.sim = MissionSimulation(self.game, self.callbacks)
self.started = False
self.completed = False
@ -75,6 +76,6 @@ class GameLoop:
if self.completed:
self.pause()
logging.info(f"Simulation completed at {self.sim.time}")
self.on_complete()
self.callbacks.on_simulation_complete()
except SimulationAlreadyCompletedError:
logging.exception("Attempted to tick already completed sim")

View File

@ -0,0 +1,17 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from game.sim.combat import FrozenCombat
# Ought to be frozen but mypy can't handle that:
# https://github.com/python/mypy/issues/5485
@dataclass
class GameUpdateCallbacks:
on_simulation_complete: Callable[[], None]
on_add_combat: Callable[[FrozenCombat], None]
on_combat_changed: Callable[[FrozenCombat], None]

View File

@ -9,6 +9,7 @@ from game.debriefing import Debriefing
from game.missiongenerator import MissionGenerator
from game.unitmap import UnitMap
from .aircraftsimulation import AircraftSimulation
from .gameupdatecallbacks import GameUpdateCallbacks
from .missionresultsprocessor import MissionResultsProcessor
if TYPE_CHECKING:
@ -24,10 +25,10 @@ class SimulationAlreadyCompletedError(RuntimeError):
class MissionSimulation:
def __init__(self, game: Game) -> None:
def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None:
self.game = game
self.unit_map: Optional[UnitMap] = None
self.aircraft_simulation = AircraftSimulation(self.game)
self.aircraft_simulation = AircraftSimulation(self.game, callbacks)
self.completed = False
self.time = self.game.conditions.start_time

View File

@ -8,7 +8,9 @@ from typing import Callable, Optional, TYPE_CHECKING
from PySide2.QtCore import QObject, Signal
from game.polldebriefingfilethread import PollDebriefingFileThread
from game.sim.combat import FrozenCombat
from game.sim.gameloop import GameLoop
from game.sim.gameupdatecallbacks import GameUpdateCallbacks
from game.sim.simspeedsetting import SimSpeedSetting
from qt_ui.simupdatethread import SimUpdateThread
@ -21,6 +23,8 @@ class SimController(QObject):
sim_update = Signal()
sim_speed_reset = Signal(SimSpeedSetting)
simulation_complete = Signal()
on_add_combat = Signal(FrozenCombat)
on_combat_changed = Signal(FrozenCombat)
def __init__(self, game: Optional[Game]) -> None:
super().__init__()
@ -59,7 +63,14 @@ class SimController(QObject):
self.game_loop.pause()
self.game_loop = None
if game is not None:
self.game_loop = GameLoop(game, self.on_simulation_complete)
self.game_loop = GameLoop(
game,
GameUpdateCallbacks(
self.on_simulation_complete,
self.on_add_combat.emit,
self.on_combat_changed.emit,
),
)
self.started = False
def set_simulation_speed(self, simulation_speed: SimSpeedSetting) -> None:

View File

@ -0,0 +1,27 @@
from PySide2.QtCore import Property, QObject, Signal
from game.sim.combat.aircombat import AirCombat
from game.theater import ConflictTheater
from .leaflet import LeafletPoly
from .shapelyutil import ShapelyUtil
class AirCombatJs(QObject):
footprintChanged = Signal()
def __init__(self, combat: AirCombat, theater: ConflictTheater) -> None:
super().__init__()
self.combat = combat
self.theater = theater
self._footprint = self._make_footprint()
@Property(type=list, notify=footprintChanged)
def footprint(self) -> list[LeafletPoly]:
return self._footprint
def refresh(self) -> None:
self._footprint = self._make_footprint()
self.footprintChanged.emit()
def _make_footprint(self) -> list[LeafletPoly]:
return ShapelyUtil.polys_to_leaflet(self.combat.footprint, self.theater)

View File

@ -0,0 +1,28 @@
from PySide2.QtCore import Property, QObject, Signal
from game.sim.combat.atip import AtIp
from qt_ui.models import GameModel
from .flightjs import FlightJs
class IpCombatJs(QObject):
flightChanged = Signal()
def __init__(self, combat: AtIp, game_model: GameModel) -> None:
super().__init__()
assert game_model.game is not None
self.combat = combat
self.theater = game_model.game.theater
self._flight = FlightJs(
combat.flight,
selected=False,
theater=game_model.game.theater,
ato_model=game_model.ato_model_for(combat.flight.squadron.player),
)
@Property(FlightJs, notify=flightChanged)
def flight(self) -> FlightJs:
return self._flight
def refresh(self) -> None:
pass

View File

@ -9,22 +9,29 @@ from dcs import Point
from game import Game
from game.ato.airtaaskingorder import AirTaskingOrder
from game.profiling import logged_duration
from game.sim.combat import FrozenCombat
from game.sim.combat.aircombat import AirCombat
from game.sim.combat.atip import AtIp
from game.sim.combat.defendingsam import DefendingSam
from game.theater import (
ConflictTheater,
)
from qt_ui.models import GameModel
from qt_ui.simcontroller import SimController
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from .aircombatjs import AirCombatJs
from .controlpointjs import ControlPointJs
from .flightjs import FlightJs
from .frontlinejs import FrontLineJs
from .groundobjectjs import GroundObjectJs
from .holdzonesjs import HoldZonesJs
from .ipcombatjs import IpCombatJs
from .ipzonesjs import IpZonesJs
from .joinzonesjs import JoinZonesJs
from .leaflet import LeafletLatLon
from .mapzonesjs import MapZonesJs
from .navmeshjs import NavMeshJs
from .samcombatjs import SamCombatJs
from .supplyroutejs import SupplyRouteJs
from .threatzonecontainerjs import ThreatZoneContainerJs
from .threatzonesjs import ThreatZonesJs
@ -63,6 +70,9 @@ class MapModel(QObject):
ipZonesChanged = Signal()
joinZonesChanged = Signal()
holdZonesChanged = Signal()
airCombatsChanged = Signal()
samCombatsChanged = Signal()
ipCombatsChanged = Signal()
def __init__(self, game_model: GameModel, sim_controller: SimController) -> None:
super().__init__()
@ -83,6 +93,10 @@ class MapModel(QObject):
self._join_zones = JoinZonesJs.empty()
self._hold_zones = HoldZonesJs.empty()
self._selected_flight_index: Optional[Tuple[int, int]] = None
self._air_combats = []
self._sam_combats = []
self._ip_combats = []
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(
@ -92,6 +106,8 @@ class MapModel(QObject):
self.set_flight_selection
)
sim_controller.sim_update.connect(self.on_sim_update)
sim_controller.on_add_combat.connect(self.on_add_combat)
sim_controller.on_combat_changed.connect(self.on_combat_changed)
self.reset()
def clear(self) -> None:
@ -107,6 +123,9 @@ class MapModel(QObject):
self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = []
self._ip_zones = IpZonesJs.empty()
self._air_combats = []
self._sam_combats = []
self._ip_combats = []
self.cleared.emit()
def on_sim_update(self) -> None:
@ -180,6 +199,7 @@ class MapModel(QObject):
self.reset_navmeshes()
self.reset_map_zones()
self.reset_unculled_zones()
self.reset_combats()
def on_game_load(self, game: Optional[Game]) -> None:
if game is not None:
@ -367,6 +387,79 @@ class MapModel(QObject):
def holdZones(self) -> HoldZonesJs:
return self._hold_zones
def reset_combats(self) -> None:
self._air_combats = []
self._sam_combats = []
self._ip_combats = []
self.airCombatsChanged.emit()
self.samCombatsChanged.emit()
self.ipCombatsChanged.emit()
def on_add_combat(self, combat: FrozenCombat) -> None:
if isinstance(combat, AirCombat):
self.add_air_combat(combat)
elif isinstance(combat, DefendingSam):
self.add_sam_combat(combat)
elif isinstance(combat, AtIp):
self.add_ip_combat(combat)
else:
logging.error(f"Unhandled FrozenCombat type: {combat.__class__}")
def add_air_combat(self, combat: AirCombat) -> None:
self._air_combats.append(AirCombatJs(combat, self.game.theater))
self.airCombatsChanged.emit()
def add_sam_combat(self, combat: DefendingSam) -> None:
self._sam_combats.append(SamCombatJs(combat, self.game_model))
self.samCombatsChanged.emit()
def add_ip_combat(self, combat: AtIp) -> None:
self._ip_combats.append(IpCombatJs(combat, self.game_model))
self.ipCombatsChanged.emit()
def on_combat_changed(self, combat: FrozenCombat) -> None:
if isinstance(combat, AirCombat):
self.refresh_air_combat(combat)
elif isinstance(combat, DefendingSam):
self.refresh_sam_combat(combat)
elif isinstance(combat, AtIp):
self.refresh_ip_combat(combat)
else:
logging.error(f"Unhandled FrozenCombat type: {combat.__class__}")
def refresh_air_combat(self, combat: AirCombat) -> None:
for js in self._air_combats:
if js.combat == combat:
js.refresh()
return
logging.error(f"Could not find existing combat model to update for {combat}")
def refresh_sam_combat(self, combat: DefendingSam) -> None:
for js in self._sam_combats:
if js.combat == combat:
js.refresh()
return
logging.error(f"Could not find existing combat model to update for {combat}")
def refresh_ip_combat(self, combat: AtIp) -> None:
for js in self._ip_combats:
if js.combat == combat:
js.refresh()
return
logging.error(f"Could not find existing combat model to update for {combat}")
@Property(list, notify=airCombatsChanged)
def airCombats(self) -> list[AirCombatJs]:
return self._air_combats
@Property(list, notify=samCombatsChanged)
def samCombats(self) -> list[SamCombatJs]:
return self._sam_combats
@Property(list, notify=ipCombatsChanged)
def ipCombats(self) -> list[IpCombatJs]:
return self._ip_combats
@property
def game(self) -> Game:
if self.game_model.game is None:

View File

@ -0,0 +1,37 @@
from PySide2.QtCore import Property, QObject, Signal
from game.sim.combat.defendingsam import DefendingSam
from qt_ui.models import GameModel
from .flightjs import FlightJs
from .groundobjectjs import GroundObjectJs
class SamCombatJs(QObject):
flightChanged = Signal()
airDefensesChanged = Signal()
def __init__(self, combat: DefendingSam, game_model: GameModel) -> None:
super().__init__()
assert game_model.game is not None
self.combat = combat
self.theater = game_model.game.theater
self._flight = FlightJs(
combat.flight,
selected=False,
theater=game_model.game.theater,
ato_model=game_model.ato_model_for(combat.flight.squadron.player),
)
self._air_defenses = [
GroundObjectJs(tgo, game_model.game) for tgo in self.combat.air_defenses
]
@Property(FlightJs, notify=flightChanged)
def flight(self) -> FlightJs:
return self._flight
@Property(list, notify=airDefensesChanged)
def airDefenses(self) -> list[GroundObjectJs]:
return self._air_defenses
def refresh(self) -> None:
pass

View File

@ -206,6 +206,7 @@ const supplyRoutesLayer = L.layerGroup().addTo(map);
const frontLinesLayer = L.layerGroup().addTo(map);
const redSamThreatLayer = L.layerGroup().addTo(map);
const blueFlightPlansLayer = L.layerGroup().addTo(map);
const combatLayer = L.layerGroup().addTo(map);
// Added to map by the user via layer controls.
const blueSamThreatLayer = L.layerGroup();
@ -287,6 +288,7 @@ L.control
"Units and locations": {
"Control points": controlPointsLayer,
Aircraft: aircraftLayer,
"Active combat": combatLayer,
"Air defenses": airDefensesLayer,
Factories: factoriesLayer,
Ships: shipsLayer,
@ -351,6 +353,9 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
game.ipZonesChanged.connect(drawIpZones);
game.joinZonesChanged.connect(drawJoinZones);
game.holdZonesChanged.connect(drawHoldZones);
game.airCombatsChanged.connect(drawCombat);
game.samCombatsChanged.connect(drawCombat);
game.ipCombatsChanged.connect(drawCombat);
});
function recenterMap(center) {
@ -1226,6 +1231,34 @@ function drawHoldZones() {
}
}
function drawCombat() {
combatLayer.clearLayers();
for (const airCombat of game.airCombats) {
L.polygon(airCombat.footprint, {
color: Colors.Red,
interactive: false,
fillOpacity: 0.2,
}).addTo(combatLayer);
}
for (const samCombat of game.samCombats) {
for (const airDefense of samCombat.airDefenses) {
L.polyline([samCombat.flight.position, airDefense.position], {
color: Colors.Red,
interactive: false,
}).addTo(combatLayer);
}
}
for (const ipCombat of game.ipCombats) {
L.polyline([ipCombat.flight.position, ipCombat.flight.target], {
color: Colors.Red,
interactive: false,
}).addTo(combatLayer);
}
}
function drawInitialMap() {
recenterMap(game.mapCenter);
drawControlPoints();
@ -1240,6 +1273,7 @@ function drawInitialMap() {
drawIpZones();
drawJoinZones();
drawHoldZones();
drawCombat();
}
function clearAllLayers() {