diff --git a/changelog.md b/changelog.md index 08acf714..c4d5b035 100644 --- a/changelog.md +++ b/changelog.md @@ -5,12 +5,20 @@ * **[Mission Generation]** Add option to switch ATFLIR to LITENING automatically for ground based F-18C flights * **[Mission Generation]** Add option to configure OPFOR autoplanner aggressiveness and have the AI take risks and plan missions against defended targets * **[Mission Generation]** Add option to configure the desired tanker on-station time in settings +* **[Mission Generation]** Reserve GUARD frequency on VHF/UHF +* **[Mission Generation]** Randomization in radio frequency allocation * **[Cheat Menu]** Option to instantly transfer squadrons across bases. * **[UI]** Add selectable units in faction overview during campaign generation. * **[UI]** Add button to rename pilots in Air Wing's Squadron dialog. * **[UI]** Add clone buttons for flights & packages. * **[UI]** Editing of flight's custom name. * **[UI]** Introduce custom names for packages (purely for organizational purposes). +* **[UI]** Configurable UHF frequency (225-400MHz) for Packages, Carriers, LHAs, FOBs & FARPs. +* **[UI]** Configurable Intra-Flight frequency for Flights. +* **[UI]** Configurable TACAN for Carriers, LHAs & Tankers. +* **[UI]** Configurable ICLS for capable Carriers & LHAs. +* **[UI]** Configurable LINK4 for Carriers. +* **[Kneeboard]** Show package information in Support page ## Fixes * **[UI]** Removed deprecated options diff --git a/game/ato/flight.py b/game/ato/flight.py index e3c39463..4e19d7e3 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -12,6 +12,10 @@ from .flightroster import FlightRoster from .flightstate import FlightState, Navigating, Uninitialized from .flightstate.killed import Killed from .loadouts import Loadout, Weapon +from ..radio.RadioFrequencyContainer import RadioFrequencyContainer +from ..radio.TacanContainer import TacanContainer +from ..radio.radios import RadioFrequency +from ..radio.tacan import TacanChannel from ..sidc import ( Entity, SidcDescribable, @@ -36,7 +40,7 @@ if TYPE_CHECKING: F18_TGP_PYLON: int = 4 -class Flight(SidcDescribable): +class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer): def __init__( self, package: Package, @@ -49,6 +53,9 @@ class Flight(SidcDescribable): custom_name: Optional[str] = None, cargo: Optional[TransferOrder] = None, roster: Optional[FlightRoster] = None, + frequency: Optional[RadioFrequency] = None, + channel: Optional[TacanChannel] = None, + callsign: Optional[str] = None, ) -> None: self.id = uuid.uuid4() self.package = package @@ -68,6 +75,11 @@ class Flight(SidcDescribable): self.custom_name = custom_name self.group_id: int = 0 + self.frequency = frequency + if self.unit_type.dcs_unit_type.tacan: + self.tacan = channel + self.tcn_name = callsign + # Only used by transport missions. self.cargo = cargo diff --git a/game/ato/package.py b/game/ato/package.py index b91c8a61..037231c6 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -13,13 +13,14 @@ from .flightplans.formation import FormationFlightPlan from .flighttype import FlightType from .packagewaypoints import PackageWaypoints from .traveltime import TotEstimator +from ..radio.RadioFrequencyContainer import RadioFrequencyContainer from ..radio.radios import RadioFrequency if TYPE_CHECKING: from game.theater import ControlPoint, MissionTarget -class Package: +class Package(RadioFrequencyContainer): """A mission package.""" def __init__( diff --git a/game/migrator.py b/game/migrator.py index 2242e89d..c5f40c79 100644 --- a/game/migrator.py +++ b/game/migrator.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from game.ato.packagewaypoints import PackageWaypoints from game.data.doctrine import MODERN_DOCTRINE, COLDWAR_DOCTRINE, WWII_DOCTRINE @@ -9,6 +9,11 @@ if TYPE_CHECKING: from game import Game +def try_set_attr(obj: Any, attr_name: str, val: Any = None) -> None: + if not hasattr(obj, attr_name): + setattr(obj, attr_name, val) + + class Migrator: def __init__(self, game: Game): self.game = game @@ -18,6 +23,8 @@ class Migrator: self._update_doctrine() self._update_packagewaypoints() self._update_package_attributes() + self._update_control_points() + self._update_flights() def _update_doctrine(self) -> None: doctrines = [ @@ -40,13 +47,32 @@ class Migrator: def _update_packagewaypoints(self) -> None: for c in self.game.coalitions: for p in c.ato.packages: - if p.flights and not hasattr(p.waypoints, "initial"): - p.waypoints = PackageWaypoints.create(p, c) + if p.flights: + try_set_attr(p.waypoints, "initial", PackageWaypoints.create(p, c)) def _update_package_attributes(self) -> None: for c in self.game.coalitions: for p in c.ato.packages: - if not hasattr(p, "custom_name"): - p.custom_name = None - if not hasattr(p, "frequency"): - p.frequency = None + try_set_attr(p, "custom_name") + try_set_attr(p, "frequency") + + def _update_control_points(self) -> None: + for cp in self.game.theater.controlpoints: + is_carrier = cp.is_carrier + is_lha = cp.is_lha + is_fob = cp.category == "fob" + radio_configurable = is_carrier or is_lha or is_fob + if radio_configurable: + try_set_attr(cp, "frequency") + if is_carrier or is_lha: + try_set_attr(cp, "tacan") + try_set_attr(cp, "tcn_name") + try_set_attr(cp, "icls_channel") + try_set_attr(cp, "icls_name") + try_set_attr(cp, "link4") + + def _update_flights(self) -> None: + for f in self.game.db.flights.objects.values(): + try_set_attr(f, "frequency") + try_set_attr(f, "tacan") + try_set_attr(f, "tcn_name") diff --git a/game/missiongenerator/aircraft/aircraftgenerator.py b/game/missiongenerator/aircraft/aircraftgenerator.py index 300af99b..cf4fdcbc 100644 --- a/game/missiongenerator/aircraft/aircraftgenerator.py +++ b/game/missiongenerator/aircraft/aircraftgenerator.py @@ -104,11 +104,7 @@ class AircraftGenerator: ato: The ATO to spawn aircraft for. dynamic_runways: Runway data for carriers and FARPs. """ - for package in reversed(sorted(ato.packages, key=lambda x: x.time_over_target)): - if package.frequency is None: - continue - if package.frequency not in self.radio_registry.allocated_channels: - self.radio_registry.reserve(package.frequency) + self._reserve_frequencies_and_tacan(ato) for package in reversed(sorted(ato.packages, key=lambda x: x.time_over_target)): if not package.flights: @@ -206,3 +202,18 @@ class AircraftGenerator: ).configure() ) return group + + def _reserve_frequencies_and_tacan(self, ato: AirTaskingOrder) -> None: + for package in ato.packages: + if package.frequency is None: + continue + if package.frequency not in self.radio_registry.allocated_channels: + self.radio_registry.reserve(package.frequency) + for f in package.flights: + if ( + f.frequency + and f.frequency not in self.radio_registry.allocated_channels + ): + self.radio_registry.reserve(f.frequency) + if f.tacan and f.tacan not in self.tacan_registy.allocated_channels: + self.tacan_registy.mark_unavailable(f.tacan) diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 0654973a..e1764d8a 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -133,15 +133,16 @@ class FlightGroupConfigurator: laser_codes.append(None) def setup_radios(self) -> RadioFrequency: - if (freq := self.flight.package.frequency) is None: + freq = self.flight.frequency + if freq is None and (freq := self.flight.package.frequency) is None: freq = self.radio_registry.alloc_uhf() self.flight.package.frequency = freq - elif freq not in self.radio_registry.allocated_channels: + if freq not in self.radio_registry.allocated_channels: self.radio_registry.reserve(freq) if self.flight.flight_type in {FlightType.AEWC, FlightType.REFUELING}: self.register_air_support(freq) - elif self.flight.client_count: + elif self.flight.frequency is None and self.flight.client_count: freq = self.flight.unit_type.alloc_flight_radio(self.radio_registry) self.group.set_frequency(freq.mhz) @@ -162,7 +163,12 @@ class FlightGroupConfigurator: ) ) elif isinstance(self.flight.flight_plan, TheaterRefuelingFlightPlan): - tacan = self.tacan_registry.alloc_for_band(TacanBand.Y, TacanUsage.AirToAir) + if self.flight.tacan is None: + tacan = self.tacan_registry.alloc_for_band( + TacanBand.Y, TacanUsage.AirToAir + ) + else: + tacan = self.flight.tacan self.mission_data.tankers.append( TankerInfo( group_name=str(self.group.name), diff --git a/game/missiongenerator/aircraft/waypoints/racetrack.py b/game/missiongenerator/aircraft/waypoints/racetrack.py index e38c3734..a0722261 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrack.py +++ b/game/missiongenerator/aircraft/waypoints/racetrack.py @@ -71,11 +71,14 @@ class RaceTrackBuilder(PydcsWaypointBuilder): if self.flight.unit_type.dcs_unit_type.tacan: tanker_info = self.mission_data.tankers[-1] tacan = tanker_info.tacan - tacan_callsign = { - "Texaco": "TEX", - "Arco": "ARC", - "Shell": "SHL", - }.get(tanker_info.callsign) + if self.flight.tcn_name is None: + tacan_callsign = { + "Texaco": "TEX", + "Arco": "ARC", + "Shell": "SHL", + }.get(tanker_info.callsign) + else: + tacan_callsign = self.flight.tcn_name waypoint.add_task( ActivateBeaconCommand( diff --git a/game/missiongenerator/missiongenerator.py b/game/missiongenerator/missiongenerator.py index 622c02f3..0b9ad36a 100644 --- a/game/missiongenerator/missiongenerator.py +++ b/game/missiongenerator/missiongenerator.py @@ -37,6 +37,7 @@ from .missiondata import MissionData from .tgogenerator import TgoGenerator from .triggergenerator import TriggerGenerator from .visualsgenerator import VisualsGenerator +from ..radio.TacanContainer import TacanContainer if TYPE_CHECKING: from game import Game @@ -177,6 +178,9 @@ class MissionGenerator: logging.warning(f"TACAN beacon has no channel: {beacon.callsign}") else: self.tacan_registry.mark_unavailable(beacon.tacan_channel) + for cp in self.game.theater.controlpoints: + if isinstance(cp, TacanContainer) and cp.tacan is not None: + self.tacan_registry.mark_unavailable(cp.tacan) def initialize_radio_registry( self, unique_map_frequencies: set[RadioFrequency] diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index 2352d12c..f47d5768 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -22,6 +22,8 @@ from dcs.ships import ( CVN_73, CVN_75, Stennis, + Forrestal, + LHA_Tarawa, ) from dcs.statics import Fortification from dcs.task import ( @@ -35,7 +37,7 @@ from dcs.task import ( ) from dcs.translation import String from dcs.triggers import Event, TriggerOnce, TriggerStart, TriggerZone -from dcs.unit import Unit, InvisibleFARP +from dcs.unit import Unit, InvisibleFARP, BaseFARP from dcs.unitgroup import MovingGroup, ShipGroup, StaticGroup, VehicleGroup from dcs.unittype import ShipType, VehicleType from dcs.vehicles import vehicle_map @@ -45,10 +47,16 @@ from game.missiongenerator.groundforcepainter import ( GroundForcePainter, ) from game.missiongenerator.missiondata import CarrierInfo, MissionData +from game.radio.RadioFrequencyContainer import RadioFrequencyContainer from game.radio.radios import RadioFrequency, RadioRegistry from game.radio.tacan import TacanBand, TacanChannel, TacanRegistry, TacanUsage from game.runways import RunwayData -from game.theater import ControlPoint, TheaterGroundObject, TheaterUnit +from game.theater import ( + ControlPoint, + TheaterGroundObject, + TheaterUnit, + NavalControlPoint, +) from game.theater.theatergroundobject import ( CarrierGroundObject, GenericCarrierGroundObject, @@ -351,7 +359,7 @@ class GenericCarrierGenerator(GroundObjectGenerator): def __init__( self, ground_object: GenericCarrierGroundObject, - control_point: ControlPoint, + control_point: NavalControlPoint, country: Country, game: Game, mission: Mission, @@ -372,9 +380,12 @@ class GenericCarrierGenerator(GroundObjectGenerator): self.mission_data = mission_data def generate(self) -> None: - - # This can also be refactored as the general generation was updated - atc = self.radio_registry.alloc_uhf() + if self.control_point.frequency is not None: + atc = self.control_point.frequency + if atc not in self.radio_registry.allocated_channels: + self.radio_registry.reserve(atc) + else: + atc = self.radio_registry.alloc_uhf() for g_id, group in enumerate(self.ground_object.groups): if not group.units: @@ -409,15 +420,33 @@ class GenericCarrierGenerator(GroundObjectGenerator): f"Error generating carrier group for {self.control_point.name}" ) ship_group.units[0].type = carrier_type.id - tacan = self.tacan_registry.alloc_for_band( - TacanBand.X, TacanUsage.TransmitReceive - ) - tacan_callsign = self.tacan_callsign() - icls = next(self.icls_alloc) + if self.control_point.tacan is None: + tacan = self.tacan_registry.alloc_for_band( + TacanBand.X, TacanUsage.TransmitReceive + ) + else: + tacan = self.control_point.tacan + if self.control_point.tcn_name is None: + tacan_callsign = self.tacan_callsign() + else: + tacan_callsign = self.control_point.tcn_name link4 = None - if carrier_type in [Stennis, CVN_71, CVN_72, CVN_73, CVN_75]: - link4 = self.radio_registry.alloc_uhf() - self.activate_beacons(ship_group, tacan, tacan_callsign, icls, link4) + link4carriers = [Stennis, CVN_71, CVN_72, CVN_73, CVN_75, Forrestal] + if carrier_type in link4carriers: + if self.control_point.link4 is None: + link4 = self.radio_registry.alloc_uhf() + else: + link4 = self.control_point.link4 + icls = None + icls_name = self.control_point.icls_name + if carrier_type in link4carriers or carrier_type == LHA_Tarawa: + if self.control_point.icls_channel is None: + icls = next(self.icls_alloc) + else: + icls = self.control_point.icls_channel + self.activate_beacons( + ship_group, tacan, tacan_callsign, icls, icls_name, link4 + ) self.add_runway_data( brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls ) @@ -461,7 +490,8 @@ class GenericCarrierGenerator(GroundObjectGenerator): group: ShipGroup, tacan: TacanChannel, callsign: str, - icls: int, + icls: Optional[int] = None, + icls_name: Optional[str] = None, link4: Optional[RadioFrequency] = None, ) -> None: group.points[0].tasks.append( @@ -473,12 +503,14 @@ class GenericCarrierGenerator(GroundObjectGenerator): aa=False, ) ) - group.points[0].tasks.append( - ActivateICLSCommand(icls, unit_id=group.units[0].id) - ) + if icls is not None: + icls_name = "" if icls_name is None else icls_name + group.points[0].tasks.append( + ActivateICLSCommand(icls, group.units[0].id, icls_name) + ) if link4 is not None: group.points[0].tasks.append( - ActivateLink4Command(int(link4.mhz), group.units[0].id) + ActivateLink4Command(link4.hertz, group.units[0].id) ) group.points[0].tasks.append(ActivateACLSCommand(unit_id=group.units[0].id)) @@ -488,7 +520,7 @@ class GenericCarrierGenerator(GroundObjectGenerator): atc: RadioFrequency, tacan: TacanChannel, callsign: str, - icls: int, + icls: Optional[int], ) -> None: # TODO: Make unit name usable. # This relies on one control point mapping exactly @@ -590,6 +622,13 @@ class HelipadGenerator: self.helipads.add_unit( InvisibleFARP(self.m.terrain, self.m.next_unit_id(), name_i) ) + + # Set FREQ + if isinstance(self.cp, RadioFrequencyContainer) and self.cp.frequency: + for hp in self.helipads.units: + if isinstance(hp, BaseFARP): + hp.heliport_frequency = self.cp.frequency.mhz + pad = self.helipads.units[-1] pad.position = helipad pad.heading = heading @@ -661,7 +700,9 @@ class TgoGenerator: for ground_object in cp.ground_objects: generator: GroundObjectGenerator - if isinstance(ground_object, CarrierGroundObject): + if isinstance(ground_object, CarrierGroundObject) and isinstance( + cp, NavalControlPoint + ): generator = CarrierGenerator( ground_object, cp, @@ -675,7 +716,9 @@ class TgoGenerator: self.unit_map, self.mission_data, ) - elif isinstance(ground_object, LhaGroundObject): + elif isinstance(ground_object, LhaGroundObject) and isinstance( + cp, NavalControlPoint + ): generator = LhaGenerator( ground_object, cp, diff --git a/game/radio/ICLSContainer.py b/game/radio/ICLSContainer.py new file mode 100644 index 00000000..34c403ac --- /dev/null +++ b/game/radio/ICLSContainer.py @@ -0,0 +1,6 @@ +from typing import Optional + + +class ICLSContainer: + icls_channel: Optional[int] = None + icls_name: Optional[str] = None diff --git a/game/radio/Link4Container.py b/game/radio/Link4Container.py new file mode 100644 index 00000000..e872cc22 --- /dev/null +++ b/game/radio/Link4Container.py @@ -0,0 +1,8 @@ +from typing import Optional + +from game.radio.RadioFrequencyContainer import RadioFrequencyContainer +from game.radio.radios import RadioFrequency + + +class Link4Container(RadioFrequencyContainer): + link4: Optional[RadioFrequency] = None diff --git a/game/radio/RadioFrequencyContainer.py b/game/radio/RadioFrequencyContainer.py new file mode 100644 index 00000000..9882cf70 --- /dev/null +++ b/game/radio/RadioFrequencyContainer.py @@ -0,0 +1,7 @@ +from typing import Optional + +from game.radio.radios import RadioFrequency + + +class RadioFrequencyContainer: + frequency: Optional[RadioFrequency] = None diff --git a/game/radio/TacanContainer.py b/game/radio/TacanContainer.py new file mode 100644 index 00000000..a851cfe2 --- /dev/null +++ b/game/radio/TacanContainer.py @@ -0,0 +1,8 @@ +from typing import Optional + +from game.radio.tacan import TacanChannel + + +class TacanContainer: + tacan: Optional[TacanChannel] = None + tcn_name: Optional[str] = None diff --git a/game/radio/radios.py b/game/radio/radios.py index 2d61a076..47860d50 100644 --- a/game/radio/radios.py +++ b/game/radio/radios.py @@ -3,6 +3,7 @@ from __future__ import annotations import itertools import logging +import random import re from dataclasses import dataclass from typing import Dict, FrozenSet, Iterator, List, Set, Tuple @@ -156,57 +157,61 @@ class ChannelInUseError(RuntimeError): # TODO: Figure out appropriate steps for each radio. These are just guesses. #: List of all known radios used by aircraft in the game. RADIOS: List[Radio] = [ - Radio("AN/ARC-164", (RadioRange(MHz(225), MHz(400), MHz(1), Modulation.AM),)), - Radio("AN/ARC-186(V) AM", (RadioRange(MHz(116), MHz(152), MHz(1), Modulation.AM),)), - Radio("AN/ARC-186(V) FM", (RadioRange(MHz(30), MHz(76), MHz(1), Modulation.FM),)), + Radio("AN/ARC-164", (RadioRange(MHz(225), MHz(400), kHz(25), Modulation.AM),)), + Radio( + "AN/ARC-186(V) AM", (RadioRange(MHz(116), MHz(152), kHz(25), Modulation.AM),) + ), + Radio("AN/ARC-186(V) FM", (RadioRange(MHz(30), MHz(76), kHz(25), Modulation.FM),)), Radio( "AN/ARC-210", ( RadioRange( MHz(225), MHz(400), - MHz(1), + kHz(25), Modulation.AM, frozenset((MHz(243),)), ), - RadioRange(MHz(136), MHz(155), MHz(1), Modulation.AM), - RadioRange(MHz(156), MHz(174), MHz(1), Modulation.FM), - RadioRange(MHz(118), MHz(136), MHz(1), Modulation.AM), - RadioRange(MHz(30), MHz(88), MHz(1), Modulation.FM), + RadioRange(MHz(136), MHz(155), kHz(25), Modulation.AM), + RadioRange(MHz(156), MHz(174), kHz(25), Modulation.FM), + RadioRange(MHz(118), MHz(136), kHz(25), Modulation.AM), + RadioRange(MHz(30), MHz(88), kHz(25), Modulation.FM), # The AN/ARC-210 can also use 225-400 and 136-155 with FM Modulation RadioRange( MHz(225), MHz(400), - MHz(1), + kHz(25), Modulation.FM, frozenset((MHz(243),)), ), - RadioRange(MHz(136), MHz(155), MHz(1), Modulation.FM), + RadioRange(MHz(136), MHz(155), kHz(25), Modulation.FM), ), ), - Radio("AN/ARC-222", (RadioRange(MHz(116), MHz(152), MHz(1), Modulation.AM),)), - Radio("SCR-522", (RadioRange(MHz(100), MHz(156), MHz(1), Modulation.AM),)), - Radio("A.R.I. 1063", (RadioRange(MHz(100), MHz(156), MHz(1), Modulation.AM),)), + Radio("AN/ARC-222", (RadioRange(MHz(116), MHz(152), kHz(25), Modulation.AM),)), + Radio("SCR-522", (RadioRange(MHz(100), MHz(156), kHz(25), Modulation.AM),)), + Radio("A.R.I. 1063", (RadioRange(MHz(100), MHz(156), kHz(25), Modulation.AM),)), Radio("BC-1206", (RadioRange(kHz(200), kHz(400), kHz(10), Modulation.AM),)), Radio( "TRT ERA 7000 V/UHF", ( - RadioRange(MHz(118), MHz(150), MHz(1), Modulation.AM), - RadioRange(MHz(225), MHz(400), MHz(1), Modulation.AM), + RadioRange(MHz(118), MHz(150), kHz(25), Modulation.AM), + RadioRange(MHz(225), MHz(400), kHz(25), Modulation.AM), ), ), - Radio("TRT ERA 7200 UHF", (RadioRange(MHz(225), MHz(400), MHz(1), Modulation.AM),)), + Radio( + "TRT ERA 7200 UHF", (RadioRange(MHz(225), MHz(400), kHz(25), Modulation.AM),) + ), # Tomcat radios # # https://www.heatblur.se/F-14Manual/general.html#an-arc-159-uhf-1-radio - Radio("AN/ARC-159", (RadioRange(MHz(225), MHz(400), MHz(1), Modulation.AM),)), + Radio("AN/ARC-159", (RadioRange(MHz(225), MHz(400), kHz(25), Modulation.AM),)), # https://www.heatblur.se/F-14Manual/general.html#an-arc-182-v-uhf-2-radio Radio( "AN/ARC-182", ( - RadioRange(MHz(225), MHz(400), MHz(1), Modulation.AM), - RadioRange(MHz(108), MHz(174), MHz(1), Modulation.AM), + RadioRange(MHz(225), MHz(400), kHz(25), Modulation.AM), + RadioRange(MHz(108), MHz(174), kHz(25), Modulation.AM), # The Range from 30-88MHz should be FM but its modeled as AM in dcs - RadioRange(MHz(30), MHz(88), MHz(1), Modulation.AM), + RadioRange(MHz(30), MHz(88), kHz(25), Modulation.AM), ), ), Radio( @@ -220,14 +225,14 @@ RADIOS: List[Radio] = [ # 4 preset channels (A/B/C/D) Radio("SCR522", (RadioRange(MHz(100), MHz(156), kHz(25), Modulation.AM),)), # JF-17 Radios should use AM - Radio("R&S M3AR VHF", (RadioRange(MHz(120), MHz(174), MHz(1), Modulation.AM),)), - Radio("R&S M3AR UHF", (RadioRange(MHz(225), MHz(400), MHz(1), Modulation.AM),)), + Radio("R&S M3AR VHF", (RadioRange(MHz(120), MHz(174), kHz(25), Modulation.AM),)), + Radio("R&S M3AR UHF", (RadioRange(MHz(225), MHz(400), kHz(25), Modulation.AM),)), # MiG-15bis Radio("RSI-6K HF", (RadioRange(MHz(3, 750), MHz(5), kHz(25), Modulation.AM),)), # MiG-19P - Radio("RSIU-4V", (RadioRange(MHz(100), MHz(150), MHz(1), Modulation.AM),)), + Radio("RSIU-4V", (RadioRange(MHz(100), MHz(150), kHz(25), Modulation.AM),)), # MiG-21bis - Radio("RSIU-5V", (RadioRange(MHz(118), MHz(140), MHz(1), Modulation.AM),)), + Radio("RSIU-5V", (RadioRange(MHz(118), MHz(140), kHz(25), Modulation.AM),)), # Ka-50 # Note: Also capable of 100MHz-150MHz, but we can't model gaps. Radio("R-800L1", (RadioRange(MHz(220), MHz(400), kHz(25), Modulation.AM),)), @@ -263,7 +268,7 @@ RADIOS: List[Radio] = [ RadioRange( MHz(225), MHz(400), - MHz(1), + kHz(25), Modulation.AM, frozenset((MHz(243),)), ), @@ -275,28 +280,28 @@ RADIOS: List[Radio] = [ RadioRange( MHz(30), MHz(88), - MHz(1), + kHz(25), Modulation.FM, frozenset((MHz(40, 500),)), ), RadioRange( MHz(108), MHz(156), - MHz(1), + kHz(25), Modulation.AM, frozenset((MHz(121, 500),)), ), RadioRange( MHz(156), MHz(174), - MHz(1), + kHz(25), Modulation.FM, frozenset((MHz(156, 800),)), ), RadioRange( MHz(225), MHz(400), - MHz(1), + kHz(25), Modulation.AM, # Actually AM/FM, but we can't represent that. frozenset((MHz(243),)), ), @@ -323,6 +328,13 @@ def get_radio(name: str) -> Radio: raise KeyError(f"Unknown radio: {name}") +def random_frequency(radio: Radio) -> RadioFrequency: + range: RadioRange = radio.ranges[0] + delta = round(random.random() * (range.maximum.hertz - range.minimum.hertz)) + delta -= delta % range.step.hertz + return RadioFrequency(range.minimum.hertz + delta) + + class RadioRegistry: """Manages allocation of radio channels. @@ -368,9 +380,8 @@ class RadioRegistry: OutOfChannelsError: All channels compatible with the given radio are already allocated. """ - allocator = self.radio_allocators[radio] try: - while (channel := next(allocator)) in self.allocated_channels: + while (channel := random_frequency(radio)) in self.allocated_channels: pass self.reserve(channel) return channel diff --git a/game/radio/tacan.py b/game/radio/tacan.py index fb7bd42f..c9200391 100644 --- a/game/radio/tacan.py +++ b/game/radio/tacan.py @@ -54,8 +54,8 @@ class TacanChannel: if match is None: raise ValueError(f"Could not parse TACAN from {text}") number = int(match.group(1)) - if not number: - raise ValueError("TACAN channel cannot be 0") + if not (0 < number <= 126): + raise ValueError("TACAN channel cannot be 0 or larger than 126") return TacanChannel(number, TacanBand(match.group(2))) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index a80dadef..976cbde5 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -22,6 +22,7 @@ from typing import ( TYPE_CHECKING, Tuple, Type, + Union, ) from uuid import UUID @@ -73,6 +74,10 @@ from ..data.units import UnitClass from ..db import Database from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType +from ..radio.ICLSContainer import ICLSContainer +from ..radio.Link4Container import Link4Container +from ..radio.RadioFrequencyContainer import RadioFrequencyContainer +from ..radio.TacanContainer import TacanContainer from ..utils import nautical_miles from ..weather import Conditions @@ -305,7 +310,7 @@ class ControlPointStatus(IntEnum): Destroyed = auto() -StartingPosition = ShipGroup | StaticGroup | Airport | Point +StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] class ControlPoint(MissionTarget, SidcDescribable, ABC): @@ -1161,7 +1166,9 @@ class Airfield(ControlPoint): return ControlPointStatus.Functional -class NavalControlPoint(ControlPoint, ABC): +class NavalControlPoint( + ControlPoint, ABC, Link4Container, TacanContainer, ICLSContainer +): @property def is_fleet(self) -> bool: return True @@ -1392,7 +1399,7 @@ class OffMapSpawn(ControlPoint): return ControlPointStatus.Functional -class Fob(ControlPoint): +class Fob(ControlPoint, RadioFrequencyContainer): def __init__( self, name: str, at: Point, theater: ConflictTheater, starts_blue: bool ): diff --git a/qt_ui/models.py b/qt_ui/models.py index 51c2c87b..302060ff 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -17,9 +17,13 @@ from game.ato.flight import Flight from game.ato.flighttype import FlightType from game.ato.package import Package from game.game import Game +from game.radio.RadioFrequencyContainer import RadioFrequencyContainer +from game.radio.radios import RadioFrequency +from game.radio.tacan import TacanChannel from game.server import EventStream from game.sim.gameupdateevents import GameUpdateEvents from game.squadrons.squadron import Pilot, Squadron +from game.theater import NavalControlPoint from game.theater.missiontarget import MissionTarget from game.transfers import PendingTransfers, TransferOrder from qt_ui.simcontroller import SimController @@ -179,6 +183,8 @@ class PackageModel(QAbstractListModel): self.package.remove_flight(flight) self.endRemoveRows() self.update_tot() + self.game_model.release_freq(flight.frequency) + self.game_model.release_tacan(flight.tacan) def flight_at_index(self, index: QModelIndex) -> Flight: """Returns the flight located at the given index.""" @@ -294,6 +300,7 @@ class AtoModel(QAbstractListModel): # noinspection PyUnresolvedReferences self.client_slots_changed.emit() self.on_packages_changed() + self.release_comms_for_package(package) def on_packages_changed(self) -> None: if self.game is not None: @@ -342,6 +349,12 @@ class AtoModel(QAbstractListModel): def on_sim_update(self, _events: GameUpdateEvents) -> None: self.dataChanged.emit(self.index(0), self.index(self.rowCount())) + def release_comms_for_package(self, package: Package): + self.game_model.release_freq(package.frequency) + for f in package.flights: + self.game_model.release_freq(f.frequency) + self.game_model.release_tacan(f.tacan) + class TransferModel(QAbstractListModel): """The model for a ground unit transfer.""" @@ -532,6 +545,12 @@ class GameModel: self.ato_model = AtoModel(self, self.game.blue.ato) self.red_ato_model = AtoModel(self, self.game.red.ato) + # For UI purposes + self.allocated_freqs: list[RadioFrequency] = list() + self.allocated_tacan: list[TacanChannel] = list() + self.allocated_icls: list[int] = list() + self.init_comms_registry() + def ato_model_for(self, player: bool) -> AtoModel: if player: return self.ato_model @@ -553,3 +572,43 @@ class GameModel: if self.game is None: raise RuntimeError("GameModel has no Game set") return self.game + + def init_comms_registry(self) -> None: + if self.game is None: + return + allocated_freqs = {None} + allocated_tacan = {None} + allocated_icls = {None} + for p in self.ato_model.ato.packages: + allocated_freqs.add(p.frequency) + for f in p.flights: + allocated_freqs.add(f.frequency) + allocated_tacan.add(f.tacan) + for cp in self.game.theater.control_points_for(True): + if isinstance(cp, NavalControlPoint): + allocated_freqs.add(cp.frequency) + allocated_freqs.add(cp.link4) + allocated_tacan.add(cp.tacan) + allocated_icls.add(cp.icls_channel) + elif isinstance(cp, RadioFrequencyContainer): + allocated_freqs.add(cp.frequency) + allocated_freqs.remove(None) + allocated_tacan.remove(None) + allocated_icls.remove(None) + self.allocated_freqs = list(allocated_freqs) + self.allocated_tacan = list(allocated_tacan) + self.allocated_icls = list(allocated_icls) + + def release_freq(self, freq: RadioFrequency): + if freq: + try: + self.allocated_freqs.remove(freq) + except ValueError: + pass + + def release_tacan(self, tacan: TacanChannel): + if tacan: + try: + self.allocated_tacan.remove(tacan) + except ValueError: + pass diff --git a/qt_ui/widgets/QFrequencyWidget.py b/qt_ui/widgets/QFrequencyWidget.py new file mode 100644 index 00000000..30abddda --- /dev/null +++ b/qt_ui/widgets/QFrequencyWidget.py @@ -0,0 +1,101 @@ +from PySide2.QtCore import Signal +from PySide2.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QWidget, +) + +from game.ato import Flight +from game.radio.RadioFrequencyContainer import RadioFrequencyContainer +from game.radio.radios import RadioFrequency, RadioRange, MHz, kHz +from qt_ui.models import GameModel +from qt_ui.windows.QRadioFrequencyDialog import QRadioFrequencyDialog + + +class QFrequencyWidget(QWidget): + + freq_changed = Signal(QWidget) + + def __init__( + self, container: RadioFrequencyContainer, game_model: GameModel + ) -> None: + super().__init__() + + self.ct = container + self.gm = game_model + + columns = QHBoxLayout() + self.setLayout(columns) + + self.freq = QLabel(self._get_label_text()) + self.check_freq() + columns.addWidget(self.freq) + columns.addStretch() + + self.set_freq_btn = QPushButton("Set FREQ") + self.set_freq_btn.setProperty("class", "comms") + self.set_freq_btn.setFixedWidth(100) + columns.addWidget(self.set_freq_btn) + self.set_freq_btn.clicked.connect(self.open_freq_dialog) + + self.reset_freq_btn = QPushButton("Reset FREQ") + self.reset_freq_btn.setProperty("class", "btn-danger comms") + self.reset_freq_btn.setFixedWidth(100) + columns.addWidget(self.reset_freq_btn) + self.reset_freq_btn.clicked.connect(self.reset_freq) + + def _get_label_text(self) -> str: + freq = "AUTO" if self.ct.frequency is None else self.ct.frequency + return f"FREQ: {freq}" + + def open_freq_dialog(self) -> None: + range = RadioRange(MHz(30), MHz(400), kHz(25)) + if isinstance(self.ct, Flight): + if self.ct.unit_type.intra_flight_radio is not None: + range = self.ct.unit_type.intra_flight_radio.ranges[0] + self.frequency_dialog = QRadioFrequencyDialog(self, self.ct, range) + self.frequency_dialog.accepted.connect(self.assign_frequency) + self.frequency_dialog.show() + + def assign_frequency(self) -> None: + hz = round(self.frequency_dialog.frequency_input.value() * 10**6) + self._try_remove() + self.ct.frequency = RadioFrequency(hertz=hz) + self.gm.allocated_freqs.append(self.ct.frequency) + self.freq.setText(self._get_label_text()) + self.check_freq() + self.freq_changed.emit(self) + + def reset_freq(self) -> None: + self._try_remove() + self.ct.frequency = None + self.freq.setText(self._get_label_text()) + self._reset_color_and_tooltip() + self.freq_changed.emit(self) + + def check_freq(self) -> None: + if self.ct.frequency is None: + return + if self.gm.allocated_freqs.count(self.ct.frequency) > 1: + self.freq.setStyleSheet("color: orange") + self.freq.setToolTip( + "Double booked frequency, verify that this was your intention." + ) + elif self.gm.allocated_freqs.count(self.ct.frequency) == 1: + self._reset_color_and_tooltip() + if self.ct.frequency == MHz(243) or self.ct.frequency == MHz(121, 500): + self.freq.setStyleSheet("color: red") + self.freq.setToolTip( + "GUARD Freq. was assigned, verify that this was your intention." + ) + + def _reset_color_and_tooltip(self): + self.freq.setStyleSheet("color: white") + self.freq.setToolTip(None) + + def _try_remove(self) -> None: + try: + self.gm.allocated_freqs.remove(self.ct.frequency) + except ValueError: + pass diff --git a/qt_ui/widgets/QICLSWidget.py b/qt_ui/widgets/QICLSWidget.py new file mode 100644 index 00000000..ffcb08ea --- /dev/null +++ b/qt_ui/widgets/QICLSWidget.py @@ -0,0 +1,86 @@ +from PySide2.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QWidget, +) + +from game.radio.ICLSContainer import ICLSContainer +from qt_ui.models import GameModel +from qt_ui.windows.QICLSDialog import QICLSDialog + + +class QICLSWidget(QWidget): + def __init__(self, container: ICLSContainer, game_model: GameModel) -> None: + super().__init__() + + self.ct = container + self.gm = game_model + + columns = QHBoxLayout() + self.setLayout(columns) + + self.channel = QLabel(self._get_label_text()) + self.check_channel() + columns.addWidget(self.channel) + columns.addStretch() + + self.set_icls_btn = QPushButton("Set ICLS") + self.set_icls_btn.setProperty("class", "comms") + self.set_icls_btn.setFixedWidth(100) + columns.addWidget(self.set_icls_btn) + self.set_icls_btn.clicked.connect(self.open_icls_dialog) + + self.reset_icls_btn = QPushButton("Reset ICLS") + self.reset_icls_btn.setProperty("class", "btn-danger comms") + self.reset_icls_btn.setFixedWidth(100) + columns.addWidget(self.reset_icls_btn) + self.reset_icls_btn.clicked.connect(self.reset_icls) + + def _get_label_text(self) -> str: + c = "AUTO" if self.ct.icls_channel is None else self.ct.icls_channel + cs = self.ct.icls_name + cs = "" if cs is None else f" ({cs})" + return f"ICLS: {c}{cs}" + + def open_icls_dialog(self) -> None: + self.icls_dialog = QICLSDialog(self, self.ct) + self.icls_dialog.accepted.connect(self.assign_icls) + self.icls_dialog.show() + + def assign_icls(self) -> None: + self._try_remove() + self.ct.icls_channel = self.icls_dialog.icls_input.value() + self.gm.allocated_icls.append(self.ct.icls_channel) + if cs := self.icls_dialog.callsign_input.text(): + self.ct.icls_name = cs.upper() + self.channel.setText(self._get_label_text()) + self.check_channel() + + def reset_icls(self) -> None: + self._try_remove() + self.ct.icls_channel = None + self.ct.icls_name = None + self.channel.setText(self._get_label_text()) + self._reset_color_and_tooltip() + + def check_channel(self) -> None: + if self.ct.icls_channel is None: + return + if self.gm.allocated_icls.count(self.ct.icls_channel) > 1: + self.channel.setStyleSheet("color: orange") + self.channel.setToolTip( + "Double booked ICLS channel, verify that this was your intention." + ) + elif self.gm.allocated_icls.count(self.ct.icls_channel) == 1: + self._reset_color_and_tooltip() + + def _reset_color_and_tooltip(self): + self.channel.setStyleSheet("color: white") + self.channel.setToolTip(None) + + def _try_remove(self) -> None: + try: + self.gm.allocated_icls.remove(self.ct.icls_channel) + except ValueError: + pass diff --git a/qt_ui/widgets/QLink4Widget.py b/qt_ui/widgets/QLink4Widget.py new file mode 100644 index 00000000..1126e346 --- /dev/null +++ b/qt_ui/widgets/QLink4Widget.py @@ -0,0 +1,95 @@ +from PySide2.QtCore import Signal +from PySide2.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QWidget, +) + +from game.radio.radios import RadioFrequency, RadioRange, MHz, kHz +from game.theater import NavalControlPoint +from qt_ui.models import GameModel +from qt_ui.windows.QRadioFrequencyDialog import QRadioFrequencyDialog + + +class QLink4Widget(QWidget): + + freq_changed = Signal(QWidget) + + def __init__(self, cp: NavalControlPoint, game_model: GameModel) -> None: + super().__init__() + + self.cp = cp + self.gm = game_model + + columns = QHBoxLayout() + self.setLayout(columns) + + self.freq = QLabel(self._get_label_text()) + self.check_freq() + columns.addWidget(self.freq) + columns.addStretch() + + self.set_freq_btn = QPushButton("Set LINK4") + self.set_freq_btn.setProperty("class", "comms") + self.set_freq_btn.setFixedWidth(100) + columns.addWidget(self.set_freq_btn) + self.set_freq_btn.clicked.connect(self.open_freq_dialog) + + self.reset_freq_btn = QPushButton("Reset LINK4") + self.reset_freq_btn.setProperty("class", "btn-danger comms") + self.reset_freq_btn.setFixedWidth(100) + columns.addWidget(self.reset_freq_btn) + self.reset_freq_btn.clicked.connect(self.reset_freq) + + def _get_label_text(self) -> str: + freq = "AUTO" if self.cp.link4 is None else self.cp.link4 + return f"LINK4: {freq}" + + def open_freq_dialog(self) -> None: + range = RadioRange(MHz(225), MHz(400), kHz(25)) + self.frequency_dialog = QRadioFrequencyDialog(self, self.cp, range, link4=True) + self.frequency_dialog.accepted.connect(self.assign_frequency) + self.frequency_dialog.show() + + def assign_frequency(self) -> None: + hz = round(self.frequency_dialog.frequency_input.value() * 10**6) + self._try_remove() + self.cp.link4 = RadioFrequency(hertz=hz) + self.gm.allocated_freqs.append(self.cp.link4) + self.freq.setText(self._get_label_text()) + self.check_freq() + self.freq_changed.emit(self) + + def reset_freq(self): + self._try_remove() + self.cp.link4 = None + self.freq.setText(self._get_label_text()) + self._reset_color_and_tooltip() + self.freq_changed.emit(self) + + def check_freq(self): + if self.cp.link4 is None: + return + if self.gm.allocated_freqs.count(self.cp.link4) > 1: + self.freq.setStyleSheet("color: orange") + self.freq.setToolTip( + "Double booked frequency, verify that this was your intention." + ) + elif self.gm.allocated_freqs.count(self.cp.link4) == 1: + self._reset_color_and_tooltip() + if self.cp.link4 == MHz(243) or self.cp.link4 == MHz(121, 500): + self.freq.setStyleSheet("color: red") + self.freq.setToolTip( + "GUARD Freq. was assigned, verify that this was your intention." + ) + + def _reset_color_and_tooltip(self): + self.freq.setStyleSheet("color: white") + self.freq.setToolTip(None) + + def _try_remove(self): + try: + self.gm.allocated_freqs.remove(self.cp.link4) + except ValueError: + pass diff --git a/qt_ui/widgets/QTacanWidget.py b/qt_ui/widgets/QTacanWidget.py new file mode 100644 index 00000000..bb227f44 --- /dev/null +++ b/qt_ui/widgets/QTacanWidget.py @@ -0,0 +1,90 @@ +from PySide2.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QWidget, +) + +from game.radio.TacanContainer import TacanContainer +from game.radio.tacan import TacanChannel, TacanBand +from qt_ui.models import GameModel +from qt_ui.windows.QTacanDialog import QTacanDialog + + +class QTacanWidget(QWidget): + def __init__(self, container: TacanContainer, game_model: GameModel) -> None: + super().__init__() + + self.ct = container + self.gm = game_model + + columns = QHBoxLayout() + self.setLayout(columns) + + self.channel = QLabel(self._get_label_text()) + self.check_channel() + columns.addWidget(self.channel) + columns.addStretch() + + self.set_tacan_btn = QPushButton("Set TACAN") + self.set_tacan_btn.setProperty("class", "comms") + self.set_tacan_btn.setFixedWidth(100) + columns.addWidget(self.set_tacan_btn) + self.set_tacan_btn.clicked.connect(self.open_tacan_dialog) + + self.reset_tacan_btn = QPushButton("Reset TACAN") + self.reset_tacan_btn.setProperty("class", "btn-danger comms") + self.reset_tacan_btn.setFixedWidth(100) + columns.addWidget(self.reset_tacan_btn) + self.reset_tacan_btn.clicked.connect(self.reset_tacan) + + def _get_label_text(self) -> str: + c = "AUTO" if self.ct.tacan is None else self.ct.tacan + cs = self.ct.tcn_name + cs = "" if cs is None else f" ({cs})" + return f"TACAN: {c}{cs}" + + def open_tacan_dialog(self) -> None: + self.tacan_dialog = QTacanDialog(self, self.ct) + self.tacan_dialog.accepted.connect(self.assign_tacan) + self.tacan_dialog.show() + + def assign_tacan(self) -> None: + channel = self.tacan_dialog.tacan_input.value() + band = self.tacan_dialog.band_input.currentText() + band = TacanBand.X if band == "X" else TacanBand.Y + self._try_remove() + self.ct.tacan = TacanChannel(number=channel, band=band) + self.gm.allocated_tacan.append(self.ct.tacan) + if cs := self.tacan_dialog.callsign_input.text(): + self.ct.tcn_name = cs.upper() + self.channel.setText(self._get_label_text()) + self.check_channel() + + def reset_tacan(self) -> None: + self._try_remove() + self.ct.tacan = None + self.ct.tcn_name = None + self.channel.setText(self._get_label_text()) + self._reset_color_and_tooltip() + + def check_channel(self) -> None: + if self.ct.tacan is None: + return + if self.gm.allocated_tacan.count(self.ct.tacan) > 1: + self.channel.setStyleSheet("color: orange") + self.channel.setToolTip( + "Double booked TACAN channel, verify that this was your intention." + ) + elif self.gm.allocated_tacan.count(self.ct.tacan) == 1: + self._reset_color_and_tooltip() + + def _reset_color_and_tooltip(self): + self.channel.setStyleSheet("color: white") + self.channel.setToolTip(None) + + def _try_remove(self) -> None: + try: + self.gm.allocated_tacan.remove(self.ct.tacan) + except ValueError: + pass diff --git a/qt_ui/windows/QICLSDialog.py b/qt_ui/windows/QICLSDialog.py new file mode 100644 index 00000000..d69d0fe1 --- /dev/null +++ b/qt_ui/windows/QICLSDialog.py @@ -0,0 +1,54 @@ +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QDialog, + QPushButton, + QLabel, + QHBoxLayout, + QSpinBox, + QLineEdit, +) + +from game.radio.ICLSContainer import ICLSContainer +from qt_ui.uiconstants import EVENT_ICONS + + +class QICLSDialog(QDialog): + def __init__(self, parent=None, container: Optional[ICLSContainer] = None) -> None: + super().__init__(parent=parent) + self.container = container + self.setMinimumWidth(400) + + # Make dialog modal to prevent background windows to close unexpectedly. + self.setModal(True) + + self.setWindowTitle("Assign ICLS") + self.setWindowIcon(EVENT_ICONS["strike"]) + + layout = QHBoxLayout() + + self.icls_label = QLabel("ICLS:") + self.icls_input = QSpinBox() + self.icls_input.setRange(1, 20) + self.icls_input.setSingleStep(1) + layout.addWidget(self.icls_label) + layout.addStretch() + layout.addWidget(self.icls_input) + self.callsign_input = QLineEdit() + self.callsign_input.setMaxLength(3) + self.callsign_input.setMaximumWidth(50) + layout.addWidget(self.callsign_input) + layout.addStretch() + + self.create_button = QPushButton("Save") + self.create_button.clicked.connect(self.accept) + layout.addWidget(self.create_button, alignment=Qt.AlignRight) + + self.setLayout(layout) + + if container is not None: + if container.icls_name is not None: + self.callsign_input.setText(container.icls_name) + if container.icls_channel is not None: + self.icls_input.setValue(container.icls_channel) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index a69bd5a2..4b52f60b 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -404,6 +404,7 @@ class QLiberationWindow(QMainWindow): self.info_panel.setGame(game) self.sim_controller.set_game(game) self.game_model.set(self.game) + self.game_model.init_comms_registry() except AttributeError: logging.exception("Incompatible save game") QMessageBox.critical( @@ -530,6 +531,7 @@ class QLiberationWindow(QMainWindow): logging.info("On Debriefing") self.debriefing = QDebriefingWindow(debrief) self.debriefing.show() + self.game_model.init_comms_registry() def open_tgo_info_dialog(self, tgo: TheaterGroundObject) -> None: QGroundObjectMenu(self, tgo, tgo.control_point, self.game).show() diff --git a/qt_ui/windows/QRadioFrequencyDialog.py b/qt_ui/windows/QRadioFrequencyDialog.py new file mode 100644 index 00000000..edcf3639 --- /dev/null +++ b/qt_ui/windows/QRadioFrequencyDialog.py @@ -0,0 +1,60 @@ +from typing import Optional + +from PySide2.QtCore import Qt, QLocale +from PySide2.QtWidgets import ( + QDialog, + QPushButton, + QLabel, + QHBoxLayout, + QDoubleSpinBox, +) + +from game.radio.Link4Container import Link4Container +from game.radio.RadioFrequencyContainer import RadioFrequencyContainer +from game.radio.radios import RadioRange, kHz, MHz +from qt_ui.uiconstants import EVENT_ICONS + + +class QRadioFrequencyDialog(QDialog): + def __init__( + self, + parent=None, + container: Optional[RadioFrequencyContainer] = None, + range: RadioRange = RadioRange(MHz(225), MHz(400), kHz(25)), + link4: bool = False, + ) -> None: + super().__init__(parent=parent) + self.container = container + self.setMinimumWidth(400) + + # Make dialog modal to prevent background windows to close unexpectedly. + self.setModal(True) + + self.setWindowTitle("Assign frequency") + self.setWindowIcon(EVENT_ICONS["strike"]) + + layout = QHBoxLayout() + + self.frequency_label = QLabel("FREQ (MHz):") + self.frequency_input = QDoubleSpinBox() + self.frequency_input.setRange( + range.minimum.mhz, range.maximum.mhz - range.step.mhz + ) + self.frequency_input.setSingleStep(range.step.mhz) + self.frequency_input.setDecimals(3) + self.frequency_input.setLocale(QLocale(QLocale.Language.English)) + self.frequency_input.setValue(225.0) + layout.addWidget(self.frequency_label) + layout.addWidget(self.frequency_input) + + self.create_button = QPushButton("Save") + self.create_button.clicked.connect(self.accept) + layout.addWidget(self.create_button, alignment=Qt.AlignRight) + + self.setLayout(layout) + + if link4 and isinstance(container, Link4Container): + if container.link4: + self.frequency_input.setValue(container.link4.mhz) + elif container.frequency: + self.frequency_input.setValue(container.frequency.mhz) diff --git a/qt_ui/windows/QTacanDialog.py b/qt_ui/windows/QTacanDialog.py new file mode 100644 index 00000000..b5042a0e --- /dev/null +++ b/qt_ui/windows/QTacanDialog.py @@ -0,0 +1,59 @@ +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QDialog, + QPushButton, + QLabel, + QHBoxLayout, + QSpinBox, + QComboBox, + QLineEdit, +) + +from game.radio.TacanContainer import TacanContainer +from qt_ui.uiconstants import EVENT_ICONS + + +class QTacanDialog(QDialog): + def __init__(self, parent=None, container: Optional[TacanContainer] = None) -> None: + super().__init__(parent=parent) + self.container = container + self.setMinimumWidth(400) + + # Make dialog modal to prevent background windows to close unexpectedly. + self.setModal(True) + + self.setWindowTitle("Assign TACAN") + self.setWindowIcon(EVENT_ICONS["strike"]) + + layout = QHBoxLayout() + + self.tacan_label = QLabel("TACAN:") + self.tacan_input = QSpinBox() + self.tacan_input.setRange(1, 126) + self.tacan_input.setSingleStep(1) + layout.addWidget(self.tacan_label) + layout.addStretch() + layout.addWidget(self.tacan_input) + self.band_input = QComboBox() + self.band_input.addItems(["X", "Y"]) + layout.addWidget(self.band_input) + self.callsign_input = QLineEdit() + self.callsign_input.setMaxLength(3) + self.callsign_input.setMaximumWidth(50) + layout.addWidget(self.callsign_input) + layout.addStretch() + + self.create_button = QPushButton("Save") + self.create_button.clicked.connect(self.accept) + layout.addWidget(self.create_button, alignment=Qt.AlignRight) + + self.setLayout(layout) + + if container is not None: + if container.tcn_name is not None: + self.callsign_input.setText(container.tcn_name) + if container.tacan is not None: + self.tacan_input.setValue(container.tacan.number) + self.band_input.setCurrentText(container.tacan.band.value) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 6932c6b8..73af5c9b 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -8,11 +8,15 @@ from PySide2.QtWidgets import ( QPushButton, QVBoxLayout, QWidget, + QGridLayout, ) from game import Game from game.ato.flighttype import FlightType from game.config import RUNWAY_REPAIR_COST +from game.radio.ICLSContainer import ICLSContainer +from game.radio.RadioFrequencyContainer import RadioFrequencyContainer +from game.radio.TacanContainer import TacanContainer from game.server import EventStream from game.sim import GameUpdateEvents from game.theater import ( @@ -20,10 +24,15 @@ from game.theater import ( ControlPoint, ControlPointType, FREE_FRONTLINE_UNIT_SUPPLY, + NavalControlPoint, ) from qt_ui.dialogs import Dialog from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS +from qt_ui.widgets.QFrequencyWidget import QFrequencyWidget +from qt_ui.widgets.QICLSWidget import QICLSWidget +from qt_ui.widgets.QLink4Widget import QLink4Widget +from qt_ui.widgets.QTacanWidget import QTacanWidget from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs @@ -60,13 +69,49 @@ class QBaseMenu2(QDialog): pixmap = QPixmap(self.get_base_image()) header.setPixmap(pixmap) + cp_settings = QGridLayout() + top_layout.addLayout(cp_settings) + title = QLabel("" + self.cp.name + "") title.setAlignment(Qt.AlignLeft | Qt.AlignTop) title.setProperty("style", "base-title") + cp_settings.addWidget(title, 0, 0, 1, 2) + cp_settings.setHorizontalSpacing(20) + + counter = 2 + + self.freq_widget = None + self.link4_widget = None + + is_friendly = cp.is_friendly(True) + if is_friendly and isinstance(cp, RadioFrequencyContainer): + self.freq_widget = QFrequencyWidget(cp, self.game_model) + cp_settings.addWidget(self.freq_widget, counter // 2, counter % 2) + counter += 1 + + if is_friendly and isinstance(cp, TacanContainer): + self.tacan_widget = QTacanWidget(cp, self.game_model) + cp_settings.addWidget(self.tacan_widget, counter // 2, counter % 2) + counter += 1 + + if is_friendly and isinstance(cp, ICLSContainer): + self.icls_widget = QICLSWidget(cp, self.game_model) + cp_settings.addWidget(self.icls_widget, counter // 2, counter % 2) + counter += 1 + + if is_friendly and isinstance(cp, NavalControlPoint): + self.link4_widget = QLink4Widget(cp, self.game_model) + cp_settings.addWidget(self.link4_widget, counter // 2, counter % 2) + counter += 1 + + if self.freq_widget and self.link4_widget: + # link them so on change they check freq + self.freq_widget.freq_changed.connect(self.link4_widget.check_freq) + self.link4_widget.freq_changed.connect(self.freq_widget.check_freq) + self.intel_summary = QLabel() self.intel_summary.setToolTip(self.generate_intel_tooltip()) self.update_intel_summary() - top_layout.addWidget(title) top_layout.addWidget(self.intel_summary) top_layout.setAlignment(Qt.AlignTop) @@ -127,6 +172,7 @@ class QBaseMenu2(QDialog): GameUpdateSignal.get_instance().updateGame(self.game_model.game) state = self.game_model.game.check_win_loss() GameUpdateSignal.get_instance().gameStateChanged(state) + self.close() @property def has_transfer_destinations(self) -> bool: diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 40d43cf7..0f94cc42 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -32,7 +32,7 @@ class QEditFlightDialog(QDialog): layout = QVBoxLayout() - self.flight_planner = QFlightPlanner(package_model, flight, game_model.game) + self.flight_planner = QFlightPlanner(package_model, flight, game_model) layout.addWidget(self.flight_planner) self.setLayout(layout) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index de12dafc..7b0c8948 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -26,9 +26,10 @@ from game.sim import GameUpdateEvents from game.theater.missiontarget import MissionTarget from qt_ui.models import AtoModel, GameModel, PackageModel from qt_ui.uiconstants import EVENT_ICONS +from qt_ui.widgets.QFrequencyWidget import QFrequencyWidget from qt_ui.widgets.ato import QFlightList +from qt_ui.windows.QRadioFrequencyDialog import QRadioFrequencyDialog from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator -from qt_ui.windows.mission.package.QPackageFrequency import QPackageFrequency class QPackageDialog(QDialog): @@ -70,22 +71,9 @@ class QPackageDialog(QDialog): package_type_row.addWidget(self.package_type_text) self.package_type_column.addLayout(package_type_row) - # TODO: make freq red if used by another package - package_freq_row = QHBoxLayout() - self.package_freq_label = QLabel("Package FREQ:") - freq = ( - self.package_model.package.frequency.mhz - if self.package_model.package.frequency is not None - else "" - ) - self.package_freq_text = QLabel(f"{freq}") - package_freq_row.addWidget(self.package_freq_label) - package_freq_row.addWidget(self.package_freq_text) - self.package_type_column.addLayout(package_freq_row) - self.summary_row.addStretch(1) - self.package_name_column = QVBoxLayout() + self.package_name_column = QHBoxLayout() self.summary_row.addLayout(self.package_name_column) self.package_name_label = QLabel("Package Name:") self.package_name_label.setAlignment(Qt.AlignCenter) @@ -145,16 +133,17 @@ class QPackageDialog(QDialog): self.delete_flight_button.setEnabled(model.rowCount() > 0) self.button_layout.addWidget(self.delete_flight_button) - self.open_radio_button = QPushButton("Set Package FREQ") - self.open_radio_button.clicked.connect(self.on_open_radio) - self.button_layout.addWidget(self.open_radio_button) + self.button_layout.addStretch() - self.package_model.tot_changed.connect(self.update_tot) + self.freq_widget = QFrequencyWidget(self.package_model.package, game_model) + self.button_layout.addWidget(self.freq_widget) self.button_layout.addStretch() self.setLayout(self.layout) + self.package_model.tot_changed.connect(self.update_tot) + self.accepted.connect(self.on_save) self.finished.connect(self.on_close) self.rejected.connect(self.on_cancel) @@ -238,8 +227,8 @@ class QPackageDialog(QDialog): self.package_model.package.custom_name = self.package_name_text.text() def on_open_radio(self) -> None: - self.package_frequency_dialog = QPackageFrequency( - self.game, self.package_model.package, parent=self.window() + self.package_frequency_dialog = QRadioFrequencyDialog( + parent=self.window(), container=self.package_model.package ) self.package_frequency_dialog.accepted.connect(self.assign_frequency) self.package_frequency_dialog.show() @@ -251,8 +240,11 @@ class QPackageDialog(QDialog): def on_package_changed(self): self.package_type_text.setText(self.package_model.description) - if (freq := self.package_model.package.frequency) is not None: - self.package_freq_text.setText(f"{freq.mhz} MHz") + self.freq_widget.check_freq() + + def on_reset_radio(self): + self.package_model.package.frequency = None + self.package_freq_text.setText("AUTO") class QNewPackageDialog(QPackageDialog): diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py index ab86f052..69178c2d 100644 --- a/qt_ui/windows/mission/flight/QFlightPlanner.py +++ b/qt_ui/windows/mission/flight/QFlightPlanner.py @@ -1,8 +1,7 @@ from PySide2.QtWidgets import QTabWidget -from game import Game from game.ato.flight import Flight -from qt_ui.models import PackageModel +from qt_ui.models import PackageModel, GameModel from qt_ui.windows.mission.flight.payload.QFlightPayloadTab import QFlightPayloadTab from qt_ui.windows.mission.flight.settings.QGeneralFlightSettingsTab import ( QGeneralFlightSettingsTab, @@ -11,14 +10,12 @@ from qt_ui.windows.mission.flight.waypoints.QFlightWaypointTab import QFlightWay class QFlightPlanner(QTabWidget): - def __init__(self, package_model: PackageModel, flight: Flight, game: Game): + def __init__(self, package_model: PackageModel, flight: Flight, gm: GameModel): super().__init__() - self.general_settings_tab = QGeneralFlightSettingsTab( - game, package_model, flight - ) - self.payload_tab = QFlightPayloadTab(flight, game) - self.waypoint_tab = QFlightWaypointTab(game, package_model.package, flight) + self.general_settings_tab = QGeneralFlightSettingsTab(gm, package_model, flight) + self.payload_tab = QFlightPayloadTab(flight, gm.game) + self.waypoint_tab = QFlightWaypointTab(gm.game, package_model.package, flight) self.waypoint_tab.loadout_changed.connect(self.payload_tab.reload_from_flight) self.addTab(self.general_settings_tab, "General Flight settings") self.addTab(self.payload_tab, "Payload") diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py index 844ad801..b9177383 100644 --- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py +++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py @@ -4,6 +4,8 @@ from PySide2.QtWidgets import ( QFrame, QLabel, QVBoxLayout, + QScrollArea, + QWidget, ) from game import Game @@ -42,7 +44,12 @@ class QFlightPayloadTab(QFrame): docsText.setAlignment(Qt.AlignCenter) docsText.setOpenExternalLinks(True) - layout.addLayout(PropertyEditor(self.flight)) + self.scroll_area = QScrollArea() + self.property_editor = QWidget() + self.property_editor.setLayout(PropertyEditor(self.flight)) + self.scroll_area.setWidget(self.property_editor) + + layout.addWidget(self.scroll_area) self.loadout_selector = DcsLoadoutSelector(flight) self.loadout_selector.currentIndexChanged.connect(self.on_new_loadout) layout.addWidget(self.loadout_selector) diff --git a/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py b/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py index 0b70af76..c0048984 100644 --- a/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py +++ b/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py @@ -30,7 +30,6 @@ class QLoadoutEditor(QGroupBox): layout.addWidget(QPylonEditor(game, flight, pylon), i, 1) vbox.addLayout(layout) - vbox.addStretch() self.setLayout(vbox) for i in self.findChildren(QPylonEditor): diff --git a/qt_ui/windows/mission/flight/settings/QCommsEditor.py b/qt_ui/windows/mission/flight/settings/QCommsEditor.py new file mode 100644 index 00000000..8c738561 --- /dev/null +++ b/qt_ui/windows/mission/flight/settings/QCommsEditor.py @@ -0,0 +1,25 @@ +from PySide2.QtWidgets import QGroupBox, QVBoxLayout + +from game.ato import Flight, FlightType +from qt_ui.models import GameModel +from qt_ui.widgets.QFrequencyWidget import QFrequencyWidget +from qt_ui.widgets.QTacanWidget import QTacanWidget + + +class QCommsEditor(QGroupBox): + def __init__(self, flight: Flight, game: GameModel): + title = "Intra-Flight Frequency" + + layout = QVBoxLayout() + + is_refuel = flight.flight_type == FlightType.REFUELING + has_tacan = flight.unit_type.dcs_unit_type.tacan + + layout.addWidget(QFrequencyWidget(flight, game)) + if is_refuel and has_tacan: + layout.addWidget(QTacanWidget(flight, game)) + title = title + " / TACAN" + super(QCommsEditor, self).__init__(title) + self.flight = flight + + self.setLayout(layout) diff --git a/qt_ui/windows/mission/flight/settings/QCustomName.py b/qt_ui/windows/mission/flight/settings/QCustomName.py index 1258a4b2..bd614d0c 100644 --- a/qt_ui/windows/mission/flight/settings/QCustomName.py +++ b/qt_ui/windows/mission/flight/settings/QCustomName.py @@ -1,6 +1,6 @@ from typing import Optional -from PySide2.QtWidgets import QGroupBox, QVBoxLayout, QLineEdit, QLabel, QMessageBox +from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QLineEdit, QLabel, QMessageBox from game.ato.flight import Flight @@ -10,7 +10,7 @@ class QFlightCustomName(QGroupBox): self.flight = flight - self.layout = QVBoxLayout() + self.layout = QHBoxLayout() self.custom_name_label = QLabel(f"Custom Name:") self.custom_name_input = QLineEdit(flight.custom_name) self.custom_name_input.textChanged.connect(self.on_change) diff --git a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py index 0c9070a5..8cb30d4e 100644 --- a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py +++ b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py @@ -3,10 +3,11 @@ from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout from game import Game from game.ato.flight import Flight -from qt_ui.models import PackageModel +from qt_ui.models import PackageModel, GameModel from qt_ui.windows.mission.flight.settings.FlightAirfieldDisplay import ( FlightAirfieldDisplay, ) +from qt_ui.windows.mission.flight.settings.QCommsEditor import QCommsEditor from qt_ui.windows.mission.flight.settings.QCustomName import QFlightCustomName from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import QFlightSlotEditor from qt_ui.windows.mission.flight.settings.QFlightStartType import QFlightStartType @@ -18,16 +19,23 @@ from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import ( class QGeneralFlightSettingsTab(QFrame): on_flight_settings_changed = Signal() - def __init__(self, game: Game, package_model: PackageModel, flight: Flight): + def __init__(self, game: GameModel, package_model: PackageModel, flight: Flight): super().__init__() + widgets = [ + QFlightTypeTaskInfo(flight), + QCommsEditor(flight, game), + FlightAirfieldDisplay(game.game, package_model, flight), + QFlightSlotEditor(package_model, flight, game.game), + QFlightStartType(package_model, flight), + QFlightCustomName(flight), + ] layout = QGridLayout() - layout.addWidget(QFlightTypeTaskInfo(flight), 0, 0) - layout.addWidget(FlightAirfieldDisplay(game, package_model, flight), 1, 0) - layout.addWidget(QFlightSlotEditor(package_model, flight, game), 2, 0) - layout.addWidget(QFlightStartType(package_model, flight), 3, 0) - layout.addWidget(QFlightCustomName(flight), 4, 0) + row = 0 + for w in widgets: + layout.addWidget(w, row, 0) + row += 1 vstretch = QVBoxLayout() vstretch.addStretch() - layout.addLayout(vstretch, 5, 0) + layout.addLayout(vstretch, row, 0) self.setLayout(layout) diff --git a/qt_ui/windows/mission/package/QPackageFrequency.py b/qt_ui/windows/mission/package/QPackageFrequency.py deleted file mode 100644 index 7a97f319..00000000 --- a/qt_ui/windows/mission/package/QPackageFrequency.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Optional, Type - -from PySide2.QtCore import Qt -from PySide2.QtWidgets import ( - QDialog, - QPushButton, - QVBoxLayout, - QLabel, - QHBoxLayout, - QDoubleSpinBox, -) - -from game import Game -from game.ato.package import Package -from game.radio.radios import RadioRegistry -from qt_ui.uiconstants import EVENT_ICONS - - -class QPackageFrequency(QDialog): - def __init__(self, game: Game, package: Package, parent=None) -> None: - super().__init__(parent=parent) - self.setMinimumWidth(400) - - self.game = game - self.package = package - - # Make dialog modal to prevent background windows to close unexpectedly. - self.setModal(True) - - self.setWindowTitle("Assign frequency") - self.setWindowIcon(EVENT_ICONS["strike"]) - - layout = QHBoxLayout() - - self.frequency_label = QLabel("FREQ (Mhz):") - self.frequency_input = QDoubleSpinBox() - self.frequency_input.setRange(225, 399.975) - self.frequency_input.setSingleStep(0.025) - self.frequency_input.setDecimals(3) - layout.addWidget(self.frequency_label) - layout.addWidget(self.frequency_input) - - self.create_button = QPushButton("Save") - self.create_button.clicked.connect(self.accept) - layout.addWidget(self.create_button, alignment=Qt.AlignRight) - - self.setLayout(layout) diff --git a/requirements.txt b/requirements.txt index 56d4be3b..6c0ccecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ platformdirs==2.5.4 pluggy==1.0.0 pre-commit==2.20.0 pydantic==1.10.2 --e git+https://github.com/dcs-retribution/pydcs@14ba81f5d61baa9c181c3008d6baa631f2a1587c#egg=pydcs +-e git+https://github.com/dcs-retribution/pydcs@81b11734710c050b1c162b269668945cfb9fd2f0#egg=pydcs pyinstaller==5.6.2 pyinstaller-hooks-contrib==2022.13 pyparsing==3.0.9 diff --git a/resources/stylesheets/style-dcs.css b/resources/stylesheets/style-dcs.css index 2d0e0ccf..601dd897 100644 --- a/resources/stylesheets/style-dcs.css +++ b/resources/stylesheets/style-dcs.css @@ -208,7 +208,6 @@ QPushButton[style="btn-danger"]:hover{ background-color:#D84545; } - QPushButton[style="btn-accept"] { background-color:#82A466; color: white; @@ -510,7 +509,11 @@ QLineEdit{ border:1px solid #3B4656; background: #465C74; color: #fff; - margin-bottom:10px; +} + +.tacan-edit { + background: #1D2731; + text-transform: uppercase; } @@ -642,4 +645,22 @@ QCalendarWidget QTableView{ border-width: 2px; background-color:lightgrey; border: 1px solid black; -} \ No newline at end of file +} + +/* Plain old classes */ + +.btn-danger { + background-color:#9E3232; + color: white; + padding: 6px; + border-radius:2px; + border: 1px solid #9E3232; +} + +.btn-danger:hover { + background-color:#D84545; +} + +.comms { + padding: 2px; +}