diff --git a/.gitignore b/.gitignore index 8c7d79f3..1bf595f6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ tests/** /liberation_preferences.json /state.json -logs/liberation.log +logs/ qt_ui/logs/liberation.log diff --git a/gen/aircraft.py b/gen/aircraft.py index f9cd63f1..cbb028fe 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import random from dataclasses import dataclass @@ -28,7 +30,7 @@ from dcs.planes import ( SpitfireLFMkIXCW, Su_33, ) -from dcs.point import PointAction +from dcs.point import MovingPoint, PointAction from dcs.task import ( AntishipStrike, AttackGroup, @@ -40,8 +42,6 @@ from dcs.task import ( EngageTargets, Escort, GroundAttack, - MainTask, - NoTask, OptROE, OptRTBOnBingoFuel, OptRTBOnOutOfAmmo, @@ -64,10 +64,10 @@ 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 meter_to_nm, nm_to_meter from gen.airfields import RunwayData from gen.airsupportgen import AirSupport -from gen.ato import AirTaskingOrder +from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( Flight, @@ -76,9 +76,10 @@ from gen.flights.flight import ( FlightWaypointType, ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio +from theater import MissionTarget, TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType -from .naming import namegen from .conflictgen import Conflict +from .naming import namegen WARM_START_HELI_AIRSPEED = 120 WARM_START_HELI_ALT = 500 @@ -532,6 +533,148 @@ AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] +@dataclass(frozen=True) +class PackageWaypointTiming: + #: The package being scheduled. + package: Package + + #: The package join time. + join: int + + #: The ingress waypoint TOT. + ingress: int + + #: The egress waypoint TOT. + egress: int + + #: The package split time. + split: int + + @property + def target(self) -> int: + """The package time over target.""" + assert self.package.time_over_target is not None + return self.package.time_over_target + + @property + def race_track_start(self) -> Optional[int]: + cap_types = (FlightType.BARCAP, FlightType.CAP) + if self.package.primary_task in cap_types: + # CAP flights don't have hold points, and we don't calculate takeoff + # times yet or adjust the TOT based on when the flight can arrive, + # so if we set a TOT that gives the flight a lot of extra time it + # will just fly to the start point slowly, possibly slowly enough to + # stall and crash. Just don't set a TOT for these points and let the + # CAP get on station ASAP. + return None + else: + return self.ingress + + @property + def race_track_end(self) -> int: + cap_types = (FlightType.BARCAP, FlightType.CAP) + if self.package.primary_task in cap_types: + return self.target + CAP_DURATION * 60 + else: + return self.egress + + def push_time(self, flight: Flight, hold_point: Point) -> int: + assert self.package.waypoints is not None + return self.join - self.travel_time( + hold_point, + self.package.waypoints.join, + self.flight_ground_speed(flight) + ) + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: + target_types = ( + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + + ingress_types = ( + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + ) + + if waypoint.waypoint_type == FlightWaypointType.JOIN: + return self.join + elif waypoint.waypoint_type in ingress_types: + return self.ingress + elif waypoint.waypoint_type in target_types: + return self.target + elif waypoint.waypoint_type == FlightWaypointType.EGRESS: + return self.egress + elif waypoint.waypoint_type == FlightWaypointType.SPLIT: + return self.split + elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK: + return self.race_track_start + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint, + flight: Flight) -> Optional[int]: + if waypoint.waypoint_type == FlightWaypointType.LOITER: + return self.push_time(flight, Point(waypoint.x, waypoint.y)) + elif waypoint.waypoint_type == FlightWaypointType.PATROL: + return self.race_track_end + return None + + @classmethod + def for_package(cls, package: Package) -> PackageWaypointTiming: + assert package.time_over_target is not None + assert package.waypoints is not None + + group_ground_speed = cls.package_ground_speed(package) + + ingress = package.time_over_target - cls.travel_time( + package.waypoints.ingress, + package.target.position, + group_ground_speed + ) + + join = ingress - cls.travel_time( + package.waypoints.join, + package.waypoints.ingress, + group_ground_speed + ) + + egress = package.time_over_target + cls.travel_time( + package.target.position, + package.waypoints.egress, + group_ground_speed + ) + + split = egress + cls.travel_time( + package.waypoints.egress, + package.waypoints.split, + group_ground_speed + ) + + return cls(package, join, ingress, egress, split) + + @classmethod + def package_ground_speed(cls, package: Package) -> int: + speeds = [] + for flight in package.flights: + speeds.append(cls.flight_ground_speed(flight)) + return min(speeds) # knots + + @staticmethod + def flight_ground_speed(_flight: Flight) -> int: + # TODO: Gather data so this is useful. + return 400 # knots + + @staticmethod + def travel_time(a: Point, b: Point, speed: float) -> int: + error_factor = 1.1 + distance = meter_to_nm(a.distance_to_point(b)) + hours = distance / speed + seconds = hours * 3600 + return int(seconds * error_factor) + + class AircraftConflictGenerator: def __init__(self, mission: Mission, conflict: Conflict, settings: Settings, game, radio_registry: RadioRegistry): @@ -634,7 +777,8 @@ class AircraftConflictGenerator: arrival=departure_runway, # TODO: Support for divert airfields. divert=None, - waypoints=[first_point] + flight.points, + # Waypoints are added later, after they've had their TOTs set. + waypoints=[], intra_flight_channel=channel )) @@ -817,6 +961,7 @@ class AircraftConflictGenerator: self.clear_parking_slots() for package in ato.packages: + timing = PackageWaypointTiming.for_package(package) for flight in package.flights: culled = self.game.position_culled(flight.from_cp.position) if flight.client_count == 0 and culled: @@ -825,8 +970,7 @@ class AircraftConflictGenerator: logging.info(f"Generating flight: {flight.unit_type}") group = self.generate_planned_flight(flight.from_cp, country, flight) - self.setup_flight_group(group, flight, flight.flight_type, - dynamic_runways) + self.setup_flight_group(group, flight, timing, dynamic_runways) self.setup_group_activation_trigger(flight, group) def setup_group_activation_trigger(self, flight, group): @@ -936,134 +1080,334 @@ class AircraftConflictGenerator: flight.group = group return group - - def setup_flight_group(self, group, flight, flight_type, - dynamic_runways: Dict[str, RunwayData]): - - if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, FlightType.INTERCEPTION]: - group.task = CAP.name - self._setup_group(group, CAP, flight, dynamic_runways) - # group.points[0].tasks.clear() - group.points[0].tasks.clear() - group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), targets=[Targets.All.Air])) - # group.tasks.append(EngageTargets(max_distance=nm_to_meter(120), targets=[Targets.All.Air])) - if flight.unit_type not in GUNFIGHTERS: - group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.AAM)) - else: - group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.Cannon)) - - elif flight_type in [FlightType.CAS, FlightType.BAI]: - group.task = CAS.name - self._setup_group(group, CAS, flight, dynamic_runways) - group.points[0].tasks.clear() - group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(10), targets=[Targets.All.GroundUnits.GroundVehicles])) - group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) - group.points[0].tasks.append(OptROE(OptROE.Values.OpenFireWeaponFree)) - group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.Unguided)) - group.points[0].tasks.append(OptRestrictJettison(True)) - elif flight_type in [FlightType.SEAD, FlightType.DEAD]: - group.task = SEAD.name - self._setup_group(group, SEAD, flight, dynamic_runways) - group.points[0].tasks.clear() - group.points[0].tasks.append(NoTask()) - group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) - group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire)) - group.points[0].tasks.append(OptRestrictJettison(True)) - group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.ASM)) - elif flight_type in [FlightType.STRIKE]: - group.task = PinpointStrike.name - self._setup_group(group, GroundAttack, flight, dynamic_runways) - group.points[0].tasks.clear() - group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) - group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire)) - group.points[0].tasks.append(OptRestrictJettison(True)) - elif flight_type in [FlightType.ANTISHIP]: - group.task = AntishipStrike.name - self._setup_group(group, AntishipStrike, flight, dynamic_runways) - group.points[0].tasks.clear() - group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) - group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire)) - group.points[0].tasks.append(OptRestrictJettison(True)) - elif flight_type == FlightType.ESCORT: - group.task = Escort.name - self._setup_group(group, Escort, flight, dynamic_runways) - # TODO: Cleanup duplication... - group.points[0].tasks.clear() - group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) - group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire)) - group.points[0].tasks.append(OptRestrictJettison(True)) + @staticmethod + def configure_behavior( + group: FlyingGroup, + react_on_threat: Optional[OptReactOnThreat.Values] = None, + roe: Optional[OptROE.Values] = None, + rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None, + restrict_jettison: Optional[bool] = None) -> None: + group.points[0].tasks.clear() + if react_on_threat is not None: + group.points[0].tasks.append(OptReactOnThreat(react_on_threat)) + if roe is not None: + group.points[0].tasks.append(OptROE(roe)) + if restrict_jettison is not None: + group.points[0].tasks.append(OptRestrictJettison(restrict_jettison)) + if rtb_winchester is not None: + group.points[0].tasks.append(OptRTBOnOutOfAmmo(rtb_winchester)) group.points[0].tasks.append(OptRTBOnBingoFuel(True)) group.points[0].tasks.append(OptRestrictAfterburner(True)) + @staticmethod + def configure_eplrs(group: FlyingGroup, flight: Flight) -> None: if hasattr(flight.unit_type, 'eplrs'): if flight.unit_type.eplrs: group.points[0].tasks.append(EPLRS(group.id)) - for i, point in enumerate(flight.points): - if not point.only_for_player or (point.only_for_player and flight.client_count > 0): - pt = group.add_waypoint(Point(point.x, point.y), point.alt) - if point.waypoint_type == FlightWaypointType.PATROL_TRACK: - action = ControlledTask(OrbitAction(altitude=pt.alt, pattern=OrbitAction.OrbitPattern.RaceTrack)) - action.stop_after_duration(CAP_DURATION * 60) - #for tgt in point.targets: - # if hasattr(tgt, "position"): - # engagetgt = EngageTargetsInZone(tgt.position, radius=CAP_DEFAULT_ENGAGE_DISTANCE, targets=[Targets.All.Air]) - # pt.tasks.append(engagetgt) - elif point.waypoint_type == FlightWaypointType.LANDING_POINT: - pt.type = "Land" - pt.action = PointAction.Landing - elif point.waypoint_type == FlightWaypointType.INGRESS_STRIKE: + def configure_cap(self, group: FlyingGroup, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = CAP.name + self._setup_group(group, CAP, flight, dynamic_runways) - if group.units[0].unit_type == B_17G: - if len(point.targets) > 0: - bcenter = Point(0,0) - for j, t in enumerate(point.targets): - bcenter.x += t.position.x - bcenter.y += t.position.y - bcenter.x = bcenter.x / len(point.targets) - bcenter.y = bcenter.y / len(point.targets) - bombing = Bombing(bcenter) - bombing.params["expend"] = "All" - bombing.params["attackQtyLimit"] = False - bombing.params["directionEnabled"] = False - bombing.params["altitudeEnabled"] = False - bombing.params["weaponType"] = 2032 - bombing.params["groupAttack"] = True - pt.tasks.append(bombing) - else: - for j, t in enumerate(point.targets): - print(t.position) - pt.tasks.append(Bombing(t.position)) - if group.units[0].unit_type == JF_17 and j < 4: - group.add_nav_target_point(t.position, "PP" + str(j + 1)) - if group.units[0].unit_type == F_14B and j == 0: - group.add_nav_target_point(t.position, "ST") - if group.units[0].unit_type == AJS37 and j < 9: - group.add_nav_target_point(t.position, "M" + str(j + 1)) - elif point.waypoint_type == FlightWaypointType.INGRESS_SEAD: + if flight.unit_type not in GUNFIGHTERS: + ammo_type = OptRTBOnOutOfAmmo.Values.AAM + else: + ammo_type = OptRTBOnOutOfAmmo.Values.Cannon - tgroup = self.m.find_group(point.targetGroup.group_identifier) - if tgroup is not None: - task = AttackGroup(tgroup.id) - task.params["expend"] = "All" - task.params["attackQtyLimit"] = False - task.params["directionEnabled"] = False - task.params["altitudeEnabled"] = False - task.params["weaponType"] = 268402702 # Guided Weapons - task.params["groupAttack"] = True - pt.tasks.append(task) + self.configure_behavior(group, rtb_winchester=ammo_type) - for j, t in enumerate(point.targets): - if group.units[0].unit_type == JF_17 and j < 4: - group.add_nav_target_point(t.position, "PP" + str(j + 1)) - if group.units[0].unit_type == F_14B and j == 0: - group.add_nav_target_point(t.position, "ST") - if group.units[0].unit_type == AJS37 and j < 9: - group.add_nav_target_point(t.position, "M" + str(j + 1)) + group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), + targets=[Targets.All.Air])) - if pt is not None: - pt.alt_type = point.alt_type - pt.name = String(point.name) + def configure_cas(self, group: FlyingGroup, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = CAS.name + self._setup_group(group, CAS, flight, dynamic_runways) + self.configure_behavior( + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.OpenFireWeaponFree, + rtb_winchester=OptRTBOnOutOfAmmo.Values.Unguided, + restrict_jettison=True) + group.points[0].tasks.append( + EngageTargets(max_distance=nm_to_meter(10), + targets=[Targets.All.GroundUnits.GroundVehicles]) + ) + def configure_sead(self, group: FlyingGroup, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = SEAD.name + self._setup_group(group, SEAD, flight, dynamic_runways) + self.configure_behavior( + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.OpenFire, + rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM, + restrict_jettison=True) + + def configure_strike(self, group: FlyingGroup, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = PinpointStrike.name + self._setup_group(group, GroundAttack, flight, dynamic_runways) + self.configure_behavior( + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.OpenFire, + restrict_jettison=True) + + def configure_anti_ship(self, group: FlyingGroup, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = AntishipStrike.name + self._setup_group(group, AntishipStrike, flight, dynamic_runways) + self.configure_behavior( + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.OpenFire, + restrict_jettison=True) + + def configure_escort(self, group: FlyingGroup, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = Escort.name + self._setup_group(group, Escort, flight, dynamic_runways) + self.configure_behavior(group, roe=OptROE.Values.OpenFire, + restrict_jettison=True) + + def configure_unknown_task(self, group: FlyingGroup, + flight: Flight) -> None: + logging.error(f"Unhandled flight type: {flight.flight_type.name}") + self.configure_behavior(group) + + def setup_flight_group(self, group: FlyingGroup, flight: Flight, + timing: PackageWaypointTiming, + dynamic_runways: Dict[str, RunwayData]) -> None: + flight_type = flight.flight_type + if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, + FlightType.INTERCEPTION]: + self.configure_cap(group, flight, dynamic_runways) + elif flight_type in [FlightType.CAS, FlightType.BAI]: + self.configure_cas(group, flight, dynamic_runways) + elif flight_type in [FlightType.SEAD, FlightType.DEAD]: + self.configure_sead(group, flight, dynamic_runways) + elif flight_type in [FlightType.STRIKE]: + self.configure_strike(group, flight, dynamic_runways) + elif flight_type in [FlightType.ANTISHIP]: + self.configure_anti_ship(group, flight, dynamic_runways) + elif flight_type == FlightType.ESCORT: + self.configure_escort(group, flight, dynamic_runways) + else: + self.configure_unknown_task(group, flight) + + self.configure_eplrs(group, flight) + + for waypoint in flight.points: + waypoint.tot = None + + for point in flight.points: + if point.only_for_player and not flight.client_count: + continue + + PydcsWaypointBuilder.for_waypoint( + point, group, flight, timing, self.m + ).build() + + # Set here rather than when the FlightData is created so they waypoints + # have their TOTs set. + self.flights[-1].waypoints = flight.points self._setup_custom_payload(flight, group) + + +class PydcsWaypointBuilder: + def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup, + flight: Flight, timing: PackageWaypointTiming, + mission: Mission) -> None: + self.waypoint = waypoint + self.group = group + self.flight = flight + self.timing = timing + self.mission = mission + + def build(self) -> MovingPoint: + waypoint = self.group.add_waypoint( + Point(self.waypoint.x, self.waypoint.y), self.waypoint.alt) + + waypoint.alt_type = self.waypoint.alt_type + waypoint.name = String(self.waypoint.name) + return waypoint + + def set_waypoint_tot(self, waypoint: MovingPoint, tot: int) -> None: + self.waypoint.tot = tot + waypoint.ETA = tot + waypoint.ETA_locked = True + waypoint.speed_locked = False + + @classmethod + def for_waypoint(cls, waypoint: FlightWaypoint, + group: FlyingGroup, + flight: Flight, + timing: PackageWaypointTiming, + mission: Mission) -> PydcsWaypointBuilder: + builders = { + FlightWaypointType.EGRESS: EgressPointBuilder, + FlightWaypointType.INGRESS_SEAD: SeadIngressBuilder, + FlightWaypointType.INGRESS_STRIKE: StrikeIngressBuilder, + FlightWaypointType.JOIN: JoinPointBuilder, + FlightWaypointType.LANDING_POINT: LandingPointBuilder, + FlightWaypointType.LOITER: HoldPointBuilder, + FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, + FlightWaypointType.SPLIT: SplitPointBuilder, + FlightWaypointType.TARGET_GROUP_LOC: TargetPointBuilder, + FlightWaypointType.TARGET_POINT: TargetPointBuilder, + FlightWaypointType.TARGET_SHIP: TargetPointBuilder, + } + builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) + return builder(waypoint, group, flight, timing, mission) + + +class DefaultWaypointBuilder(PydcsWaypointBuilder): + pass + + +class HoldPointBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + loiter = ControlledTask(OrbitAction( + altitude=waypoint.alt, + pattern=OrbitAction.OrbitPattern.Circle + )) + loiter.stop_after_time( + self.timing.push_time(self.flight, waypoint.position)) + waypoint.add_task(loiter) + return waypoint + + +class EgressPointBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + self.set_waypoint_tot(waypoint, self.timing.egress) + return waypoint + + +class IngressBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + self.set_waypoint_tot(waypoint, self.timing.ingress) + return waypoint + + +class SeadIngressBuilder(IngressBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + + target_group = self.waypoint.targetGroup + if isinstance(target_group, TheaterGroundObject): + tgroup = self.mission.find_group(target_group.group_identifier) + if tgroup is not None: + task = AttackGroup(tgroup.id) + task.params["expend"] = "All" + task.params["attackQtyLimit"] = False + task.params["directionEnabled"] = False + task.params["altitudeEnabled"] = False + task.params["weaponType"] = 268402702 # Guided Weapons + task.params["groupAttack"] = True + waypoint.tasks.append(task) + + for i, t in enumerate(self.waypoint.targets): + if self.group.units[0].unit_type == JF_17 and i < 4: + self.group.add_nav_target_point(t.position, "PP" + str(i + 1)) + if self.group.units[0].unit_type == F_14B and i == 0: + self.group.add_nav_target_point(t.position, "ST") + if self.group.units[0].unit_type == AJS37 and i < 9: + self.group.add_nav_target_point(t.position, "M" + str(i + 1)) + return waypoint + + +class StrikeIngressBuilder(IngressBuilder): + def build(self) -> MovingPoint: + if self.group.units[0].unit_type == B_17G: + return self.build_bombing() + else: + return self.build_strike() + + def build_bombing(self) -> MovingPoint: + waypoint = super().build() + + targets = self.waypoint.targets + if not targets: + return waypoint + + center = Point(0, 0) + for target in targets: + center.x += target.position.x + center.y += target.position.y + center.x /= len(targets) + center.y /= len(targets) + bombing = Bombing(center) + bombing.params["expend"] = "All" + bombing.params["attackQtyLimit"] = False + bombing.params["directionEnabled"] = False + bombing.params["altitudeEnabled"] = False + bombing.params["weaponType"] = 2032 + bombing.params["groupAttack"] = True + waypoint.tasks.append(bombing) + return waypoint + + def build_strike(self) -> MovingPoint: + waypoint = super().build() + + for i, t in enumerate(self.waypoint.targets): + waypoint.tasks.append(Bombing(t.position)) + if self.group.units[0].unit_type == JF_17 and i < 4: + self.group.add_nav_target_point(t.position, "PP" + str(i + 1)) + if self.group.units[0].unit_type == F_14B and i == 0: + self.group.add_nav_target_point(t.position, "ST") + if self.group.units[0].unit_type == AJS37 and i < 9: + self.group.add_nav_target_point(t.position, "M" + str(i + 1)) + return waypoint + + +class JoinPointBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + self.set_waypoint_tot(waypoint, self.timing.join) + return waypoint + + +class LandingPointBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + waypoint.type = "Land" + waypoint.action = PointAction.Landing + return waypoint + + +class RaceTrackBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + + racetrack = ControlledTask(OrbitAction( + altitude=waypoint.alt, + pattern=OrbitAction.OrbitPattern.RaceTrack + )) + + start = self.timing.race_track_start + if start is not None: + self.set_waypoint_tot(waypoint, start) + racetrack.stop_after_time(self.timing.race_track_end) + waypoint.add_task(racetrack) + return waypoint + + +class SplitPointBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + self.set_waypoint_tot(waypoint, self.timing.split) + return waypoint + + +class TargetPointBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + self.set_waypoint_tot(waypoint, self.timing.target) + return waypoint diff --git a/gen/ato.py b/gen/ato.py index ee3a3c55..e9c5393c 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -8,14 +8,15 @@ example, the package to strike an enemy airfield may contain an escort flight, a SEAD flight, and the strike aircraft themselves. CAP packages may contain only the single CAP flight. """ +import logging from collections import defaultdict from dataclasses import dataclass, field -import logging -from typing import Dict, Iterator, List, Optional +from typing import Dict, List, Optional from dcs.mapping import Point -from .flights.flight import Flight, FlightType + from theater.missiontarget import MissionTarget +from .flights.flight import Flight, FlightType @dataclass(frozen=True) @@ -29,6 +30,14 @@ class Task: location: str +@dataclass(frozen=True) +class PackageWaypoints: + join: Point + ingress: Point + egress: Point + split: Point + + @dataclass class Package: """A mission package.""" @@ -42,10 +51,10 @@ class Package: delay: int = field(default=0) - join_point: Optional[Point] = field(default=None, init=False, hash=False) - split_point: Optional[Point] = field(default=None, init=False, hash=False) - ingress_point: Optional[Point] = field(default=None, init=False, hash=False) - egress_point: Optional[Point] = field(default=None, init=False, hash=False) + #: Desired TOT measured in seconds from mission start. + time_over_target: Optional[int] = field(default=None) + + waypoints: Optional[PackageWaypoints] = field(default=None) def add_flight(self, flight: Flight) -> None: """Adds a flight to the package.""" @@ -55,8 +64,7 @@ class Package: """Removes a flight from the package.""" self.flights.remove(flight) if not self.flights: - self.ingress_point = None - self.egress_point = None + self.waypoints = None @property def primary_task(self) -> Optional[FlightType]: diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 7bc3fd26..09be3773 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -12,7 +12,7 @@ from game import db from game.data.radar_db import UNITS_WITH_RADAR from game.infos.information import Information from game.utils import nm_to_meter -from gen import Conflict +from gen import Conflict, PackageWaypointTiming from gen.ato import Package from gen.flights.ai_flight_planner_db import ( CAP_CAPABLE, @@ -496,10 +496,17 @@ class CoalitionMissionPlanner: latest=90, margin=5 ) - for package in non_dca_packages: - package.delay = next(start_time) - for flight in package.flights: - flight.scheduled_in = package.delay + for package in self.ato.packages: + if package.primary_task in dca_types: + # All CAP missions should be on station in 15-25 minutes. + package.time_over_target = (20 + random.randint(-5, 5)) * 60 + else: + # But other packages should be spread out a bit. + package.delay = next(start_time) + # TODO: Compute TOT based on package. + package.time_over_target = (package.delay + 40) * 60 + for flight in package.flights: + flight.scheduled_in = package.delay def message(self, title, text) -> None: """Emits a planning message to the player. diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 90b27ccf..0c972723 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -49,6 +49,7 @@ class FlightWaypointType(Enum): CUSTOM = 15 # User waypoint (no specific behaviour) JOIN = 16 SPLIT = 17 + LOITER = 18 class PredefinedWaypointCategory(Enum): @@ -66,6 +67,15 @@ class FlightWaypoint: def __init__(self, waypoint_type: FlightWaypointType, x: float, y: float, alt: int = 0) -> None: + """Creates a flight waypoint. + + Args: + waypoint_type: The waypoint type. + x: X cooidinate of the waypoint. + y: Y coordinate of the waypoint. + alt: Altitude of the waypoint. By default this is AGL, but it can be + changed to MSL by setting alt_type to "RADIO". + """ self.waypoint_type = waypoint_type self.x = x self.y = y @@ -81,6 +91,12 @@ class FlightWaypoint: self.only_for_player = False self.data = None + # This is set very late by the air conflict generator (part of mission + # generation). We do it late so that we don't need to propagate changes + # to waypoint times whenever the player alters the package TOT or the + # flight's offset in the UI. + self.tot: Optional[int] = None + @classmethod def from_pydcs(cls, point: MovingPoint, diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 5e5fefe2..74462c2d 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -16,10 +16,10 @@ from dcs.unit import Unit from game.data.doctrine import Doctrine, MODERN_DOCTRINE from game.utils import nm_to_meter -from gen.ato import Package +from gen.ato import Package, PackageWaypoints from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject from .closestairfields import ObjectiveDistanceCache -from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType +from .flight import Flight, FlightType, FlightWaypoint from .waypointbuilder import WaypointBuilder from ..conflictgen import Conflict @@ -55,7 +55,8 @@ class FlightPlanBuilder: """Creates a default flight plan for the given mission.""" if flight not in self.package.flights: raise RuntimeError("Flight must be a part of the package") - self.generate_missing_package_waypoints() + if self.package.waypoints is None: + self.regenerate_package_waypoints() # TODO: Flesh out mission types. try: @@ -105,15 +106,18 @@ class FlightPlanBuilder: except InvalidObjectiveLocation as ex: logging.error(f"Could not create flight plan: {ex}") - def generate_missing_package_waypoints(self) -> None: - if self.package.ingress_point is None: - self.package.ingress_point = self._ingress_point() - if self.package.egress_point is None: - self.package.egress_point = self._egress_point() - if self.package.join_point is None: - self.package.join_point = self._join_point() - if self.package.split_point is None: - self.package.split_point = self._split_point() + def regenerate_package_waypoints(self) -> None: + ingress_point = self._ingress_point() + egress_point = self._egress_point() + join_point = self._join_point(ingress_point) + split_point = self._split_point(egress_point) + + self.package.waypoints = PackageWaypoints( + join_point, + ingress_point, + egress_point, + split_point, + ) def generate_strike(self, flight: Flight) -> None: """Generates a strike flight plan. @@ -121,6 +125,7 @@ class FlightPlanBuilder: Args: flight: The flight to generate the flight plan for. """ + assert self.package.waypoints is not None location = self.package.target # TODO: Support airfield strikes. @@ -129,8 +134,9 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.join(self.package.join_point) - builder.ingress_strike(self.package.ingress_point, location) + builder.hold(self._hold_point(flight)) + builder.join(self.package.waypoints.join) + builder.ingress_strike(self.package.waypoints.ingress, location) if len(location.groups) > 0 and location.dcs_identifier == "AA": # TODO: Replace with DEAD? @@ -157,8 +163,8 @@ class FlightPlanBuilder: location ) - builder.egress(self.package.egress_point, location) - builder.split(self.package.split_point) + builder.egress(self.package.waypoints.egress, location) + builder.split(self.package.waypoints.split) builder.rtb(flight.from_cp) flight.points = builder.build() @@ -215,6 +221,7 @@ class FlightPlanBuilder: Args: flight: The flight to generate the flight plan for. """ + assert self.package.waypoints is not None location = self.package.target if not isinstance(location, FrontLine): @@ -246,7 +253,10 @@ class FlightPlanBuilder: # Create points builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) + builder.hold(self._hold_point(flight)) + builder.join(self.package.waypoints.join) builder.race_track(orbit0p, orbit1p, patrol_alt) + builder.split(self.package.waypoints.split) builder.rtb(flight.from_cp) flight.points = builder.build() @@ -258,6 +268,7 @@ class FlightPlanBuilder: flight: The flight to generate the flight plan for. custom_targets: Specific radar equipped units selected by the user. """ + assert self.package.waypoints is not None location = self.package.target if not isinstance(location, TheaterGroundObject): @@ -268,21 +279,15 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.join(self.package.join_point) - builder.ingress_sead(self.package.ingress_point, location) + builder.hold(self._hold_point(flight)) + builder.join(self.package.waypoints.join) + builder.ingress_sead(self.package.waypoints.ingress, location) # TODO: Unify these. # There doesn't seem to be any reason to treat the UI fragged missions # different from the automatic missions. if custom_targets: for target in custom_targets: - point = FlightWaypoint( - FlightWaypointType.TARGET_POINT, - target.position.x, - target.position.y, - 0 - ) - point.alt_type = "RADIO" if flight.flight_type == FlightType.DEAD: builder.dead_point(target, location.name, location) else: @@ -293,17 +298,22 @@ class FlightPlanBuilder: else: builder.sead_area(location) - builder.egress(self.package.egress_point, location) - builder.split(self.package.split_point) + builder.egress(self.package.waypoints.egress, location) + builder.split(self.package.waypoints.split) builder.rtb(flight.from_cp) flight.points = builder.build() + def _hold_point(self, flight: Flight) -> Point: + heading = flight.from_cp.position.heading_between_point( + self.package.target.position + ) + return flight.from_cp.position.point_from_heading( + heading, nm_to_meter(15) + ) + def generate_escort(self, flight: Flight) -> None: - # TODO: Decide common waypoints for the package ahead of time. - # Packages should determine some common points like push, ingress, - # egress, and split points ahead of time so they can be shared by all - # flights. + assert self.package.waypoints is not None patrol_alt = random.randint( self.doctrine.min_patrol_altitude, @@ -312,13 +322,11 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.join(self.package.join_point) - builder.race_track( - self.package.ingress_point, - self.package.egress_point, - patrol_alt - ) - builder.split(self.package.split_point) + builder.hold(self._hold_point(flight)) + builder.join(self.package.waypoints.join) + builder.race_track(self.package.waypoints.ingress, + self.package.waypoints.egress, patrol_alt) + builder.split(self.package.waypoints.split) builder.rtb(flight.from_cp) flight.points = builder.build() @@ -329,6 +337,7 @@ class FlightPlanBuilder: Args: flight: The flight to generate the flight plan for. """ + assert self.package.waypoints is not None location = self.package.target if not isinstance(location, FrontLine): @@ -346,11 +355,12 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp, is_helo) - builder.join(self.package.join_point) + builder.hold(self._hold_point(flight)) + builder.join(self.package.waypoints.join) builder.ingress_cas(ingress, location) builder.cas(center, cap_alt) builder.egress(egress, location) - builder.split(self.package.split_point) + builder.split(self.package.waypoints.split) builder.rtb(flight.from_cp, is_helo) flight.points = builder.build() @@ -386,16 +396,12 @@ class FlightPlanBuilder: builder.land(arrival) return builder.build()[0] - def _join_point(self) -> Point: - ingress_point = self.package.ingress_point - assert ingress_point is not None + def _join_point(self, ingress_point: Point) -> Point: heading = self._heading_to_package_airfield(ingress_point) return ingress_point.point_from_heading(heading, -self.doctrine.join_distance) - def _split_point(self) -> Point: - egress_point = self.package.egress_point - assert egress_point is not None + def _split_point(self, egress_point: Point) -> Point: heading = self._heading_to_package_airfield(egress_point) return egress_point.point_from_heading(heading, -self.doctrine.split_distance) diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 6fc3fd85..a0374d25 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -25,6 +25,7 @@ class WaypointBuilder: Args: departure: Departure airfield or carrier. + is_helo: True if the flight is a helicopter. """ # TODO: Pick runway based on wind direction. heading = departure.heading @@ -48,6 +49,7 @@ class WaypointBuilder: Args: arrival: Arrival airfield or carrier. + is_helo: True if the flight is a helicopter. """ # TODO: Pick runway based on wind direction. # ControlPoint.heading is the departure heading. @@ -64,7 +66,7 @@ class WaypointBuilder: waypoint.name = "DESCEND" waypoint.alt_type = "RADIO" waypoint.description = "Descend to pattern altitude" - waypoint.pretty_name = "Ascend" + waypoint.pretty_name = "Descend" self.waypoints.append(waypoint) def land(self, arrival: ControlPoint) -> None: @@ -86,6 +88,18 @@ class WaypointBuilder: waypoint.pretty_name = "Land" self.waypoints.append(waypoint) + def hold(self, position: Point) -> None: + waypoint = FlightWaypoint( + FlightWaypointType.LOITER, + position.x, + position.y, + self.doctrine.rendezvous_altitude + ) + waypoint.pretty_name = "Hold" + waypoint.description = "Wait until push time" + waypoint.name = "HOLD" + self.waypoints.append(waypoint) + def join(self, position: Point) -> None: waypoint = FlightWaypoint( FlightWaypointType.JOIN, @@ -170,7 +184,7 @@ class WaypointBuilder: location) def _target_point(self, target: Union[TheaterGroundObject, Unit], name: str, - description: str, location: MissionTarget) -> None: + description: str, location: MissionTarget) -> None: if self.ingress_point is None: raise RuntimeError( "An ingress point must be added before target points." @@ -293,6 +307,7 @@ class WaypointBuilder: Args: arrival: Arrival airfield or carrier. + is_helo: True if the flight is a helicopter. """ self.descent(arrival, is_helo) self.land(arrival) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index e7a86bc5..cea2e591 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -24,6 +24,7 @@ aircraft will be able to see the enemy's kneeboard for the same airframe. """ from collections import defaultdict from dataclasses import dataclass +import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -106,7 +107,8 @@ class NumberedWaypoint: class FlightPlanBuilder: - def __init__(self) -> None: + def __init__(self, start_time: datetime.datetime) -> None: + self.start_time = start_time self.rows: List[List[str]] = [] self.target_points: List[NumberedWaypoint] = [] @@ -133,16 +135,24 @@ class FlightPlanBuilder: self.rows.append([ f"{first_waypoint_num}-{last_waypoint_num}", "Target points", - "0" + "0", + self._format_time(self.target_points[0].waypoint.tot), ]) def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None: self.rows.append([ str(waypoint.number), waypoint.waypoint.pretty_name, - str(int(units.meters_to_feet(waypoint.waypoint.alt))) + str(int(units.meters_to_feet(waypoint.waypoint.alt))), + self._format_time(waypoint.waypoint.tot), ]) + def _format_time(self, time: Optional[int]) -> str: + if time is None: + return "" + local_time = self.start_time + datetime.timedelta(seconds=time) + return local_time.strftime(f"%H:%M:%S LOCAL") + def build(self) -> List[List[str]]: return self.rows @@ -151,12 +161,13 @@ class BriefingPage(KneeboardPage): """A kneeboard page containing briefing information.""" def __init__(self, flight: FlightData, comms: List[CommInfo], awacs: List[AwacsInfo], tankers: List[TankerInfo], - jtacs: List[JtacInfo]) -> None: + jtacs: List[JtacInfo], start_time: datetime.datetime) -> None: self.flight = flight self.comms = list(comms) self.awacs = awacs self.tankers = tankers self.jtacs = jtacs + self.start_time = start_time self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel)) def write(self, path: Path) -> None: @@ -172,11 +183,11 @@ class BriefingPage(KneeboardPage): ], headers=["", "Airbase", "ATC", "TCN", "I(C)LS", "RWY"]) writer.heading("Flight Plan") - flight_plan_builder = FlightPlanBuilder() + flight_plan_builder = FlightPlanBuilder(self.start_time) for num, waypoint in enumerate(self.flight.waypoints): flight_plan_builder.add_waypoint(num, waypoint) writer.table(flight_plan_builder.build(), - headers=["STPT", "Action", "Alt"]) + headers=["STPT", "Action", "Alt", "TOT"]) writer.heading("Comm Ladder") comms = [] @@ -297,6 +308,11 @@ class KneeboardGenerator(MissionInfoGenerator): """Returns a list of kneeboard pages for the given flight.""" return [ BriefingPage( - flight, self.comms, self.awacs, self.tankers, self.jtacs + flight, + self.comms, + self.awacs, + self.tankers, + self.jtacs, + self.mission.start_time ), ] diff --git a/qt_ui/models.py b/qt_ui/models.py index 87d52538..98515eab 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -1,4 +1,6 @@ """Qt data models for game objects.""" +import datetime +from enum import auto, IntEnum from typing import Any, Callable, Dict, Iterator, TypeVar, Optional from PySide2.QtCore import ( @@ -156,6 +158,9 @@ class PackageModel(QAbstractListModel): """Returns the flight located at the given index.""" return self.package.flights[index.row()] + def update_tot(self, tot: int) -> None: + self.package.time_over_target = tot + @property def mission_target(self) -> MissionTarget: """Returns the mission target of the package.""" @@ -178,6 +183,8 @@ class PackageModel(QAbstractListModel): class AtoModel(QAbstractListModel): """The model for an AirTaskingOrder.""" + PackageRole = Qt.UserRole + def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: super().__init__() self.game = game @@ -190,9 +197,11 @@ class AtoModel(QAbstractListModel): def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: if not index.isValid(): return None + package = self.ato.packages[index.row()] if role == Qt.DisplayRole: - package = self.ato.packages[index.row()] return f"{package.package_description} {package.target.name}" + elif role == AtoModel.PackageRole: + return package return None def add_package(self, package: Package) -> None: diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index fae3a11d..f75826ad 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,6 +1,12 @@ from typing import Optional -from PySide2.QtWidgets import QFrame, QGroupBox, QHBoxLayout, QPushButton +from PySide2.QtWidgets import ( + QFrame, + QGroupBox, + QHBoxLayout, + QMessageBox, + QPushButton, +) import qt_ui.uiconstants as CONST from game import Game @@ -9,9 +15,10 @@ from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QFactionsInfos import QFactionsInfos from qt_ui.widgets.QTurnCounter import QTurnCounter from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from qt_ui.windows.QWaitingForMissionResultWindow import \ + QWaitingForMissionResultWindow from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow from qt_ui.windows.stats.QStatsWindow import QStatsWindow -from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow class QTopPanel(QFrame): @@ -101,8 +108,36 @@ class QTopPanel(QFrame): GameUpdateSignal.get_instance().updateGame(self.game) self.proceedButton.setEnabled(True) + def ato_has_clients(self) -> bool: + for package in self.game.blue_ato.packages: + for flight in package.flights: + if flight.client_count > 0: + return True + return False + + def confirm_no_client_launch(self) -> bool: + result = QMessageBox.question( + self, + "Continue without client slots?", + ("No client slots have been created for players. Continuing will " + "allow the AI to perform the mission, but players will be unable " + "to participate.
" + "
" + "To add client slots for players, select a package from the " + "Packages panel on the left of the main window, and then a flight " + "from the Flights panel below the Packages panel. The edit button " + "below the Flights panel will allow you to edit the number of " + "client slots in the flight. Each client slot allows one player."), + QMessageBox.No, + QMessageBox.Yes + ) + return result == QMessageBox.Yes + def launch_mission(self): """Finishes planning and waits for mission completion.""" + if not self.ato_has_clients() and not self.confirm_no_client_launch(): + return + # TODO: Refactor this nonsense. game_event = None for event in self.game.events: diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index e4c178c4..b39f3063 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -1,8 +1,16 @@ """Widgets for displaying air tasking orders.""" +import datetime import logging -from typing import Optional +from contextlib import contextmanager +from typing import ContextManager, Optional -from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize, Qt +from PySide2.QtCore import ( + QItemSelectionModel, + QModelIndex, + QSize, + Qt, +) +from PySide2.QtGui import QFont, QFontMetrics, QPainter from PySide2.QtWidgets import ( QAbstractItemView, QGroupBox, @@ -10,13 +18,13 @@ from PySide2.QtWidgets import ( QListView, QPushButton, QSplitter, - QVBoxLayout, + QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, ) from gen.ato import Package from gen.flights.flight import Flight -from ..models import AtoModel, GameModel, NullListModel, PackageModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from ..models import AtoModel, GameModel, NullListModel, PackageModel class QFlightList(QListView): @@ -138,6 +146,65 @@ class QFlightPanel(QGroupBox): GameUpdateSignal.get_instance().redraw_flight_paths() +@contextmanager +def painter_context(painter: QPainter) -> ContextManager[None]: + try: + painter.save() + yield + finally: + painter.restore() + + +class PackageDelegate(QStyledItemDelegate): + FONT_SIZE = 12 + HMARGIN = 4 + VMARGIN = 4 + + def get_font(self, option: QStyleOptionViewItem) -> QFont: + font = QFont(option.font) + font.setPointSize(self.FONT_SIZE) + return font + + @staticmethod + def package(index: QModelIndex) -> Package: + return index.data(AtoModel.PackageRole) + + def left_text(self, index: QModelIndex) -> str: + package = self.package(index) + return f"{package.package_description} {package.target.name}" + + def right_text(self, index: QModelIndex) -> str: + package = self.package(index) + if package.time_over_target is None: + return "" + tot = datetime.timedelta(seconds=package.time_over_target) + return f"TOT T+{tot}" + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, + index: QModelIndex) -> None: + # Draw the list item with all the default selection styling, but with an + # invalid index so text formatting is left to us. + super().paint(painter, option, QModelIndex()) + + rect = option.rect.adjusted(self.HMARGIN, self.VMARGIN, -self.HMARGIN, + -self.VMARGIN) + + with painter_context(painter): + painter.setFont(self.get_font(option)) + + painter.drawText(rect, Qt.AlignLeft, self.left_text(index)) + line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) + painter.drawText(line2, Qt.AlignLeft, self.right_text(index)) + + def sizeHint(self, option: QStyleOptionViewItem, + index: QModelIndex) -> QSize: + metrics = QFontMetrics(self.get_font(option)) + left = metrics.size(0, self.left_text(index)) + right = metrics.size(0, self.right_text(index)) + return QSize(max(left.width(), right.width()) + 2 * self.HMARGIN, + left.height() + right.height() + 2 * self.VMARGIN) + + class QPackageList(QListView): """List view for displaying the packages of an ATO.""" @@ -145,6 +212,7 @@ class QPackageList(QListView): super().__init__() self.ato_model = model self.setModel(model) + self.setItemDelegate(PackageDelegate()) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 21a44aa3..d9deb613 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -2,12 +2,13 @@ import logging from typing import Optional -from PySide2.QtCore import QItemSelection, Signal +from PySide2.QtCore import QItemSelection, QTime, Signal from PySide2.QtWidgets import ( QDialog, QHBoxLayout, QLabel, QPushButton, + QTimeEdit, QVBoxLayout, ) @@ -56,14 +57,39 @@ class QPackageDialog(QDialog): self.summary_row = QHBoxLayout() self.layout.addLayout(self.summary_row) + self.package_type_column = QHBoxLayout() + self.summary_row.addLayout(self.package_type_column) + self.package_type_label = QLabel("Package Type:") self.package_type_text = QLabel(self.package_model.description) # noinspection PyUnresolvedReferences self.package_changed.connect(lambda: self.package_type_text.setText( self.package_model.description )) - self.summary_row.addWidget(self.package_type_label) - self.summary_row.addWidget(self.package_type_text) + self.package_type_column.addWidget(self.package_type_label) + self.package_type_column.addWidget(self.package_type_text) + + self.summary_row.addStretch(1) + + self.tot_column = QHBoxLayout() + self.summary_row.addLayout(self.tot_column) + + self.tot_label = QLabel("Time Over Target:") + self.tot_column.addWidget(self.tot_label) + + if self.package_model.package.time_over_target is None: + time = None + else: + delay = self.package_model.package.time_over_target + hours = delay // 3600 + minutes = delay // 60 % 60 + seconds = delay % 60 + time = QTime(hours, minutes, seconds) + + self.tot_spinner = QTimeEdit(time) + self.tot_spinner.setMinimumTime(QTime(0, 0)) + self.tot_spinner.setDisplayFormat("T+hh:mm:ss") + self.tot_column.addWidget(self.tot_spinner) self.package_view = QFlightList(self.package_model) self.package_view.selectionModel().selectionChanged.connect( @@ -90,8 +116,10 @@ class QPackageDialog(QDialog): self.finished.connect(self.on_close) - @staticmethod - def on_close(_result) -> None: + def on_close(self, _result) -> None: + time = self.tot_spinner.time() + seconds = time.hour() * 3600 + time.minute() * 60 + time.second() + self.package_model.update_tot(seconds) GameUpdateSignal.get_instance().redraw_flight_paths() def on_selection_changed(self, selected: QItemSelection, diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 8deefbc7..aa904e2d 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -1,26 +1,33 @@ +import datetime +import itertools + from PySide2.QtCore import QItemSelectionModel, QPoint -from PySide2.QtGui import QStandardItemModel, QStandardItem -from PySide2.QtWidgets import QTableView, QHeaderView +from PySide2.QtGui import QStandardItem, QStandardItemModel +from PySide2.QtWidgets import QHeaderView, QTableView from game.utils import meter_to_feet +from gen.aircraft import PackageWaypointTiming +from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint -from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import QWaypointItem +from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import \ + QWaypointItem class QFlightWaypointList(QTableView): - def __init__(self, flight: Flight): - super(QFlightWaypointList, self).__init__() + def __init__(self, package: Package, flight: Flight): + super().__init__() + self.package = package + self.flight = flight + self.model = QStandardItemModel(self) self.setModel(self.model) self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.model.setHorizontalHeaderLabels(["Name", "Alt"]) + self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"]) header = self.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) - self.flight = flight - if len(self.flight.points) > 0: self.selectedPoint = self.flight.points[0] self.update_list() @@ -33,18 +40,49 @@ class QFlightWaypointList(QTableView): def update_list(self): self.model.clear() - self.model.setHorizontalHeaderLabels(["Name", "Alt"]) - takeoff = FlightWaypoint(self.flight.from_cp.position.x, self.flight.from_cp.position.y, 0) + + self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"]) + + timing = PackageWaypointTiming.for_package(self.package) + + # 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 - self.model.appendRow(QWaypointItem(takeoff, 0)) - item = QStandardItem("0 feet AGL") - item.setEditable(False) - self.model.setItem(0, 1, item) - for i, point in enumerate(self.flight.points): - self.model.insertRow(self.model.rowCount()) - self.model.setItem(self.model.rowCount()-1, 0, QWaypointItem(point, i + 1)) - item = QStandardItem(str(meter_to_feet(point.alt)) + " ft " + str(["AGL" if point.alt_type == "RADIO" else "MSL"][0])) - item.setEditable(False) - self.model.setItem(self.model.rowCount()-1, 1, item) - self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select) \ No newline at end of file + takeoff.alt_type = "RADIO" + + waypoints = itertools.chain([takeoff], self.flight.points) + for row, waypoint in enumerate(waypoints): + self.add_waypoint_row(row, waypoint, timing) + self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), + QItemSelectionModel.Select) + + def add_waypoint_row(self, row: int, waypoint: FlightWaypoint, + timing: PackageWaypointTiming) -> None: + self.model.insertRow(self.model.rowCount()) + + self.model.setItem(row, 0, QWaypointItem(waypoint, row)) + + altitude = meter_to_feet(waypoint.alt) + altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" + altitude_item = QStandardItem(f"{altitude} ft {altitude_type}") + altitude_item.setEditable(False) + self.model.setItem(row, 1, altitude_item) + + tot = self.tot_text(waypoint, timing) + tot_item = QStandardItem(tot) + tot_item.setEditable(False) + self.model.setItem(row, 2, tot_item) + + def tot_text(self, waypoint: FlightWaypoint, + timing: PackageWaypointTiming) -> str: + prefix = "" + time = timing.tot_for_waypoint(waypoint) + if time is None: + prefix = "Depart " + time = timing.depart_time_for_waypoint(waypoint, self.flight) + if time is None: + return "" + return f"{prefix}T+{datetime.timedelta(seconds=time)}" diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 5e9e57a2..98064b5c 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -44,7 +44,8 @@ class QFlightWaypointTab(QFrame): def init_ui(self): layout = QGridLayout() - self.flight_waypoint_list = QFlightWaypointList(self.flight) + self.flight_waypoint_list = QFlightWaypointList(self.package, + self.flight) layout.addWidget(self.flight_waypoint_list, 0, 0) rlayout = QVBoxLayout()