Merge 'upstream/develop' into new-plugin-system

This commit is contained in:
David Pierron 2020-10-20 22:10:08 +02:00
commit b1840ce2ca
47 changed files with 783 additions and 415 deletions

View File

@ -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 # 2.1.5
## Features/Improvements : ## Features/Improvements :

View File

@ -12,7 +12,6 @@ from game import db, persistency
from game.debriefing import Debriefing from game.debriefing import Debriefing
from game.infos.information import Information from game.infos.information import Information
from game.operation.operation import Operation from game.operation.operation import Operation
from gen.environmentgen import EnvironmentSettings
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from theater import ControlPoint from theater import ControlPoint
from theater.start_generator import generate_airbase_defense_group from theater.start_generator import generate_airbase_defense_group
@ -42,7 +41,6 @@ class Event:
operation = None # type: Operation operation = None # type: Operation
difficulty = 1 # type: int difficulty = 1 # type: int
environment_settings = None # type: EnvironmentSettings
BONUS_BASE = 5 BONUS_BASE = 5
def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str): def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str):

View File

@ -2,7 +2,7 @@ import logging
import math import math
import random import random
import sys import sys
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any, Dict, List from typing import Any, Dict, List
from dcs.action import Coalition from dcs.action import Coalition
@ -29,6 +29,7 @@ from .event.frontlineattack import FrontlineAttackEvent
from .infos.information import Information from .infos.information import Information
from .settings import Settings from .settings import Settings
from plugin import LuaPluginManager from plugin import LuaPluginManager
from .weather import Conditions, TimeOfDay
COMMISION_UNIT_VARIETY = 4 COMMISION_UNIT_VARIETY = 4
COMMISION_LIMITS_SCALE = 1.5 COMMISION_LIMITS_SCALE = 1.5
@ -79,7 +80,7 @@ class Game:
self.enemy_name = enemy_name self.enemy_name = enemy_name
self.enemy_country = db.FACTIONS[enemy_name]["country"] self.enemy_country = db.FACTIONS[enemy_name]["country"]
self.turn = 0 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 = GameStats()
self.game_stats.update(self) self.game_stats.update(self)
self.ground_planners: Dict[int, GroundPlanner] = {} self.ground_planners: Dict[int, GroundPlanner] = {}
@ -92,6 +93,8 @@ class Game:
self.current_unit_id = 0 self.current_unit_id = 0
self.current_group_id = 0 self.current_group_id = 0
self.conditions = self.generate_conditions()
self.blue_ato = AirTaskingOrder() self.blue_ato = AirTaskingOrder()
self.red_ato = AirTaskingOrder() self.red_ato = AirTaskingOrder()
@ -102,6 +105,9 @@ class Game:
self.sanitize_sides() self.sanitize_sides()
self.on_load() 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): def sanitize_sides(self):
""" """
@ -223,6 +229,12 @@ class Game:
for plugin in LuaPluginManager().getPlugins(): for plugin in LuaPluginManager().getPlugins():
plugin.setSettings(self.settings) 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): def pass_turn(self, no_action=False):
logging.info("Pass turn") logging.info("Pass turn")
self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0))
@ -257,6 +269,8 @@ class Game:
for cp in self.theater.controlpoints: for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp) self.aircraft_inventory.set_from_control_point(cp)
self.conditions = self.generate_conditions()
# Plan flights & combat for next turn # Plan flights & combat for next turn
self.__culling_points = self.compute_conflicts_position() self.__culling_points = self.compute_conflicts_position()
self.ground_planners = {} self.ground_planners = {}
@ -345,11 +359,11 @@ class Game:
self.informations.append(info) self.informations.append(info)
@property @property
def current_turn_daytime(self): def current_turn_time_of_day(self) -> TimeOfDay:
return ["dawn", "day", "dusk", "night"][self.turn % 4] return list(TimeOfDay)[self.turn % 4]
@property @property
def current_day(self): def current_day(self) -> date:
return self.date + timedelta(days=self.turn // 4) return self.date + timedelta(days=self.turn // 4)
def next_unit_id(self): def next_unit_id(self):

View File

@ -65,16 +65,6 @@ class ControlPointAircraftInventory:
if count > 0: if count > 0:
yield aircraft, count 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: def clear(self) -> None:
"""Clears all aircraft from the inventory.""" """Clears all aircraft from the inventory."""
self.inventory.clear() self.inventory.clear()

View File

@ -21,7 +21,7 @@ from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator, JtacInfo from gen.armor import GroundConflictGenerator, JtacInfo
from gen.beacons import load_beacons_for_terrain from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator from gen.briefinggen import BriefingGenerator
from gen.environmentgen import EnviromentGenerator from gen.environmentgen import EnvironmentGenerator
from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator from gen.kneeboard import KneeboardGenerator
@ -45,7 +45,6 @@ class Operation:
triggersgen = None # type: TriggersGenerator triggersgen = None # type: TriggersGenerator
airsupportgen = None # type: AirSupportConflictGenerator airsupportgen = None # type: AirSupportConflictGenerator
visualgen = None # type: VisualGenerator visualgen = None # type: VisualGenerator
envgen = None # type: EnviromentGenerator
groundobjectgen = None # type: GroundObjectsGenerator groundobjectgen = None # type: GroundObjectsGenerator
briefinggen = None # type: BriefingGenerator briefinggen = None # type: BriefingGenerator
forcedoptionsgen = None # type: ForcedOptionsGenerator forcedoptionsgen = None # type: ForcedOptionsGenerator
@ -191,13 +190,9 @@ class Operation:
for frequency in unique_map_frequencies: for frequency in unique_map_frequencies:
radio_registry.reserve(frequency) radio_registry.reserve(frequency)
# Generate meteo # Set mission time and weather conditions.
envgen = EnviromentGenerator(self.current_mission, self.conflict, EnvironmentGenerator(self.current_mission,
self.game) self.game.conditions).generate()
if self.environment_settings is None:
self.environment_settings = envgen.generate()
else:
envgen.load(self.environment_settings)
# Generate ground object first # Generate ground object first

View File

@ -45,4 +45,15 @@ class Settings:
plugin.setSettings(self) 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)

183
game/weather.py Normal file
View File

@ -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()

View File

@ -419,6 +419,7 @@ class SCR522RadioChannelAllocator(RadioChannelAllocator):
# TODO : Some GCI on Channel 4 ? # TODO : Some GCI on Channel 4 ?
@dataclass(frozen=True) @dataclass(frozen=True)
class AircraftData: class AircraftData:
"""Additional aircraft data not exposed by pydcs.""" """Additional aircraft data not exposed by pydcs."""
@ -442,7 +443,9 @@ class AircraftData:
AIRCRAFT_DATA: Dict[str, AircraftData] = { AIRCRAFT_DATA: Dict[str, AircraftData] = {
"A-10C": AircraftData( "A-10C": AircraftData(
inter_flight_radio=get_radio("AN/ARC-164"), 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() channel_allocator=WarthogRadioChannelAllocator()
), ),
@ -532,6 +535,7 @@ AIRCRAFT_DATA: Dict[str, AircraftData] = {
channel_namer=SCR522ChannelNamer 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-51D-30-NA"] = AIRCRAFT_DATA["P-51D"]
AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"]
@ -793,6 +797,8 @@ class AircraftConflictGenerator:
self.clear_parking_slots() self.clear_parking_slots()
for package in ato.packages: for package in ato.packages:
if not package.flights:
continue
timing = PackageWaypointTiming.for_package(package) timing = PackageWaypointTiming.for_package(package)
for flight in package.flights: for flight in package.flights:
culled = self.game.position_culled(flight.from_cp.position) culled = self.game.position_culled(flight.from_cp.position)
@ -996,7 +1002,7 @@ class AircraftConflictGenerator:
flight: Flight, timing: PackageWaypointTiming, flight: Flight, timing: PackageWaypointTiming,
dynamic_runways: Dict[str, RunwayData]) -> None: dynamic_runways: Dict[str, RunwayData]) -> None:
flight_type = flight.flight_type 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]: FlightType.INTERCEPTION]:
self.configure_cap(group, flight, dynamic_runways) self.configure_cap(group, flight, dynamic_runways)
elif flight_type in [FlightType.CAS, FlightType.BAI]: elif flight_type in [FlightType.CAS, FlightType.BAI]:
@ -1135,7 +1141,7 @@ class HoldPointBuilder(PydcsWaypointBuilder):
pattern=OrbitAction.OrbitPattern.Circle pattern=OrbitAction.OrbitPattern.Circle
)) ))
loiter.stop_after_time( loiter.stop_after_time(
self.timing.push_time(self.flight, waypoint.position)) self.timing.push_time(self.flight, self.waypoint))
waypoint.add_task(loiter) waypoint.add_task(loiter)
return waypoint return waypoint

View File

@ -129,6 +129,11 @@ class BriefingGenerator(MissionInfoGenerator):
self.description += "DCS Liberation turn #" + str(self.game.turn) + "\n" self.description += "DCS Liberation turn #" + str(self.game.turn) + "\n"
self.description += "=" * 15 + "\n\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.generate_ongoing_war_text()
self.description += "\n"*2 self.description += "\n"*2

View File

@ -1,147 +1,36 @@
import logging from typing import Optional
import random
from datetime import timedelta
from dcs.mission import Mission from dcs.mission import Mission
from dcs.weather import Weather, Wind
from .conflictgen import Conflict from game.weather import Clouds, Fog, Conditions, WindConditions
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
}
class EnvironmentSettings: class EnvironmentGenerator:
weather_dict = None def __init__(self, mission: Mission, conditions: Conditions) -> None:
start_time = None
class EnviromentGenerator:
def __init__(self, mission: Mission, conflict: Conflict, game):
self.mission = mission self.mission = mission
self.conflict = conflict self.conditions = conditions
self.game = game
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 def set_wind(self, wind: WindConditions) -> None:
logging.info("Mission time will be {}".format(daytime)) self.mission.weather.wind_at_ground = wind.at_0m
if self.game.settings.night_disabled: self.mission.weather.wind_at_2000 = wind.at_2000m
logging.info("Skip Night mission due to user settings") self.mission.weather.wind_at_8000 = wind.at_8000m
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 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)

View File

@ -131,7 +131,7 @@ class AircraftAllocator:
@staticmethod @staticmethod
def preferred_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: 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: if task in cap_missions:
return CAP_PREFERRED return CAP_PREFERRED
elif task == FlightType.CAS: elif task == FlightType.CAS:
@ -147,7 +147,7 @@ class AircraftAllocator:
@staticmethod @staticmethod
def capable_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: 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: if task in cap_missions:
return CAP_CAPABLE return CAP_CAPABLE
elif task == FlightType.CAS: elif task == FlightType.CAS:
@ -404,7 +404,7 @@ class CoalitionMissionPlanner:
# Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
for cp in self.objective_finder.vulnerable_control_points(): for cp in self.objective_finder.vulnerable_control_points():
yield ProposedMission(cp, [ yield ProposedMission(cp, [
ProposedFlight(FlightType.CAP, 2, self.MAX_CAP_RANGE), ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
]) ])
# Find front lines, plan CAP. # Find front lines, plan CAP.
@ -493,11 +493,7 @@ class CoalitionMissionPlanner:
error = random.randint(-margin, margin) error = random.randint(-margin, margin)
yield max(0, time + error) yield max(0, time + error)
dca_types = ( dca_types = (FlightType.BARCAP, FlightType.INTERCEPTION)
FlightType.BARCAP,
FlightType.CAP,
FlightType.INTERCEPTION,
)
non_dca_packages = [p for p in self.ato.packages if non_dca_packages = [p for p in self.ato.packages if
p.primary_task not in dca_types] p.primary_task not in dca_types]

View File

@ -326,7 +326,7 @@ SEAD_CAPABLE = [
F_4E, F_4E,
FA_18C_hornet, FA_18C_hornet,
F_15E, F_15E,
# F_16C_50, Not yet F_16C_50,
AV8BNA, AV8BNA,
JF_17, JF_17,

View File

@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from typing import Dict, Optional from typing import Dict, Iterable, Optional
from game import db from game import db
from dcs.unittype import UnitType from dcs.unittype import UnitType
@ -8,7 +8,7 @@ from theater.controlpoint import ControlPoint, MissionTarget
class FlightType(Enum): class FlightType(Enum):
CAP = 0 CAP = 0 # Do not use. Use BARCAP or TARCAP.
TARCAP = 1 TARCAP = 1
BARCAP = 2 BARCAP = 2
CAS = 3 CAS = 3
@ -152,6 +152,14 @@ class Flight:
return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \
+ " (" + str(len(self.points)) + " wpt)" + " (" + 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 # Test
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -69,8 +69,6 @@ class FlightPlanBuilder:
logging.error("BAI flight plan generation not implemented") logging.error("BAI flight plan generation not implemented")
elif task == FlightType.BARCAP: elif task == FlightType.BARCAP:
self.generate_barcap(flight) self.generate_barcap(flight)
elif task == FlightType.CAP:
self.generate_barcap(flight)
elif task == FlightType.CAS: elif task == FlightType.CAS:
self.generate_cas(flight) self.generate_cas(flight)
elif task == FlightType.DEAD: elif task == FlightType.DEAD:
@ -103,8 +101,10 @@ class FlightPlanBuilder:
logging.error( logging.error(
"Troop transport flight plan generation not implemented" "Troop transport flight plan generation not implemented"
) )
except InvalidObjectiveLocation as ex: else:
logging.error(f"Could not create flight plan: {ex}") logging.error(f"Unsupported task type: {task.name}")
except InvalidObjectiveLocation:
logging.exception(f"Could not create flight plan")
def regenerate_package_waypoints(self) -> None: def regenerate_package_waypoints(self) -> None:
ingress_point = self._ingress_point() ingress_point = self._ingress_point()
@ -424,7 +424,6 @@ class FlightPlanBuilder:
def _heading_to_package_airfield(self, point: Point) -> int: def _heading_to_package_airfield(self, point: Point) -> int:
return self.package_airfield().position.heading_between_point(point) return self.package_airfield().position.heading_between_point(point)
# TODO: Set ingress/egress/join/split points in the Package.
def package_airfield(self) -> ControlPoint: def package_airfield(self) -> ControlPoint:
# We'll always have a package, but if this is being planned via the UI # We'll always have a package, but if this is being planned via the UI
# it could be the first flight in the package. # it could be the first flight in the package.

View File

@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable, Optional from typing import Iterable, Optional
from dcs.mapping import Point from dcs.mapping import Point
from dcs.unittype import FlyingType
from game.utils import meter_to_nm from game.utils import meter_to_nm
from gen.ato import Package from gen.ato import Package
@ -17,25 +19,101 @@ from gen.flights.flight import (
CAP_DURATION = 30 # Minutes 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: 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 @staticmethod
def for_flight(_flight: Flight) -> int: def mission_speed(package: Package) -> int:
# TODO: Gather data so this is useful. 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. # TODO: Expose both a cruise speed and target speed.
# The cruise speed can be used for ascent, hold, join, and RTB to save # 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 # on fuel, but mission speed will be fast enough to keep the flight
# safer. # 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: class TravelTime:
@ -72,7 +150,7 @@ class TotEstimator:
# Takeoff immediately. # Takeoff immediately.
return 0 return 0
if self.package.primary_task in CAP_TYPES: if self.package.primary_task == FlightType.BARCAP:
start_time = self.timing.race_track_start start_time = self.timing.race_track_start
else: else:
start_time = self.timing.join start_time = self.timing.join
@ -97,13 +175,7 @@ class TotEstimator:
The earliest possible TOT for the given flight in seconds. Returns 0 The earliest possible TOT for the given flight in seconds. Returns 0
if an ingress point cannot be found. if an ingress point cannot be found.
""" """
stop_types = { time_to_ingress = self.estimate_waypoints_to_target(flight, IP_TYPES)
FlightWaypointType.PATROL_TRACK,
FlightWaypointType.INGRESS_CAS,
FlightWaypointType.INGRESS_SEAD,
FlightWaypointType.INGRESS_STRIKE,
}
time_to_ingress = self.estimate_waypoints_to_target(flight, stop_types)
if time_to_ingress is None: if time_to_ingress is None:
logging.warning( logging.warning(
f"Found no ingress types. Cannot estimate TOT for {flight}") f"Found no ingress types. Cannot estimate TOT for {flight}")
@ -111,7 +183,7 @@ class TotEstimator:
# the package. # the package.
return 0 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 # The racetrack start *is* the target. The package target is the
# protected objective. # protected objective.
time_to_target = 0 time_to_target = 0
@ -119,7 +191,7 @@ class TotEstimator:
assert self.package.waypoints is not None assert self.package.waypoints is not None
time_to_target = TravelTime.between_points( time_to_target = TravelTime.between_points(
self.package.waypoints.ingress, self.package.target.position, self.package.waypoints.ingress, self.package.target.position,
GroundSpeed.for_package(self.package)) GroundSpeed.mission_speed(self.package))
return sum([ return sum([
self.estimate_startup(flight), self.estimate_startup(flight),
self.estimate_ground_ops(flight), self.estimate_ground_ops(flight),
@ -146,30 +218,38 @@ class TotEstimator:
self, flight: Flight, self, flight: Flight,
stop_types: Iterable[FlightWaypointType]) -> Optional[int]: stop_types: Iterable[FlightWaypointType]) -> Optional[int]:
total = 0 total = 0
# TODO: This is AGL. We want MSL.
previous_altitude = 0
previous_position = flight.from_cp.position previous_position = flight.from_cp.position
for waypoint in flight.points: for waypoint in flight.points:
position = Point(waypoint.x, waypoint.y) position = Point(waypoint.x, waypoint.y)
total += TravelTime.between_points( total += TravelTime.between_points(
previous_position, position, previous_position, position,
self.speed_to_waypoint(flight, waypoint) self.speed_to_waypoint(flight, waypoint, previous_altitude)
) )
previous_position = position previous_position = position
previous_altitude = waypoint.alt
if waypoint.waypoint_type in stop_types: if waypoint.waypoint_type in stop_types:
return total return total
return None return None
def speed_to_waypoint(self, flight: Flight, def speed_to_waypoint(self, flight: Flight, waypoint: FlightWaypoint,
waypoint: FlightWaypoint) -> int: 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) pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN)
if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT: if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT:
# Flights that start airborne already have some altitude and a good # Flights that start airborne already have some altitude and a good
# amount of speed. # amount of speed.
factor = 1.0 if flight.start_type == "In Flight" else 0.5 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: elif waypoint.waypoint_type in pre_join:
return GroundSpeed.for_flight(flight) return GroundSpeed.for_flight(flight, alt_for_speed)
return GroundSpeed.for_package(self.package) return GroundSpeed.mission_speed(self.package)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -197,24 +277,24 @@ class PackageWaypointTiming:
@property @property
def race_track_start(self) -> int: 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 return self.package.time_over_target
else: else:
return self.ingress return self.ingress
@property @property
def race_track_end(self) -> int: 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 return self.target + CAP_DURATION * 60
else: else:
return self.egress 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 assert self.package.waypoints is not None
return self.join - TravelTime.between_points( return self.join - TravelTime.between_points(
hold_point, Point(hold_point.x, hold_point.y),
self.package.waypoints.join, self.package.waypoints.join,
GroundSpeed.for_flight(flight) GroundSpeed.for_flight(flight, hold_point.alt)
) )
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]:
@ -224,15 +304,9 @@ class PackageWaypointTiming:
FlightWaypointType.TARGET_SHIP, FlightWaypointType.TARGET_SHIP,
) )
ingress_types = (
FlightWaypointType.INGRESS_CAS,
FlightWaypointType.INGRESS_SEAD,
FlightWaypointType.INGRESS_STRIKE,
)
if waypoint.waypoint_type == FlightWaypointType.JOIN: if waypoint.waypoint_type == FlightWaypointType.JOIN:
return self.join return self.join
elif waypoint.waypoint_type in ingress_types: elif waypoint.waypoint_type in INGRESS_TYPES:
return self.ingress return self.ingress
elif waypoint.waypoint_type in target_types: elif waypoint.waypoint_type in target_types:
return self.target return self.target
@ -247,7 +321,7 @@ class PackageWaypointTiming:
def depart_time_for_waypoint(self, waypoint: FlightWaypoint, def depart_time_for_waypoint(self, waypoint: FlightWaypoint,
flight: Flight) -> Optional[int]: flight: Flight) -> Optional[int]:
if waypoint.waypoint_type == FlightWaypointType.LOITER: 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: elif waypoint.waypoint_type == FlightWaypointType.PATROL:
return self.race_track_end return self.race_track_end
return None return None
@ -256,7 +330,17 @@ class PackageWaypointTiming:
def for_package(cls, package: Package) -> PackageWaypointTiming: def for_package(cls, package: Package) -> PackageWaypointTiming:
assert package.waypoints is not None 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( ingress = package.time_over_target - TravelTime.between_points(
package.waypoints.ingress, package.waypoints.ingress,

View File

@ -267,12 +267,6 @@ class WaypointBuilder:
waypoint.pretty_name = "Race-track start" waypoint.pretty_name = "Race-track start"
self.waypoints.append(waypoint) 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: def race_track_end(self, position: Point, altitude: int) -> None:
"""Creates a racetrack end waypoint. """Creates a racetrack end waypoint.

2
pydcs

@ -1 +1 @@
Subproject commit c203e5a1b8d5eb42d559dab074e668bf37fa5158 Subproject commit c12733a4712e802b41fd26ad8df7475d06c334b3

View File

@ -164,6 +164,7 @@ class PackageModel(QAbstractListModel):
def update_tot(self, tot: int) -> None: def update_tot(self, tot: int) -> None:
self.package.time_over_target = tot self.package.time_over_target = tot
self.layoutChanged.emit()
@property @property
def mission_target(self) -> MissionTarget: def mission_target(self) -> MissionTarget:
@ -234,7 +235,7 @@ class AtoModel(QAbstractListModel):
"""Returns the package at the given index.""" """Returns the package at the given index."""
return self.ato.packages[index.row()] 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. """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 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.game = game
self.package_models.clear() self.package_models.clear()
if self.game is not None: 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: else:
self.ato = AirTaskingOrder() self.ato = AirTaskingOrder()
self.endResetModel() self.endResetModel()
@ -268,8 +272,8 @@ class GameModel:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.game: Optional[Game] = 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.ato_model = AtoModel(self.game, AirTaskingOrder())
self.red_ato_model = AtoModel(self.game, AirTaskingOrder())
def set(self, game: Optional[Game]) -> None: def set(self, game: Optional[Game]) -> None:
"""Updates the managed Game object. """Updates the managed Game object.
@ -280,4 +284,5 @@ class GameModel:
loaded. loaded.
""" """
self.game = game 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)

View File

@ -6,7 +6,7 @@ from PySide2.QtGui import QColor, QFont, QPixmap
from theater.theatergroundobject import CATEGORY_MAP from theater.theatergroundobject import CATEGORY_MAP
from .liberation_theme import get_theme_icons from .liberation_theme import get_theme_icons
VERSION_STRING = "2.1.4" VERSION_STRING = "2.2.0-preview"
URLS : Dict[str, str] = { URLS : Dict[str, str] = {
"Manual": "https://github.com/khopa/dcs_liberation/wiki", "Manual": "https://github.com/khopa/dcs_liberation/wiki",
@ -40,6 +40,7 @@ COLORS: Dict[str, QColor] = {
"light_blue": QColor(105, 182, 240, 90), "light_blue": QColor(105, 182, 240, 90),
"blue": QColor(0, 132, 255), "blue": QColor(0, 132, 255),
"dark_blue": QColor(45, 62, 80), "dark_blue": QColor(45, 62, 80),
"sea_blue": QColor(52, 68, 85),
"blue_transparent": QColor(0, 132, 255, 20), "blue_transparent": QColor(0, 132, 255, 20),
"purple": QColor(187, 137, 255), "purple": QColor(187, 137, 255),

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import List, Optional
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QFrame, QFrame,
@ -11,6 +11,8 @@ from PySide2.QtWidgets import (
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game import Game from game import Game
from game.event import CAP, CAS, FrontlineAttackEvent 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.models import GameModel
from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QBudgetBox import QBudgetBox
from qt_ui.widgets.QFactionsInfos import QFactionsInfos from qt_ui.widgets.QFactionsInfos import QFactionsInfos
@ -95,7 +97,7 @@ class QTopPanel(QFrame):
if game is None: if game is None:
return return
self.turnCounter.setCurrentTurn(game.turn, game.current_day) self.turnCounter.setCurrentTurn(game.turn, game.conditions)
self.budgetBox.setGame(game) self.budgetBox.setGame(game)
self.factionsInfos.setGame(game) self.factionsInfos.setGame(game)
@ -117,6 +119,24 @@ class QTopPanel(QFrame):
GameUpdateSignal.get_instance().updateGame(self.game) GameUpdateSignal.get_instance().updateGame(self.game)
self.proceedButton.setEnabled(True) 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: def ato_has_clients(self) -> bool:
for package in self.game.blue_ato.packages: for package in self.game.blue_ato.packages:
for flight in package.flights: for flight in package.flights:
@ -142,12 +162,52 @@ class QTopPanel(QFrame):
) )
return result == QMessageBox.Yes return result == QMessageBox.Yes
def confirm_negative_start_time(self,
negative_starts: List[Package]) -> bool:
formatted = '<br />'.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:<br />"
"<br />"
f"{formatted}<br />"
"<br />"
"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.<br />"
"<br />"
"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): def launch_mission(self):
"""Finishes planning and waits for mission completion.""" """Finishes planning and waits for mission completion."""
if not self.ato_has_clients() and not self.confirm_no_client_launch(): if not self.ato_has_clients() and not self.confirm_no_client_launch():
return 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. # TODO: Refactor this nonsense.
game_event = None game_event = None

View File

@ -1,7 +1,8 @@
import datetime 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 import qt_ui.uiconstants as CONST
@ -13,23 +14,37 @@ class QTurnCounter(QGroupBox):
def __init__(self): def __init__(self):
super(QTurnCounter, self).__init__("Turn") super(QTurnCounter, self).__init__("Turn")
self.icons = [CONST.ICONS["Dawn"], CONST.ICONS["Day"], CONST.ICONS["Dusk"], CONST.ICONS["Night"]] self.icons = {
TimeOfDay.Dawn: CONST.ICONS["Dawn"],
self.daytime_icon = QLabel() TimeOfDay.Day: CONST.ICONS["Day"],
self.daytime_icon.setPixmap(self.icons[0]) TimeOfDay.Dusk: CONST.ICONS["Dusk"],
self.turn_info = QLabel() TimeOfDay.Night: CONST.ICONS["Night"],
}
self.layout = QHBoxLayout() self.layout = QHBoxLayout()
self.layout.addWidget(self.daytime_icon)
self.layout.addWidget(self.turn_info)
self.setLayout(self.layout) 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 self.daytime_icon.setPixmap(self.icons[conditions.time_of_day])
:arg turn Current turn number self.date_display.setText(conditions.start_time.strftime("%d %b %Y"))
:arg current_day Current day self.time_display.setText(
""" conditions.start_time.strftime("%H:%M:%S Local"))
self.daytime_icon.setPixmap(self.icons[turn % 4])
self.turn_info.setText(current_day.strftime("%d %b %Y"))
self.setTitle("Turn " + str(turn + 1)) self.setTitle("Turn " + str(turn + 1))

View File

@ -19,7 +19,6 @@ class QFlightTypeComboBox(QComboBox):
COMMON_ENEMY_MISSIONS = [ COMMON_ENEMY_MISSIONS = [
FlightType.ESCORT, FlightType.ESCORT,
FlightType.TARCAP,
FlightType.SEAD, FlightType.SEAD,
FlightType.DEAD, FlightType.DEAD,
# TODO: FlightType.ELINT, # TODO: FlightType.ELINT,
@ -27,42 +26,46 @@ class QFlightTypeComboBox(QComboBox):
# TODO: FlightType.RECON, # TODO: FlightType.RECON,
] ]
FRIENDLY_AIRBASE_MISSIONS = [ COMMON_FRIENDLY_MISSIONS = [
FlightType.CAP, FlightType.BARCAP,
# TODO: FlightType.INTERCEPTION
# TODO: FlightType.LOGISTICS
] ]
FRIENDLY_AIRBASE_MISSIONS = [
# TODO: FlightType.INTERCEPTION
# TODO: FlightType.LOGISTICS
] + COMMON_FRIENDLY_MISSIONS
FRIENDLY_CARRIER_MISSIONS = [ FRIENDLY_CARRIER_MISSIONS = [
FlightType.BARCAP,
# TODO: FlightType.INTERCEPTION # TODO: FlightType.INTERCEPTION
# TODO: Buddy tanking for the A-4? # TODO: Buddy tanking for the A-4?
# TODO: Rescue chopper? # TODO: Rescue chopper?
# TODO: Inter-ship logistics? # TODO: Inter-ship logistics?
] ] + COMMON_FRIENDLY_MISSIONS
ENEMY_CARRIER_MISSIONS = [ ENEMY_CARRIER_MISSIONS = [
FlightType.ESCORT, FlightType.ESCORT,
FlightType.TARCAP, FlightType.BARCAP,
# TODO: FlightType.ANTISHIP # TODO: FlightType.ANTISHIP
] ]
ENEMY_AIRBASE_MISSIONS = [ ENEMY_AIRBASE_MISSIONS = [
FlightType.BARCAP,
# TODO: FlightType.STRIKE # TODO: FlightType.STRIKE
] + COMMON_ENEMY_MISSIONS ] + COMMON_ENEMY_MISSIONS
FRIENDLY_GROUND_OBJECT_MISSIONS = [ FRIENDLY_GROUND_OBJECT_MISSIONS = [
FlightType.CAP,
# TODO: FlightType.LOGISTICS # TODO: FlightType.LOGISTICS
# TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.TROOP_TRANSPORT
] ] + COMMON_FRIENDLY_MISSIONS
ENEMY_GROUND_OBJECT_MISSIONS = [ ENEMY_GROUND_OBJECT_MISSIONS = [
FlightType.BARCAP,
FlightType.STRIKE, FlightType.STRIKE,
] + COMMON_ENEMY_MISSIONS ] + COMMON_ENEMY_MISSIONS
FRONT_LINE_MISSIONS = [ FRONT_LINE_MISSIONS = [
FlightType.CAS, FlightType.CAS,
FlightType.TARCAP,
# TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.TROOP_TRANSPORT
# TODO: FlightType.EVAC # TODO: FlightType.EVAC
] + COMMON_ENEMY_MISSIONS ] + COMMON_ENEMY_MISSIONS

View File

@ -21,6 +21,7 @@ from game import Game, db
from game.data.aaa_db import AAA_UNITS from game.data.aaa_db import AAA_UNITS
from game.data.radar_db import UNITS_WITH_RADAR from game.data.radar_db import UNITS_WITH_RADAR
from game.utils import meter_to_feet from game.utils import meter_to_feet
from game.weather import TimeOfDay
from gen import Conflict, PackageWaypointTiming from gen import Conflict, PackageWaypointTiming
from gen.ato import Package from gen.ato import Package
from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType
@ -55,25 +56,42 @@ class QLiberationMap(QGraphicsView):
self.factor = 1 self.factor = 1
self.factorized = 1 self.factorized = 1
self.init_scene() self.init_scene()
self.connectSignals()
self.setGame(game_model.game) self.setGame(game_model.game)
GameUpdateSignal.get_instance().flight_paths_changed.connect( GameUpdateSignal.get_instance().flight_paths_changed.connect(
lambda: self.draw_flight_plans(self.scene()) lambda: self.draw_flight_plans(self.scene())
) )
def update_package_selection(index: Optional[int]) -> None: def update_package_selection(index: int) -> None:
self.selected_flight = index, 0 # 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()) self.draw_flight_plans(self.scene())
GameUpdateSignal.get_instance().package_selection_changed.connect( GameUpdateSignal.get_instance().package_selection_changed.connect(
update_package_selection update_package_selection
) )
def update_flight_selection(index: Optional[int]) -> None: def update_flight_selection(index: int) -> None:
if self.selected_flight is 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 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.selected_flight = self.selected_flight[0], index
self.draw_flight_plans(self.scene()) self.draw_flight_plans(self.scene())
@ -90,9 +108,6 @@ class QLiberationMap(QGraphicsView):
self.setFrameShape(QFrame.NoFrame) self.setFrameShape(QFrame.NoFrame)
self.setDragMode(QGraphicsView.ScrollHandDrag) self.setDragMode(QGraphicsView.ScrollHandDrag)
def connectSignals(self):
GameUpdateSignal.get_instance().gameupdated.connect(self.setGame)
def setGame(self, game: Optional[Game]): def setGame(self, game: Optional[Game]):
self.game = game self.game = game
logging.debug("Reloading Map Canvas") logging.debug("Reloading Map Canvas")
@ -244,7 +259,7 @@ class QLiberationMap(QGraphicsView):
text.setDefaultTextColor(Qt.white) text.setDefaultTextColor(Qt.white)
text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) 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: for item in self.flight_path_items:
try: try:
scene.removeItem(item) scene.removeItem(item)
@ -252,12 +267,20 @@ class QLiberationMap(QGraphicsView):
# Something may have caused those items to already be removed. # Something may have caused those items to already be removed.
pass pass
self.flight_path_items.clear() self.flight_path_items.clear()
def draw_flight_plans(self, scene: QGraphicsScene) -> None:
self.clear_flight_paths(scene)
if DisplayOptions.flight_paths.hide: if DisplayOptions.flight_paths.hide:
return return
packages = list(self.game_model.ato_model.packages) 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 p_idx, package_model in enumerate(packages):
for f_idx, flight in enumerate(package_model.flights): 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: if DisplayOptions.flight_paths.only_selected and not selected:
continue continue
self.draw_flight_plan(scene, package_model.package, flight, self.draw_flight_plan(scene, package_model.package, flight,
@ -486,9 +509,9 @@ class QLiberationMap(QGraphicsView):
scene.addPixmap(bg) scene.addPixmap(bg)
# Apply graphical effects to simulate current daytime # 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 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 = QPixmap(bg.width(), bg.height())
ov.fill(CONST.COLORS["night_overlay"]) ov.fill(CONST.COLORS["night_overlay"])
overlay = scene.addPixmap(ov) overlay = scene.addPixmap(ov)
@ -507,6 +530,11 @@ class QLiberationMap(QGraphicsView):
# Polygon display mode # Polygon display mode
if self.game.theater.landmap is not None: 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]: 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]) 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"]) 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"]) scene.addPolygon(poly, CONST.COLORS["grey"], CONST.COLORS["dark_dark_grey"])

View File

@ -7,6 +7,7 @@ from PySide2.QtWidgets import QGraphicsItem
import qt_ui.uiconstants as const import qt_ui.uiconstants as const
from game import Game from game import Game
from game.data.building_data import FORTIFICATION_BUILDINGS from game.data.building_data import FORTIFICATION_BUILDINGS
from game.db import REWARDS
from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu
from theater import ControlPoint, TheaterGroundObject from theater import ControlPoint, TheaterGroundObject
from .QMapObject import QMapObject from .QMapObject import QMapObject
@ -27,7 +28,14 @@ class QMapGroundObject(QMapObject):
self.buildings = buildings if buildings is not None else [] self.buildings = buildings if buildings is not None else []
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False)
self.ground_object_dialog: Optional[QGroundObjectMenu] = None 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: if self.ground_object.groups:
units = {} units = {}
for g in self.ground_object.groups: for g in self.ground_object.groups:
@ -36,16 +44,23 @@ class QMapGroundObject(QMapObject):
units[u.type] = units[u.type]+1 units[u.type] = units[u.type]+1
else: else:
units[u.type] = 1 units[u.type] = 1
tooltip = "[" + self.ground_object.obj_name + "]" + "\n"
for unit in units.keys(): for unit in units.keys():
tooltip = tooltip + str(unit) + "x" + str(units[unit]) + "\n" lines.append(f"{unit} x {units[unit]}")
self.setToolTip(tooltip[:-1])
else: else:
tooltip = "[" + self.ground_object.obj_name + "]" + "\n" for building in self.buildings:
for building in buildings:
if not building.is_dead: if not building.is_dead:
tooltip = tooltip + str(building.dcs_identifier) + "\n" lines.append(f"{building.dcs_identifier}")
self.setToolTip(tooltip[:-1])
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: def paint(self, painter, option, widget=None) -> None:
player_icons = "_blue" player_icons = "_blue"

View File

@ -24,8 +24,8 @@ class GameUpdateSignal(QObject):
debriefingReceived = Signal(DebriefingSignal) debriefingReceived = Signal(DebriefingSignal)
flight_paths_changed = Signal() flight_paths_changed = Signal()
package_selection_changed = Signal(int) # Optional[int] package_selection_changed = Signal(int) # -1 indicates no selection.
flight_selection_changed = Signal(int) # Optional[int] flight_selection_changed = Signal(int) # -1 indicates no selection.
def __init__(self): def __init__(self):
super(GameUpdateSignal, self).__init__() super(GameUpdateSignal, self).__init__()
@ -33,11 +33,11 @@ class GameUpdateSignal(QObject):
def select_package(self, index: Optional[int]) -> None: def select_package(self, index: Optional[int]) -> None:
# noinspection PyUnresolvedReferences # 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: def select_flight(self, index: Optional[int]) -> None:
# noinspection PyUnresolvedReferences # 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: def redraw_flight_paths(self) -> None:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences

View File

@ -43,6 +43,7 @@ class QLiberationWindow(QMainWindow):
Dialog.set_game(self.game_model) Dialog.set_game(self.game_model)
self.ato_panel = None self.ato_panel = None
self.info_panel = None self.info_panel = None
self.liberation_map = None
self.setGame(persistency.restore_game()) self.setGame(persistency.restore_game())
self.setGeometry(300, 100, 270, 100) self.setGeometry(300, 100, 270, 100)
@ -224,9 +225,11 @@ class QLiberationWindow(QMainWindow):
if game is not None: if game is not None:
game.on_load() game.on_load()
self.game = game self.game = game
if self.info_panel: if self.info_panel is not None:
self.info_panel.setGame(game) self.info_panel.setGame(game)
self.game_model.set(self.game) self.game_model.set(self.game)
if self.liberation_map is not None:
self.liberation_map.setGame(game)
def showAboutDialog(self): def showAboutDialog(self):
text = "<h3>DCS Liberation " + CONST.VERSION_STRING + "</h3>" + \ text = "<h3>DCS Liberation " + CONST.VERSION_STRING + "</h3>" + \

