From 86558bdef6143bb4ca414f769b446c0a57dd4c16 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 21 Dec 2020 13:45:12 -0800 Subject: [PATCH] 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 --- game/game.py | 35 ++++++++++- game/theater/theatergroundobject.py | 19 ++++++ game/threatzones.py | 92 +++++++++++++++++++++++++++++ qt_ui/displayoptions.py | 2 + qt_ui/widgets/map/QLiberationMap.py | 39 ++++++++++++ qt_ui/windows/QLiberationWindow.py | 2 - 6 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 game/threatzones.py diff --git a/game/game.py b/game/game.py index 4ec1f980..0bba2954 100644 --- a/game/game.py +++ b/game/game.py @@ -4,7 +4,7 @@ import random import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import Dict, List +from typing import Any, Dict, List from dcs.action import Coalition from dcs.mapping import Point @@ -31,6 +31,7 @@ from .infos.information import Information from .procurement import ProcurementAi from .settings import Settings from .theater import ConflictTheater, ControlPoint +from .threatzones import ThreatZones from .unitmap import UnitMap from .weather import Conditions, TimeOfDay @@ -113,6 +114,9 @@ class Game: self.sanitize_sides() + self.blue_threat_zone: ThreatZones + self.red_threat_zone: ThreatZones + self.on_load() # 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) + 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: return Conditions.generate(self.theater, self.date, self.current_turn_time_of_day, self.settings) @@ -151,6 +168,11 @@ class Game: def enemy_faction(self) -> Faction: 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): if self.settings.version == "dev": # always generate all events for dev @@ -227,6 +249,7 @@ class Game: LuaPluginManager.load_settings(self.settings) ObjectiveDistanceCache.set_theater(self.theater) self.compute_conflicts_position() + self.compute_threat_zones() def pass_turn(self, no_action: bool = False) -> None: logging.info("Pass turn") @@ -290,6 +313,7 @@ class Game: # Plan flights & combat for next turn self.compute_conflicts_position() + self.compute_threat_zones() self.ground_planners = {} self.blue_ato.clear() self.red_ato.clear() @@ -353,6 +377,15 @@ class Game: self.current_group_id += 1 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): """ Compute the current conflict center position(s), mainly used for culling calculation diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index df588ec8..64b5e19e 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -1,6 +1,7 @@ from __future__ import annotations import itertools +import logging from typing import Iterator, List, TYPE_CHECKING from dcs.mapping import Point @@ -9,6 +10,7 @@ from dcs.unitgroup import Group from .. import db from ..data.radar_db import UNITS_WITH_RADAR +from ..utils import Distance, meters if TYPE_CHECKING: from .controlpoint import ControlPoint @@ -156,6 +158,23 @@ class TheaterGroundObject(MissionTarget): return True 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): def __init__(self, name: str, category: str, group_id: int, object_id: int, diff --git a/game/threatzones.py b/game/threatzones.py new file mode 100644 index 00000000..cdaea856 --- /dev/null +++ b/game/threatzones.py @@ -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) \ No newline at end of file diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py index 55dcb10b..4c6ab126 100644 --- a/qt_ui/displayoptions.py +++ b/qt_ui/displayoptions.py @@ -59,6 +59,8 @@ class DisplayOptions: culling = DisplayRule("Display Culling Zones", False) flight_paths = FlightPathOptions() 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 def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index a21a4058..41e2d064 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -26,6 +26,10 @@ from PySide2.QtWidgets import ( ) from dcs import Point from dcs.mapping import point_from_heading +from shapely.geometry import ( + MultiPolygon, + Polygon, +) import qt_ui.uiconstants as CONST from game import Game, db @@ -275,6 +279,35 @@ class QLiberationMap(QGraphicsView): 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"]) + 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 def should_display_ground_objects_at(cp: ControlPoint) -> bool: return ((DisplayOptions.sam_ranges and cp.captured) or @@ -331,6 +364,12 @@ class QLiberationMap(QGraphicsView): if DisplayOptions.culling and self.game.settings.perf_culling: 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: pos = self._transform_point(cp.position) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index fbce72c1..14a2925d 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -257,8 +257,6 @@ class QLiberationWindow(QMainWindow): def setGame(self, game: Optional[Game]): try: - if game is not None: - game.on_load() self.game = game if self.info_panel is not None: self.info_panel.setGame(game)