[1/3] Rework IP placement.

Test cases:

1. Target is not threatened.

   The IP should be placed on a direct heading from the origin to the
   target at the max ingress distance, or very near the origin airfield
   if the airfield is closer to the target than the IP distance.

2. Unthreatened home zone, max IP between origin and target, safe
   locations available for IP.

   The IP should be placed in LAR at the closest point to home.

3. Unthreatened home zone, origin within LAR, safe locations available
   for IP.

   The IP should be placed near the origin airfield to prevent
   backtracking more than needed.

4. Unthreatened home zone, origin entirely nearer the target than LAR,
   safe locations available for IP.

   The IP should be placed in LAR as close as possible to the origin.

5. Threatened home zone, safe locations available for IP.

   The IP should be placed in LAR as close as possible to the origin.

6. No safe IP.

   The IP should be placed in LAR at the point nearest the threat
   boundary.
This commit is contained in:
Dan Albert 2021-07-14 21:59:46 -07:00
parent 2b696144e3
commit e03d710d53
6 changed files with 354 additions and 102 deletions

View File

@ -47,8 +47,13 @@ class Doctrine:
#: fallback flight plan layout (when the departure airfield is near a threat zone). #: fallback flight plan layout (when the departure airfield is near a threat zone).
join_distance: Distance join_distance: Distance
#: The distance between the ingress point (beginning of the attack) and target. #: The maximum distance between the ingress point (beginning of the attack) and
ingress_distance: Distance #: target.
max_ingress_distance: Distance
#: The minimum distance between the ingress point (beginning of the attack) and
#: target.
min_ingress_distance: Distance
ingress_altitude: Distance ingress_altitude: Distance
@ -87,9 +92,22 @@ class Doctrine:
@has_save_compat_for(5) @has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None: def __setstate__(self, state: dict[str, Any]) -> None:
if "ingress_distance" not in state: if "max_ingress_distance" not in state:
state["ingress_distance"] = state["ingress_egress_distance"] try:
del state["ingress_egress_distance"] state["max_ingress_distance"] = state["ingress_distance"]
del state["ingress_distance"]
except KeyError:
state["max_ingress_distance"] = state["ingress_egress_distance"]
del state["ingress_egress_distance"]
max_ip: Distance = state["max_ingress_distance"]
if "min_ingress_distance" not in state:
if max_ip < nautical_miles(10):
min_ip = nautical_miles(5)
else:
min_ip = nautical_miles(10)
state["min_ingress_distance"] = min_ip
self.__dict__.update(state) self.__dict__.update(state)
@ -103,7 +121,8 @@ MODERN_DOCTRINE = Doctrine(
hold_distance=nautical_miles(15), hold_distance=nautical_miles(15),
push_distance=nautical_miles(20), push_distance=nautical_miles(20),
join_distance=nautical_miles(20), join_distance=nautical_miles(20),
ingress_distance=nautical_miles(45), max_ingress_distance=nautical_miles(45),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000), ingress_altitude=feet(20000),
min_patrol_altitude=feet(15000), min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000), max_patrol_altitude=feet(33000),
@ -139,7 +158,8 @@ COLDWAR_DOCTRINE = Doctrine(
hold_distance=nautical_miles(10), hold_distance=nautical_miles(10),
push_distance=nautical_miles(10), push_distance=nautical_miles(10),
join_distance=nautical_miles(10), join_distance=nautical_miles(10),
ingress_distance=nautical_miles(30), max_ingress_distance=nautical_miles(30),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000), ingress_altitude=feet(18000),
min_patrol_altitude=feet(10000), min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000), max_patrol_altitude=feet(24000),
@ -175,7 +195,8 @@ WWII_DOCTRINE = Doctrine(
push_distance=nautical_miles(5), push_distance=nautical_miles(5),
join_distance=nautical_miles(5), join_distance=nautical_miles(5),
rendezvous_altitude=feet(10000), rendezvous_altitude=feet(10000),
ingress_distance=nautical_miles(7), max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000), ingress_altitude=feet(8000),
min_patrol_altitude=feet(4000), min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000), max_patrol_altitude=feet(15000),

View File

@ -0,0 +1 @@
from .ipzonegeometry import IpZoneGeometry

View File

