diff --git a/game/game.py b/game/game.py index d69f9cc8..20d3ddb1 100644 --- a/game/game.py +++ b/game/game.py @@ -26,7 +26,7 @@ from .event.frontlineattack import FrontlineAttackEvent from .factions.faction import Faction from .infos.information import Information from .settings import Settings -from .theater import ConflictTheater, ControlPoint +from .theater import ConflictTheater, ControlPoint, OffMapSpawn from .weather import Conditions, TimeOfDay COMMISION_UNIT_VARIETY = 4 @@ -289,6 +289,9 @@ class Game: if len(potential_cp_armor) == 0: potential_cp_armor = self.theater.enemy_points() + potential_cp_armor = [p for p in potential_cp_armor if + not isinstance(p, OffMapSpawn)] + i = 0 potential_units = db.FACTIONS[self.enemy_name].frontline_units diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 566118f9..7766c90e 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -16,6 +16,7 @@ from dcs.countries import ( ) from dcs.country import Country from dcs.mapping import Point +from dcs.planes import F_15C from dcs.ships import ( CVN_74_John_C__Stennis, LHA_1_Tarawa, @@ -31,11 +32,17 @@ from dcs.terrain import ( thechannel, ) from dcs.terrain.terrain import Airport, Terrain -from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup +from dcs.unitgroup import ( + FlyingGroup, + Group, + ShipGroup, + StaticGroup, + VehicleGroup, +) from dcs.vehicles import AirDefence, Armor from gen.flights.flight import FlightType -from .controlpoint import ControlPoint, MissionTarget +from .controlpoint import ControlPoint, MissionTarget, OffMapSpawn from .landmap import Landmap, load_landmap, poly_contains from ..utils import nm_to_meter @@ -94,6 +101,8 @@ class MizCampaignLoader: BLUE_COUNTRY = CombinedJointTaskForcesBlue() RED_COUNTRY = CombinedJointTaskForcesRed() + OFF_MAP_UNIT_TYPE = F_15C.id + CV_UNIT_TYPE = CVN_74_John_C__Stennis.id LHA_UNIT_TYPE = LHA_1_Tarawa.id FRONT_LINE_UNIT_TYPE = Armor.APC_M113.id @@ -175,6 +184,11 @@ class MizCampaignLoader: def red(self) -> Country: return self.country(blue=False) + def off_map_spawns(self, blue: bool) -> Iterator[FlyingGroup]: + for group in self.country(blue).plane_group: + if group.units[0].type == self.OFF_MAP_UNIT_TYPE: + yield group + def carriers(self, blue: bool) -> Iterator[ShipGroup]: for group in self.country(blue).ship_group: if group.units[0].type == self.CV_UNIT_TYPE: @@ -236,6 +250,12 @@ class MizCampaignLoader: control_points[control_point.id] = control_point for blue in (False, True): + for group in self.off_map_spawns(blue): + control_point = OffMapSpawn(next(self.control_point_id), + str(group.name), group.position) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point for group in self.carriers(blue): # TODO: Name the carrier. control_point = ControlPoint.carrier( diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 4e61cfa5..88d622a6 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -40,6 +40,7 @@ class ControlPointType(Enum): LHA_GROUP = 2 # A group with a Tarawa carrier (Helicopters & Harrier) FARP = 4 # A FARP, with slots for helicopters FOB = 5 # A FOB (ground units only) + OFF_MAP = 6 class LocationType(Enum): @@ -364,3 +365,17 @@ class ControlPoint(MissionTarget): yield from [ # TODO: FlightType.STRIKE ] + + +class OffMapSpawn(ControlPoint): + def __init__(self, id: int, name: str, position: Point): + from . import IMPORTANCE_MEDIUM, SIZE_REGULAR + super().__init__(id, name, position, at=position, radials=[], + size=SIZE_REGULAR, importance=IMPORTANCE_MEDIUM, + has_frontline=False, cptype=ControlPointType.OFF_MAP) + + def capture(self, game: Game, for_player: bool) -> None: + raise RuntimeError("Off map control points cannot be captured") + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + yield from [] diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 2eee490a..ed30750b 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -42,8 +42,7 @@ from gen.sam.sam_group_generator import ( from theater import ( ConflictTheater, ControlPoint, - ControlPointType, - TheaterGroundObject, + ControlPointType, OffMapSpawn, ) GroundObjectTemplates = Dict[str, Dict[str, Any]] @@ -139,7 +138,13 @@ class GameGenerator: control_point.base.commision_points = {} control_point.base.strength = 1 + # The tasks here are confusing. PinpointStrike for some reason means + # ground units. for task in [PinpointStrike, CAP, CAS, AirDefence]: + if isinstance(control_point, OffMapSpawn): + # Off-map spawn locations start with no aircraft. + continue + if IMPORTANCE_HIGH <= control_point.importance <= IMPORTANCE_LOW: raise ValueError( f"CP importance must be between {IMPORTANCE_LOW} and " @@ -366,6 +371,11 @@ class ControlPointGroundObjectGenerator: self.control_point.connected_objectives.append(g) +class NoOpGroundObjectGenerator(ControlPointGroundObjectGenerator): + def generate(self) -> bool: + return True + + class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): def generate(self) -> bool: if not super().generate(): @@ -660,6 +670,8 @@ class GroundObjectGenerator: generator = CarrierGroundObjectGenerator(self.game, control_point) elif control_point.cptype == ControlPointType.LHA_GROUP: generator = LhaGroundObjectGenerator(self.game, control_point) + elif isinstance(control_point, OffMapSpawn): + generator = NoOpGroundObjectGenerator(self.game, control_point) else: generator = AirbaseGroundObjectGenerator(self.game, control_point, self.templates) diff --git a/game/utils.py b/game/utils.py index 44652472..58bc5018 100644 --- a/game/utils.py +++ b/game/utils.py @@ -12,3 +12,7 @@ def meter_to_nm(value_in_meter: float) -> int: def nm_to_meter(value_in_nm: float) -> int: return int(value_in_nm * 1852) + + +def knots_to_kph(knots: float) -> int: + return int(knots * 1.852) diff --git a/gen/aircraft.py b/gen/aircraft.py index 9eccfb17..9edc52f4 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -70,7 +70,7 @@ from dcs.unittype import FlyingType, UnitType from game import db from game.data.cap_capabilities_db import GUNFIGHTERS from game.settings import Settings -from game.utils import nm_to_meter +from game.utils import knots_to_kph, nm_to_meter from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit @@ -84,7 +84,11 @@ from gen.flights.flight import ( from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData from theater import TheaterGroundObject -from game.theater.controlpoint import ControlPoint, ControlPointType +from game.theater.controlpoint import ( + ControlPoint, + ControlPointType, + OffMapSpawn, +) from .conflictgen import Conflict from .flights.flightplan import ( CasFlightPlan, @@ -92,7 +96,7 @@ from .flights.flightplan import ( PatrollingFlightPlan, SweepFlightPlan, ) -from .flights.traveltime import TotEstimator +from .flights.traveltime import GroundSpeed, TotEstimator from .naming import namegen from .runways import RunwayAssigner @@ -804,31 +808,37 @@ class AircraftConflictGenerator: group_size=count, parking_slots=None) - def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, at: Point) -> FlyingGroup: - assert count > 0 + def _generate_inflight(self, name: str, side: Country, flight: Flight, + origin: ControlPoint) -> FlyingGroup: + assert flight.count > 0 + at = origin.position - if unit_type in helicopters.helicopter_map.values(): + alt_type = "RADIO" + if isinstance(origin, OffMapSpawn): + alt = flight.flight_plan.waypoints[0].alt + alt_type = flight.flight_plan.waypoints[0].alt_type + elif flight.unit_type in helicopters.helicopter_map.values(): alt = WARM_START_HELI_ALT - speed = WARM_START_HELI_AIRSPEED else: alt = WARM_START_ALTITUDE - speed = WARM_START_AIRSPEED + + speed = knots_to_kph(GroundSpeed.for_flight(flight, alt)) pos = Point(at.x + random.randint(100, 1000), at.y + random.randint(100, 1000)) - logging.info("airgen: {} for {} at {} at {}".format(unit_type, side.id, alt, speed)) + logging.info("airgen: {} for {} at {} at {}".format(flight.unit_type, side.id, alt, speed)) group = self.m.flight_group( country=side, name=name, - aircraft_type=unit_type, + aircraft_type=flight.unit_type, airport=None, position=pos, altitude=alt, speed=speed, maintask=None, - group_size=count) + group_size=flight.count) - group.points[0].alt_type = "RADIO" + group.points[0].alt_type = alt_type return group def _generate_at_group(self, name: str, side: Country, @@ -974,9 +984,8 @@ class AircraftConflictGenerator: group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, - unit_type=flight.unit_type, - count=flight.count, - at=cp.position) + flight=flight, + origin=cp) elif cp.is_fleet: group_name = cp.get_carrier_group_name() group = self._generate_at_group( @@ -1002,9 +1011,8 @@ class AircraftConflictGenerator: group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, - unit_type=flight.unit_type, - count=flight.count, - at=cp.position) + flight=flight, + origin=cp) group.points[0].alt = 1500 return group diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index ee458908..7d152efa 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -50,7 +50,7 @@ from theater import ( ControlPoint, FrontLine, MissionTarget, - TheaterGroundObject, + OffMapSpawn, TheaterGroundObject, SamGroundObject, ) @@ -232,8 +232,12 @@ class PackageBuilder: if assignment is None: return False airfield, aircraft = assignment + if isinstance(airfield, OffMapSpawn): + start_type = "In Flight" + else: + start_type = self.start_type flight = Flight(self.package, aircraft, plan.num_aircraft, airfield, - plan.task, self.start_type) + plan.task, start_type) self.package.add_flight(flight) return True @@ -406,6 +410,9 @@ class ObjectiveFinder: CP. """ for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + # Off-map spawn locations don't need protection. + continue airfields_in_proximity = self.closest_airfields_to(cp) airfields_in_threat_range = airfields_in_proximity.airfields_within( self.AIRFIELD_THREAT_RANGE diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index f220ebb4..346a4498 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -8,11 +8,15 @@ from dcs.unit import Unit from dcs.unitgroup import VehicleGroup from game.data.doctrine import Doctrine -from game.utils import nm_to_meter +from game.utils import feet_to_meter from game.weather import Conditions -from theater import ControlPoint, MissionTarget, TheaterGroundObject +from theater import ( + ControlPoint, + MissionTarget, + OffMapSpawn, + TheaterGroundObject, +) from .flight import Flight, FlightWaypoint, FlightWaypointType -from ..runways import RunwayAssigner @dataclass(frozen=True) @@ -34,8 +38,7 @@ class WaypointBuilder: def is_helo(self) -> bool: return getattr(self.flight.unit_type, "helicopter", False) - @staticmethod - def takeoff(departure: ControlPoint) -> FlightWaypoint: + def takeoff(self, departure: ControlPoint) -> FlightWaypoint: """Create takeoff waypoint for the given arrival airfield or carrier. Note that the takeoff waypoint will automatically be created by pydcs @@ -46,36 +49,59 @@ class WaypointBuilder: departure: Departure airfield or carrier. """ position = departure.position - waypoint = FlightWaypoint( - FlightWaypointType.TAKEOFF, - position.x, - position.y, - 0 - ) - waypoint.name = "TAKEOFF" - waypoint.alt_type = "RADIO" - waypoint.description = "Takeoff" - waypoint.pretty_name = "Takeoff" + if isinstance(departure, OffMapSpawn): + waypoint = FlightWaypoint( + FlightWaypointType.NAV, + position.x, + position.y, + 500 if self.is_helo else self.doctrine.rendezvous_altitude + ) + waypoint.name = "NAV" + waypoint.alt_type = "BARO" + waypoint.description = "Enter theater" + waypoint.pretty_name = "Enter theater" + else: + waypoint = FlightWaypoint( + FlightWaypointType.TAKEOFF, + position.x, + position.y, + 0 + ) + waypoint.name = "TAKEOFF" + waypoint.alt_type = "RADIO" + waypoint.description = "Takeoff" + waypoint.pretty_name = "Takeoff" return waypoint - @staticmethod - def land(arrival: ControlPoint) -> FlightWaypoint: + def land(self, arrival: ControlPoint) -> FlightWaypoint: """Create descent waypoint for the given arrival airfield or carrier. Args: arrival: Arrival airfield or carrier. """ position = arrival.position - waypoint = FlightWaypoint( - FlightWaypointType.LANDING_POINT, - position.x, - position.y, - 0 - ) - waypoint.name = "LANDING" - waypoint.alt_type = "RADIO" - waypoint.description = "Land" - waypoint.pretty_name = "Land" + if isinstance(arrival, OffMapSpawn): + waypoint = FlightWaypoint( + FlightWaypointType.NAV, + position.x, + position.y, + 500 if self.is_helo else self.doctrine.rendezvous_altitude + ) + waypoint.name = "NAV" + waypoint.alt_type = "BARO" + waypoint.description = "Exit theater" + waypoint.pretty_name = "Exit theater" + else: + waypoint = FlightWaypoint( + FlightWaypointType.LANDING_POINT, + position.x, + position.y, + 0 + ) + waypoint.name = "LANDING" + waypoint.alt_type = "RADIO" + waypoint.description = "Land" + waypoint.pretty_name = "Land" return waypoint def hold(self, position: Point) -> FlightWaypoint: diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index e59cdbfb..7ef55952 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -79,11 +79,8 @@ class QMapControlPoint(QMapObject): for connected in self.control_point.connected_points: if connected.captured: + menu.addAction(self.capture_action) break - else: - return - - menu.addAction(self.capture_action) def cheat_capture(self) -> None: self.control_point.capture(self.game_model.game, for_player=True) diff --git a/qt_ui/widgets/map/QMapObject.py b/qt_ui/widgets/map/QMapObject.py index a3c57c19..16f07061 100644 --- a/qt_ui/widgets/map/QMapObject.py +++ b/qt_ui/widgets/map/QMapObject.py @@ -47,9 +47,12 @@ class QMapObject(QGraphicsRectItem): object_details_action.triggered.connect(self.on_click) menu.addAction(object_details_action) - new_package_action = QAction(f"New package") - new_package_action.triggered.connect(self.open_new_package_dialog) - menu.addAction(new_package_action) + # Not all locations have valid objetives. Off-map spawns, for example, + # have no mission types. + if list(self.mission_target.mission_types(for_player=True)): + new_package_action = QAction(f"New package") + new_package_action.triggered.connect(self.open_new_package_dialog) + menu.addAction(new_package_action) self.add_context_menu_actions(menu) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index cf5e1a34..cfdb8128 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -18,7 +18,6 @@ class QBaseMenu2(QDialog): # Attrs self.cp = cp self.game_model = game_model - self.is_carrier = self.cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP] self.objectName = "menuDialogue" # Widgets diff --git a/qt_ui/windows/basemenu/QBaseMenuTabs.py b/qt_ui/windows/basemenu/QBaseMenuTabs.py index 0c82c86e..fd2f7ae7 100644 --- a/qt_ui/windows/basemenu/QBaseMenuTabs.py +++ b/qt_ui/windows/basemenu/QBaseMenuTabs.py @@ -5,39 +5,30 @@ from qt_ui.windows.basemenu.airfield.QAirfieldCommand import QAirfieldCommand from qt_ui.windows.basemenu.base_defenses.QBaseDefensesHQ import QBaseDefensesHQ from qt_ui.windows.basemenu.ground_forces.QGroundForcesHQ import QGroundForcesHQ from qt_ui.windows.basemenu.intel.QIntelInfo import QIntelInfo -from theater import ControlPoint +from theater import ControlPoint, OffMapSpawn class QBaseMenuTabs(QTabWidget): def __init__(self, cp: ControlPoint, game_model: GameModel): super(QBaseMenuTabs, self).__init__() - self.cp = cp - if cp: - - if not cp.captured: - if not cp.is_carrier: - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") - self.intel = QIntelInfo(cp, game_model.game) - self.addTab(self.intel, "Intel") - else: - if cp.has_runway(): - self.airfield_command = QAirfieldCommand(cp, game_model) - self.addTab(self.airfield_command, "Airfield Command") - - if not cp.is_carrier: - self.ground_forces_hq = QGroundForcesHQ(cp, game_model) - self.addTab(self.ground_forces_hq, "Ground Forces HQ") - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") - else: - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Fleet") + if not cp.captured: + if not cp.is_carrier and not isinstance(cp, OffMapSpawn): + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) + self.addTab(self.base_defenses_hq, "Base Defenses") + self.intel = QIntelInfo(cp, game_model.game) + self.addTab(self.intel, "Intel") else: - tabError = QFrame() - l = QGridLayout() - l.addWidget(QLabel("No Control Point")) - tabError.setLayout(l) - self.addTab(tabError, "No Control Point") \ No newline at end of file + if cp.has_runway(): + self.airfield_command = QAirfieldCommand(cp, game_model) + self.addTab(self.airfield_command, "Airfield Command") + + if cp.is_carrier: + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) + self.addTab(self.base_defenses_hq, "Fleet") + elif not isinstance(cp, OffMapSpawn): + self.ground_forces_hq = QGroundForcesHQ(cp, game_model) + self.addTab(self.ground_forces_hq, "Ground Forces HQ") + self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) + self.addTab(self.base_defenses_hq, "Base Defenses") \ No newline at end of file diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index f4fe6041..80fbf219 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -18,7 +18,7 @@ from qt_ui.widgets.QLabeledWidget import QLabeledWidget from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector -from theater import ControlPoint +from theater import ControlPoint, OffMapSpawn class QFlightCreator(QDialog): @@ -107,7 +107,9 @@ class QFlightCreator(QDialog): origin = self.airfield_selector.currentData() size = self.flight_size_spinner.value() - if self.game.settings.perf_ai_parking_start: + if isinstance(origin, OffMapSpawn): + start_type = "In Flight" + elif self.game.settings.perf_ai_parking_start: start_type = "Cold" else: start_type = "Warm" diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 381d8e39..c8d4562f 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -42,15 +42,7 @@ class QFlightWaypointList(QTableView): self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"]) - # The first waypoint is set up by pydcs at mission generation time, so - # we need to add that waypoint manually. - takeoff = FlightWaypoint(self.flight.from_cp.position.x, - self.flight.from_cp.position.y, 0) - takeoff.description = "Take Off" - takeoff.name = takeoff.pretty_name = "Take Off from " + self.flight.from_cp.name - takeoff.alt_type = "RADIO" - - waypoints = itertools.chain([takeoff], self.flight.points) + waypoints = self.flight.flight_plan.waypoints for row, waypoint in enumerate(waypoints): self.add_waypoint_row(row, self.flight, waypoint) self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), diff --git a/resources/campaigns/inherent_resolve.miz b/resources/campaigns/inherent_resolve.miz index 12dbaed8..6b1e4be3 100644 Binary files a/resources/campaigns/inherent_resolve.miz and b/resources/campaigns/inherent_resolve.miz differ