mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Plan waypoint TOTs.
Also fixes the CAP racetracks so the AI actually stays on station. Waypoint TOT assignment happens at mission generation time for the sake of the UI. It's a bit messy since we have the late-initialized field in FlightWaypoint, but on the other hand we don't have to reset every extant waypoint whenever the player adjusts the mission's TOT. If we want to clean this up a bit more, we could have two distinct types for waypoints: one for the planning stage and one with the resolved TOTs. We already do some thing like this with Flight vs FlightData. Future improvements: * Estimate the group's ground speed so we don't need such wide margins of error. * Delay takeoff to cut loiter fuel cost. * Plan mission TOT based on the aircraft in the package and their travel times to the objective. * Tune target area time prediction. Flights often don't need to travel all the way to the target point, and probably won't be doing it slowly, so the current planning causes a lot of extra time spent in enemy territory. * Per-flight TOT offsets from the package to allow a sweep to arrive before the rest, etc.
This commit is contained in:
parent
7abe32be5c
commit
b5e5a3b2da
596
gen/aircraft.py
596
gen/aircraft.py
@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
@ -28,7 +30,7 @@ from dcs.planes import (
|
||||
SpitfireLFMkIXCW,
|
||||
Su_33,
|
||||
)
|
||||
from dcs.point import PointAction
|
||||
from dcs.point import MovingPoint, PointAction
|
||||
from dcs.task import (
|
||||
AntishipStrike,
|
||||
AttackGroup,
|
||||
@ -40,8 +42,6 @@ from dcs.task import (
|
||||
EngageTargets,
|
||||
Escort,
|
||||
GroundAttack,
|
||||
MainTask,
|
||||
NoTask,
|
||||
OptROE,
|
||||
OptRTBOnBingoFuel,
|
||||
OptRTBOnOutOfAmmo,
|
||||
@ -64,10 +64,10 @@ from dcs.unittype import FlyingType, UnitType
|
||||
from game import db
|
||||
from game.data.cap_capabilities_db import GUNFIGHTERS
|
||||
from game.settings import Settings
|
||||
from game.utils import nm_to_meter
|
||||
from game.utils import meter_to_nm, nm_to_meter
|
||||
from gen.airfields import RunwayData
|
||||
from gen.airsupportgen import AirSupport
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.ato import AirTaskingOrder, Package
|
||||
from gen.callsigns import create_group_callsign_from_unit
|
||||
from gen.flights.flight import (
|
||||
Flight,
|
||||
@ -76,9 +76,10 @@ from gen.flights.flight import (
|
||||
FlightWaypointType,
|
||||
)
|
||||
from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio
|
||||
from theater import MissionTarget, TheaterGroundObject
|
||||
from theater.controlpoint import ControlPoint, ControlPointType
|
||||
from .naming import namegen
|
||||
from .conflictgen import Conflict
|
||||
from .naming import namegen
|
||||
|
||||
WARM_START_HELI_AIRSPEED = 120
|
||||
WARM_START_HELI_ALT = 500
|
||||
@ -532,6 +533,148 @@ AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"]
|
||||
AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageWaypointTiming:
|
||||
#: The package being scheduled.
|
||||
package: Package
|
||||
|
||||
#: The package join time.
|
||||
join: int
|
||||
|
||||
#: The ingress waypoint TOT.
|
||||
ingress: int
|
||||
|
||||
#: The egress waypoint TOT.
|
||||
egress: int
|
||||
|
||||
#: The package split time.
|
||||
split: int
|
||||
|
||||
@property
|
||||
def target(self) -> int:
|
||||
"""The package time over target."""
|
||||
assert self.package.time_over_target is not None
|
||||
return self.package.time_over_target
|
||||
|
||||
@property
|
||||
def race_track_start(self) -> Optional[int]:
|
||||
cap_types = (FlightType.BARCAP, FlightType.CAP)
|
||||
if self.package.primary_task in cap_types:
|
||||
# CAP flights don't have hold points, and we don't calculate takeoff
|
||||
# times yet or adjust the TOT based on when the flight can arrive,
|
||||
# so if we set a TOT that gives the flight a lot of extra time it
|
||||
# will just fly to the start point slowly, possibly slowly enough to
|
||||
# stall and crash. Just don't set a TOT for these points and let the
|
||||
# CAP get on station ASAP.
|
||||
return None
|
||||
else:
|
||||
return self.ingress
|
||||
|
||||
@property
|
||||
def race_track_end(self) -> int:
|
||||
cap_types = (FlightType.BARCAP, FlightType.CAP)
|
||||
if self.package.primary_task in cap_types:
|
||||
return self.target + CAP_DURATION * 60
|
||||
else:
|
||||
return self.egress
|
||||
|
||||
def push_time(self, flight: Flight, hold_point: Point) -> int:
|
||||
assert self.package.waypoints is not None
|
||||
return self.join - self.travel_time(
|
||||
hold_point,
|
||||
self.package.waypoints.join,
|
||||
self.flight_ground_speed(flight)
|
||||
)
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]:
|
||||
target_types = (
|
||||
FlightWaypointType.TARGET_GROUP_LOC,
|
||||
FlightWaypointType.TARGET_POINT,
|
||||
FlightWaypointType.TARGET_SHIP,
|
||||
)
|
||||
|
||||
ingress_types = (
|
||||
FlightWaypointType.INGRESS_CAS,
|
||||
FlightWaypointType.INGRESS_SEAD,
|
||||
FlightWaypointType.INGRESS_STRIKE,
|
||||
)
|
||||
|
||||
if waypoint.waypoint_type == FlightWaypointType.JOIN:
|
||||
return self.join
|
||||
elif waypoint.waypoint_type in ingress_types:
|
||||
return self.ingress
|
||||
elif waypoint.waypoint_type in target_types:
|
||||
return self.target
|
||||
elif waypoint.waypoint_type == FlightWaypointType.EGRESS:
|
||||
return self.egress
|
||||
elif waypoint.waypoint_type == FlightWaypointType.SPLIT:
|
||||
return self.split
|
||||
elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK:
|
||||
return self.race_track_start
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint,
|
||||
flight: Flight) -> Optional[int]:
|
||||
if waypoint.waypoint_type == FlightWaypointType.LOITER:
|
||||
return self.push_time(flight, Point(waypoint.x, waypoint.y))
|
||||
elif waypoint.waypoint_type == FlightWaypointType.PATROL:
|
||||
return self.race_track_end
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def for_package(cls, package: Package) -> PackageWaypointTiming:
|
||||
assert package.time_over_target is not None
|
||||
assert package.waypoints is not None
|
||||
|
||||
group_ground_speed = cls.package_ground_speed(package)
|
||||
|
||||
ingress = package.time_over_target - cls.travel_time(
|
||||
package.waypoints.ingress,
|
||||
package.target.position,
|
||||
group_ground_speed
|
||||
)
|
||||
|
||||
join = ingress - cls.travel_time(
|
||||
package.waypoints.join,
|
||||
package.waypoints.ingress,
|
||||
group_ground_speed
|
||||
)
|
||||
|
||||
egress = package.time_over_target + cls.travel_time(
|
||||
package.target.position,
|
||||
package.waypoints.egress,
|
||||
group_ground_speed
|
||||
)
|
||||
|
||||
split = egress + cls.travel_time(
|
||||
package.waypoints.egress,
|
||||
package.waypoints.split,
|
||||
group_ground_speed
|
||||
)
|
||||
|
||||
return cls(package, join, ingress, egress, split)
|
||||
|
||||
@classmethod
|
||||
def package_ground_speed(cls, package: Package) -> int:
|
||||
speeds = []
|
||||
for flight in package.flights:
|
||||
speeds.append(cls.flight_ground_speed(flight))
|
||||
return min(speeds) # knots
|
||||
|
||||
@staticmethod
|
||||
def flight_ground_speed(_flight: Flight) -> int:
|
||||
# TODO: Gather data so this is useful.
|
||||
return 400 # knots
|
||||
|
||||
@staticmethod
|
||||
def travel_time(a: Point, b: Point, speed: float) -> int:
|
||||
error_factor = 1.1
|
||||
distance = meter_to_nm(a.distance_to_point(b))
|
||||
hours = distance / speed
|
||||
seconds = hours * 3600
|
||||
return int(seconds * error_factor)
|
||||
|
||||
|
||||
class AircraftConflictGenerator:
|
||||
def __init__(self, mission: Mission, conflict: Conflict, settings: Settings,
|
||||
game, radio_registry: RadioRegistry):
|
||||
@ -634,7 +777,8 @@ class AircraftConflictGenerator:
|
||||
arrival=departure_runway,
|
||||
# TODO: Support for divert airfields.
|
||||
divert=None,
|
||||
waypoints=[first_point] + flight.points,
|
||||
# Waypoints are added later, after they've had their TOTs set.
|
||||
waypoints=[],
|
||||
intra_flight_channel=channel
|
||||
))
|
||||
|
||||
@ -817,6 +961,7 @@ class AircraftConflictGenerator:
|
||||
self.clear_parking_slots()
|
||||
|
||||
for package in ato.packages:
|
||||
timing = PackageWaypointTiming.for_package(package)
|
||||
for flight in package.flights:
|
||||
culled = self.game.position_culled(flight.from_cp.position)
|
||||
if flight.client_count == 0 and culled:
|
||||
@ -825,8 +970,7 @@ class AircraftConflictGenerator:
|
||||
logging.info(f"Generating flight: {flight.unit_type}")
|
||||
group = self.generate_planned_flight(flight.from_cp, country,
|
||||
flight)
|
||||
self.setup_flight_group(group, flight, flight.flight_type,
|
||||
dynamic_runways)
|
||||
self.setup_flight_group(group, flight, timing, dynamic_runways)
|
||||
self.setup_group_activation_trigger(flight, group)
|
||||
|
||||
def setup_group_activation_trigger(self, flight, group):
|
||||
@ -936,134 +1080,334 @@ class AircraftConflictGenerator:
|
||||
flight.group = group
|
||||
return group
|
||||
|
||||
|
||||
def setup_flight_group(self, group, flight, flight_type,
|
||||
dynamic_runways: Dict[str, RunwayData]):
|
||||
|
||||
if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, FlightType.INTERCEPTION]:
|
||||
group.task = CAP.name
|
||||
self._setup_group(group, CAP, flight, dynamic_runways)
|
||||
# group.points[0].tasks.clear()
|
||||
group.points[0].tasks.clear()
|
||||
group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), targets=[Targets.All.Air]))
|
||||
# group.tasks.append(EngageTargets(max_distance=nm_to_meter(120), targets=[Targets.All.Air]))
|
||||
if flight.unit_type not in GUNFIGHTERS:
|
||||
group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.AAM))
|
||||
else:
|
||||
group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.Cannon))
|
||||
|
||||
elif flight_type in [FlightType.CAS, FlightType.BAI]:
|
||||
group.task = CAS.name
|
||||
self._setup_group(group, CAS, flight, dynamic_runways)
|
||||
group.points[0].tasks.clear()
|
||||
group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(10), targets=[Targets.All.GroundUnits.GroundVehicles]))
|
||||
group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
|
||||
group.points[0].tasks.append(OptROE(OptROE.Values.OpenFireWeaponFree))
|
||||
group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.Unguided))
|
||||
group.points[0].tasks.append(OptRestrictJettison(True))
|
||||
elif flight_type in [FlightType.SEAD, FlightType.DEAD]:
|
||||
group.task = SEAD.name
|
||||
self._setup_group(group, SEAD, flight, dynamic_runways)
|
||||
group.points[0].tasks.clear()
|
||||
group.points[0].tasks.append(NoTask())
|
||||
group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
|
||||
group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
|
||||
group.points[0].tasks.append(OptRestrictJettison(True))
|
||||
group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.ASM))
|
||||
elif flight_type in [FlightType.STRIKE]:
|
||||
group.task = PinpointStrike.name
|
||||
self._setup_group(group, GroundAttack, flight, dynamic_runways)
|
||||
group.points[0].tasks.clear()
|
||||
group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
|
||||
group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
|
||||
group.points[0].tasks.append(OptRestrictJettison(True))
|
||||
elif flight_type in [FlightType.ANTISHIP]:
|
||||
group.task = AntishipStrike.name
|
||||
self._setup_group(group, AntishipStrike, flight, dynamic_runways)
|
||||
group.points[0].tasks.clear()
|
||||
group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
|
||||
group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
|
||||
group.points[0].tasks.append(OptRestrictJettison(True))
|
||||
elif flight_type == FlightType.ESCORT:
|
||||
group.task = Escort.name
|
||||
self._setup_group(group, Escort, flight, dynamic_runways)
|
||||
# TODO: Cleanup duplication...
|
||||
group.points[0].tasks.clear()
|
||||
group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
|
||||
group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
|
||||
group.points[0].tasks.append(OptRestrictJettison(True))
|
||||
@staticmethod
|
||||
def configure_behavior(
|
||||
group: FlyingGroup,
|
||||
react_on_threat: Optional[OptReactOnThreat.Values] = None,
|
||||
roe: Optional[OptROE.Values] = None,
|
||||
rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None,
|
||||
restrict_jettison: Optional[bool] = None) -> None:
|
||||
group.points[0].tasks.clear()
|
||||
if react_on_threat is not None:
|
||||
group.points[0].tasks.append(OptReactOnThreat(react_on_threat))
|
||||
if roe is not None:
|
||||
group.points[0].tasks.append(OptROE(roe))
|
||||
if restrict_jettison is not None:
|
||||
group.points[0].tasks.append(OptRestrictJettison(restrict_jettison))
|
||||
if rtb_winchester is not None:
|
||||
group.points[0].tasks.append(OptRTBOnOutOfAmmo(rtb_winchester))
|
||||
|
||||
group.points[0].tasks.append(OptRTBOnBingoFuel(True))
|
||||
group.points[0].tasks.append(OptRestrictAfterburner(True))
|
||||
|
||||
@staticmethod
|
||||
def configure_eplrs(group: FlyingGroup, flight: Flight) -> None:
|
||||
if hasattr(flight.unit_type, 'eplrs'):
|
||||
if flight.unit_type.eplrs:
|
||||
group.points[0].tasks.append(EPLRS(group.id))
|
||||
|
||||
for i, point in enumerate(flight.points):
|
||||
if not point.only_for_player or (point.only_for_player and flight.client_count > 0):
|
||||
pt = group.add_waypoint(Point(point.x, point.y), point.alt)
|
||||
if point.waypoint_type == FlightWaypointType.PATROL_TRACK:
|
||||
action = ControlledTask(OrbitAction(altitude=pt.alt, pattern=OrbitAction.OrbitPattern.RaceTrack))
|
||||
action.stop_after_duration(CAP_DURATION * 60)
|
||||
#for tgt in point.targets:
|
||||
# if hasattr(tgt, "position"):
|
||||
# engagetgt = EngageTargetsInZone(tgt.position, radius=CAP_DEFAULT_ENGAGE_DISTANCE, targets=[Targets.All.Air])
|
||||
# pt.tasks.append(engagetgt)
|
||||
elif point.waypoint_type == FlightWaypointType.LANDING_POINT:
|
||||
pt.type = "Land"
|
||||
pt.action = PointAction.Landing
|
||||
elif point.waypoint_type == FlightWaypointType.INGRESS_STRIKE:
|
||||
def configure_cap(self, group: FlyingGroup, flight: Flight,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
group.task = CAP.name
|
||||
self._setup_group(group, CAP, flight, dynamic_runways)
|
||||
|
||||
if group.units[0].unit_type == B_17G:
|
||||
if len(point.targets) > 0:
|
||||
bcenter = Point(0,0)
|
||||
for j, t in enumerate(point.targets):
|
||||
bcenter.x += t.position.x
|
||||
bcenter.y += t.position.y
|
||||
bcenter.x = bcenter.x / len(point.targets)
|
||||
bcenter.y = bcenter.y / len(point.targets)
|
||||
bombing = Bombing(bcenter)
|
||||
bombing.params["expend"] = "All"
|
||||
bombing.params["attackQtyLimit"] = False
|
||||
bombing.params["directionEnabled"] = False
|
||||
bombing.params["altitudeEnabled"] = False
|
||||
bombing.params["weaponType"] = 2032
|
||||
bombing.params["groupAttack"] = True
|
||||
pt.tasks.append(bombing)
|
||||
else:
|
||||
for j, t in enumerate(point.targets):
|
||||
print(t.position)
|
||||
pt.tasks.append(Bombing(t.position))
|
||||
if group.units[0].unit_type == JF_17 and j < 4:
|
||||
group.add_nav_target_point(t.position, "PP" + str(j + 1))
|
||||
if group.units[0].unit_type == F_14B and j == 0:
|
||||
group.add_nav_target_point(t.position, "ST")
|
||||
if group.units[0].unit_type == AJS37 and j < 9:
|
||||
group.add_nav_target_point(t.position, "M" + str(j + 1))
|
||||
elif point.waypoint_type == FlightWaypointType.INGRESS_SEAD:
|
||||
if flight.unit_type not in GUNFIGHTERS:
|
||||
ammo_type = OptRTBOnOutOfAmmo.Values.AAM
|
||||
else:
|
||||
ammo_type = OptRTBOnOutOfAmmo.Values.Cannon
|
||||
|
||||
tgroup = self.m.find_group(point.targetGroup.group_identifier)
|
||||
if tgroup is not None:
|
||||
task = AttackGroup(tgroup.id)
|
||||
task.params["expend"] = "All"
|
||||
task.params["attackQtyLimit"] = False
|
||||
task.params["directionEnabled"] = False
|
||||
task.params["altitudeEnabled"] = False
|
||||
task.params["weaponType"] = 268402702 # Guided Weapons
|
||||
task.params["groupAttack"] = True
|
||||
pt.tasks.append(task)
|
||||
self.configure_behavior(group, rtb_winchester=ammo_type)
|
||||
|
||||
for j, t in enumerate(point.targets):
|
||||
if group.units[0].unit_type == JF_17 and j < 4:
|
||||
group.add_nav_target_point(t.position, "PP" + str(j + 1))
|
||||
if group.units[0].unit_type == F_14B and j == 0:
|
||||
group.add_nav_target_point(t.position, "ST")
|
||||
if group.units[0].unit_type == AJS37 and j < 9:
|
||||
group.add_nav_target_point(t.position, "M" + str(j + 1))
|
||||
group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50),
|
||||
targets=[Targets.All.Air]))
|
||||
|
||||
if pt is not None:
|
||||
pt.alt_type = point.alt_type
|
||||
pt.name = String(point.name)
|
||||
def configure_cas(self, group: FlyingGroup, flight: Flight,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
group.task = CAS.name
|
||||
self._setup_group(group, CAS, flight, dynamic_runways)
|
||||
self.configure_behavior(
|
||||
group,
|
||||
react_on_threat=OptReactOnThreat.Values.EvadeFire,
|
||||
roe=OptROE.Values.OpenFireWeaponFree,
|
||||
rtb_winchester=OptRTBOnOutOfAmmo.Values.Unguided,
|
||||
restrict_jettison=True)
|
||||
group.points[0].tasks.append(
|
||||
EngageTargets(max_distance=nm_to_meter(10),
|
||||
targets=[Targets.All.GroundUnits.GroundVehicles])
|
||||
)
|
||||
|
||||
def configure_sead(self, group: FlyingGroup, flight: Flight,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
group.task = SEAD.name
|
||||
self._setup_group(group, SEAD, flight, dynamic_runways)
|
||||
self.configure_behavior(
|
||||
group,
|
||||
react_on_threat=OptReactOnThreat.Values.EvadeFire,
|
||||
roe=OptROE.Values.OpenFire,
|
||||
rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM,
|
||||
restrict_jettison=True)
|
||||
|
||||
def configure_strike(self, group: FlyingGroup, flight: Flight,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
group.task = PinpointStrike.name
|
||||
self._setup_group(group, GroundAttack, flight, dynamic_runways)
|
||||
self.configure_behavior(
|
||||
group,
|
||||
react_on_threat=OptReactOnThreat.Values.EvadeFire,
|
||||
roe=OptROE.Values.OpenFire,
|
||||
restrict_jettison=True)
|
||||
|
||||
def configure_anti_ship(self, group: FlyingGroup, flight: Flight,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
group.task = AntishipStrike.name
|
||||
self._setup_group(group, AntishipStrike, flight, dynamic_runways)
|
||||
self.configure_behavior(
|
||||
group,
|
||||
react_on_threat=OptReactOnThreat.Values.EvadeFire,
|
||||
roe=OptROE.Values.OpenFire,
|
||||
restrict_jettison=True)
|
||||
|
||||
def configure_escort(self, group: FlyingGroup, flight: Flight,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
group.task = Escort.name
|
||||
self._setup_group(group, Escort, flight, dynamic_runways)
|
||||
self.configure_behavior(group, roe=OptROE.Values.OpenFire,
|
||||
restrict_jettison=True)
|
||||
|
||||
def configure_unknown_task(self, group: FlyingGroup,
|
||||
flight: Flight) -> None:
|
||||
logging.error(f"Unhandled flight type: {flight.flight_type.name}")
|
||||
self.configure_behavior(group)
|
||||
|
||||
def setup_flight_group(self, group: FlyingGroup, flight: Flight,
|
||||
timing: PackageWaypointTiming,
|
||||
dynamic_runways: Dict[str, RunwayData]) -> None:
|
||||
flight_type = flight.flight_type
|
||||
if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP,
|
||||
FlightType.INTERCEPTION]:
|
||||
self.configure_cap(group, flight, dynamic_runways)
|
||||
elif flight_type in [FlightType.CAS, FlightType.BAI]:
|
||||
self.configure_cas(group, flight, dynamic_runways)
|
||||
elif flight_type in [FlightType.SEAD, FlightType.DEAD]:
|
||||
self.configure_sead(group, flight, dynamic_runways)
|
||||
elif flight_type in [FlightType.STRIKE]:
|
||||
self.configure_strike(group, flight, dynamic_runways)
|
||||
elif flight_type in [FlightType.ANTISHIP]:
|
||||
self.configure_anti_ship(group, flight, dynamic_runways)
|
||||
elif flight_type == FlightType.ESCORT:
|
||||
self.configure_escort(group, flight, dynamic_runways)
|
||||
else:
|
||||
self.configure_unknown_task(group, flight)
|
||||
|
||||
self.configure_eplrs(group, flight)
|
||||
|
||||
for waypoint in flight.points:
|
||||
waypoint.tot = None
|
||||
|
||||
for point in flight.points:
|
||||
if point.only_for_player and not flight.client_count:
|
||||
continue
|
||||
|
||||
PydcsWaypointBuilder.for_waypoint(
|
||||
point, group, flight, timing, self.m
|
||||
).build()
|
||||
|
||||
# Set here rather than when the FlightData is created so they waypoints
|
||||
# have their TOTs set.
|
||||
self.flights[-1].waypoints = flight.points
|
||||
self._setup_custom_payload(flight, group)
|
||||
|
||||
|
||||
class PydcsWaypointBuilder:
|
||||
def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup,
|
||||
flight: Flight, timing: PackageWaypointTiming,
|
||||
mission: Mission) -> None:
|
||||
self.waypoint = waypoint
|
||||
self.group = group
|
||||
self.flight = flight
|
||||
self.timing = timing
|
||||
self.mission = mission
|
||||
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = self.group.add_waypoint(
|
||||
Point(self.waypoint.x, self.waypoint.y), self.waypoint.alt)
|
||||
|
||||
waypoint.alt_type = self.waypoint.alt_type
|
||||
waypoint.name = String(self.waypoint.name)
|
||||
return waypoint
|
||||
|
||||
def set_waypoint_tot(self, waypoint: MovingPoint, tot: int) -> None:
|
||||
self.waypoint.tot = tot
|
||||
waypoint.ETA = tot
|
||||
waypoint.ETA_locked = True
|
||||
waypoint.speed_locked = False
|
||||
|
||||
@classmethod
|
||||
def for_waypoint(cls, waypoint: FlightWaypoint,
|
||||
group: FlyingGroup,
|
||||
flight: Flight,
|
||||
timing: PackageWaypointTiming,
|
||||
mission: Mission) -> PydcsWaypointBuilder:
|
||||
builders = {
|
||||
FlightWaypointType.EGRESS: EgressPointBuilder,
|
||||
FlightWaypointType.INGRESS_SEAD: SeadIngressBuilder,
|
||||
FlightWaypointType.INGRESS_STRIKE: StrikeIngressBuilder,
|
||||
FlightWaypointType.JOIN: JoinPointBuilder,
|
||||
FlightWaypointType.LANDING_POINT: LandingPointBuilder,
|
||||
FlightWaypointType.LOITER: HoldPointBuilder,
|
||||
FlightWaypointType.PATROL_TRACK: RaceTrackBuilder,
|
||||
FlightWaypointType.SPLIT: SplitPointBuilder,
|
||||
FlightWaypointType.TARGET_GROUP_LOC: TargetPointBuilder,
|
||||
FlightWaypointType.TARGET_POINT: TargetPointBuilder,
|
||||
FlightWaypointType.TARGET_SHIP: TargetPointBuilder,
|
||||
}
|
||||
builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder)
|
||||
return builder(waypoint, group, flight, timing, mission)
|
||||
|
||||
|
||||
class DefaultWaypointBuilder(PydcsWaypointBuilder):
|
||||
pass
|
||||
|
||||
|
||||
class HoldPointBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
loiter = ControlledTask(OrbitAction(
|
||||
altitude=waypoint.alt,
|
||||
pattern=OrbitAction.OrbitPattern.Circle
|
||||
))
|
||||
loiter.stop_after_time(
|
||||
self.timing.push_time(self.flight, waypoint.position))
|
||||
waypoint.add_task(loiter)
|
||||
return waypoint
|
||||
|
||||
|
||||
class EgressPointBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
self.set_waypoint_tot(waypoint, self.timing.egress)
|
||||
return waypoint
|
||||
|
||||
|
||||
class IngressBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
self.set_waypoint_tot(waypoint, self.timing.ingress)
|
||||
return waypoint
|
||||
|
||||
|
||||
class SeadIngressBuilder(IngressBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
|
||||
target_group = self.waypoint.targetGroup
|
||||
if isinstance(target_group, TheaterGroundObject):
|
||||
tgroup = self.mission.find_group(target_group.group_identifier)
|
||||
if tgroup is not None:
|
||||
task = AttackGroup(tgroup.id)
|
||||
task.params["expend"] = "All"
|
||||
task.params["attackQtyLimit"] = False
|
||||
task.params["directionEnabled"] = False
|
||||
task.params["altitudeEnabled"] = False
|
||||
task.params["weaponType"] = 268402702 # Guided Weapons
|
||||
task.params["groupAttack"] = True
|
||||
waypoint.tasks.append(task)
|
||||
|
||||
for i, t in enumerate(self.waypoint.targets):
|
||||
if self.group.units[0].unit_type == JF_17 and i < 4:
|
||||
self.group.add_nav_target_point(t.position, "PP" + str(i + 1))
|
||||
if self.group.units[0].unit_type == F_14B and i == 0:
|
||||
self.group.add_nav_target_point(t.position, "ST")
|
||||
if self.group.units[0].unit_type == AJS37 and i < 9:
|
||||
self.group.add_nav_target_point(t.position, "M" + str(i + 1))
|
||||
return waypoint
|
||||
|
||||
|
||||
class StrikeIngressBuilder(IngressBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
if self.group.units[0].unit_type == B_17G:
|
||||
return self.build_bombing()
|
||||
else:
|
||||
return self.build_strike()
|
||||
|
||||
def build_bombing(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
|
||||
targets = self.waypoint.targets
|
||||
if not targets:
|
||||
return waypoint
|
||||
|
||||
center = Point(0, 0)
|
||||
for target in targets:
|
||||
center.x += target.position.x
|
||||
center.y += target.position.y
|
||||
center.x /= len(targets)
|
||||
center.y /= len(targets)
|
||||
bombing = Bombing(center)
|
||||
bombing.params["expend"] = "All"
|
||||
bombing.params["attackQtyLimit"] = False
|
||||
bombing.params["directionEnabled"] = False
|
||||
bombing.params["altitudeEnabled"] = False
|
||||
bombing.params["weaponType"] = 2032
|
||||
bombing.params["groupAttack"] = True
|
||||
waypoint.tasks.append(bombing)
|
||||
return waypoint
|
||||
|
||||
def build_strike(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
|
||||
for i, t in enumerate(self.waypoint.targets):
|
||||
waypoint.tasks.append(Bombing(t.position))
|
||||
if self.group.units[0].unit_type == JF_17 and i < 4:
|
||||
self.group.add_nav_target_point(t.position, "PP" + str(i + 1))
|
||||
if self.group.units[0].unit_type == F_14B and i == 0:
|
||||
self.group.add_nav_target_point(t.position, "ST")
|
||||
if self.group.units[0].unit_type == AJS37 and i < 9:
|
||||
self.group.add_nav_target_point(t.position, "M" + str(i + 1))
|
||||
return waypoint
|
||||
|
||||
|
||||
class JoinPointBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
self.set_waypoint_tot(waypoint, self.timing.join)
|
||||
return waypoint
|
||||
|
||||
|
||||
class LandingPointBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
waypoint.type = "Land"
|
||||
waypoint.action = PointAction.Landing
|
||||
return waypoint
|
||||
|
||||
|
||||
class RaceTrackBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
|
||||
racetrack = ControlledTask(OrbitAction(
|
||||
altitude=waypoint.alt,
|
||||
pattern=OrbitAction.OrbitPattern.RaceTrack
|
||||
))
|
||||
|
||||
start = self.timing.race_track_start
|
||||
if start is not None:
|
||||
self.set_waypoint_tot(waypoint, start)
|
||||
racetrack.stop_after_time(self.timing.race_track_end)
|
||||
waypoint.add_task(racetrack)
|
||||
return waypoint
|
||||
|
||||
|
||||
class SplitPointBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
self.set_waypoint_tot(waypoint, self.timing.split)
|
||||
return waypoint
|
||||
|
||||
|
||||
class TargetPointBuilder(PydcsWaypointBuilder):
|
||||
def build(self) -> MovingPoint:
|
||||
waypoint = super().build()
|
||||
self.set_waypoint_tot(waypoint, self.timing.target)
|
||||
return waypoint
|
||||
|
||||
26
gen/ato.py
26
gen/ato.py
@ -8,14 +8,15 @@ example, the package to strike an enemy airfield may contain an escort flight,
|
||||
a SEAD flight, and the strike aircraft themselves. CAP packages may contain only
|
||||
the single CAP flight.
|
||||
"""
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Dict, Iterator, List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from dcs.mapping import Point
|
||||
from .flights.flight import Flight, FlightType
|
||||
|
||||
from theater.missiontarget import MissionTarget
|
||||
from .flights.flight import Flight, FlightType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -29,6 +30,14 @@ class Task:
|
||||
location: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageWaypoints:
|
||||
join: Point
|
||||
ingress: Point
|
||||
egress: Point
|
||||
split: Point
|
||||
|
||||
|
||||
@dataclass
|
||||
class Package:
|
||||
"""A mission package."""
|
||||
@ -42,10 +51,10 @@ class Package:
|
||||
|
||||
delay: int = field(default=0)
|
||||
|
||||
join_point: Optional[Point] = field(default=None, init=False, hash=False)
|
||||
split_point: Optional[Point] = field(default=None, init=False, hash=False)
|
||||
ingress_point: Optional[Point] = field(default=None, init=False, hash=False)
|
||||
egress_point: Optional[Point] = field(default=None, init=False, hash=False)
|
||||
#: Desired TOT measured in seconds from mission start.
|
||||
time_over_target: Optional[int] = field(default=None)
|
||||
|
||||
waypoints: Optional[PackageWaypoints] = field(default=None)
|
||||
|
||||
def add_flight(self, flight: Flight) -> None:
|
||||
"""Adds a flight to the package."""
|
||||
@ -55,8 +64,7 @@ class Package:
|
||||
"""Removes a flight from the package."""
|
||||
self.flights.remove(flight)
|
||||
if not self.flights:
|
||||
self.ingress_point = None
|
||||
self.egress_point = None
|
||||
self.waypoints = None
|
||||
|
||||
@property
|
||||
def primary_task(self) -> Optional[FlightType]:
|
||||
|
||||
@ -12,7 +12,7 @@ from game import db
|
||||
from game.data.radar_db import UNITS_WITH_RADAR
|
||||
from game.infos.information import Information
|
||||
from game.utils import nm_to_meter
|
||||
from gen import Conflict
|
||||
from gen import Conflict, PackageWaypointTiming
|
||||
from gen.ato import Package
|
||||
from gen.flights.ai_flight_planner_db import (
|
||||
CAP_CAPABLE,
|
||||
@ -496,10 +496,17 @@ class CoalitionMissionPlanner:
|
||||
latest=90,
|
||||
margin=5
|
||||
)
|
||||
for package in non_dca_packages:
|
||||
package.delay = next(start_time)
|
||||
for flight in package.flights:
|
||||
flight.scheduled_in = package.delay
|
||||
for package in self.ato.packages:
|
||||
if package.primary_task in dca_types:
|
||||
# All CAP missions should be on station in 15-25 minutes.
|
||||
package.time_over_target = (20 + random.randint(-5, 5)) * 60
|
||||
else:
|
||||
# But other packages should be spread out a bit.
|
||||
package.delay = next(start_time)
|
||||
# TODO: Compute TOT based on package.
|
||||
package.time_over_target = (package.delay + 40) * 60
|
||||
for flight in package.flights:
|
||||
flight.scheduled_in = package.delay
|
||||
|
||||
def message(self, title, text) -> None:
|
||||
"""Emits a planning message to the player.
|
||||
|
||||
@ -49,6 +49,7 @@ class FlightWaypointType(Enum):
|
||||
CUSTOM = 15 # User waypoint (no specific behaviour)
|
||||
JOIN = 16
|
||||
SPLIT = 17
|
||||
LOITER = 18
|
||||
|
||||
|
||||
class PredefinedWaypointCategory(Enum):
|
||||
@ -66,6 +67,15 @@ class FlightWaypoint:
|
||||
|
||||
def __init__(self, waypoint_type: FlightWaypointType, x: float, y: float,
|
||||
alt: int = 0) -> None:
|
||||
"""Creates a flight waypoint.
|
||||
|
||||
Args:
|
||||
waypoint_type: The waypoint type.
|
||||
x: X cooidinate 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".
|
||||
"""
|
||||
self.waypoint_type = waypoint_type
|
||||
self.x = x
|
||||
self.y = y
|
||||
@ -81,6 +91,12 @@ class FlightWaypoint:
|
||||
self.only_for_player = False
|
||||
self.data = None
|
||||
|
||||
# This is 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
|
||||
# to waypoint times whenever the player alters the package TOT or the
|
||||
# flight's offset in the UI.
|
||||
self.tot: Optional[int] = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_pydcs(cls, point: MovingPoint,
|
||||
|
||||
@ -16,10 +16,10 @@ from dcs.unit import Unit
|
||||
|
||||
from game.data.doctrine import Doctrine, MODERN_DOCTRINE
|
||||
from game.utils import nm_to_meter
|
||||
from gen.ato import Package
|
||||
from gen.ato import Package, PackageWaypoints
|
||||
from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject
|
||||
from .closestairfields import ObjectiveDistanceCache
|
||||
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
|
||||
from .flight import Flight, FlightType, FlightWaypoint
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..conflictgen import Conflict
|
||||
|
||||
@ -55,7 +55,8 @@ class FlightPlanBuilder:
|
||||
"""Creates a default flight plan for the given mission."""
|
||||
if flight not in self.package.flights:
|
||||
raise RuntimeError("Flight must be a part of the package")
|
||||
self.generate_missing_package_waypoints()
|
||||
if self.package.waypoints is None:
|
||||
self.regenerate_package_waypoints()
|
||||
|
||||
# TODO: Flesh out mission types.
|
||||
try:
|
||||
@ -105,15 +106,18 @@ class FlightPlanBuilder:
|
||||
except InvalidObjectiveLocation as ex:
|
||||
logging.error(f"Could not create flight plan: {ex}")
|
||||
|
||||
def generate_missing_package_waypoints(self) -> None:
|
||||
if self.package.ingress_point is None:
|
||||
self.package.ingress_point = self._ingress_point()
|
||||
if self.package.egress_point is None:
|
||||
self.package.egress_point = self._egress_point()
|
||||
if self.package.join_point is None:
|
||||
self.package.join_point = self._join_point()
|
||||
if self.package.split_point is None:
|
||||
self.package.split_point = self._split_point()
|
||||
def regenerate_package_waypoints(self) -> None:
|
||||
ingress_point = self._ingress_point()
|
||||
egress_point = self._egress_point()
|
||||
join_point = self._join_point(ingress_point)
|
||||
split_point = self._split_point(egress_point)
|
||||
|
||||
self.package.waypoints = PackageWaypoints(
|
||||
join_point,
|
||||
ingress_point,
|
||||
egress_point,
|
||||
split_point,
|
||||
)
|
||||
|
||||
def generate_strike(self, flight: Flight) -> None:
|
||||
"""Generates a strike flight plan.
|
||||
@ -121,6 +125,7 @@ class FlightPlanBuilder:
|
||||
Args:
|
||||
flight: The flight to generate the flight plan for.
|
||||
"""
|
||||
assert self.package.waypoints is not None
|
||||
location = self.package.target
|
||||
|
||||
# TODO: Support airfield strikes.
|
||||
@ -129,8 +134,9 @@ class FlightPlanBuilder:
|
||||
|
||||
builder = WaypointBuilder(self.doctrine)
|
||||
builder.ascent(flight.from_cp)
|
||||
builder.join(self.package.join_point)
|
||||
builder.ingress_strike(self.package.ingress_point, location)
|
||||
builder.hold(self._hold_point(flight))
|
||||
builder.join(self.package.waypoints.join)
|
||||
builder.ingress_strike(self.package.waypoints.ingress, location)
|
||||
|
||||
if len(location.groups) > 0 and location.dcs_identifier == "AA":
|
||||
# TODO: Replace with DEAD?
|
||||
@ -157,8 +163,8 @@ class FlightPlanBuilder:
|
||||
location
|
||||
)
|
||||
|
||||
builder.egress(self.package.egress_point, location)
|
||||
builder.split(self.package.split_point)
|
||||
builder.egress(self.package.waypoints.egress, location)
|
||||
builder.split(self.package.waypoints.split)
|
||||
builder.rtb(flight.from_cp)
|
||||
|
||||
flight.points = builder.build()
|
||||
@ -215,6 +221,7 @@ class FlightPlanBuilder:
|
||||
Args:
|
||||
flight: The flight to generate the flight plan for.
|
||||
"""
|
||||
assert self.package.waypoints is not None
|
||||
location = self.package.target
|
||||
|
||||
if not isinstance(location, FrontLine):
|
||||
@ -246,7 +253,10 @@ class FlightPlanBuilder:
|
||||
# Create points
|
||||
builder = WaypointBuilder(self.doctrine)
|
||||
builder.ascent(flight.from_cp)
|
||||
builder.hold(self._hold_point(flight))
|
||||
builder.join(self.package.waypoints.join)
|
||||
builder.race_track(orbit0p, orbit1p, patrol_alt)
|
||||
builder.split(self.package.waypoints.split)
|
||||
builder.rtb(flight.from_cp)
|
||||
flight.points = builder.build()
|
||||
|
||||
@ -258,6 +268,7 @@ class FlightPlanBuilder:
|
||||
flight: The flight to generate the flight plan for.
|
||||
custom_targets: Specific radar equipped units selected by the user.
|
||||
"""
|
||||
assert self.package.waypoints is not None
|
||||
location = self.package.target
|
||||
|
||||
if not isinstance(location, TheaterGroundObject):
|
||||
@ -268,21 +279,15 @@ class FlightPlanBuilder:
|
||||
|
||||
builder = WaypointBuilder(self.doctrine)
|
||||
builder.ascent(flight.from_cp)
|
||||
builder.join(self.package.join_point)
|
||||
builder.ingress_sead(self.package.ingress_point, location)
|
||||
builder.hold(self._hold_point(flight))
|
||||
builder.join(self.package.waypoints.join)
|
||||
builder.ingress_sead(self.package.waypoints.ingress, location)
|
||||
|
||||
# TODO: Unify these.
|
||||
# There doesn't seem to be any reason to treat the UI fragged missions
|
||||
# different from the automatic missions.
|
||||
if custom_targets:
|
||||
for target in custom_targets:
|
||||
point = FlightWaypoint(
|
||||
FlightWaypointType.TARGET_POINT,
|
||||
target.position.x,
|
||||
target.position.y,
|
||||
0
|
||||
)
|
||||
point.alt_type = "RADIO"
|
||||
if flight.flight_type == FlightType.DEAD:
|
||||
builder.dead_point(target, location.name, location)
|
||||
else:
|
||||
@ -293,17 +298,22 @@ class FlightPlanBuilder:
|
||||
else:
|
||||
builder.sead_area(location)
|
||||
|
||||
builder.egress(self.package.egress_point, location)
|
||||
builder.split(self.package.split_point)
|
||||
builder.egress(self.package.waypoints.egress, location)
|
||||
builder.split(self.package.waypoints.split)
|
||||
builder.rtb(flight.from_cp)
|
||||
|
||||
flight.points = builder.build()
|
||||
|
||||
def _hold_point(self, flight: Flight) -> Point:
|
||||
heading = flight.from_cp.position.heading_between_point(
|
||||
self.package.target.position
|
||||
)
|
||||
return flight.from_cp.position.point_from_heading(
|
||||
heading, nm_to_meter(15)
|
||||
)
|
||||
|
||||
def generate_escort(self, flight: Flight) -> None:
|
||||
# TODO: Decide common waypoints for the package ahead of time.
|
||||
# Packages should determine some common points like push, ingress,
|
||||
# egress, and split points ahead of time so they can be shared by all
|
||||
# flights.
|
||||
assert self.package.waypoints is not None
|
||||
|
||||
patrol_alt = random.randint(
|
||||
self.doctrine.min_patrol_altitude,
|
||||
@ -312,13 +322,11 @@ class FlightPlanBuilder:
|
||||
|
||||
builder = WaypointBuilder(self.doctrine)
|
||||
builder.ascent(flight.from_cp)
|
||||
builder.join(self.package.join_point)
|
||||
builder.race_track(
|
||||
self.package.ingress_point,
|
||||
self.package.egress_point,
|
||||
patrol_alt
|
||||
)
|
||||
builder.split(self.package.split_point)
|
||||
builder.hold(self._hold_point(flight))
|
||||
builder.join(self.package.waypoints.join)
|
||||
builder.race_track(self.package.waypoints.ingress,
|
||||
self.package.waypoints.egress, patrol_alt)
|
||||
builder.split(self.package.waypoints.split)
|
||||
builder.rtb(flight.from_cp)
|
||||
|
||||
flight.points = builder.build()
|
||||
@ -329,6 +337,7 @@ class FlightPlanBuilder:
|
||||
Args:
|
||||
flight: The flight to generate the flight plan for.
|
||||
"""
|
||||
assert self.package.waypoints is not None
|
||||
location = self.package.target
|
||||
|
||||
if not isinstance(location, FrontLine):
|
||||
@ -346,11 +355,12 @@ class FlightPlanBuilder:
|
||||
|
||||
builder = WaypointBuilder(self.doctrine)
|
||||
builder.ascent(flight.from_cp, is_helo)
|
||||
builder.join(self.package.join_point)
|
||||
builder.hold(self._hold_point(flight))
|
||||
builder.join(self.package.waypoints.join)
|
||||
builder.ingress_cas(ingress, location)
|
||||
builder.cas(center, cap_alt)
|
||||
builder.egress(egress, location)
|
||||
builder.split(self.package.split_point)
|
||||
builder.split(self.package.waypoints.split)
|
||||
builder.rtb(flight.from_cp, is_helo)
|
||||
|
||||
flight.points = builder.build()
|
||||
@ -386,16 +396,12 @@ class FlightPlanBuilder:
|
||||
builder.land(arrival)
|
||||
return builder.build()[0]
|
||||
|
||||
def _join_point(self) -> Point:
|
||||
ingress_point = self.package.ingress_point
|
||||
assert ingress_point is not None
|
||||
def _join_point(self, ingress_point: Point) -> Point:
|
||||
heading = self._heading_to_package_airfield(ingress_point)
|
||||
return ingress_point.point_from_heading(heading,
|
||||
-self.doctrine.join_distance)
|
||||
|
||||
def _split_point(self) -> Point:
|
||||
egress_point = self.package.egress_point
|
||||
assert egress_point is not None
|
||||
def _split_point(self, egress_point: Point) -> Point:
|
||||
heading = self._heading_to_package_airfield(egress_point)
|
||||
return egress_point.point_from_heading(heading,
|
||||
-self.doctrine.split_distance)
|
||||
|
||||
@ -25,6 +25,7 @@ class WaypointBuilder:
|
||||
|
||||
Args:
|
||||
departure: Departure airfield or carrier.
|
||||
is_helo: True if the flight is a helicopter.
|
||||
"""
|
||||
# TODO: Pick runway based on wind direction.
|
||||
heading = departure.heading
|
||||
@ -48,6 +49,7 @@ class WaypointBuilder:
|
||||
|
||||
Args:
|
||||
arrival: Arrival airfield or carrier.
|
||||
is_helo: True if the flight is a helicopter.
|
||||
"""
|
||||
# TODO: Pick runway based on wind direction.
|
||||
# ControlPoint.heading is the departure heading.
|
||||
@ -86,6 +88,18 @@ class WaypointBuilder:
|
||||
waypoint.pretty_name = "Land"
|
||||
self.waypoints.append(waypoint)
|
||||
|
||||
def hold(self, position: Point) -> None:
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.LOITER,
|
||||
position.x,
|
||||
position.y,
|
||||
self.doctrine.rendezvous_altitude
|
||||
)
|
||||
waypoint.pretty_name = "Hold"
|
||||
waypoint.description = "Wait until push time"
|
||||
waypoint.name = "HOLD"
|
||||
self.waypoints.append(waypoint)
|
||||
|
||||
def join(self, position: Point) -> None:
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.JOIN,
|
||||
@ -170,7 +184,7 @@ class WaypointBuilder:
|
||||
location)
|
||||
|
||||
def _target_point(self, target: Union[TheaterGroundObject, Unit], name: str,
|
||||
description: str, location: MissionTarget) -> None:
|
||||
description: str, location: MissionTarget) -> None:
|
||||
if self.ingress_point is None:
|
||||
raise RuntimeError(
|
||||
"An ingress point must be added before target points."
|
||||
@ -293,6 +307,7 @@ class WaypointBuilder:
|
||||
|
||||
Args:
|
||||
arrival: Arrival airfield or carrier.
|
||||
is_helo: True if the flight is a helicopter.
|
||||
"""
|
||||
self.descent(arrival, is_helo)
|
||||
self.land(arrival)
|
||||
|
||||
@ -24,6 +24,7 @@ aircraft will be able to see the enemy's kneeboard for the same airframe.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
@ -106,7 +107,8 @@ class NumberedWaypoint:
|
||||
|
||||
|
||||
class FlightPlanBuilder:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, start_time: datetime.datetime) -> None:
|
||||
self.start_time = start_time
|
||||
self.rows: List[List[str]] = []
|
||||
self.target_points: List[NumberedWaypoint] = []
|
||||
|
||||
@ -133,16 +135,24 @@ class FlightPlanBuilder:
|
||||
self.rows.append([
|
||||
f"{first_waypoint_num}-{last_waypoint_num}",
|
||||
"Target points",
|
||||
"0"
|
||||
"0",
|
||||
self._format_time(self.target_points[0].waypoint.tot),
|
||||
])
|
||||
|
||||
def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None:
|
||||
self.rows.append([
|
||||
str(waypoint.number),
|
||||
waypoint.waypoint.pretty_name,
|
||||
str(int(units.meters_to_feet(waypoint.waypoint.alt)))
|
||||
str(int(units.meters_to_feet(waypoint.waypoint.alt))),
|
||||
self._format_time(waypoint.waypoint.tot),
|
||||
])
|
||||
|
||||
def _format_time(self, time: Optional[int]) -> str:
|
||||
if time is None:
|
||||
return ""
|
||||
local_time = self.start_time + datetime.timedelta(seconds=time)
|
||||
return local_time.strftime(f"%H:%M:%S LOCAL")
|
||||
|
||||
def build(self) -> List[List[str]]:
|
||||
return self.rows
|
||||
|
||||
@ -151,12 +161,13 @@ class BriefingPage(KneeboardPage):
|
||||
"""A kneeboard page containing briefing information."""
|
||||
def __init__(self, flight: FlightData, comms: List[CommInfo],
|
||||
awacs: List[AwacsInfo], tankers: List[TankerInfo],
|
||||
jtacs: List[JtacInfo]) -> None:
|
||||
jtacs: List[JtacInfo], start_time: datetime.datetime) -> None:
|
||||
self.flight = flight
|
||||
self.comms = list(comms)
|
||||
self.awacs = awacs
|
||||
self.tankers = tankers
|
||||
self.jtacs = jtacs
|
||||
self.start_time = start_time
|
||||
self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel))
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
@ -172,11 +183,11 @@ class BriefingPage(KneeboardPage):
|
||||
], headers=["", "Airbase", "ATC", "TCN", "I(C)LS", "RWY"])
|
||||
|
||||
writer.heading("Flight Plan")
|
||||
flight_plan_builder = FlightPlanBuilder()
|
||||
flight_plan_builder = FlightPlanBuilder(self.start_time)
|
||||
for num, waypoint in enumerate(self.flight.waypoints):
|
||||
flight_plan_builder.add_waypoint(num, waypoint)
|
||||
writer.table(flight_plan_builder.build(),
|
||||
headers=["STPT", "Action", "Alt"])
|
||||
headers=["STPT", "Action", "Alt", "TOT"])
|
||||
|
||||
writer.heading("Comm Ladder")
|
||||
comms = []
|
||||
@ -297,6 +308,11 @@ class KneeboardGenerator(MissionInfoGenerator):
|
||||
"""Returns a list of kneeboard pages for the given flight."""
|
||||
return [
|
||||
BriefingPage(
|
||||
flight, self.comms, self.awacs, self.tankers, self.jtacs
|
||||
flight,
|
||||
self.comms,
|
||||
self.awacs,
|
||||
self.tankers,
|
||||
self.jtacs,
|
||||
self.mission.start_time
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
"""Qt data models for game objects."""
|
||||
import datetime
|
||||
from enum import auto, IntEnum
|
||||
from typing import Any, Callable, Dict, Iterator, TypeVar, Optional
|
||||
|
||||
from PySide2.QtCore import (
|
||||
@ -156,6 +158,9 @@ class PackageModel(QAbstractListModel):
|
||||
"""Returns the flight located at the given index."""
|
||||
return self.package.flights[index.row()]
|
||||
|
||||
def update_tot(self, tot: int) -> None:
|
||||
self.package.time_over_target = tot
|
||||
|
||||
@property
|
||||
def mission_target(self) -> MissionTarget:
|
||||
"""Returns the mission target of the package."""
|
||||
@ -178,6 +183,8 @@ class PackageModel(QAbstractListModel):
|
||||
class AtoModel(QAbstractListModel):
|
||||
"""The model for an AirTaskingOrder."""
|
||||
|
||||
PackageRole = Qt.UserRole
|
||||
|
||||
def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None:
|
||||
super().__init__()
|
||||
self.game = game
|
||||
@ -190,9 +197,11 @@ class AtoModel(QAbstractListModel):
|
||||
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
|
||||
if not index.isValid():
|
||||
return None
|
||||
package = self.ato.packages[index.row()]
|
||||
if role == Qt.DisplayRole:
|
||||
package = self.ato.packages[index.row()]
|
||||
return f"{package.package_description} {package.target.name}"
|
||||
elif role == AtoModel.PackageRole:
|
||||
return package
|
||||
return None
|
||||
|
||||
def add_package(self, package: Package) -> None:
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
"""Widgets for displaying air tasking orders."""
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
from contextlib import contextmanager
|
||||
from typing import ContextManager, Optional
|
||||
|
||||
from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize, Qt
|
||||
from PySide2.QtCore import (
|
||||
QItemSelectionModel,
|
||||
QModelIndex,
|
||||
QSize,
|
||||
Qt,
|
||||
)
|
||||
from PySide2.QtGui import QFont, QFontMetrics, QPainter
|
||||
from PySide2.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QGroupBox,
|
||||
@ -10,13 +18,13 @@ from PySide2.QtWidgets import (
|
||||
QListView,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout,
|
||||
)
|
||||
|
||||
from gen.ato import Package
|
||||
from gen.flights.flight import Flight
|
||||
from ..models import AtoModel, GameModel, NullListModel, PackageModel
|
||||
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
|
||||
from ..models import AtoModel, GameModel, NullListModel, PackageModel
|
||||
|
||||
|
||||
class QFlightList(QListView):
|
||||
@ -138,6 +146,65 @@ class QFlightPanel(QGroupBox):
|
||||
GameUpdateSignal.get_instance().redraw_flight_paths()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def painter_context(painter: QPainter) -> ContextManager[None]:
|
||||
try:
|
||||
painter.save()
|
||||
yield
|
||||
finally:
|
||||
painter.restore()
|
||||
|
||||
|
||||
class PackageDelegate(QStyledItemDelegate):
|
||||
FONT_SIZE = 12
|
||||
HMARGIN = 4
|
||||
VMARGIN = 4
|
||||
|
||||
def get_font(self, option: QStyleOptionViewItem) -> QFont:
|
||||
font = QFont(option.font)
|
||||
font.setPointSize(self.FONT_SIZE)
|
||||
return font
|
||||
|
||||
@staticmethod
|
||||
def package(index: QModelIndex) -> Package:
|
||||
return index.data(AtoModel.PackageRole)
|
||||
|
||||
def left_text(self, index: QModelIndex) -> str:
|
||||
package = self.package(index)
|
||||
return f"{package.package_description} {package.target.name}"
|
||||
|
||||
def right_text(self, index: QModelIndex) -> str:
|
||||
package = self.package(index)
|
||||
if package.time_over_target is None:
|
||||
return ""
|
||||
tot = datetime.timedelta(seconds=package.time_over_target)
|
||||
return f"TOT T+{tot}"
|
||||
|
||||
def paint(self, painter: QPainter, option: QStyleOptionViewItem,
|
||||
index: QModelIndex) -> None:
|
||||
# Draw the list item with all the default selection styling, but with an
|
||||
# invalid index so text formatting is left to us.
|
||||
super().paint(painter, option, QModelIndex())
|
||||
|
||||
rect = option.rect.adjusted(self.HMARGIN, self.VMARGIN, -self.HMARGIN,
|
||||
-self.VMARGIN)
|
||||
|
||||
with painter_context(painter):
|
||||
painter.setFont(self.get_font(option))
|
||||
|
||||
painter.drawText(rect, Qt.AlignLeft, self.left_text(index))
|
||||
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
|
||||
painter.drawText(line2, Qt.AlignLeft, self.right_text(index))
|
||||
|
||||
def sizeHint(self, option: QStyleOptionViewItem,
|
||||
index: QModelIndex) -> QSize:
|
||||
metrics = QFontMetrics(self.get_font(option))
|
||||
left = metrics.size(0, self.left_text(index))
|
||||
right = metrics.size(0, self.right_text(index))
|
||||
return QSize(max(left.width(), right.width()) + 2 * self.HMARGIN,
|
||||
left.height() + right.height() + 2 * self.VMARGIN)
|
||||
|
||||
|
||||
class QPackageList(QListView):
|
||||
"""List view for displaying the packages of an ATO."""
|
||||
|
||||
@ -145,6 +212,7 @@ class QPackageList(QListView):
|
||||
super().__init__()
|
||||
self.ato_model = model
|
||||
self.setModel(model)
|
||||
self.setItemDelegate(PackageDelegate())
|
||||
self.setIconSize(QSize(91, 24))
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectItems)
|
||||
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from PySide2.QtCore import QItemSelection, Signal
|
||||
from PySide2.QtCore import QItemSelection, QTime, Signal
|
||||
from PySide2.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTimeEdit,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
@ -56,14 +57,39 @@ class QPackageDialog(QDialog):
|
||||
self.summary_row = QHBoxLayout()
|
||||
self.layout.addLayout(self.summary_row)
|
||||
|
||||
self.package_type_column = QHBoxLayout()
|
||||
self.summary_row.addLayout(self.package_type_column)
|
||||
|
||||
self.package_type_label = QLabel("Package Type:")
|
||||
self.package_type_text = QLabel(self.package_model.description)
|
||||
# noinspection PyUnresolvedReferences
|
||||
self.package_changed.connect(lambda: self.package_type_text.setText(
|
||||
self.package_model.description
|
||||
))
|
||||
self.summary_row.addWidget(self.package_type_label)
|
||||
self.summary_row.addWidget(self.package_type_text)
|
||||
self.package_type_column.addWidget(self.package_type_label)
|
||||
self.package_type_column.addWidget(self.package_type_text)
|
||||
|
||||
self.summary_row.addStretch(1)
|
||||
|
||||
self.tot_column = QHBoxLayout()
|
||||
self.summary_row.addLayout(self.tot_column)
|
||||
|
||||
self.tot_label = QLabel("Time Over Target:")
|
||||
self.tot_column.addWidget(self.tot_label)
|
||||
|
||||
if self.package_model.package.time_over_target is None:
|
||||
time = None
|
||||
else:
|
||||
delay = self.package_model.package.time_over_target
|
||||
hours = delay // 3600
|
||||
minutes = delay // 60 % 60
|
||||
seconds = delay % 60
|
||||
time = QTime(hours, minutes, seconds)
|
||||
|
||||
self.tot_spinner = QTimeEdit(time)
|
||||
self.tot_spinner.setMinimumTime(QTime(0, 0))
|
||||
self.tot_spinner.setDisplayFormat("T+hh:mm:ss")
|
||||
self.tot_column.addWidget(self.tot_spinner)
|
||||
|
||||
self.package_view = QFlightList(self.package_model)
|
||||
self.package_view.selectionModel().selectionChanged.connect(
|
||||
@ -90,8 +116,10 @@ class QPackageDialog(QDialog):
|
||||
|
||||
self.finished.connect(self.on_close)
|
||||
|
||||
@staticmethod
|
||||
def on_close(_result) -> None:
|
||||
def on_close(self, _result) -> None:
|
||||
time = self.tot_spinner.time()
|
||||
seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
|
||||
self.package_model.update_tot(seconds)
|
||||
GameUpdateSignal.get_instance().redraw_flight_paths()
|
||||
|
||||
def on_selection_changed(self, selected: QItemSelection,
|
||||
|
||||
@ -1,26 +1,33 @@
|
||||
import datetime
|
||||
import itertools
|
||||
|
||||
from PySide2.QtCore import QItemSelectionModel, QPoint
|
||||
from PySide2.QtGui import QStandardItemModel, QStandardItem
|
||||
from PySide2.QtWidgets import QTableView, QHeaderView
|
||||
from PySide2.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide2.QtWidgets import QHeaderView, QTableView
|
||||
|
||||
from game.utils import meter_to_feet
|
||||
from gen.aircraft import PackageWaypointTiming
|
||||
from gen.ato import Package
|
||||
from gen.flights.flight import Flight, FlightWaypoint
|
||||
from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import QWaypointItem
|
||||
from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import \
|
||||
QWaypointItem
|
||||
|
||||
|
||||
class QFlightWaypointList(QTableView):
|
||||
|
||||
def __init__(self, flight: Flight):
|
||||
super(QFlightWaypointList, self).__init__()
|
||||
def __init__(self, package: Package, flight: Flight):
|
||||
super().__init__()
|
||||
self.package = package
|
||||
self.flight = flight
|
||||
|
||||
self.model = QStandardItemModel(self)
|
||||
self.setModel(self.model)
|
||||
self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.model.setHorizontalHeaderLabels(["Name", "Alt"])
|
||||
self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"])
|
||||
|
||||
header = self.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
|
||||
self.flight = flight
|
||||
|
||||
if len(self.flight.points) > 0:
|
||||
self.selectedPoint = self.flight.points[0]
|
||||
self.update_list()
|
||||
@ -33,18 +40,49 @@ class QFlightWaypointList(QTableView):
|
||||
|
||||
def update_list(self):
|
||||
self.model.clear()
|
||||
self.model.setHorizontalHeaderLabels(["Name", "Alt"])
|
||||
takeoff = FlightWaypoint(self.flight.from_cp.position.x, self.flight.from_cp.position.y, 0)
|
||||
|
||||
self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"])
|
||||
|
||||
timing = PackageWaypointTiming.for_package(self.package)
|
||||
|
||||
# The first waypoint is set up by pydcs at mission generation time, so
|
||||
# we need to add that waypoint manually.
|
||||
takeoff = FlightWaypoint(self.flight.from_cp.position.x,
|
||||
self.flight.from_cp.position.y, 0)
|
||||
takeoff.description = "Take Off"
|
||||
takeoff.name = takeoff.pretty_name = "Take Off from " + self.flight.from_cp.name
|
||||
self.model.appendRow(QWaypointItem(takeoff, 0))
|
||||
item = QStandardItem("0 feet AGL")
|
||||
item.setEditable(False)
|
||||
self.model.setItem(0, 1, item)
|
||||
for i, point in enumerate(self.flight.points):
|
||||
self.model.insertRow(self.model.rowCount())
|
||||
self.model.setItem(self.model.rowCount()-1, 0, QWaypointItem(point, i + 1))
|
||||
item = QStandardItem(str(meter_to_feet(point.alt)) + " ft " + str(["AGL" if point.alt_type == "RADIO" else "MSL"][0]))
|
||||
item.setEditable(False)
|
||||
self.model.setItem(self.model.rowCount()-1, 1, item)
|
||||
self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select)
|
||||
takeoff.alt_type = "RADIO"
|
||||
|
||||
waypoints = itertools.chain([takeoff], self.flight.points)
|
||||
for row, waypoint in enumerate(waypoints):
|
||||
self.add_waypoint_row(row, waypoint, timing)
|
||||
self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)),
|
||||
QItemSelectionModel.Select)
|
||||
|
||||
def add_waypoint_row(self, row: int, waypoint: FlightWaypoint,
|
||||
timing: PackageWaypointTiming) -> None:
|
||||
self.model.insertRow(self.model.rowCount())
|
||||
|
||||
self.model.setItem(row, 0, QWaypointItem(waypoint, row))
|
||||
|
||||
altitude = meter_to_feet(waypoint.alt)
|
||||
altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL"
|
||||
altitude_item = QStandardItem(f"{altitude} ft {altitude_type}")
|
||||
altitude_item.setEditable(False)
|
||||
self.model.setItem(row, 1, altitude_item)
|
||||
|
||||
tot = self.tot_text(waypoint, timing)
|
||||
tot_item = QStandardItem(tot)
|
||||
tot_item.setEditable(False)
|
||||
self.model.setItem(row, 2, tot_item)
|
||||
|
||||
def tot_text(self, waypoint: FlightWaypoint,
|
||||
timing: PackageWaypointTiming) -> str:
|
||||
prefix = ""
|
||||
time = timing.tot_for_waypoint(waypoint)
|
||||
if time is None:
|
||||
prefix = "Depart "
|
||||
time = timing.depart_time_for_waypoint(waypoint, self.flight)
|
||||
if time is None:
|
||||
return ""
|
||||
return f"{prefix}T+{datetime.timedelta(seconds=time)}"
|
||||
|
||||
@ -44,7 +44,8 @@ class QFlightWaypointTab(QFrame):
|
||||
def init_ui(self):
|
||||
layout = QGridLayout()
|
||||
|
||||
self.flight_waypoint_list = QFlightWaypointList(self.flight)
|
||||
self.flight_waypoint_list = QFlightWaypointList(self.package,
|
||||
self.flight)
|
||||
layout.addWidget(self.flight_waypoint_list, 0, 0)
|
||||
|
||||
rlayout = QVBoxLayout()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user