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 = "