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

View File

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

View File

@ -3,9 +3,10 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Callable, TYPE_CHECKING from typing import TYPE_CHECKING
from .gamelooptimer import GameLoopTimer from .gamelooptimer import GameLoopTimer
from .gameupdatecallbacks import GameUpdateCallbacks
from .missionsimulation import MissionSimulation, SimulationAlreadyCompletedError from .missionsimulation import MissionSimulation, SimulationAlreadyCompletedError
from .simspeedsetting import SimSpeedSetting from .simspeedsetting import SimSpeedSetting
@ -15,11 +16,11 @@ if TYPE_CHECKING:
class GameLoop: class GameLoop:
def __init__(self, game: Game, on_complete: Callable[[], None]) -> None: def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None:
self.game = game self.game = game
self.on_complete = on_complete self.callbacks = callbacks
self.timer = GameLoopTimer(self.tick) self.timer = GameLoopTimer(self.tick)
self.sim = MissionSimulation(self.game) self.sim = MissionSimulation(self.game, self.callbacks)
self.started = False self.started = False
self.completed = False self.completed = False
@ -75,6 +76,6 @@ class GameLoop:
if self.completed: if self.completed:
self.pause() self.pause()
logging.info(f"Simulation completed at {self.sim.time}") logging.info(f"Simulation completed at {self.sim.time}")
self.on_complete() self.callbacks.on_simulation_complete()
except SimulationAlreadyCompletedError: except SimulationAlreadyCompletedError:
logging.exception("Attempted to tick already completed sim") 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.missiongenerator import MissionGenerator
from game.unitmap import UnitMap from game.unitmap import UnitMap
from .aircraftsimulation import AircraftSimulation from .aircraftsimulation import AircraftSimulation
from .gameupdatecallbacks import GameUpdateCallbacks
from .missionresultsprocessor import MissionResultsProcessor from .missionresultsprocessor import MissionResultsProcessor
if TYPE_CHECKING: if TYPE_CHECKING:
@ -24,10 +25,10 @@ class SimulationAlreadyCompletedError(RuntimeError):
class MissionSimulation: class MissionSimulation:
def __init__(self, game: Game) -> None: def __init__(self, game: Game, callbacks: GameUpdateCallbacks) -> None:
self.game = game self.game = game
self.unit_map: Optional[UnitMap] = None self.unit_map: Optional[UnitMap] = None
self.aircraft_simulation = AircraftSimulation(self.game) self.aircraft_simulation = AircraftSimulation(self.game, callbacks)
self.completed = False self.completed = False
self.time = self.game.conditions.start_time 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 PySide2.QtCore import QObject, Signal
from game.polldebriefingfilethread import PollDebriefingFileThread from game.polldebriefingfilethread import PollDebriefingFileThread
from game.sim.combat import FrozenCombat
from game.sim.gameloop import GameLoop from game.sim.gameloop import GameLoop
from game.sim.gameupdatecallbacks import GameUpdateCallbacks
from game.sim.simspeedsetting import SimSpeedSetting from game.sim.simspeedsetting import SimSpeedSetting
from qt_ui.simupdatethread import SimUpdateThread from qt_ui.simupdatethread import SimUpdateThread
@ -21,6 +23,8 @@ class SimController(QObject):
sim_update = Signal() sim_update = Signal()
sim_speed_reset = Signal(SimSpeedSetting) sim_speed_reset = Signal(SimSpeedSetting)
simulation_complete = Signal() simulation_complete = Signal()
on_add_combat = Signal(FrozenCombat)
on_combat_changed = Signal(FrozenCombat)
def __init__(self, game: Optional[Game]) -> None: def __init__(self, game: Optional[Game]) -> None:
super().__init__() super().__init__()
@ -59,7 +63,14 @@ class SimController(QObject):
self.game_loop.pause() self.game_loop.pause()
self.game_loop = None self.game_loop = None
if game is not 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 self.started = False
def set_simulation_speed(self, simulation_speed: SimSpeedSetting) -> None: 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 import Game
from game.ato.airtaaskingorder import AirTaskingOrder from game.ato.airtaaskingorder import AirTaskingOrder
from game.profiling import logged_duration 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 ( from game.theater import (
ConflictTheater, ConflictTheater,
) )
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.simcontroller import SimController from qt_ui.simcontroller import SimController
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from .aircombatjs import AirCombatJs
from .controlpointjs import ControlPointJs from .controlpointjs import ControlPointJs
from .flightjs import FlightJs from .flightjs import FlightJs
from .frontlinejs import FrontLineJs from .frontlinejs import FrontLineJs
from .groundobjectjs import GroundObjectJs from .groundobjectjs import GroundObjectJs
from .holdzonesjs import HoldZonesJs from .holdzonesjs import HoldZonesJs
from .ipcombatjs import IpCombatJs
from .ipzonesjs import IpZonesJs from .ipzonesjs import IpZonesJs
from .joinzonesjs import JoinZonesJs from .joinzonesjs import JoinZonesJs
from .leaflet import LeafletLatLon from .leaflet import LeafletLatLon
from .mapzonesjs import MapZonesJs from .mapzonesjs import MapZonesJs
from .navmeshjs import NavMeshJs from .navmeshjs import NavMeshJs
from .samcombatjs import SamCombatJs
from .supplyroutejs import SupplyRouteJs from .supplyroutejs import SupplyRouteJs
from .threatzonecontainerjs import ThreatZoneContainerJs from .threatzonecontainerjs import ThreatZoneContainerJs
from .threatzonesjs import ThreatZonesJs from .threatzonesjs import ThreatZonesJs
@ -63,6 +70,9 @@ class MapModel(QObject):
ipZonesChanged = Signal() ipZonesChanged = Signal()
joinZonesChanged = Signal() joinZonesChanged = Signal()
holdZonesChanged = Signal() holdZonesChanged = Signal()
airCombatsChanged = Signal()
samCombatsChanged = Signal()
ipCombatsChanged = Signal()
def __init__(self, game_model: GameModel, sim_controller: SimController) -> None: def __init__(self, game_model: GameModel, sim_controller: SimController) -> None:
super().__init__() super().__init__()
@ -83,6 +93,10 @@ class MapModel(QObject):
self._join_zones = JoinZonesJs.empty() self._join_zones = JoinZonesJs.empty()
self._hold_zones = HoldZonesJs.empty() self._hold_zones = HoldZonesJs.empty()
self._selected_flight_index: Optional[Tuple[int, int]] = None 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().game_loaded.connect(self.on_game_load)
GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos)
GameUpdateSignal.get_instance().package_selection_changed.connect( GameUpdateSignal.get_instance().package_selection_changed.connect(
@ -92,6 +106,8 @@ class MapModel(QObject):
self.set_flight_selection self.set_flight_selection
) )
sim_controller.sim_update.connect(self.on_sim_update) 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() self.reset()
def clear(self) -> None: def clear(self) -> None:
@ -107,6 +123,9 @@ class MapModel(QObject):
self._map_zones = MapZonesJs([], [], []) self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = [] self._unculled_zones = []
self._ip_zones = IpZonesJs.empty() self._ip_zones = IpZonesJs.empty()
self._air_combats = []
self._sam_combats = []
self._ip_combats = []
self.cleared.emit() self.cleared.emit()
def on_sim_update(self) -> None: def on_sim_update(self) -> None:
@ -180,6 +199,7 @@ class MapModel(QObject):
self.reset_navmeshes() self.reset_navmeshes()
self.reset_map_zones() self.reset_map_zones()
self.reset_unculled_zones() self.reset_unculled_zones()
self.reset_combats()
def on_game_load(self, game: Optional[Game]) -> None: def on_game_load(self, game: Optional[Game]) -> None:
if game is not None: if game is not None:
@ -367,6 +387,79 @@ class MapModel(QObject):
def holdZones(self) -> HoldZonesJs: def holdZones(self) -> HoldZonesJs:
return self._hold_zones 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 @property
def game(self) -> Game: def game(self) -> Game:
if self.game_model.game is None: 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 frontLinesLayer = L.layerGroup().addTo(map);
const redSamThreatLayer = L.layerGroup().addTo(map); const redSamThreatLayer = L.layerGroup().addTo(map);
const blueFlightPlansLayer = 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. // Added to map by the user via layer controls.
const blueSamThreatLayer = L.layerGroup(); const blueSamThreatLayer = L.layerGroup();
@ -287,6 +288,7 @@ L.control
"Units and locations": { "Units and locations": {
"Control points": controlPointsLayer, "Control points": controlPointsLayer,
Aircraft: aircraftLayer, Aircraft: aircraftLayer,
"Active combat": combatLayer,
"Air defenses": airDefensesLayer, "Air defenses": airDefensesLayer,
Factories: factoriesLayer, Factories: factoriesLayer,
Ships: shipsLayer, Ships: shipsLayer,
@ -351,6 +353,9 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
game.ipZonesChanged.connect(drawIpZones); game.ipZonesChanged.connect(drawIpZones);
game.joinZonesChanged.connect(drawJoinZones); game.joinZonesChanged.connect(drawJoinZones);
game.holdZonesChanged.connect(drawHoldZones); game.holdZonesChanged.connect(drawHoldZones);
game.airCombatsChanged.connect(drawCombat);
game.samCombatsChanged.connect(drawCombat);
game.ipCombatsChanged.connect(drawCombat);
}); });
function recenterMap(center) { 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() { function drawInitialMap() {
recenterMap(game.mapCenter); recenterMap(game.mapCenter);
drawControlPoints(); drawControlPoints();
@ -1240,6 +1273,7 @@ function drawInitialMap() {
drawIpZones(); drawIpZones();
drawJoinZones(); drawJoinZones();
drawHoldZones(); drawHoldZones();
drawCombat();
} }
function clearAllLayers() { function clearAllLayers() {