@ -0,0 +1,114 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import Point as ShapelyPoint
from game.utils import nautical_miles, meters
if TYPE_CHECKING:
from game.coalition import Coalition
class IpZoneGeometry:
"""Defines the zones used for finding optimal IP placement.
The zones themselves are stored in the class rather than just the resulting IP so
that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
coalition: Coalition,
) -> None:
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
max_ip_distance = coalition.doctrine.max_ingress_distance
min_ip_distance = coalition.doctrine.min_ingress_distance
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
# The distance that is expected to be needed between the beginning of the attack
# and weapon release. This buffers the threat zone to give a 5nm window between
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
# likely to end up with the attacker entering a threatened area.
attack_distance_buffer = nautical_miles(5)
home_threatened = coalition.opponent.threat_zone.threatened(home)
shapely_target = ShapelyPoint(target.x, target.y)
home_to_target_distance = meters(home.distance_to_point(target))
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
self.home.buffer(min_distance_from_home.meters)
)
# If the home zone is not threatened and home is within LAR, constrain the max
# range to the home-to-target distance to prevent excessive backtracking.
#
# If the home zone *is* threatened, we need to back out of the zone to
# rendezvous anyway.
if not home_threatened and (
min_ip_distance < home_to_target_distance < max_ip_distance
):
max_ip_distance = home_to_target_distance
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
# The intersection of the home bubble and IP bubble will be all the points that
# are within the valid IP range that are not farther from home than the target
# is. However, if the origin airfield is threatened but there are safe
# placements for the IP, we should not constrain to the home zone. In this case
# we'll either end up with a safe zone outside the home zone and pick the
# closest point in to to home (minimizing backtracking), or we'll have no safe
# IP anywhere within range of the target, and we'll later pick the IP nearest
# the edge of the threat zone.
if home_threatened:
self.permissible_zone = self.ip_bubble
else:
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
if self.permissible_zone.is_empty:
# If home is closer to the target than the min range, there will not be an
# IP solution that's close enough to home, in which case we need to ignore
# the home bubble.
self.permissible_zone = self.ip_bubble
self.safe_zone = self.permissible_zone.difference(
self.threat_zone.buffer(attack_distance_buffer.meters)
)
def _unsafe_ip(self) -> ShapelyPoint:
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
if unthreatened_home_zone.is_empty:
# Nowhere in our home zone is safe. The package will need to exit the
# threatened area to hold and rendezvous. Pick the IP closest to the
# edge of the threat zone.
return shapely.ops.nearest_points(
self.permissible_zone, self.threat_zone.boundary
)[0]
# No safe point in the IP zone, but the home zone is safe. Pick the max-
# distance IP that's closest to the untreatened home zone.
return shapely.ops.nearest_points(
self.permissible_zone, unthreatened_home_zone
)[0]
def _safe_ip(self) -> ShapelyPoint:
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
# the IP in the zone that's closest to the target.
return shapely.ops.nearest_points(self.safe_zone, self.home)[0]
def find_best_ip(self) -> Point:
if self.safe_zone.is_empty:
ip = self._unsafe_ip()
else:
ip = self._safe_ip()
return Point(ip.x, ip.y)

View File

@ -20,6 +20,7 @@ from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.flightplan import IpZoneGeometry
from game.theater import ( from game.theater import (
Airfield, Airfield,
ControlPoint, ControlPoint,
@ -946,57 +947,35 @@ class FlightPlanBuilder:
raise PlanningError(f"{task} flight plan generation not implemented") raise PlanningError(f"{task} flight plan generation not implemented")
def regenerate_package_waypoints(self) -> None: def regenerate_package_waypoints(self) -> None:
# The simple case is where the target is not near the departure airfield. In
# this case, we can plan the shortest route from the departure airfield to the
# target, use the nearest non-threatened point *that's farther from the target
# than the ingress point to avoid backtracking) as the join point.
#
# The other case that we need to handle is when the target is close to
# the origin airfield. In this case we currently fall back to the old planning
# behavior.
#
# A messy (and very unlikely) case that we can't do much about:
#
# +--------------+ +---------------+
# | | | |
# | IP-+---+-T |
# | | | |
# | | | |
# +--------------+ +---------------+
from gen.ato import PackageWaypoints from gen.ato import PackageWaypoints
target = self.package.target.position # Start by picking the best IP for the attack.
ingress_point = IpZoneGeometry(
self.package.target.position,
self.package_airfield().position,
self.coalition,
).find_best_ip()
for join_point in self.preferred_join_points(): # Pick the join point based on the best route to the IP.
join_distance = meters(join_point.distance_to_point(target)) join_point = self.preferred_join_point(ingress_point)
if join_distance > self.doctrine.ingress_distance: if join_point is None:
break
else:
# The entire path to the target is threatened. Use the fallback behavior for # The entire path to the target is threatened. Use the fallback behavior for
# now. # now.
self.legacy_package_waypoints_impl() self.legacy_package_waypoints_impl(ingress_point)
return return
attack_heading = join_point.heading_between_point(target) # And the split point based on the best route from the IP. Since that's no
ingress_point = self._ingress_point(attack_heading) # different than the best route *to* the IP, this is the same as the join point.
# TODO: Estimate attack completion point based on the IP and split from there?
# The first case described above. The ingress and join points are placed
# reasonably relative to each other.
self.package.waypoints = PackageWaypoints( self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point), WaypointBuilder.perturb(join_point),
ingress_point, ingress_point,
WaypointBuilder.perturb( WaypointBuilder.perturb(join_point),
self.preferred_split_point(ingress_point, join_point)
),
) )
def retreat_point(self, origin: Point) -> Point: def legacy_package_waypoints_impl(self, ingress_point: Point) -> None:
return self.threat_zones.closest_boundary(origin)
def legacy_package_waypoints_impl(self) -> None:
from gen.ato import PackageWaypoints from gen.ato import PackageWaypoints
ingress_point = self._ingress_point(self._target_heading_to_package_airfield())
join_point = self._rendezvous_point(ingress_point) join_point = self._rendezvous_point(ingress_point)
self.package.waypoints = PackageWaypoints( self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point), WaypointBuilder.perturb(join_point),
@ -1009,23 +988,15 @@ class FlightPlanBuilder:
if not self.threat_zones.threatened(point): if not self.threat_zones.threatened(point):
yield point yield point
def preferred_join_points(self) -> Iterator[Point]: def preferred_join_point(self, ingress_point: Point) -> Optional[Point]:
# Use non-threatened points along the path to the target as the join point. We # Use non-threatened points along the path to the target as the join point. We
# may need to try more than one in the event that the close non-threatened # may need to try more than one in the event that the close non-threatened
# points are closer than the ingress point itself. # points are closer than the ingress point itself.
return self.safe_points_between( for join_point in self.safe_points_between(
self.package.target.position, self.package_airfield().position
)
def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point:
# Use non-threatened points along the path to the target as the join point. We
# may need to try more than one in the event that the close non-threatened
# points are closer than the ingress point itself.
for point in self.safe_points_between(
ingress_point, self.package_airfield().position ingress_point, self.package_airfield().position
): ):
return point return join_point
return join_point return None
def generate_strike(self, flight: Flight) -> StrikeFlightPlan: def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
"""Generates a strike flight plan. """Generates a strike flight plan.
@ -1847,7 +1818,7 @@ class FlightPlanBuilder:
def _ingress_point(self, heading: float) -> Point: def _ingress_point(self, heading: float) -> Point:
return self.package.target.position.point_from_heading( return self.package.target.position.point_from_heading(
heading - 180, self.doctrine.ingress_distance.meters heading - 180, self.doctrine.max_ingress_distance.meters
) )
def _target_heading_to_package_airfield(self) -> float: def _target_heading_to_package_airfield(self) -> float:

View File

@ -27,7 +27,12 @@ from game.transfers import MultiGroupTransport, TransportMap
from game.utils import meters, nautical_miles from game.utils import meters, nautical_miles
from gen.ato import AirTaskingOrder from gen.ato import AirTaskingOrder
from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType
from gen.flights.flightplan import FlightPlan, PatrollingFlightPlan, CasFlightPlan from gen.flights.flightplan import (
FlightPlan,
PatrollingFlightPlan,
CasFlightPlan,
)
from game.flightplan.ipzonegeometry import IpZoneGeometry
from qt_ui.dialogs import Dialog from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel, AtoModel from qt_ui.models import GameModel, AtoModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
@ -39,6 +44,10 @@ LeafletPoly = list[LeafletLatLon]
MAX_SHIP_DISTANCE = nautical_miles(80) MAX_SHIP_DISTANCE = nautical_miles(80)
# Set to True to enable computing expensive debugging information. At the time of
# writing this only controls computing the waypoint placement zones.
ENABLE_EXPENSIVE_DEBUG_TOOLS = False
# **EVERY PROPERTY NEEDS A NOTIFY SIGNAL** # **EVERY PROPERTY NEEDS A NOTIFY SIGNAL**
# #
# https://bugreports.qt.io/browse/PYSIDE-1426 # https://bugreports.qt.io/browse/PYSIDE-1426
@ -512,6 +521,19 @@ class FlightJs(QObject):
selectedChanged = Signal() selectedChanged = Signal()
commitBoundaryChanged = Signal() commitBoundaryChanged = Signal()
originChanged = Signal()
@Property(list, notify=originChanged)
def origin(self) -> LeafletLatLon:
return self._waypoints[0].position
targetChanged = Signal()
@Property(list, notify=targetChanged)
def target(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.flight.package.target.position)
return [ll.latitude, ll.longitude]
def __init__( def __init__(
self, self,
flight: Flight, flight: Flight,
@ -769,6 +791,56 @@ class UnculledZone(QObject):
) )
class IpZonesJs(QObject):
homeBubbleChanged = Signal()
ipBubbleChanged = Signal()
permissibleZoneChanged = Signal()
safeZoneChanged = Signal()
def __init__(
self,
home_bubble: list[LeafletPoly],
ip_bubble: list[LeafletPoly],
permissible_zone: list[LeafletPoly],
safe_zone: list[LeafletPoly],
) -> None:
super().__init__()
self._home_bubble = home_bubble
self._ip_bubble = ip_bubble
self._permissible_zone = permissible_zone
self._safe_zone = safe_zone
@Property(list, notify=homeBubbleChanged)
def homeBubble(self) -> list[LeafletPoly]:
return self._home_bubble
@Property(list, notify=ipBubbleChanged)
def ipBubble(self) -> list[LeafletPoly]:
return self._ip_bubble
@Property(list, notify=permissibleZoneChanged)
def permissibleZone(self) -> list[LeafletPoly]:
return self._permissible_zone
@Property(list, notify=permissibleZoneChanged)
def safeZone(self) -> list[LeafletPoly]:
return self._safe_zone
@classmethod
def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs:
target = flight.package.target
home = flight.departure
if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home:
return IpZonesJs([], [], [], [])
geometry = IpZoneGeometry(target.position, home.position, game.blue)
return IpZonesJs(
shapely_to_leaflet_polys(geometry.home_bubble, game.theater),
shapely_to_leaflet_polys(geometry.ip_bubble, game.theater),
shapely_to_leaflet_polys(geometry.permissible_zone, game.theater),
shapely_to_leaflet_polys(geometry.safe_zone, game.theater),
)
class MapModel(QObject): class MapModel(QObject):
cleared = Signal() cleared = Signal()
@ -782,6 +854,7 @@ class MapModel(QObject):
navmeshesChanged = Signal() navmeshesChanged = Signal()
mapZonesChanged = Signal() mapZonesChanged = Signal()
unculledZonesChanged = Signal() unculledZonesChanged = Signal()
ipZonesChanged = Signal()
def __init__(self, game_model: GameModel) -> None: def __init__(self, game_model: GameModel) -> None:
super().__init__() super().__init__()
@ -798,6 +871,7 @@ class MapModel(QObject):
self._navmeshes = NavMeshJs([], []) self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], []) self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = [] self._unculled_zones = []
self._ip_zones = IpZonesJs([], [], [], [])
self._selected_flight_index: Optional[Tuple[int, int]] = None self._selected_flight_index: Optional[Tuple[int, int]] = None
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)
@ -821,6 +895,7 @@ class MapModel(QObject):
self._navmeshes = NavMeshJs([], []) self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], []) self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = [] self._unculled_zones = []
self._ip_zones = IpZonesJs([], [], [], [])
self.cleared.emit() self.cleared.emit()
def set_package_selection(self, index: int) -> None: def set_package_selection(self, index: int) -> None:
@ -896,11 +971,24 @@ class MapModel(QObject):
) )
return flights return flights
def _get_selected_flight(self) -> Optional[Flight]:
for p_idx, package in enumerate(self.game.blue.ato.packages):
for f_idx, flight in enumerate(package.flights):
if (p_idx, f_idx) == self._selected_flight_index:
return flight
return None
def reset_atos(self) -> None: def reset_atos(self) -> None:
self._flights = self._flights_in_ato( self._flights = self._flights_in_ato(
self.game.blue.ato, blue=True self.game.blue.ato, blue=True
) + self._flights_in_ato(self.game.red.ato, blue=False) ) + self._flights_in_ato(self.game.red.ato, blue=False)
self.flightsChanged.emit() self.flightsChanged.emit()
selected_flight = self._get_selected_flight()
if selected_flight is not None:
self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game)
else:
self._ip_zones = IpZonesJs([], [], [], [])
self.ipZonesChanged.emit()
@Property(list, notify=flightsChanged) @Property(list, notify=flightsChanged)
def flights(self) -> List[FlightJs]: def flights(self) -> List[FlightJs]:
@ -1029,6 +1117,10 @@ class MapModel(QObject):
def unculledZones(self) -> list[UnculledZone]: def unculledZones(self) -> list[UnculledZone]:
return self._unculled_zones return self._unculled_zones
@Property(IpZonesJs, notify=ipZonesChanged)
def ipZones(self) -> IpZonesJs:
return self._ip_zones
@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

@ -1,3 +1,7 @@
// Won't actually enable anything unless the same property is set in
// mapmodel.py.
const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
const Colors = Object.freeze({ const Colors = Object.freeze({
Blue: "#0084ff", Blue: "#0084ff",
Red: "#c85050", Red: "#c85050",
@ -124,26 +128,26 @@ const map = L.map("map", {
L.control.scale({ maxWidth: 200 }).addTo(map); L.control.scale({ maxWidth: 200 }).addTo(map);
const rulerOptions = { const rulerOptions = {
position: 'topleft', position: "topleft",
circleMarker: { circleMarker: {
color: Colors.Highlight, color: Colors.Highlight,
radius: 2 radius: 2,
}, },
lineStyle: { lineStyle: {
color: Colors.Highlight, color: Colors.Highlight,
dashArray: '1,6' dashArray: "1,6",
}, },
lengthUnit: { lengthUnit: {
display: "nm", display: "nm",
decimal: "2", decimal: "2",
factor: 0.539956803, factor: 0.539956803,
label: "Distance:" label: "Distance:",
}, },
angleUnit: { angleUnit: {
display: '&deg;', display: "&deg;",
decimal: 0, decimal: 0,
label: "Bearing:" label: "Bearing:",
} },
}; };
L.control.ruler(rulerOptions).addTo(map); L.control.ruler(rulerOptions).addTo(map);
@ -194,6 +198,48 @@ const exclusionZones = L.layerGroup();
const seaZones = L.layerGroup(); const seaZones = L.layerGroup();
const unculledZones = L.layerGroup(); const unculledZones = L.layerGroup();
const homeBubble = L.layerGroup();
const ipBubble = L.layerGroup();
const permissibleZone = L.layerGroup();
const safeZone = L.layerGroup();
const debugControlGroups = {
"Blue Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: blueFullThreatZones,
Aircraft: blueAircraftThreatZones,
"Air Defenses": blueAirDefenseThreatZones,
"Radar SAMs": blueRadarSamThreatZones,
},
"Red Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: redFullThreatZones,
Aircraft: redAircraftThreatZones,
"Air Defenses": redAirDefenseThreatZones,
"Radar SAMs": redRadarSamThreatZones,
},
Navmeshes: {
Hide: L.layerGroup().addTo(map),
Blue: blueNavmesh,
Red: redNavmesh,
},
"Map Zones": {
"Inclusion zones": inclusionZones,
"Exclusion zones": exclusionZones,
"Sea zones": seaZones,
"Culling exclusion zones": unculledZones,
},
};
if (ENABLE_EXPENSIVE_DEBUG_TOOLS) {
debugControlGroups["IP Zones"] = {
"Home bubble": homeBubble,
"IP bubble": ipBubble,
"Permissible zone": permissibleZone,
"Safe zone": safeZone,
};
}
// Main map controls. These are the ones that we expect users to interact with. // Main map controls. These are the ones that we expect users to interact with.
// These are always open, which unfortunately means that the scroll bar will not // These are always open, which unfortunately means that the scroll bar will not
// appear if the menu doesn't fit. This fits in the smallest window size we // appear if the menu doesn't fit. This fits in the smallest window size we
@ -239,41 +285,11 @@ L.control
// Debug map controls. Hover over to open. Not something most users will want or // Debug map controls. Hover over to open. Not something most users will want or
// need to interact with. // need to interact with.
L.control L.control
.groupedLayers( .groupedLayers(null, debugControlGroups, {
null, position: "topleft",
{ exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"],
"Blue Threat Zones": { groupCheckboxes: true,
Hide: L.layerGroup().addTo(map), })
Full: blueFullThreatZones,
Aircraft: blueAircraftThreatZones,
"Air Defenses": blueAirDefenseThreatZones,
"Radar SAMs": blueRadarSamThreatZones,
},
"Red Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: redFullThreatZones,
Aircraft: redAircraftThreatZones,
"Air Defenses": redAirDefenseThreatZones,
"Radar SAMs": redRadarSamThreatZones,
},
Navmeshes: {
Hide: L.layerGroup().addTo(map),
Blue: blueNavmesh,
Red: redNavmesh,
},
"Map Zones": {
"Inclusion zones": inclusionZones,
"Exclusion zones": exclusionZones,
"Sea zones": seaZones,
"Culling exclusion zones": unculledZones,
},
},
{
position: "topleft",
exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"],
groupCheckboxes: true,
}
)
.addTo(map); .addTo(map);
let game; let game;
@ -291,6 +307,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
game.navmeshesChanged.connect(drawNavmeshes); game.navmeshesChanged.connect(drawNavmeshes);
game.mapZonesChanged.connect(drawMapZones); game.mapZonesChanged.connect(drawMapZones);
game.unculledZonesChanged.connect(drawUnculledZones); game.unculledZonesChanged.connect(drawUnculledZones);
game.ipZonesChanged.connect(drawIpZones);
}); });
function recenterMap(center) { function recenterMap(center) {
@ -570,7 +587,11 @@ class TheaterGroundObject {
} }
L.marker(this.tgo.position, { icon: this.icon() }) L.marker(this.tgo.position, { icon: this.icon() })
.bindTooltip(`${this.tgo.name} (${this.tgo.controlPointName})<br />${this.tgo.units.join("<br />")}`) .bindTooltip(
`${this.tgo.name} (${
this.tgo.controlPointName
})<br />${this.tgo.units.join("<br />")}`
)
.on("click", () => this.tgo.showInfoDialog()) .on("click", () => this.tgo.showInfoDialog())
.on("contextmenu", () => this.tgo.showPackageDialog()) .on("contextmenu", () => this.tgo.showPackageDialog())
.addTo(this.layer()); .addTo(this.layer());
@ -970,6 +991,37 @@ function drawUnculledZones() {
} }
} }
function drawIpZones() {
homeBubble.clearLayers();
ipBubble.clearLayers();
permissibleZone.clearLayers();
safeZone.clearLayers();
L.polygon(game.ipZones.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(homeBubble);
L.polygon(game.ipZones.ipBubble, {
color: "#bb89ff",
fillOpacity: 0.1,
interactive: false,
}).addTo(ipBubble);
L.polygon(game.ipZones.permissibleZone, {
color: "#ffffff",
fillOpacity: 0.1,
interactive: false,
}).addTo(permissibleZone);
L.polygon(game.ipZones.safeZone, {
color: Colors.Green,
fillOpacity: 0.1,
interactive: false,
}).addTo(safeZone);
}
function drawInitialMap() { function drawInitialMap() {
recenterMap(game.mapCenter); recenterMap(game.mapCenter);
drawControlPoints(); drawControlPoints();
@ -981,6 +1033,7 @@ function drawInitialMap() {
drawNavmeshes(); drawNavmeshes();
drawMapZones(); drawMapZones();
drawUnculledZones(); drawUnculledZones();
drawIpZones();
} }
function clearAllLayers() { function clearAllLayers() {