mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Add threat zone modeling.
Creates threat zones around airfields and non-trivial air defenses (it's not worth dodging anything with a threat range under 3nm). These threat zones can be used to aid mission planning and waypoint placement. https://github.com/Khopa/dcs_liberation/issues/292
This commit is contained in:
parent
e46262b021
commit
86558bdef6
35
game/game.py
35
game/game.py
@ -4,7 +4,7 @@ import random
|
|||||||
import sys
|
import sys
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from dcs.action import Coalition
|
from dcs.action import Coalition
|
||||||
from dcs.mapping import Point
|
from dcs.mapping import Point
|
||||||
@ -31,6 +31,7 @@ from .infos.information import Information
|
|||||||
from .procurement import ProcurementAi
|
from .procurement import ProcurementAi
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
from .theater import ConflictTheater, ControlPoint
|
from .theater import ConflictTheater, ControlPoint
|
||||||
|
from .threatzones import ThreatZones
|
||||||
from .unitmap import UnitMap
|
from .unitmap import UnitMap
|
||||||
from .weather import Conditions, TimeOfDay
|
from .weather import Conditions, TimeOfDay
|
||||||
|
|
||||||
@ -113,6 +114,9 @@ class Game:
|
|||||||
|
|
||||||
self.sanitize_sides()
|
self.sanitize_sides()
|
||||||
|
|
||||||
|
self.blue_threat_zone: ThreatZones
|
||||||
|
self.red_threat_zone: ThreatZones
|
||||||
|
|
||||||
self.on_load()
|
self.on_load()
|
||||||
|
|
||||||
# Turn 0 procurement. We don't actually have any missions to plan, but
|
# Turn 0 procurement. We don't actually have any missions to plan, but
|
||||||
@ -126,6 +130,19 @@ class Game:
|
|||||||
|
|
||||||
self.plan_procurement(blue_planner, red_planner)
|
self.plan_procurement(blue_planner, red_planner)
|
||||||
|
|
||||||
|
def __getstate__(self) -> Dict[str, Any]:
|
||||||
|
state = self.__dict__.copy()
|
||||||
|
# Avoid persisting any volatile types that can be deterministically
|
||||||
|
# recomputed on load for the sake of save compatibility.
|
||||||
|
del state["blue_threat_zone"]
|
||||||
|
del state["red_threat_zone"]
|
||||||
|
return state
|
||||||
|
|
||||||
|
def __setstate__(self, state: Dict[str, Any]) -> None:
|
||||||
|
self.__dict__.update(state)
|
||||||
|
# Regenerate any state that was not persisted.
|
||||||
|
self.on_load()
|
||||||
|
|
||||||
def generate_conditions(self) -> Conditions:
|
def generate_conditions(self) -> Conditions:
|
||||||
return Conditions.generate(self.theater, self.date,
|
return Conditions.generate(self.theater, self.date,
|
||||||
self.current_turn_time_of_day, self.settings)
|
self.current_turn_time_of_day, self.settings)
|
||||||
@ -151,6 +168,11 @@ class Game:
|
|||||||
def enemy_faction(self) -> Faction:
|
def enemy_faction(self) -> Faction:
|
||||||
return db.FACTIONS[self.enemy_name]
|
return db.FACTIONS[self.enemy_name]
|
||||||
|
|
||||||
|
def faction_for(self, player: bool) -> Faction:
|
||||||
|
if player:
|
||||||
|
return self.player_faction
|
||||||
|
return self.enemy_faction
|
||||||
|
|
||||||
def _roll(self, prob, mult):
|
def _roll(self, prob, mult):
|
||||||
if self.settings.version == "dev":
|
if self.settings.version == "dev":
|
||||||
# always generate all events for dev
|
# always generate all events for dev
|
||||||
@ -227,6 +249,7 @@ class Game:
|
|||||||
LuaPluginManager.load_settings(self.settings)
|
LuaPluginManager.load_settings(self.settings)
|
||||||
ObjectiveDistanceCache.set_theater(self.theater)
|
ObjectiveDistanceCache.set_theater(self.theater)
|
||||||
self.compute_conflicts_position()
|
self.compute_conflicts_position()
|
||||||
|
self.compute_threat_zones()
|
||||||
|
|
||||||
def pass_turn(self, no_action: bool = False) -> None:
|
def pass_turn(self, no_action: bool = False) -> None:
|
||||||
logging.info("Pass turn")
|
logging.info("Pass turn")
|
||||||
@ -290,6 +313,7 @@ class Game:
|
|||||||
|
|
||||||
# Plan flights & combat for next turn
|
# Plan flights & combat for next turn
|
||||||
self.compute_conflicts_position()
|
self.compute_conflicts_position()
|
||||||
|
self.compute_threat_zones()
|
||||||
self.ground_planners = {}
|
self.ground_planners = {}
|
||||||
self.blue_ato.clear()
|
self.blue_ato.clear()
|
||||||
self.red_ato.clear()
|
self.red_ato.clear()
|
||||||
@ -353,6 +377,15 @@ class Game:
|
|||||||
self.current_group_id += 1
|
self.current_group_id += 1
|
||||||
return self.current_group_id
|
return self.current_group_id
|
||||||
|
|
||||||
|
def compute_threat_zones(self) -> None:
|
||||||
|
self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
|
||||||
|
self.red_threat_zone = ThreatZones.for_faction(self, player=False)
|
||||||
|
|
||||||
|
def threat_zone_for(self, player: bool) -> ThreatZones:
|
||||||
|
if player:
|
||||||
|
return self.blue_threat_zone
|
||||||
|
return self.red_threat_zone
|
||||||
|
|
||||||
def compute_conflicts_position(self):
|
def compute_conflicts_position(self):
|
||||||
"""
|
"""
|
||||||
Compute the current conflict center position(s), mainly used for culling calculation
|
Compute the current conflict center position(s), mainly used for culling calculation
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
|
import logging
|
||||||
from typing import Iterator, List, TYPE_CHECKING
|
from typing import Iterator, List, TYPE_CHECKING
|
||||||
|
|
||||||
from dcs.mapping import Point
|
from dcs.mapping import Point
|
||||||
@ -9,6 +10,7 @@ from dcs.unitgroup import Group
|
|||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
from ..data.radar_db import UNITS_WITH_RADAR
|
from ..data.radar_db import UNITS_WITH_RADAR
|
||||||
|
from ..utils import Distance, meters
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .controlpoint import ControlPoint
|
from .controlpoint import ControlPoint
|
||||||
@ -156,6 +158,23 @@ class TheaterGroundObject(MissionTarget):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def threat_range(self) -> Distance:
|
||||||
|
threat_range = meters(0)
|
||||||
|
for group in self.groups:
|
||||||
|
for u in group.units:
|
||||||
|
unit = db.unit_type_from_name(u.type)
|
||||||
|
if unit is None:
|
||||||
|
logging.error(f"Unknown unit type {u.type}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Some units in pydcs have threat_range defined, but explicitly
|
||||||
|
# set to None.
|
||||||
|
unit_threat_range = getattr(unit, "threat_range", None)
|
||||||
|
if unit_threat_range is not None:
|
||||||
|
threat_range = max(threat_range, meters(unit_threat_range))
|
||||||
|
return threat_range
|
||||||
|
|
||||||
|
|
||||||
class BuildingGroundObject(TheaterGroundObject):
|
class BuildingGroundObject(TheaterGroundObject):
|
||||||
def __init__(self, name: str, category: str, group_id: int, object_id: int,
|
def __init__(self, name: str, category: str, group_id: int, object_id: int,
|
||||||
|
|||||||
92
game/threatzones.py
Normal file
92
game/threatzones.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import singledispatchmethod
|
||||||
|
from typing import TYPE_CHECKING, Union
|
||||||
|
|
||||||
|
from dcs.mapping import Point as DcsPoint
|
||||||
|
from shapely.geometry import (
|
||||||
|
LineString,
|
||||||
|
MultiPolygon,
|
||||||
|
Point as ShapelyPoint,
|
||||||
|
Polygon,
|
||||||
|
)
|
||||||
|
from shapely.geometry.base import BaseGeometry
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
|
||||||
|
from game.utils import nautical_miles
|
||||||
|
from gen.flights.flight import Flight
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from game import Game
|
||||||
|
|
||||||
|
|
||||||
|
ThreatPoly = Union[MultiPolygon, Polygon]
|
||||||
|
|
||||||
|
|
||||||
|
class ThreatZones:
|
||||||
|
def __init__(self, airbases: ThreatPoly, air_defenses: ThreatPoly) -> None:
|
||||||
|
self.airbases = airbases
|
||||||
|
self.air_defenses = air_defenses
|
||||||
|
self.all = unary_union([airbases, air_defenses])
|
||||||
|
|
||||||
|
@singledispatchmethod
|
||||||
|
def threatened_by_aircraft(self, target) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@threatened_by_aircraft.register
|
||||||
|
def _threatened_by_aircraft_geom(self, position: BaseGeometry) -> bool:
|
||||||
|
return self.airbases.intersects(position)
|
||||||
|
|
||||||
|
@threatened_by_aircraft.register
|
||||||
|
def _threatened_by_aircraft_flight(self, flight: Flight) -> bool:
|
||||||
|
return self.threatened_by_aircraft(LineString((
|
||||||
|
self.dcs_to_shapely_point(p.position) for p in flight.points
|
||||||
|
)))
|
||||||
|
|
||||||
|
@singledispatchmethod
|
||||||
|
def threatened_by_air_defense(self, target) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@threatened_by_air_defense.register
|
||||||
|
def _threatened_by_air_defense_geom(self, position: BaseGeometry) -> bool:
|
||||||
|
return self.air_defenses.intersects(position)
|
||||||
|
|
||||||
|
@threatened_by_air_defense.register
|
||||||
|
def _threatened_by_air_defense_flight(self, flight: Flight) -> bool:
|
||||||
|
return self.threatened_by_air_defense(LineString((
|
||||||
|
self.dcs_to_shapely_point(p.position) for p in flight.points
|
||||||
|
)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_faction(cls, game: Game, player: bool) -> ThreatZones:
|
||||||
|
opposing_doctrine = game.faction_for(not player).doctrine
|
||||||
|
|
||||||
|
airbases = []
|
||||||
|
air_defenses = []
|
||||||
|
for control_point in game.theater.controlpoints:
|
||||||
|
if control_point.captured != player:
|
||||||
|
continue
|
||||||
|
if control_point.runway_is_operational():
|
||||||
|
point = ShapelyPoint(control_point.position.x,
|
||||||
|
control_point.position.y)
|
||||||
|
cap_threat_range = (opposing_doctrine.cap_max_distance_from_cp +
|
||||||
|
opposing_doctrine.cap_engagement_range)
|
||||||
|
airbases.append(point.buffer(cap_threat_range.meters))
|
||||||
|
|
||||||
|
for tgo in control_point.ground_objects:
|
||||||
|
threat_range = tgo.threat_range
|
||||||
|
# Any system with a shorter range than this is not worth even
|
||||||
|
# avoiding.
|
||||||
|
if threat_range > nautical_miles(3):
|
||||||
|
point = ShapelyPoint(tgo.position.x, tgo.position.y)
|
||||||
|
threat_zone = point.buffer(threat_range.meters)
|
||||||
|
air_defenses.append(threat_zone)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
airbases=unary_union(airbases),
|
||||||
|
air_defenses=unary_union(air_defenses)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dcs_to_shapely_point(point: DcsPoint) -> ShapelyPoint:
|
||||||
|
return ShapelyPoint(point.x, point.y)
|
||||||
@ -59,6 +59,8 @@ class DisplayOptions:
|
|||||||
culling = DisplayRule("Display Culling Zones", False)
|
culling = DisplayRule("Display Culling Zones", False)
|
||||||
flight_paths = FlightPathOptions()
|
flight_paths = FlightPathOptions()
|
||||||
actual_frontline_pos = DisplayRule("Display Actual Frontline Location", False)
|
actual_frontline_pos = DisplayRule("Display Actual Frontline Location", False)
|
||||||
|
blue_threat_zone = DisplayRule("Display Blue Threat Zones", False)
|
||||||
|
red_threat_zone = DisplayRule("Display Red Threat Zones", False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]:
|
def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]:
|
||||||
|
|||||||
@ -26,6 +26,10 @@ from PySide2.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from dcs import Point
|
from dcs import Point
|
||||||
from dcs.mapping import point_from_heading
|
from dcs.mapping import point_from_heading
|
||||||
|
from shapely.geometry import (
|
||||||
|
MultiPolygon,
|
||||||
|
Polygon,
|
||||||
|
)
|
||||||
|
|
||||||
import qt_ui.uiconstants as CONST
|
import qt_ui.uiconstants as CONST
|
||||||
from game import Game, db
|
from game import Game, db
|
||||||
@ -275,6 +279,35 @@ class QLiberationMap(QGraphicsView):
|
|||||||
radius = distance_point[0] - transformed[0]
|
radius = distance_point[0] - transformed[0]
|
||||||
scene.addEllipse(transformed[0]-radius, transformed[1]-radius, 2*radius, 2*radius, CONST.COLORS["transparent"], CONST.COLORS["light_green_transparent"])
|
scene.addEllipse(transformed[0]-radius, transformed[1]-radius, 2*radius, 2*radius, CONST.COLORS["transparent"], CONST.COLORS["light_green_transparent"])
|
||||||
|
|
||||||
|
def draw_shapely_poly(self, scene: QGraphicsScene, poly: Polygon, pen: QPen,
|
||||||
|
brush: QBrush) -> None:
|
||||||
|
if poly.is_empty:
|
||||||
|
return
|
||||||
|
points = []
|
||||||
|
for x, y in poly.exterior.coords:
|
||||||
|
x, y = self._transform_point(Point(x, y))
|
||||||
|
points.append(QPointF(x, y))
|
||||||
|
scene.addPolygon(QPolygonF(points), pen, brush)
|
||||||
|
|
||||||
|
def draw_threat_zone(self, scene: QGraphicsScene, poly: Polygon,
|
||||||
|
player: bool) -> None:
|
||||||
|
if player:
|
||||||
|
brush = QColor(0, 132, 255, 100)
|
||||||
|
else:
|
||||||
|
brush = QColor(227, 32, 0, 100)
|
||||||
|
self.draw_shapely_poly(scene, poly, CONST.COLORS["transparent"], brush)
|
||||||
|
|
||||||
|
def display_threat_zones(self, scene: QGraphicsScene,
|
||||||
|
player: bool) -> None:
|
||||||
|
"""Draws the threat zones on the map."""
|
||||||
|
threat_poly = self.game.threat_zone_for(player).all
|
||||||
|
if isinstance(threat_poly, MultiPolygon):
|
||||||
|
polys = threat_poly.geoms
|
||||||
|
else:
|
||||||
|
polys = [threat_poly]
|
||||||
|
for poly in polys:
|
||||||
|
self.draw_threat_zone(scene, poly, player)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_display_ground_objects_at(cp: ControlPoint) -> bool:
|
def should_display_ground_objects_at(cp: ControlPoint) -> bool:
|
||||||
return ((DisplayOptions.sam_ranges and cp.captured) or
|
return ((DisplayOptions.sam_ranges and cp.captured) or
|
||||||
@ -331,6 +364,12 @@ class QLiberationMap(QGraphicsView):
|
|||||||
if DisplayOptions.culling and self.game.settings.perf_culling:
|
if DisplayOptions.culling and self.game.settings.perf_culling:
|
||||||
self.display_culling(scene)
|
self.display_culling(scene)
|
||||||
|
|
||||||
|
if DisplayOptions.blue_threat_zone:
|
||||||
|
self.display_threat_zones(scene, player=True)
|
||||||
|
|
||||||
|
if DisplayOptions.red_threat_zone:
|
||||||
|
self.display_threat_zones(scene, player=False)
|
||||||
|
|
||||||
for cp in self.game.theater.controlpoints:
|
for cp in self.game.theater.controlpoints:
|
||||||
|
|
||||||
pos = self._transform_point(cp.position)
|
pos = self._transform_point(cp.position)
|
||||||
|
|||||||
@ -257,8 +257,6 @@ class QLiberationWindow(QMainWindow):
|
|||||||
|
|
||||||
def setGame(self, game: Optional[Game]):
|
def setGame(self, game: Optional[Game]):
|
||||||
try:
|
try:
|
||||||
if game is not None:
|
|
||||||
game.on_load()
|
|
||||||
self.game = game
|
self.game = game
|
||||||
if self.info_panel is not None:
|
if self.info_panel is not None:
|
||||||
self.info_panel.setGame(game)
|
self.info_panel.setGame(game)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user