diff --git a/changelog.md b/changelog.md index c0ecb509..59c10b15 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ +# 2.1.5 + +## Features/Improvements : +* **[Units/Factions]** Enabled EPLRS for ground units that supports it (so they appear on A-10C II TAD and Helmet) + +## Fixes : +* **[UI]** Fixed an issue that prevent saving after aborting a mission +* **[Mission Generator]** Fixed aircraft landing point type being wrong + # 2.1.4 ## Fixes : diff --git a/gen/aircraft.py b/gen/aircraft.py index 08491d42..592058dd 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -57,14 +57,14 @@ from dcs.task import ( ) from dcs.terrain.terrain import Airport, NoParkingSlotError from dcs.translation import String -from dcs.triggers import Event, TriggerOnce +from dcs.triggers import Event, TriggerOnce, TriggerRule from dcs.unitgroup import FlyingGroup, Group, ShipGroup, StaticGroup 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 meter_to_nm, nm_to_meter +from game.utils import nm_to_meter from gen.airfields import RunwayData from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package @@ -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 import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict +from .flights.traveltime import PackageWaypointTiming, TotEstimator from .naming import namegen WARM_START_HELI_AIRSPEED = 120 @@ -86,8 +87,6 @@ WARM_START_HELI_ALT = 500 WARM_START_ALTITUDE = 3000 WARM_START_AIRSPEED = 550 -CAP_DURATION = 30 # minutes - RTB_ALTITUDE = 800 RTB_DISTANCE = 5000 HELI_ALT = 500 @@ -217,7 +216,7 @@ class FlightData: #: True if this flight belongs to the player's coalition. friendly: bool - #: Number of minutes after mission start the flight is set to depart. + #: Number of seconds after mission start the flight is set to depart. departure_delay: int #: Arrival airport. @@ -537,148 +536,6 @@ 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): @@ -706,8 +563,13 @@ class AircraftConflictGenerator: except KeyError: return get_fallback_channel(airframe) - def _start_type(self) -> StartType: - return self.settings.cold_start and StartType.Cold or StartType.Warm + @staticmethod + def _start_type(start_type: str) -> StartType: + if start_type == "Runway": + return StartType.Runway + elif start_type == "Cold": + return StartType.Cold + return StartType.Warm def _setup_group(self, group: FlyingGroup, for_task: Type[Task], flight: Flight, dynamic_runways: Dict[str, RunwayData]): @@ -813,15 +675,10 @@ class AircraftConflictGenerator: return runways[0] def _generate_at_airport(self, name: str, side: Country, - unit_type: FlyingType, count: int, - client_count: int, - airport: Optional[Airport] = None, - start_type=None) -> FlyingGroup: + unit_type: FlyingType, count: int, start_type: str, + airport: Optional[Airport] = None) -> FlyingGroup: assert count > 0 - if start_type is None: - start_type = self._start_type() - logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport)) return self.m.flight_group_from_airport( country=side, @@ -829,11 +686,11 @@ class AircraftConflictGenerator: aircraft_type=unit_type, airport=airport, maintask=None, - start_type=start_type, + start_type=self._start_type(start_type), group_size=count, parking_slots=None) - def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: Point) -> FlyingGroup: + def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, at: Point) -> FlyingGroup: assert count > 0 if unit_type in helicopters.helicopter_map.values(): @@ -855,21 +712,16 @@ class AircraftConflictGenerator: altitude=alt, speed=speed, maintask=None, - start_type=self._start_type(), group_size=count) group.points[0].alt_type = "RADIO" return group def _generate_at_group(self, name: str, side: Country, - unit_type: FlyingType, count: int, client_count: int, - at: Union[ShipGroup, StaticGroup], - start_type=None) -> FlyingGroup: + unit_type: FlyingType, count: int, start_type: str, + at: Union[ShipGroup, StaticGroup]) -> FlyingGroup: assert count > 0 - if start_type is None: - start_type = self._start_type() - logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at)) return self.m.flight_group_from_unit( country=side, @@ -877,34 +729,9 @@ class AircraftConflictGenerator: aircraft_type=unit_type, pad_group=at, maintask=None, - start_type=start_type, + start_type=self._start_type(start_type), group_size=count) - def _generate_group(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: db.StartingPosition): - if isinstance(at, Point): - return self._generate_inflight(name, side, unit_type, count, client_count, at) - elif isinstance(at, Group): - takeoff_ban = unit_type in db.CARRIER_TAKEOFF_BAN - ai_ban = client_count == 0 and self.settings.only_player_takeoff - - if not takeoff_ban and not ai_ban: - return self._generate_at_group(name, side, unit_type, count, client_count, at) - else: - return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - elif isinstance(at, Airport): - takeoff_ban = unit_type in db.TAKEOFF_BAN - ai_ban = client_count == 0 and self.settings.only_player_takeoff - - if not takeoff_ban and not ai_ban: - try: - return self._generate_at_airport(name, side, unit_type, count, client_count, at) - except NoParkingSlotError: - logging.info("No parking slot found at " + at.name + ", switching to air start.") - pass - return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - else: - assert False - def _add_radio_waypoint(self, group: FlyingGroup, position, altitude: int, airspeed: int = 600): point = group.add_waypoint(position, altitude, airspeed) point.alt_type = "RADIO" @@ -975,110 +802,91 @@ 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, timing, dynamic_runways) - self.setup_group_activation_trigger(flight, group) + self.setup_flight_group(group, package, flight, timing, + dynamic_runways) - def setup_group_activation_trigger(self, flight, group): - if flight.scheduled_in > 0 and flight.client_count == 0: + def set_activation_time(self, flight: Flight, group: FlyingGroup, + delay: int) -> None: + # Note: Late activation causes the waypoint TOTs to look *weird* in the + # mission editor. Waypoint times will be relative to the group + # activation time rather than in absolute local time. A flight delayed + # until 09:10 when the overall mission start time is 09:00, with a join + # time of 09:30 will show the join time as 00:30, not 09:30. + group.late_activation = True - if flight.start_type != "In Flight" and flight.from_cp.cptype not in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]: - group.late_activation = False - group.uncontrolled = True + activation_trigger = TriggerOnce( + Event.NoEvent, f"FlightLateActivationTrigger{group.id}") + activation_trigger.add_condition(TimeAfter(seconds=delay)) - activation_trigger = TriggerOnce(Event.NoEvent, "FlightStartTrigger" + str(group.id)) - activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in * 60)) - if (flight.from_cp.cptype == ControlPointType.AIRBASE): - if flight.from_cp.captured: - activation_trigger.add_condition( - CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id)) - else: - activation_trigger.add_condition( - CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id)) + self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) + activation_trigger.add_action(ActivateGroup(group.id)) + self.m.triggerrules.triggers.append(activation_trigger) - if flight.flight_type == FlightType.INTERCEPTION: - self.setup_interceptor_triggers(group, flight, activation_trigger) + def set_startup_time(self, flight: Flight, group: FlyingGroup, + delay: int) -> None: + # Uncontrolled causes the AI unit to spawn, but not begin startup. + group.uncontrolled = True - group.add_trigger_action(StartCommand()) - activation_trigger.add_action(AITaskPush(group.id, len(group.tasks))) + activation_trigger = TriggerOnce(Event.NoEvent, + f"FlightStartTrigger{group.id}") + activation_trigger.add_condition(TimeAfter(seconds=delay)) - self.m.triggerrules.triggers.append(activation_trigger) - else: - group.late_activation = True - activation_trigger = TriggerOnce(Event.NoEvent, "FlightLateActivationTrigger" + str(group.id)) - activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in*60)) + self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) + group.add_trigger_action(StartCommand()) + activation_trigger.add_action(AITaskPush(group.id, len(group.tasks))) + self.m.triggerrules.triggers.append(activation_trigger) - if(flight.from_cp.cptype == ControlPointType.AIRBASE): - if flight.from_cp.captured: - activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id)) - else: - activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id)) + def prevent_spawn_at_hostile_airbase(self, flight: Flight, + trigger: TriggerRule) -> None: + # Prevent delayed flights from spawning at airbases if they were + # captured before they've spawned. + if flight.from_cp.cptype != ControlPointType.AIRBASE: + return - if flight.flight_type == FlightType.INTERCEPTION: - self.setup_interceptor_triggers(group, flight, activation_trigger) - - activation_trigger.add_action(ActivateGroup(group.id)) - self.m.triggerrules.triggers.append(activation_trigger) - - def setup_interceptor_triggers(self, group, flight, activation_trigger): - - detection_zone = self.m.triggers.add_triggerzone(flight.from_cp.position, radius=25000, hidden=False, name="ITZ") if flight.from_cp.captured: - activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_enemy_color(), detection_zone.id)) # TODO : support unit type in part of coalition - activation_trigger.add_action(MessageToAll(String("WARNING : Enemy aircraft have been detected in the vicinity of " + flight.from_cp.name + ". Interceptors are taking off."), 20)) + coalition = self.game.get_player_coalition_id() else: - activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_player_color(), detection_zone.id)) - activation_trigger.add_action(MessageToAll(String("WARNING : We have detected that enemy aircraft are scrambling for an interception on " + flight.from_cp.name + " airbase."), 20)) + coalition = self.game.get_enemy_coalition_id() + + trigger.add_condition( + CoalitionHasAirdrome(coalition, flight.from_cp.id)) def generate_planned_flight(self, cp, country, flight:Flight): try: - if flight.client_count == 0 and self.game.settings.perf_ai_parking_start: - flight.start_type = "Cold" - if flight.start_type == "In Flight": - group = self._generate_group( + 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, - client_count=0, at=cp.position) + elif cp.is_fleet: + group_name = cp.get_carrier_group_name() + group = self._generate_at_group( + name=namegen.next_unit_name(country, cp.id, flight.unit_type), + side=country, + unit_type=flight.unit_type, + count=flight.count, + start_type=flight.start_type, + at=self.m.find_group(group_name)) else: - st = StartType.Runway - if flight.start_type == "Cold": - st = StartType.Cold - elif flight.start_type == "Warm": - st = StartType.Warm - - if cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]: - group_name = cp.get_carrier_group_name() - group = self._generate_at_group( - name=namegen.next_unit_name(country, cp.id, flight.unit_type), - side=country, - unit_type=flight.unit_type, - count=flight.count, - client_count=0, - at=self.m.find_group(group_name), - start_type=st) - else: - group = self._generate_at_airport( - name=namegen.next_unit_name(country, cp.id, flight.unit_type), - side=country, - unit_type=flight.unit_type, - count=flight.count, - client_count=0, - airport=cp.airport, - start_type=st) + group = self._generate_at_airport( + name=namegen.next_unit_name(country, cp.id, flight.unit_type), + side=country, + unit_type=flight.unit_type, + count=flight.count, + start_type=flight.start_type, + airport=cp.airport) except Exception as e: # Generated when there is no place on Runway or on Parking Slots logging.error(e) logging.warning("No room on runway or parking slots. Starting from the air.") flight.start_type = "In Flight" - group = self._generate_group( + 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, - client_count=0, at=cp.position) group.points[0].alt = 1500 @@ -1184,8 +992,8 @@ class AircraftConflictGenerator: 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, + def setup_flight_group(self, group: FlyingGroup, package: Package, + 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, @@ -1209,6 +1017,9 @@ class AircraftConflictGenerator: for waypoint in flight.points: waypoint.tot = None + takeoff_point = FlightWaypoint.from_pydcs(group.points[0], + flight.from_cp) + self.set_takeoff_time(takeoff_point, package, flight, group) for point in flight.points: if point.only_for_player and not flight.client_count: continue @@ -1219,9 +1030,53 @@ class AircraftConflictGenerator: # Set here rather than when the FlightData is created so they waypoints # have their TOTs set. - self.flights[-1].waypoints = flight.points + self.flights[-1].waypoints = [takeoff_point] + flight.points self._setup_custom_payload(flight, group) + def set_takeoff_time(self, waypoint: FlightWaypoint, package: Package, + flight: Flight, group: FlyingGroup) -> None: + estimator = TotEstimator(package) + start_time = estimator.mission_start_time(flight) + + if start_time > 0: + if self.should_activate_late(flight): + # Late activation causes the aircraft to not be spawned until + # triggered. + self.set_activation_time(flight, group, start_time) + elif flight.start_type == "Cold": + # Setting the start time causes the AI to wait until the + # specified time to begin their startup sequence. + self.set_startup_time(flight, group, start_time) + + # And setting *our* waypoint TOT causes the takeoff time to show up in + # the player's kneeboard. + waypoint.tot = estimator.takeoff_time_for_flight(flight) + + @staticmethod + def should_activate_late(flight: Flight) -> bool: + if flight.client_count: + # Never delay players. Note that cold start player flights with + # AI members will still be marked as uncontrolled until the start + # trigger fires to postpone engine start. + # + # Player flights that start on the runway or in the air will start + # immediately, and AI flight members will not be delayed. + return False + + if flight.start_type != "Cold": + # Avoid spawning aircraft in the air or on the runway until it's + # time for their mission. Also avoid burning through gas spawning + # hot aircraft hours before their takeoff time. + return True + + if flight.from_cp.is_fleet: + # Carrier spawns will crowd the carrier deck, especially without + # super carrier. + # TODO: Is there enough parking on the supercarrier? + return True + + return False + class PydcsWaypointBuilder: def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup, @@ -1248,10 +1103,8 @@ class PydcsWaypointBuilder: waypoint.speed_locked = False @classmethod - def for_waypoint(cls, waypoint: FlightWaypoint, - group: FlyingGroup, - flight: Flight, - timing: PackageWaypointTiming, + def for_waypoint(cls, waypoint: FlightWaypoint, group: FlyingGroup, + flight: Flight, timing: PackageWaypointTiming, mission: Mission) -> PydcsWaypointBuilder: builders = { FlightWaypointType.EGRESS: EgressPointBuilder, @@ -1396,9 +1249,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder): pattern=OrbitAction.OrbitPattern.RaceTrack )) - start = self.timing.race_track_start - if start is not None: - self.set_waypoint_tot(waypoint, start) + self.set_waypoint_tot(waypoint, self.timing.race_track_start) racetrack.stop_after_time(self.timing.race_track_end) waypoint.add_task(racetrack) return waypoint diff --git a/gen/ato.py b/gen/ato.py index e9c5393c..f4877125 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -52,7 +52,7 @@ class Package: delay: int = field(default=0) #: Desired TOT measured in seconds from mission start. - time_over_target: Optional[int] = field(default=None) + time_over_target: int = field(default=0) waypoints: Optional[PackageWaypoints] = field(default=None) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 82744a8a..d52f25cc 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -1,3 +1,4 @@ +import datetime import os from collections import defaultdict from dataclasses import dataclass @@ -116,9 +117,10 @@ class BriefingGenerator(MissionInfoGenerator): assert not flight.client_units aircraft = flight.aircraft_type flight_unit_name = db.unit_type_name(aircraft) + delay = datetime.timedelta(seconds=flight.departure_delay) self.description += ( f"{flight.flight_type.name} {flight_unit_name} x {flight.size}, " - f"departing in {flight.departure_delay} minutes\n" + f"departing in {delay}\n" ) def generate(self): diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 03fe6d32..8616180f 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -33,6 +33,7 @@ from gen.flights.flight import ( FlightType, ) from gen.flights.flightplan import FlightPlanBuilder +from gen.flights.traveltime import TotEstimator from theater import ( ControlPoint, FrontLine, @@ -185,11 +186,13 @@ class PackageBuilder: def __init__(self, location: MissionTarget, closest_airfields: ClosestAirfields, global_inventory: GlobalAircraftInventory, - is_player: bool) -> None: + is_player: bool, + start_type: str) -> None: self.package = Package(location) self.allocator = AircraftAllocator(closest_airfields, global_inventory, is_player) self.global_inventory = global_inventory + self.start_type = start_type def plan_flight(self, plan: ProposedFlight) -> bool: """Allocates aircraft for the given flight and adds them to the package. @@ -203,7 +206,8 @@ class PackageBuilder: if assignment is None: return False airfield, aircraft = assignment - flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task) + flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task, + self.start_type) self.package.add_flight(flight) flight.targetPoint = self.package.target return True @@ -445,11 +449,18 @@ class CoalitionMissionPlanner: def plan_mission(self, mission: ProposedMission) -> None: """Allocates aircraft for a proposed mission and adds it to the ATO.""" + + if self.game.settings.perf_ai_parking_start: + start_type = "Cold" + else: + start_type = "Warm" + builder = PackageBuilder( mission.location, self.objective_finder.closest_airfields_to(mission.location), self.game.aircraft_inventory, - self.is_player + self.is_player, + start_type ) missing_types: Set[FlightType] = set() @@ -498,16 +509,18 @@ class CoalitionMissionPlanner: margin=5 ) for package in self.ato.packages: + tot = TotEstimator(package).earliest_tot() 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 + # All CAP missions should be on station ASAP. + package.time_over_target = tot 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 + # But other packages should be spread out a bit. Note that take + # times are delayed, but all aircraft will become active at + # mission start. This makes it more worthwhile to attack enemy + # airfields to hit grounded aircraft, since they're more likely + # to be present. Runway and air started aircraft will be + # delayed until their takeoff time by AirConflictGenerator. + package.time_over_target = next(start_time) * 60 + tot 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 cef0987d..86abd24e 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -97,7 +97,6 @@ class FlightWaypoint: # flight's offset in the UI. self.tot: Optional[int] = None - @classmethod def from_pydcs(cls, point: MovingPoint, from_cp: ControlPoint) -> "FlightWaypoint": @@ -130,14 +129,11 @@ class Flight: client_count: int = 0 use_custom_loadout = False preset_loadout_name = "" - start_type = "Runway" group = False # Contains DCS Mission group data after mission has been generated targetPoint = None # Contains either None or a Strike/SEAD target point location - # How long before this flight should take off - scheduled_in = 0 - - def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, flight_type: FlightType): + def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, + flight_type: FlightType, start_type: str) -> None: self.unit_type = unit_type self.count = count self.from_cp = from_cp @@ -145,11 +141,16 @@ class Flight: self.points: List[FlightWaypoint] = [] self.targets: List[MissionTarget] = [] self.loadout: Dict[str, str] = {} - self.start_type = "Runway" + self.start_type = start_type + # Late activation delay in seconds from mission start. This is not + # the same as the flight's takeoff time. Takeoff time depends on the + # mission's TOT and the other flights in the package. Takeoff time is + # determined by AirConflictGenerator. + self.scheduled_in = 0 def __repr__(self): return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ - + " in " + str(self.scheduled_in) + " minutes (" + str(len(self.points)) + " wpt)" + + " (" + str(len(self.points)) + " wpt)" # Test @@ -158,6 +159,6 @@ if __name__ == '__main__': from theater import ControlPoint, Point, List from_cp = ControlPoint(0, "AA", Point(0, 0), Point(0, 0), [], 0, 0) - f = Flight(A_10C(), 4, from_cp, FlightType.CAS) + f = Flight(A_10C(), 4, from_cp, FlightType.CAS, "Cold") f.scheduled_in = 50 print(f) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py new file mode 100644 index 00000000..87d2817d --- /dev/null +++ b/gen/flights/traveltime.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable, Optional + +from dcs.mapping import Point + +from game.utils import meter_to_nm +from gen.ato import Package +from gen.flights.flight import ( + Flight, + FlightType, + FlightWaypoint, + FlightWaypointType, +) + + +CAP_DURATION = 30 # Minutes +CAP_TYPES = (FlightType.BARCAP, FlightType.CAP) + + +class GroundSpeed: + @classmethod + def for_package(cls, package: Package) -> int: + speeds = [] + for flight in package.flights: + speeds.append(cls.for_flight(flight)) + return min(speeds) # knots + + @staticmethod + def for_flight(_flight: Flight) -> int: + # TODO: Gather data so this is useful. + # TODO: Expose both a cruise speed and target speed. + # The cruise speed can be used for ascent, hold, join, and RTB to save + # on fuel, but mission speed will be fast enough to keep the flight + # safer. + return 400 # knots + + +class TravelTime: + @staticmethod + def between_points(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 TotEstimator: + # An extra five minutes given as wiggle room. Expected to be spent at the + # hold point performing any last minute configuration. + HOLD_TIME = 5 * 60 + + def __init__(self, package: Package) -> None: + self.package = package + self.timing = PackageWaypointTiming.for_package(package) + + def mission_start_time(self, flight: Flight) -> int: + takeoff_time = self.takeoff_time_for_flight(flight) + startup_time = self.estimate_startup(flight) + ground_ops_time = self.estimate_ground_ops(flight) + return takeoff_time - startup_time - ground_ops_time + + def takeoff_time_for_flight(self, flight: Flight) -> int: + stop_types = {FlightWaypointType.JOIN, FlightWaypointType.PATROL_TRACK} + travel_time = self.estimate_waypoints_to_target(flight, stop_types) + if travel_time is None: + logging.warning("Found no join point or patrol point. Cannot " + f"estimate takeoff time takeoff time for {flight}") + # Takeoff immediately. + return 0 + + if self.package.primary_task in CAP_TYPES: + start_time = self.timing.race_track_start + else: + start_time = self.timing.join + return start_time - travel_time - self.HOLD_TIME + + def earliest_tot(self) -> int: + return max(( + self.earliest_tot_for_flight(f) for f in self.package.flights + )) + self.HOLD_TIME + + def earliest_tot_for_flight(self, flight: Flight) -> int: + """Estimate fastest time from mission start to the target position. + + For CAP missions, this is time to race track start. + + For other mission types this is the time to the mission target. + + Args: + flight: The flight to get the earliest TOT time for. + + Returns: + The earliest possible TOT for the given flight in seconds. Returns 0 + if an ingress point cannot be found. + """ + stop_types = { + FlightWaypointType.PATROL_TRACK, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + } + time_to_ingress = self.estimate_waypoints_to_target(flight, stop_types) + if time_to_ingress is None: + logging.warning( + f"Found no ingress types. Cannot estimate TOT for {flight}") + # Return 0 so this flight's travel time does not affect the rest of + # the package. + return 0 + + if self.package.primary_task in CAP_TYPES: + # The racetrack start *is* the target. The package target is the + # protected objective. + time_to_target = 0 + else: + assert self.package.waypoints is not None + time_to_target = TravelTime.between_points( + self.package.waypoints.ingress, self.package.target.position, + GroundSpeed.for_package(self.package)) + return sum([ + self.estimate_startup(flight), + self.estimate_ground_ops(flight), + time_to_ingress, + time_to_target, + ]) + + @staticmethod + def estimate_startup(flight: Flight) -> int: + if flight.start_type == "Cold": + return 10 * 60 + return 0 + + @staticmethod + def estimate_ground_ops(flight: Flight) -> int: + if flight.start_type in ("Runway", "In Flight"): + return 0 + if flight.from_cp.is_fleet: + return 2 * 60 + else: + return 5 * 60 + + def estimate_waypoints_to_target( + self, flight: Flight, + stop_types: Iterable[FlightWaypointType]) -> Optional[int]: + total = 0 + previous_position = flight.from_cp.position + for waypoint in flight.points: + position = Point(waypoint.x, waypoint.y) + total += TravelTime.between_points( + previous_position, position, + self.speed_to_waypoint(flight, waypoint) + ) + previous_position = position + if waypoint.waypoint_type in stop_types: + return total + + return None + + def speed_to_waypoint(self, flight: Flight, + waypoint: FlightWaypoint) -> int: + pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN) + if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT: + # Flights that start airborne already have some altitude and a good + # amount of speed. + factor = 1.0 if flight.start_type == "In Flight" else 0.5 + return int(GroundSpeed.for_flight(flight) * factor) + elif waypoint.waypoint_type in pre_join: + return GroundSpeed.for_flight(flight) + return GroundSpeed.for_package(self.package) + + +@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) -> int: + if self.package.primary_task in CAP_TYPES: + return self.package.time_over_target + else: + return self.ingress + + @property + def race_track_end(self) -> int: + 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 - TravelTime.between_points( + hold_point, + self.package.waypoints.join, + GroundSpeed.for_flight(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.waypoints is not None + + group_ground_speed = GroundSpeed.for_package(package) + + ingress = package.time_over_target - TravelTime.between_points( + package.waypoints.ingress, + package.target.position, + group_ground_speed + ) + + join = ingress - TravelTime.between_points( + package.waypoints.join, + package.waypoints.ingress, + group_ground_speed + ) + + egress = package.time_over_target + TravelTime.between_points( + package.target.position, + package.waypoints.egress, + group_ground_speed + ) + + split = egress + TravelTime.between_points( + package.waypoints.egress, + package.waypoints.split, + group_ground_speed + ) + + return cls(package, join, ingress, egress, split) diff --git a/qt_ui/dialogs.py b/qt_ui/dialogs.py index e09dd92a..f88aa4b0 100644 --- a/qt_ui/dialogs.py +++ b/qt_ui/dialogs.py @@ -58,7 +58,7 @@ class Dialog: flight: Flight) -> None: """Opens the dialog to edit the given flight.""" cls.edit_flight_dialog = QEditFlightDialog( - cls.game_model.game, + cls.game_model, package_model.package, flight ) diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py new file mode 100644 index 00000000..1efa13ae --- /dev/null +++ b/qt_ui/displayoptions.py @@ -0,0 +1,65 @@ +"""Visibility options for the game map.""" +from dataclasses import dataclass +from typing import Iterator, Optional, Union + + +@dataclass +class DisplayRule: + name: str + _value: bool + + @property + def menu_text(self) -> str: + return self.name + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool) -> None: + from qt_ui.widgets.map.QLiberationMap import QLiberationMap + self._value = value + QLiberationMap.instance.reload_scene() + QLiberationMap.instance.update() + + def __bool__(self) -> bool: + return self.value + + +class DisplayGroup: + def __init__(self, name: Optional[str]) -> None: + self.name = name + + def __iter__(self) -> Iterator[DisplayRule]: + # Python 3.6 enforces that __dict__ is order preserving by default. + for value in self.__dict__.values(): + if isinstance(value, DisplayRule): + yield value + + +class FlightPathOptions(DisplayGroup): + def __init__(self) -> None: + super().__init__("Flight Paths") + self.hide = DisplayRule("Hide Flight Paths", False) + self.only_selected = DisplayRule("Show Selected Flight Path", False) + self.all = DisplayRule("Show All Flight Paths", True) + + +class DisplayOptions: + ground_objects = DisplayRule("Ground Objects", True) + control_points = DisplayRule("Control Points", True) + lines = DisplayRule("Lines", True) + events = DisplayRule("Events", True) + sam_ranges = DisplayRule("SAM Ranges", True) + waypoint_info = DisplayRule("Waypoint Information", True) + flight_paths = FlightPathOptions() + + @classmethod + def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: + # Python 3.6 enforces that __dict__ is order preserving by default. + for value in cls.__dict__.values(): + if isinstance(value, DisplayRule): + yield value + elif isinstance(value, DisplayGroup): + yield value diff --git a/qt_ui/models.py b/qt_ui/models.py index 98515eab..ba816fd1 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -1,7 +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 typing import Any, Callable, Dict, Iterator, Optional, TypeVar from PySide2.QtCore import ( QAbstractListModel, @@ -15,6 +14,7 @@ from game import db from game.game import Game from gen.ato import AirTaskingOrder, Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS from theater.missiontarget import MissionTarget @@ -95,6 +95,8 @@ class NullListModel(QAbstractListModel): class PackageModel(QAbstractListModel): """The model for an ATO package.""" + FlightRole = Qt.UserRole + #: Emitted when this package is being deleted from the ATO. deleted = Signal() @@ -113,17 +115,19 @@ class PackageModel(QAbstractListModel): return self.text_for_flight(flight) if role == Qt.DecorationRole: return self.icon_for_flight(flight) + elif role == PackageModel.FlightRole: + return flight return None - @staticmethod - def text_for_flight(flight: Flight) -> str: + def text_for_flight(self, flight: Flight) -> str: """Returns the text that should be displayed for the flight.""" task = flight.flight_type.name count = flight.count name = db.unit_type_name(flight.unit_type) - delay = flight.scheduled_in + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) origin = flight.from_cp.name - return f"[{task}] {count} x {name} from {origin} in {delay} minutes" + return f"[{task}] {count} x {name} from {origin} in {delay}" @staticmethod def icon_for_flight(flight: Flight) -> Optional[QIcon]: @@ -185,6 +189,8 @@ class AtoModel(QAbstractListModel): PackageRole = Qt.UserRole + client_slots_changed = Signal() + def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: super().__init__() self.game = game diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index 5c831c1e..0e80fe31 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -31,14 +31,20 @@ COLORS: Dict[str, QColor] = { "white_transparent": QColor(255, 255, 255, 35), "grey_transparent": QColor(150, 150, 150, 30), + "light_red": QColor(231, 92, 83, 90), "red": QColor(200, 80, 80), "dark_red": QColor(140, 20, 20), "red_transparent": QColor(227, 32, 0, 20), + "transparent": QColor(255, 255, 255, 0), + "light_blue": QColor(105, 182, 240, 90), "blue": QColor(0, 132, 255), "dark_blue": QColor(45, 62, 80), "blue_transparent": QColor(0, 132, 255, 20), + "purple": QColor(187, 137, 255), + "yellow": QColor(238, 225, 123), + "bright_red": QColor(150, 80, 80), "super_red": QColor(227, 32, 0), diff --git a/qt_ui/widgets/QLabeledWidget.py b/qt_ui/widgets/QLabeledWidget.py index 88459896..91bb52bb 100644 --- a/qt_ui/widgets/QLabeledWidget.py +++ b/qt_ui/widgets/QLabeledWidget.py @@ -1,4 +1,6 @@ """A layout containing a widget with an associated label.""" +from typing import Optional + from PySide2.QtCore import Qt from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget @@ -10,8 +12,13 @@ class QLabeledWidget(QHBoxLayout): label is used to name the input. """ - def __init__(self, text: str, widget: QWidget) -> None: + def __init__(self, text: str, widget: QWidget, + tooltip: Optional[str] = None) -> None: super().__init__() - self.addWidget(QLabel(text)) + label = QLabel(text) + self.addWidget(label) self.addStretch() self.addWidget(widget, alignment=Qt.AlignRight) + if tooltip is not None: + label.setToolTip(tooltip) + widget.setToolTip(tooltip) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index f75826ad..99f0ac9f 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -11,9 +11,11 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game from game.event import CAP, CAS, FrontlineAttackEvent +from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QFactionsInfos import QFactionsInfos from qt_ui.widgets.QTurnCounter import QTurnCounter +from qt_ui.widgets.clientslots import MaxPlayerCount from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QWaitingForMissionResultWindow import \ QWaitingForMissionResultWindow @@ -23,14 +25,18 @@ from qt_ui.windows.stats.QStatsWindow import QStatsWindow class QTopPanel(QFrame): - def __init__(self, game: Game): + def __init__(self, game_model: GameModel): super(QTopPanel, self).__init__() - self.game = game + self.game_model = game_model self.setMaximumHeight(70) self.init_ui() GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) GameUpdateSignal.get_instance().budgetupdated.connect(self.budget_update) + @property + def game(self) -> Optional[Game]: + return self.game_model.game + def init_ui(self): self.turnCounter = QTurnCounter() @@ -68,6 +74,8 @@ class QTopPanel(QFrame): self.proceedBox = QGroupBox("Proceed") self.proceedBoxLayout = QHBoxLayout() + self.proceedBoxLayout.addLayout( + MaxPlayerCount(self.game_model.ato_model)) self.proceedBoxLayout.addWidget(self.passTurnButton) self.proceedBoxLayout.addWidget(self.proceedButton) self.proceedBox.setLayout(self.proceedBoxLayout) @@ -84,16 +92,17 @@ class QTopPanel(QFrame): self.setLayout(self.layout) def setGame(self, game: Optional[Game]): - self.game = game - if game is not None: - self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day) - self.budgetBox.setGame(self.game) - self.factionsInfos.setGame(self.game) + if game is None: + return - if self.game and self.game.turn == 0: - self.proceedButton.setEnabled(False) - else: - self.proceedButton.setEnabled(True) + self.turnCounter.setCurrentTurn(game.turn, game.current_day) + self.budgetBox.setGame(game) + self.factionsInfos.setGame(game) + + if game and game.turn == 0: + self.proceedButton.setEnabled(False) + else: + self.proceedButton.setEnabled(True) def openSettings(self): self.subwindow = QSettingsWindow(self.game) @@ -138,6 +147,8 @@ class QTopPanel(QFrame): if not self.ato_has_clients() and not self.confirm_no_client_launch(): return + # TODO: Verify no negative start times. + # 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 b39f3063..32178381 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -10,7 +10,7 @@ from PySide2.QtCore import ( QSize, Qt, ) -from PySide2.QtGui import QFont, QFontMetrics, QPainter +from PySide2.QtGui import QFont, QFontMetrics, QIcon, QPainter from PySide2.QtWidgets import ( QAbstractItemView, QGroupBox, @@ -18,15 +18,115 @@ from PySide2.QtWidgets import ( QListView, QPushButton, QSplitter, - QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, + QStyle, QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout, ) +from game import db from gen.ato import Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from ..models import AtoModel, GameModel, NullListModel, PackageModel +class FlightDelegate(QStyledItemDelegate): + FONT_SIZE = 10 + HMARGIN = 4 + VMARGIN = 4 + + def __init__(self, package: Package) -> None: + super().__init__() + self.package = package + + def get_font(self, option: QStyleOptionViewItem) -> QFont: + font = QFont(option.font) + font.setPointSize(self.FONT_SIZE) + return font + + @staticmethod + def flight(index: QModelIndex) -> Flight: + return index.data(PackageModel.FlightRole) + + def first_row_text(self, index: QModelIndex) -> str: + flight = self.flight(index) + task = flight.flight_type.name + count = flight.count + name = db.unit_type_name(flight.unit_type) + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + return f"[{task}] {count} x {name} in {delay}" + + def second_row_text(self, index: QModelIndex) -> str: + flight = self.flight(index) + origin = flight.from_cp.name + return f"From {origin}" + + 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)) + + icon: Optional[QIcon] = index.data(Qt.DecorationRole) + if icon is not None: + icon.paint(painter, rect, Qt.AlignLeft | Qt.AlignVCenter, + self.icon_mode(option), + self.icon_state(option)) + + rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, + 0, 0, 0) + painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index)) + line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) + painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index)) + + clients = self.num_clients(index) + if clients: + painter.drawText(rect, Qt.AlignRight, + f"Player Slots: {clients}") + + def num_clients(self, index: QModelIndex) -> int: + flight = self.flight(index) + return flight.client_count + + @staticmethod + def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode: + if not (option.state & QStyle.State_Enabled): + return QIcon.Disabled + elif option.state & QStyle.State_Selected: + return QIcon.Selected + elif option.state & QStyle.State_Active: + return QIcon.Active + return QIcon.Normal + + @staticmethod + def icon_state(option: QStyleOptionViewItem) -> QIcon.State: + return QIcon.On if option.state & QStyle.State_Open else QIcon.Off + + @staticmethod + def icon_size(option: QStyleOptionViewItem) -> QSize: + icon_size: Optional[QSize] = option.decorationSize + if icon_size is None: + return QSize(0, 0) + else: + return icon_size + + def sizeHint(self, option: QStyleOptionViewItem, + index: QModelIndex) -> QSize: + left = self.icon_size(option).width() + self.HMARGIN + metrics = QFontMetrics(self.get_font(option)) + first = metrics.size(0, self.first_row_text(index)) + second = metrics.size(0, self.second_row_text(index)) + text_width = max(first.width(), second.width()) + return QSize(left + text_width + 2 * self.HMARGIN, + first.height() + second.height() + 2 * self.VMARGIN) + + class QFlightList(QListView): """List view for displaying the flights of a package.""" @@ -34,6 +134,8 @@ class QFlightList(QListView): super().__init__() self.package_model = model self.set_package(model) + if model is not None: + self.setItemDelegate(FlightDelegate(model.package)) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) @@ -43,6 +145,7 @@ class QFlightList(QListView): self.disconnect_model() else: self.package_model = model + self.setItemDelegate(FlightDelegate(model.package)) self.setModel(model) # noinspection PyUnresolvedReferences model.deleted.connect(self.disconnect_model) @@ -109,6 +212,7 @@ class QFlightPanel(QGroupBox): """Sets the package model to display.""" self.package_model = model self.flight_list.set_package(model) + self.selection_changed.connect(self.on_selection_changed) self.on_selection_changed() @property @@ -122,6 +226,15 @@ class QFlightPanel(QGroupBox): enabled = index.isValid() self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) + self.change_map_flight_selection(index) + + @staticmethod + def change_map_flight_selection(index: QModelIndex) -> None: + if not index.isValid(): + GameUpdateSignal.get_instance().select_flight(None) + return + + GameUpdateSignal.get_instance().select_flight(index.row()) def on_edit(self) -> None: """Opens the flight edit dialog.""" @@ -196,6 +309,15 @@ class PackageDelegate(QStyledItemDelegate): line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) painter.drawText(line2, Qt.AlignLeft, self.right_text(index)) + clients = self.num_clients(index) + if clients: + painter.drawText(rect, Qt.AlignRight, + f"Player Slots: {clients}") + + def num_clients(self, index: QModelIndex) -> int: + package = self.package(index) + return sum(f.client_count for f in package.flights) + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: metrics = QFontMetrics(self.get_font(option)) @@ -270,6 +392,18 @@ class QPackagePanel(QGroupBox): enabled = index.isValid() self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) + self.change_map_package_selection(index) + + def change_map_package_selection(self, index: QModelIndex) -> None: + if not index.isValid(): + GameUpdateSignal.get_instance().select_package(None) + return + + package = self.ato_model.get_package_model(index) + if package.rowCount() == 0: + GameUpdateSignal.get_instance().select_package(None) + else: + GameUpdateSignal.get_instance().select_package(index.row()) def on_edit(self) -> None: """Opens the package edit dialog.""" diff --git a/qt_ui/widgets/clientslots.py b/qt_ui/widgets/clientslots.py new file mode 100644 index 00000000..1c9fff9b --- /dev/null +++ b/qt_ui/widgets/clientslots.py @@ -0,0 +1,28 @@ +"""Widgets for displaying client slots.""" +from PySide2.QtWidgets import QLabel + +from qt_ui.models import AtoModel +from qt_ui.widgets.QLabeledWidget import QLabeledWidget + + +class MaxPlayerCount(QLabeledWidget): + def __init__(self, ato_model: AtoModel) -> None: + self.ato_model = ato_model + self.slots_label = QLabel(str(self.count_client_slots)) + self.ato_model.client_slots_changed.connect(self.update_count) + super().__init__( + "Max Players:", self.slots_label, + ("Total number of client slots. To add client slots, edit a flight " + "using the panel on the left.") + ) + + @property + def count_client_slots(self) -> int: + slots = 0 + for package in self.ato_model.packages: + for flight in package.flights: + slots += flight.client_count + return slots + + def update_count(self) -> None: + self.slots_label.setText(str(self.count_client_slots)) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 3d775367..3c0064f9 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,5 +1,8 @@ +from __future__ import annotations + +import datetime import logging -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from PySide2.QtCore import Qt from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent @@ -15,14 +18,18 @@ from dcs.mapping import point_from_heading import qt_ui.uiconstants as CONST from game import Game, db +from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR -from gen import Conflict -from gen.flights.flight import Flight +from game.utils import meter_to_feet +from gen import Conflict, PackageWaypointTiming +from gen.ato import Package +from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType +from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel +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.QFrontLine import QFrontLine from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from theater import ControlPoint, FrontLine @@ -30,15 +37,7 @@ from theater import ControlPoint, FrontLine class QLiberationMap(QGraphicsView): WAYPOINT_SIZE = 4 - instance = None - display_rules: Dict[str, bool] = { - "cp": True, - "go": True, - "lines": True, - "events": True, - "sam": True, - "flight_paths": False - } + instance: Optional[QLiberationMap] = None def __init__(self, game_model: GameModel): super(QLiberationMap, self).__init__() @@ -47,6 +46,8 @@ class QLiberationMap(QGraphicsView): self.game: Optional[Game] = game_model.game self.flight_path_items: List[QGraphicsItem] = [] + # A tuple of (package index, flight index), or none. + self.selected_flight: Optional[Tuple[int, int]] = None self.setMinimumSize(800,600) self.setMaximumHeight(2160) @@ -61,6 +62,25 @@ class QLiberationMap(QGraphicsView): lambda: self.draw_flight_plans(self.scene()) ) + def update_package_selection(index: Optional[int]) -> None: + self.selected_flight = index, 0 + self.draw_flight_plans(self.scene()) + + GameUpdateSignal.get_instance().package_selection_changed.connect( + update_package_selection + ) + + def update_flight_selection(index: Optional[int]) -> None: + if self.selected_flight is None: + logging.error("Flight was selected with no package selected") + return + self.selected_flight = self.selected_flight[0], index + self.draw_flight_plans(self.scene()) + + GameUpdateSignal.get_instance().flight_selection_changed.connect( + update_flight_selection + ) + def init_scene(self): scene = QLiberationScene(self) self.setScene(scene) @@ -161,27 +181,44 @@ class QLiberationMap(QGraphicsView): buildings = self.game.theater.find_ground_objects_by_obj_name(ground_object.obj_name) scene.addItem(QMapGroundObject(self, go_pos[0], go_pos[1], 14, 12, cp, ground_object, self.game, buildings)) - if ground_object.category == "aa" and self.get_display_rule("sam"): - max_range = 0 - has_radar = False + is_aa = ground_object.category == "aa" + if is_aa and DisplayOptions.sam_ranges: + threat_range = 0 + detection_range = 0 + can_fire = False if ground_object.groups: for g in ground_object.groups: for u in g.units: unit = db.unit_type_from_name(u.type) - if unit in UNITS_WITH_RADAR: - has_radar = True - if unit.threat_range > max_range: - max_range = unit.threat_range - if has_radar: - scene.addEllipse(go_pos[0] - max_range/300.0 + 8, go_pos[1] - max_range/300.0 + 8, max_range/150.0, max_range/150.0, CONST.COLORS["white_transparent"], CONST.COLORS["grey_transparent"]) + if unit in UNITS_WITH_RADAR or unit in AAA_UNITS: + can_fire = True + if unit.detection_range > detection_range: + detection_range = unit.detection_range + if unit.threat_range > threat_range: + threat_range = unit.threat_range + if can_fire: + threat_pos = self._transform_point(Point(ground_object.position.x+threat_range, + ground_object.position.y+threat_range)) + detection_pos = self._transform_point(Point(ground_object.position.x+detection_range, + ground_object.position.y+detection_range)) + threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) + detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) + + # Add detection range circle + scene.addEllipse(go_pos[0] - detection_radius/2 + 7, go_pos[1] - detection_radius/2 + 6, + detection_radius, detection_radius, self.detection_pen(cp.captured)) + + # Add threat range circle + scene.addEllipse(go_pos[0] - threat_radius / 2 + 7, go_pos[1] - threat_radius / 2 + 6, + threat_radius, threat_radius, self.threat_pen(cp.captured)) added_objects.append(ground_object.obj_name) for cp in self.game.theater.enemy_points(): - if self.get_display_rule("lines"): + if DisplayOptions.lines: self.scene_create_lines_for_cp(cp, playerColor, enemyColor) for cp in self.game.theater.player_points(): - if self.get_display_rule("lines"): + if DisplayOptions.lines: self.scene_create_lines_for_cp(cp, playerColor, enemyColor) self.draw_flight_plans(scene) @@ -202,37 +239,94 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() - if not self.get_display_rule("flight_paths"): + if DisplayOptions.flight_paths.hide: return - for package in self.game_model.ato_model.packages: - for flight in package.flights: - self.draw_flight_plan(scene, flight) + packages = list(self.game_model.ato_model.packages) + for p_idx, package_model in enumerate(packages): + for f_idx, flight in enumerate(package_model.flights): + selected = (p_idx, f_idx) == self.selected_flight + if DisplayOptions.flight_paths.only_selected and not selected: + continue + self.draw_flight_plan(scene, package_model.package, flight, + selected) - def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight) -> None: + def draw_flight_plan(self, scene: QGraphicsScene, package: Package, + flight: Flight, selected: bool) -> None: is_player = flight.from_cp.captured pos = self._transform_point(flight.from_cp.position) - self.draw_waypoint(scene, pos, is_player) + self.draw_waypoint(scene, pos, is_player, selected) prev_pos = tuple(pos) - for point in flight.points: + drew_target = False + target_types = ( + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + for idx, point in enumerate(flight.points): new_pos = self._transform_point(Point(point.x, point.y)) - self.draw_flight_path(scene, prev_pos, new_pos, is_player) - self.draw_waypoint(scene, new_pos, is_player) + self.draw_flight_path(scene, prev_pos, new_pos, is_player, + selected) + self.draw_waypoint(scene, new_pos, is_player, selected) + if selected and DisplayOptions.waypoint_info: + if point.waypoint_type in target_types: + if drew_target: + # Don't draw dozens of targets over each other. + continue + drew_target = True + self.draw_waypoint_info(scene, idx + 1, point, new_pos, package, + flight) prev_pos = tuple(new_pos) - self.draw_flight_path(scene, prev_pos, pos, is_player) + self.draw_flight_path(scene, prev_pos, pos, is_player, selected) def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], - player: bool) -> None: - waypoint_pen = self.waypoint_pen(player) - waypoint_brush = self.waypoint_brush(player) + player: bool, selected: bool) -> None: + waypoint_pen = self.waypoint_pen(player, selected) + waypoint_brush = self.waypoint_brush(player, selected) self.flight_path_items.append(scene.addEllipse( position[0], position[1], self.WAYPOINT_SIZE, self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush )) + def draw_waypoint_info(self, scene: QGraphicsScene, number: int, + waypoint: FlightWaypoint, position: Tuple[int, int], + package: Package, flight: Flight) -> None: + timing = PackageWaypointTiming.for_package(package) + + altitude = meter_to_feet(waypoint.alt) + altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" + + prefix = "TOT" + time = timing.tot_for_waypoint(waypoint) + if time is None: + prefix = "Depart" + time = timing.depart_time_for_waypoint(waypoint, flight) + if time is None: + tot = "" + else: + tot = f"{prefix} T+{datetime.timedelta(seconds=time)}" + + pen = QPen(QColor("black"), 0.3) + brush = QColor("white") + + def draw_text(text: str, x: int, y: int) -> None: + item = scene.addSimpleText(text) + item.setBrush(brush) + item.setPen(pen) + item.moveBy(x, y) + item.setZValue(2) + self.flight_path_items.append(item) + + draw_text(f"{number} {waypoint.name}", position[0] + 8, + position[1] - 15) + draw_text(f"{altitude} ft {altitude_type}", position[0] + 8, + position[1] - 5) + draw_text(tot, position[0] + 8, position[1] + 5) + def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], - pos1: Tuple[int, int], player: bool): - flight_path_pen = self.flight_path_pen(player) + pos1: Tuple[int, int], player: bool, + selected: bool) -> None: + flight_path_pen = self.flight_path_pen(player, selected) # Draw the line to the *middle* of the waypoint. offset = self.WAYPOINT_SIZE // 2 self.flight_path_items.append(scene.addLine( @@ -321,21 +415,48 @@ class QLiberationMap(QGraphicsView): return X > treshold and X or treshold, Y > treshold and Y or treshold + def highlight_color(self, transparent: Optional[bool] = False) -> QColor: + return QColor(255, 255, 0, 20 if transparent else 255) + def base_faction_color_name(self, player: bool) -> str: if player: return self.game.get_player_color() else: return self.game.get_enemy_color() - def waypoint_pen(self, player: bool) -> QPen: + def waypoint_pen(self, player: bool, selected: bool) -> QColor: + if selected and DisplayOptions.flight_paths.all: + return self.highlight_color() name = self.base_faction_color_name(player) - return QPen(brush=CONST.COLORS[name]) + return CONST.COLORS[name] - def waypoint_brush(self, player: bool) -> QColor: + def waypoint_brush(self, player: bool, selected: bool) -> QColor: + if selected and DisplayOptions.flight_paths.all: + return self.highlight_color(transparent=True) name = self.base_faction_color_name(player) return CONST.COLORS[f"{name}_transparent"] - def flight_path_pen(self, player: bool) -> QPen: + def threat_pen(self, player: bool) -> QPen: + if player: + color = "blue" + else: + color = "red" + qpen = QPen(CONST.COLORS[color]) + return qpen + + def detection_pen(self, player: bool) -> QPen: + if player: + color = "purple" + else: + color = "yellow" + qpen = QPen(CONST.COLORS[color]) + qpen.setStyle(Qt.DotLine) + return qpen + + def flight_path_pen(self, player: bool, selected: bool) -> QPen: + if selected and DisplayOptions.flight_paths.all: + return self.highlight_color() + name = self.base_faction_color_name(player) color = CONST.COLORS[name] pen = QPen(brush=color) @@ -367,18 +488,3 @@ class QLiberationMap(QGraphicsView): effect = QGraphicsOpacityEffect() effect.setOpacity(0.3) overlay.setGraphicsEffect(effect) - - - @staticmethod - def set_display_rule(rule: str, value: bool): - QLiberationMap.display_rules[rule] = value - QLiberationMap.instance.reload_scene() - QLiberationMap.instance.update() - - @staticmethod - def get_display_rules() -> Dict[str, bool]: - return QLiberationMap.display_rules - - @staticmethod - def get_display_rule(rule) -> bool: - return QLiberationMap.display_rules[rule] diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index f5b2e1c4..ef9bf5c9 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -7,6 +7,7 @@ from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 from theater import ControlPoint from .QMapObject import QMapObject +from ...displayoptions import DisplayOptions class QMapControlPoint(QMapObject): @@ -21,7 +22,7 @@ class QMapControlPoint(QMapObject): self.base_details_dialog: Optional[QBaseMenu2] = None def paint(self, painter, option, widget=None) -> None: - if self.parent.get_display_rule("cp"): + if DisplayOptions.control_points: painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(self.brush_color) diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index 1ed9f3d2..af0789a8 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -8,8 +8,9 @@ import qt_ui.uiconstants as const from game import Game from game.data.building_data import FORTIFICATION_BUILDINGS from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from theater import TheaterGroundObject, ControlPoint +from theater import ControlPoint, TheaterGroundObject from .QMapObject import QMapObject +from ...displayoptions import DisplayOptions class QMapGroundObject(QMapObject): @@ -50,7 +51,7 @@ class QMapGroundObject(QMapObject): player_icons = "_blue" enemy_icons = "" - if self.parent.get_display_rule("go"): + if DisplayOptions.ground_objects: painter.save() cat = self.ground_object.category diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 8a52d555..3c112952 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Tuple from PySide2.QtCore import QObject, Signal @@ -24,11 +24,21 @@ class GameUpdateSignal(QObject): debriefingReceived = Signal(DebriefingSignal) flight_paths_changed = Signal() + package_selection_changed = Signal(int) # Optional[int] + flight_selection_changed = Signal(int) # Optional[int] def __init__(self): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self + def select_package(self, index: Optional[int]) -> None: + # noinspection PyUnresolvedReferences + self.package_selection_changed.emit(index) + + def select_flight(self, index: Optional[int]) -> None: + # noinspection PyUnresolvedReferences + self.flight_selection_changed.emit(index) + def redraw_flight_paths(self) -> None: # noinspection PyUnresolvedReferences self.flight_paths_changed.emit() diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 3933083c..db5caa10 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -1,16 +1,16 @@ import logging import sys import webbrowser -from typing import Optional +from typing import Optional, Union from PySide2.QtCore import Qt from PySide2.QtGui import QIcon from PySide2.QtWidgets import ( QAction, - QDesktopWidget, + QActionGroup, QDesktopWidget, QFileDialog, QMainWindow, - QMessageBox, + QMenu, QMessageBox, QSplitter, QVBoxLayout, QWidget, @@ -19,6 +19,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, persistency from qt_ui.dialogs import Dialog +from qt_ui.displayoptions import DisplayGroup, DisplayOptions, DisplayRule from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel @@ -76,7 +77,7 @@ class QLiberationWindow(QMainWindow): vbox = QVBoxLayout() vbox.setMargin(0) - vbox.addWidget(QTopPanel(self.game)) + vbox.addWidget(QTopPanel(self.game_model)) vbox.addWidget(hbox) central_widget = QWidget() @@ -134,48 +135,23 @@ class QLiberationWindow(QMainWindow): file_menu.addSeparator() file_menu.addAction(self.showLiberationPrefDialogAction) file_menu.addSeparator() - #file_menu.addAction("Close Current Game", lambda: self.closeGame()) # Not working file_menu.addAction("E&xit" , lambda: self.exit()) displayMenu = self.menu.addMenu("&Display") - tg_cp_visibility = QAction('&Control Point', displayMenu) - tg_cp_visibility.setCheckable(True) - tg_cp_visibility.setChecked(True) - tg_cp_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("cp", tg_cp_visibility.isChecked())) - - tg_go_visibility = QAction('&Ground Objects', displayMenu) - tg_go_visibility.setCheckable(True) - tg_go_visibility.setChecked(True) - tg_go_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("go", tg_go_visibility.isChecked())) - - tg_line_visibility = QAction('&Lines', displayMenu) - tg_line_visibility.setCheckable(True) - tg_line_visibility.setChecked(True) - tg_line_visibility.toggled.connect( - lambda: QLiberationMap.set_display_rule("lines", tg_line_visibility.isChecked())) - - tg_event_visibility = QAction('&Events', displayMenu) - tg_event_visibility.setCheckable(True) - tg_event_visibility.setChecked(True) - tg_event_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("events", tg_event_visibility.isChecked())) - - tg_sam_visibility = QAction('&SAM Range', displayMenu) - tg_sam_visibility.setCheckable(True) - tg_sam_visibility.setChecked(True) - tg_sam_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("sam", tg_sam_visibility.isChecked())) - - tg_flight_path_visibility = QAction('&Flight Paths', displayMenu) - tg_flight_path_visibility.setCheckable(True) - tg_flight_path_visibility.setChecked(False) - tg_flight_path_visibility.toggled.connect(lambda: QLiberationMap.set_display_rule("flight_paths", tg_flight_path_visibility.isChecked())) - - displayMenu.addAction(tg_go_visibility) - displayMenu.addAction(tg_cp_visibility) - displayMenu.addAction(tg_line_visibility) - displayMenu.addAction(tg_event_visibility) - displayMenu.addAction(tg_sam_visibility) - displayMenu.addAction(tg_flight_path_visibility) + last_was_group = True + for item in DisplayOptions.menu_items(): + if isinstance(item, DisplayRule): + displayMenu.addAction(self.make_display_rule_action(item)) + last_was_group = False + elif isinstance(item, DisplayGroup): + if not last_was_group: + displayMenu.addSeparator() + group = QActionGroup(displayMenu) + for display_rule in item: + displayMenu.addAction( + self.make_display_rule_action(display_rule, group)) + last_was_group = True help_menu = self.menu.addMenu("&Help") help_menu.addAction("&Discord Server", lambda: webbrowser.open_new_tab("https://" + "discord.gg" + "/" + "bKrt" + "rkJ")) @@ -188,6 +164,21 @@ class QLiberationWindow(QMainWindow): help_menu.addSeparator() help_menu.addAction(self.showAboutDialogAction) + @staticmethod + def make_display_rule_action( + display_rule, group: Optional[QActionGroup] = None) -> QAction: + def make_check_closure(): + def closure(): + display_rule.value = action.isChecked() + + return closure + + action = QAction(f"&{display_rule.menu_text}", group) + action.setCheckable(True) + action.setChecked(display_rule.value) + action.toggled.connect(make_check_closure()) + return action + def newGame(self): wizard = NewGameWizard(self) wizard.show() diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 9f795b79..bfbcc5cb 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -4,9 +4,9 @@ from PySide2.QtWidgets import ( QVBoxLayout, ) -from game import Game from gen.ato import Package from gen.flights.flight import Flight +from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner @@ -15,22 +15,22 @@ from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner class QEditFlightDialog(QDialog): """Dialog window for editing flight plans and loadouts.""" - def __init__(self, game: Game, package: Package, flight: Flight) -> None: + def __init__(self, game_model: GameModel, package: Package, flight: Flight) -> None: super().__init__() - self.game = game + self.game_model = game_model self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) layout = QVBoxLayout() - self.flight_planner = QFlightPlanner(package, flight, game) + self.flight_planner = QFlightPlanner(package, flight, game_model.game) layout.addWidget(self.flight_planner) self.setLayout(layout) self.finished.connect(self.on_close) - @staticmethod - def on_close(_result) -> None: + def on_close(self, _result) -> None: GameUpdateSignal.get_instance().redraw_flight_paths() + self.game_model.ato_model.client_slots_changed.emit() diff --git a/qt_ui/windows/mission/QFlightItem.py b/qt_ui/windows/mission/QFlightItem.py index 5e4c4c11..7f006dbb 100644 --- a/qt_ui/windows/mission/QFlightItem.py +++ b/qt_ui/windows/mission/QFlightItem.py @@ -1,26 +1,28 @@ +import datetime + from PySide2.QtGui import QStandardItem, QIcon from game import db +from gen.ato import Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS +# TODO: Replace with QFlightList. class QFlightItem(QStandardItem): - def __init__(self, flight:Flight): + def __init__(self, package: Package, flight: Flight): super(QFlightItem, self).__init__() + self.package = package self.flight = flight if db.unit_type_name(self.flight.unit_type).replace("/", " ") in AIRCRAFT_ICONS.keys(): icon = QIcon((AIRCRAFT_ICONS[db.unit_type_name(self.flight.unit_type)])) self.setIcon(icon) self.setEditable(False) + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) self.setText("["+str(self.flight.flight_type.name[:6])+"] " + str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type) - + " in " + str(self.flight.scheduled_in) + " minutes") - - def update(self, flight): - self.flight = flight - self.setText("[" + str(self.flight.flight_type.name[:6]) + "] " - + str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type) - + " in " + str(self.flight.scheduled_in) + " minutes") \ No newline at end of file + + " in " + str(delay)) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index d9deb613..3c64c160 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -116,11 +116,14 @@ class QPackageDialog(QDialog): self.finished.connect(self.on_close) - def on_close(self, _result) -> None: + @staticmethod + def on_close(_result) -> None: + GameUpdateSignal.get_instance().redraw_flight_paths() + + def save_tot(self) -> 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, _deselected: QItemSelection) -> None: @@ -182,6 +185,7 @@ class QNewPackageDialog(QPackageDialog): Empty packages may be created. They can be modified later, and will have no effect if empty when the mission is generated. """ + self.save_tot() self.ato_model.add_package(self.package_model.package) for flight in self.package_model.package.flights: self.game.aircraft_inventory.claim_for_flight(flight) @@ -227,6 +231,7 @@ class QEditPackageDialog(QPackageDialog): def on_done(self) -> None: """Closes the window.""" + self.save_tot() self.close() def on_delete(self) -> None: diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 2c8c7dfe..e514b9d7 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -90,7 +90,11 @@ class QFlightCreator(QDialog): origin = self.airfield_selector.currentData() size = self.flight_size_spinner.value() - flight = Flight(aircraft, size, origin, task) + if self.game.settings.perf_ai_parking_start: + start_type = "Cold" + else: + start_type = "Warm" + flight = Flight(aircraft, size, origin, task, start_type) flight.scheduled_in = self.package.delay # noinspection PyUnresolvedReferences diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py index 25e75e7c..abf429cf 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py @@ -1,6 +1,7 @@ from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox +# TODO: Remove? class QFlightDepartureEditor(QGroupBox): def __init__(self, flight): @@ -15,7 +16,7 @@ class QFlightDepartureEditor(QGroupBox): self.departure_delta = QSpinBox(self) self.departure_delta.setMinimum(0) self.departure_delta.setMaximum(120) - self.departure_delta.setValue(self.flight.scheduled_in) + self.departure_delta.setValue(self.flight.scheduled_in // 60) self.departure_delta.valueChanged.connect(self.change_scheduled) layout.addWidget(self.depart_from) @@ -27,4 +28,4 @@ class QFlightDepartureEditor(QGroupBox): self.changed = self.departure_delta.valueChanged def change_scheduled(self): - self.flight.scheduled_in = int(self.departure_delta.value()) + self.flight.scheduled_in = int(self.departure_delta.value() * 60) diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 8a706ec7..9f49e8d1 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals import datetime import logging -from typing import List +from typing import List, Optional from PySide2 import QtGui, QtWidgets -from PySide2.QtCore import QItemSelectionModel, QPoint +from PySide2.QtCore import QItemSelectionModel, QPoint, Qt from PySide2.QtWidgets import QVBoxLayout from dcs.task import CAP, CAS @@ -63,6 +63,7 @@ class NewGameWizard(QtWidgets.QWizard): no_player_navy = self.field("no_player_navy") no_enemy_navy = self.field("no_enemy_navy") invertMap = self.field("invertMap") + starting_money = int(self.field("starting_money")) player_name = blueFaction enemy_name = redFaction @@ -76,12 +77,12 @@ class NewGameWizard(QtWidgets.QWizard): settings.do_not_generate_enemy_navy = no_enemy_navy self.generatedGame = self.start_new_game(player_name, enemy_name, conflictTheater, midGame, multiplier, - timePeriod, settings) + timePeriod, settings, starting_money) super(NewGameWizard, self).accept() def start_new_game(self, player_name: str, enemy_name: str, conflictTheater: ConflictTheater, - midgame: bool, multiplier: float, period: datetime, settings:Settings): + midgame: bool, multiplier: float, period: datetime, settings:Settings, starting_money: int): # Reset name generator namegen.reset() @@ -102,14 +103,10 @@ class NewGameWizard(QtWidgets.QWizard): print("-- Game Object generated") start_generator.generate_groundobjects(conflictTheater, game) - game.budget = int(game.budget * multiplier) + game.budget = starting_money game.settings.multiplier = multiplier game.settings.sams = True game.settings.version = CONST.VERSION_STRING - - if midgame: - game.budget = game.budget * 4 * len(list(conflictTheater.conflicts())) - return game @@ -298,6 +295,44 @@ class TheaterConfiguration(QtWidgets.QWizardPage): self.setLayout(layout) +class CurrencySpinner(QtWidgets.QSpinBox): + def __init__(self, minimum: Optional[int] = None, + maximum: Optional[int] = None, + initial: Optional[int] = None) -> None: + super().__init__() + + if minimum is not None: + self.setMinimum(minimum) + if maximum is not None: + self.setMaximum(maximum) + if initial is not None: + self.setValue(initial) + + def textFromValue(self, val: int) -> str: + return f"${val}" + + +class BudgetInputs(QtWidgets.QGridLayout): + def __init__(self) -> None: + super().__init__() + self.addWidget(QtWidgets.QLabel("Starting money"), 0, 0) + + minimum = 0 + maximum = 5000 + initial = 650 + + slider = QtWidgets.QSlider(Qt.Horizontal) + slider.setMinimum(minimum) + slider.setMaximum(maximum) + slider.setValue(initial) + self.starting_money = CurrencySpinner(minimum, maximum, initial) + slider.valueChanged.connect(lambda x: self.starting_money.setValue(x)) + self.starting_money.valueChanged.connect(lambda x: slider.setValue(x)) + + self.addWidget(slider, 1, 0) + self.addWidget(self.starting_money, 1, 1) + + class MiscOptions(QtWidgets.QWizardPage): def __init__(self, parent=None): super(MiscOptions, self).__init__(parent) @@ -330,6 +365,13 @@ class MiscOptions(QtWidgets.QWizardPage): no_enemy_navy = QtWidgets.QCheckBox() self.registerField('no_enemy_navy', no_enemy_navy) + layout = QtWidgets.QGridLayout() + layout.addWidget(QtWidgets.QLabel("Start at mid game"), 1, 0) + layout.addWidget(midGame, 1, 1) + layout.addWidget(QtWidgets.QLabel("Ennemy forces multiplier [Disabled for Now]"), 2, 0) + layout.addWidget(multiplier, 2, 1) + miscSettingsGroup.setLayout(layout) + generatorLayout = QtWidgets.QGridLayout() generatorLayout.addWidget(QtWidgets.QLabel("No Aircraft Carriers"), 1, 0) generatorLayout.addWidget(no_carrier, 1, 1) @@ -343,16 +385,15 @@ class MiscOptions(QtWidgets.QWizardPage): generatorLayout.addWidget(no_enemy_navy, 5, 1) generatorSettingsGroup.setLayout(generatorLayout) - layout = QtWidgets.QGridLayout() - layout.addWidget(QtWidgets.QLabel("Start at mid game"), 1, 0) - layout.addWidget(midGame, 1, 1) - layout.addWidget(QtWidgets.QLabel("Ennemy forces multiplier [Disabled for Now]"), 2, 0) - layout.addWidget(multiplier, 2, 1) - miscSettingsGroup.setLayout(layout) + budget_inputs = BudgetInputs() + economySettingsGroup = QtWidgets.QGroupBox("Economy") + economySettingsGroup.setLayout(budget_inputs) + self.registerField('starting_money', budget_inputs.starting_money) mlayout = QVBoxLayout() mlayout.addWidget(miscSettingsGroup) mlayout.addWidget(generatorSettingsGroup) + mlayout.addWidget(economySettingsGroup) self.setLayout(mlayout) diff --git a/qt_ui/windows/preferences/QLiberationPreferences.py b/qt_ui/windows/preferences/QLiberationPreferences.py index bc392561..3e8db6a7 100644 --- a/qt_ui/windows/preferences/QLiberationPreferences.py +++ b/qt_ui/windows/preferences/QLiberationPreferences.py @@ -13,9 +13,8 @@ from PySide2.QtWidgets import ( QVBoxLayout, ) -import qt_ui.uiconstants as CONST from qt_ui import liberation_install, liberation_theme -from qt_ui.liberation_theme import get_theme_index, set_theme_index +from qt_ui.liberation_theme import THEMES, get_theme_index, set_theme_index class QLiberationPreferences(QFrame): @@ -39,7 +38,7 @@ class QLiberationPreferences(QFrame): self.browse_install_dir = QPushButton("Browse...") self.browse_install_dir.clicked.connect(self.on_browse_installation_dir) self.themeSelect = QComboBox() - [self.themeSelect.addItem(y['themeName']) for x, y in CONST.THEMES.items()] + [self.themeSelect.addItem(y['themeName']) for x, y in THEMES.items()] self.initUi()