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

View File

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

View File

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

View File

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

View File

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

View File

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

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 ?
@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

View File

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

View File

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

View File

@ -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]

View File

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

View File

@ -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__':

View File

@ -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.

View File

@ -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,

View File

@ -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.

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:
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)

View File

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

View File

@ -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 = '<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):
"""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

View File

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

View File

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

View File

@ -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"])

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ from game.game import Game
from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.flightplan import FlightPlanBuilder
from gen.flights.traveltime import TotEstimator
from qt_ui.models import AtoModel, PackageModel
from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.ato import QFlightList
@ -34,12 +35,6 @@ class QPackageDialog(QDialog):
#: Emitted when a change is made to the package.
package_changed = Signal()
#: Emitted when a flight is added to the package.
flight_added = Signal(Flight)
#: Emitted when a flight is removed from the package.
flight_removed = Signal(Flight)
def __init__(self, game: Game, model: PackageModel) -> None:
super().__init__()
self.game = game
@ -77,20 +72,20 @@ class QPackageDialog(QDialog):
self.tot_label = QLabel("Time Over Target:")
self.tot_column.addWidget(self.tot_label)
if self.package_model.package.time_over_target is None:
time = None
else:
delay = self.package_model.package.time_over_target
hours = delay // 3600
minutes = delay // 60 % 60
seconds = delay % 60
time = QTime(hours, minutes, seconds)
self.tot_spinner = QTimeEdit(time)
self.tot_spinner = QTimeEdit(self.tot_qtime())
self.tot_spinner.setMinimumTime(QTime(0, 0))
self.tot_spinner.setDisplayFormat("T+hh:mm:ss")
self.tot_spinner.timeChanged.connect(self.save_tot)
self.tot_column.addWidget(self.tot_spinner)
self.reset_tot_button = QPushButton("Reset TOT")
self.reset_tot_button.setToolTip(
"Sets the package TOT to the earliest time that all flights can "
"arrive at the target."
)
self.reset_tot_button.clicked.connect(self.reset_tot)
self.tot_column.addWidget(self.reset_tot_button)
self.package_view = QFlightList(self.package_model)
self.package_view.selectionModel().selectionChanged.connect(
self.on_selection_changed
@ -114,17 +109,40 @@ class QPackageDialog(QDialog):
self.setLayout(self.layout)
self.accepted.connect(self.on_save)
self.finished.connect(self.on_close)
self.rejected.connect(self.on_cancel)
def tot_qtime(self) -> QTime:
delay = self.package_model.package.time_over_target
hours = delay // 3600
minutes = delay // 60 % 60
seconds = delay % 60
return QTime(hours, minutes, seconds)
def on_cancel(self) -> None:
pass
@staticmethod
def on_close(_result) -> None:
GameUpdateSignal.get_instance().redraw_flight_paths()
def on_save(self) -> None:
self.save_tot()
def save_tot(self) -> None:
time = self.tot_spinner.time()
seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
self.package_model.update_tot(seconds)
def reset_tot(self) -> None:
if not list(self.package_model.flights):
self.package_model.update_tot(0)
else:
self.package_model.update_tot(
TotEstimator(self.package_model.package).earliest_tot())
self.tot_spinner.setTime(self.tot_qtime())
def on_selection_changed(self, selected: QItemSelection,
_deselected: QItemSelection) -> None:
"""Updates the state of the delete button."""
@ -139,14 +157,13 @@ class QPackageDialog(QDialog):
def add_flight(self, flight: Flight) -> None:
"""Adds the new flight to the package."""
self.game.aircraft_inventory.claim_for_flight(flight)
self.package_model.add_flight(flight)
planner = FlightPlanBuilder(self.game, self.package_model.package,
is_player=True)
planner.populate_flight_plan(flight)
# noinspection PyUnresolvedReferences
self.package_changed.emit()
# noinspection PyUnresolvedReferences
self.flight_added.emit(flight)
def on_delete_flight(self) -> None:
"""Removes the selected flight from the package."""
@ -154,11 +171,10 @@ class QPackageDialog(QDialog):
if flight is None:
logging.error(f"Cannot delete flight when no flight is selected.")
return
self.game.aircraft_inventory.return_from_flight(flight)
self.package_model.delete_flight(flight)
# noinspection PyUnresolvedReferences
self.package_changed.emit()
# noinspection PyUnresolvedReferences
self.flight_removed.emit(flight)
class QNewPackageDialog(QPackageDialog):
@ -174,22 +190,22 @@ class QNewPackageDialog(QPackageDialog):
self.save_button = QPushButton("Save")
self.save_button.setProperty("style", "start-button")
self.save_button.clicked.connect(self.on_save)
self.save_button.clicked.connect(self.accept)
self.button_layout.addWidget(self.save_button)
self.delete_flight_button.clicked.connect(self.on_delete_flight)
def on_save(self) -> None:
"""Saves the created package.
Empty packages may be created. They can be modified later, and will have
no effect if empty when the mission is generated.
"""
self.save_tot()
super().on_save()
self.ato_model.add_package(self.package_model.package)
def on_cancel(self) -> None:
super().on_cancel()
for flight in self.package_model.package.flights:
self.game.aircraft_inventory.claim_for_flight(flight)
self.close()
self.game_model.game.aircraft_inventory.return_from_flight(flight)
class QEditPackageDialog(QPackageDialog):
@ -210,30 +226,9 @@ class QEditPackageDialog(QPackageDialog):
self.done_button = QPushButton("Done")
self.done_button.setProperty("style", "start-button")
self.done_button.clicked.connect(self.on_done)
self.done_button.clicked.connect(self.accept)
self.button_layout.addWidget(self.done_button)
# noinspection PyUnresolvedReferences
self.flight_added.connect(self.on_flight_added)
# noinspection PyUnresolvedReferences
self.flight_removed.connect(self.on_flight_removed)
# TODO: Make the new package dialog do this too, return on cancel.
# Not claiming the aircraft when they are added to the planner means that
# inventory counts are not updated until after the new package is updated,
# so you can add an infinite number of aircraft to a new package in the UI,
# which will crash when the flight package is saved.
def on_flight_added(self, flight: Flight) -> None:
self.game.aircraft_inventory.claim_for_flight(flight)
def on_flight_removed(self, flight: Flight) -> None:
self.game.aircraft_inventory.return_from_flight(flight)
def on_done(self) -> None:
"""Closes the window."""
self.save_tot()
self.close()
def on_delete(self) -> None:
"""Removes the viewed package from the ATO."""
# The ATO model returns inventory for us when deleting a package.

View File

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

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

View File

@ -54,6 +54,7 @@ class QFlightWaypointTab(QFrame):
rlayout.addWidget(QLabel("<strong>Generator :</strong>"))
rlayout.addWidget(QLabel("<small>AI compatible</small>"))
# TODO: Filter by objective type.
self.recreate_buttons.clear()
recreate_types = [
FlightType.CAS,
@ -137,13 +138,16 @@ class QFlightWaypointTab(QFrame):
QMessageBox.Yes
)
if result == QMessageBox.Yes:
# TODO: These should all be just CAP.
# TODO: Should be buttons for both BARCAP and TARCAP.
# BARCAP and TARCAP behave differently. TARCAP arrives a few minutes
# ahead of the rest of the package and stays until the package
# departs, whereas BARCAP usually isn't part of a strike package and
# has a fixed mission time.
if task == FlightType.CAP:
if isinstance(self.package.target, FrontLine):
task = FlightType.TARCAP
elif isinstance(self.package.target, ControlPoint):
if self.package.target.is_fleet:
task = FlightType.BARCAP
task = FlightType.BARCAP
self.flight.flight_type = task
self.planner.populate_flight_plan(self.flight)
self.flight_waypoint_list.update_list()

View File

@ -1,18 +1,50 @@
import logging
from typing import Callable
from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint
from PySide2.QtGui import QStandardItemModel, QStandardItem
from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \
QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox
from PySide2.QtWidgets import (
QLabel,
QDialog,
QGridLayout,
QListView,
QStackedLayout,
QComboBox,
QWidget,
QAbstractItemView,
QPushButton,
QGroupBox,
QCheckBox,
QVBoxLayout,
QSpinBox,
)
from dcs.forcedoptions import ForcedOptions
import qt_ui.uiconstants as CONST
from game.game import Game
from game.infos.information import Information
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine
from plugin import LuaPluginManager
class CheatSettingsBox(QGroupBox):
def __init__(self, game: Game, apply_settings: Callable[[], None]) -> None:
super().__init__("Cheat Settings")
self.main_layout = QVBoxLayout()
self.setLayout(self.main_layout)
self.red_ato_checkbox = QCheckBox()
self.red_ato_checkbox.setChecked(game.settings.show_red_ato)
self.red_ato_checkbox.toggled.connect(apply_settings)
self.red_ato = QLabeledWidget("Show Red ATO:", self.red_ato_checkbox)
self.main_layout.addLayout(self.red_ato)
@property
def show_red_ato(self) -> bool:
return self.red_ato_checkbox.isChecked()
class QSettingsWindow(QDialog):
def __init__(self, game: Game):
@ -262,9 +294,12 @@ class QSettingsWindow(QDialog):
def initCheatLayout(self):
self.cheatPage = QWidget()
self.cheatLayout = QGridLayout()
self.cheatLayout = QVBoxLayout()
self.cheatPage.setLayout(self.cheatLayout)
self.cheat_options = CheatSettingsBox(self.game, self.applySettings)
self.cheatLayout.addWidget(self.cheat_options)
self.moneyCheatBox = QGroupBox("Money Cheat")
self.moneyCheatBox.setAlignment(Qt.AlignTop)
self.moneyCheatBoxLayout = QGridLayout()
@ -280,7 +315,7 @@ class QSettingsWindow(QDialog):
btn.setProperty("style", "btn-danger")
btn.clicked.connect(self.cheatLambda(amount))
self.moneyCheatBoxLayout.addWidget(btn, i/2, i%2)
self.cheatLayout.addWidget(self.moneyCheatBox, 0, 0)
self.cheatLayout.addWidget(self.moneyCheatBox, stretch=1)
def initPluginsLayout(self):
uiPrepared = False
@ -332,8 +367,6 @@ class QSettingsWindow(QDialog):
self.game.settings.external_views_allowed = self.ext_views.isChecked()
self.game.settings.generate_marks = self.generate_marks.isChecked()
print(self.game.settings.map_coalition_visibility)
self.game.settings.supercarrier = self.supercarrier.isChecked()
self.game.settings.perf_red_alert_state = self.red_alert.isChecked()
@ -347,6 +380,8 @@ class QSettingsWindow(QDialog):
self.game.settings.perf_culling = self.culling.isChecked()
self.game.settings.perf_culling_distance = int(self.culling_distance.value())
self.game.settings.show_red_ato = self.cheat_options.show_red_ato
GameUpdateSignal.get_instance().updateGame(self.game)
def onSelectionChanged(self):

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -95,11 +95,14 @@ class ConflictTheater:
if not self.landmap:
return False
for inclusion_zone in self.landmap[0]:
if poly_contains(point.x, point.y, inclusion_zone):
return False
if self.is_on_land(point):
return False
return True
for sea in self.landmap[2]:
if poly_contains(point.x, point.y, sea):
return True
return False
def is_on_land(self, point: Point) -> bool:
if not self.landmap: