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:
Dan Albert 2020-12-21 13:45:12 -08:00
parent e46262b021
commit 86558bdef6
6 changed files with 186 additions and 3 deletions

View File

@ -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

View File

@ -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,

92
game/threatzones.py Normal file
View 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)

View File

@ -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]]:

View File

@ -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)

View File

@ -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)