mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Merge branch 'dcs-liberation:develop' into develop
This commit is contained in:
commit
e5a40bfb69
12
changelog.md
12
changelog.md
@ -1,15 +1,23 @@
|
||||
# 5.0.0
|
||||
|
||||
Saves from 3.x are not compatible with 5.0.
|
||||
Saves from 4.x are not compatible with 5.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
|
||||
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
|
||||
* **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions.
|
||||
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
|
||||
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
|
||||
* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
|
||||
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
|
||||
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet).
|
||||
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
|
||||
|
||||
# 4.1.0
|
||||
|
||||
Saves from 4.0.0 are compatible with 4.1.0.
|
||||
@ -19,6 +27,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
|
||||
* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
|
||||
* **[Campaign]** Added support for Mariana Islands map.
|
||||
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
|
||||
* **[Mission Generation]** SAM sites are now headed towards the center of the conflict
|
||||
* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
|
||||
* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page.
|
||||
* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet).
|
||||
@ -34,6 +43,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
|
||||
* **[Mission Generation]** The lua data for other plugins is now generated correctly
|
||||
* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
|
||||
* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation.
|
||||
* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
|
||||
* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
|
||||
* **[UI]** Statistics window tick marks are now always integers.
|
||||
* **[UI]** Statistics window now shows the correct info for the turn
|
||||
|
||||
80
doc/fuel-consumption-measurement.md
Normal file
80
doc/fuel-consumption-measurement.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Measuring estimated fuel consumption
|
||||
|
||||
To estimate fuel consumption numbers for an aircraft, create a mission with a
|
||||
typical heavy load for the aircraft. For example, to measure for the F/A-18C, a
|
||||
loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR.
|
||||
Do **not** drop bags or weapons during the test flight.
|
||||
|
||||
Start the aircraft on the ground at a large airport (for example, Akrotiri) at a
|
||||
parking space at the opposite end of the takeoff runway so you can estimate long
|
||||
taxi fuel consumption.
|
||||
|
||||
When you enter the jet, note the amount of fuel below, then taxi to the far end
|
||||
of the runway. Hold short and note the remaining fuel below.
|
||||
|
||||
Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might
|
||||
be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until
|
||||
cruise altitude (angles 25).
|
||||
|
||||
Once you reach angels 25, pause the game. Note your remaining fuel below and
|
||||
measure the distance traveled from takeoff. Mark your location on the map.
|
||||
|
||||
Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach
|
||||
for supersonic aircraft, for subsonic aircraft it depends so pick something
|
||||
reasonable and note your descision in a comment in the file when done. Maintain
|
||||
speed, heading, and altitude for a long distance (the longer the distance, the
|
||||
more accurate the result, but be careful to leave enough fuel for the final
|
||||
section). Once complete, note the distance traveled and the remaining fuel.
|
||||
|
||||
Finally, increase speed as you would for an attack. At least MIL power,
|
||||
potentially use AB sparingly, etc. The goal is to measure fuel consumption per
|
||||
mile traveled during an attack run.
|
||||
|
||||
```
|
||||
start:
|
||||
taxi end:
|
||||
to 25k distance:
|
||||
at 25k fuel:
|
||||
cruise (.85 mach) distance:
|
||||
cruise (.85 mach) end fuel:
|
||||
combat distance:
|
||||
combat end fuel:
|
||||
```
|
||||
|
||||
Finally, fill out the data in the aircraft data. Below is an example for the
|
||||
F/A-18C:
|
||||
|
||||
```
|
||||
start: 15290
|
||||
taxi end: 15120
|
||||
climb distance: 40NM
|
||||
at 25k fuel: 13350
|
||||
cruise (.85 mach) distance: 100NM
|
||||
cruise (.85 mach) end fuel: 11140
|
||||
combat distance: 100NM
|
||||
combat end fuel: 8390
|
||||
|
||||
taxi = start - taxi end = 15290 - 15120 = 170
|
||||
climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770
|
||||
climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25
|
||||
cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210
|
||||
cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1
|
||||
combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750
|
||||
combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5
|
||||
```
|
||||
|
||||
```yaml
|
||||
fuel:
|
||||
# Parking A1 to RWY 32 at Akrotiri.
|
||||
taxi: 170
|
||||
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
|
||||
climb_ppm: 44.25
|
||||
# 0.85 mach for 100NM.
|
||||
cruise_ppm: 22.1
|
||||
# ~0.9 mach for 100NM. Occasional AB use.
|
||||
combat_ppm: 27.5
|
||||
min_safe: 2000
|
||||
```
|
||||
|
||||
The last entry (`min_safe`) is the minimum amount of fuel that the aircraft
|
||||
should land with.
|
||||
@ -150,6 +150,13 @@ class Coalition:
|
||||
# is handled correctly.
|
||||
self.transfers.perform_transfers()
|
||||
|
||||
def preinit_turn_0(self) -> None:
|
||||
"""Runs final Coalition initialization.
|
||||
|
||||
Final initialization occurs before Game.initialize_turn runs for turn 0.
|
||||
"""
|
||||
self.air_wing.populate_for_turn_0()
|
||||
|
||||
def initialize_turn(self) -> None:
|
||||
"""Processes coalition-specific turn initialization.
|
||||
|
||||
|
||||
@ -3,7 +3,8 @@ from typing import Optional, Tuple
|
||||
from game.commander.missionproposals import ProposedFlight
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.squadrons import AirWing, Squadron
|
||||
from game.theater import ControlPoint
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.utils import meters
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
from gen.flights.closestairfields import ClosestAirfields
|
||||
from gen.flights.flight import FlightType
|
||||
@ -25,7 +26,7 @@ class AircraftAllocator:
|
||||
self.is_player = is_player
|
||||
|
||||
def find_squadron_for_flight(
|
||||
self, flight: ProposedFlight
|
||||
self, target: MissionTarget, flight: ProposedFlight
|
||||
) -> Optional[Tuple[ControlPoint, Squadron]]:
|
||||
"""Finds aircraft suitable for the given mission.
|
||||
|
||||
@ -45,17 +46,13 @@ class AircraftAllocator:
|
||||
on subsequent calls. If the found aircraft are not used, the caller is
|
||||
responsible for returning them to the inventory.
|
||||
"""
|
||||
return self.find_aircraft_for_task(flight, flight.task)
|
||||
return self.find_aircraft_for_task(target, flight, flight.task)
|
||||
|
||||
def find_aircraft_for_task(
|
||||
self, flight: ProposedFlight, task: FlightType
|
||||
self, target: MissionTarget, flight: ProposedFlight, task: FlightType
|
||||
) -> Optional[Tuple[ControlPoint, Squadron]]:
|
||||
types = aircraft_for_task(task)
|
||||
airfields_in_range = self.closest_airfields.operational_airfields_within(
|
||||
flight.max_distance
|
||||
)
|
||||
|
||||
for airfield in airfields_in_range:
|
||||
for airfield in self.closest_airfields.operational_airfields:
|
||||
if not airfield.is_friendly(self.is_player):
|
||||
continue
|
||||
inventory = self.global_inventory.for_control_point(airfield)
|
||||
@ -64,13 +61,18 @@ class AircraftAllocator:
|
||||
continue
|
||||
if inventory.available(aircraft) < flight.num_aircraft:
|
||||
continue
|
||||
distance_to_target = meters(target.distance_to(airfield))
|
||||
if distance_to_target > aircraft.max_mission_range:
|
||||
continue
|
||||
# Valid location with enough aircraft available. Find a squadron to fit
|
||||
# the role.
|
||||
squadrons = self.air_wing.auto_assignable_for_task_with_type(
|
||||
aircraft, task
|
||||
)
|
||||
for squadron in squadrons:
|
||||
if squadron.can_provide_pilots(flight.num_aircraft):
|
||||
if squadron.operates_from(airfield) and squadron.can_provide_pilots(
|
||||
flight.num_aircraft
|
||||
):
|
||||
inventory.remove_aircraft(aircraft, flight.num_aircraft)
|
||||
return airfield, squadron
|
||||
return None
|
||||
|
||||
@ -3,7 +3,6 @@ from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from game.theater import MissionTarget
|
||||
from game.utils import Distance
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@ -27,9 +26,6 @@ class ProposedFlight:
|
||||
#: The number of aircraft required.
|
||||
num_aircraft: int
|
||||
|
||||
#: The maximum distance between the objective and the departure airfield.
|
||||
max_distance: Distance
|
||||
|
||||
#: The type of threat this flight defends against if it is an escort. Escort
|
||||
#: flights will be pruned if the rest of the package is not threatened by
|
||||
#: the threat they defend against. If this flight is not an escort, this
|
||||
|
||||
@ -44,7 +44,7 @@ class PackageBuilder:
|
||||
caller should return any previously planned flights to the inventory
|
||||
using release_planned_aircraft.
|
||||
"""
|
||||
assignment = self.allocator.find_squadron_for_flight(plan)
|
||||
assignment = self.allocator.find_squadron_for_flight(self.package.target, plan)
|
||||
if assignment is None:
|
||||
return False
|
||||
airfield, squadron = assignment
|
||||
|
||||
@ -83,7 +83,6 @@ class PackageFulfiller:
|
||||
missing_types.add(flight.task)
|
||||
purchase_order = AircraftProcurementRequest(
|
||||
near=mission.location,
|
||||
range=flight.max_distance,
|
||||
task_capability=flight.task,
|
||||
number=flight.num_aircraft,
|
||||
)
|
||||
|
||||
@ -59,28 +59,23 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
coalition.ato.add_package(self.package)
|
||||
|
||||
@abstractmethod
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
def propose_flights(self) -> None:
|
||||
...
|
||||
|
||||
def propose_flight(
|
||||
self,
|
||||
task: FlightType,
|
||||
num_aircraft: int,
|
||||
max_distance: Optional[Distance],
|
||||
escort_type: Optional[EscortType] = None,
|
||||
) -> None:
|
||||
if max_distance is None:
|
||||
max_distance = Distance.inf()
|
||||
self.flights.append(
|
||||
ProposedFlight(task, num_aircraft, max_distance, escort_type)
|
||||
)
|
||||
self.flights.append(ProposedFlight(task, num_aircraft, escort_type))
|
||||
|
||||
@property
|
||||
def asap(self) -> bool:
|
||||
return False
|
||||
|
||||
def fulfill_mission(self, state: TheaterState) -> bool:
|
||||
self.propose_flights(state.context.coalition.doctrine)
|
||||
self.propose_flights()
|
||||
fulfiller = PackageFulfiller(
|
||||
state.context.coalition,
|
||||
state.context.theater,
|
||||
@ -92,20 +87,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
)
|
||||
return self.package is not None
|
||||
|
||||
def propose_common_escorts(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(
|
||||
FlightType.SEAD_ESCORT,
|
||||
2,
|
||||
doctrine.mission_ranges.offensive,
|
||||
EscortType.Sead,
|
||||
)
|
||||
|
||||
self.propose_flight(
|
||||
FlightType.ESCORT,
|
||||
2,
|
||||
doctrine.mission_ranges.offensive,
|
||||
EscortType.AirToAir,
|
||||
)
|
||||
def propose_common_escorts(self) -> None:
|
||||
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
|
||||
def iter_iads_ranges(
|
||||
self, state: TheaterState, range_type: RangeType
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater import MissionTarget
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -19,8 +18,8 @@ class PlanAewc(PackagePlanningTask[MissionTarget]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.aewc_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.AEWC, 1, doctrine.mission_ranges.aewc)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.AEWC, 1)
|
||||
|
||||
@property
|
||||
def asap(self) -> bool:
|
||||
|
||||
@ -5,7 +5,6 @@ from dataclasses import dataclass
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater.theatergroundobject import NavalGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -22,11 +21,6 @@ class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.eliminate_ship(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive)
|
||||
self.propose_flight(
|
||||
FlightType.ESCORT,
|
||||
2,
|
||||
doctrine.mission_ranges.offensive,
|
||||
EscortType.AirToAir,
|
||||
)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.ANTISHIP, 2)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.transfers import CargoShip
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -21,6 +20,6 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.enemy_shipping.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive)
|
||||
self.propose_common_escorts(doctrine)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.ANTISHIP, 2)
|
||||
self.propose_common_escorts()
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater.theatergroundobject import VehicleGroupGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -21,6 +20,6 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.eliminate_garrison(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive)
|
||||
self.propose_common_escorts(doctrine)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.BAI, 2)
|
||||
self.propose_common_escorts()
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater import ControlPoint
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -19,5 +18,5 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.barcaps_needed[self.target] -= 1
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.BARCAP, 2, doctrine.mission_ranges.cap)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.BARCAP, 2)
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater import FrontLine
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -19,6 +18,6 @@ class PlanCas(PackagePlanningTask[FrontLine]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.vulnerable_front_lines.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.CAS, 2, doctrine.mission_ranges.cas)
|
||||
self.propose_flight(FlightType.TARCAP, 2, doctrine.mission_ranges.cap)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.CAS, 2)
|
||||
self.propose_flight(FlightType.TARCAP, 2)
|
||||
|
||||
@ -21,6 +21,6 @@ class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.enemy_convoys.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive)
|
||||
self.propose_common_escorts(doctrine)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.BAI, 2)
|
||||
self.propose_common_escorts()
|
||||
|
||||
@ -5,7 +5,6 @@ from dataclasses import dataclass
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater.theatergroundobject import IadsGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -25,8 +24,8 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.eliminate_air_defense(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.DEAD, 2)
|
||||
|
||||
# Only include SEAD against SAMs that still have emitters. No need to
|
||||
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
|
||||
@ -41,18 +40,7 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]):
|
||||
# package is *only* threatened by the target though. Could be improved, but
|
||||
# needs a decent refactor to the escort planning to do so.
|
||||
if self.target.has_live_radar_sam:
|
||||
self.propose_flight(FlightType.SEAD, 2, doctrine.mission_ranges.offensive)
|
||||
self.propose_flight(FlightType.SEAD, 2)
|
||||
else:
|
||||
self.propose_flight(
|
||||
FlightType.SEAD_ESCORT,
|
||||
2,
|
||||
doctrine.mission_ranges.offensive,
|
||||
EscortType.Sead,
|
||||
)
|
||||
|
||||
self.propose_flight(
|
||||
FlightType.ESCORT,
|
||||
2,
|
||||
doctrine.mission_ranges.offensive,
|
||||
EscortType.AirToAir,
|
||||
)
|
||||
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater import ControlPoint
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -23,10 +22,8 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.oca_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.OCA_RUNWAY, 2, doctrine.mission_ranges.offensive)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.OCA_RUNWAY, 2)
|
||||
if self.aircraft_cold_start:
|
||||
self.propose_flight(
|
||||
FlightType.OCA_AIRCRAFT, 2, doctrine.mission_ranges.offensive
|
||||
)
|
||||
self.propose_common_escorts(doctrine)
|
||||
self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
|
||||
self.propose_common_escorts()
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater import MissionTarget
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -19,5 +18,5 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.refueling_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.REFUELING, 1, doctrine.mission_ranges.refueling)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.REFUELING, 1)
|
||||
|
||||
@ -5,7 +5,6 @@ from typing import Any
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -22,6 +21,6 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]):
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.strike_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self, doctrine: Doctrine) -> None:
|
||||
self.propose_flight(FlightType.STRIKE, 2, doctrine.mission_ranges.offensive)
|
||||
self.propose_common_escorts(doctrine)
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.STRIKE, 2)
|
||||
self.propose_common_escorts()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@ -18,15 +18,6 @@ class GroundUnitProcurementRatios:
|
||||
return 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionPlannerMaxRanges:
|
||||
cap: Distance = field(default=nautical_miles(100))
|
||||
cas: Distance = field(default=nautical_miles(50))
|
||||
offensive: Distance = field(default=nautical_miles(150))
|
||||
aewc: Distance = field(default=Distance.inf())
|
||||
refueling: Distance = field(default=nautical_miles(200))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Doctrine:
|
||||
cas: bool
|
||||
@ -47,8 +38,13 @@ class Doctrine:
|
||||
#: fallback flight plan layout (when the departure airfield is near a threat zone).
|
||||
join_distance: Distance
|
||||
|
||||
#: The distance between the ingress point (beginning of the attack) and target.
|
||||
ingress_distance: Distance
|
||||
#: The maximum distance between the ingress point (beginning of the attack) and
|
||||
#: target.
|
||||
max_ingress_distance: Distance
|
||||
|
||||
#: The minimum distance between the ingress point (beginning of the attack) and
|
||||
#: target.
|
||||
min_ingress_distance: Distance
|
||||
|
||||
ingress_altitude: Distance
|
||||
|
||||
@ -83,16 +79,33 @@ class Doctrine:
|
||||
|
||||
ground_unit_procurement_ratios: GroundUnitProcurementRatios
|
||||
|
||||
mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges())
|
||||
|
||||
@has_save_compat_for(5)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "ingress_distance" not in state:
|
||||
state["ingress_distance"] = state["ingress_egress_distance"]
|
||||
del state["ingress_egress_distance"]
|
||||
if "max_ingress_distance" not in state:
|
||||
try:
|
||||
state["max_ingress_distance"] = state["ingress_distance"]
|
||||
del state["ingress_distance"]
|
||||
except KeyError:
|
||||
state["max_ingress_distance"] = state["ingress_egress_distance"]
|
||||
del state["ingress_egress_distance"]
|
||||
|
||||
max_ip: Distance = state["max_ingress_distance"]
|
||||
if "min_ingress_distance" not in state:
|
||||
if max_ip < nautical_miles(10):
|
||||
min_ip = nautical_miles(5)
|
||||
else:
|
||||
min_ip = nautical_miles(10)
|
||||
state["min_ingress_distance"] = min_ip
|
||||
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
class MissionPlannerMaxRanges:
|
||||
@has_save_compat_for(5)
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
MODERN_DOCTRINE = Doctrine(
|
||||
cap=True,
|
||||
cas=True,
|
||||
@ -100,10 +113,11 @@ MODERN_DOCTRINE = Doctrine(
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(25000),
|
||||
hold_distance=nautical_miles(15),
|
||||
hold_distance=nautical_miles(25),
|
||||
push_distance=nautical_miles(20),
|
||||
join_distance=nautical_miles(20),
|
||||
ingress_distance=nautical_miles(45),
|
||||
max_ingress_distance=nautical_miles(45),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(20000),
|
||||
min_patrol_altitude=feet(15000),
|
||||
max_patrol_altitude=feet(33000),
|
||||
@ -136,10 +150,11 @@ COLDWAR_DOCTRINE = Doctrine(
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(22000),
|
||||
hold_distance=nautical_miles(10),
|
||||
hold_distance=nautical_miles(15),
|
||||
push_distance=nautical_miles(10),
|
||||
join_distance=nautical_miles(10),
|
||||
ingress_distance=nautical_miles(30),
|
||||
max_ingress_distance=nautical_miles(30),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(18000),
|
||||
min_patrol_altitude=feet(10000),
|
||||
max_patrol_altitude=feet(24000),
|
||||
@ -171,11 +186,12 @@ WWII_DOCTRINE = Doctrine(
|
||||
sead=False,
|
||||
strike=True,
|
||||
antiship=True,
|
||||
hold_distance=nautical_miles(5),
|
||||
hold_distance=nautical_miles(10),
|
||||
push_distance=nautical_miles(5),
|
||||
join_distance=nautical_miles(5),
|
||||
rendezvous_altitude=feet(10000),
|
||||
ingress_distance=nautical_miles(7),
|
||||
max_ingress_distance=nautical_miles(7),
|
||||
min_ingress_distance=nautical_miles(5),
|
||||
ingress_altitude=feet(8000),
|
||||
min_patrol_altitude=feet(4000),
|
||||
max_patrol_altitude=feet(15000),
|
||||
|
||||
@ -4,6 +4,7 @@ import datetime
|
||||
import inspect
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from enum import unique, Enum
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Any, ClassVar
|
||||
@ -61,7 +62,7 @@ class Weapon:
|
||||
duplicate = cls._by_clsid[weapon.clsid]
|
||||
raise ValueError(
|
||||
"Weapon CLSID used in more than one weapon type: "
|
||||
f"{duplicate.name} and {weapon.name}"
|
||||
f"{duplicate.name} and {weapon.name}: {weapon.clsid}"
|
||||
)
|
||||
cls._by_clsid[weapon.clsid] = weapon
|
||||
|
||||
@ -91,6 +92,13 @@ class Weapon:
|
||||
fallback = fallback.fallback
|
||||
|
||||
|
||||
@unique
|
||||
class WeaponType(Enum):
|
||||
LGB = "LGB"
|
||||
TGP = "TGP"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeaponGroup:
|
||||
"""Group of "identical" weapons loaded from resources/weapons.
|
||||
@ -101,7 +109,10 @@ class WeaponGroup:
|
||||
"""
|
||||
|
||||
#: The name of the weapon group in the resource file.
|
||||
name: str = field(compare=False)
|
||||
name: str
|
||||
|
||||
#: The type of the weapon group.
|
||||
type: WeaponType = field(compare=False)
|
||||
|
||||
#: The year of introduction.
|
||||
introduction_year: Optional[int] = field(compare=False)
|
||||
@ -152,9 +163,13 @@ class WeaponGroup:
|
||||
with group_file_path.open(encoding="utf8") as group_file:
|
||||
data = yaml.safe_load(group_file)
|
||||
name = data["name"]
|
||||
try:
|
||||
weapon_type = WeaponType(data["type"])
|
||||
except KeyError:
|
||||
weapon_type = WeaponType.UNKNOWN
|
||||
year = data.get("year")
|
||||
fallback_name = data.get("fallback")
|
||||
group = WeaponGroup(name, year, fallback_name)
|
||||
group = WeaponGroup(name, weapon_type, year, fallback_name)
|
||||
for clsid in data["clsids"]:
|
||||
weapon = Weapon(clsid, group)
|
||||
Weapon.register(weapon)
|
||||
@ -163,7 +178,12 @@ class WeaponGroup:
|
||||
|
||||
@classmethod
|
||||
def register_clean_pylon(cls) -> None:
|
||||
group = WeaponGroup("Clean pylon", introduction_year=None, fallback_name=None)
|
||||
group = WeaponGroup(
|
||||
"Clean pylon",
|
||||
type=WeaponType.UNKNOWN,
|
||||
introduction_year=None,
|
||||
fallback_name=None,
|
||||
)
|
||||
cls.register(group)
|
||||
weapon = Weapon("<CLEAN>", group)
|
||||
Weapon.register(weapon)
|
||||
@ -172,7 +192,12 @@ class WeaponGroup:
|
||||
@classmethod
|
||||
def register_unknown_weapons(cls, seen_clsids: set[str]) -> None:
|
||||
unknown_weapons = set(weapon_ids.keys()) - seen_clsids
|
||||
group = WeaponGroup("Unknown", introduction_year=None, fallback_name=None)
|
||||
group = WeaponGroup(
|
||||
"Unknown",
|
||||
type=WeaponType.UNKNOWN,
|
||||
introduction_year=None,
|
||||
fallback_name=None,
|
||||
)
|
||||
cls.register(group)
|
||||
for clsid in unknown_weapons:
|
||||
weapon = Weapon(clsid, group)
|
||||
@ -181,6 +206,8 @@ class WeaponGroup:
|
||||
|
||||
@classmethod
|
||||
def load_all(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
seen_clsids: set[str] = set()
|
||||
for group in cls._each_weapon_group():
|
||||
cls.register(group)
|
||||
|
||||
@ -29,7 +29,7 @@ from game.radio.channels import (
|
||||
ViggenRadioChannelAllocator,
|
||||
NoOpChannelAllocator,
|
||||
)
|
||||
from game.utils import Distance, Speed, feet, kph, knots
|
||||
from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.aircraft import FlightData
|
||||
@ -105,6 +105,35 @@ class PatrolConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FuelConsumption:
|
||||
#: The estimated taxi fuel requirement, in pounds.
|
||||
taxi: int
|
||||
|
||||
#: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile.
|
||||
climb: float
|
||||
|
||||
#: The estimated fuel consumption for cruising, in pounds per nautical mile.
|
||||
cruise: float
|
||||
|
||||
#: The estimated fuel consumption for combat speeds, in pounds per nautical mile.
|
||||
combat: float
|
||||
|
||||
#: The minimum amount of fuel that the aircraft should land with, in pounds. This is
|
||||
#: a reserve amount for landing delays or emergencies.
|
||||
min_safe: int
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> FuelConsumption:
|
||||
return FuelConsumption(
|
||||
int(data["taxi"]),
|
||||
float(data["climb_ppm"]),
|
||||
float(data["cruise_ppm"]),
|
||||
float(data["combat_ppm"]),
|
||||
int(data["min_safe"]),
|
||||
)
|
||||
|
||||
|
||||
# TODO: Split into PlaneType and HelicopterType?
|
||||
@dataclass(frozen=True)
|
||||
class AircraftType(UnitType[Type[FlyingType]]):
|
||||
@ -112,13 +141,20 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
lha_capable: bool
|
||||
always_keeps_gun: bool
|
||||
|
||||
# If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon.
|
||||
# It'll RTB when it doesn't have gun ammo left.
|
||||
# If true, the aircraft does not use the guns as the last resort weapons, but as a
|
||||
# main weapon. It'll RTB when it doesn't have gun ammo left.
|
||||
gunfighter: bool
|
||||
|
||||
max_group_size: int
|
||||
patrol_altitude: Optional[Distance]
|
||||
patrol_speed: Optional[Speed]
|
||||
|
||||
#: The maximum range between the origin airfield and the target for which the auto-
|
||||
#: planner will consider this aircraft usable for a mission.
|
||||
max_mission_range: Distance
|
||||
|
||||
fuel_consumption: Optional[FuelConsumption]
|
||||
|
||||
intra_flight_radio: Optional[Radio]
|
||||
channel_allocator: Optional[RadioChannelAllocator]
|
||||
channel_namer: Type[ChannelNamer]
|
||||
@ -230,6 +266,25 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
radio_config = RadioConfig.from_data(data.get("radios", {}))
|
||||
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
|
||||
|
||||
try:
|
||||
mission_range = nautical_miles(int(data["max_range"]))
|
||||
except (KeyError, ValueError):
|
||||
mission_range = (
|
||||
nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
|
||||
)
|
||||
logging.warning(
|
||||
f"{aircraft.id} does not specify a max_range. Defaulting to "
|
||||
f"{mission_range.nautical_miles}NM"
|
||||
)
|
||||
|
||||
fuel_data = data.get("fuel")
|
||||
if fuel_data is not None:
|
||||
fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data(
|
||||
fuel_data
|
||||
)
|
||||
else:
|
||||
fuel_consumption = None
|
||||
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
@ -257,6 +312,8 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
max_group_size=data.get("max_group_size", aircraft.group_size_max),
|
||||
patrol_altitude=patrol_config.altitude,
|
||||
patrol_speed=patrol_config.speed,
|
||||
max_mission_range=mission_range,
|
||||
fuel_consumption=fuel_consumption,
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
|
||||
3
game/flightplan/__init__.py
Normal file
3
game/flightplan/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .holdzonegeometry import HoldZoneGeometry
|
||||
from .ipzonegeometry import IpZoneGeometry
|
||||
from .joinzonegeometry import JoinZoneGeometry
|
||||
108
game/flightplan/holdzonegeometry.py
Normal file
108
game/flightplan/holdzonegeometry.py
Normal file
@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon
|
||||
|
||||
from game.theater import ConflictTheater
|
||||
from game.utils import nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class HoldZoneGeometry:
|
||||
"""Defines the zones used for finding optimal hold point placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting hold
|
||||
point so that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: Point,
|
||||
home: Point,
|
||||
ip: Point,
|
||||
join: Point,
|
||||
coalition: Coalition,
|
||||
theater: ConflictTheater,
|
||||
) -> None:
|
||||
# Hold points are placed one of two ways. Either approach guarantees:
|
||||
#
|
||||
# * Safe hold point.
|
||||
# * Minimum distance to the join point.
|
||||
# * Not closer to the target than the join point.
|
||||
#
|
||||
# 1. As near the join point as possible with a specific distance from the
|
||||
# departure airfield. This prevents loitering directly above the airfield but
|
||||
# also keeps the hold point close to the departure airfield.
|
||||
#
|
||||
# 2. Alternatively, if the entire home zone is excluded by the above criteria,
|
||||
# as neat the departure airfield as possible within a minimum distance from
|
||||
# the join point, with a restricted turn angle at the join point. This
|
||||
# handles the case where we need to backtrack from the departure airfield and
|
||||
# the join point to place the hold point, but the turn angle limit restricts
|
||||
# the maximum distance of the backtrack while maintaining the direction of
|
||||
# the flight plan.
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
self.join = ShapelyPoint(join.x, join.y)
|
||||
|
||||
self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters)
|
||||
|
||||
join_to_target_distance = join.distance_to_point(target)
|
||||
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(
|
||||
join_to_target_distance
|
||||
)
|
||||
|
||||
self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters)
|
||||
|
||||
excluded_zones = shapely.ops.unary_union(
|
||||
[self.join_bubble, self.target_bubble, self.threat_zone]
|
||||
)
|
||||
if not isinstance(excluded_zones, MultiPolygon):
|
||||
excluded_zones = MultiPolygon([excluded_zones])
|
||||
self.excluded_zones = excluded_zones
|
||||
|
||||
join_heading = ip.heading_between_point(join)
|
||||
|
||||
# Arbitrarily large since this is later constrained by the map boundary, and
|
||||
# we'll be picking a location close to the IP anyway. Just used to avoid real
|
||||
# distance calculations to project to the map edge.
|
||||
large_distance = nautical_miles(400).meters
|
||||
turn_limit = 40
|
||||
join_limit_ccw = join.point_from_heading(
|
||||
join_heading - turn_limit, large_distance
|
||||
)
|
||||
join_limit_cw = join.point_from_heading(
|
||||
join_heading + turn_limit, large_distance
|
||||
)
|
||||
|
||||
join_direction_limit_wedge = Polygon(
|
||||
[
|
||||
(join.x, join.y),
|
||||
(join_limit_ccw.x, join_limit_ccw.y),
|
||||
(join_limit_cw.x, join_limit_cw.y),
|
||||
]
|
||||
)
|
||||
|
||||
permissible_zones = (
|
||||
coalition.nav_mesh.map_bounds(theater)
|
||||
.intersection(join_direction_limit_wedge)
|
||||
.difference(self.excluded_zones)
|
||||
.difference(self.home_bubble)
|
||||
)
|
||||
if not isinstance(permissible_zones, MultiPolygon):
|
||||
permissible_zones = MultiPolygon([permissible_zones])
|
||||
self.permissible_zones = permissible_zones
|
||||
self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones)
|
||||
|
||||
def find_best_hold_point(self) -> Point:
|
||||
if self.preferred_lines.is_empty:
|
||||
hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home)
|
||||
else:
|
||||
hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join)
|
||||
return Point(hold.x, hold.y)
|
||||
118
game/flightplan/ipzonegeometry.py
Normal file
118
game/flightplan/ipzonegeometry.py
Normal file
@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import Point as ShapelyPoint, MultiPolygon
|
||||
|
||||
from game.utils import nautical_miles, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class IpZoneGeometry:
|
||||
"""Defines the zones used for finding optimal IP placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting IP so
|
||||
that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: Point,
|
||||
home: Point,
|
||||
coalition: Coalition,
|
||||
) -> None:
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
max_ip_distance = coalition.doctrine.max_ingress_distance
|
||||
min_ip_distance = coalition.doctrine.min_ingress_distance
|
||||
|
||||
# The minimum distance between the home location and the IP.
|
||||
min_distance_from_home = nautical_miles(5)
|
||||
|
||||
# The distance that is expected to be needed between the beginning of the attack
|
||||
# and weapon release. This buffers the threat zone to give a 5nm window between
|
||||
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
|
||||
# likely to end up with the attacker entering a threatened area.
|
||||
attack_distance_buffer = nautical_miles(5)
|
||||
|
||||
home_threatened = coalition.opponent.threat_zone.threatened(home)
|
||||
|
||||
shapely_target = ShapelyPoint(target.x, target.y)
|
||||
home_to_target_distance = meters(home.distance_to_point(target))
|
||||
|
||||
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
|
||||
self.home.buffer(min_distance_from_home.meters)
|
||||
)
|
||||
|
||||
# If the home zone is not threatened and home is within LAR, constrain the max
|
||||
# range to the home-to-target distance to prevent excessive backtracking.
|
||||
#
|
||||
# If the home zone *is* threatened, we need to back out of the zone to
|
||||
# rendezvous anyway.
|
||||
if not home_threatened and (
|
||||
min_ip_distance < home_to_target_distance < max_ip_distance
|
||||
):
|
||||
max_ip_distance = home_to_target_distance
|
||||
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
|
||||
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
|
||||
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
|
||||
|
||||
# The intersection of the home bubble and IP bubble will be all the points that
|
||||
# are within the valid IP range that are not farther from home than the target
|
||||
# is. However, if the origin airfield is threatened but there are safe
|
||||
# placements for the IP, we should not constrain to the home zone. In this case
|
||||
# we'll either end up with a safe zone outside the home zone and pick the
|
||||
# closest point in to to home (minimizing backtracking), or we'll have no safe
|
||||
# IP anywhere within range of the target, and we'll later pick the IP nearest
|
||||
# the edge of the threat zone.
|
||||
if home_threatened:
|
||||
self.permissible_zone = self.ip_bubble
|
||||
else:
|
||||
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
|
||||
|
||||
if self.permissible_zone.is_empty:
|
||||
# If home is closer to the target than the min range, there will not be an
|
||||
# IP solution that's close enough to home, in which case we need to ignore
|
||||
# the home bubble.
|
||||
self.permissible_zone = self.ip_bubble
|
||||
|
||||
safe_zones = self.permissible_zone.difference(
|
||||
self.threat_zone.buffer(attack_distance_buffer.meters)
|
||||
)
|
||||
|
||||
if not isinstance(safe_zones, MultiPolygon):
|
||||
safe_zones = MultiPolygon([safe_zones])
|
||||
self.safe_zones = safe_zones
|
||||
|
||||
def _unsafe_ip(self) -> ShapelyPoint:
|
||||
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
|
||||
if unthreatened_home_zone.is_empty:
|
||||
# Nowhere in our home zone is safe. The package will need to exit the
|
||||
# threatened area to hold and rendezvous. Pick the IP closest to the
|
||||
# edge of the threat zone.
|
||||
return shapely.ops.nearest_points(
|
||||
self.permissible_zone, self.threat_zone.boundary
|
||||
)[0]
|
||||
|
||||
# No safe point in the IP zone, but the home zone is safe. Pick the max-
|
||||
# distance IP that's closest to the untreatened home zone.
|
||||
return shapely.ops.nearest_points(
|
||||
self.permissible_zone, unthreatened_home_zone
|
||||
)[0]
|
||||
|
||||
def _safe_ip(self) -> ShapelyPoint:
|
||||
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
|
||||
# the IP in the zone that's closest to the target.
|
||||
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
|
||||
|
||||
def find_best_ip(self) -> Point:
|
||||
if self.safe_zones.is_empty:
|
||||
ip = self._unsafe_ip()
|
||||
else:
|
||||
ip = self._safe_ip()
|
||||
return Point(ip.x, ip.y)
|
||||
103
game/flightplan/joinzonegeometry.py
Normal file
103
game/flightplan/joinzonegeometry.py
Normal file
@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import (
|
||||
Point as ShapelyPoint,
|
||||
Polygon,
|
||||
MultiPolygon,
|
||||
MultiLineString,
|
||||
)
|
||||
|
||||
from game.utils import nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class JoinZoneGeometry:
|
||||
"""Defines the zones used for finding optimal join point placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting join
|
||||
point so that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, target: Point, home: Point, ip: Point, coalition: Coalition
|
||||
) -> None:
|
||||
# Normal join placement is based on the path from home to the IP. If no path is
|
||||
# found it means that the target is on a direct path. In that case we instead
|
||||
# want to enforce that the join point is:
|
||||
#
|
||||
# * Not closer to the target than the IP.
|
||||
# * Not too close to the home airfield.
|
||||
# * Not threatened.
|
||||
# * A minimum distance from the IP.
|
||||
# * Not too sharp a turn at the ingress point.
|
||||
self.ip = ShapelyPoint(ip.x, ip.y)
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters)
|
||||
|
||||
ip_distance = ip.distance_to_point(target)
|
||||
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance)
|
||||
|
||||
# The minimum distance between the home location and the IP.
|
||||
min_distance_from_home = nautical_miles(5)
|
||||
|
||||
self.home_bubble = self.home.buffer(min_distance_from_home.meters)
|
||||
|
||||
excluded_zones = shapely.ops.unary_union(
|
||||
[self.ip_bubble, self.target_bubble, self.threat_zone]
|
||||
)
|
||||
|
||||
if not isinstance(excluded_zones, MultiPolygon):
|
||||
excluded_zones = MultiPolygon([excluded_zones])
|
||||
self.excluded_zones = excluded_zones
|
||||
|
||||
ip_heading = target.heading_between_point(ip)
|
||||
|
||||
# Arbitrarily large since this is later constrained by the map boundary, and
|
||||
# we'll be picking a location close to the IP anyway. Just used to avoid real
|
||||
# distance calculations to project to the map edge.
|
||||
large_distance = nautical_miles(400).meters
|
||||
turn_limit = 40
|
||||
ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance)
|
||||
ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance)
|
||||
|
||||
ip_direction_limit_wedge = Polygon(
|
||||
[
|
||||
(ip.x, ip.y),
|
||||
(ip_limit_ccw.x, ip_limit_ccw.y),
|
||||
(ip_limit_cw.x, ip_limit_cw.y),
|
||||
]
|
||||
)
|
||||
|
||||
permissible_zones = ip_direction_limit_wedge.difference(
|
||||
self.excluded_zones
|
||||
).difference(self.home_bubble)
|
||||
if permissible_zones.is_empty:
|
||||
permissible_zones = MultiPolygon([])
|
||||
if not isinstance(permissible_zones, MultiPolygon):
|
||||
permissible_zones = MultiPolygon([permissible_zones])
|
||||
self.permissible_zones = permissible_zones
|
||||
|
||||
preferred_lines = ip_direction_limit_wedge.intersection(
|
||||
self.excluded_zones.boundary
|
||||
).difference(self.home_bubble)
|
||||
|
||||
if preferred_lines.is_empty:
|
||||
preferred_lines = MultiLineString([])
|
||||
if not isinstance(preferred_lines, MultiLineString):
|
||||
preferred_lines = MultiLineString([preferred_lines])
|
||||
self.preferred_lines = preferred_lines
|
||||
|
||||
def find_best_join_point(self) -> Point:
|
||||
if self.preferred_lines.is_empty:
|
||||
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
|
||||
else:
|
||||
join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home)
|
||||
return Point(join.x, join.y)
|
||||
@ -294,6 +294,8 @@ class Game:
|
||||
def begin_turn_0(self) -> None:
|
||||
"""Initialization for the first turn of the game."""
|
||||
self.turn = 0
|
||||
self.blue.preinit_turn_0()
|
||||
self.red.preinit_turn_0()
|
||||
self.initialize_turn()
|
||||
|
||||
def pass_turn(self, no_action: bool = False) -> None:
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dcs import Point
|
||||
from game.utils import Heading
|
||||
|
||||
|
||||
class PointWithHeading(Point):
|
||||
def __init__(self) -> None:
|
||||
super(PointWithHeading, self).__init__(0, 0)
|
||||
self.heading = 0
|
||||
self.heading: Heading = Heading.from_degrees(0)
|
||||
|
||||
@staticmethod
|
||||
def from_point(point: Point, heading: int) -> PointWithHeading:
|
||||
def from_point(point: Point, heading: Heading) -> PointWithHeading:
|
||||
p = PointWithHeading()
|
||||
p.x = point.x
|
||||
p.y = point.y
|
||||
|
||||
@ -11,7 +11,7 @@ from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.factions.faction import Faction
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.utils import Distance
|
||||
from game.utils import meters
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3
|
||||
@dataclass(frozen=True)
|
||||
class AircraftProcurementRequest:
|
||||
near: MissionTarget
|
||||
range: Distance
|
||||
task_capability: FlightType
|
||||
number: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
task = self.task_capability.value
|
||||
distance = self.range.nautical_miles
|
||||
target = self.near.name
|
||||
return f"{self.number} ship {task} within {distance} nm of {target}"
|
||||
return f"{self.number} ship {task} near {target}"
|
||||
|
||||
|
||||
class ProcurementAi:
|
||||
@ -211,24 +209,28 @@ class ProcurementAi:
|
||||
return GroundUnitClass.Tank
|
||||
return worst_balanced
|
||||
|
||||
def _affordable_aircraft_for_task(
|
||||
self,
|
||||
task: FlightType,
|
||||
airbase: ControlPoint,
|
||||
number: int,
|
||||
max_price: float,
|
||||
def affordable_aircraft_for(
|
||||
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
|
||||
) -> Optional[AircraftType]:
|
||||
best_choice: Optional[AircraftType] = None
|
||||
for unit in aircraft_for_task(task):
|
||||
for unit in aircraft_for_task(request.task_capability):
|
||||
if unit not in self.faction.aircrafts:
|
||||
continue
|
||||
if unit.price * number > max_price:
|
||||
if unit.price * request.number > budget:
|
||||
continue
|
||||
if not airbase.can_operate(unit):
|
||||
continue
|
||||
|
||||
distance_to_target = meters(request.near.distance_to(airbase))
|
||||
if distance_to_target > unit.max_mission_range:
|
||||
continue
|
||||
|
||||
for squadron in self.air_wing.squadrons_for(unit):
|
||||
if task in squadron.auto_assignable_mission_types:
|
||||
if (
|
||||
squadron.operates_from(airbase)
|
||||
and request.task_capability
|
||||
in squadron.auto_assignable_mission_types
|
||||
):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
@ -241,13 +243,6 @@ class ProcurementAi:
|
||||
break
|
||||
return best_choice
|
||||
|
||||
def affordable_aircraft_for(
|
||||
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
|
||||
) -> Optional[AircraftType]:
|
||||
return self._affordable_aircraft_for_task(
|
||||
request.task_capability, airbase, request.number, budget
|
||||
)
|
||||
|
||||
def fulfill_aircraft_request(
|
||||
self, request: AircraftProcurementRequest, budget: float
|
||||
) -> Tuple[float, bool]:
|
||||
@ -293,7 +288,7 @@ class ProcurementAi:
|
||||
) -> Iterator[ControlPoint]:
|
||||
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
|
||||
threatened = []
|
||||
for cp in distance_cache.operational_airfields_within(request.range):
|
||||
for cp in distance_cache.operational_airfields:
|
||||
if not cp.is_friendly(self.is_player):
|
||||
continue
|
||||
if cp.unclaimed_parking(self.game) < request.number:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
@ -26,6 +27,7 @@ if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -73,6 +75,33 @@ class Pilot:
|
||||
return Pilot(faker.name())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatingBases:
|
||||
shore: bool
|
||||
carrier: bool
|
||||
lha: bool
|
||||
|
||||
@classmethod
|
||||
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
|
||||
if aircraft.dcs_unit_type.helicopter:
|
||||
# Helicopters operate from anywhere by default.
|
||||
return OperatingBases(shore=True, carrier=True, lha=True)
|
||||
if aircraft.lha_capable:
|
||||
# Marine aircraft operate from LHAs and the shore by default.
|
||||
return OperatingBases(shore=True, carrier=False, lha=True)
|
||||
if aircraft.carrier_capable:
|
||||
# Carrier aircraft operate from carriers by default.
|
||||
return OperatingBases(shore=False, carrier=True, lha=False)
|
||||
# And the rest are only capable of shore operation.
|
||||
return OperatingBases(shore=True, carrier=False, lha=False)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
|
||||
return dataclasses.replace(
|
||||
OperatingBases.default_for_aircraft(aircraft), **data
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Squadron:
|
||||
name: str
|
||||
@ -82,6 +111,7 @@ class Squadron:
|
||||
aircraft: AircraftType
|
||||
livery: Optional[str]
|
||||
mission_types: tuple[FlightType, ...]
|
||||
operating_bases: OperatingBases
|
||||
|
||||
#: The pool of pilots that have not yet been assigned to the squadron. This only
|
||||
#: happens when a preset squadron defines more preset pilots than the squadron limit
|
||||
@ -101,9 +131,6 @@ class Squadron:
|
||||
settings: Settings = field(hash=False, compare=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
|
||||
raise ValueError("Squadrons can only be created with active pilots.")
|
||||
self._recruit_pilots(self.settings.squadron_pilot_limit)
|
||||
self.auto_assignable_mission_types = set(self.mission_types)
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -181,6 +208,11 @@ class Squadron:
|
||||
self.current_roster.extend(new_pilots)
|
||||
self.available_pilots.extend(new_pilots)
|
||||
|
||||
def populate_for_turn_0(self) -> None:
|
||||
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
|
||||
raise ValueError("Squadrons can only be created with active pilots.")
|
||||
self._recruit_pilots(self.settings.squadron_pilot_limit)
|
||||
|
||||
def replenish_lost_pilots(self) -> None:
|
||||
if not self.pilot_limits_enabled:
|
||||
return
|
||||
@ -250,6 +282,14 @@ class Squadron:
|
||||
def can_auto_assign(self, task: FlightType) -> bool:
|
||||
return task in self.auto_assignable_mission_types
|
||||
|
||||
def operates_from(self, control_point: ControlPoint) -> bool:
|
||||
if control_point.is_carrier:
|
||||
return self.operating_bases.carrier
|
||||
elif control_point.is_lha:
|
||||
return self.operating_bases.lha
|
||||
else:
|
||||
return self.operating_bases.shore
|
||||
|
||||
def pilot_at_index(self, index: int) -> Pilot:
|
||||
return self.current_roster[index]
|
||||
|
||||
@ -288,6 +328,7 @@ class Squadron:
|
||||
aircraft=unit_type,
|
||||
livery=data.get("livery"),
|
||||
mission_types=tuple(mission_types),
|
||||
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
|
||||
pilot_pool=pilots,
|
||||
coalition=coalition,
|
||||
settings=game.settings,
|
||||
@ -377,6 +418,7 @@ class AirWing:
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
mission_types=tuple(tasks_for_aircraft(aircraft)),
|
||||
operating_bases=OperatingBases.default_for_aircraft(aircraft),
|
||||
pilot_pool=[],
|
||||
coalition=coalition,
|
||||
settings=game.settings,
|
||||
@ -414,6 +456,10 @@ class AirWing:
|
||||
def squadron_at_index(self, index: int) -> Squadron:
|
||||
return list(self.iter_squadrons())[index]
|
||||
|
||||
def populate_for_turn_0(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.populate_for_turn_0()
|
||||
|
||||
def replenish(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.replenish_lost_pilots()
|
||||
|
||||
@ -59,7 +59,7 @@ from ..point_with_heading import PointWithHeading
|
||||
from ..positioned import Positioned
|
||||
from ..profiling import logged_duration
|
||||
from ..scenery_group import SceneryGroup
|
||||
from ..utils import Distance, meters
|
||||
from ..utils import Distance, Heading, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import TheaterGroundObject
|
||||
@ -389,8 +389,10 @@ class MizCampaignLoader:
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def objective_info(self, near: Positioned) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(near.position)
|
||||
def objective_info(
|
||||
self, near: Positioned, allow_naval: bool = False
|
||||
) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(near.position, allow_naval)
|
||||
distance = meters(closest.position.distance_to_point(near.position))
|
||||
return closest, distance
|
||||
|
||||
@ -398,85 +400,113 @@ class MizCampaignLoader:
|
||||
for static in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.offshore_strike_locations.append(
|
||||
PointWithHeading.from_point(static.position, static.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for ship in self.ships:
|
||||
closest, distance = self.objective_info(ship)
|
||||
closest, distance = self.objective_info(ship, allow_naval=True)
|
||||
closest.preset_locations.ships.append(
|
||||
PointWithHeading.from_point(ship.position, ship.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
ship.position, Heading.from_degrees(ship.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.missile_sites:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.missile_sites.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.coastal_defenses:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.coastal_defenses.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.long_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.long_range_sams.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.medium_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.medium_range_sams.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.short_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.short_range_sams.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.aaa:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.aaa.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.ewrs:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.ewrs.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.armor_groups:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.armor_groups.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.helipads:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.helipads.append(
|
||||
PointWithHeading.from_point(static.position, static.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.factories:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.factories.append(
|
||||
PointWithHeading.from_point(static.position, static.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.ammunition_depots:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.ammunition_depots.append(
|
||||
PointWithHeading.from_point(static.position, static.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.strike_locations.append(
|
||||
PointWithHeading.from_point(static.position, static.units[0].heading)
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for scenery_group in self.scenery:
|
||||
@ -497,6 +527,17 @@ class ReferencePoint:
|
||||
image_coordinates: Point
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeasonalConditions:
|
||||
# Units are inHg and degrees Celsius
|
||||
# Future improvement: add clouds/precipitation
|
||||
summer_avg_pressure: float
|
||||
winter_avg_pressure: float
|
||||
summer_avg_temperature: float
|
||||
winter_avg_temperature: float
|
||||
temperature_day_night_difference: float
|
||||
|
||||
|
||||
class ConflictTheater:
|
||||
terrain: Terrain
|
||||
|
||||
@ -633,10 +674,14 @@ class ConflictTheater:
|
||||
def enemy_points(self) -> List[ControlPoint]:
|
||||
return list(self.control_points_for(player=False))
|
||||
|
||||
def closest_control_point(self, point: Point) -> ControlPoint:
|
||||
def closest_control_point(
|
||||
self, point: Point, allow_naval: bool = False
|
||||
) -> ControlPoint:
|
||||
closest = self.controlpoints[0]
|
||||
closest_distance = point.distance_to_point(closest.position)
|
||||
for control_point in self.controlpoints[1:]:
|
||||
if control_point.is_fleet and not allow_naval:
|
||||
continue
|
||||
distance = point.distance_to_point(control_point.position)
|
||||
if distance < closest_distance:
|
||||
closest = control_point
|
||||
@ -719,6 +764,10 @@ class ConflictTheater:
|
||||
MizCampaignLoader(directory / miz, t).populate_theater()
|
||||
return t
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
raise NotImplementedError
|
||||
@ -748,6 +797,16 @@ class CaucasusTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=30.02, # TODO: More science
|
||||
winter_avg_pressure=29.72, # TODO: More science
|
||||
summer_avg_temperature=22.5,
|
||||
winter_avg_temperature=3.0,
|
||||
temperature_day_night_difference=6.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .caucasus import PARAMETERS
|
||||
@ -770,6 +829,16 @@ class PersianGulfTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=29.98, # TODO: More science
|
||||
winter_avg_pressure=29.80, # TODO: More science
|
||||
summer_avg_temperature=32.5,
|
||||
winter_avg_temperature=15.0,
|
||||
temperature_day_night_difference=2.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .persiangulf import PARAMETERS
|
||||
@ -792,6 +861,16 @@ class NevadaTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=30.02, # TODO: More science
|
||||
winter_avg_pressure=29.72, # TODO: More science
|
||||
summer_avg_temperature=31.5,
|
||||
winter_avg_temperature=5.0,
|
||||
temperature_day_night_difference=6.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .nevada import PARAMETERS
|
||||
@ -814,6 +893,16 @@ class NormandyTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=30.02, # TODO: More science
|
||||
winter_avg_pressure=29.72, # TODO: More science
|
||||
summer_avg_temperature=20.0,
|
||||
winter_avg_temperature=0.0,
|
||||
temperature_day_night_difference=5.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .normandy import PARAMETERS
|
||||
@ -836,6 +925,16 @@ class TheChannelTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=30.02, # TODO: More science
|
||||
winter_avg_pressure=29.72, # TODO: More science
|
||||
summer_avg_temperature=20.0,
|
||||
winter_avg_temperature=0.0,
|
||||
temperature_day_night_difference=5.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .thechannel import PARAMETERS
|
||||
@ -858,6 +957,16 @@ class SyriaTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=29.98, # TODO: More science
|
||||
winter_avg_pressure=29.86, # TODO: More science
|
||||
summer_avg_temperature=28.5,
|
||||
winter_avg_temperature=10.0,
|
||||
temperature_day_night_difference=8.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .syria import PARAMETERS
|
||||
@ -877,6 +986,16 @@ class MarianaIslandsTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def seasonal_conditions(self) -> SeasonalConditions:
|
||||
return SeasonalConditions(
|
||||
summer_avg_pressure=30.02, # TODO: More science
|
||||
winter_avg_pressure=29.82, # TODO: More science
|
||||
summer_avg_temperature=28.0,
|
||||
winter_avg_temperature=27.0,
|
||||
temperature_day_night_difference=1.0,
|
||||
)
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .marianaislands import PARAMETERS
|
||||
|
||||
@ -35,6 +35,7 @@ from dcs.unit import Unit
|
||||
from game import db
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.scenery_group import SceneryGroup
|
||||
from game.utils import Heading
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
from gen.runways import RunwayAssigner, RunwayData
|
||||
@ -335,7 +336,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def heading(self) -> int:
|
||||
def heading(self) -> Heading:
|
||||
...
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -838,8 +839,8 @@ class Airfield(ControlPoint):
|
||||
return len(self.airport.parking_slots)
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return self.airport.runways[0].heading
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(self.airport.runways[0].heading)
|
||||
|
||||
def runway_is_operational(self) -> bool:
|
||||
return not self.runway_status.damaged
|
||||
@ -903,8 +904,8 @@ class NavalControlPoint(ControlPoint, ABC):
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return 0 # TODO compute heading
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(0) # TODO compute heading
|
||||
|
||||
def find_main_tgo(self) -> GenericCarrierGroundObject:
|
||||
for g in self.ground_objects:
|
||||
@ -933,7 +934,9 @@ class NavalControlPoint(ControlPoint, ABC):
|
||||
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
|
||||
) -> RunwayData:
|
||||
# TODO: Assign TACAN and ICLS earlier so we don't need this.
|
||||
fallback = RunwayData(self.full_name, runway_heading=0, runway_name="")
|
||||
fallback = RunwayData(
|
||||
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
|
||||
)
|
||||
return dynamic_runways.get(self.name, fallback)
|
||||
|
||||
@property
|
||||
@ -1071,14 +1074,16 @@ class OffMapSpawn(ControlPoint):
|
||||
return True
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return 0
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(0)
|
||||
|
||||
def active_runway(
|
||||
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
|
||||
) -> RunwayData:
|
||||
logging.warning("TODO: Off map spawns have no runways.")
|
||||
return RunwayData(self.full_name, runway_heading=0, runway_name="")
|
||||
return RunwayData(
|
||||
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
|
||||
)
|
||||
|
||||
@property
|
||||
def runway_status(self) -> RunwayStatus:
|
||||
@ -1120,7 +1125,9 @@ class Fob(ControlPoint):
|
||||
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
|
||||
) -> RunwayData:
|
||||
logging.warning("TODO: FOBs have no runways.")
|
||||
return RunwayData(self.full_name, runway_heading=0, runway_name="")
|
||||
return RunwayData(
|
||||
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
|
||||
)
|
||||
|
||||
@property
|
||||
def runway_status(self) -> RunwayStatus:
|
||||
@ -1142,8 +1149,8 @@ class Fob(ControlPoint):
|
||||
return False
|
||||
|
||||
@property
|
||||
def heading(self) -> int:
|
||||
return 0
|
||||
def heading(self) -> Heading:
|
||||
return Heading.from_degrees(0)
|
||||
|
||||
@property
|
||||
def can_deploy_ground_units(self) -> bool:
|
||||
|
||||
@ -11,7 +11,7 @@ from .controlpoint import (
|
||||
ControlPoint,
|
||||
MissionTarget,
|
||||
)
|
||||
from ..utils import pairwise
|
||||
from ..utils import Heading, pairwise
|
||||
|
||||
|
||||
FRONTLINE_MIN_CP_DISTANCE = 5000
|
||||
@ -27,9 +27,9 @@ class FrontLineSegment:
|
||||
point_b: Point
|
||||
|
||||
@property
|
||||
def attack_heading(self) -> float:
|
||||
def attack_heading(self) -> Heading:
|
||||
"""The heading of the frontline segment from player to enemy control point"""
|
||||
return self.point_a.heading_between_point(self.point_b)
|
||||
return Heading.from_degrees(self.point_a.heading_between_point(self.point_b))
|
||||
|
||||
@property
|
||||
def attack_distance(self) -> float:
|
||||
@ -123,7 +123,7 @@ class FrontLine(MissionTarget):
|
||||
return sum(i.attack_distance for i in self.segments)
|
||||
|
||||
@property
|
||||
def attack_heading(self) -> float:
|
||||
def attack_heading(self) -> Heading:
|
||||
"""The heading of the active attack segment from player to enemy control point"""
|
||||
return self.active_segment.attack_heading
|
||||
|
||||
@ -150,13 +150,13 @@ class FrontLine(MissionTarget):
|
||||
"""
|
||||
if distance < self.segments[0].attack_distance:
|
||||
return self.blue_cp.position.point_from_heading(
|
||||
self.segments[0].attack_heading, distance
|
||||
self.segments[0].attack_heading.degrees, distance
|
||||
)
|
||||
remaining_dist = distance
|
||||
for segment in self.segments:
|
||||
if remaining_dist < segment.attack_distance:
|
||||
return segment.point_a.point_from_heading(
|
||||
segment.attack_heading, remaining_dist
|
||||
segment.attack_heading.degrees, remaining_dist
|
||||
)
|
||||
else:
|
||||
remaining_dist -= segment.attack_distance
|
||||
|
||||
@ -28,6 +28,7 @@ from game.theater.theatergroundobject import (
|
||||
VehicleGroupGroundObject,
|
||||
CoastalSiteGroundObject,
|
||||
)
|
||||
from game.utils import Heading
|
||||
from game.version import VERSION
|
||||
from gen import namegen
|
||||
from gen.coastal.coastal_group_generator import generate_coastal_group
|
||||
@ -123,7 +124,6 @@ class GameGenerator:
|
||||
|
||||
GroundObjectGenerator(game, self.generator_settings).generate()
|
||||
game.settings.version = VERSION
|
||||
game.begin_turn_0()
|
||||
return game
|
||||
|
||||
def prepare_theater(self) -> None:
|
||||
@ -386,7 +386,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
group_id,
|
||||
object_id,
|
||||
position + template_point,
|
||||
unit["heading"],
|
||||
Heading.from_degrees(unit["heading"]),
|
||||
self.control_point,
|
||||
unit["type"],
|
||||
)
|
||||
@ -586,7 +586,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
|
||||
group_id,
|
||||
object_id,
|
||||
point + template_point,
|
||||
unit["heading"],
|
||||
Heading.from_degrees(unit["heading"]),
|
||||
self.control_point,
|
||||
unit["type"],
|
||||
is_fob_structure=True,
|
||||
|
||||
@ -17,7 +17,7 @@ from ..data.radar_db import (
|
||||
TELARS,
|
||||
LAUNCHER_TRACKER_PAIRS,
|
||||
)
|
||||
from ..utils import Distance, meters
|
||||
from ..utils import Distance, Heading, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .controlpoint import ControlPoint
|
||||
@ -58,7 +58,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]):
|
||||
category: str,
|
||||
group_id: int,
|
||||
position: Point,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
control_point: ControlPoint,
|
||||
dcs_identifier: str,
|
||||
sea_object: bool,
|
||||
@ -222,7 +222,7 @@ class BuildingGroundObject(TheaterGroundObject[VehicleGroup]):
|
||||
group_id: int,
|
||||
object_id: int,
|
||||
position: Point,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
control_point: ControlPoint,
|
||||
dcs_identifier: str,
|
||||
is_fob_structure: bool = False,
|
||||
@ -310,7 +310,7 @@ class SceneryGroundObject(BuildingGroundObject):
|
||||
group_id=group_id,
|
||||
object_id=object_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier=dcs_identifier,
|
||||
is_fob_structure=False,
|
||||
@ -334,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject):
|
||||
name: str,
|
||||
group_id: int,
|
||||
position: Point,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
control_point: ControlPoint,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@ -385,7 +385,7 @@ class CarrierGroundObject(GenericCarrierGroundObject):
|
||||
category="CARRIER",
|
||||
group_id=group_id,
|
||||
position=control_point.position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="CARRIER",
|
||||
sea_object=True,
|
||||
@ -406,7 +406,7 @@ class LhaGroundObject(GenericCarrierGroundObject):
|
||||
category="LHA",
|
||||
group_id=group_id,
|
||||
position=control_point.position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="LHA",
|
||||
sea_object=True,
|
||||
@ -428,7 +428,7 @@ class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]):
|
||||
category="missile",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
sea_object=False,
|
||||
@ -450,7 +450,7 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]):
|
||||
group_id: int,
|
||||
position: Point,
|
||||
control_point: ControlPoint,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
@ -497,7 +497,7 @@ class SamGroundObject(IadsGroundObject):
|
||||
category="aa",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
sea_object=False,
|
||||
@ -565,7 +565,7 @@ class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]):
|
||||
category="armor",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
sea_object=False,
|
||||
@ -593,7 +593,7 @@ class EwrGroundObject(IadsGroundObject):
|
||||
category="ewr",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="EWR",
|
||||
sea_object=False,
|
||||
@ -627,7 +627,7 @@ class ShipGroundObject(NavalGroundObject):
|
||||
category="ship",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
heading=Heading.from_degrees(0),
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
sea_object=True,
|
||||
|
||||
@ -688,7 +688,5 @@ class PendingTransfers:
|
||||
gap += 1
|
||||
|
||||
self.game.procurement_requests_for(self.player).append(
|
||||
AircraftProcurementRequest(
|
||||
control_point, nautical_miles(200), FlightType.TRANSPORT, gap
|
||||
)
|
||||
AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap)
|
||||
)
|
||||
|
||||
@ -40,7 +40,10 @@ class PendingUnitDeliveries:
|
||||
|
||||
def sell(self, units: dict[UnitType[Any], int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] -= v
|
||||
if self.units[k] > v:
|
||||
self.units[k] -= v
|
||||
else:
|
||||
del self.units[k]
|
||||
|
||||
def refund_all(self, coalition: Coalition) -> None:
|
||||
self.refund(coalition, self.units)
|
||||
|
||||
105
game/utils.py
105
game/utils.py
@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import math
|
||||
import random
|
||||
from collections import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Union, Any
|
||||
from typing import Union, Any, TypeVar
|
||||
|
||||
METERS_TO_FEET = 3.28084
|
||||
FEET_TO_METERS = 1 / METERS_TO_FEET
|
||||
@ -16,14 +17,8 @@ KPH_TO_KNOTS = 1 / KNOTS_TO_KPH
|
||||
MS_TO_KPH = 3.6
|
||||
KPH_TO_MS = 1 / MS_TO_KPH
|
||||
|
||||
|
||||
def heading_sum(h: int, a: int) -> int:
|
||||
h += a
|
||||
return h % 360
|
||||
|
||||
|
||||
def opposite_heading(h: int) -> int:
|
||||
return heading_sum(h, 180)
|
||||
INHG_TO_HPA = 33.86389
|
||||
INHG_TO_MMHG = 25.400002776728
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
@ -181,7 +176,85 @@ def mach(value: float, altitude: Distance) -> Speed:
|
||||
SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5)
|
||||
|
||||
|
||||
def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Heading:
|
||||
heading_in_degrees: int
|
||||
|
||||
@property
|
||||
def degrees(self) -> int:
|
||||
return Heading.reduce_angle(self.heading_in_degrees)
|
||||
|
||||
@property
|
||||
def radians(self) -> float:
|
||||
return math.radians(Heading.reduce_angle(self.heading_in_degrees))
|
||||
|
||||
@property
|
||||
def opposite(self) -> Heading:
|
||||
return self + Heading.from_degrees(180)
|
||||
|
||||
@property
|
||||
def right(self) -> Heading:
|
||||
return self + Heading.from_degrees(90)
|
||||
|
||||
@property
|
||||
def left(self) -> Heading:
|
||||
return self - Heading.from_degrees(90)
|
||||
|
||||
def angle_between(self, other: Heading) -> Heading:
|
||||
angle_between = abs(self.degrees - other.degrees)
|
||||
if angle_between > 180:
|
||||
angle_between = 360 - angle_between
|
||||
return Heading.from_degrees(angle_between)
|
||||
|
||||
@staticmethod
|
||||
def reduce_angle(angle: int) -> int:
|
||||
return angle % 360
|
||||
|
||||
@classmethod
|
||||
def from_degrees(cls, angle: Union[int, float]) -> Heading:
|
||||
return cls(Heading.reduce_angle(round(angle)))
|
||||
|
||||
@classmethod
|
||||
def from_radians(cls, angle: Union[int, float]) -> Heading:
|
||||
deg = round(math.degrees(angle))
|
||||
return cls(Heading.reduce_angle(deg))
|
||||
|
||||
@classmethod
|
||||
def random(cls, min_angle: int = 0, max_angle: int = 0) -> Heading:
|
||||
return Heading.from_degrees(random.randint(min_angle, max_angle))
|
||||
|
||||
def __add__(self, other: Heading) -> Heading:
|
||||
return Heading.from_degrees(self.degrees + other.degrees)
|
||||
|
||||
def __sub__(self, other: Heading) -> Heading:
|
||||
return Heading.from_degrees(self.degrees - other.degrees)
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Pressure:
|
||||
pressure_in_inches_hg: float
|
||||
|
||||
@property
|
||||
def inches_hg(self) -> float:
|
||||
return self.pressure_in_inches_hg
|
||||
|
||||
@property
|
||||
def mm_hg(self) -> float:
|
||||
return self.pressure_in_inches_hg * INHG_TO_MMHG
|
||||
|
||||
@property
|
||||
def hecto_pascals(self) -> float:
|
||||
return self.pressure_in_inches_hg * INHG_TO_HPA
|
||||
|
||||
|
||||
def inches_hg(value: float) -> Pressure:
|
||||
return Pressure(value)
|
||||
|
||||
|
||||
PairwiseT = TypeVar("PairwiseT")
|
||||
|
||||
|
||||
def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]:
|
||||
"""
|
||||
itertools recipe
|
||||
s -> (s0,s1), (s1,s2), (s2, s3), ...
|
||||
@ -189,3 +262,15 @@ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
|
||||
a, b = itertools.tee(iterable)
|
||||
next(b, None)
|
||||
return zip(a, b)
|
||||
|
||||
|
||||
def interpolate(value1: float, value2: float, factor: float, clamp: bool) -> float:
|
||||
"""Inerpolate between two values, factor 0-1"""
|
||||
interpolated = value1 + (value2 - value1) * factor
|
||||
|
||||
if clamp:
|
||||
bigger_value = max(value1, value2)
|
||||
smaller_value = min(value1, value2)
|
||||
return min(bigger_value, max(smaller_value, interpolated))
|
||||
else:
|
||||
return interpolated
|
||||
|
||||
@ -106,4 +106,8 @@ VERSION = _build_version_string()
|
||||
#:
|
||||
#: Version 7.1
|
||||
#: * Support for Mariana Islands terrain
|
||||
CAMPAIGN_FORMAT_VERSION = (7, 1)
|
||||
#:
|
||||
#: Version 8.0
|
||||
#: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as
|
||||
#: strike targets must check and potentially recreate all those objectives.
|
||||
CAMPAIGN_FORMAT_VERSION = (8, 0)
|
||||
|
||||
151
game/weather.py
151
game/weather.py
@ -5,16 +5,18 @@ import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from typing import Optional, TYPE_CHECKING, Any
|
||||
|
||||
from dcs.cloud_presets import Clouds as PydcsClouds
|
||||
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
|
||||
|
||||
from game.savecompat import has_save_compat_for
|
||||
from game.settings import Settings
|
||||
from game.utils import Distance, meters
|
||||
from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ConflictTheater
|
||||
from game.theater.conflicttheater import SeasonalConditions
|
||||
|
||||
|
||||
class TimeOfDay(Enum):
|
||||
@ -26,11 +28,19 @@ class TimeOfDay(Enum):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AtmosphericConditions:
|
||||
#: Pressure at sea level in inches of mercury.
|
||||
qnh_inches_mercury: float
|
||||
#: Pressure at sea level.
|
||||
qnh: Pressure
|
||||
|
||||
#: Temperature at sea level in Celcius.
|
||||
temperature_celsius: float
|
||||
|
||||
@has_save_compat_for(5)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "qnh" not in state:
|
||||
state["qnh"] = inches_hg(state["qnh_inches_mercury"])
|
||||
del state["qnh_inches_mercury"]
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindConditions:
|
||||
@ -71,15 +81,56 @@ class Fog:
|
||||
|
||||
|
||||
class Weather:
|
||||
def __init__(self) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
seasonal_conditions: SeasonalConditions,
|
||||
day: datetime.date,
|
||||
time_of_day: TimeOfDay,
|
||||
) -> None:
|
||||
# Future improvement: Use theater, day and time of day
|
||||
# to get a more realistic conditions
|
||||
self.atmospheric = self.generate_atmospheric()
|
||||
self.atmospheric = self.generate_atmospheric(
|
||||
seasonal_conditions, day, time_of_day
|
||||
)
|
||||
self.clouds = self.generate_clouds()
|
||||
self.fog = self.generate_fog()
|
||||
self.wind = self.generate_wind()
|
||||
|
||||
def generate_atmospheric(self) -> AtmosphericConditions:
|
||||
def generate_atmospheric(
|
||||
self,
|
||||
seasonal_conditions: SeasonalConditions,
|
||||
day: datetime.date,
|
||||
time_of_day: TimeOfDay,
|
||||
) -> AtmosphericConditions:
|
||||
pressure = self.interpolate_summer_winter(
|
||||
seasonal_conditions.summer_avg_pressure,
|
||||
seasonal_conditions.winter_avg_pressure,
|
||||
day,
|
||||
)
|
||||
temperature = self.interpolate_summer_winter(
|
||||
seasonal_conditions.summer_avg_temperature,
|
||||
seasonal_conditions.winter_avg_temperature,
|
||||
day,
|
||||
)
|
||||
|
||||
if time_of_day == TimeOfDay.Day:
|
||||
temperature += seasonal_conditions.temperature_day_night_difference / 2
|
||||
if time_of_day == TimeOfDay.Night:
|
||||
temperature -= seasonal_conditions.temperature_day_night_difference / 2
|
||||
pressure += self.pressure_adjustment
|
||||
temperature += self.temperature_adjustment
|
||||
conditions = AtmosphericConditions(
|
||||
qnh=self.random_pressure(pressure),
|
||||
temperature_celsius=self.random_temperature(temperature),
|
||||
)
|
||||
return conditions
|
||||
|
||||
@property
|
||||
def pressure_adjustment(self) -> float:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def temperature_adjustment(self) -> float:
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_clouds(self) -> Optional[Clouds]:
|
||||
@ -98,7 +149,7 @@ class Weather:
|
||||
|
||||
@staticmethod
|
||||
def random_wind(minimum: int, maximum: int) -> WindConditions:
|
||||
wind_direction = random.randint(0, 360)
|
||||
wind_direction = Heading.random()
|
||||
at_0m_factor = 1
|
||||
at_2000m_factor = 2
|
||||
at_8000m_factor = 3
|
||||
@ -106,9 +157,9 @@ class Weather:
|
||||
|
||||
return WindConditions(
|
||||
# Always some wind to make the smoke move a bit.
|
||||
at_0m=Wind(wind_direction, max(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),
|
||||
at_0m=Wind(wind_direction.degrees, max(1, base_wind * at_0m_factor)),
|
||||
at_2000m=Wind(wind_direction.degrees, base_wind * at_2000m_factor),
|
||||
at_8000m=Wind(wind_direction.degrees, base_wind * at_8000m_factor),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@ -120,14 +171,14 @@ class Weather:
|
||||
return random.randint(100, 400)
|
||||
|
||||
@staticmethod
|
||||
def random_pressure(average_pressure: float) -> float:
|
||||
def random_pressure(average_pressure: float) -> Pressure:
|
||||
# "Safe" constants based roughly on ME and viper altimeter.
|
||||
# Units are inches of mercury.
|
||||
SAFE_MIN = 28.4
|
||||
SAFE_MAX = 30.9
|
||||
# Use normalvariate to get normal distribution, more realistic than uniform
|
||||
pressure = random.normalvariate(average_pressure, 0.2)
|
||||
return max(SAFE_MIN, min(SAFE_MAX, pressure))
|
||||
pressure = random.normalvariate(average_pressure, 0.1)
|
||||
return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure)))
|
||||
|
||||
@staticmethod
|
||||
def random_temperature(average_temperature: float) -> float:
|
||||
@ -136,17 +187,29 @@ class Weather:
|
||||
SAFE_MIN = -12
|
||||
SAFE_MAX = 49
|
||||
# Use normalvariate to get normal distribution, more realistic than uniform
|
||||
temperature = random.normalvariate(average_temperature, 4)
|
||||
temperature = random.normalvariate(average_temperature, 2)
|
||||
temperature = round(temperature)
|
||||
return max(SAFE_MIN, min(SAFE_MAX, temperature))
|
||||
|
||||
@staticmethod
|
||||
def interpolate_summer_winter(
|
||||
summer_value: float, winter_value: float, day: datetime.date
|
||||
) -> float:
|
||||
day_of_year = day.timetuple().tm_yday
|
||||
day_of_year_peak_summer = 183
|
||||
distance_from_peak_summer = abs(-day_of_year_peak_summer + day_of_year)
|
||||
winter_factor = distance_from_peak_summer / day_of_year_peak_summer
|
||||
return interpolate(summer_value, winter_value, winter_factor, clamp=True)
|
||||
|
||||
|
||||
class ClearSkies(Weather):
|
||||
def generate_atmospheric(self) -> AtmosphericConditions:
|
||||
return AtmosphericConditions(
|
||||
qnh_inches_mercury=self.random_pressure(29.96),
|
||||
temperature_celsius=self.random_temperature(22),
|
||||
)
|
||||
@property
|
||||
def pressure_adjustment(self) -> float:
|
||||
return 0.22
|
||||
|
||||
@property
|
||||
def temperature_adjustment(self) -> float:
|
||||
return 3.0
|
||||
|
||||
def generate_clouds(self) -> Optional[Clouds]:
|
||||
return None
|
||||
@ -159,11 +222,13 @@ class ClearSkies(Weather):
|
||||
|
||||
|
||||
class Cloudy(Weather):
|
||||
def generate_atmospheric(self) -> AtmosphericConditions:
|
||||
return AtmosphericConditions(
|
||||
qnh_inches_mercury=self.random_pressure(29.90),
|
||||
temperature_celsius=self.random_temperature(20),
|
||||
)
|
||||
@property
|
||||
def pressure_adjustment(self) -> float:
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def temperature_adjustment(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def generate_clouds(self) -> Optional[Clouds]:
|
||||
return Clouds.random_preset(rain=False)
|
||||
@ -177,11 +242,13 @@ class Cloudy(Weather):
|
||||
|
||||
|
||||
class Raining(Weather):
|
||||
def generate_atmospheric(self) -> AtmosphericConditions:
|
||||
return AtmosphericConditions(
|
||||
qnh_inches_mercury=self.random_pressure(29.70),
|
||||
temperature_celsius=self.random_temperature(16),
|
||||
)
|
||||
@property
|
||||
def pressure_adjustment(self) -> float:
|
||||
return -0.22
|
||||
|
||||
@property
|
||||
def temperature_adjustment(self) -> float:
|
||||
return -3.0
|
||||
|
||||
def generate_clouds(self) -> Optional[Clouds]:
|
||||
return Clouds.random_preset(rain=True)
|
||||
@ -195,11 +262,13 @@ class Raining(Weather):
|
||||
|
||||
|
||||
class Thunderstorm(Weather):
|
||||
def generate_atmospheric(self) -> AtmosphericConditions:
|
||||
return AtmosphericConditions(
|
||||
qnh_inches_mercury=self.random_pressure(29.60),
|
||||
temperature_celsius=self.random_temperature(15),
|
||||
)
|
||||
@property
|
||||
def pressure_adjustment(self) -> float:
|
||||
return 0.1
|
||||
|
||||
@property
|
||||
def temperature_adjustment(self) -> float:
|
||||
return -3.0
|
||||
|
||||
def generate_clouds(self) -> Optional[Clouds]:
|
||||
return Clouds(
|
||||
@ -233,7 +302,7 @@ class Conditions:
|
||||
return cls(
|
||||
time_of_day=time_of_day,
|
||||
start_time=_start_time,
|
||||
weather=cls.generate_weather(),
|
||||
weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@ -259,7 +328,13 @@ class Conditions:
|
||||
return datetime.datetime.combine(day, time)
|
||||
|
||||
@classmethod
|
||||
def generate_weather(cls) -> Weather:
|
||||
def generate_weather(
|
||||
cls,
|
||||
seasonal_conditions: SeasonalConditions,
|
||||
day: datetime.date,
|
||||
time_of_day: TimeOfDay,
|
||||
) -> Weather:
|
||||
# Future improvement: use seasonal weights for theaters
|
||||
chances = {
|
||||
Thunderstorm: 1,
|
||||
Raining: 20,
|
||||
@ -269,4 +344,4 @@ class Conditions:
|
||||
weather_type = random.choices(
|
||||
list(chances.keys()), weights=list(chances.values())
|
||||
)[0]
|
||||
return weather_type()
|
||||
return weather_type(seasonal_conditions, day, time_of_day)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
@ -80,7 +81,7 @@ from game.theater.missiontarget import MissionTarget
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
from game.transfers import MultiGroupTransport
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import Distance, meters, nautical_miles
|
||||
from game.utils import Distance, Heading, meters, nautical_miles, pairwise
|
||||
from gen.ato import AirTaskingOrder, Package
|
||||
from gen.callsigns import create_group_callsign_from_unit
|
||||
from gen.flights.flight import (
|
||||
@ -1194,8 +1195,57 @@ class AircraftConflictGenerator:
|
||||
).build()
|
||||
|
||||
# Set here rather than when the FlightData is created so they waypoints
|
||||
# have their TOTs set.
|
||||
self.flights[-1].waypoints = [takeoff_point] + flight.points
|
||||
# have their TOTs and fuel minimums set. Once we're more confident in our fuel
|
||||
# estimation ability the minimum fuel amounts will be calculated during flight
|
||||
# plan construction, but for now it's only used by the kneeboard so is generated
|
||||
# late.
|
||||
waypoints = [takeoff_point] + flight.points
|
||||
self._estimate_min_fuel_for(flight, waypoints)
|
||||
self.flights[-1].waypoints = waypoints
|
||||
|
||||
@staticmethod
|
||||
def _estimate_min_fuel_for(flight: Flight, waypoints: list[FlightWaypoint]) -> None:
|
||||
if flight.unit_type.fuel_consumption is None:
|
||||
return
|
||||
|
||||
combat_speed_types = {
|
||||
FlightWaypointType.INGRESS_BAI,
|
||||
FlightWaypointType.INGRESS_CAS,
|
||||
FlightWaypointType.INGRESS_DEAD,
|
||||
FlightWaypointType.INGRESS_ESCORT,
|
||||
FlightWaypointType.INGRESS_OCA_AIRCRAFT,
|
||||
FlightWaypointType.INGRESS_OCA_RUNWAY,
|
||||
FlightWaypointType.INGRESS_SEAD,
|
||||
FlightWaypointType.INGRESS_STRIKE,
|
||||
FlightWaypointType.INGRESS_SWEEP,
|
||||
FlightWaypointType.SPLIT,
|
||||
} | set(TARGET_WAYPOINTS)
|
||||
|
||||
consumption = flight.unit_type.fuel_consumption
|
||||
min_fuel: float = consumption.min_safe
|
||||
|
||||
# The flight plan (in reverse) up to and including the arrival point.
|
||||
main_flight_plan = reversed(waypoints)
|
||||
try:
|
||||
while waypoint := next(main_flight_plan):
|
||||
if waypoint.waypoint_type is FlightWaypointType.LANDING_POINT:
|
||||
waypoint.min_fuel = min_fuel
|
||||
main_flight_plan = itertools.chain([waypoint], main_flight_plan)
|
||||
break
|
||||
except StopIteration:
|
||||
# Some custom flight plan without a landing point. Skip it.
|
||||
return
|
||||
|
||||
for b, a in pairwise(main_flight_plan):
|
||||
distance = meters(a.position.distance_to_point(b.position))
|
||||
if a.waypoint_type is FlightWaypointType.TAKEOFF:
|
||||
ppm = consumption.climb
|
||||
elif b.waypoint_type in combat_speed_types:
|
||||
ppm = consumption.combat
|
||||
else:
|
||||
ppm = consumption.cruise
|
||||
min_fuel += distance.nautical_miles * ppm
|
||||
a.min_fuel = min_fuel
|
||||
|
||||
def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool:
|
||||
if start_time.total_seconds() <= 0:
|
||||
|
||||
@ -17,6 +17,9 @@ from dcs.task import (
|
||||
)
|
||||
from dcs.unittype import UnitType
|
||||
|
||||
from game.utils import Heading
|
||||
from .flights.ai_flight_planner_db import AEWC_CAPABLE
|
||||
from .naming import namegen
|
||||
from .callsigns import callsign_for_support_unit
|
||||
from .conflictgen import Conflict
|
||||
from .flights.ai_flight_planner_db import AEWC_CAPABLE
|
||||
@ -122,14 +125,14 @@ class AirSupportConflictGenerator:
|
||||
alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type)
|
||||
freq = self.radio_registry.alloc_uhf()
|
||||
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y)
|
||||
tanker_heading = (
|
||||
tanker_heading = Heading.from_degrees(
|
||||
self.conflict.red_cp.position.heading_between_point(
|
||||
self.conflict.blue_cp.position
|
||||
)
|
||||
+ TANKER_HEADING_OFFSET * i
|
||||
)
|
||||
tanker_position = player_cp.position.point_from_heading(
|
||||
tanker_heading, TANKER_DISTANCE
|
||||
tanker_heading.degrees, TANKER_DISTANCE
|
||||
)
|
||||
tanker_group = self.mission.refuel_flight(
|
||||
country=country,
|
||||
|
||||
84
gen/armor.py
84
gen/armor.py
@ -32,7 +32,7 @@ from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import heading_sum, opposite_heading
|
||||
from game.utils import Heading
|
||||
from gen.ground_forces.ai_ground_planner import (
|
||||
DISTANCE_FROM_FRONTLINE,
|
||||
CombatGroup,
|
||||
@ -130,7 +130,7 @@ class GroundConflictGenerator:
|
||||
self.player_stance,
|
||||
player_groups,
|
||||
enemy_groups,
|
||||
self.conflict.heading + 90,
|
||||
self.conflict.heading.right,
|
||||
self.conflict.blue_cp,
|
||||
self.conflict.red_cp,
|
||||
)
|
||||
@ -138,7 +138,7 @@ class GroundConflictGenerator:
|
||||
self.enemy_stance,
|
||||
enemy_groups,
|
||||
player_groups,
|
||||
self.conflict.heading - 90,
|
||||
self.conflict.heading.left,
|
||||
self.conflict.red_cp,
|
||||
self.conflict.blue_cp,
|
||||
)
|
||||
@ -182,7 +182,11 @@ class GroundConflictGenerator:
|
||||
)
|
||||
|
||||
def gen_infantry_group_for_group(
|
||||
self, group: VehicleGroup, is_player: bool, side: Country, forward_heading: int
|
||||
self,
|
||||
group: VehicleGroup,
|
||||
is_player: bool,
|
||||
side: Country,
|
||||
forward_heading: Heading,
|
||||
) -> None:
|
||||
|
||||
infantry_position = self.conflict.find_ground_position(
|
||||
@ -217,7 +221,7 @@ class GroundConflictGenerator:
|
||||
u.dcs_unit_type,
|
||||
position=infantry_position,
|
||||
group_size=1,
|
||||
heading=forward_heading,
|
||||
heading=forward_heading.degrees,
|
||||
move_formation=PointAction.OffRoad,
|
||||
)
|
||||
return
|
||||
@ -244,7 +248,7 @@ class GroundConflictGenerator:
|
||||
units[0].dcs_unit_type,
|
||||
position=infantry_position,
|
||||
group_size=1,
|
||||
heading=forward_heading,
|
||||
heading=forward_heading.degrees,
|
||||
move_formation=PointAction.OffRoad,
|
||||
)
|
||||
|
||||
@ -256,17 +260,19 @@ class GroundConflictGenerator:
|
||||
unit.dcs_unit_type,
|
||||
position=position,
|
||||
group_size=1,
|
||||
heading=forward_heading,
|
||||
heading=forward_heading.degrees,
|
||||
move_formation=PointAction.OffRoad,
|
||||
)
|
||||
|
||||
def _set_reform_waypoint(
|
||||
self, dcs_group: VehicleGroup, forward_heading: int
|
||||
self, dcs_group: VehicleGroup, forward_heading: Heading
|
||||
) -> None:
|
||||
"""Setting a waypoint close to the spawn position allows the group to reform gracefully
|
||||
rather than spin
|
||||
"""
|
||||
reform_point = dcs_group.position.point_from_heading(forward_heading, 50)
|
||||
reform_point = dcs_group.position.point_from_heading(
|
||||
forward_heading.degrees, 50
|
||||
)
|
||||
dcs_group.add_waypoint(reform_point)
|
||||
|
||||
def _plan_artillery_action(
|
||||
@ -274,7 +280,7 @@ class GroundConflictGenerator:
|
||||
stance: CombatStance,
|
||||
gen_group: CombatGroup,
|
||||
dcs_group: VehicleGroup,
|
||||
forward_heading: int,
|
||||
forward_heading: Heading,
|
||||
target: Point,
|
||||
) -> bool:
|
||||
"""
|
||||
@ -308,7 +314,7 @@ class GroundConflictGenerator:
|
||||
dcs_group, forward_heading, (int)(RETREAT_DISTANCE / 3)
|
||||
)
|
||||
dcs_group.add_waypoint(
|
||||
dcs_group.position.point_from_heading(forward_heading, 1),
|
||||
dcs_group.position.point_from_heading(forward_heading.degrees, 1),
|
||||
PointAction.OffRoad,
|
||||
)
|
||||
dcs_group.points[2].tasks.append(Hold())
|
||||
@ -336,7 +342,7 @@ class GroundConflictGenerator:
|
||||
self.mission.triggerrules.triggers.append(artillery_fallback)
|
||||
|
||||
for u in dcs_group.units:
|
||||
u.heading = forward_heading + random.randint(-5, 5)
|
||||
u.heading = (forward_heading + Heading.random(-5, 5)).degrees
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -345,7 +351,7 @@ class GroundConflictGenerator:
|
||||
stance: CombatStance,
|
||||
enemy_groups: List[Tuple[VehicleGroup, CombatGroup]],
|
||||
dcs_group: VehicleGroup,
|
||||
forward_heading: int,
|
||||
forward_heading: Heading,
|
||||
to_cp: ControlPoint,
|
||||
) -> bool:
|
||||
"""
|
||||
@ -378,9 +384,7 @@ class GroundConflictGenerator:
|
||||
else:
|
||||
# We use an offset heading here because DCS doesn't always
|
||||
# force vehicles to move if there's no heading change.
|
||||
offset_heading = forward_heading - 2
|
||||
if offset_heading < 0:
|
||||
offset_heading = 358
|
||||
offset_heading = forward_heading - Heading.from_degrees(2)
|
||||
attack_point = self.find_offensive_point(
|
||||
dcs_group, offset_heading, AGGRESIVE_MOVE_DISTANCE
|
||||
)
|
||||
@ -398,9 +402,7 @@ class GroundConflictGenerator:
|
||||
else:
|
||||
# We use an offset heading here because DCS doesn't always
|
||||
# force vehicles to move if there's no heading change.
|
||||
offset_heading = forward_heading - 1
|
||||
if offset_heading < 0:
|
||||
offset_heading = 359
|
||||
offset_heading = forward_heading - Heading.from_degrees(1)
|
||||
attack_point = self.find_offensive_point(
|
||||
dcs_group, offset_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE
|
||||
)
|
||||
@ -436,7 +438,7 @@ class GroundConflictGenerator:
|
||||
self,
|
||||
stance: CombatStance,
|
||||
dcs_group: VehicleGroup,
|
||||
forward_heading: int,
|
||||
forward_heading: Heading,
|
||||
to_cp: ControlPoint,
|
||||
) -> bool:
|
||||
"""
|
||||
@ -473,7 +475,7 @@ class GroundConflictGenerator:
|
||||
stance: CombatStance,
|
||||
ally_groups: List[Tuple[VehicleGroup, CombatGroup]],
|
||||
enemy_groups: List[Tuple[VehicleGroup, CombatGroup]],
|
||||
forward_heading: int,
|
||||
forward_heading: Heading,
|
||||
from_cp: ControlPoint,
|
||||
to_cp: ControlPoint,
|
||||
) -> None:
|
||||
@ -514,12 +516,14 @@ class GroundConflictGenerator:
|
||||
else:
|
||||
retreat_point = self.find_retreat_point(dcs_group, forward_heading)
|
||||
reposition_point = retreat_point.point_from_heading(
|
||||
forward_heading, 10
|
||||
forward_heading.degrees, 10
|
||||
) # Another point to make the unit face the enemy
|
||||
dcs_group.add_waypoint(retreat_point, PointAction.OffRoad)
|
||||
dcs_group.add_waypoint(reposition_point, PointAction.OffRoad)
|
||||
|
||||
def add_morale_trigger(self, dcs_group: VehicleGroup, forward_heading: int) -> None:
|
||||
def add_morale_trigger(
|
||||
self, dcs_group: VehicleGroup, forward_heading: Heading
|
||||
) -> None:
|
||||
"""
|
||||
This add a trigger to manage units fleeing whenever their group is hit hard, or being engaged by CAS
|
||||
"""
|
||||
@ -532,7 +536,7 @@ class GroundConflictGenerator:
|
||||
|
||||
# Force unit heading
|
||||
for unit in dcs_group.units:
|
||||
unit.heading = forward_heading
|
||||
unit.heading = forward_heading.degrees
|
||||
dcs_group.manualHeading = True
|
||||
|
||||
# We add a new retreat waypoint
|
||||
@ -563,7 +567,7 @@ class GroundConflictGenerator:
|
||||
def find_retreat_point(
|
||||
self,
|
||||
dcs_group: VehicleGroup,
|
||||
frontline_heading: int,
|
||||
frontline_heading: Heading,
|
||||
distance: int = RETREAT_DISTANCE,
|
||||
) -> Point:
|
||||
"""
|
||||
@ -573,14 +577,14 @@ class GroundConflictGenerator:
|
||||
:return: dcs.mapping.Point object with the desired position
|
||||
"""
|
||||
desired_point = dcs_group.points[0].position.point_from_heading(
|
||||
heading_sum(frontline_heading, +180), distance
|
||||
frontline_heading.opposite.degrees, distance
|
||||
)
|
||||
if self.conflict.theater.is_on_land(desired_point):
|
||||
return desired_point
|
||||
return self.conflict.theater.nearest_land_pos(desired_point)
|
||||
|
||||
def find_offensive_point(
|
||||
self, dcs_group: VehicleGroup, frontline_heading: int, distance: int
|
||||
self, dcs_group: VehicleGroup, frontline_heading: Heading, distance: int
|
||||
) -> Point:
|
||||
"""
|
||||
Find a point to attack
|
||||
@ -590,7 +594,7 @@ class GroundConflictGenerator:
|
||||
:return: dcs.mapping.Point object with the desired position
|
||||
"""
|
||||
desired_point = dcs_group.points[0].position.point_from_heading(
|
||||
frontline_heading, distance
|
||||
frontline_heading.degrees, distance
|
||||
)
|
||||
if self.conflict.theater.is_on_land(desired_point):
|
||||
return desired_point
|
||||
@ -688,14 +692,14 @@ class GroundConflictGenerator:
|
||||
conflict_position: Point,
|
||||
combat_width: int,
|
||||
distance_from_frontline: int,
|
||||
heading: int,
|
||||
spawn_heading: int,
|
||||
heading: Heading,
|
||||
spawn_heading: Heading,
|
||||
) -> Optional[Point]:
|
||||
shifted = conflict_position.point_from_heading(
|
||||
heading, random.randint(0, combat_width)
|
||||
heading.degrees, random.randint(0, combat_width)
|
||||
)
|
||||
desired_point = shifted.point_from_heading(
|
||||
spawn_heading, distance_from_frontline
|
||||
spawn_heading.degrees, distance_from_frontline
|
||||
)
|
||||
return Conflict.find_ground_position(
|
||||
desired_point, combat_width, heading, self.conflict.theater
|
||||
@ -704,17 +708,13 @@ class GroundConflictGenerator:
|
||||
def _generate_groups(
|
||||
self,
|
||||
groups: list[CombatGroup],
|
||||
frontline_vector: Tuple[Point, int, int],
|
||||
frontline_vector: Tuple[Point, Heading, int],
|
||||
is_player: bool,
|
||||
) -> List[Tuple[VehicleGroup, CombatGroup]]:
|
||||
"""Finds valid positions for planned groups and generates a pydcs group for them"""
|
||||
positioned_groups = []
|
||||
position, heading, combat_width = frontline_vector
|
||||
spawn_heading = (
|
||||
int(heading_sum(heading, -90))
|
||||
if is_player
|
||||
else int(heading_sum(heading, 90))
|
||||
)
|
||||
spawn_heading = heading.left if is_player else heading.right
|
||||
country = self.game.coalition_for(is_player).country_name
|
||||
for group in groups:
|
||||
if group.role == CombatGroupRole.ARTILLERY:
|
||||
@ -737,7 +737,7 @@ class GroundConflictGenerator:
|
||||
group.unit_type,
|
||||
group.size,
|
||||
final_position,
|
||||
heading=opposite_heading(spawn_heading),
|
||||
heading=spawn_heading.opposite,
|
||||
)
|
||||
if is_player:
|
||||
g.set_skill(Skill(self.game.settings.player_skill))
|
||||
@ -750,7 +750,7 @@ class GroundConflictGenerator:
|
||||
g,
|
||||
is_player,
|
||||
self.mission.country(country),
|
||||
opposite_heading(spawn_heading),
|
||||
spawn_heading.opposite,
|
||||
)
|
||||
else:
|
||||
logging.warning(f"Unable to get valid position for {group}")
|
||||
@ -764,7 +764,7 @@ class GroundConflictGenerator:
|
||||
count: int,
|
||||
at: Point,
|
||||
move_formation: PointAction = PointAction.OffRoad,
|
||||
heading: int = 0,
|
||||
heading: Heading = Heading.from_degrees(0),
|
||||
) -> VehicleGroup:
|
||||
|
||||
if side == self.conflict.attackers_country:
|
||||
@ -778,7 +778,7 @@ class GroundConflictGenerator:
|
||||
unit_type.dcs_unit_type,
|
||||
position=at,
|
||||
group_size=count,
|
||||
heading=heading,
|
||||
heading=heading.degrees,
|
||||
move_formation=move_formation,
|
||||
)
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ from dcs.vehicles import MissilesSS, Unarmed, AirDefence
|
||||
from game import Game
|
||||
from game.factions.faction import Faction
|
||||
from game.theater.theatergroundobject import CoastalSiteGroundObject
|
||||
from game.utils import Heading
|
||||
from gen.sam.group_generator import VehicleGroupGenerator
|
||||
|
||||
|
||||
@ -59,5 +60,5 @@ class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]):
|
||||
"STRELA#0",
|
||||
self.position.x + 200,
|
||||
self.position.y + 15,
|
||||
90,
|
||||
Heading.from_degrees(90),
|
||||
)
|
||||
|
||||
@ -9,7 +9,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint
|
||||
|
||||
from game.theater.conflicttheater import ConflictTheater, FrontLine
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
from game.utils import heading_sum, opposite_heading
|
||||
from game.utils import Heading
|
||||
|
||||
|
||||
FRONTLINE_LENGTH = 80000
|
||||
@ -25,7 +25,7 @@ class Conflict:
|
||||
attackers_country: Country,
|
||||
defenders_country: Country,
|
||||
position: Point,
|
||||
heading: Optional[int] = None,
|
||||
heading: Optional[Heading] = None,
|
||||
size: Optional[int] = None,
|
||||
):
|
||||
|
||||
@ -55,28 +55,28 @@ class Conflict:
|
||||
@classmethod
|
||||
def frontline_position(
|
||||
cls, frontline: FrontLine, theater: ConflictTheater
|
||||
) -> Tuple[Point, int]:
|
||||
attack_heading = int(frontline.attack_heading)
|
||||
) -> Tuple[Point, Heading]:
|
||||
attack_heading = frontline.attack_heading
|
||||
position = cls.find_ground_position(
|
||||
frontline.position,
|
||||
FRONTLINE_LENGTH,
|
||||
heading_sum(attack_heading, 90),
|
||||
attack_heading.right,
|
||||
theater,
|
||||
)
|
||||
if position is None:
|
||||
raise RuntimeError("Could not find front line position")
|
||||
return position, opposite_heading(attack_heading)
|
||||
return position, attack_heading.opposite
|
||||
|
||||
@classmethod
|
||||
def frontline_vector(
|
||||
cls, front_line: FrontLine, theater: ConflictTheater
|
||||
) -> Tuple[Point, int, int]:
|
||||
) -> Tuple[Point, Heading, int]:
|
||||
"""
|
||||
Returns a vector for a valid frontline location avoiding exclusion zones.
|
||||
"""
|
||||
center_position, heading = cls.frontline_position(front_line, theater)
|
||||
left_heading = heading_sum(heading, -90)
|
||||
right_heading = heading_sum(heading, 90)
|
||||
left_heading = heading.left
|
||||
right_heading = heading.right
|
||||
left_position = cls.extend_ground_position(
|
||||
center_position, int(FRONTLINE_LENGTH / 2), left_heading, theater
|
||||
)
|
||||
@ -113,10 +113,14 @@ class Conflict:
|
||||
|
||||
@classmethod
|
||||
def extend_ground_position(
|
||||
cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater
|
||||
cls,
|
||||
initial: Point,
|
||||
max_distance: int,
|
||||
heading: Heading,
|
||||
theater: ConflictTheater,
|
||||
) -> Point:
|
||||
"""Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance"""
|
||||
extended = initial.point_from_heading(heading, max_distance)
|
||||
extended = initial.point_from_heading(heading.degrees, max_distance)
|
||||
if theater.landmap is None:
|
||||
# TODO: Why is this possible?
|
||||
return extended
|
||||
@ -133,14 +137,14 @@ class Conflict:
|
||||
return extended
|
||||
|
||||
# Otherwise extend the front line only up to the intersection.
|
||||
return initial.point_from_heading(heading, p0.distance(intersection))
|
||||
return initial.point_from_heading(heading.degrees, p0.distance(intersection))
|
||||
|
||||
@classmethod
|
||||
def find_ground_position(
|
||||
cls,
|
||||
initial: Point,
|
||||
max_distance: int,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
theater: ConflictTheater,
|
||||
coerce: bool = True,
|
||||
) -> Optional[Point]:
|
||||
@ -153,10 +157,10 @@ class Conflict:
|
||||
if theater.is_on_land(pos):
|
||||
return pos
|
||||
for distance in range(0, int(max_distance), 100):
|
||||
pos = initial.point_from_heading(heading, distance)
|
||||
pos = initial.point_from_heading(heading.degrees, distance)
|
||||
if theater.is_on_land(pos):
|
||||
return pos
|
||||
pos = initial.point_from_heading(opposite_heading(heading), distance)
|
||||
pos = initial.point_from_heading(heading.opposite.degrees, distance)
|
||||
if theater.is_on_land(pos):
|
||||
return pos
|
||||
if coerce:
|
||||
|
||||
@ -3,7 +3,6 @@ from typing import Optional
|
||||
from dcs.mission import Mission
|
||||
|
||||
from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions
|
||||
from .units import inches_hg_to_mm_hg
|
||||
|
||||
|
||||
class EnvironmentGenerator:
|
||||
@ -12,7 +11,7 @@ class EnvironmentGenerator:
|
||||
self.conditions = conditions
|
||||
|
||||
def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None:
|
||||
self.mission.weather.qnh = inches_hg_to_mm_hg(atmospheric.qnh_inches_mercury)
|
||||
self.mission.weather.qnh = atmospheric.qnh.mm_hg
|
||||
self.mission.weather.season_temperature = atmospheric.temperature_celsius
|
||||
|
||||
def set_clouds(self, clouds: Optional[Clouds]) -> None:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import random
|
||||
|
||||
from gen.sam.group_generator import ShipGroupGenerator
|
||||
from game.utils import Heading
|
||||
|
||||
from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG
|
||||
|
||||
@ -54,7 +55,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
|
||||
# Add Ticonderoga escort
|
||||
if self.heading >= 180:
|
||||
if self.heading >= Heading.from_degrees(180):
|
||||
self.add_unit(
|
||||
TICONDEROG,
|
||||
"USS Hué City",
|
||||
|
||||
@ -2,13 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from typing import List, Optional, TYPE_CHECKING, Union, Sequence
|
||||
from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.point import MovingPoint, PointAction
|
||||
from dcs.unit import Unit
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.savecompat import has_save_compat_for
|
||||
from game.squadrons import Pilot, Squadron
|
||||
from game.theater.controlpoint import ControlPoint, MissionTarget
|
||||
from game.utils import Distance, meters
|
||||
@ -138,7 +139,7 @@ class FlightWaypoint:
|
||||
|
||||
Args:
|
||||
waypoint_type: The waypoint type.
|
||||
x: X cooidinate of the waypoint.
|
||||
x: X coordinate of the waypoint.
|
||||
y: Y coordinate of the waypoint.
|
||||
alt: Altitude of the waypoint. By default this is AGL, but it can be
|
||||
changed to MSL by setting alt_type to "RADIO".
|
||||
@ -158,6 +159,8 @@ class FlightWaypoint:
|
||||
self.pretty_name = ""
|
||||
self.only_for_player = False
|
||||
self.flyover = False
|
||||
# The minimum amount of fuel remaining at this waypoint in pounds.
|
||||
self.min_fuel: Optional[float] = None
|
||||
|
||||
# These are set very late by the air conflict generator (part of mission
|
||||
# generation). We do it late so that we don't need to propagate changes
|
||||
@ -166,6 +169,12 @@ class FlightWaypoint:
|
||||
self.tot: Optional[timedelta] = None
|
||||
self.departure_time: Optional[timedelta] = None
|
||||
|
||||
@has_save_compat_for(5)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "min_fuel" not in state:
|
||||
state["min_fuel"] = None
|
||||
self.__dict__.update(state)
|
||||
|
||||
@property
|
||||
def position(self) -> Point:
|
||||
return Point(self.x, self.y)
|
||||
|
||||
@ -20,6 +20,8 @@ from dcs.unit import Unit
|
||||
from shapely.geometry import Point as ShapelyPoint
|
||||
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.dcs.aircrafttype import FuelConsumption
|
||||
from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry
|
||||
from game.theater import (
|
||||
Airfield,
|
||||
ControlPoint,
|
||||
@ -35,8 +37,10 @@ from game.theater.theatergroundobject import (
|
||||
NavalGroundObject,
|
||||
BuildingGroundObject,
|
||||
)
|
||||
|
||||
from game.threatzones import ThreatZones
|
||||
from game.utils import Distance, Speed, feet, meters, nautical_miles, knots
|
||||
from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots
|
||||
|
||||
from .closestairfields import ObjectiveDistanceCache
|
||||
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
|
||||
from .traveltime import GroundSpeed, TravelTime
|
||||
@ -137,6 +141,17 @@ class FlightPlan:
|
||||
@cached_property
|
||||
def bingo_fuel(self) -> int:
|
||||
"""Bingo fuel value for the FlightPlan"""
|
||||
if (fuel := self.flight.unit_type.fuel_consumption) is not None:
|
||||
return self._bingo_estimate(fuel)
|
||||
return self._legacy_bingo_estimate()
|
||||
|
||||
def _bingo_estimate(self, fuel: FuelConsumption) -> int:
|
||||
distance_to_arrival = self.max_distance_from(self.flight.arrival)
|
||||
fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
|
||||
bingo = fuel_consumed + fuel.min_safe
|
||||
return math.ceil(bingo / 100) * 100
|
||||
|
||||
def _legacy_bingo_estimate(self) -> int:
|
||||
distance_to_arrival = self.max_distance_from(self.flight.arrival)
|
||||
|
||||
bingo = 1000.0 # Minimum Emergency Fuel
|
||||
@ -946,87 +961,33 @@ class FlightPlanBuilder:
|
||||
raise PlanningError(f"{task} flight plan generation not implemented")
|
||||
|
||||
def regenerate_package_waypoints(self) -> None:
|
||||
# The simple case is where the target is not near the departure airfield. In
|
||||
# this case, we can plan the shortest route from the departure airfield to the
|
||||
# target, use the nearest non-threatened point *that's farther from the target
|
||||
# than the ingress point to avoid backtracking) as the join point.
|
||||
#
|
||||
# The other case that we need to handle is when the target is close to
|
||||
# the origin airfield. In this case we currently fall back to the old planning
|
||||
# behavior.
|
||||
#
|
||||
# A messy (and very unlikely) case that we can't do much about:
|
||||
#
|
||||
# +--------------+ +---------------+
|
||||
# | | | |
|
||||
# | IP-+---+-T |
|
||||
# | | | |
|
||||
# | | | |
|
||||
# +--------------+ +---------------+
|
||||
from gen.ato import PackageWaypoints
|
||||
|
||||
target = self.package.target.position
|
||||
package_airfield = self.package_airfield()
|
||||
|
||||
for join_point in self.preferred_join_points():
|
||||
join_distance = meters(join_point.distance_to_point(target))
|
||||
if join_distance > self.doctrine.ingress_distance:
|
||||
break
|
||||
else:
|
||||
# The entire path to the target is threatened. Use the fallback behavior for
|
||||
# now.
|
||||
self.legacy_package_waypoints_impl()
|
||||
return
|
||||
# Start by picking the best IP for the attack.
|
||||
ingress_point = IpZoneGeometry(
|
||||
self.package.target.position,
|
||||
package_airfield.position,
|
||||
self.coalition,
|
||||
).find_best_ip()
|
||||
|
||||
attack_heading = join_point.heading_between_point(target)
|
||||
ingress_point = self._ingress_point(attack_heading)
|
||||
|
||||
# The first case described above. The ingress and join points are placed
|
||||
# reasonably relative to each other.
|
||||
self.package.waypoints = PackageWaypoints(
|
||||
WaypointBuilder.perturb(join_point),
|
||||
join_point = JoinZoneGeometry(
|
||||
self.package.target.position,
|
||||
package_airfield.position,
|
||||
ingress_point,
|
||||
WaypointBuilder.perturb(
|
||||
self.preferred_split_point(ingress_point, join_point)
|
||||
),
|
||||
)
|
||||
self.coalition,
|
||||
).find_best_join_point()
|
||||
|
||||
def retreat_point(self, origin: Point) -> Point:
|
||||
return self.threat_zones.closest_boundary(origin)
|
||||
|
||||
def legacy_package_waypoints_impl(self) -> None:
|
||||
from gen.ato import PackageWaypoints
|
||||
|
||||
ingress_point = self._ingress_point(self._target_heading_to_package_airfield())
|
||||
join_point = self._rendezvous_point(ingress_point)
|
||||
# And the split point based on the best route from the IP. Since that's no
|
||||
# different than the best route *to* the IP, this is the same as the join point.
|
||||
# TODO: Estimate attack completion point based on the IP and split from there?
|
||||
self.package.waypoints = PackageWaypoints(
|
||||
WaypointBuilder.perturb(join_point),
|
||||
ingress_point,
|
||||
WaypointBuilder.perturb(join_point),
|
||||
)
|
||||
|
||||
def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]:
|
||||
for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]:
|
||||
if not self.threat_zones.threatened(point):
|
||||
yield point
|
||||
|
||||
def preferred_join_points(self) -> Iterator[Point]:
|
||||
# Use non-threatened points along the path to the target as the join point. We
|
||||
# may need to try more than one in the event that the close non-threatened
|
||||
# points are closer than the ingress point itself.
|
||||
return self.safe_points_between(
|
||||
self.package.target.position, self.package_airfield().position
|
||||
)
|
||||
|
||||
def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point:
|
||||
# Use non-threatened points along the path to the target as the join point. We
|
||||
# may need to try more than one in the event that the close non-threatened
|
||||
# points are closer than the ingress point itself.
|
||||
for point in self.safe_points_between(
|
||||
ingress_point, self.package_airfield().position
|
||||
):
|
||||
return point
|
||||
return join_point
|
||||
|
||||
def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
|
||||
"""Generates a strike flight plan.
|
||||
|
||||
@ -1192,10 +1153,11 @@ class FlightPlanBuilder:
|
||||
"""
|
||||
assert self.package.waypoints is not None
|
||||
target = self.package.target.position
|
||||
|
||||
heading = self.package.waypoints.join.heading_between_point(target)
|
||||
heading = Heading.from_degrees(
|
||||
self.package.waypoints.join.heading_between_point(target)
|
||||
)
|
||||
start_pos = target.point_from_heading(
|
||||
heading, -self.doctrine.sweep_distance.meters
|
||||
heading.degrees, -self.doctrine.sweep_distance.meters
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(flight, self.coalition)
|
||||
@ -1290,7 +1252,9 @@ class FlightPlanBuilder:
|
||||
else:
|
||||
raise PlanningError("Could not find any enemy airfields")
|
||||
|
||||
heading = location.position.heading_between_point(closest_airfield.position)
|
||||
heading = Heading.from_degrees(
|
||||
location.position.heading_between_point(closest_airfield.position)
|
||||
)
|
||||
|
||||
position = ShapelyPoint(
|
||||
self.package.target.position.x, self.package.target.position.y
|
||||
@ -1326,20 +1290,20 @@ class FlightPlanBuilder:
|
||||
)
|
||||
|
||||
end = location.position.point_from_heading(
|
||||
heading,
|
||||
heading.degrees,
|
||||
random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)),
|
||||
)
|
||||
diameter = random.randint(
|
||||
int(self.doctrine.cap_min_track_length.meters),
|
||||
int(self.doctrine.cap_max_track_length.meters),
|
||||
)
|
||||
start = end.point_from_heading(heading - 180, diameter)
|
||||
start = end.point_from_heading(heading.opposite.degrees, diameter)
|
||||
return start, end
|
||||
|
||||
def aewc_orbit(self, location: MissionTarget) -> Point:
|
||||
closest_boundary = self.threat_zones.closest_boundary(location.position)
|
||||
heading_to_threat_boundary = location.position.heading_between_point(
|
||||
closest_boundary
|
||||
heading_to_threat_boundary = Heading.from_degrees(
|
||||
location.position.heading_between_point(closest_boundary)
|
||||
)
|
||||
distance_to_threat = meters(
|
||||
location.position.distance_to_point(closest_boundary)
|
||||
@ -1353,7 +1317,7 @@ class FlightPlanBuilder:
|
||||
orbit_distance = distance_to_threat - threat_buffer
|
||||
|
||||
return location.position.point_from_heading(
|
||||
orbit_heading, orbit_distance.meters
|
||||
orbit_heading.degrees, orbit_distance.meters
|
||||
)
|
||||
|
||||
def racetrack_for_frontline(
|
||||
@ -1361,9 +1325,9 @@ class FlightPlanBuilder:
|
||||
) -> Tuple[Point, Point]:
|
||||
# Find targets waypoints
|
||||
ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater)
|
||||
center = ingress.point_from_heading(heading, distance / 2)
|
||||
center = ingress.point_from_heading(heading.degrees, distance / 2)
|
||||
orbit_center = center.point_from_heading(
|
||||
heading - 90,
|
||||
heading.left.degrees,
|
||||
random.randint(
|
||||
int(nautical_miles(6).meters), int(nautical_miles(15).meters)
|
||||
),
|
||||
@ -1376,8 +1340,8 @@ class FlightPlanBuilder:
|
||||
combat_width = 35000
|
||||
|
||||
radius = combat_width * 1.25
|
||||
start = orbit_center.point_from_heading(heading, radius)
|
||||
end = orbit_center.point_from_heading(heading + 180, radius)
|
||||
start = orbit_center.point_from_heading(heading.degrees, radius)
|
||||
end = orbit_center.point_from_heading(heading.opposite.degrees, radius)
|
||||
|
||||
if end.distance_to_point(origin) < start.distance_to_point(origin):
|
||||
start, end = end, start
|
||||
@ -1571,8 +1535,8 @@ class FlightPlanBuilder:
|
||||
raise InvalidObjectiveLocation(flight.flight_type, location)
|
||||
|
||||
ingress, heading, distance = Conflict.frontline_vector(location, self.theater)
|
||||
center = ingress.point_from_heading(heading, distance / 2)
|
||||
egress = ingress.point_from_heading(heading, distance)
|
||||
center = ingress.point_from_heading(heading.degrees, distance / 2)
|
||||
egress = ingress.point_from_heading(heading.degrees, distance)
|
||||
|
||||
ingress_distance = ingress.distance_to_point(flight.departure.position)
|
||||
egress_distance = egress.distance_to_point(flight.departure.position)
|
||||
@ -1607,8 +1571,8 @@ class FlightPlanBuilder:
|
||||
location = self.package.target
|
||||
|
||||
closest_boundary = self.threat_zones.closest_boundary(location.position)
|
||||
heading_to_threat_boundary = location.position.heading_between_point(
|
||||
closest_boundary
|
||||
heading_to_threat_boundary = Heading.from_degrees(
|
||||
location.position.heading_between_point(closest_boundary)
|
||||
)
|
||||
distance_to_threat = meters(
|
||||
location.position.distance_to_point(closest_boundary)
|
||||
@ -1623,16 +1587,16 @@ class FlightPlanBuilder:
|
||||
orbit_distance = distance_to_threat - threat_buffer
|
||||
|
||||
racetrack_center = location.position.point_from_heading(
|
||||
orbit_heading, orbit_distance.meters
|
||||
orbit_heading.degrees, orbit_distance.meters
|
||||
)
|
||||
|
||||
racetrack_half_distance = Distance.from_nautical_miles(20).meters
|
||||
|
||||
racetrack_start = racetrack_center.point_from_heading(
|
||||
orbit_heading + 90, racetrack_half_distance
|
||||
orbit_heading.right.degrees, racetrack_half_distance
|
||||
)
|
||||
racetrack_end = racetrack_center.point_from_heading(
|
||||
orbit_heading - 90, racetrack_half_distance
|
||||
orbit_heading.left.degrees, racetrack_half_distance
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(flight, self.coalition)
|
||||
@ -1702,48 +1666,10 @@ class FlightPlanBuilder:
|
||||
origin = flight.departure.position
|
||||
target = self.package.target.position
|
||||
join = self.package.waypoints.join
|
||||
origin_to_join = origin.distance_to_point(join)
|
||||
if meters(origin_to_join) < self.doctrine.push_distance:
|
||||
# If the origin airfield is closer to the join point, than the minimum push
|
||||
# distance. Plan the hold point such that it retreats from the origin
|
||||
# airfield.
|
||||
return join.point_from_heading(
|
||||
target.heading_between_point(origin), self.doctrine.push_distance.meters
|
||||
)
|
||||
|
||||
heading_to_join = origin.heading_between_point(join)
|
||||
hold_point = origin.point_from_heading(
|
||||
heading_to_join, self.doctrine.push_distance.meters
|
||||
)
|
||||
hold_distance = meters(hold_point.distance_to_point(join))
|
||||
if hold_distance >= self.doctrine.push_distance:
|
||||
# Hold point is between the origin airfield and the join point and
|
||||
# spaced sufficiently.
|
||||
return hold_point
|
||||
|
||||
# The hold point is between the origin airfield and the join point, but
|
||||
# the distance between the hold point and the join point is too short.
|
||||
# Bend the hold point out to extend the distance while maintaining the
|
||||
# minimum distance from the origin airfield to keep the AI flying
|
||||
# properly.
|
||||
origin_to_join = origin.distance_to_point(join)
|
||||
cos_theta = (
|
||||
self.doctrine.hold_distance.meters ** 2
|
||||
+ origin_to_join ** 2
|
||||
- self.doctrine.join_distance.meters ** 2
|
||||
) / (2 * self.doctrine.hold_distance.meters * origin_to_join)
|
||||
try:
|
||||
theta = math.acos(cos_theta)
|
||||
except ValueError:
|
||||
# No solution that maintains hold and join distances. Extend the
|
||||
# hold point away from the target.
|
||||
return origin.point_from_heading(
|
||||
target.heading_between_point(origin), self.doctrine.hold_distance.meters
|
||||
)
|
||||
|
||||
return origin.point_from_heading(
|
||||
heading_to_join - theta, self.doctrine.hold_distance.meters
|
||||
)
|
||||
ip = self.package.waypoints.ingress
|
||||
return HoldZoneGeometry(
|
||||
target, origin, ip, join, self.coalition, self.theater
|
||||
).find_best_hold_point()
|
||||
|
||||
# TODO: Make a model for the waypoint builder and use that in the UI.
|
||||
def generate_rtb_waypoint(
|
||||
@ -1806,59 +1732,6 @@ class FlightPlanBuilder:
|
||||
lead_time=lead_time,
|
||||
)
|
||||
|
||||
def _retreating_rendezvous_point(self, attack_transition: Point) -> Point:
|
||||
"""Creates a rendezvous point that retreats from the origin airfield."""
|
||||
return attack_transition.point_from_heading(
|
||||
self.package.target.position.heading_between_point(
|
||||
self.package_airfield().position
|
||||
),
|
||||
self.doctrine.join_distance.meters,
|
||||
)
|
||||
|
||||
def _advancing_rendezvous_point(self, attack_transition: Point) -> Point:
|
||||
"""Creates a rendezvous point that advances toward the target."""
|
||||
heading = self._heading_to_package_airfield(attack_transition)
|
||||
return attack_transition.point_from_heading(
|
||||
heading, -self.doctrine.join_distance.meters
|
||||
)
|
||||
|
||||
def _rendezvous_should_retreat(self, attack_transition: Point) -> bool:
|
||||
transition_target_distance = attack_transition.distance_to_point(
|
||||
self.package.target.position
|
||||
)
|
||||
origin_target_distance = self._distance_to_package_airfield(
|
||||
self.package.target.position
|
||||
)
|
||||
|
||||
# If the origin point is closer to the target than the ingress point,
|
||||
# the rendezvous point should be positioned in a position that retreats
|
||||
# from the origin airfield.
|
||||
return origin_target_distance < transition_target_distance
|
||||
|
||||
def _rendezvous_point(self, attack_transition: Point) -> Point:
|
||||
"""Returns the position of the rendezvous point.
|
||||
|
||||
Args:
|
||||
attack_transition: The ingress or target point for this rendezvous.
|
||||
"""
|
||||
if self._rendezvous_should_retreat(attack_transition):
|
||||
return self._retreating_rendezvous_point(attack_transition)
|
||||
return self._advancing_rendezvous_point(attack_transition)
|
||||
|
||||
def _ingress_point(self, heading: float) -> Point:
|
||||
return self.package.target.position.point_from_heading(
|
||||
heading - 180, self.doctrine.ingress_distance.meters
|
||||
)
|
||||
|
||||
def _target_heading_to_package_airfield(self) -> float:
|
||||
return self._heading_to_package_airfield(self.package.target.position)
|
||||
|
||||
def _heading_to_package_airfield(self, point: Point) -> float:
|
||||
return self.package_airfield().position.heading_between_point(point)
|
||||
|
||||
def _distance_to_package_airfield(self, point: Point) -> float:
|
||||
return self.package_airfield().position.distance_to_point(point)
|
||||
|
||||
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.
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping
|
||||
from collections import Iterable
|
||||
from typing import Optional, Iterator, TYPE_CHECKING, Mapping
|
||||
|
||||
from game.data.weapons import Weapon, Pylon
|
||||
from game.data.weapons import Weapon, Pylon, WeaponType
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -30,9 +31,28 @@ class Loadout:
|
||||
def derive_custom(self, name: str) -> Loadout:
|
||||
return Loadout(name, self.pylons, self.date, is_custom=True)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_for(
|
||||
weapon: Weapon,
|
||||
pylon: Pylon,
|
||||
date: datetime.date,
|
||||
skip_types: Optional[Iterable[WeaponType]] = None,
|
||||
) -> Optional[Weapon]:
|
||||
if skip_types is None:
|
||||
skip_types = set()
|
||||
for fallback in weapon.fallbacks:
|
||||
if not pylon.can_equip(fallback):
|
||||
continue
|
||||
if not fallback.available_on(date):
|
||||
continue
|
||||
if fallback.weapon_group.type in skip_types:
|
||||
continue
|
||||
return fallback
|
||||
return None
|
||||
|
||||
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
|
||||
if self.date is not None and self.date <= date:
|
||||
return Loadout(self.name, self.pylons, self.date)
|
||||
return Loadout(self.name, self.pylons, self.date, self.is_custom)
|
||||
|
||||
new_pylons = dict(self.pylons)
|
||||
for pylon_number, weapon in self.pylons.items():
|
||||
@ -41,16 +61,41 @@ class Loadout:
|
||||
continue
|
||||
if not weapon.available_on(date):
|
||||
pylon = Pylon.for_aircraft(unit_type, pylon_number)
|
||||
for fallback in weapon.fallbacks:
|
||||
if not pylon.can_equip(fallback):
|
||||
continue
|
||||
if not fallback.available_on(date):
|
||||
continue
|
||||
new_pylons[pylon_number] = fallback
|
||||
break
|
||||
else:
|
||||
fallback = self._fallback_for(weapon, pylon, date)
|
||||
if fallback is None:
|
||||
del new_pylons[pylon_number]
|
||||
return Loadout(f"{self.name} ({date.year})", new_pylons, date)
|
||||
else:
|
||||
new_pylons[pylon_number] = fallback
|
||||
loadout = Loadout(self.name, new_pylons, date, self.is_custom)
|
||||
# If this is not a custom loadout, we should replace any LGBs with iron bombs if
|
||||
# the loadout lost its TGP.
|
||||
#
|
||||
# If the loadout was chosen explicitly by the user, assume they know what
|
||||
# they're doing. They may be coordinating buddy-lase.
|
||||
if not loadout.is_custom:
|
||||
loadout.replace_lgbs_if_no_tgp(unit_type, date)
|
||||
return loadout
|
||||
|
||||
def replace_lgbs_if_no_tgp(
|
||||
self, unit_type: AircraftType, date: datetime.date
|
||||
) -> None:
|
||||
for weapon in self.pylons.values():
|
||||
if weapon is not None and weapon.weapon_group.type is WeaponType.TGP:
|
||||
# Have a TGP. Nothing to do.
|
||||
return
|
||||
|
||||
new_pylons = dict(self.pylons)
|
||||
for pylon_number, weapon in self.pylons.items():
|
||||
if weapon is not None and weapon.weapon_group.type is WeaponType.LGB:
|
||||
pylon = Pylon.for_aircraft(unit_type, pylon_number)
|
||||
fallback = self._fallback_for(
|
||||
weapon, pylon, date, skip_types={WeaponType.LGB}
|
||||
)
|
||||
if fallback is None:
|
||||
del new_pylons[pylon_number]
|
||||
else:
|
||||
new_pylons[pylon_number] = fallback
|
||||
self.pylons = new_pylons
|
||||
|
||||
@classmethod
|
||||
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
|
||||
@ -72,10 +117,6 @@ class Loadout:
|
||||
date=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def all_for(cls, flight: Flight) -> List[Loadout]:
|
||||
return list(cls.iter_for(flight))
|
||||
|
||||
@classmethod
|
||||
def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
@ -55,7 +55,7 @@ from game.theater.theatergroundobject import (
|
||||
SceneryGroundObject,
|
||||
)
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import feet, knots, mps
|
||||
from game.utils import Heading, feet, knots, mps
|
||||
from .radios import RadioFrequency, RadioRegistry
|
||||
from .runways import RunwayData
|
||||
from .tacan import TacanBand, TacanChannel, TacanRegistry
|
||||
@ -166,7 +166,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject]
|
||||
if targets:
|
||||
target = random.choice(targets)
|
||||
real_target = target.point_from_heading(
|
||||
random.randint(0, 360), random.randint(0, 2500)
|
||||
Heading.random().degrees, random.randint(0, 2500)
|
||||
)
|
||||
vg.points[0].add_task(FireAtPoint(real_target))
|
||||
logging.info("Set up fire task for missile group.")
|
||||
@ -246,7 +246,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]):
|
||||
name=self.ground_object.group_name,
|
||||
_type=unit_type,
|
||||
position=self.ground_object.position,
|
||||
heading=self.ground_object.heading,
|
||||
heading=self.ground_object.heading.degrees,
|
||||
)
|
||||
self._register_fortification(group)
|
||||
|
||||
@ -256,7 +256,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]):
|
||||
name=self.ground_object.group_name,
|
||||
_type=static_type,
|
||||
position=self.ground_object.position,
|
||||
heading=self.ground_object.heading,
|
||||
heading=self.ground_object.heading.degrees,
|
||||
dead=self.ground_object.is_dead,
|
||||
)
|
||||
self._register_building(group)
|
||||
@ -387,7 +387,9 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO
|
||||
# time as the recovery window.
|
||||
brc = self.steam_into_wind(ship_group)
|
||||
self.activate_beacons(ship_group, tacan, tacan_callsign, icls)
|
||||
self.add_runway_data(brc or 0, atc, tacan, tacan_callsign, icls)
|
||||
self.add_runway_data(
|
||||
brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls
|
||||
)
|
||||
self._register_unit_group(group, ship_group)
|
||||
|
||||
def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]:
|
||||
@ -422,14 +424,14 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO
|
||||
ship.set_frequency(atc_channel.hertz)
|
||||
return ship
|
||||
|
||||
def steam_into_wind(self, group: ShipGroup) -> Optional[int]:
|
||||
def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]:
|
||||
wind = self.game.conditions.weather.wind.at_0m
|
||||
brc = wind.direction + 180
|
||||
brc = Heading.from_degrees(wind.direction).opposite
|
||||
# Aim for 25kts over the deck.
|
||||
carrier_speed = knots(25) - mps(wind.speed)
|
||||
for attempt in range(5):
|
||||
point = group.points[0].position.point_from_heading(
|
||||
brc, 100000 - attempt * 20000
|
||||
brc.degrees, 100000 - attempt * 20000
|
||||
)
|
||||
if self.game.theater.is_in_sea(point):
|
||||
group.points[0].speed = carrier_speed.meters_per_second
|
||||
@ -459,7 +461,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO
|
||||
|
||||
def add_runway_data(
|
||||
self,
|
||||
brc: int,
|
||||
brc: Heading,
|
||||
atc: RadioFrequency,
|
||||
tacan: TacanChannel,
|
||||
callsign: str,
|
||||
@ -593,7 +595,7 @@ class HelipadGenerator:
|
||||
logging.info("Generating helipad : " + name)
|
||||
pad = SingleHeliPad(name=(name + "_unit"))
|
||||
pad.position = Point(helipad.x, helipad.y)
|
||||
pad.heading = helipad.heading
|
||||
pad.heading = helipad.heading.degrees
|
||||
# pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign
|
||||
sg = unitgroup.StaticGroup(self.m.next_group_id(), name)
|
||||
sg.add_unit(pad)
|
||||
|
||||
@ -23,6 +23,7 @@ only be added per airframe, so PvP missions where each side have the same
|
||||
aircraft will be able to see the enemy's kneeboard for the same airframe.
|
||||
"""
|
||||
import datetime
|
||||
import math
|
||||
import textwrap
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
@ -39,15 +40,14 @@ from game.db import unit_type_from_name
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.theater import ConflictTheater, TheaterGroundObject, LatLon
|
||||
from game.theater.bullseye import Bullseye
|
||||
from game.weather import Weather
|
||||
from game.utils import meters
|
||||
from game.weather import Weather
|
||||
from .aircraft import FlightData
|
||||
from .airsupportgen import AwacsInfo, TankerInfo
|
||||
from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator
|
||||
from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType
|
||||
from .radios import RadioFrequency
|
||||
from .runways import RunwayData
|
||||
from .units import inches_hg_to_mm_hg, inches_hg_to_hpa
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
@ -112,12 +112,17 @@ class KneeboardPageWriter:
|
||||
self.text(text, font=self.heading_font, fill=self.foreground_fill)
|
||||
|
||||
def table(
|
||||
self, cells: List[List[str]], headers: Optional[List[str]] = None
|
||||
self,
|
||||
cells: List[List[str]],
|
||||
headers: Optional[List[str]] = None,
|
||||
font: Optional[ImageFont.FreeTypeFont] = None,
|
||||
) -> None:
|
||||
if headers is None:
|
||||
headers = []
|
||||
if font is None:
|
||||
font = self.table_font
|
||||
table = tabulate(cells, headers=headers, numalign="right")
|
||||
self.text(table, font=self.table_font, fill=self.foreground_fill)
|
||||
self.text(table, font, fill=self.foreground_fill)
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
self.image.save(path)
|
||||
@ -200,6 +205,7 @@ class FlightPlanBuilder:
|
||||
self._ground_speed(self.target_points[0].waypoint),
|
||||
self._format_time(self.target_points[0].waypoint.tot),
|
||||
self._format_time(self.target_points[0].waypoint.departure_time),
|
||||
self._format_min_fuel(self.target_points[0].waypoint.min_fuel),
|
||||
]
|
||||
)
|
||||
self.last_waypoint = self.target_points[-1].waypoint
|
||||
@ -217,6 +223,7 @@ class FlightPlanBuilder:
|
||||
self._ground_speed(waypoint.waypoint),
|
||||
self._format_time(waypoint.waypoint.tot),
|
||||
self._format_time(waypoint.waypoint.departure_time),
|
||||
self._format_min_fuel(waypoint.waypoint.min_fuel),
|
||||
]
|
||||
)
|
||||
|
||||
@ -255,6 +262,12 @@ class FlightPlanBuilder:
|
||||
duration = (waypoint.tot - last_time).total_seconds() / 3600
|
||||
return f"{int(distance.nautical_miles / duration)} kt"
|
||||
|
||||
@staticmethod
|
||||
def _format_min_fuel(min_fuel: Optional[float]) -> str:
|
||||
if min_fuel is None:
|
||||
return ""
|
||||
return str(math.ceil(min_fuel / 100) * 100)
|
||||
|
||||
def build(self) -> List[List[str]]:
|
||||
return self.rows
|
||||
|
||||
@ -277,6 +290,11 @@ class BriefingPage(KneeboardPage):
|
||||
self.weather = weather
|
||||
self.start_time = start_time
|
||||
self.dark_kneeboard = dark_kneeboard
|
||||
self.flight_plan_font = ImageFont.truetype(
|
||||
"resources/fonts/Inconsolata.otf",
|
||||
16,
|
||||
layout_engine=ImageFont.LAYOUT_BASIC,
|
||||
)
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
@ -303,18 +321,24 @@ class BriefingPage(KneeboardPage):
|
||||
flight_plan_builder.add_waypoint(num, waypoint)
|
||||
writer.table(
|
||||
flight_plan_builder.build(),
|
||||
headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"],
|
||||
headers=[
|
||||
"#",
|
||||
"Action",
|
||||
"Alt",
|
||||
"Dist",
|
||||
"GSPD",
|
||||
"Time",
|
||||
"Departure",
|
||||
"Min fuel",
|
||||
],
|
||||
font=self.flight_plan_font,
|
||||
)
|
||||
|
||||
writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")
|
||||
|
||||
qnh_in_hg = "{:.2f}".format(self.weather.atmospheric.qnh_inches_mercury)
|
||||
qnh_mm_hg = "{:.1f}".format(
|
||||
inches_hg_to_mm_hg(self.weather.atmospheric.qnh_inches_mercury)
|
||||
)
|
||||
qnh_hpa = "{:.1f}".format(
|
||||
inches_hg_to_hpa(self.weather.atmospheric.qnh_inches_mercury)
|
||||
)
|
||||
qnh_in_hg = f"{self.weather.atmospheric.qnh.inches_hg:.2f}"
|
||||
qnh_mm_hg = f"{self.weather.atmospheric.qnh.mm_hg:.1f}"
|
||||
qnh_hpa = f"{self.weather.atmospheric.qnh.hecto_pascals:.1f}"
|
||||
writer.text(
|
||||
f"Temperature: {round(self.weather.atmospheric.temperature_celsius)} °C at sea level"
|
||||
)
|
||||
@ -578,20 +602,16 @@ class NotesPage(KneeboardPage):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
game: "Game",
|
||||
notes: str,
|
||||
dark_kneeboard: bool,
|
||||
) -> None:
|
||||
self.game = game
|
||||
self.notes = notes
|
||||
self.dark_kneeboard = dark_kneeboard
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
writer.title(f"Notes")
|
||||
|
||||
try:
|
||||
writer.text(self.game.notes)
|
||||
except AttributeError: # old saves may not have .notes ;)
|
||||
writer.text("")
|
||||
writer.text(self.notes)
|
||||
writer.write(path)
|
||||
|
||||
|
||||
@ -663,12 +683,12 @@ class KneeboardGenerator(MissionInfoGenerator):
|
||||
self.mission.start_time,
|
||||
self.dark_kneeboard,
|
||||
),
|
||||
NotesPage(
|
||||
self.game,
|
||||
self.dark_kneeboard,
|
||||
),
|
||||
]
|
||||
|
||||
# Only create the notes page if there are notes to show.
|
||||
if notes := self.game.notes:
|
||||
pages.append(NotesPage(notes, self.dark_kneeboard))
|
||||
|
||||
if (target_page := self.generate_task_page(flight)) is not None:
|
||||
pages.append(target_page)
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence
|
||||
from game import Game
|
||||
from game.factions.faction import Faction
|
||||
from game.theater.theatergroundobject import MissileSiteGroundObject
|
||||
from game.utils import Heading
|
||||
from gen.sam.group_generator import VehicleGroupGenerator
|
||||
|
||||
|
||||
@ -63,5 +64,5 @@ class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]):
|
||||
"STRELA#0",
|
||||
self.position.x + 200,
|
||||
self.position.y + 15,
|
||||
90,
|
||||
Heading.from_degrees(90),
|
||||
)
|
||||
|
||||
@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence
|
||||
from game import Game
|
||||
from game.factions.faction import Faction
|
||||
from game.theater.theatergroundobject import MissileSiteGroundObject
|
||||
from game.utils import Heading
|
||||
from gen.sam.group_generator import VehicleGroupGenerator
|
||||
|
||||
|
||||
@ -65,5 +66,5 @@ class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]):
|
||||
"Blitz#0",
|
||||
self.position.x + 200,
|
||||
self.position.y + 15,
|
||||
90,
|
||||
Heading.from_degrees(90),
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@ from typing import Iterator, Optional
|
||||
from dcs.terrain.terrain import Airport
|
||||
|
||||
from game.weather import Conditions
|
||||
from game.utils import Heading
|
||||
from .airfields import AIRFIELD_DATA
|
||||
from .radios import RadioFrequency
|
||||
from .tacan import TacanChannel
|
||||
@ -16,7 +17,7 @@ from .tacan import TacanChannel
|
||||
@dataclass(frozen=True)
|
||||
class RunwayData:
|
||||
airfield_name: str
|
||||
runway_heading: int
|
||||
runway_heading: Heading
|
||||
runway_name: str
|
||||
atc: Optional[RadioFrequency] = None
|
||||
tacan: Optional[TacanChannel] = None
|
||||
@ -26,7 +27,7 @@ class RunwayData:
|
||||
|
||||
@classmethod
|
||||
def for_airfield(
|
||||
cls, airport: Airport, runway_heading: int, runway_name: str
|
||||
cls, airport: Airport, runway_heading: Heading, runway_name: str
|
||||
) -> RunwayData:
|
||||
"""Creates RunwayData for the given runway of an airfield.
|
||||
|
||||
@ -66,12 +67,14 @@ class RunwayData:
|
||||
runway_number = runway.heading // 10
|
||||
runway_side = ["", "L", "R"][runway.leftright]
|
||||
runway_name = f"{runway_number:02}{runway_side}"
|
||||
yield cls.for_airfield(airport, runway.heading, runway_name)
|
||||
yield cls.for_airfield(
|
||||
airport, Heading.from_degrees(runway.heading), runway_name
|
||||
)
|
||||
|
||||
# pydcs only exposes one runway per physical runway, so to expose
|
||||
# both sides of the runway we need to generate the other.
|
||||
heading = (runway.heading + 180) % 360
|
||||
runway_number = heading // 10
|
||||
heading = Heading.from_degrees(runway.heading).opposite
|
||||
runway_number = heading.degrees // 10
|
||||
runway_side = ["", "R", "L"][runway.leftright]
|
||||
runway_name = f"{runway_number:02}{runway_side}"
|
||||
yield cls.for_airfield(airport, heading, runway_name)
|
||||
@ -81,10 +84,10 @@ class RunwayAssigner:
|
||||
def __init__(self, conditions: Conditions):
|
||||
self.conditions = conditions
|
||||
|
||||
def angle_off_headwind(self, runway: RunwayData) -> int:
|
||||
wind = self.conditions.weather.wind.at_0m.direction
|
||||
ideal_heading = (wind + 180) % 360
|
||||
return abs(runway.runway_heading - ideal_heading)
|
||||
def angle_off_headwind(self, runway: RunwayData) -> Heading:
|
||||
wind = Heading.from_degrees(self.conditions.weather.wind.at_0m.direction)
|
||||
ideal_heading = wind.opposite
|
||||
return runway.runway_heading.angle_between(ideal_heading)
|
||||
|
||||
def get_preferred_runway(self, airport: Airport) -> RunwayData:
|
||||
"""Returns the preferred runway for the given airport.
|
||||
|
||||
@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import (
|
||||
AirDefenseRange,
|
||||
AirDefenseGroupGenerator,
|
||||
)
|
||||
from game.utils import Heading
|
||||
|
||||
GFLAK = [
|
||||
AirDefence.Flak38,
|
||||
@ -88,7 +89,7 @@ class FlakGenerator(AirDefenseGroupGenerator):
|
||||
"BLITZ#" + str(index),
|
||||
self.position.x + 125 + 15 * i + random.randint(1, 5),
|
||||
self.position.y + 15 * j + random.randint(1, 5),
|
||||
75,
|
||||
Heading.from_degrees(75),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import (
|
||||
AirDefenseRange,
|
||||
AirDefenseGroupGenerator,
|
||||
)
|
||||
from game.utils import Heading
|
||||
|
||||
|
||||
class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
|
||||
@ -53,28 +54,28 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
|
||||
"CMD#1",
|
||||
self.position.x,
|
||||
self.position.y - 20,
|
||||
random.randint(0, 360),
|
||||
Heading.random(),
|
||||
)
|
||||
self.add_unit(
|
||||
Unarmed.M30_CC,
|
||||
"LOG#1",
|
||||
self.position.x,
|
||||
self.position.y + 20,
|
||||
random.randint(0, 360),
|
||||
Heading.random(),
|
||||
)
|
||||
self.add_unit(
|
||||
Unarmed.M4_Tractor,
|
||||
"LOG#2",
|
||||
self.position.x + 20,
|
||||
self.position.y,
|
||||
random.randint(0, 360),
|
||||
Heading.random(),
|
||||
)
|
||||
self.add_unit(
|
||||
Unarmed.Bedford_MWD,
|
||||
"LOG#3",
|
||||
self.position.x - 20,
|
||||
self.position.y,
|
||||
random.randint(0, 360),
|
||||
Heading.random(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -48,6 +48,7 @@ class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC):
|
||||
|
||||
self.vg.name = self.group_name_for_role(self.vg.id, self.primary_group_role())
|
||||
self.auxiliary_groups: List[VehicleGroup] = []
|
||||
self.heading = self.heading_to_conflict()
|
||||
|
||||
def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup:
|
||||
gid = self.game.next_group_id()
|
||||
|
||||
@ -41,7 +41,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
|
||||
"SHO#1",
|
||||
self.position.x - 40,
|
||||
self.position.y - 40,
|
||||
self.heading + 180,
|
||||
self.heading.opposite,
|
||||
),
|
||||
self.add_unit(
|
||||
AirDefence.S_60_Type59_Artillery,
|
||||
@ -57,7 +57,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
|
||||
"SHO#3",
|
||||
self.position.x - 80,
|
||||
self.position.y - 40,
|
||||
self.heading + 180,
|
||||
self.heading.opposite,
|
||||
),
|
||||
self.add_unit(
|
||||
AirDefence.ZU_23_Emplacement_Closed,
|
||||
@ -113,7 +113,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
|
||||
"SHO#1",
|
||||
self.position.x - 40,
|
||||
self.position.y - 40,
|
||||
self.heading + 180,
|
||||
self.heading.opposite,
|
||||
),
|
||||
self.add_unit(
|
||||
AirDefence.S_60_Type59_Artillery,
|
||||
@ -129,7 +129,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
|
||||
"SHO#3",
|
||||
self.position.x - 80,
|
||||
self.position.y - 40,
|
||||
self.heading + 180,
|
||||
self.heading.opposite,
|
||||
),
|
||||
self.add_unit(
|
||||
AirDefence.ZU_23_Emplacement_Closed,
|
||||
|
||||
@ -4,6 +4,7 @@ from gen.sam.airdefensegroupgenerator import (
|
||||
AirDefenseRange,
|
||||
AirDefenseGroupGenerator,
|
||||
)
|
||||
from game.utils import Heading
|
||||
|
||||
|
||||
class FreyaGenerator(AirDefenseGroupGenerator):
|
||||
@ -101,7 +102,7 @@ class FreyaGenerator(AirDefenseGroupGenerator):
|
||||
"Inf#3",
|
||||
self.position.x + 20,
|
||||
self.position.y - 24,
|
||||
self.heading + 45,
|
||||
self.heading + Heading.from_degrees(45),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import operator
|
||||
import random
|
||||
from collections import Iterable
|
||||
from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any
|
||||
@ -15,7 +16,9 @@ from dcs.unittype import VehicleType, UnitType, ShipType
|
||||
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.factions.faction import Faction
|
||||
from game.theater import MissionTarget
|
||||
from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject
|
||||
from game.utils import Heading
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.game import Game
|
||||
@ -37,7 +40,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
|
||||
self.game = game
|
||||
self.go = ground_object
|
||||
self.position = ground_object.position
|
||||
self.heading = random.randint(0, 359)
|
||||
self.heading: Heading = Heading.random()
|
||||
self.price = 0
|
||||
self.vg: GroupT = group
|
||||
|
||||
@ -53,7 +56,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
|
||||
name: str,
|
||||
pos_x: float,
|
||||
pos_y: float,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
) -> UnitT:
|
||||
return self.add_unit_to_group(
|
||||
self.vg, unit_type, name, Point(pos_x, pos_y), heading
|
||||
@ -65,10 +68,33 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
|
||||
unit_type: UnitTypeT,
|
||||
name: str,
|
||||
position: Point,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
) -> UnitT:
|
||||
raise NotImplementedError
|
||||
|
||||
def heading_to_conflict(self) -> Heading:
|
||||
# Heading for a Group to the enemy.
|
||||
# Should be the point between the nearest and the most distant conflict
|
||||
conflicts: dict[MissionTarget, float] = {}
|
||||
|
||||
for conflict in self.game.theater.conflicts():
|
||||
conflicts[conflict] = conflict.distance_to(self.go)
|
||||
|
||||
if len(conflicts) == 0:
|
||||
return self.heading
|
||||
|
||||
closest_conflict = min(conflicts.items(), key=operator.itemgetter(1))[0]
|
||||
most_distant_conflict = max(conflicts.items(), key=operator.itemgetter(1))[0]
|
||||
|
||||
conflict_center = Point(
|
||||
(closest_conflict.position.x + most_distant_conflict.position.x) / 2,
|
||||
(closest_conflict.position.y + most_distant_conflict.position.y) / 2,
|
||||
)
|
||||
|
||||
return Heading.from_degrees(
|
||||
self.go.position.heading_between_point(conflict_center)
|
||||
)
|
||||
|
||||
|
||||
class VehicleGroupGenerator(
|
||||
Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT]
|
||||
@ -91,11 +117,11 @@ class VehicleGroupGenerator(
|
||||
unit_type: Type[VehicleType],
|
||||
name: str,
|
||||
position: Point,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
) -> Vehicle:
|
||||
unit = Vehicle(self.game.next_unit_id(), f"{group.name}|{name}", unit_type.id)
|
||||
unit.position = position
|
||||
unit.heading = heading
|
||||
unit.heading = heading.degrees
|
||||
group.add_unit(unit)
|
||||
|
||||
# get price of unit to calculate the real price of the whole group
|
||||
@ -109,7 +135,7 @@ class VehicleGroupGenerator(
|
||||
|
||||
def get_circular_position(
|
||||
self, num_units: int, launcher_distance: int, coverage: int = 90
|
||||
) -> Iterable[tuple[float, float, int]]:
|
||||
) -> Iterable[tuple[float, float, Heading]]:
|
||||
"""
|
||||
Given a position on the map, array a group of units in a circle a uniform distance from the unit
|
||||
:param num_units:
|
||||
@ -131,9 +157,9 @@ class VehicleGroupGenerator(
|
||||
positions = []
|
||||
|
||||
if num_units % 2 == 0:
|
||||
current_offset = self.heading - ((coverage / (num_units - 1)) / 2)
|
||||
current_offset = self.heading.degrees - ((coverage / (num_units - 1)) / 2)
|
||||
else:
|
||||
current_offset = self.heading
|
||||
current_offset = self.heading.degrees
|
||||
current_offset -= outer_offset * (math.ceil(num_units / 2) - 1)
|
||||
for _ in range(1, num_units + 1):
|
||||
x: float = self.position.x + launcher_distance * math.cos(
|
||||
@ -142,8 +168,7 @@ class VehicleGroupGenerator(
|
||||
y: float = self.position.y + launcher_distance * math.sin(
|
||||
math.radians(current_offset)
|
||||
)
|
||||
heading = current_offset
|
||||
positions.append((x, y, int(heading)))
|
||||
positions.append((x, y, Heading.from_degrees(current_offset)))
|
||||
current_offset += outer_offset
|
||||
return positions
|
||||
|
||||
@ -172,10 +197,10 @@ class ShipGroupGenerator(
|
||||
unit_type: Type[ShipType],
|
||||
name: str,
|
||||
position: Point,
|
||||
heading: int,
|
||||
heading: Heading,
|
||||
) -> Ship:
|
||||
unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type)
|
||||
unit.position = position
|
||||
unit.heading = heading
|
||||
unit.heading = heading.degrees
|
||||
group.add_unit(unit)
|
||||
return unit
|
||||
|
||||
16
gen/units.py
16
gen/units.py
@ -1,16 +0,0 @@
|
||||
"""Unit conversions."""
|
||||
|
||||
|
||||
def meters_to_feet(meters: float) -> float:
|
||||
"""Converts meters to feet."""
|
||||
return meters * 3.28084
|
||||
|
||||
|
||||
def inches_hg_to_mm_hg(inches_hg: float) -> float:
|
||||
"""Converts inches mercury to millimeters mercury."""
|
||||
return inches_hg * 25.400002776728
|
||||
|
||||
|
||||
def inches_hg_to_hpa(inches_hg: float) -> float:
|
||||
"""Converts inches mercury to hectopascal."""
|
||||
return inches_hg * 33.86389
|
||||
@ -86,7 +86,7 @@ class VisualGenerator:
|
||||
continue
|
||||
|
||||
for offset in range(0, distance, self.game.settings.perf_smoke_spacing):
|
||||
position = plane_start.point_from_heading(heading, offset)
|
||||
position = plane_start.point_from_heading(heading.degrees, offset)
|
||||
|
||||
for k, v in FRONT_SMOKE_TYPE_CHANCES.items():
|
||||
if random.randint(0, 100) <= k:
|
||||
|
||||
@ -13,8 +13,9 @@ from PySide2.QtWidgets import QApplication, QSplashScreen
|
||||
from dcs.payloads import PayloadDirectories
|
||||
|
||||
from game import Game, VERSION, persistency
|
||||
from game.data.weapons import WeaponGroup
|
||||
from game.data.weapons import WeaponGroup, Pylon, Weapon
|
||||
from game.db import FACTIONS
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.profiling import logged_duration
|
||||
from game.settings import Settings
|
||||
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
|
||||
@ -183,8 +184,24 @@ def parse_args() -> argparse.Namespace:
|
||||
"--inverted", action="store_true", help="Invert the campaign."
|
||||
)
|
||||
|
||||
new_game.add_argument(
|
||||
"--date",
|
||||
type=datetime.fromisoformat,
|
||||
default=datetime.today(),
|
||||
help="Start date of the campaign.",
|
||||
)
|
||||
|
||||
new_game.add_argument(
|
||||
"--restrict-weapons-by-date",
|
||||
action="store_true",
|
||||
help="Enable campaign date restricted weapons.",
|
||||
)
|
||||
|
||||
new_game.add_argument("--cheats", action="store_true", help="Enable cheats.")
|
||||
|
||||
lint_weapons = subparsers.add_parser("lint-weapons")
|
||||
lint_weapons.add_argument("aircraft", help="Name of the aircraft variant to lint.")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@ -196,6 +213,8 @@ def create_game(
|
||||
auto_procurement: bool,
|
||||
inverted: bool,
|
||||
cheats: bool,
|
||||
start_date: datetime,
|
||||
restrict_weapons_by_date: bool,
|
||||
) -> Game:
|
||||
first_start = liberation_install.init()
|
||||
if first_start:
|
||||
@ -224,9 +243,10 @@ def create_game(
|
||||
automate_aircraft_reinforcements=auto_procurement,
|
||||
enable_frontline_cheats=cheats,
|
||||
enable_base_capture_cheat=cheats,
|
||||
restrict_weapons_by_date=restrict_weapons_by_date,
|
||||
),
|
||||
GeneratorSettings(
|
||||
start_date=datetime.today(),
|
||||
start_date=start_date,
|
||||
player_budget=DEFAULT_BUDGET,
|
||||
enemy_budget=DEFAULT_BUDGET,
|
||||
midgame=False,
|
||||
@ -246,14 +266,26 @@ def create_game(
|
||||
high_digit_sams=False,
|
||||
),
|
||||
)
|
||||
return generator.generate()
|
||||
game = generator.generate()
|
||||
game.begin_turn_0()
|
||||
return game
|
||||
|
||||
|
||||
def lint_weapon_data() -> None:
|
||||
def lint_all_weapon_data() -> None:
|
||||
for weapon in WeaponGroup.named("Unknown").weapons:
|
||||
logging.warning(f"No weapon data for {weapon}: {weapon.clsid}")
|
||||
|
||||
|
||||
def lint_weapon_data_for_aircraft(aircraft: AircraftType) -> None:
|
||||
all_weapons: set[Weapon] = set()
|
||||
for pylon in Pylon.iter_pylons(aircraft):
|
||||
all_weapons |= pylon.allowed
|
||||
|
||||
for weapon in all_weapons:
|
||||
if weapon.weapon_group.name == "Unknown":
|
||||
logging.warning(f'{weapon.clsid} "{weapon.name}" has no weapon data')
|
||||
|
||||
|
||||
def main():
|
||||
logging_config.init_logging(VERSION)
|
||||
|
||||
@ -265,7 +297,7 @@ def main():
|
||||
|
||||
# TODO: Flesh out data and then make unconditional.
|
||||
if args.warn_missing_weapon_data:
|
||||
lint_weapon_data()
|
||||
lint_all_weapon_data()
|
||||
|
||||
if args.subcommand == "new-game":
|
||||
with logged_duration("New game creation"):
|
||||
@ -277,7 +309,12 @@ def main():
|
||||
args.auto_procurement,
|
||||
args.inverted,
|
||||
args.cheats,
|
||||
args.date,
|
||||
args.restrict_weapons_by_date,
|
||||
)
|
||||
if args.subcommand == "lint-weapons":
|
||||
lint_weapon_data_for_aircraft(AircraftType.named(args.aircraft))
|
||||
return
|
||||
|
||||
run_ui(game)
|
||||
|
||||
|
||||
@ -8,10 +8,17 @@ from PySide2.QtCore import Property, QObject, Signal, Slot
|
||||
from dcs import Point
|
||||
from dcs.unit import Unit
|
||||
from dcs.vehicles import vehicle_map
|
||||
from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPolygon
|
||||
from shapely.geometry import (
|
||||
LineString,
|
||||
Point as ShapelyPoint,
|
||||
Polygon,
|
||||
MultiPolygon,
|
||||
MultiLineString,
|
||||
)
|
||||
|
||||
from game import Game
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.flightplan import JoinZoneGeometry, HoldZoneGeometry
|
||||
from game.navmesh import NavMesh, NavMeshPoly
|
||||
from game.profiling import logged_duration
|
||||
from game.theater import (
|
||||
@ -27,7 +34,12 @@ from game.transfers import MultiGroupTransport, TransportMap
|
||||
from game.utils import meters, nautical_miles
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType
|
||||
from gen.flights.flightplan import FlightPlan, PatrollingFlightPlan, CasFlightPlan
|
||||
from gen.flights.flightplan import (
|
||||
FlightPlan,
|
||||
PatrollingFlightPlan,
|
||||
CasFlightPlan,
|
||||
)
|
||||
from game.flightplan.ipzonegeometry import IpZoneGeometry
|
||||
from qt_ui.dialogs import Dialog
|
||||
from qt_ui.models import GameModel, AtoModel
|
||||
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
|
||||
@ -39,6 +51,10 @@ LeafletPoly = list[LeafletLatLon]
|
||||
|
||||
MAX_SHIP_DISTANCE = nautical_miles(80)
|
||||
|
||||
# Set to True to enable computing expensive debugging information. At the time of
|
||||
# writing this only controls computing the waypoint placement zones.
|
||||
ENABLE_EXPENSIVE_DEBUG_TOOLS = False
|
||||
|
||||
# **EVERY PROPERTY NEEDS A NOTIFY SIGNAL**
|
||||
#
|
||||
# https://bugreports.qt.io/browse/PYSIDE-1426
|
||||
@ -73,6 +89,18 @@ def shapely_to_leaflet_polys(
|
||||
return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys]
|
||||
|
||||
|
||||
def shapely_line_to_leaflet_points(
|
||||
line: LineString, theater: ConflictTheater
|
||||
) -> list[LeafletLatLon]:
|
||||
return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords]
|
||||
|
||||
|
||||
def shapely_lines_to_leaflet_points(
|
||||
lines: MultiLineString, theater: ConflictTheater
|
||||
) -> list[list[LeafletLatLon]]:
|
||||
return [shapely_line_to_leaflet_points(l, theater) for l in lines.geoms]
|
||||
|
||||
|
||||
class ControlPointJs(QObject):
|
||||
nameChanged = Signal()
|
||||
blueChanged = Signal()
|
||||
@ -389,12 +417,12 @@ class FrontLineJs(QObject):
|
||||
def extents(self) -> List[LeafletLatLon]:
|
||||
a = self.theater.point_to_ll(
|
||||
self.front_line.position.point_from_heading(
|
||||
self.front_line.attack_heading + 90, nautical_miles(2).meters
|
||||
self.front_line.attack_heading.right.degrees, nautical_miles(2).meters
|
||||
)
|
||||
)
|
||||
b = self.theater.point_to_ll(
|
||||
self.front_line.position.point_from_heading(
|
||||
self.front_line.attack_heading + 270, nautical_miles(2).meters
|
||||
self.front_line.attack_heading.left.degrees, nautical_miles(2).meters
|
||||
)
|
||||
)
|
||||
return [[a.latitude, a.longitude], [b.latitude, b.longitude]]
|
||||
@ -512,6 +540,19 @@ class FlightJs(QObject):
|
||||
selectedChanged = Signal()
|
||||
commitBoundaryChanged = Signal()
|
||||
|
||||
originChanged = Signal()
|
||||
|
||||
@Property(list, notify=originChanged)
|
||||
def origin(self) -> LeafletLatLon:
|
||||
return self._waypoints[0].position
|
||||
|
||||
targetChanged = Signal()
|
||||
|
||||
@Property(list, notify=targetChanged)
|
||||
def target(self) -> LeafletLatLon:
|
||||
ll = self.theater.point_to_ll(self.flight.package.target.position)
|
||||
return [ll.latitude, ll.longitude]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flight: Flight,
|
||||
@ -769,6 +810,209 @@ class UnculledZone(QObject):
|
||||
)
|
||||
|
||||
|
||||
class IpZonesJs(QObject):
|
||||
homeBubbleChanged = Signal()
|
||||
ipBubbleChanged = Signal()
|
||||
permissibleZoneChanged = Signal()
|
||||
safeZonesChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home_bubble: LeafletPoly,
|
||||
ip_bubble: LeafletPoly,
|
||||
permissible_zone: LeafletPoly,
|
||||
safe_zones: list[LeafletPoly],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._home_bubble = home_bubble
|
||||
self._ip_bubble = ip_bubble
|
||||
self._permissible_zone = permissible_zone
|
||||
self._safe_zones = safe_zones
|
||||
|
||||
@Property(list, notify=homeBubbleChanged)
|
||||
def homeBubble(self) -> LeafletPoly:
|
||||
return self._home_bubble
|
||||
|
||||
@Property(list, notify=ipBubbleChanged)
|
||||
def ipBubble(self) -> LeafletPoly:
|
||||
return self._ip_bubble
|
||||
|
||||
@Property(list, notify=permissibleZoneChanged)
|
||||
def permissibleZone(self) -> LeafletPoly:
|
||||
return self._permissible_zone
|
||||
|
||||
@Property(list, notify=safeZonesChanged)
|
||||
def safeZones(self) -> list[LeafletPoly]:
|
||||
return self._safe_zones
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> IpZonesJs:
|
||||
return IpZonesJs([], [], [], [])
|
||||
|
||||
@classmethod
|
||||
def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs:
|
||||
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
|
||||
return IpZonesJs.empty()
|
||||
target = flight.package.target
|
||||
home = flight.departure
|
||||
geometry = IpZoneGeometry(target.position, home.position, game.blue)
|
||||
return IpZonesJs(
|
||||
shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater),
|
||||
shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater),
|
||||
shapely_poly_to_leaflet_points(geometry.permissible_zone, game.theater),
|
||||
shapely_to_leaflet_polys(geometry.safe_zones, game.theater),
|
||||
)
|
||||
|
||||
|
||||
class JoinZonesJs(QObject):
|
||||
homeBubbleChanged = Signal()
|
||||
targetBubbleChanged = Signal()
|
||||
ipBubbleChanged = Signal()
|
||||
excludedZonesChanged = Signal()
|
||||
permissibleZonesChanged = Signal()
|
||||
preferredLinesChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home_bubble: LeafletPoly,
|
||||
target_bubble: LeafletPoly,
|
||||
ip_bubble: LeafletPoly,
|
||||
excluded_zones: list[LeafletPoly],
|
||||
permissible_zones: list[LeafletPoly],
|
||||
preferred_lines: list[list[LeafletLatLon]],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._home_bubble = home_bubble
|
||||
self._target_bubble = target_bubble
|
||||
self._ip_bubble = ip_bubble
|
||||
self._excluded_zones = excluded_zones
|
||||
self._permissible_zones = permissible_zones
|
||||
self._preferred_lines = preferred_lines
|
||||
|
||||
@Property(list, notify=homeBubbleChanged)
|
||||
def homeBubble(self) -> LeafletPoly:
|
||||
return self._home_bubble
|
||||
|
||||
@Property(list, notify=targetBubbleChanged)
|
||||
def targetBubble(self) -> LeafletPoly:
|
||||
return self._target_bubble
|
||||
|
||||
@Property(list, notify=ipBubbleChanged)
|
||||
def ipBubble(self) -> LeafletPoly:
|
||||
return self._ip_bubble
|
||||
|
||||
@Property(list, notify=excludedZonesChanged)
|
||||
def excludedZones(self) -> list[LeafletPoly]:
|
||||
return self._excluded_zones
|
||||
|
||||
@Property(list, notify=permissibleZonesChanged)
|
||||
def permissibleZones(self) -> list[LeafletPoly]:
|
||||
return self._permissible_zones
|
||||
|
||||
@Property(list, notify=preferredLinesChanged)
|
||||
def preferredLines(self) -> list[list[LeafletLatLon]]:
|
||||
return self._preferred_lines
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> JoinZonesJs:
|
||||
return JoinZonesJs([], [], [], [], [], [])
|
||||
|
||||
@classmethod
|
||||
def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs:
|
||||
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
|
||||
return JoinZonesJs.empty()
|
||||
target = flight.package.target
|
||||
home = flight.departure
|
||||
if flight.package.waypoints is None:
|
||||
return JoinZonesJs.empty()
|
||||
ip = flight.package.waypoints.ingress
|
||||
geometry = JoinZoneGeometry(target.position, home.position, ip, game.blue)
|
||||
return JoinZonesJs(
|
||||
shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater),
|
||||
shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater),
|
||||
shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater),
|
||||
shapely_to_leaflet_polys(geometry.excluded_zones, game.theater),
|
||||
shapely_to_leaflet_polys(geometry.permissible_zones, game.theater),
|
||||
shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater),
|
||||
)
|
||||
|
||||
|
||||
class HoldZonesJs(QObject):
|
||||
homeBubbleChanged = Signal()
|
||||
targetBubbleChanged = Signal()
|
||||
joinBubbleChanged = Signal()
|
||||
excludedZonesChanged = Signal()
|
||||
permissibleZonesChanged = Signal()
|
||||
preferredLinesChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home_bubble: LeafletPoly,
|
||||
target_bubble: LeafletPoly,
|
||||
join_bubble: LeafletPoly,
|
||||
excluded_zones: list[LeafletPoly],
|
||||
permissible_zones: list[LeafletPoly],
|
||||
preferred_lines: list[list[LeafletLatLon]],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._home_bubble = home_bubble
|
||||
self._target_bubble = target_bubble
|
||||
self._join_bubble = join_bubble
|
||||
self._excluded_zones = excluded_zones
|
||||
self._permissible_zones = permissible_zones
|
||||
self._preferred_lines = preferred_lines
|
||||
|
||||
@Property(list, notify=homeBubbleChanged)
|
||||
def homeBubble(self) -> LeafletPoly:
|
||||
return self._home_bubble
|
||||
|
||||
@Property(list, notify=targetBubbleChanged)
|
||||
def targetBubble(self) -> LeafletPoly:
|
||||
return self._target_bubble
|
||||
|
||||
@Property(list, notify=joinBubbleChanged)
|
||||
def joinBubble(self) -> LeafletPoly:
|
||||
return self._join_bubble
|
||||
|
||||
@Property(list, notify=excludedZonesChanged)
|
||||
def excludedZones(self) -> list[LeafletPoly]:
|
||||
return self._excluded_zones
|
||||
|
||||
@Property(list, notify=permissibleZonesChanged)
|
||||
def permissibleZones(self) -> list[LeafletPoly]:
|
||||
return self._permissible_zones
|
||||
|
||||
@Property(list, notify=preferredLinesChanged)
|
||||
def preferredLines(self) -> list[list[LeafletLatLon]]:
|
||||
return self._preferred_lines
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> HoldZonesJs:
|
||||
return HoldZonesJs([], [], [], [], [], [])
|
||||
|
||||
@classmethod
|
||||
def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs:
|
||||
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
|
||||
return JoinZonesJs.empty()
|
||||
target = flight.package.target
|
||||
home = flight.departure
|
||||
if flight.package.waypoints is None:
|
||||
return HoldZonesJs.empty()
|
||||
ip = flight.package.waypoints.ingress
|
||||
join = flight.package.waypoints.join
|
||||
geometry = HoldZoneGeometry(
|
||||
target.position, home.position, ip, join, game.blue, game.theater
|
||||
)
|
||||
return HoldZonesJs(
|
||||
shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater),
|
||||
shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater),
|
||||
shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater),
|
||||
shapely_to_leaflet_polys(geometry.excluded_zones, game.theater),
|
||||
shapely_to_leaflet_polys(geometry.permissible_zones, game.theater),
|
||||
shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater),
|
||||
)
|
||||
|
||||
|
||||
class MapModel(QObject):
|
||||
cleared = Signal()
|
||||
|
||||
@ -782,6 +1026,9 @@ class MapModel(QObject):
|
||||
navmeshesChanged = Signal()
|
||||
mapZonesChanged = Signal()
|
||||
unculledZonesChanged = Signal()
|
||||
ipZonesChanged = Signal()
|
||||
joinZonesChanged = Signal()
|
||||
holdZonesChanged = Signal()
|
||||
|
||||
def __init__(self, game_model: GameModel) -> None:
|
||||
super().__init__()
|
||||
@ -798,6 +1045,9 @@ class MapModel(QObject):
|
||||
self._navmeshes = NavMeshJs([], [])
|
||||
self._map_zones = MapZonesJs([], [], [])
|
||||
self._unculled_zones = []
|
||||
self._ip_zones = IpZonesJs.empty()
|
||||
self._join_zones = JoinZonesJs.empty()
|
||||
self._hold_zones = HoldZonesJs.empty()
|
||||
self._selected_flight_index: Optional[Tuple[int, int]] = None
|
||||
GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load)
|
||||
GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos)
|
||||
@ -821,6 +1071,7 @@ class MapModel(QObject):
|
||||
self._navmeshes = NavMeshJs([], [])
|
||||
self._map_zones = MapZonesJs([], [], [])
|
||||
self._unculled_zones = []
|
||||
self._ip_zones = IpZonesJs.empty()
|
||||
self.cleared.emit()
|
||||
|
||||
def set_package_selection(self, index: int) -> None:
|
||||
@ -896,11 +1147,30 @@ class MapModel(QObject):
|
||||
)
|
||||
return flights
|
||||
|
||||
def _get_selected_flight(self) -> Optional[Flight]:
|
||||
for p_idx, package in enumerate(self.game.blue.ato.packages):
|
||||
for f_idx, flight in enumerate(package.flights):
|
||||
if (p_idx, f_idx) == self._selected_flight_index:
|
||||
return flight
|
||||
return None
|
||||
|
||||
def reset_atos(self) -> None:
|
||||
self._flights = self._flights_in_ato(
|
||||
self.game.blue.ato, blue=True
|
||||
) + self._flights_in_ato(self.game.red.ato, blue=False)
|
||||
self.flightsChanged.emit()
|
||||
selected_flight = self._get_selected_flight()
|
||||
if selected_flight is None:
|
||||
self._ip_zones = IpZonesJs.empty()
|
||||
self._join_zones = JoinZonesJs.empty()
|
||||
self._hold_zones = HoldZonesJs.empty()
|
||||
else:
|
||||
self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game)
|
||||
self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game)
|
||||
self._hold_zones = HoldZonesJs.for_flight(selected_flight, self.game)
|
||||
self.ipZonesChanged.emit()
|
||||
self.joinZonesChanged.emit()
|
||||
self.holdZonesChanged.emit()
|
||||
|
||||
@Property(list, notify=flightsChanged)
|
||||
def flights(self) -> List[FlightJs]:
|
||||
@ -1029,6 +1299,18 @@ class MapModel(QObject):
|
||||
def unculledZones(self) -> list[UnculledZone]:
|
||||
return self._unculled_zones
|
||||
|
||||
@Property(IpZonesJs, notify=ipZonesChanged)
|
||||
def ipZones(self) -> IpZonesJs:
|
||||
return self._ip_zones
|
||||
|
||||
@Property(JoinZonesJs, notify=joinZonesChanged)
|
||||
def joinZones(self) -> JoinZonesJs:
|
||||
return self._join_zones
|
||||
|
||||
@Property(HoldZonesJs, notify=holdZonesChanged)
|
||||
def holdZones(self) -> HoldZonesJs:
|
||||
return self._hold_zones
|
||||
|
||||
@property
|
||||
def game(self) -> Game:
|
||||
if self.game_model.game is None:
|
||||
|
||||
326
qt_ui/windows/AirWingConfigurationDialog.py
Normal file
326
qt_ui/windows/AirWingConfigurationDialog.py
Normal file
@ -0,0 +1,326 @@
|
||||
from typing import Optional, Callable
|
||||
|
||||
from PySide2.QtCore import (
|
||||
QItemSelectionModel,
|
||||
QModelIndex,
|
||||
QSize,
|
||||
Qt,
|
||||
QItemSelection,
|
||||
Signal,
|
||||
)
|
||||
from PySide2.QtGui import QStandardItemModel, QStandardItem, QIcon
|
||||
from PySide2.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QDialog,
|
||||
QListView,
|
||||
QVBoxLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QScrollArea,
|
||||
QLineEdit,
|
||||
QTextEdit,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QStackedLayout,
|
||||
QTabWidget,
|
||||
)
|
||||
|
||||
from game import Game
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons import Squadron, AirWing, Pilot
|
||||
from gen.flights.flight import FlightType
|
||||
from qt_ui.models import AirWingModel, SquadronModel
|
||||
from qt_ui.uiconstants import AIRCRAFT_ICONS
|
||||
from qt_ui.windows.AirWingDialog import SquadronDelegate
|
||||
from qt_ui.windows.SquadronDialog import SquadronDialog
|
||||
|
||||
|
||||
class SquadronList(QListView):
|
||||
"""List view for displaying the air wing's squadrons."""
|
||||
|
||||
def __init__(self, air_wing_model: AirWingModel) -> None:
|
||||
super().__init__()
|
||||
self.air_wing_model = air_wing_model
|
||||
self.dialog: Optional[SquadronDialog] = None
|
||||
|
||||
self.setIconSize(QSize(91, 24))
|
||||
self.setItemDelegate(SquadronDelegate(self.air_wing_model))
|
||||
self.setModel(self.air_wing_model)
|
||||
self.selectionModel().setCurrentIndex(
|
||||
self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
|
||||
)
|
||||
|
||||
# self.setIconSize(QSize(91, 24))
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectItems)
|
||||
self.doubleClicked.connect(self.on_double_click)
|
||||
|
||||
def on_double_click(self, index: QModelIndex) -> None:
|
||||
if not index.isValid():
|
||||
return
|
||||
self.dialog = SquadronDialog(
|
||||
SquadronModel(self.air_wing_model.squadron_at_index(index)), self
|
||||
)
|
||||
self.dialog.show()
|
||||
|
||||
|
||||
class AllowedMissionTypeControls(QVBoxLayout):
|
||||
def __init__(self, squadron: Squadron) -> None:
|
||||
super().__init__()
|
||||
self.squadron = squadron
|
||||
self.allowed_mission_types = set()
|
||||
|
||||
self.addWidget(QLabel("Allowed mission types"))
|
||||
|
||||
def make_callback(toggled_task: FlightType) -> Callable[[bool], None]:
|
||||
def callback(checked: bool) -> None:
|
||||
self.on_toggled(toggled_task, checked)
|
||||
|
||||
return callback
|
||||
|
||||
for task in FlightType:
|
||||
enabled = task in squadron.mission_types
|
||||
if enabled:
|
||||
self.allowed_mission_types.add(task)
|
||||
checkbox = QCheckBox(text=task.value)
|
||||
checkbox.setChecked(enabled)
|
||||
checkbox.toggled.connect(make_callback(task))
|
||||
self.addWidget(checkbox)
|
||||
|
||||
self.addStretch()
|
||||
|
||||
def on_toggled(self, task: FlightType, checked: bool) -> None:
|
||||
if checked:
|
||||
self.allowed_mission_types.add(task)
|
||||
else:
|
||||
self.allowed_mission_types.remove(task)
|
||||
|
||||
|
||||
class SquadronConfigurationBox(QGroupBox):
|
||||
def __init__(self, squadron: Squadron) -> None:
|
||||
super().__init__()
|
||||
self.setCheckable(True)
|
||||
self.squadron = squadron
|
||||
self.reset_title()
|
||||
|
||||
columns = QHBoxLayout()
|
||||
self.setLayout(columns)
|
||||
|
||||
left_column = QVBoxLayout()
|
||||
columns.addLayout(left_column)
|
||||
|
||||
left_column.addWidget(QLabel("Name:"))
|
||||
self.name_edit = QLineEdit(squadron.name)
|
||||
self.name_edit.textChanged.connect(self.on_name_changed)
|
||||
left_column.addWidget(self.name_edit)
|
||||
|
||||
left_column.addWidget(QLabel("Nickname:"))
|
||||
self.nickname_edit = QLineEdit(squadron.nickname)
|
||||
self.nickname_edit.textChanged.connect(self.on_nickname_changed)
|
||||
left_column.addWidget(self.nickname_edit)
|
||||
|
||||
if squadron.player:
|
||||
player_label = QLabel(
|
||||
"Players (one per line, leave empty for an AI-only squadron):"
|
||||
)
|
||||
else:
|
||||
player_label = QLabel("Player slots not available for opfor")
|
||||
left_column.addWidget(player_label)
|
||||
|
||||
players = [p for p in squadron.pilot_pool if p.player]
|
||||
for player in players:
|
||||
squadron.pilot_pool.remove(player)
|
||||
if not squadron.player:
|
||||
players = []
|
||||
self.player_list = QTextEdit("<br />".join(p.name for p in players))
|
||||
self.player_list.setAcceptRichText(False)
|
||||
self.player_list.setEnabled(squadron.player)
|
||||
left_column.addWidget(self.player_list)
|
||||
|
||||
left_column.addStretch()
|
||||
|
||||
self.allowed_missions = AllowedMissionTypeControls(squadron)
|
||||
columns.addLayout(self.allowed_missions)
|
||||
|
||||
def on_name_changed(self, text: str) -> None:
|
||||
self.squadron.name = text
|
||||
self.reset_title()
|
||||
|
||||
def on_nickname_changed(self, text: str) -> None:
|
||||
self.squadron.nickname = text
|
||||
|
||||
def reset_title(self) -> None:
|
||||
self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}")
|
||||
|
||||
def apply(self) -> Squadron:
|
||||
player_names = self.player_list.toPlainText().splitlines()
|
||||
# Prepend player pilots so they get set active first.
|
||||
self.squadron.pilot_pool = [
|
||||
Pilot(n, player=True) for n in player_names
|
||||
] + self.squadron.pilot_pool
|
||||
self.squadron.mission_types = tuple(self.allowed_missions.allowed_mission_types)
|
||||
return self.squadron
|
||||
|
||||
|
||||
class SquadronConfigurationLayout(QVBoxLayout):
|
||||
def __init__(self, squadrons: list[Squadron]) -> None:
|
||||
super().__init__()
|
||||
self.squadron_configs = []
|
||||
for squadron in squadrons:
|
||||
squadron_config = SquadronConfigurationBox(squadron)
|
||||
self.squadron_configs.append(squadron_config)
|
||||
self.addWidget(squadron_config)
|
||||
|
||||
def apply(self) -> list[Squadron]:
|
||||
keep_squadrons = []
|
||||
for squadron_config in self.squadron_configs:
|
||||
if squadron_config.isChecked():
|
||||
keep_squadrons.append(squadron_config.apply())
|
||||
return keep_squadrons
|
||||
|
||||
|
||||
class AircraftSquadronsPage(QWidget):
|
||||
def __init__(self, squadrons: list[Squadron]) -> None:
|
||||
super().__init__()
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.squadrons_config = SquadronConfigurationLayout(squadrons)
|
||||
|
||||
scrolling_widget = QWidget()
|
||||
scrolling_widget.setLayout(self.squadrons_config)
|
||||
|
||||
scrolling_area = QScrollArea()
|
||||
scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scrolling_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
|
||||
scrolling_area.setWidgetResizable(True)
|
||||
scrolling_area.setWidget(scrolling_widget)
|
||||
|
||||
layout.addWidget(scrolling_area)
|
||||
|
||||
def apply(self) -> list[Squadron]:
|
||||
return self.squadrons_config.apply()
|
||||
|
||||
|
||||
class AircraftSquadronsPanel(QStackedLayout):
|
||||
def __init__(self, air_wing: AirWing) -> None:
|
||||
super().__init__()
|
||||
self.air_wing = air_wing
|
||||
self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {}
|
||||
for aircraft, squadrons in self.air_wing.squadrons.items():
|
||||
page = AircraftSquadronsPage(squadrons)
|
||||
self.addWidget(page)
|
||||
self.squadrons_pages[aircraft] = page
|
||||
|
||||
def apply(self) -> None:
|
||||
for aircraft, page in self.squadrons_pages.items():
|
||||
self.air_wing.squadrons[aircraft] = page.apply()
|
||||
|
||||
|
||||
class AircraftTypeList(QListView):
|
||||
page_index_changed = Signal(int)
|
||||
|
||||
def __init__(self, air_wing: AirWing) -> None:
|
||||
super().__init__()
|
||||
self.setIconSize(QSize(91, 24))
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
model = QStandardItemModel(self)
|
||||
self.setModel(model)
|
||||
|
||||
self.selectionModel().setCurrentIndex(
|
||||
model.index(0, 0), QItemSelectionModel.Select
|
||||
)
|
||||
self.selectionModel().selectionChanged.connect(self.on_selection_changed)
|
||||
for aircraft in air_wing.squadrons:
|
||||
aircraft_item = QStandardItem(aircraft.name)
|
||||
icon = self.icon_for(aircraft)
|
||||
if icon is not None:
|
||||
aircraft_item.setIcon(icon)
|
||||
aircraft_item.setEditable(False)
|
||||
aircraft_item.setSelectable(True)
|
||||
model.appendRow(aircraft_item)
|
||||
|
||||
def on_selection_changed(
|
||||
self, selected: QItemSelection, _deselected: QItemSelection
|
||||
) -> None:
|
||||
indexes = selected.indexes()
|
||||
if len(indexes) > 1:
|
||||
raise RuntimeError("Aircraft list should not allow multi-selection")
|
||||
if not indexes:
|
||||
return
|
||||
self.page_index_changed.emit(indexes[0].row())
|
||||
|
||||
@staticmethod
|
||||
def icon_for(aircraft: AircraftType) -> Optional[QIcon]:
|
||||
name = aircraft.dcs_id
|
||||
if name in AIRCRAFT_ICONS:
|
||||
return QIcon(AIRCRAFT_ICONS[name])
|
||||
return None
|
||||
|
||||
|
||||
class AirWingConfigurationTab(QWidget):
|
||||
def __init__(self, air_wing: AirWing) -> None:
|
||||
super().__init__()
|
||||
|
||||
layout = QHBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
type_list = AircraftTypeList(air_wing)
|
||||
type_list.page_index_changed.connect(self.on_aircraft_changed)
|
||||
layout.addWidget(type_list)
|
||||
|
||||
self.squadrons_panel = AircraftSquadronsPanel(air_wing)
|
||||
layout.addLayout(self.squadrons_panel)
|
||||
|
||||
def apply(self) -> None:
|
||||
self.squadrons_panel.apply()
|
||||
|
||||
def on_aircraft_changed(self, index: QModelIndex) -> None:
|
||||
self.squadrons_panel.setCurrentIndex(index)
|
||||
|
||||
|
||||
class AirWingConfigurationDialog(QDialog):
|
||||
"""Dialog window for air wing configuration."""
|
||||
|
||||
def __init__(self, game: Game, parent) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumSize(500, 800)
|
||||
self.setWindowTitle(f"Air Wing Configuration")
|
||||
# TODO: self.setWindowIcon()
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
doc_url = (
|
||||
"https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots"
|
||||
)
|
||||
doc_label = QLabel(
|
||||
"Use this opportunity to customize the squadrons available to your "
|
||||
"coalition. <strong>This is your only opportunity to make changes.</strong>"
|
||||
"<br /><br />"
|
||||
"To accept your changes and continue, close this window.<br />"
|
||||
"<br />"
|
||||
"To remove a squadron from the game, uncheck the box in the title. New "
|
||||
"squadrons cannot be added via the UI at this time. To add a custom "
|
||||
"squadron,<br />"
|
||||
f'see <a style="color:#ffffff" href="{doc_url}">the wiki</a>.'
|
||||
)
|
||||
|
||||
doc_label.setOpenExternalLinks(True)
|
||||
layout.addWidget(doc_label)
|
||||
|
||||
tab_widget = QTabWidget()
|
||||
layout.addWidget(tab_widget)
|
||||
|
||||
self.tabs = []
|
||||
for coalition in game.coalitions:
|
||||
coalition_tab = AirWingConfigurationTab(coalition.air_wing)
|
||||
name = "Blue" if coalition.player else "Red"
|
||||
tab_widget.addTab(coalition_tab, name)
|
||||
self.tabs.append(coalition_tab)
|
||||
|
||||
def reject(self) -> None:
|
||||
for tab in self.tabs:
|
||||
tab.apply()
|
||||
super().reject()
|
||||
@ -209,8 +209,6 @@ class QRecruitBehaviour:
|
||||
if self.pending_deliveries.available_next_turn(unit_type) > 0:
|
||||
self.budget += unit_type.price
|
||||
self.pending_deliveries.sell({unit_type: 1})
|
||||
if self.pending_deliveries.units[unit_type] == 0:
|
||||
del self.pending_deliveries.units[unit_type]
|
||||
self.update_purchase_controls()
|
||||
self.update_available_budget()
|
||||
return True
|
||||
|
||||
@ -15,6 +15,7 @@ from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSe
|
||||
from game.factions.faction import Faction
|
||||
from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar
|
||||
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner
|
||||
from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog
|
||||
from qt_ui.windows.newgame.QCampaignList import (
|
||||
Campaign,
|
||||
QCampaignList,
|
||||
@ -125,6 +126,10 @@ class NewGameWizard(QtWidgets.QWizard):
|
||||
)
|
||||
self.generatedGame = generator.generate()
|
||||
|
||||
AirWingConfigurationDialog(self.generatedGame, self).exec_()
|
||||
|
||||
self.generatedGame.begin_turn_0()
|
||||
|
||||
super(NewGameWizard, self).accept()
|
||||
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ pathspec==0.8.1
|
||||
pefile==2019.4.18
|
||||
Pillow==8.2.0
|
||||
pre-commit==2.10.1
|
||||
-e git://github.com/pydcs/dcs@2baba37e32bc55fed59ef977c43dad275c9821eb#egg=pydcs
|
||||
-e git://github.com/pydcs/dcs@7eb720b341c95ad4c3659cc934be4029d526c36e#egg=pydcs
|
||||
pyinstaller==4.3
|
||||
pyinstaller-hooks-contrib==2021.1
|
||||
pyparsing==2.4.7
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
"authors": "Colonel Panic",
|
||||
"recommended_player_faction": "Iran 2015",
|
||||
"recommended_enemy_faction": "United Arab Emirates 2015",
|
||||
"description": "<p>You have managed to establish a foothold at Khasab. Continue pushing south.</p>",
|
||||
"description": "<p>You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.</p>",
|
||||
"miz": "battle_of_abu_dhabi.miz",
|
||||
"performance": 2,
|
||||
"version": "7.0"
|
||||
"version": "8.0"
|
||||
}
|
||||
Binary file not shown.
@ -5,5 +5,5 @@
|
||||
"description": "<p>A medium sized theater with bases along the coast of the Black Sea.</p>",
|
||||
"miz": "black_sea.miz",
|
||||
"performance": 2,
|
||||
"version": "7.0"
|
||||
"version": "8.0"
|
||||
}
|
||||
@ -61,7 +61,6 @@
|
||||
"SA8Generator",
|
||||
"SA8Generator",
|
||||
"SA9Generator",
|
||||
"SA10Generator",
|
||||
"SA11Generator",
|
||||
"SA13Generator",
|
||||
"SA17Generator",
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
// Won't actually enable anything unless the same property is set in
|
||||
// mapmodel.py.
|
||||
const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
|
||||
|
||||
const Colors = Object.freeze({
|
||||
Blue: "#0084ff",
|
||||
Red: "#c85050",
|
||||
@ -124,26 +128,26 @@ const map = L.map("map", {
|
||||
L.control.scale({ maxWidth: 200 }).addTo(map);
|
||||
|
||||
const rulerOptions = {
|
||||
position: 'topleft',
|
||||
position: "topleft",
|
||||
circleMarker: {
|
||||
color: Colors.Highlight,
|
||||
radius: 2
|
||||
radius: 2,
|
||||
},
|
||||
lineStyle: {
|
||||
color: Colors.Highlight,
|
||||
dashArray: '1,6'
|
||||
dashArray: "1,6",
|
||||
},
|
||||
lengthUnit: {
|
||||
display: "nm",
|
||||
decimal: "2",
|
||||
factor: 0.539956803,
|
||||
label: "Distance:"
|
||||
label: "Distance:",
|
||||
},
|
||||
angleUnit: {
|
||||
display: '°',
|
||||
display: "°",
|
||||
decimal: 0,
|
||||
label: "Bearing:"
|
||||
}
|
||||
label: "Bearing:",
|
||||
},
|
||||
};
|
||||
L.control.ruler(rulerOptions).addTo(map);
|
||||
|
||||
@ -194,6 +198,48 @@ const exclusionZones = L.layerGroup();
|
||||
const seaZones = L.layerGroup();
|
||||
const unculledZones = L.layerGroup();
|
||||
|
||||
const noWaypointZones = L.layerGroup();
|
||||
const ipZones = L.layerGroup();
|
||||
const joinZones = L.layerGroup();
|
||||
const holdZones = L.layerGroup().addTo(map);
|
||||
|
||||
const debugControlGroups = {
|
||||
"Blue Threat Zones": {
|
||||
Hide: L.layerGroup().addTo(map),
|
||||
Full: blueFullThreatZones,
|
||||
Aircraft: blueAircraftThreatZones,
|
||||
"Air Defenses": blueAirDefenseThreatZones,
|
||||
"Radar SAMs": blueRadarSamThreatZones,
|
||||
},
|
||||
"Red Threat Zones": {
|
||||
Hide: L.layerGroup().addTo(map),
|
||||
Full: redFullThreatZones,
|
||||
Aircraft: redAircraftThreatZones,
|
||||
"Air Defenses": redAirDefenseThreatZones,
|
||||
"Radar SAMs": redRadarSamThreatZones,
|
||||
},
|
||||
Navmeshes: {
|
||||
Hide: L.layerGroup().addTo(map),
|
||||
Blue: blueNavmesh,
|
||||
Red: redNavmesh,
|
||||
},
|
||||
"Map Zones": {
|
||||
"Inclusion zones": inclusionZones,
|
||||
"Exclusion zones": exclusionZones,
|
||||
"Sea zones": seaZones,
|
||||
"Culling exclusion zones": unculledZones,
|
||||
},
|
||||
};
|
||||
|
||||
if (ENABLE_EXPENSIVE_DEBUG_TOOLS) {
|
||||
debugControlGroups["Waypoint Zones"] = {
|
||||
None: noWaypointZones,
|
||||
"IP Zones": ipZones,
|
||||
"Join Zones": joinZones,
|
||||
"Hold Zones": holdZones,
|
||||
};
|
||||
}
|
||||
|
||||
// Main map controls. These are the ones that we expect users to interact with.
|
||||
// These are always open, which unfortunately means that the scroll bar will not
|
||||
// appear if the menu doesn't fit. This fits in the smallest window size we
|
||||
@ -239,41 +285,16 @@ L.control
|
||||
// Debug map controls. Hover over to open. Not something most users will want or
|
||||
// need to interact with.
|
||||
L.control
|
||||
.groupedLayers(
|
||||
null,
|
||||
{
|
||||
"Blue Threat Zones": {
|
||||
Hide: L.layerGroup().addTo(map),
|
||||
Full: blueFullThreatZones,
|
||||
Aircraft: blueAircraftThreatZones,
|
||||
"Air Defenses": blueAirDefenseThreatZones,
|
||||
"Radar SAMs": blueRadarSamThreatZones,
|
||||
},
|
||||
"Red Threat Zones": {
|
||||
Hide: L.layerGroup().addTo(map),
|
||||
Full: redFullThreatZones,
|
||||
Aircraft: redAircraftThreatZones,
|
||||
"Air Defenses": redAirDefenseThreatZones,
|
||||
"Radar SAMs": redRadarSamThreatZones,
|
||||
},
|
||||
Navmeshes: {
|
||||
Hide: L.layerGroup().addTo(map),
|
||||
Blue: blueNavmesh,
|
||||
Red: redNavmesh,
|
||||
},
|
||||
"Map Zones": {
|
||||
"Inclusion zones": inclusionZones,
|
||||
"Exclusion zones": exclusionZones,
|
||||
"Sea zones": seaZones,
|
||||
"Culling exclusion zones": unculledZones,
|
||||
},
|
||||
},
|
||||
{
|
||||
position: "topleft",
|
||||
exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"],
|
||||
groupCheckboxes: true,
|
||||
}
|
||||
)
|
||||
.groupedLayers(null, debugControlGroups, {
|
||||
position: "topleft",
|
||||
exclusiveGroups: [
|
||||
"Blue Threat Zones",
|
||||
"Red Threat Zones",
|
||||
"Navmeshes",
|
||||
"Waypoint Zones",
|
||||
],
|
||||
groupCheckboxes: true,
|
||||
})
|
||||
.addTo(map);
|
||||
|
||||
let game;
|
||||
@ -291,6 +312,9 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
|
||||
game.navmeshesChanged.connect(drawNavmeshes);
|
||||
game.mapZonesChanged.connect(drawMapZones);
|
||||
game.unculledZonesChanged.connect(drawUnculledZones);
|
||||
game.ipZonesChanged.connect(drawIpZones);
|
||||
game.joinZonesChanged.connect(drawJoinZones);
|
||||
game.holdZonesChanged.connect(drawHoldZones);
|
||||
});
|
||||
|
||||
function recenterMap(center) {
|
||||
@ -570,7 +594,11 @@ class TheaterGroundObject {
|
||||
}
|
||||
|
||||
L.marker(this.tgo.position, { icon: this.icon() })
|
||||
.bindTooltip(`${this.tgo.name} (${this.tgo.controlPointName})<br />${this.tgo.units.join("<br />")}`)
|
||||
.bindTooltip(
|
||||
`${this.tgo.name} (${
|
||||
this.tgo.controlPointName
|
||||
})<br />${this.tgo.units.join("<br />")}`
|
||||
)
|
||||
.on("click", () => this.tgo.showInfoDialog())
|
||||
.on("contextmenu", () => this.tgo.showPackageDialog())
|
||||
.addTo(this.layer());
|
||||
@ -970,6 +998,138 @@ function drawUnculledZones() {
|
||||
}
|
||||
}
|
||||
|
||||
function drawIpZones() {
|
||||
ipZones.clearLayers();
|
||||
|
||||
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
|
||||
return;
|
||||
}
|
||||
|
||||
L.polygon(game.ipZones.homeBubble, {
|
||||
color: Colors.Highlight,
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(ipZones);
|
||||
|
||||
L.polygon(game.ipZones.ipBubble, {
|
||||
color: "#bb89ff",
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(ipZones);
|
||||
|
||||
L.polygon(game.ipZones.permissibleZone, {
|
||||
color: "#ffffff",
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(ipZones);
|
||||
|
||||
for (const zone of game.ipZones.safeZones) {
|
||||
L.polygon(zone, {
|
||||
color: Colors.Green,
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(ipZones);
|
||||
}
|
||||
}
|
||||
|
||||
function drawJoinZones() {
|
||||
joinZones.clearLayers();
|
||||
|
||||
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
|
||||
return;
|
||||
}
|
||||
|
||||
L.polygon(game.joinZones.homeBubble, {
|
||||
color: Colors.Highlight,
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(joinZones);
|
||||
|
||||
L.polygon(game.joinZones.targetBubble, {
|
||||
color: "#bb89ff",
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(joinZones);
|
||||
|
||||
L.polygon(game.joinZones.ipBubble, {
|
||||
color: "#ffffff",
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(joinZones);
|
||||
|
||||
for (const zone of game.joinZones.excludedZones) {
|
||||
L.polygon(zone, {
|
||||
color: "#ffa500",
|
||||
fillOpacity: 0.2,
|
||||
stroke: false,
|
||||
interactive: false,
|
||||
}).addTo(joinZones);
|
||||
}
|
||||
|
||||
for (const zone of game.joinZones.permissibleZones) {
|
||||
L.polygon(zone, {
|
||||
color: Colors.Green,
|
||||
interactive: false,
|
||||
}).addTo(joinZones);
|
||||
}
|
||||
|
||||
for (const line of game.joinZones.preferredLines) {
|
||||
L.polyline(line, {
|
||||
color: Colors.Green,
|
||||
interactive: false,
|
||||
}).addTo(joinZones);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHoldZones() {
|
||||
holdZones.clearLayers();
|
||||
|
||||
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
|
||||
return;
|
||||
}
|
||||
|
||||
L.polygon(game.holdZones.homeBubble, {
|
||||
color: Colors.Highlight,
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(holdZones);
|
||||
|
||||
L.polygon(game.holdZones.targetBubble, {
|
||||
color: Colors.Highlight,
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(holdZones);
|
||||
|
||||
L.polygon(game.holdZones.joinBubble, {
|
||||
color: Colors.Highlight,
|
||||
fillOpacity: 0.1,
|
||||
interactive: false,
|
||||
}).addTo(holdZones);
|
||||
|
||||
for (const zone of game.holdZones.excludedZones) {
|
||||
L.polygon(zone, {
|
||||
color: "#ffa500",
|
||||
fillOpacity: 0.2,
|
||||
stroke: false,
|
||||
interactive: false,
|
||||
}).addTo(holdZones);
|
||||
}
|
||||
|
||||
for (const zone of game.holdZones.permissibleZones) {
|
||||
L.polygon(zone, {
|
||||
color: Colors.Green,
|
||||
interactive: false,
|
||||
}).addTo(holdZones);
|
||||
}
|
||||
|
||||
for (const line of game.holdZones.preferredLines) {
|
||||
L.polyline(line, {
|
||||
color: Colors.Green,
|
||||
interactive: false,
|
||||
}).addTo(holdZones);
|
||||
}
|
||||
}
|
||||
|
||||
function drawInitialMap() {
|
||||
recenterMap(game.mapCenter);
|
||||
drawControlPoints();
|
||||
@ -981,6 +1141,9 @@ function drawInitialMap() {
|
||||
drawNavmeshes();
|
||||
drawMapZones();
|
||||
drawUnculledZones();
|
||||
drawIpZones();
|
||||
drawJoinZones();
|
||||
drawHoldZones();
|
||||
}
|
||||
|
||||
function clearAllLayers() {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
description: The A-50 is an AWACS plane.
|
||||
max_group_size: 1
|
||||
max_range: 2000
|
||||
price: 50
|
||||
patrol:
|
||||
altitude: 33000
|
||||
|
||||
@ -27,6 +27,7 @@ manufacturer: McDonnell Douglas
|
||||
origin: USA/UK
|
||||
price: 15
|
||||
role: V/STOL Attack
|
||||
max_range: 100
|
||||
variants:
|
||||
AV-8B Harrier II Night Attack: {}
|
||||
radios:
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The An-26B is a military transport aircraft.
|
||||
price: 15
|
||||
max_range: 800
|
||||
variants:
|
||||
An-26B: null
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber
|
||||
description:
|
||||
The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber
|
||||
used by the United States Air Force. It is commonly called the 'Bone' (from 'B-One').It
|
||||
is one of three strategic bombers in the U.S. Air Force fleet as of 2021, the other
|
||||
two being the B-2 Spirit and the B-52 Stratofortress. It first served in combat
|
||||
@ -12,5 +13,6 @@ manufacturer: Rockwell
|
||||
origin: USA
|
||||
price: 45
|
||||
role: Supersonic Strategic Bomber
|
||||
max_range: 2000
|
||||
variants:
|
||||
B-1B Lancer: {}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds
|
||||
description:
|
||||
The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds
|
||||
(32,000 kg) of weapons, and has a typical combat range of more than 8,800 miles
|
||||
(14,080 km) without aerial refueling. The B-52 completed sixty years of continuous
|
||||
service with its original operator in 2015. After being upgraded between 2013 and
|
||||
@ -8,5 +9,6 @@ manufacturer: Boeing
|
||||
origin: USA
|
||||
price: 35
|
||||
role: Strategic Bomber
|
||||
max_range: 2000
|
||||
variants:
|
||||
B-52H Stratofortress: {}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The C-130 is a military transport aircraft.
|
||||
price: 15
|
||||
max_range: 1000
|
||||
variants:
|
||||
C-130: null
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The C-17 is a military transport aircraft.
|
||||
price: 18
|
||||
max_range: 2000
|
||||
variants:
|
||||
C-17A: null
|
||||
|
||||
@ -8,6 +8,7 @@ manufacturer: Northrop Grumman
|
||||
origin: USA
|
||||
price: 50
|
||||
role: AEW&C
|
||||
max_range: 2000
|
||||
patrol:
|
||||
altitude: 30000
|
||||
variants:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
description: The E-3A is a AWACS aicraft.
|
||||
price: 50
|
||||
max_group_size: 1
|
||||
max_range: 2000
|
||||
patrol:
|
||||
altitude: 35000
|
||||
variants:
|
||||
|
||||
@ -21,6 +21,7 @@ manufacturer: Grumman
|
||||
origin: USA
|
||||
price: 22
|
||||
role: Carrier-based Air-Superiority Fighter/Fighter Bomber
|
||||
max_range: 250
|
||||
variants:
|
||||
F-14A Tomcat (Block 135-GR Late): {}
|
||||
radios:
|
||||
|
||||
@ -21,6 +21,7 @@ manufacturer: Grumman
|
||||
origin: USA
|
||||
price: 26
|
||||
role: Carrier-based Air-Superiority Fighter/Fighter Bomber
|
||||
max_range: 250
|
||||
variants:
|
||||
F-14B Tomcat: {}
|
||||
radios:
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The early verison of the F-16. It flew in Desert Storm.
|
||||
price: 15
|
||||
max_range: 200
|
||||
variants:
|
||||
F-16A: null
|
||||
|
||||
@ -27,6 +27,7 @@ manufacturer: General Dynamics
|
||||
origin: USA
|
||||
price: 22
|
||||
role: Multirole Fighter
|
||||
max_range: 200
|
||||
variants:
|
||||
F-16CM Fighting Falcon (Block 50): {}
|
||||
F-2A: {}
|
||||
|
||||
@ -21,6 +21,16 @@ manufacturer: McDonnell Douglas
|
||||
origin: USA
|
||||
price: 24
|
||||
role: Carrier-based Multirole Fighter
|
||||
fuel:
|
||||
# Parking A1 to RWY 32 at Akrotiri.
|
||||
taxi: 170
|
||||
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
|
||||
climb_ppm: 44.25
|
||||
# 0.85 mach for 100NM.
|
||||
cruise_ppm: 22.1
|
||||
# ~0.9 mach for 100NM. Occasional AB use.
|
||||
combat_ppm: 27.5
|
||||
min_safe: 2000
|
||||
variants:
|
||||
CF-188 Hornet: {}
|
||||
EF-18A+ Hornet: {}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The Lockheed Martin C-130J Super Hercules is a four-engine turboprop
|
||||
description:
|
||||
The Lockheed Martin C-130J Super Hercules is a four-engine turboprop
|
||||
military transport aircraft. The C-130J is a comprehensive update of the Lockheed
|
||||
C-130 Hercules, with new engines, flight deck, and other systems. As of February
|
||||
2018, 400 C-130J aircraft have been delivered to 17 nations.
|
||||
@ -7,5 +8,6 @@ manufacturer: Lockheed
|
||||
origin: USA
|
||||
price: 18
|
||||
role: Transport
|
||||
max_range: 1000
|
||||
variants:
|
||||
C-130J-30 Super Hercules: {}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
price: 20
|
||||
max_range: 1000
|
||||
variants:
|
||||
IL-76MD: null
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
price: 20
|
||||
max_group_size: 1
|
||||
max_range: 1000
|
||||
patrol:
|
||||
# ~280 knots IAS.
|
||||
speed: 400
|
||||
|
||||
@ -8,6 +8,7 @@ manufacturer: Beoing
|
||||
origin: USA
|
||||
price: 25
|
||||
role: Tanker
|
||||
max_range: 1000
|
||||
patrol:
|
||||
# ~300 knots IAS.
|
||||
speed: 445
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
description: The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range
|
||||
description:
|
||||
The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range
|
||||
tanker version of the C-130 Hercules transport aircraft modified for aerial refueling.
|
||||
introduced: 1962
|
||||
manufacturer: Lockheed Martin
|
||||
origin: USA
|
||||
price: 25
|
||||
role: Tanker
|
||||
max_range: 1000
|
||||
patrol:
|
||||
# ~210 knots IAS, roughly the max for the KC-130 at altitude.
|
||||
speed: 370
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
description: The Boeing KC-135 Stratotanker is a military aerial refueling aircraft
|
||||
description:
|
||||
The Boeing KC-135 Stratotanker is a military aerial refueling aircraft
|
||||
that was developed from the Boeing 367-80 prototype, alongside the Boeing 707 airliner. This
|
||||
model has the Multi-point Refueling System modification, allowing for probe and
|
||||
drogue refuelling.
|
||||
@ -7,6 +8,7 @@ manufacturer: Boeing
|
||||
origin: USA
|
||||
price: 25
|
||||
role: Tanker
|
||||
max_range: 1000
|
||||
patrol:
|
||||
# 300 knots IAS.
|
||||
speed: 440
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
price: 50
|
||||
max_range: 2000
|
||||
patrol:
|
||||
altitude: 40000
|
||||
variants:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
carrier_capable: true
|
||||
description: The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet
|
||||
description:
|
||||
The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet
|
||||
aircraft that was used by the U.S. Navy (USN) primarily for anti-submarine warfare.
|
||||
In the late 1990s, the S-3B's mission focus shifted to surface warfare and aerial
|
||||
refueling. The Viking also provided electronic warfare and surface surveillance
|
||||
@ -16,6 +17,7 @@ origin: USA
|
||||
price: 20
|
||||
max_group_size: 1
|
||||
role: Carrier-based Tanker
|
||||
max_range: 1000
|
||||
patrol:
|
||||
# ~265 knots IAS.
|
||||
speed: 320
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
price: 25
|
||||
max_range: 600
|
||||
variants:
|
||||
Yak-40: null
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
name: 2xAIM-120B
|
||||
year: 1994
|
||||
fallback: AIM-7MH
|
||||
# If we've run out of doubles, start over with the singles.
|
||||
fallback: AIM-120C
|
||||
clsids:
|
||||
- "LAU-115_2*LAU-127_AIM-120B"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
name: AIM-7E
|
||||
year: 1963
|
||||
fallback: AIM-9X
|
||||
clsids:
|
||||
- "{AIM-7E}"
|
||||
- "{LAU-115 - AIM-7E}"
|
||||
|
||||
8
resources/weapons/a2a-missiles/AIM-9L-2X.yaml
Normal file
8
resources/weapons/a2a-missiles/AIM-9L-2X.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
name: 2xAIM-9L
|
||||
year: 1977
|
||||
# If we've run out of doubles, start over with the singles.
|
||||
fallback: AIM-9X
|
||||
clsids:
|
||||
- "LAU-105_2*AIM-9L"
|
||||
- "LAU-115_2*LAU-127_AIM-9L"
|
||||
- "{F4-2-AIM9L}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user