View File

@ -1,15 +1,16 @@
from typing import Optional from typing import Optional, Set
from PySide2.QtCore import Qt from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QFrame, QFrame,
QGridLayout, QGridLayout,
QScrollArea,
QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QScrollArea,
QVBoxLayout,
QWidget, QWidget,
) )
from dcs.unittype import UnitType
from game.event.event import UnitsDeliveryEvent from game.event.event import UnitsDeliveryEvent
from qt_ui.models import GameModel from qt_ui.models import GameModel
@ -48,26 +49,27 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
def init_ui(self): def init_ui(self):
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
units = { tasks = [CAP, CAS]
CAP: db.find_unittype(CAP, self.game_model.game.player_name),
CAS: db.find_unittype(CAS, self.game_model.game.player_name),
}
scroll_content = QWidget() scroll_content = QWidget()
task_box_layout = QGridLayout() task_box_layout = QGridLayout()
row = 0 row = 0
for task_type in units.keys(): unit_types: Set[UnitType] = set()
units_column = list(set(units[task_type])) for task in tasks:
if len(units_column) == 0: units = db.find_unittype(task, self.game_model.game.player_name)
if not units:
continue continue
units_column.sort(key=lambda x: db.PRICES[x]) for unit in units:
for unit_type in units_column: if self.cp.is_carrier and unit not in db.CARRIER_CAPABLE:
if self.cp.is_carrier and not unit_type in db.CARRIER_CAPABLE:
continue 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 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 = QVBoxLayout()
stretch.addStretch() stretch.addStretch()
task_box_layout.addLayout(stretch, row, 0) task_box_layout.addLayout(stretch, row, 0)

View File

