diff --git a/changelog.md b/changelog.md index 59c10b15..e648cb30 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,18 @@ +# 2.2.X + +## Features/Improvements : +* **[Flight Planner]** Flight planner overhaul, with package and TOT system +* **[Map]** Highlight the selected flight path on the map +* **[Map]** Improved flight plan display settings +* **[Map]** Improved SAM display settings +* **[Map]** Added polygon debug mode display +* **[New Game]** Starting budget can be freely selected +* **[Moddability]** Custom campaigns can be designed through json files + +## Fixes : +* **[Campaign generator]** Ship group and offshore buildings should not be generated on land anymore + + # 2.1.5 ## Features/Improvements : diff --git a/game/event/event.py b/game/event/event.py index 0af3852c..8f7ac1b8 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -12,7 +12,6 @@ from game import db, persistency from game.debriefing import Debriefing from game.infos.information import Information from game.operation.operation import Operation -from gen.environmentgen import EnvironmentSettings from gen.ground_forces.combat_stance import CombatStance from theater import ControlPoint from theater.start_generator import generate_airbase_defense_group @@ -42,7 +41,6 @@ class Event: operation = None # type: Operation difficulty = 1 # type: int - environment_settings = None # type: EnvironmentSettings BONUS_BASE = 5 def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str): diff --git a/game/game.py b/game/game.py index 00ca8b5f..bd0f68f7 100644 --- a/game/game.py +++ b/game/game.py @@ -2,7 +2,7 @@ import logging import math import random import sys -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import Any, Dict, List from dcs.action import Coalition @@ -29,6 +29,7 @@ from .event.frontlineattack import FrontlineAttackEvent from .infos.information import Information from .settings import Settings from plugin import LuaPluginManager +from .weather import Conditions, TimeOfDay COMMISION_UNIT_VARIETY = 4 COMMISION_LIMITS_SCALE = 1.5 @@ -79,7 +80,7 @@ class Game: self.enemy_name = enemy_name self.enemy_country = db.FACTIONS[enemy_name]["country"] self.turn = 0 - self.date = datetime(start_date.year, start_date.month, start_date.day) + self.date = date(start_date.year, start_date.month, start_date.day) self.game_stats = GameStats() self.game_stats.update(self) self.ground_planners: Dict[int, GroundPlanner] = {} @@ -92,6 +93,8 @@ class Game: self.current_unit_id = 0 self.current_group_id = 0 + self.conditions = self.generate_conditions() + self.blue_ato = AirTaskingOrder() self.red_ato = AirTaskingOrder() @@ -102,6 +105,9 @@ class Game: self.sanitize_sides() self.on_load() + def generate_conditions(self) -> Conditions: + return Conditions.generate(self.theater, self.date, + self.current_turn_time_of_day, self.settings) def sanitize_sides(self): """ @@ -223,6 +229,12 @@ class Game: for plugin in LuaPluginManager().getPlugins(): plugin.setSettings(self.settings) + # Save game compatibility. + + # TODO: Remove in 2.3. + if not hasattr(self, "conditions"): + self.conditions = self.generate_conditions() + def pass_turn(self, no_action=False): logging.info("Pass turn") self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) @@ -257,6 +269,8 @@ class Game: for cp in self.theater.controlpoints: self.aircraft_inventory.set_from_control_point(cp) + self.conditions = self.generate_conditions() + # Plan flights & combat for next turn self.__culling_points = self.compute_conflicts_position() self.ground_planners = {} @@ -345,11 +359,11 @@ class Game: self.informations.append(info) @property - def current_turn_daytime(self): - return ["dawn", "day", "dusk", "night"][self.turn % 4] + def current_turn_time_of_day(self) -> TimeOfDay: + return list(TimeOfDay)[self.turn % 4] @property - def current_day(self): + def current_day(self) -> date: return self.date + timedelta(days=self.turn // 4) def next_unit_id(self): diff --git a/game/inventory.py b/game/inventory.py index 5ef68b04..ae75c837 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -65,16 +65,6 @@ class ControlPointAircraftInventory: if count > 0: yield aircraft, count - @property - def total_available(self) -> int: - """Returns the total number of aircraft available.""" - # TODO: Remove? - # This probably isn't actually useful. It's used by the AI flight - # planner to determine how many flights of a given type it should - # allocate, but it should probably be making that decision based on the - # number of aircraft available to perform a particular role. - return sum(self.inventory.values()) - def clear(self) -> None: """Clears all aircraft from the inventory.""" self.inventory.clear() diff --git a/game/operation/operation.py b/game/operation/operation.py index c64d0992..3284c800 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -21,7 +21,7 @@ from gen.airsupportgen import AirSupport, AirSupportConflictGenerator from gen.armor import GroundConflictGenerator, JtacInfo from gen.beacons import load_beacons_for_terrain from gen.briefinggen import BriefingGenerator -from gen.environmentgen import EnviromentGenerator +from gen.environmentgen import EnvironmentGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.groundobjectsgen import GroundObjectsGenerator from gen.kneeboard import KneeboardGenerator @@ -45,7 +45,6 @@ class Operation: triggersgen = None # type: TriggersGenerator airsupportgen = None # type: AirSupportConflictGenerator visualgen = None # type: VisualGenerator - envgen = None # type: EnviromentGenerator groundobjectgen = None # type: GroundObjectsGenerator briefinggen = None # type: BriefingGenerator forcedoptionsgen = None # type: ForcedOptionsGenerator @@ -191,13 +190,9 @@ class Operation: for frequency in unique_map_frequencies: radio_registry.reserve(frequency) - # Generate meteo - envgen = EnviromentGenerator(self.current_mission, self.conflict, - self.game) - if self.environment_settings is None: - self.environment_settings = envgen.generate() - else: - envgen.load(self.environment_settings) + # Set mission time and weather conditions. + EnvironmentGenerator(self.current_mission, + self.game.conditions).generate() # Generate ground object first diff --git a/game/settings.py b/game/settings.py index 7b398330..11ba0753 100644 --- a/game/settings.py +++ b/game/settings.py @@ -45,4 +45,15 @@ class Settings: plugin.setSettings(self) + # Cheating + self.show_red_ato = False + def __setstate__(self, state) -> None: + # __setstate__ is called with the dict of the object being unpickled. We + # can provide save compatibility for new settings options (which + # normally would not be present in the unpickled object) by creating a + # new settings object, updating it with the unpickled state, and + # updating our dict with that. + new_state = Settings().__dict__ + new_state.update(state) + self.__dict__.update(new_state) diff --git a/game/weather.py b/game/weather.py new file mode 100644 index 00000000..a9ac5141 --- /dev/null +++ b/game/weather.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import datetime +import logging +import random +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from dcs.weather import Weather as PydcsWeather, Wind + +from game.settings import Settings +from theater import ConflictTheater + + +class TimeOfDay(Enum): + Dawn = "dawn" + Day = "day" + Dusk = "dusk" + Night = "night" + + +@dataclass(frozen=True) +class WindConditions: + at_0m: Wind + at_2000m: Wind + at_8000m: Wind + + +@dataclass(frozen=True) +class Clouds: + base: int + density: int + thickness: int + precipitation: PydcsWeather.Preceptions + + +@dataclass(frozen=True) +class Fog: + visibility: int + thickness: int + + +class Weather: + def __init__(self) -> None: + self.clouds = self.generate_clouds() + self.fog = self.generate_fog() + self.wind = self.generate_wind() + + def generate_clouds(self) -> Optional[Clouds]: + raise NotImplementedError + + def generate_fog(self) -> Optional[Fog]: + if random.randrange(5) != 0: + return None + return Fog( + visibility=random.randint(2500, 5000), + thickness=random.randint(100, 500) + ) + + def generate_wind(self) -> WindConditions: + raise NotImplementedError + + @staticmethod + def random_wind(minimum: int, maximum) -> WindConditions: + wind_direction = random.randint(0, 360) + at_0m_factor = 1 + at_2000m_factor = 2 + at_8000m_factor = 3 + base_wind = random.randint(minimum, maximum) + + return WindConditions( + # Always some wind to make the smoke move a bit. + at_0m=Wind(wind_direction, min(1, base_wind * at_0m_factor)), + at_2000m=Wind(wind_direction, base_wind * at_2000m_factor), + at_8000m=Wind(wind_direction, base_wind * at_8000m_factor) + ) + + @staticmethod + def random_cloud_base() -> int: + return random.randint(2000, 3000) + + @staticmethod + def random_cloud_thickness() -> int: + return random.randint(100, 400) + + +class ClearSkies(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return None + + def generate_fog(self) -> Optional[Fog]: + return None + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 0) + + +class Cloudy(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return Clouds( + base=self.random_cloud_base(), + density=random.randint(1, 8), + thickness=self.random_cloud_thickness(), + precipitation=PydcsWeather.Preceptions.None_ + ) + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 4) + + +class Raining(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return Clouds( + base=self.random_cloud_base(), + density=random.randint(5, 8), + thickness=self.random_cloud_thickness(), + precipitation=PydcsWeather.Preceptions.Rain + ) + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 6) + + +class Thunderstorm(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return Clouds( + base=self.random_cloud_base(), + density=random.randint(9, 10), + thickness=self.random_cloud_thickness(), + precipitation=PydcsWeather.Preceptions.Thunderstorm + ) + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 8) + + +@dataclass +class Conditions: + time_of_day: TimeOfDay + start_time: datetime.datetime + weather: Weather + + @classmethod + def generate(cls, theater: ConflictTheater, day: datetime.date, + time_of_day: TimeOfDay, settings: Settings) -> Conditions: + return cls( + time_of_day=time_of_day, + start_time=cls.generate_start_time( + theater, day, time_of_day, settings.night_disabled + ), + weather=cls.generate_weather() + ) + + @classmethod + def generate_start_time(cls, theater: ConflictTheater, day: datetime.date, + time_of_day: TimeOfDay, + night_disabled: bool) -> datetime.datetime: + if night_disabled: + logging.info("Skip Night mission due to user settings") + time_range = { + TimeOfDay.Dawn: (8, 9), + TimeOfDay.Day: (10, 12), + TimeOfDay.Dusk: (12, 14), + TimeOfDay.Night: (14, 17), + }[time_of_day] + else: + time_range = theater.daytime_map[time_of_day.value] + + time = datetime.time(hour=random.randint(*time_range)) + return datetime.datetime.combine(day, time) + + @classmethod + def generate_weather(cls) -> Weather: + chances = { + Thunderstorm: 1, + Raining: 20, + Cloudy: 60, + ClearSkies: 20, + } + weather_type = random.choices(list(chances.keys()), + weights=list(chances.values()))[0] + return weather_type() diff --git a/gen/aircraft.py b/gen/aircraft.py index 592058dd..8cc7363f 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -419,6 +419,7 @@ class SCR522RadioChannelAllocator(RadioChannelAllocator): # TODO : Some GCI on Channel 4 ? + @dataclass(frozen=True) class AircraftData: """Additional aircraft data not exposed by pydcs.""" @@ -442,7 +443,9 @@ class AircraftData: AIRCRAFT_DATA: Dict[str, AircraftData] = { "A-10C": AircraftData( inter_flight_radio=get_radio("AN/ARC-164"), - intra_flight_radio=get_radio("AN/ARC-164"), # VHF for intraflight is not accepted anymore by DCS (see https://forums.eagle.ru/showthread.php?p=4499738) + # VHF for intraflight is not accepted anymore by DCS + # (see https://forums.eagle.ru/showthread.php?p=4499738). + intra_flight_radio=get_radio("AN/ARC-164"), channel_allocator=WarthogRadioChannelAllocator() ), @@ -532,6 +535,7 @@ AIRCRAFT_DATA: Dict[str, AircraftData] = { channel_namer=SCR522ChannelNamer ), } +AIRCRAFT_DATA["A-10C_2"] = AIRCRAFT_DATA["A-10C"] AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] @@ -793,6 +797,8 @@ class AircraftConflictGenerator: self.clear_parking_slots() for package in ato.packages: + if not package.flights: + continue timing = PackageWaypointTiming.for_package(package) for flight in package.flights: culled = self.game.position_culled(flight.from_cp.position) @@ -996,7 +1002,7 @@ class AircraftConflictGenerator: 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, + if flight_type in [FlightType.BARCAP, FlightType.TARCAP, FlightType.INTERCEPTION]: self.configure_cap(group, flight, dynamic_runways) elif flight_type in [FlightType.CAS, FlightType.BAI]: @@ -1135,7 +1141,7 @@ class HoldPointBuilder(PydcsWaypointBuilder): pattern=OrbitAction.OrbitPattern.Circle )) loiter.stop_after_time( - self.timing.push_time(self.flight, waypoint.position)) + self.timing.push_time(self.flight, self.waypoint)) waypoint.add_task(loiter) return waypoint diff --git a/gen/briefinggen.py b/gen/briefinggen.py index d52f25cc..1eef67a7 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -129,6 +129,11 @@ class BriefingGenerator(MissionInfoGenerator): self.description += "DCS Liberation turn #" + str(self.game.turn) + "\n" self.description += "=" * 15 + "\n\n" + self.description += ( + "Most briefing information, including communications and flight " + "plan information, can be found on your kneeboard.\n\n" + ) + self.generate_ongoing_war_text() self.description += "\n"*2 diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 57d70452..7712cea5 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -1,147 +1,36 @@ -import logging -import random -from datetime import timedelta +from typing import Optional from dcs.mission import Mission -from dcs.weather import Weather, Wind -from .conflictgen import Conflict - -WEATHER_CLOUD_BASE = 2000, 3000 -WEATHER_CLOUD_DENSITY = 1, 8 -WEATHER_CLOUD_THICKNESS = 100, 400 -WEATHER_CLOUD_BASE_MIN = 1600 - -WEATHER_FOG_CHANCE = 20 -WEATHER_FOG_VISIBILITY = 2500, 5000 -WEATHER_FOG_THICKNESS = 100, 500 - -RANDOM_TIME = { - "night": 7, - "dusk": 40, - "dawn": 40, - "day": 100, -} - -RANDOM_WEATHER = { - 1: 0, # thunderstorm - 2: 20, # rain - 3: 80, # clouds - 4: 100, # clear -} +from game.weather import Clouds, Fog, Conditions, WindConditions -class EnvironmentSettings: - weather_dict = None - start_time = None - - -class EnviromentGenerator: - def __init__(self, mission: Mission, conflict: Conflict, game): +class EnvironmentGenerator: + def __init__(self, mission: Mission, conditions: Conditions) -> None: self.mission = mission - self.conflict = conflict - self.game = game + self.conditions = conditions - def _gen_time(self): + def set_clouds(self, clouds: Optional[Clouds]) -> None: + if clouds is None: + return + self.mission.weather.clouds_base = clouds.base + self.mission.weather.clouds_thickness = clouds.thickness + self.mission.weather.clouds_density = clouds.density + self.mission.weather.clouds_iprecptns = clouds.precipitation - start_time = self.game.current_day + def set_fog(self, fog: Optional[Fog]) -> None: + if fog is None: + return + self.mission.weather.fog_visibility = fog.visibility + self.mission.weather.fog_thickness = fog.thickness - daytime = self.game.current_turn_daytime - logging.info("Mission time will be {}".format(daytime)) - if self.game.settings.night_disabled: - logging.info("Skip Night mission due to user settings") - if daytime == "dawn": - time_range = (8, 9) - elif daytime == "day": - time_range = (10, 12) - elif daytime == "dusk": - time_range = (12, 14) - elif daytime == "night": - time_range = (14, 17) - else: - time_range = (10, 12) - else: - time_range = self.game.theater.daytime_map[daytime] - - start_time += timedelta(hours=random.randint(*time_range)) - - logging.info("time - {}, slot - {}, night skipped - {}".format( - str(start_time), - str(time_range), - self.game.settings.night_disabled)) - - self.mission.start_time = start_time - - def _generate_wind(self, wind_speed, wind_direction=None): - # wind - if not wind_direction: - wind_direction = random.randint(0, 360) - - self.mission.weather.wind_at_ground = Wind(wind_direction, wind_speed) - self.mission.weather.wind_at_2000 = Wind(wind_direction, wind_speed * 2) - self.mission.weather.wind_at_8000 = Wind(wind_direction, wind_speed * 3) - - def _generate_base_weather(self): - # clouds - self.mission.weather.clouds_base = random.randint(*WEATHER_CLOUD_BASE) - self.mission.weather.clouds_density = random.randint(*WEATHER_CLOUD_DENSITY) - self.mission.weather.clouds_thickness = random.randint(*WEATHER_CLOUD_THICKNESS) - - # wind - self._generate_wind(random.randint(0, 4)) - - # fog - if random.randint(0, 100) < WEATHER_FOG_CHANCE: - self.mission.weather.fog_visibility = random.randint(*WEATHER_FOG_VISIBILITY) - self.mission.weather.fog_thickness = random.randint(*WEATHER_FOG_THICKNESS) - - def _gen_random_weather(self): - weather_type = None - for k, v in RANDOM_WEATHER.items(): - if random.randint(0, 100) <= v: - weather_type = k - break - - logging.info("generated weather {}".format(weather_type)) - if weather_type == 1: - # thunderstorm - self._generate_base_weather() - self._generate_wind(random.randint(0, 8)) - - self.mission.weather.clouds_density = random.randint(9, 10) - self.mission.weather.clouds_iprecptns = Weather.Preceptions.Thunderstorm - elif weather_type == 2: - # rain - self._generate_base_weather() - self.mission.weather.clouds_density = random.randint(5, 8) - self.mission.weather.clouds_iprecptns = Weather.Preceptions.Rain - - self._generate_wind(random.randint(0, 6)) - elif weather_type == 3: - # clouds - self._generate_base_weather() - elif weather_type == 4: - # clear - pass - - if self.mission.weather.clouds_density > 0: - # sometimes clouds are randomized way too low and need to be fixed - self.mission.weather.clouds_base = max(self.mission.weather.clouds_base, WEATHER_CLOUD_BASE_MIN) - - if self.mission.weather.wind_at_ground.speed == 0: - # frontline smokes look silly w/o any wind - self._generate_wind(1) - - def generate(self) -> EnvironmentSettings: - self._gen_time() - self._gen_random_weather() - - settings = EnvironmentSettings() - settings.start_time = self.mission.start_time - settings.weather_dict = self.mission.weather.dict() - return settings - - def load(self, settings: EnvironmentSettings): - self.mission.start_time = settings.start_time - self.mission.weather.load_from_dict(settings.weather_dict) + def set_wind(self, wind: WindConditions) -> None: + self.mission.weather.wind_at_ground = wind.at_0m + self.mission.weather.wind_at_2000 = wind.at_2000m + self.mission.weather.wind_at_8000 = wind.at_8000m + def generate(self): + self.mission.start_time = self.conditions.start_time + self.set_clouds(self.conditions.weather.clouds) + self.set_fog(self.conditions.weather.fog) + self.set_wind(self.conditions.weather.wind) diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 8616180f..493c55b6 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -131,7 +131,7 @@ class AircraftAllocator: @staticmethod def preferred_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: - cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP) + cap_missions = (FlightType.BARCAP, FlightType.TARCAP) if task in cap_missions: return CAP_PREFERRED elif task == FlightType.CAS: @@ -147,7 +147,7 @@ class AircraftAllocator: @staticmethod def capable_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: - cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP) + cap_missions = (FlightType.BARCAP, FlightType.TARCAP) if task in cap_missions: return CAP_CAPABLE elif task == FlightType.CAS: @@ -404,7 +404,7 @@ class CoalitionMissionPlanner: # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. for cp in self.objective_finder.vulnerable_control_points(): yield ProposedMission(cp, [ - ProposedFlight(FlightType.CAP, 2, self.MAX_CAP_RANGE), + ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), ]) # Find front lines, plan CAP. @@ -493,11 +493,7 @@ class CoalitionMissionPlanner: error = random.randint(-margin, margin) yield max(0, time + error) - dca_types = ( - FlightType.BARCAP, - FlightType.CAP, - FlightType.INTERCEPTION, - ) + dca_types = (FlightType.BARCAP, FlightType.INTERCEPTION) non_dca_packages = [p for p in self.ato.packages if p.primary_task not in dca_types] diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 715a7f66..2baadf7d 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -326,7 +326,7 @@ SEAD_CAPABLE = [ F_4E, FA_18C_hornet, F_15E, - # F_16C_50, Not yet + F_16C_50, AV8BNA, JF_17, diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 86abd24e..85f60597 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Dict, Optional +from typing import Dict, Iterable, Optional from game import db from dcs.unittype import UnitType @@ -8,7 +8,7 @@ from theater.controlpoint import ControlPoint, MissionTarget class FlightType(Enum): - CAP = 0 + CAP = 0 # Do not use. Use BARCAP or TARCAP. TARCAP = 1 BARCAP = 2 CAS = 3 @@ -152,6 +152,14 @@ class Flight: return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ + " (" + str(len(self.points)) + " wpt)" + def waypoint_with_type( + self, + types: Iterable[FlightWaypointType]) -> Optional[FlightWaypoint]: + for waypoint in self.points: + if waypoint.waypoint_type in types: + return waypoint + return None + # Test if __name__ == '__main__': diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 74462c2d..9bc06473 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -69,8 +69,6 @@ class FlightPlanBuilder: logging.error("BAI flight plan generation not implemented") elif task == FlightType.BARCAP: self.generate_barcap(flight) - elif task == FlightType.CAP: - self.generate_barcap(flight) elif task == FlightType.CAS: self.generate_cas(flight) elif task == FlightType.DEAD: @@ -103,8 +101,10 @@ class FlightPlanBuilder: logging.error( "Troop transport flight plan generation not implemented" ) - except InvalidObjectiveLocation as ex: - logging.error(f"Could not create flight plan: {ex}") + else: + logging.error(f"Unsupported task type: {task.name}") + except InvalidObjectiveLocation: + logging.exception(f"Could not create flight plan") def regenerate_package_waypoints(self) -> None: ingress_point = self._ingress_point() @@ -424,7 +424,6 @@ class FlightPlanBuilder: def _heading_to_package_airfield(self, point: Point) -> int: return self.package_airfield().position.heading_between_point(point) - # TODO: Set ingress/egress/join/split points in the Package. def package_airfield(self) -> ControlPoint: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 87d2817d..a4690f1a 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -1,10 +1,12 @@ from __future__ import annotations import logging +import math from dataclasses import dataclass from typing import Iterable, Optional from dcs.mapping import Point +from dcs.unittype import FlyingType from game.utils import meter_to_nm from gen.ato import Package @@ -17,25 +19,101 @@ from gen.flights.flight import ( CAP_DURATION = 30 # Minutes -CAP_TYPES = (FlightType.BARCAP, FlightType.CAP) + +INGRESS_TYPES = { + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, +} + +IP_TYPES = { + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.PATROL_TRACK, +} 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. + def mission_speed(package: Package) -> int: + speeds = set() + for flight in package.flights: + waypoint = flight.waypoint_with_type(IP_TYPES) + if waypoint is None: + logging.error(f"Could not find ingress point for {flight}.") + if flight.points: + logging.warning( + "Using first waypoint for mission altitude.") + waypoint = flight.points[0] + else: + logging.warning( + "Flight has no waypoints. Assuming mission altitude " + "of 25000 feet.") + waypoint = FlightWaypoint(FlightWaypointType.NAV, 0, 0, + 25000) + speeds.add(GroundSpeed.for_flight(flight, waypoint.alt)) + return min(speeds) + + @classmethod + def for_flight(cls, flight: Flight, altitude: int) -> int: + if not issubclass(flight.unit_type, FlyingType): + raise TypeError("Flight has non-flying unit") + # 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 + + c_sound_sea_level = 661.5 + + # DCS's max speed is in kph at 0 MSL. Convert to knots. + max_speed = flight.unit_type.max_speed * 0.539957 + if max_speed > c_sound_sea_level: + # Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and + # account for heavily loaded jets. + return int(cls.from_mach(0.8, altitude)) + + # For subsonic aircraft, assume the aircraft can reasonably perform at + # 80% of its maximum, and that it can maintain the same mach at altitude + # as it can at sea level. This probably isn't great assumption, but + # might. be sufficient given the wiggle room. We can come up with + # another heuristic if needed. + mach = max_speed * 0.8 / c_sound_sea_level + return int(cls.from_mach(mach, altitude)) # knots + + @staticmethod + def from_mach(mach: float, altitude: int) -> float: + """Returns the ground speed in knots for the given mach and altitude. + + Args: + mach: The mach number to convert to ground speed. + altitude: The altitude in feet. + + Returns: + The ground speed corresponding to the given altitude and mach number + in knots. + """ + # https://www.grc.nasa.gov/WWW/K-12/airplane/atmos.html + if altitude <= 36152: + temperature_f = 59 - 0.00356 * altitude + else: + # There's another formula for altitudes over 82k feet, but we better + # not be planning waypoints that high... + temperature_f = -70 + + temperature_k = (temperature_f + 459.67) * (5 / 9) + + # https://www.engineeringtoolbox.com/specific-heat-ratio-d_602.html + # Dependent on temperature, but varies very little (+/-0.001) + # between -40F and 180F. + heat_capacity_ratio = 1.4 + + # https://www.grc.nasa.gov/WWW/K-12/airplane/sound.html + gas_constant = 286 # m^2/s^2/K + c_sound = math.sqrt(heat_capacity_ratio * gas_constant * temperature_k) + # c_sound is in m/s, convert to knots. + return (c_sound * 1.944) * mach class TravelTime: @@ -72,7 +150,7 @@ class TotEstimator: # Takeoff immediately. return 0 - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: start_time = self.timing.race_track_start else: start_time = self.timing.join @@ -97,13 +175,7 @@ class TotEstimator: 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) + time_to_ingress = self.estimate_waypoints_to_target(flight, IP_TYPES) if time_to_ingress is None: logging.warning( f"Found no ingress types. Cannot estimate TOT for {flight}") @@ -111,7 +183,7 @@ class TotEstimator: # the package. return 0 - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: # The racetrack start *is* the target. The package target is the # protected objective. time_to_target = 0 @@ -119,7 +191,7 @@ class TotEstimator: 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)) + GroundSpeed.mission_speed(self.package)) return sum([ self.estimate_startup(flight), self.estimate_ground_ops(flight), @@ -146,30 +218,38 @@ class TotEstimator: self, flight: Flight, stop_types: Iterable[FlightWaypointType]) -> Optional[int]: total = 0 + # TODO: This is AGL. We want MSL. + previous_altitude = 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) + self.speed_to_waypoint(flight, waypoint, previous_altitude) ) previous_position = position + previous_altitude = waypoint.alt if waypoint.waypoint_type in stop_types: return total return None - def speed_to_waypoint(self, flight: Flight, - waypoint: FlightWaypoint) -> int: + def speed_to_waypoint(self, flight: Flight, waypoint: FlightWaypoint, + from_altitude: int) -> int: + # TODO: Adjust if AGL. + # We don't have an exact heightmap, but we should probably be performing + # *some* adjustment for NTTR since the minimum altitude of the map is + # near 2000 ft MSL. + alt_for_speed = min(from_altitude, waypoint.alt) 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) + return int(GroundSpeed.for_flight(flight, alt_for_speed) * factor) elif waypoint.waypoint_type in pre_join: - return GroundSpeed.for_flight(flight) - return GroundSpeed.for_package(self.package) + return GroundSpeed.for_flight(flight, alt_for_speed) + return GroundSpeed.mission_speed(self.package) @dataclass(frozen=True) @@ -197,24 +277,24 @@ class PackageWaypointTiming: @property def race_track_start(self) -> int: - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: 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: + if self.package.primary_task == FlightType.BARCAP: return self.target + CAP_DURATION * 60 else: return self.egress - def push_time(self, flight: Flight, hold_point: Point) -> int: + def push_time(self, flight: Flight, hold_point: FlightWaypoint) -> int: assert self.package.waypoints is not None return self.join - TravelTime.between_points( - hold_point, + Point(hold_point.x, hold_point.y), self.package.waypoints.join, - GroundSpeed.for_flight(flight) + GroundSpeed.for_flight(flight, hold_point.alt) ) def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: @@ -224,15 +304,9 @@ class PackageWaypointTiming: 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: + elif waypoint.waypoint_type in INGRESS_TYPES: return self.ingress elif waypoint.waypoint_type in target_types: return self.target @@ -247,7 +321,7 @@ class PackageWaypointTiming: 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)) + return self.push_time(flight, waypoint) elif waypoint.waypoint_type == FlightWaypointType.PATROL: return self.race_track_end return None @@ -256,7 +330,17 @@ class PackageWaypointTiming: def for_package(cls, package: Package) -> PackageWaypointTiming: assert package.waypoints is not None - group_ground_speed = GroundSpeed.for_package(package) + # TODO: Plan similar altitudes for the in-country leg of the mission. + # Waypoint altitudes for a given flight *shouldn't* differ too much + # between the join and split points, so we don't need speeds for each + # leg individually since they should all be fairly similar. This doesn't + # hold too well right now since nothing is stopping each waypoint from + # jumping 20k feet each time, but that's a huge waste of energy we + # should be avoiding anyway. + if not package.flights: + raise ValueError("Cannot plan TOT for package with no flights") + + group_ground_speed = GroundSpeed.mission_speed(package) ingress = package.time_over_target - TravelTime.between_points( package.waypoints.ingress, diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index a0374d25..fd4b5aed 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -267,12 +267,6 @@ class WaypointBuilder: waypoint.pretty_name = "Race-track start" self.waypoints.append(waypoint) - # TODO: Does this actually do anything? - # orbit0.targets.append(location) - # Note: Targets of PATROL TRACK waypoints are the points to be defended. - # orbit0.targets.append(flight.from_cp) - # orbit0.targets.append(center) - def race_track_end(self, position: Point, altitude: int) -> None: """Creates a racetrack end waypoint. diff --git a/pydcs b/pydcs index c203e5a1..c12733a4 160000 --- a/pydcs +++ b/pydcs @@ -1 +1 @@ -Subproject commit c203e5a1b8d5eb42d559dab074e668bf37fa5158 +Subproject commit c12733a4712e802b41fd26ad8df7475d06c334b3 diff --git a/qt_ui/models.py b/qt_ui/models.py index ba816fd1..6480d933 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -164,6 +164,7 @@ class PackageModel(QAbstractListModel): def update_tot(self, tot: int) -> None: self.package.time_over_target = tot + self.layoutChanged.emit() @property def mission_target(self) -> MissionTarget: @@ -234,7 +235,7 @@ class AtoModel(QAbstractListModel): """Returns the package at the given index.""" return self.ato.packages[index.row()] - def replace_from_game(self, game: Optional[Game]) -> None: + def replace_from_game(self, game: Optional[Game], player: bool) -> None: """Updates the ATO object to match the updated game object. If the game is None (as is the case when no game has been loaded), an @@ -244,7 +245,10 @@ class AtoModel(QAbstractListModel): self.game = game self.package_models.clear() if self.game is not None: - self.ato = game.blue_ato + if player: + self.ato = game.blue_ato + else: + self.ato = game.red_ato else: self.ato = AirTaskingOrder() self.endResetModel() @@ -268,8 +272,8 @@ class GameModel: """ def __init__(self) -> None: self.game: Optional[Game] = None - # TODO: Add red ATO model, add cheat option to show red flight plan. self.ato_model = AtoModel(self.game, AirTaskingOrder()) + self.red_ato_model = AtoModel(self.game, AirTaskingOrder()) def set(self, game: Optional[Game]) -> None: """Updates the managed Game object. @@ -280,4 +284,5 @@ class GameModel: loaded. """ self.game = game - self.ato_model.replace_from_game(self.game) + self.ato_model.replace_from_game(self.game, player=True) + self.red_ato_model.replace_from_game(self.game, player=False) diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index dccac823..fcf8ef32 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -6,7 +6,7 @@ from PySide2.QtGui import QColor, QFont, QPixmap from theater.theatergroundobject import CATEGORY_MAP from .liberation_theme import get_theme_icons -VERSION_STRING = "2.1.4" +VERSION_STRING = "2.2.0-preview" URLS : Dict[str, str] = { "Manual": "https://github.com/khopa/dcs_liberation/wiki", @@ -40,6 +40,7 @@ COLORS: Dict[str, QColor] = { "light_blue": QColor(105, 182, 240, 90), "blue": QColor(0, 132, 255), "dark_blue": QColor(45, 62, 80), + "sea_blue": QColor(52, 68, 85), "blue_transparent": QColor(0, 132, 255, 20), "purple": QColor(187, 137, 255), diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 99f0ac9f..dadbee0d 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from PySide2.QtWidgets import ( QFrame, @@ -11,6 +11,8 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game from game.event import CAP, CAS, FrontlineAttackEvent +from gen.ato import Package +from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QFactionsInfos import QFactionsInfos @@ -95,7 +97,7 @@ class QTopPanel(QFrame): if game is None: return - self.turnCounter.setCurrentTurn(game.turn, game.current_day) + self.turnCounter.setCurrentTurn(game.turn, game.conditions) self.budgetBox.setGame(game) self.factionsInfos.setGame(game) @@ -117,6 +119,24 @@ class QTopPanel(QFrame): GameUpdateSignal.get_instance().updateGame(self.game) self.proceedButton.setEnabled(True) + def negative_start_packages(self) -> List[Package]: + packages = [] + for package in self.game_model.ato_model.ato.packages: + if not package.flights: + continue + estimator = TotEstimator(package) + for flight in package.flights: + if estimator.mission_start_time(flight) < 0: + packages.append(package) + break + return packages + + @staticmethod + def fix_tots(packages: List[Package]) -> None: + for package in packages: + estimator = TotEstimator(package) + package.time_over_target = estimator.earliest_tot() + def ato_has_clients(self) -> bool: for package in self.game.blue_ato.packages: for flight in package.flights: @@ -142,12 +162,52 @@ class QTopPanel(QFrame): ) return result == QMessageBox.Yes + def confirm_negative_start_time(self, + negative_starts: List[Package]) -> bool: + formatted = '
'.join( + [f"{p.primary_task.name} {p.target.name}" for p in negative_starts] + ) + mbox = QMessageBox( + QMessageBox.Question, + "Continue with past start times?", + ("Some flights in the following packages have start times set " + "earlier than mission start time:
" + "
" + f"{formatted}
" + "
" + "Flight start times are estimated based on the package TOT, so it " + "is possible that not all flights will be able to reach the " + "target area at their assigned times.
" + "
" + "You can either continue with the mission as planned, with the " + "misplanned flights potentially flying too fast and/or missing " + "their rendezvous; automatically fix negative TOTs; or cancel " + "mission start and fix the packages manually."), + parent=self + ) + auto = mbox.addButton("Fix TOTs automatically", QMessageBox.ActionRole) + ignore = mbox.addButton("Continue without fixing", + QMessageBox.DestructiveRole) + cancel = mbox.addButton(QMessageBox.Cancel) + mbox.setEscapeButton(cancel) + mbox.exec_() + clicked = mbox.clickedButton() + if clicked == auto: + self.fix_tots(negative_starts) + return True + elif clicked == ignore: + return True + return False + 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: Verify no negative start times. + negative_starts = self.negative_start_packages() + if negative_starts: + if not self.confirm_negative_start_time(negative_starts): + return # TODO: Refactor this nonsense. game_event = None diff --git a/qt_ui/widgets/QTurnCounter.py b/qt_ui/widgets/QTurnCounter.py index f7e6fd88..a26112e1 100644 --- a/qt_ui/widgets/QTurnCounter.py +++ b/qt_ui/widgets/QTurnCounter.py @@ -1,7 +1,8 @@ import datetime -from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox +from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QVBoxLayout +from game.weather import Conditions, TimeOfDay import qt_ui.uiconstants as CONST @@ -13,23 +14,37 @@ class QTurnCounter(QGroupBox): def __init__(self): super(QTurnCounter, self).__init__("Turn") - self.icons = [CONST.ICONS["Dawn"], CONST.ICONS["Day"], CONST.ICONS["Dusk"], CONST.ICONS["Night"]] - - self.daytime_icon = QLabel() - self.daytime_icon.setPixmap(self.icons[0]) - self.turn_info = QLabel() + self.icons = { + TimeOfDay.Dawn: CONST.ICONS["Dawn"], + TimeOfDay.Day: CONST.ICONS["Day"], + TimeOfDay.Dusk: CONST.ICONS["Dusk"], + TimeOfDay.Night: CONST.ICONS["Night"], + } self.layout = QHBoxLayout() - self.layout.addWidget(self.daytime_icon) - self.layout.addWidget(self.turn_info) self.setLayout(self.layout) - def setCurrentTurn(self, turn: int, current_day: datetime): + self.daytime_icon = QLabel() + self.daytime_icon.setPixmap(self.icons[TimeOfDay.Dawn]) + self.layout.addWidget(self.daytime_icon) + + self.time_column = QVBoxLayout() + self.layout.addLayout(self.time_column) + + self.date_display = QLabel() + self.time_column.addWidget(self.date_display) + + self.time_display = QLabel() + self.time_column.addWidget(self.time_display) + + def setCurrentTurn(self, turn: int, conditions: Conditions) -> None: + """Sets the turn information display. + + :arg turn Current turn number. + :arg conditions Current time and weather conditions. """ - Set the money amount to display - :arg turn Current turn number - :arg current_day Current day - """ - self.daytime_icon.setPixmap(self.icons[turn % 4]) - self.turn_info.setText(current_day.strftime("%d %b %Y")) + self.daytime_icon.setPixmap(self.icons[conditions.time_of_day]) + self.date_display.setText(conditions.start_time.strftime("%d %b %Y")) + self.time_display.setText( + conditions.start_time.strftime("%H:%M:%S Local")) self.setTitle("Turn " + str(turn + 1)) diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index 9577b26c..429ff902 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -19,7 +19,6 @@ class QFlightTypeComboBox(QComboBox): COMMON_ENEMY_MISSIONS = [ FlightType.ESCORT, - FlightType.TARCAP, FlightType.SEAD, FlightType.DEAD, # TODO: FlightType.ELINT, @@ -27,42 +26,46 @@ class QFlightTypeComboBox(QComboBox): # TODO: FlightType.RECON, ] - FRIENDLY_AIRBASE_MISSIONS = [ - FlightType.CAP, - # TODO: FlightType.INTERCEPTION - # TODO: FlightType.LOGISTICS + COMMON_FRIENDLY_MISSIONS = [ + FlightType.BARCAP, ] + FRIENDLY_AIRBASE_MISSIONS = [ + # TODO: FlightType.INTERCEPTION + # TODO: FlightType.LOGISTICS + ] + COMMON_FRIENDLY_MISSIONS + FRIENDLY_CARRIER_MISSIONS = [ - FlightType.BARCAP, # TODO: FlightType.INTERCEPTION # TODO: Buddy tanking for the A-4? # TODO: Rescue chopper? # TODO: Inter-ship logistics? - ] + ] + COMMON_FRIENDLY_MISSIONS ENEMY_CARRIER_MISSIONS = [ FlightType.ESCORT, - FlightType.TARCAP, + FlightType.BARCAP, # TODO: FlightType.ANTISHIP ] ENEMY_AIRBASE_MISSIONS = [ + FlightType.BARCAP, # TODO: FlightType.STRIKE ] + COMMON_ENEMY_MISSIONS FRIENDLY_GROUND_OBJECT_MISSIONS = [ - FlightType.CAP, # TODO: FlightType.LOGISTICS # TODO: FlightType.TROOP_TRANSPORT - ] + ] + COMMON_FRIENDLY_MISSIONS ENEMY_GROUND_OBJECT_MISSIONS = [ + FlightType.BARCAP, FlightType.STRIKE, ] + COMMON_ENEMY_MISSIONS FRONT_LINE_MISSIONS = [ FlightType.CAS, + FlightType.TARCAP, # TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.EVAC ] + COMMON_ENEMY_MISSIONS diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 568d66ac..6ccde6da 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -21,6 +21,7 @@ from game import Game, db from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR from game.utils import meter_to_feet +from game.weather import TimeOfDay from gen import Conflict, PackageWaypointTiming from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType @@ -55,25 +56,42 @@ class QLiberationMap(QGraphicsView): self.factor = 1 self.factorized = 1 self.init_scene() - self.connectSignals() self.setGame(game_model.game) GameUpdateSignal.get_instance().flight_paths_changed.connect( lambda: self.draw_flight_plans(self.scene()) ) - def update_package_selection(index: Optional[int]) -> None: - self.selected_flight = index, 0 + def update_package_selection(index: int) -> None: + # Optional[int] isn't a valid type for a Qt signal. None will be + # converted to zero automatically. We use -1 to indicate no + # selection. + if index == -1: + self.selected_flight = None + else: + 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: + def update_flight_selection(index: int) -> None: if self.selected_flight is None: - logging.error("Flight was selected with no package selected") + if index != -1: + # We don't know what order update_package_selection and + # update_flight_selection will be called in when the last + # package is removed. If no flight is selected, it's not a + # problem to also have no package selected. + logging.error( + "Flight was selected with no package selected") return + + # Optional[int] isn't a valid type for a Qt signal. None will be + # converted to zero automatically. We use -1 to indicate no + # selection. + if index == -1: + self.selected_flight = self.selected_flight[0], None self.selected_flight = self.selected_flight[0], index self.draw_flight_plans(self.scene()) @@ -90,9 +108,6 @@ class QLiberationMap(QGraphicsView): self.setFrameShape(QFrame.NoFrame) self.setDragMode(QGraphicsView.ScrollHandDrag) - def connectSignals(self): - GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) - def setGame(self, game: Optional[Game]): self.game = game logging.debug("Reloading Map Canvas") @@ -244,7 +259,7 @@ class QLiberationMap(QGraphicsView): text.setDefaultTextColor(Qt.white) text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) - def draw_flight_plans(self, scene) -> None: + def clear_flight_paths(self, scene: QGraphicsScene) -> None: for item in self.flight_path_items: try: scene.removeItem(item) @@ -252,12 +267,20 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() + + def draw_flight_plans(self, scene: QGraphicsScene) -> None: + self.clear_flight_paths(scene) if DisplayOptions.flight_paths.hide: return packages = list(self.game_model.ato_model.packages) + if self.game.settings.show_red_ato: + packages.extend(self.game_model.red_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 self.selected_flight is None: + selected = False + else: + 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, @@ -486,9 +509,9 @@ class QLiberationMap(QGraphicsView): scene.addPixmap(bg) # Apply graphical effects to simulate current daytime - if self.game.current_turn_daytime == "day": + if self.game.current_turn_time_of_day == TimeOfDay.Day: pass - elif self.game.current_turn_daytime == "night": + elif self.game.current_turn_time_of_day == TimeOfDay.Night: ov = QPixmap(bg.width(), bg.height()) ov.fill(CONST.COLORS["night_overlay"]) overlay = scene.addPixmap(ov) @@ -507,6 +530,11 @@ class QLiberationMap(QGraphicsView): # Polygon display mode if self.game.theater.landmap is not None: + for sea_zone in self.game.theater.landmap[2]: + print(sea_zone) + poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in sea_zone]) + scene.addPolygon(poly, CONST.COLORS["sea_blue"], CONST.COLORS["sea_blue"]) + for inclusion_zone in self.game.theater.landmap[0]: poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in inclusion_zone]) scene.addPolygon(poly, CONST.COLORS["grey"], CONST.COLORS["dark_grey"]) @@ -516,3 +544,5 @@ class QLiberationMap(QGraphicsView): scene.addPolygon(poly, CONST.COLORS["grey"], CONST.COLORS["dark_dark_grey"]) + + diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index af0789a8..a7d857f3 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -7,6 +7,7 @@ from PySide2.QtWidgets import QGraphicsItem import qt_ui.uiconstants as const from game import Game from game.data.building_data import FORTIFICATION_BUILDINGS +from game.db import REWARDS from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu from theater import ControlPoint, TheaterGroundObject from .QMapObject import QMapObject @@ -27,7 +28,14 @@ class QMapGroundObject(QMapObject): self.buildings = buildings if buildings is not None else [] self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) self.ground_object_dialog: Optional[QGroundObjectMenu] = None + self.setToolTip(self.tooltip) + @property + def tooltip(self) -> str: + lines = [ + f"[{self.ground_object.obj_name}]", + f"${self.production_per_turn} per turn", + ] if self.ground_object.groups: units = {} for g in self.ground_object.groups: @@ -36,16 +44,23 @@ class QMapGroundObject(QMapObject): units[u.type] = units[u.type]+1 else: units[u.type] = 1 - tooltip = "[" + self.ground_object.obj_name + "]" + "\n" + for unit in units.keys(): - tooltip = tooltip + str(unit) + "x" + str(units[unit]) + "\n" - self.setToolTip(tooltip[:-1]) + lines.append(f"{unit} x {units[unit]}") else: - tooltip = "[" + self.ground_object.obj_name + "]" + "\n" - for building in buildings: + for building in self.buildings: if not building.is_dead: - tooltip = tooltip + str(building.dcs_identifier) + "\n" - self.setToolTip(tooltip[:-1]) + lines.append(f"{building.dcs_identifier}") + + return "\n".join(lines) + + @property + def production_per_turn(self) -> int: + production = 0 + for g in self.control_point.ground_objects: + if g.category in REWARDS.keys(): + production += REWARDS[g.category] + return production def paint(self, painter, option, widget=None) -> None: player_icons = "_blue" diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 3c112952..529a7498 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -24,8 +24,8 @@ class GameUpdateSignal(QObject): debriefingReceived = Signal(DebriefingSignal) flight_paths_changed = Signal() - package_selection_changed = Signal(int) # Optional[int] - flight_selection_changed = Signal(int) # Optional[int] + package_selection_changed = Signal(int) # -1 indicates no selection. + flight_selection_changed = Signal(int) # -1 indicates no selection. def __init__(self): super(GameUpdateSignal, self).__init__() @@ -33,11 +33,11 @@ class GameUpdateSignal(QObject): def select_package(self, index: Optional[int]) -> None: # noinspection PyUnresolvedReferences - self.package_selection_changed.emit(index) + self.package_selection_changed.emit(-1 if index is None else index) def select_flight(self, index: Optional[int]) -> None: # noinspection PyUnresolvedReferences - self.flight_selection_changed.emit(index) + self.flight_selection_changed.emit(-1 if index is None else index) def redraw_flight_paths(self) -> None: # noinspection PyUnresolvedReferences diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index db5caa10..99783d11 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -43,6 +43,7 @@ class QLiberationWindow(QMainWindow): Dialog.set_game(self.game_model) self.ato_panel = None self.info_panel = None + self.liberation_map = None self.setGame(persistency.restore_game()) self.setGeometry(300, 100, 270, 100) @@ -224,9 +225,11 @@ class QLiberationWindow(QMainWindow): if game is not None: game.on_load() self.game = game - if self.info_panel: + if self.info_panel is not None: self.info_panel.setGame(game) self.game_model.set(self.game) + if self.liberation_map is not None: + self.liberation_map.setGame(game) def showAboutDialog(self): text = "

DCS Liberation " + CONST.VERSION_STRING + "

" + \ diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index b679bf7b..2dbba2f3 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -1,15 +1,16 @@ -from typing import Optional +from typing import Optional, Set from PySide2.QtCore import Qt from PySide2.QtWidgets import ( QFrame, QGridLayout, - QScrollArea, - QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, + QVBoxLayout, QWidget, ) +from dcs.unittype import UnitType from game.event.event import UnitsDeliveryEvent from qt_ui.models import GameModel @@ -48,26 +49,27 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): def init_ui(self): main_layout = QVBoxLayout() - units = { - CAP: db.find_unittype(CAP, self.game_model.game.player_name), - CAS: db.find_unittype(CAS, self.game_model.game.player_name), - } + tasks = [CAP, CAS] scroll_content = QWidget() task_box_layout = QGridLayout() row = 0 - for task_type in units.keys(): - units_column = list(set(units[task_type])) - if len(units_column) == 0: + unit_types: Set[UnitType] = set() + for task in tasks: + units = db.find_unittype(task, self.game_model.game.player_name) + if not units: continue - units_column.sort(key=lambda x: db.PRICES[x]) - for unit_type in units_column: - if self.cp.is_carrier and not unit_type in db.CARRIER_CAPABLE: + for unit in units: + if self.cp.is_carrier and unit not in db.CARRIER_CAPABLE: continue - if self.cp.is_lha and not unit_type in db.LHA_CAPABLE: + if self.cp.is_lha and unit not in db.LHA_CAPABLE: continue - row = self.add_purchase_row(unit_type, task_box_layout, row) + unit_types.add(unit) + + sorted_units = sorted(unit_types, key=lambda u: db.unit_type_name_2(u)) + for unit_type in sorted_units: + row = self.add_purchase_row(unit_type, task_box_layout, row) stretch = QVBoxLayout() stretch.addStretch() task_box_layout.addLayout(stretch, row, 0) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 3c64c160..2ab035c3 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -16,6 +16,7 @@ from game.game import Game from gen.ato import Package from gen.flights.flight import Flight from gen.flights.flightplan import FlightPlanBuilder +from gen.flights.traveltime import TotEstimator from qt_ui.models import AtoModel, PackageModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.ato import QFlightList @@ -34,12 +35,6 @@ class QPackageDialog(QDialog): #: Emitted when a change is made to the package. package_changed = Signal() - #: Emitted when a flight is added to the package. - flight_added = Signal(Flight) - - #: Emitted when a flight is removed from the package. - flight_removed = Signal(Flight) - def __init__(self, game: Game, model: PackageModel) -> None: super().__init__() self.game = game @@ -77,20 +72,20 @@ class QPackageDialog(QDialog): 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 = QTimeEdit(self.tot_qtime()) self.tot_spinner.setMinimumTime(QTime(0, 0)) self.tot_spinner.setDisplayFormat("T+hh:mm:ss") + self.tot_spinner.timeChanged.connect(self.save_tot) self.tot_column.addWidget(self.tot_spinner) + self.reset_tot_button = QPushButton("Reset TOT") + self.reset_tot_button.setToolTip( + "Sets the package TOT to the earliest time that all flights can " + "arrive at the target." + ) + self.reset_tot_button.clicked.connect(self.reset_tot) + self.tot_column.addWidget(self.reset_tot_button) + self.package_view = QFlightList(self.package_model) self.package_view.selectionModel().selectionChanged.connect( self.on_selection_changed @@ -114,17 +109,40 @@ class QPackageDialog(QDialog): self.setLayout(self.layout) + self.accepted.connect(self.on_save) self.finished.connect(self.on_close) + self.rejected.connect(self.on_cancel) + + def tot_qtime(self) -> QTime: + delay = self.package_model.package.time_over_target + hours = delay // 3600 + minutes = delay // 60 % 60 + seconds = delay % 60 + return QTime(hours, minutes, seconds) + + def on_cancel(self) -> None: + pass @staticmethod def on_close(_result) -> None: GameUpdateSignal.get_instance().redraw_flight_paths() + def on_save(self) -> None: + self.save_tot() + 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) + def reset_tot(self) -> None: + if not list(self.package_model.flights): + self.package_model.update_tot(0) + else: + self.package_model.update_tot( + TotEstimator(self.package_model.package).earliest_tot()) + self.tot_spinner.setTime(self.tot_qtime()) + def on_selection_changed(self, selected: QItemSelection, _deselected: QItemSelection) -> None: """Updates the state of the delete button.""" @@ -139,14 +157,13 @@ class QPackageDialog(QDialog): def add_flight(self, flight: Flight) -> None: """Adds the new flight to the package.""" + self.game.aircraft_inventory.claim_for_flight(flight) self.package_model.add_flight(flight) planner = FlightPlanBuilder(self.game, self.package_model.package, is_player=True) planner.populate_flight_plan(flight) # noinspection PyUnresolvedReferences self.package_changed.emit() - # noinspection PyUnresolvedReferences - self.flight_added.emit(flight) def on_delete_flight(self) -> None: """Removes the selected flight from the package.""" @@ -154,11 +171,10 @@ class QPackageDialog(QDialog): if flight is None: logging.error(f"Cannot delete flight when no flight is selected.") return + self.game.aircraft_inventory.return_from_flight(flight) self.package_model.delete_flight(flight) # noinspection PyUnresolvedReferences self.package_changed.emit() - # noinspection PyUnresolvedReferences - self.flight_removed.emit(flight) class QNewPackageDialog(QPackageDialog): @@ -174,22 +190,22 @@ class QNewPackageDialog(QPackageDialog): self.save_button = QPushButton("Save") self.save_button.setProperty("style", "start-button") - self.save_button.clicked.connect(self.on_save) + self.save_button.clicked.connect(self.accept) self.button_layout.addWidget(self.save_button) - self.delete_flight_button.clicked.connect(self.on_delete_flight) - def on_save(self) -> None: """Saves the created package. 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() + super().on_save() self.ato_model.add_package(self.package_model.package) + + def on_cancel(self) -> None: + super().on_cancel() for flight in self.package_model.package.flights: - self.game.aircraft_inventory.claim_for_flight(flight) - self.close() + self.game_model.game.aircraft_inventory.return_from_flight(flight) class QEditPackageDialog(QPackageDialog): @@ -210,30 +226,9 @@ class QEditPackageDialog(QPackageDialog): self.done_button = QPushButton("Done") self.done_button.setProperty("style", "start-button") - self.done_button.clicked.connect(self.on_done) + self.done_button.clicked.connect(self.accept) self.button_layout.addWidget(self.done_button) - # noinspection PyUnresolvedReferences - self.flight_added.connect(self.on_flight_added) - # noinspection PyUnresolvedReferences - self.flight_removed.connect(self.on_flight_removed) - - # TODO: Make the new package dialog do this too, return on cancel. - # Not claiming the aircraft when they are added to the planner means that - # inventory counts are not updated until after the new package is updated, - # so you can add an infinite number of aircraft to a new package in the UI, - # which will crash when the flight package is saved. - def on_flight_added(self, flight: Flight) -> None: - self.game.aircraft_inventory.claim_for_flight(flight) - - def on_flight_removed(self, flight: Flight) -> None: - self.game.aircraft_inventory.return_from_flight(flight) - - def on_done(self) -> None: - """Closes the window.""" - self.save_tot() - self.close() - def on_delete(self) -> None: """Removes the viewed package from the ATO.""" # The ATO model returns inventory for us when deleting a package. diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py index af48219c..b4eb9b36 100644 --- a/qt_ui/windows/mission/flight/QFlightPlanner.py +++ b/qt_ui/windows/mission/flight/QFlightPlanner.py @@ -19,11 +19,15 @@ class QFlightPlanner(QTabWidget): def __init__(self, package: Package, flight: Flight, game: Game): super().__init__() - self.general_settings_tab = QGeneralFlightSettingsTab(game, flight) + self.general_settings_tab = QGeneralFlightSettingsTab( + game, package, flight + ) + # noinspection PyUnresolvedReferences self.general_settings_tab.on_flight_settings_changed.connect( lambda: self.on_planned_flight_changed.emit()) self.payload_tab = QFlightPayloadTab(flight, game) self.waypoint_tab = QFlightWaypointTab(game, package, flight) + # noinspection PyUnresolvedReferences self.waypoint_tab.on_flight_changed.connect( lambda: self.on_planned_flight_changed.emit()) self.addTab(self.general_settings_tab, "General Flight settings") diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py new file mode 100644 index 00000000..a720cc8b --- /dev/null +++ b/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py @@ -0,0 +1,32 @@ +import datetime + +from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QVBoxLayout + +from gen.ato import Package +from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator + + +# TODO: Remove? +class QFlightDepartureDisplay(QGroupBox): + + def __init__(self, package: Package, flight: Flight): + super().__init__("Departure") + + layout = QVBoxLayout() + + departure_row = QHBoxLayout() + layout.addLayout(departure_row) + + estimator = TotEstimator(package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + + departure_row.addWidget(QLabel( + f"Departing from {flight.from_cp.name}" + )) + departure_row.addWidget(QLabel(f"At T+{delay}")) + + layout.addWidget(QLabel("Determined based on the package TOT. Edit the " + "package to adjust the TOT.")) + + self.setLayout(layout) diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py deleted file mode 100644 index abf429cf..00000000 --- a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py +++ /dev/null @@ -1,31 +0,0 @@ -from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox - - -# TODO: Remove? -class QFlightDepartureEditor(QGroupBox): - - def __init__(self, flight): - super(QFlightDepartureEditor, self).__init__("Departure") - self.flight = flight - - layout = QHBoxLayout() - self.depart_from = QLabel("Departing from " + self.flight.from_cp.name + "") - self.depart_at_t = QLabel("At T +") - self.minutes = QLabel(" minutes") - - self.departure_delta = QSpinBox(self) - self.departure_delta.setMinimum(0) - self.departure_delta.setMaximum(120) - self.departure_delta.setValue(self.flight.scheduled_in // 60) - self.departure_delta.valueChanged.connect(self.change_scheduled) - - layout.addWidget(self.depart_from) - layout.addWidget(self.depart_at_t) - layout.addWidget(self.departure_delta) - layout.addWidget(self.minutes) - self.setLayout(layout) - - self.changed = self.departure_delta.valueChanged - - def change_scheduled(self): - self.flight.scheduled_in = int(self.departure_delta.value() * 60) diff --git a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py index 99f2b63f..f1419669 100644 --- a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py +++ b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py @@ -2,26 +2,29 @@ from PySide2.QtCore import Signal from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout from game import Game +from gen.ato import Package from gen.flights.flight import Flight -from qt_ui.windows.mission.flight.settings.QFlightDepartureEditor import QFlightDepartureEditor -from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import QFlightSlotEditor -from qt_ui.windows.mission.flight.settings.QFlightStartType import QFlightStartType -from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import QFlightTypeTaskInfo +from qt_ui.windows.mission.flight.settings.QFlightDepartureDisplay import \ + QFlightDepartureDisplay +from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import \ + QFlightSlotEditor +from qt_ui.windows.mission.flight.settings.QFlightStartType import \ + QFlightStartType +from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import \ + QFlightTypeTaskInfo class QGeneralFlightSettingsTab(QFrame): on_flight_settings_changed = Signal() - def __init__(self, game: Game, flight: Flight): - super(QGeneralFlightSettingsTab, self).__init__() - self.flight = flight - self.game = game + def __init__(self, game: Game, package: Package, flight: Flight): + super().__init__() layout = QGridLayout() - flight_info = QFlightTypeTaskInfo(self.flight) - flight_departure = QFlightDepartureEditor(self.flight) - flight_slots = QFlightSlotEditor(self.flight, self.game) - flight_start_type = QFlightStartType(self.flight) + flight_info = QFlightTypeTaskInfo(flight) + flight_departure = QFlightDepartureDisplay(package, flight) + flight_slots = QFlightSlotEditor(flight, game) + flight_start_type = QFlightStartType(flight) layout.addWidget(flight_info, 0, 0) layout.addWidget(flight_departure, 1, 0) layout.addWidget(flight_slots, 2, 0) @@ -31,8 +34,6 @@ class QGeneralFlightSettingsTab(QFrame): layout.addLayout(vstretch, 3, 0) self.setLayout(layout) - flight_start_type.setEnabled(self.flight.client_count > 0) + flight_start_type.setEnabled(flight.client_count > 0) flight_slots.changed.connect( - lambda: flight_start_type.setEnabled(self.flight.client_count > 0)) - flight_departure.changed.connect( - lambda: self.on_flight_settings_changed.emit()) + lambda: flight_start_type.setEnabled(flight.client_count > 0)) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 98064b5c..21a85a84 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -54,6 +54,7 @@ class QFlightWaypointTab(QFrame): rlayout.addWidget(QLabel("Generator :")) rlayout.addWidget(QLabel("AI compatible")) + # TODO: Filter by objective type. self.recreate_buttons.clear() recreate_types = [ FlightType.CAS, @@ -137,13 +138,16 @@ class QFlightWaypointTab(QFrame): QMessageBox.Yes ) if result == QMessageBox.Yes: - # TODO: These should all be just CAP. + # TODO: Should be buttons for both BARCAP and TARCAP. + # BARCAP and TARCAP behave differently. TARCAP arrives a few minutes + # ahead of the rest of the package and stays until the package + # departs, whereas BARCAP usually isn't part of a strike package and + # has a fixed mission time. if task == FlightType.CAP: if isinstance(self.package.target, FrontLine): task = FlightType.TARCAP elif isinstance(self.package.target, ControlPoint): - if self.package.target.is_fleet: - task = FlightType.BARCAP + task = FlightType.BARCAP self.flight.flight_type = task self.planner.populate_flight_plan(self.flight) self.flight_waypoint_list.update_list() diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index c61372a6..1f1053ed 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -1,18 +1,50 @@ import logging +from typing import Callable from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint from PySide2.QtGui import QStandardItemModel, QStandardItem -from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ - QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox +from PySide2.QtWidgets import ( + QLabel, + QDialog, + QGridLayout, + QListView, + QStackedLayout, + QComboBox, + QWidget, + QAbstractItemView, + QPushButton, + QGroupBox, + QCheckBox, + QVBoxLayout, + QSpinBox, +) from dcs.forcedoptions import ForcedOptions import qt_ui.uiconstants as CONST from game.game import Game from game.infos.information import Information +from qt_ui.widgets.QLabeledWidget import QLabeledWidget from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine from plugin import LuaPluginManager +class CheatSettingsBox(QGroupBox): + def __init__(self, game: Game, apply_settings: Callable[[], None]) -> None: + super().__init__("Cheat Settings") + self.main_layout = QVBoxLayout() + self.setLayout(self.main_layout) + + self.red_ato_checkbox = QCheckBox() + self.red_ato_checkbox.setChecked(game.settings.show_red_ato) + self.red_ato_checkbox.toggled.connect(apply_settings) + self.red_ato = QLabeledWidget("Show Red ATO:", self.red_ato_checkbox) + self.main_layout.addLayout(self.red_ato) + + @property + def show_red_ato(self) -> bool: + return self.red_ato_checkbox.isChecked() + + class QSettingsWindow(QDialog): def __init__(self, game: Game): @@ -262,9 +294,12 @@ class QSettingsWindow(QDialog): def initCheatLayout(self): self.cheatPage = QWidget() - self.cheatLayout = QGridLayout() + self.cheatLayout = QVBoxLayout() self.cheatPage.setLayout(self.cheatLayout) + self.cheat_options = CheatSettingsBox(self.game, self.applySettings) + self.cheatLayout.addWidget(self.cheat_options) + self.moneyCheatBox = QGroupBox("Money Cheat") self.moneyCheatBox.setAlignment(Qt.AlignTop) self.moneyCheatBoxLayout = QGridLayout() @@ -280,7 +315,7 @@ class QSettingsWindow(QDialog): btn.setProperty("style", "btn-danger") btn.clicked.connect(self.cheatLambda(amount)) self.moneyCheatBoxLayout.addWidget(btn, i/2, i%2) - self.cheatLayout.addWidget(self.moneyCheatBox, 0, 0) + self.cheatLayout.addWidget(self.moneyCheatBox, stretch=1) def initPluginsLayout(self): uiPrepared = False @@ -332,8 +367,6 @@ class QSettingsWindow(QDialog): self.game.settings.external_views_allowed = self.ext_views.isChecked() self.game.settings.generate_marks = self.generate_marks.isChecked() - print(self.game.settings.map_coalition_visibility) - self.game.settings.supercarrier = self.supercarrier.isChecked() self.game.settings.perf_red_alert_state = self.red_alert.isChecked() @@ -347,6 +380,8 @@ class QSettingsWindow(QDialog): self.game.settings.perf_culling = self.culling.isChecked() self.game.settings.perf_culling_distance = int(self.culling_distance.value()) + self.game.settings.show_red_ato = self.cheat_options.show_red_ato + GameUpdateSignal.get_instance().updateGame(self.game) def onSelectionChanged(self): diff --git a/resources/caulandmap.p b/resources/caulandmap.p index 68f6806f..5736275a 100644 Binary files a/resources/caulandmap.p and b/resources/caulandmap.p differ diff --git a/resources/channellandmap.p b/resources/channellandmap.p index c0fe728e..649ad174 100644 Binary files a/resources/channellandmap.p and b/resources/channellandmap.p differ diff --git a/resources/gulflandmap.p b/resources/gulflandmap.p index e0447fbd..77b367f1 100644 Binary files a/resources/gulflandmap.p and b/resources/gulflandmap.p differ diff --git a/resources/nevlandmap.p b/resources/nevlandmap.p index 1a9d8cc4..1086ba36 100644 Binary files a/resources/nevlandmap.p and b/resources/nevlandmap.p differ diff --git a/resources/normandylandmap.p b/resources/normandylandmap.p index 0f41f8ea..e179bbf8 100644 Binary files a/resources/normandylandmap.p and b/resources/normandylandmap.p differ diff --git a/resources/syrialandmap.p b/resources/syrialandmap.p index d4169854..96b14c0e 100644 Binary files a/resources/syrialandmap.p and b/resources/syrialandmap.p differ diff --git a/resources/tools/cau_terrain.miz b/resources/tools/cau_terrain.miz index c885927d..e151b22d 100644 Binary files a/resources/tools/cau_terrain.miz and b/resources/tools/cau_terrain.miz differ diff --git a/resources/tools/channel_terrain.miz b/resources/tools/channel_terrain.miz index 53afc022..055cfcae 100644 Binary files a/resources/tools/channel_terrain.miz and b/resources/tools/channel_terrain.miz differ diff --git a/resources/tools/generate_landmap.py b/resources/tools/generate_landmap.py index 816747c5..1cdf2931 100644 --- a/resources/tools/generate_landmap.py +++ b/resources/tools/generate_landmap.py @@ -1,15 +1,15 @@ import pickle from dcs.mission import Mission -from dcs.planes import A_10C -for terrain in ["cau"]: +for terrain in ["cau", "nev", "syria", "channel", "normandy", "gulf"]: print("Terrain " + terrain) m = Mission() m.load_file("./{}_terrain.miz".format(terrain)) inclusion_zones = [] exclusion_zones = [] + seas_zones = [] for plane_group in m.country("USA").plane_group: zone = [(x.position.x, x.position.y) for x in plane_group.points] @@ -22,6 +22,10 @@ for terrain in ["cau"]: else: inclusion_zones.append(zone) + for ship_group in m.country("USA").ship_group: + zone = [(x.position.x, x.position.y) for x in ship_group.points] + seas_zones.append(zone) + with open("../{}landmap.p".format(terrain), "wb") as f: - print(len(inclusion_zones), len(exclusion_zones)) - pickle.dump((inclusion_zones, exclusion_zones), f) + print(len(inclusion_zones), len(exclusion_zones), len(seas_zones)) + pickle.dump((inclusion_zones, exclusion_zones, seas_zones), f) diff --git a/resources/tools/gulf_terrain.miz b/resources/tools/gulf_terrain.miz index 52ca6d05..6713c833 100644 Binary files a/resources/tools/gulf_terrain.miz and b/resources/tools/gulf_terrain.miz differ diff --git a/resources/tools/normandy_terrain.miz b/resources/tools/normandy_terrain.miz index cb42c13c..e848ee4b 100644 Binary files a/resources/tools/normandy_terrain.miz and b/resources/tools/normandy_terrain.miz differ diff --git a/resources/tools/syria_terrain.miz b/resources/tools/syria_terrain.miz index a1a9fae9..8b26b423 100644 Binary files a/resources/tools/syria_terrain.miz and b/resources/tools/syria_terrain.miz differ diff --git a/theater/conflicttheater.py b/theater/conflicttheater.py index c9013062..4339236b 100644 --- a/theater/conflicttheater.py +++ b/theater/conflicttheater.py @@ -95,11 +95,14 @@ class ConflictTheater: if not self.landmap: return False - for inclusion_zone in self.landmap[0]: - if poly_contains(point.x, point.y, inclusion_zone): - return False + if self.is_on_land(point): + return False - return True + for sea in self.landmap[2]: + if poly_contains(point.x, point.y, sea): + return True + + return False def is_on_land(self, point: Point) -> bool: if not self.landmap: