diff --git a/changelog.md b/changelog.md index 9be996fd..7722fcd7 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. +* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. ## Fixes diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 42c4e3e9..17a92708 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1,2 +1,3 @@ +from .holdzonegeometry import HoldZoneGeometry from .ipzonegeometry import IpZoneGeometry from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/holdzonegeometry.py b/game/flightplan/holdzonegeometry.py new file mode 100644 index 00000000..b382e11a --- /dev/null +++ b/game/flightplan/holdzonegeometry.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon + +from game.theater import ConflictTheater +from game.utils import nautical_miles + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class HoldZoneGeometry: + """Defines the zones used for finding optimal hold point placement. + + The zones themselves are stored in the class rather than just the resulting hold + point so that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + ip: Point, + join: Point, + coalition: Coalition, + theater: ConflictTheater, + ) -> None: + # Hold points are placed one of two ways. Either approach guarantees: + # + # * Safe hold point. + # * Minimum distance to the join point. + # * Not closer to the target than the join point. + # + # 1. As near the join point as possible with a specific distance from the + # departure airfield. This prevents loitering directly above the airfield but + # also keeps the hold point close to the departure airfield. + # + # 2. Alternatively, if the entire home zone is excluded by the above criteria, + # as neat the departure airfield as possible within a minimum distance from + # the join point, with a restricted turn angle at the join point. This + # handles the case where we need to backtrack from the departure airfield and + # the join point to place the hold point, but the turn angle limit restricts + # the maximum distance of the backtrack while maintaining the direction of + # the flight plan. + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + self.join = ShapelyPoint(join.x, join.y) + + self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters) + + join_to_target_distance = join.distance_to_point(target) + self.target_bubble = ShapelyPoint(target.x, target.y).buffer( + join_to_target_distance + ) + + self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters) + + excluded_zones = shapely.ops.unary_union( + [self.join_bubble, self.target_bubble, self.threat_zone] + ) + if not isinstance(excluded_zones, MultiPolygon): + excluded_zones = MultiPolygon([excluded_zones]) + self.excluded_zones = excluded_zones + + join_heading = ip.heading_between_point(join) + + # Arbitrarily large since this is later constrained by the map boundary, and + # we'll be picking a location close to the IP anyway. Just used to avoid real + # distance calculations to project to the map edge. + large_distance = nautical_miles(400).meters + turn_limit = 40 + join_limit_ccw = join.point_from_heading( + join_heading - turn_limit, large_distance + ) + join_limit_cw = join.point_from_heading( + join_heading + turn_limit, large_distance + ) + + join_direction_limit_wedge = Polygon( + [ + (join.x, join.y), + (join_limit_ccw.x, join_limit_ccw.y), + (join_limit_cw.x, join_limit_cw.y), + ] + ) + + permissible_zones = ( + coalition.nav_mesh.map_bounds(theater) + .intersection(join_direction_limit_wedge) + .difference(self.excluded_zones) + .difference(self.home_bubble) + ) + if not isinstance(permissible_zones, MultiPolygon): + permissible_zones = MultiPolygon([permissible_zones]) + self.permissible_zones = permissible_zones + self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones) + + def find_best_hold_point(self) -> Point: + if self.preferred_lines.is_empty: + hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home) + else: + hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join) + return Point(hold.x, hold.y) diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py index 92e647c2..a909cf03 100644 --- a/game/flightplan/ipzonegeometry.py +++ b/game/flightplan/ipzonegeometry.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import Point as ShapelyPoint +from shapely.geometry import Point as ShapelyPoint, MultiPolygon from game.utils import nautical_miles, meters @@ -81,10 +81,14 @@ class IpZoneGeometry: # the home bubble. self.permissible_zone = self.ip_bubble - self.safe_zone = self.permissible_zone.difference( + safe_zones = self.permissible_zone.difference( self.threat_zone.buffer(attack_distance_buffer.meters) ) + if not isinstance(safe_zones, MultiPolygon): + safe_zones = MultiPolygon([safe_zones]) + self.safe_zones = safe_zones + def _unsafe_ip(self) -> ShapelyPoint: unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) if unthreatened_home_zone.is_empty: @@ -104,10 +108,10 @@ class IpZoneGeometry: 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] + return shapely.ops.nearest_points(self.safe_zones, self.home)[0] def find_best_ip(self) -> Point: - if self.safe_zone.is_empty: + if self.safe_zones.is_empty: ip = self._unsafe_ip() else: ip = self._safe_ip() diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py index 39c7d640..48cff780 100644 --- a/game/flightplan/joinzonegeometry.py +++ b/game/flightplan/joinzonegeometry.py @@ -4,7 +4,12 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import Point as ShapelyPoint, Polygon +from shapely.geometry import ( + Point as ShapelyPoint, + Polygon, + MultiPolygon, + MultiLineString, +) from game.theater import ConflictTheater from game.utils import nautical_miles @@ -51,10 +56,14 @@ class JoinZoneGeometry: self.home_bubble = self.home.buffer(min_distance_from_home.meters) - self.excluded_zone = shapely.ops.unary_union( - [self.home_bubble, self.ip_bubble, self.target_bubble, self.threat_zone] + excluded_zones = shapely.ops.unary_union( + [self.ip_bubble, self.target_bubble, self.threat_zone] ) + if not isinstance(excluded_zones, MultiPolygon): + excluded_zones = MultiPolygon([excluded_zones]) + self.excluded_zones = excluded_zones + ip_heading = target.heading_between_point(ip) # Arbitrarily large since this is later constrained by the map boundary, and @@ -73,12 +82,14 @@ class JoinZoneGeometry: ] ) - self.permissible_line = ( - coalition.nav_mesh.map_bounds(theater) - .intersection(ip_direction_limit_wedge) - .intersection(self.excluded_zone.boundary) - ) + permissible_lines = ip_direction_limit_wedge.intersection( + self.excluded_zones.boundary + ).difference(self.home_bubble) + + if not isinstance(permissible_lines, MultiLineString): + permissible_lines = MultiLineString([permissible_lines]) + self.permissible_lines = permissible_lines def find_best_join_point(self) -> Point: - join, _ = shapely.ops.nearest_points(self.permissible_line, self.home) + join, _ = shapely.ops.nearest_points(self.permissible_lines, self.home) return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 5c1c0820..4f28e4cf 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,7 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry, JoinZoneGeometry +from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -975,31 +975,6 @@ class FlightPlanBuilder: WaypointBuilder.perturb(join_point), ) - def legacy_package_waypoints_impl(self, ingress_point: Point) -> None: - from gen.ato import PackageWaypoints - - join_point = self._rendezvous_point(ingress_point) - self.package.waypoints = PackageWaypoints( - WaypointBuilder.perturb(join_point), - ingress_point, - WaypointBuilder.perturb(join_point), - ) - - def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: - for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]: - if not self.threat_zones.threatened(point): - yield 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 - # may need to try more than one in the event that the close non-threatened - # points are closer than the ingress point itself. - for join_point in self.safe_points_between( - ingress_point, self.package_airfield().position - ): - return join_point - return None - def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1675,48 +1650,10 @@ class FlightPlanBuilder: origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join - origin_to_join = origin.distance_to_point(join) - if meters(origin_to_join) < self.doctrine.push_distance: - # If the origin airfield is closer to the join point, than the minimum push - # distance. Plan the hold point such that it retreats from the origin - # airfield. - return join.point_from_heading( - target.heading_between_point(origin), self.doctrine.push_distance.meters - ) - - heading_to_join = origin.heading_between_point(join) - hold_point = origin.point_from_heading( - heading_to_join, self.doctrine.push_distance.meters - ) - hold_distance = meters(hold_point.distance_to_point(join)) - if hold_distance >= self.doctrine.push_distance: - # Hold point is between the origin airfield and the join point and - # spaced sufficiently. - return hold_point - - # The hold point is between the origin airfield and the join point, but - # the distance between the hold point and the join point is too short. - # Bend the hold point out to extend the distance while maintaining the - # minimum distance from the origin airfield to keep the AI flying - # properly. - origin_to_join = origin.distance_to_point(join) - cos_theta = ( - self.doctrine.hold_distance.meters ** 2 - + origin_to_join ** 2 - - self.doctrine.join_distance.meters ** 2 - ) / (2 * self.doctrine.hold_distance.meters * origin_to_join) - try: - theta = math.acos(cos_theta) - except ValueError: - # No solution that maintains hold and join distances. Extend the - # hold point away from the target. - return origin.point_from_heading( - target.heading_between_point(origin), self.doctrine.hold_distance.meters - ) - - return origin.point_from_heading( - heading_to_join - theta, self.doctrine.hold_distance.meters - ) + ip = self.package.waypoints.ingress + return HoldZoneGeometry( + target, origin, ip, join, self.coalition, self.theater + ).find_best_hold_point() # TODO: Make a model for the waypoint builder and use that in the UI. def generate_rtb_waypoint( @@ -1779,59 +1716,6 @@ class FlightPlanBuilder: lead_time=lead_time, ) - def _retreating_rendezvous_point(self, attack_transition: Point) -> Point: - """Creates a rendezvous point that retreats from the origin airfield.""" - return attack_transition.point_from_heading( - self.package.target.position.heading_between_point( - self.package_airfield().position - ), - self.doctrine.join_distance.meters, - ) - - def _advancing_rendezvous_point(self, attack_transition: Point) -> Point: - """Creates a rendezvous point that advances toward the target.""" - heading = self._heading_to_package_airfield(attack_transition) - return attack_transition.point_from_heading( - heading, -self.doctrine.join_distance.meters - ) - - def _rendezvous_should_retreat(self, attack_transition: Point) -> bool: - transition_target_distance = attack_transition.distance_to_point( - self.package.target.position - ) - origin_target_distance = self._distance_to_package_airfield( - self.package.target.position - ) - - # If the origin point is closer to the target than the ingress point, - # the rendezvous point should be positioned in a position that retreats - # from the origin airfield. - return origin_target_distance < transition_target_distance - - def _rendezvous_point(self, attack_transition: Point) -> Point: - """Returns the position of the rendezvous point. - - Args: - attack_transition: The ingress or target point for this rendezvous. - """ - if self._rendezvous_should_retreat(attack_transition): - return self._retreating_rendezvous_point(attack_transition) - return self._advancing_rendezvous_point(attack_transition) - - def _ingress_point(self, heading: float) -> Point: - return self.package.target.position.point_from_heading( - heading - 180, self.doctrine.max_ingress_distance.meters - ) - - def _target_heading_to_package_airfield(self) -> float: - return self._heading_to_package_airfield(self.package.target.position) - - def _heading_to_package_airfield(self, point: Point) -> float: - return self.package_airfield().position.heading_between_point(point) - - def _distance_to_package_airfield(self, point: Point) -> float: - return self.package_airfield().position.distance_to_point(point) - def package_airfield(self) -> ControlPoint: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 09bc6022..e8a75298 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -8,11 +8,17 @@ from PySide2.QtCore import Property, QObject, Signal, Slot from dcs import Point from dcs.unit import Unit from dcs.vehicles import vehicle_map -from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPolygon +from shapely.geometry import ( + LineString, + Point as ShapelyPoint, + Polygon, + MultiPolygon, + MultiLineString, +) from game import Game from game.dcs.groundunittype import GroundUnitType -from game.flightplan import JoinZoneGeometry +from game.flightplan import JoinZoneGeometry, HoldZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -89,6 +95,12 @@ def shapely_line_to_leaflet_points( return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords] +def shapely_lines_to_leaflet_points( + lines: MultiLineString, theater: ConflictTheater +) -> list[list[LeafletLatLon]]: + return [shapely_line_to_leaflet_points(l, theater) for l in lines.geoms] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -802,36 +814,36 @@ class IpZonesJs(QObject): homeBubbleChanged = Signal() ipBubbleChanged = Signal() permissibleZoneChanged = Signal() - safeZoneChanged = Signal() + safeZonesChanged = Signal() def __init__( self, - home_bubble: list[LeafletPoly], - ip_bubble: list[LeafletPoly], - permissible_zone: list[LeafletPoly], - safe_zone: list[LeafletPoly], + home_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + permissible_zone: LeafletPoly, + safe_zones: 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 + self._safe_zones = safe_zones @Property(list, notify=homeBubbleChanged) - def homeBubble(self) -> list[LeafletPoly]: + def homeBubble(self) -> LeafletPoly: return self._home_bubble @Property(list, notify=ipBubbleChanged) - def ipBubble(self) -> list[LeafletPoly]: + def ipBubble(self) -> LeafletPoly: return self._ip_bubble @Property(list, notify=permissibleZoneChanged) - def permissibleZone(self) -> list[LeafletPoly]: + def permissibleZone(self) -> LeafletPoly: return self._permissible_zone - @Property(list, notify=safeZoneChanged) - def safeZone(self) -> list[LeafletPoly]: - return self._safe_zone + @Property(list, notify=safeZonesChanged) + def safeZones(self) -> list[LeafletPoly]: + return self._safe_zones @classmethod def empty(cls) -> IpZonesJs: @@ -845,10 +857,10 @@ class IpZonesJs(QObject): home = flight.departure 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), + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.permissible_zone, game.theater), + shapely_to_leaflet_polys(geometry.safe_zones, game.theater), ) @@ -856,43 +868,43 @@ class JoinZonesJs(QObject): homeBubbleChanged = Signal() targetBubbleChanged = Signal() ipBubbleChanged = Signal() - excludedZoneChanged = Signal() - permissibleLineChanged = Signal() + excludedZonesChanged = Signal() + permissibleLinesChanged = Signal() def __init__( self, - home_bubble: list[LeafletPoly], - target_bubble: list[LeafletPoly], - ip_bubble: list[LeafletPoly], - excluded_zone: list[LeafletPoly], - permissible_line: list[LeafletLatLon], + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble self._target_bubble = target_bubble self._ip_bubble = ip_bubble - self._excluded_zone = excluded_zone - self._permissible_line = permissible_line + self._excluded_zones = excluded_zones + self._permissible_lines = permissible_lines @Property(list, notify=homeBubbleChanged) - def homeBubble(self) -> list[LeafletPoly]: + def homeBubble(self) -> LeafletPoly: return self._home_bubble @Property(list, notify=targetBubbleChanged) - def targetBubble(self) -> list[LeafletPoly]: + def targetBubble(self) -> LeafletPoly: return self._target_bubble @Property(list, notify=ipBubbleChanged) - def ipBubble(self) -> list[LeafletPoly]: + def ipBubble(self) -> LeafletPoly: return self._ip_bubble - @Property(list, notify=excludedZoneChanged) - def excludedZone(self) -> list[LeafletPoly]: - return self._excluded_zone + @Property(list, notify=excludedZonesChanged) + def excludedZones(self) -> list[LeafletPoly]: + return self._excluded_zones - @Property(list, notify=permissibleLineChanged) - def permissibleLine(self) -> list[LeafletLatLon]: - return self._permissible_line + @Property(list, notify=permissibleLinesChanged) + def permissibleLines(self) -> list[list[LeafletLatLon]]: + return self._permissible_lines @classmethod def empty(cls) -> JoinZonesJs: @@ -911,11 +923,87 @@ class JoinZonesJs(QObject): target.position, home.position, ip, game.blue, game.theater ) return JoinZonesJs( - shapely_to_leaflet_polys(geometry.home_bubble, game.theater), - shapely_to_leaflet_polys(geometry.target_bubble, game.theater), - shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), - shapely_to_leaflet_polys(geometry.excluded_zone, game.theater), - shapely_line_to_leaflet_points(geometry.permissible_line, game.theater), + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), + shapely_lines_to_leaflet_points(geometry.permissible_lines, game.theater), + ) + + +class HoldZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + joinBubbleChanged = Signal() + excludedZonesChanged = Signal() + permissibleZonesChanged = Signal() + permissibleLinesChanged = Signal() + + def __init__( + self, + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + join_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_zones: list[LeafletPoly], + permissible_lines: list[list[LeafletLatLon]], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._target_bubble = target_bubble + self._join_bubble = join_bubble + self._excluded_zones = excluded_zones + self._permissible_zones = permissible_zones + self._permissible_lines = permissible_lines + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> LeafletPoly: + return self._home_bubble + + @Property(list, notify=targetBubbleChanged) + def targetBubble(self) -> LeafletPoly: + return self._target_bubble + + @Property(list, notify=joinBubbleChanged) + def joinBubble(self) -> LeafletPoly: + return self._join_bubble + + @Property(list, notify=excludedZonesChanged) + def excludedZones(self) -> list[LeafletPoly]: + return self._excluded_zones + + @Property(list, notify=permissibleZonesChanged) + def permissibleZones(self) -> list[LeafletPoly]: + return self._permissible_zones + + @Property(list, notify=permissibleLinesChanged) + def permissibleLines(self) -> list[list[LeafletLatLon]]: + return self._permissible_lines + + @classmethod + def empty(cls) -> HoldZonesJs: + return HoldZonesJs([], [], [], [], [], []) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return JoinZonesJs.empty() + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return HoldZonesJs.empty() + ip = flight.package.waypoints.ingress + join = flight.package.waypoints.join + geometry = HoldZoneGeometry( + target.position, home.position, ip, join, game.blue, game.theater + ) + return HoldZonesJs( + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), + [], # shapely_to_leaflet_polys(geometry.permissible_lines, game.theater), ) @@ -934,6 +1022,7 @@ class MapModel(QObject): unculledZonesChanged = Signal() ipZonesChanged = Signal() joinZonesChanged = Signal() + holdZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -952,6 +1041,7 @@ class MapModel(QObject): self._unculled_zones = [] self._ip_zones = IpZonesJs.empty() self._join_zones = JoinZonesJs.empty() + self._hold_zones = HoldZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -1067,11 +1157,14 @@ class MapModel(QObject): if selected_flight is None: self._ip_zones = IpZonesJs.empty() self._join_zones = JoinZonesJs.empty() + self._hold_zones = HoldZonesJs.empty() else: self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) + self._hold_zones = HoldZonesJs.for_flight(selected_flight, self.game) self.ipZonesChanged.emit() self.joinZonesChanged.emit() + self.holdZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1208,6 +1301,10 @@ class MapModel(QObject): def joinZones(self) -> JoinZonesJs: return self._join_zones + @Property(HoldZonesJs, notify=holdZonesChanged) + def holdZones(self) -> HoldZonesJs: + return self._hold_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 87e4ca7e..7ac1dac2 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -198,8 +198,10 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); +const noWaypointZones = L.layerGroup(); const ipZones = L.layerGroup(); -const joinZones = L.layerGroup().addTo(map); +const joinZones = L.layerGroup(); +const holdZones = L.layerGroup().addTo(map); const debugControlGroups = { "Blue Threat Zones": { @@ -231,8 +233,10 @@ const debugControlGroups = { if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { debugControlGroups["Waypoint Zones"] = { + None: noWaypointZones, "IP Zones": ipZones, "Join Zones": joinZones, + "Hold Zones": holdZones, }; } @@ -310,6 +314,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.unculledZonesChanged.connect(drawUnculledZones); game.ipZonesChanged.connect(drawIpZones); game.joinZonesChanged.connect(drawJoinZones); + game.holdZonesChanged.connect(drawHoldZones); }); function recenterMap(center) { @@ -1014,11 +1019,13 @@ function drawIpZones() { interactive: false, }).addTo(ipZones); - L.polygon(game.ipZones.safeZone, { - color: Colors.Green, - fillOpacity: 0.1, - interactive: false, - }).addTo(ipZones); + for (const zone of game.ipZones.safeZones) { + L.polygon(zone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + } } function drawJoinZones() { @@ -1042,17 +1049,59 @@ function drawJoinZones() { interactive: false, }).addTo(joinZones); - L.polygon(game.joinZones.excludedZone, { - color: "#ffa500", - fillOpacity: 0.2, - stroke: false, - interactive: false, - }).addTo(joinZones); + for (const zone of game.joinZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + } - L.polyline(game.joinZones.permissibleLine, { - color: Colors.Green, + for (const line of game.joinZones.permissibleLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } +} + +function drawHoldZones() { + holdZones.clearLayers(); + + L.polygon(game.holdZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, - }).addTo(joinZones); + }).addTo(holdZones); + + L.polygon(game.holdZones.targetBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(holdZones); + + L.polygon(game.holdZones.joinBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(holdZones); + + for (const zone of game.holdZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(holdZones); + } + + for (const zone of game.holdZones.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } } function drawInitialMap() { @@ -1068,6 +1117,7 @@ function drawInitialMap() { drawUnculledZones(); drawIpZones(); drawJoinZones(); + drawHoldZones(); } function clearAllLayers() {