@ -16,6 +16,7 @@ from game.game import Game
from gen.ato import Package from gen.ato import Package
from gen.flights.flight import Flight from gen.flights.flight import Flight
from gen.flights.flightplan import FlightPlanBuilder from gen.flights.flightplan import FlightPlanBuilder
from gen.flights.traveltime import TotEstimator
from qt_ui.models import AtoModel, PackageModel from qt_ui.models import AtoModel, PackageModel
from qt_ui.uiconstants import EVENT_ICONS from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.ato import QFlightList from qt_ui.widgets.ato import QFlightList
@ -34,12 +35,6 @@ class QPackageDialog(QDialog):
#: Emitted when a change is made to the package. #: Emitted when a change is made to the package.
package_changed = Signal() 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: def __init__(self, game: Game, model: PackageModel) -> None:
super().__init__() super().__init__()
self.game = game self.game = game
@ -77,20 +72,20 @@ class QPackageDialog(QDialog):
self.tot_label = QLabel("Time Over Target:") self.tot_label = QLabel("Time Over Target:")
self.tot_column.addWidget(self.tot_label) self.tot_column.addWidget(self.tot_label)
if self.package_model.package.time_over_target is None: self.tot_spinner = QTimeEdit(self.tot_qtime())
time = None
else:
delay = self.package_model.package.time_over_target
hours = delay // 3600
minutes = delay // 60 % 60
seconds = delay % 60
time = QTime(hours, minutes, seconds)
self.tot_spinner = QTimeEdit(time)
self.tot_spinner.setMinimumTime(QTime(0, 0)) self.tot_spinner.setMinimumTime(QTime(0, 0))
self.tot_spinner.setDisplayFormat("T+hh:mm:ss") self.tot_spinner.setDisplayFormat("T+hh:mm:ss")
self.tot_spinner.timeChanged.connect(self.save_tot)
self.tot_column.addWidget(self.tot_spinner) 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 = QFlightList(self.package_model)
self.package_view.selectionModel().selectionChanged.connect( self.package_view.selectionModel().selectionChanged.connect(
self.on_selection_changed self.on_selection_changed
@ -114,17 +109,40 @@ class QPackageDialog(QDialog):
self.setLayout(self.layout) self.setLayout(self.layout)
self.accepted.connect(self.on_save)
self.finished.connect(self.on_close) 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 @staticmethod
def on_close(_result) -> None: def on_close(_result) -> None:
GameUpdateSignal.get_instance().redraw_flight_paths() GameUpdateSignal.get_instance().redraw_flight_paths()
def on_save(self) -> None:
self.save_tot()
def save_tot(self) -> None: def save_tot(self) -> None:
time = self.tot_spinner.time() time = self.tot_spinner.time()
seconds = time.hour() * 3600 + time.minute() * 60 + time.second() seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
self.package_model.update_tot(seconds) 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, def on_selection_changed(self, selected: QItemSelection,
_deselected: QItemSelection) -> None: _deselected: QItemSelection) -> None:
"""Updates the state of the delete button.""" """Updates the state of the delete button."""
@ -139,14 +157,13 @@ class QPackageDialog(QDialog):
def add_flight(self, flight: Flight) -> None: def add_flight(self, flight: Flight) -> None:
"""Adds the new flight to the package.""" """Adds the new flight to the package."""
self.game.aircraft_inventory.claim_for_flight(flight)
self.package_model.add_flight(flight) self.package_model.add_flight(flight)
planner = FlightPlanBuilder(self.game, self.package_model.package, planner = FlightPlanBuilder(self.game, self.package_model.package,
is_player=True) is_player=True)
planner.populate_flight_plan(flight) planner.populate_flight_plan(flight)
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
self.package_changed.emit() self.package_changed.emit()
# noinspection PyUnresolvedReferences
self.flight_added.emit(flight)
def on_delete_flight(self) -> None: def on_delete_flight(self) -> None:
"""Removes the selected flight from the package.""" """Removes the selected flight from the package."""
@ -154,11 +171,10 @@ class QPackageDialog(QDialog):
if flight is None: if flight is None:
logging.error(f"Cannot delete flight when no flight is selected.") logging.error(f"Cannot delete flight when no flight is selected.")
return return
self.game.aircraft_inventory.return_from_flight(flight)
self.package_model.delete_flight(flight) self.package_model.delete_flight(flight)
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
self.package_changed.emit() self.package_changed.emit()
# noinspection PyUnresolvedReferences
self.flight_removed.emit(flight)
class QNewPackageDialog(QPackageDialog): class QNewPackageDialog(QPackageDialog):
@ -174,22 +190,22 @@ class QNewPackageDialog(QPackageDialog):
self.save_button = QPushButton("Save") self.save_button = QPushButton("Save")
self.save_button.setProperty("style", "start-button") 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.button_layout.addWidget(self.save_button)
self.delete_flight_button.clicked.connect(self.on_delete_flight)
def on_save(self) -> None: def on_save(self) -> None:
"""Saves the created package. """Saves the created package.
Empty packages may be created. They can be modified later, and will have Empty packages may be created. They can be modified later, and will have
no effect if empty when the mission is generated. no effect if empty when the mission is generated.
""" """
self.save_tot() super().on_save()
self.ato_model.add_package(self.package_model.package) 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: for flight in self.package_model.package.flights:
self.game.aircraft_inventory.claim_for_flight(flight) self.game_model.game.aircraft_inventory.return_from_flight(flight)
self.close()
class QEditPackageDialog(QPackageDialog): class QEditPackageDialog(QPackageDialog):
@ -210,30 +226,9 @@ class QEditPackageDialog(QPackageDialog):
self.done_button = QPushButton("Done") self.done_button = QPushButton("Done")
self.done_button.setProperty("style", "start-button") 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) 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: def on_delete(self) -> None:
"""Removes the viewed package from the ATO.""" """Removes the viewed package from the ATO."""
# The ATO model returns inventory for us when deleting a package. # The ATO model returns inventory for us when deleting a package.

View File

@ -19,11 +19,15 @@ class QFlightPlanner(QTabWidget):
def __init__(self, package: Package, flight: Flight, game: Game): def __init__(self, package: Package, flight: Flight, game: Game):
super().__init__() 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( self.general_settings_tab.on_flight_settings_changed.connect(
lambda: self.on_planned_flight_changed.emit()) lambda: self.on_planned_flight_changed.emit())
self.payload_tab = QFlightPayloadTab(flight, game) self.payload_tab = QFlightPayloadTab(flight, game)
self.waypoint_tab = QFlightWaypointTab(game, package, flight) self.waypoint_tab = QFlightWaypointTab(game, package, flight)
# noinspection PyUnresolvedReferences
self.waypoint_tab.on_flight_changed.connect( self.waypoint_tab.on_flight_changed.connect(
lambda: self.on_planned_flight_changed.emit()) lambda: self.on_planned_flight_changed.emit())
self.addTab(self.general_settings_tab, "General Flight settings") self.addTab(self.general_settings_tab, "General Flight settings")

View File

@ -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 <b>{flight.from_cp.name}</b>"
))
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)

View File

@ -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 <b>" + self.flight.from_cp.name + "</b>")
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)

View File

