diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 70887541..8d59f982 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -5,12 +5,8 @@ import json import logging from dataclasses import dataclass from functools import cached_property -from itertools import tee from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union, cast - -from shapely import geometry -from shapely import ops +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast from dcs import Mission from dcs.countries import ( @@ -21,9 +17,10 @@ from dcs.country import Country from dcs.mapping import Point from dcs.planes import F_15C from dcs.ships import ( + Bulker_Handy_Wind, CVN_74_John_C__Stennis, - LHA_1_Tarawa, DDG_Arleigh_Burke_IIa, + LHA_1_Tarawa, ) from dcs.statics import Fortification from dcs.terrain import ( @@ -43,20 +40,21 @@ from dcs.unitgroup import ( VehicleGroup, ) from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed +from shapely import geometry, ops from gen.flights.flight import FlightType from .controlpoint import ( Airfield, Carrier, ControlPoint, + Fob, Lha, MissionTarget, OffMapSpawn, - Fob, ) from .landmap import Landmap, load_landmap, poly_contains from ..point_with_heading import PointWithHeading -from ..utils import Distance, meters, nautical_miles +from ..utils import Distance, meters, nautical_miles, pairwise Numeric = Union[int, float] @@ -73,16 +71,6 @@ IMPORTANCE_HIGH = 1.4 FRONTLINE_MIN_CP_DISTANCE = 5000 -def pairwise(iterable): - """ - itertools recipe - s -> (s0,s1), (s1,s2), (s2, s3), ... - """ - a, b = tee(iterable) - next(b, None) - return zip(a, b) - - class MizCampaignLoader: BLUE_COUNTRY = CombinedJointTaskForcesBlue() RED_COUNTRY = CombinedJointTaskForcesRed() @@ -92,6 +80,7 @@ class MizCampaignLoader: CV_UNIT_TYPE = CVN_74_John_C__Stennis.id LHA_UNIT_TYPE = LHA_1_Tarawa.id FRONT_LINE_UNIT_TYPE = Armor.APC_M113.id + SHIPPING_LANE_UNIT_TYPE = Bulker_Handy_Wind.id FOB_UNIT_TYPE = Unarmed.Truck_SKP_11_Mobile_ATC.id FARP_HELIPAD = "SINGLE_HELIPAD" @@ -315,6 +304,12 @@ class MizCampaignLoader: if group.units[0].type == self.FRONT_LINE_UNIT_TYPE: yield group + @property + def shipping_lane_groups(self) -> Iterator[ShipGroup]: + for group in self.country(blue=True).ship_group: + if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE: + yield group + @cached_property def front_lines(self) -> Dict[str, ComplexFrontLine]: # Dict of front line ID to a front line. @@ -351,6 +346,28 @@ class MizCampaignLoader: ) return front_lines + def add_shipping_lanes(self) -> None: + for group in self.shipping_lane_groups: + # The unit will have its first waypoint at the source CP and the final + # waypoint at the destination CP. Each waypoint defines the path of the + # cargo ship. + waypoints = [p.position for p in group.points] + origin = self.theater.closest_control_point(waypoints[0]) + if origin is None: + raise RuntimeError( + f"No control point near the first waypoint of {group.name}" + ) + destination = self.theater.closest_control_point(waypoints[-1]) + if destination is None: + raise RuntimeError( + f"No control point near the final waypoint of {group.name}" + ) + + self.control_points[origin.id].create_shipping_lane(destination, waypoints) + self.control_points[destination.id].create_shipping_lane( + origin, list(reversed(waypoints)) + ) + def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]: closest = self.theater.closest_control_point(group.position) distance = meters(closest.position.distance_to_point(group.position)) @@ -446,6 +463,7 @@ class MizCampaignLoader: for control_point in self.control_points.values(): self.theater.add_controlpoint(control_point) self.add_preset_locations() + self.add_shipping_lanes() self.theater.set_frontline_data(self.front_lines) @@ -877,6 +895,12 @@ class FrontLine(MissionTarget): """ return self.point_from_a(self._position_distance) + @property + def points(self) -> Iterator[Point]: + yield self.segments[0].point_a + for segment in self.segments: + yield segment.point_b + @property def control_points(self) -> Tuple[ControlPoint, ControlPoint]: """Returns a tuple of the two control points.""" diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index a6777663..b3a67d3b 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -267,6 +267,7 @@ class ControlPoint(MissionTarget, ABC): # TODO: Should be Airbase specific. self.has_frontline = has_frontline self.connected_points: List[ControlPoint] = [] + self.shipping_lanes: Dict[ControlPoint, List[Point]] = {} self.convoy_spawns: Dict[ControlPoint, Point] = {} self.base: Base = Base() self.cptype = cptype @@ -397,6 +398,9 @@ class ControlPoint(MissionTarget, ABC): self.convoy_spawns[to] = convoy_location self.stances[to.id] = CombatStance.DEFENSIVE + def create_shipping_lane(self, to: ControlPoint, waypoints: List[Point]) -> None: + self.shipping_lanes[to] = waypoints + @abstractmethod def runway_is_operational(self) -> bool: """ diff --git a/game/utils.py b/game/utils.py index 85fa85da..a35a41cd 100644 --- a/game/utils.py +++ b/game/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import math from dataclasses import dataclass from typing import Union @@ -178,3 +179,13 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) + + +def pairwise(iterable): + """ + itertools recipe + s -> (s0,s1), (s1,s2), (s2, s3), ... + """ + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) diff --git a/game/version.py b/game/version.py index 5827b455..b48f3056 100644 --- a/game/version.py +++ b/game/version.py @@ -39,4 +39,9 @@ VERSION = _build_version_string() #: * Factories (Workshop_A) define factory objectives. Only control points with #: factories will be able to recruit ground units, so they should exist in sufficient #: number and be protected by IADS. -CAMPAIGN_FORMAT_VERSION = 2 +#: +#: Version 3 +#: * Bulker Handy Winds define shipping lanes. They should be placed in port areas that +#: are navigable by ships and have a route to another port area. DCS ships *will not* +#: avoid driving into islands, so ensure that their waypoints plot a navigable route. +CAMPAIGN_FORMAT_VERSION = 3 diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index b01504e1..d5e2ec2a 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -45,7 +45,7 @@ from game.theater.theatergroundobject import ( TheaterGroundObject, ) from game.transfers import Convoy -from game.utils import Distance, meters, nautical_miles +from game.utils import Distance, meters, nautical_miles, pairwise from game.weather import TimeOfDay from gen import Conflict, Package from gen.flights.flight import ( @@ -67,12 +67,16 @@ from qt_ui.widgets.map.QFrontLine import QFrontLine from qt_ui.widgets.map.QLiberationScene import QLiberationScene from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject +from qt_ui.widgets.map.ShippingLaneSegment import ShippingLaneSegment from qt_ui.widgets.map.SupplyRouteSegment import SupplyRouteSegment from qt_ui.windows.GameUpdateSignal import GameUpdateSignal MAX_SHIP_DISTANCE = nautical_miles(80) +MapPoint = Tuple[float, float] + + def binomial(i: int, n: int) -> float: """Binomial coefficient""" return math.factorial(n) / float(math.factorial(i) * math.factorial(n - i)) @@ -821,47 +825,64 @@ class QLiberationMap(QGraphicsView): ) ) + def bezier_points( + self, points: Iterable[Point] + ) -> Iterator[Tuple[MapPoint, MapPoint]]: + # Thanks to Alquimista for sharing a python implementation of the bezier + # algorithm this is adapted from. + # https://gist.github.com/Alquimista/1274149#file-bezdraw-py + bezier_fixed_points = [] + for a, b in pairwise(points): + bezier_fixed_points.append(self._transform_point(a)) + bezier_fixed_points.append(self._transform_point(b)) + + old_point = bezier_fixed_points[0] + for point in bezier_curve_range( + int(len(bezier_fixed_points) * 2), bezier_fixed_points + ): + yield old_point, point + old_point = point + def draw_bezier_frontline( self, scene: QGraphicsScene, frontline: FrontLine, convoys: List[Convoy], ) -> None: - """ - Thanks to Alquimista for sharing a python implementation of the bezier algorithm this is adapted from. - https://gist.github.com/Alquimista/1274149#file-bezdraw-py - """ - bezier_fixed_points = [] - for segment in frontline.segments: - bezier_fixed_points.append(self._transform_point(segment.point_a)) - bezier_fixed_points.append(self._transform_point(segment.point_b)) - - old_point = bezier_fixed_points[0] - for point in bezier_curve_range( - int(len(bezier_fixed_points) * 2), bezier_fixed_points - ): + for a, b in self.bezier_points(frontline.points): scene.addItem( SupplyRouteSegment( - old_point[0], - old_point[1], - point[0], - point[1], + a[0], + a[1], + b[0], + b[1], frontline.control_point_a, frontline.control_point_b, convoys, ) ) - old_point = point def draw_supply_routes(self) -> None: + if not DisplayOptions.lines: + return + seen = set() for cp in self.game.theater.controlpoints: seen.add(cp) for connected in cp.connected_points: if connected in seen: continue - if DisplayOptions.lines: - self.draw_supply_route_between(cp, connected) + self.draw_supply_route_between(cp, connected) + for destination, shipping_lane in cp.shipping_lanes.items(): + if destination in seen: + continue + if cp.is_friendly(destination.captured): + self.draw_shipping_lane_between(cp, destination) + + def draw_shipping_lane_between(self, a: ControlPoint, b: ControlPoint) -> None: + scene = self.scene() + for pa, pb in self.bezier_points(a.shipping_lanes[b]): + scene.addItem(ShippingLaneSegment(pa[0], pa[1], pb[0], pb[1], a, b)) def draw_supply_route_between(self, a: ControlPoint, b: ControlPoint) -> None: scene = self.scene() diff --git a/qt_ui/widgets/map/ShippingLaneSegment.py b/qt_ui/widgets/map/ShippingLaneSegment.py new file mode 100644 index 00000000..9eb111f8 --- /dev/null +++ b/qt_ui/widgets/map/ShippingLaneSegment.py @@ -0,0 +1,68 @@ +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtGui import QColor, QPen +from PySide2.QtWidgets import ( + QGraphicsItem, + QGraphicsLineItem, +) + +from game.theater import ControlPoint +from qt_ui.uiconstants import COLORS + + +class ShippingLaneSegment(QGraphicsLineItem): + def __init__( + self, + x0: float, + y0: float, + x1: float, + y1: float, + control_point_a: ControlPoint, + control_point_b: ControlPoint, + parent: Optional[QGraphicsItem] = None, + ) -> None: + super().__init__(x0, y0, x1, y1, parent) + self.control_point_a = control_point_a + self.control_point_b = control_point_b + self.ships = [] + self.setPen(self.make_pen()) + self.setToolTip(self.make_tooltip()) + self.setAcceptHoverEvents(True) + + @property + def has_ships(self) -> bool: + return bool(self.ships) + + def make_tooltip(self) -> str: + if not self.has_ships: + return "No ships present in this shipping lane." + + ships = [] + for ship in self.ships: + units = "units" if ship.size > 1 else "unit" + ships.append( + f"{ship.size} {units} transferring from {ship.origin} to " + f"{ship.destination}." + ) + return "\n".join(ships) + + @property + def line_color(self) -> QColor: + if self.control_point_a.captured: + return COLORS["dark_blue"] + else: + return COLORS["dark_red"] + + @property + def line_style(self) -> Qt.PenStyle: + if self.has_ships: + return Qt.PenStyle.SolidLine + return Qt.PenStyle.DotLine + + def make_pen(self) -> QPen: + pen = QPen(brush=self.line_color) + pen.setColor(self.line_color) + pen.setStyle(self.line_style) + pen.setWidth(2) + return pen