@ -2,26 +2,29 @@ from PySide2.QtCore import Signal
from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout
from game import Game from game import Game
from gen.ato import Package
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.windows.mission.flight.settings.QFlightDepartureEditor import QFlightDepartureEditor from qt_ui.windows.mission.flight.settings.QFlightDepartureDisplay import \
from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import QFlightSlotEditor QFlightDepartureDisplay
from qt_ui.windows.mission.flight.settings.QFlightStartType import QFlightStartType from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import \
from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import QFlightTypeTaskInfo QFlightSlotEditor
from qt_ui.windows.mission.flight.settings.QFlightStartType import \
QFlightStartType
from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import \
QFlightTypeTaskInfo
class QGeneralFlightSettingsTab(QFrame): class QGeneralFlightSettingsTab(QFrame):
on_flight_settings_changed = Signal() on_flight_settings_changed = Signal()
def __init__(self, game: Game, flight: Flight): def __init__(self, game: Game, package: Package, flight: Flight):
super(QGeneralFlightSettingsTab, self).__init__() super().__init__()
self.flight = flight
self.game = game
layout = QGridLayout() layout = QGridLayout()
flight_info = QFlightTypeTaskInfo(self.flight) flight_info = QFlightTypeTaskInfo(flight)
flight_departure = QFlightDepartureEditor(self.flight) flight_departure = QFlightDepartureDisplay(package, flight)
flight_slots = QFlightSlotEditor(self.flight, self.game) flight_slots = QFlightSlotEditor(flight, game)
flight_start_type = QFlightStartType(self.flight) flight_start_type = QFlightStartType(flight)
layout.addWidget(flight_info, 0, 0) layout.addWidget(flight_info, 0, 0)
layout.addWidget(flight_departure, 1, 0) layout.addWidget(flight_departure, 1, 0)
layout.addWidget(flight_slots, 2, 0) layout.addWidget(flight_slots, 2, 0)
@ -31,8 +34,6 @@ class QGeneralFlightSettingsTab(QFrame):
layout.addLayout(vstretch, 3, 0) layout.addLayout(vstretch, 3, 0)
self.setLayout(layout) self.setLayout(layout)
flight_start_type.setEnabled(self.flight.client_count > 0) flight_start_type.setEnabled(flight.client_count > 0)
flight_slots.changed.connect( flight_slots.changed.connect(
lambda: flight_start_type.setEnabled(self.flight.client_count > 0)) lambda: flight_start_type.setEnabled(flight.client_count > 0))
flight_departure.changed.connect(
lambda: self.on_flight_settings_changed.emit())

View File

@ -54,6 +54,7 @@ class QFlightWaypointTab(QFrame):
rlayout.addWidget(QLabel("<strong>Generator :</strong>")) rlayout.addWidget(QLabel("<strong>Generator :</strong>"))
rlayout.addWidget(QLabel("<small>AI compatible</small>")) rlayout.addWidget(QLabel("<small>AI compatible</small>"))
# TODO: Filter by objective type.
self.recreate_buttons.clear() self.recreate_buttons.clear()
recreate_types = [ recreate_types = [
FlightType.CAS, FlightType.CAS,
@ -137,13 +138,16 @@ class QFlightWaypointTab(QFrame):
QMessageBox.Yes QMessageBox.Yes
) )
if result == 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 task == FlightType.CAP:
if isinstance(self.package.target, FrontLine): if isinstance(self.package.target, FrontLine):
task = FlightType.TARCAP task = FlightType.TARCAP
elif isinstance(self.package.target, ControlPoint): elif isinstance(self.package.target, ControlPoint):
if self.package.target.is_fleet: task = FlightType.BARCAP
task = FlightType.BARCAP
self.flight.flight_type = task self.flight.flight_type = task
self.planner.populate_flight_plan(self.flight) self.planner.populate_flight_plan(self.flight)
self.flight_waypoint_list.update_list() self.flight_waypoint_list.update_list()

View File

@ -1,18 +1,50 @@
import logging import logging
from typing import Callable
from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint
from PySide2.QtGui import QStandardItemModel, QStandardItem from PySide2.QtGui import QStandardItemModel, QStandardItem
from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ from PySide2.QtWidgets import (
QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox QLabel,
QDialog,
QGridLayout,
QListView,
QStackedLayout,
QComboBox,
QWidget,
QAbstractItemView,
QPushButton,
QGroupBox,
QCheckBox,
QVBoxLayout,
QSpinBox,
)
from dcs.forcedoptions import ForcedOptions from dcs.forcedoptions import ForcedOptions
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game.game import Game from game.game import Game
from game.infos.information import Information from game.infos.information import Information
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine
from plugin import LuaPluginManager 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): class QSettingsWindow(QDialog):
def __init__(self, game: Game): def __init__(self, game: Game):
@ -262,9 +294,12 @@ class QSettingsWindow(QDialog):
def initCheatLayout(self): def initCheatLayout(self):
self.cheatPage = QWidget() self.cheatPage = QWidget()
self.cheatLayout = QGridLayout() self.cheatLayout = QVBoxLayout()
self.cheatPage.setLayout(self.cheatLayout) 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 = QGroupBox("Money Cheat")
self.moneyCheatBox.setAlignment(Qt.AlignTop) self.moneyCheatBox.setAlignment(Qt.AlignTop)
self.moneyCheatBoxLayout = QGridLayout() self.moneyCheatBoxLayout = QGridLayout()
@ -280,7 +315,7 @@ class QSettingsWindow(QDialog):
btn.setProperty("style", "btn-danger") btn.setProperty("style", "btn-danger")
btn.clicked.connect(self.cheatLambda(amount)) btn.clicked.connect(self.cheatLambda(amount))
self.moneyCheatBoxLayout.addWidget(btn, i/2, i%2) 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): def initPluginsLayout(self):
uiPrepared = False uiPrepared = False
@ -332,8 +367,6 @@ class QSettingsWindow(QDialog):
self.game.settings.external_views_allowed = self.ext_views.isChecked() self.game.settings.external_views_allowed = self.ext_views.isChecked()
self.game.settings.generate_marks = self.generate_marks.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.supercarrier = self.supercarrier.isChecked()
self.game.settings.perf_red_alert_state = self.red_alert.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 = self.culling.isChecked()
self.game.settings.perf_culling_distance = int(self.culling_distance.value()) 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) GameUpdateSignal.get_instance().updateGame(self.game)
def onSelectionChanged(self): def onSelectionChanged(self):

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,15 +1,15 @@
import pickle import pickle
from dcs.mission import Mission 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) print("Terrain " + terrain)
m = Mission() m = Mission()
m.load_file("./{}_terrain.miz".format(terrain)) m.load_file("./{}_terrain.miz".format(terrain))
inclusion_zones = [] inclusion_zones = []
exclusion_zones = [] exclusion_zones = []
seas_zones = []
for plane_group in m.country("USA").plane_group: for plane_group in m.country("USA").plane_group:
zone = [(x.position.x, x.position.y) for x in plane_group.points] zone = [(x.position.x, x.position.y) for x in plane_group.points]
@ -22,6 +22,10 @@ for terrain in ["cau"]:
else: else:
inclusion_zones.append(zone) 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: with open("../{}landmap.p".format(terrain), "wb") as f:
print(len(inclusion_zones), len(exclusion_zones)) print(len(inclusion_zones), len(exclusion_zones), len(seas_zones))
pickle.dump((inclusion_zones, exclusion_zones), f) pickle.dump((inclusion_zones, exclusion_zones, seas_zones), f)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -95,11 +95,14 @@ class ConflictTheater:
if not self.landmap: if not self.landmap:
return False return False
for inclusion_zone in self.landmap[0]: if self.is_on_land(point):
if poly_contains(point.x, point.y, inclusion_zone): return False
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: def is_on_land(self, point: Point) -> bool:
if not self.landmap: if not self.landmap: