diff --git a/changelog.md b/changelog.md index 5bb763c4..22d93dc2 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Saves from 8.x are not compatible with 9.0.0. ## Features/Improvements * **[Flight Planning]** Improved IP selection for targets that are near the center of a threat zone. +* **[Flight Planning]** Loadouts and aircraft properties can now be set per-flight member. Warning: AI flights should not use mixed loadouts. * **[Modding]** Factions can now specify the ship type to be used for cargo shipping. The Handy Wind will be used by default, but WW2 factions can pick something more appropriate. * **[UI]** An error will be displayed when invalid fast-forward options are selected rather than beginning a never ending simulation. * **[UI]** Added cheats for instantly repairing and destroying runways. diff --git a/game/ato/flight.py b/game/ato/flight.py index 458b4251..048738c8 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -1,16 +1,18 @@ from __future__ import annotations import uuid +from collections.abc import Iterator from datetime import datetime, timedelta from typing import Any, List, Optional, TYPE_CHECKING from dcs import Point from dcs.planes import C_101CC, C_101EB, Su_33 +from .flightmembers import FlightMembers from .flightroster import FlightRoster from .flightstate import FlightState, Navigating, Uninitialized from .flightstate.killed import Killed -from .loadouts import Loadout +from ..savecompat import has_save_compat_for from ..sidc import ( Entity, SidcDescribable, @@ -26,6 +28,8 @@ if TYPE_CHECKING: from game.squadrons import Squadron, Pilot from game.theater import ControlPoint from game.transfers import TransferOrder + from game.data.weapons import WeaponType + from .flightmember import FlightMember from .flightplans.flightplan import FlightPlan from .flighttype import FlightType from .flightwaypoint import FlightWaypoint @@ -52,17 +56,16 @@ class Flight(SidcDescribable): self.country = country self.coalition = squadron.coalition self.squadron = squadron + self.flight_type = flight_type self.squadron.claim_inventory(count) if roster is None: - self.roster = FlightRoster(self.squadron, initial_size=count) + self.roster = FlightMembers(self, initial_size=count) else: - self.roster = roster + self.roster = FlightMembers.from_roster(self, roster) self.divert = divert - self.flight_type = flight_type - self.loadout = Loadout.default_for(self) self.start_type = start_type - self.use_custom_loadout = False self.custom_name = custom_name + self.use_same_loadout_for_all_members = True # Only used by transport missions. self.cargo = cargo @@ -105,9 +108,14 @@ class Flight(SidcDescribable): del state["state"] return state + @has_save_compat_for(9) def __setstate__(self, state: dict[str, Any]) -> None: state["state"] = Uninitialized(self, state["squadron"].settings) + if "use_same_loadout_for_all_members" not in state: + state["use_same_loadout_for_all_members"] = True self.__dict__.update(state) + if isinstance(self.roster, FlightRoster): + self.roster = FlightMembers.from_roster(self, self.roster) @property def blue(self) -> bool: @@ -167,6 +175,9 @@ class Flight(SidcDescribable): def missing_pilots(self) -> int: return self.roster.missing_pilots + def iter_members(self) -> Iterator[FlightMember]: + yield from self.roster.members + def set_flight_type(self, var: FlightType) -> None: self.flight_type = var @@ -192,6 +203,11 @@ class Flight(SidcDescribable): return unit_type.fuel_max * 0.5 return None + def any_member_has_weapon_of_type(self, weapon_type: WeaponType) -> bool: + return any( + m.loadout.has_weapon_of_type(weapon_type) for m in self.iter_members() + ) + def __repr__(self) -> str: if self.custom_name: return f"{self.custom_name} {self.count} x {self.unit_type}" @@ -252,7 +268,7 @@ class Flight(SidcDescribable): Killed(self.state.estimate_position(), self, self.squadron.settings) ) events.update_flight(self) - for pilot in self.roster.pilots: + for pilot in self.roster.iter_pilots(): if pilot is not None: results.kill_pilot(self, pilot) diff --git a/game/ato/flightmember.py b/game/ato/flightmember.py new file mode 100644 index 00000000..7a9a3410 --- /dev/null +++ b/game/ato/flightmember.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from game.ato.loadouts import Loadout + +if TYPE_CHECKING: + from game.squadrons import Pilot + + +class FlightMember: + def __init__(self, pilot: Pilot | None, loadout: Loadout) -> None: + self.pilot = pilot + self.loadout = loadout + self.use_custom_loadout = False + self.properties: dict[str, bool | float | int] = {} + + @property + def is_player(self) -> bool: + if self.pilot is None: + return False + return self.pilot.player diff --git a/game/ato/flightmembers.py b/game/ato/flightmembers.py new file mode 100644 index 00000000..31e957ca --- /dev/null +++ b/game/ato/flightmembers.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Optional, TYPE_CHECKING + +from .flightmember import FlightMember +from .flightroster import FlightRoster +from .iflightroster import IFlightRoster +from .loadouts import Loadout + +if TYPE_CHECKING: + from game.squadrons import Pilot + from .flight import Flight + + +class FlightMembers(IFlightRoster): + def __init__(self, flight: Flight, initial_size: int = 0) -> None: + self.flight = flight + self.members: list[FlightMember] = [] + self.resize(initial_size) + + @staticmethod + def from_roster(flight: Flight, roster: FlightRoster) -> FlightMembers: + members = FlightMembers(flight) + loadout = Loadout.default_for(flight) + members.members = [FlightMember(p, loadout) for p in roster.pilots] + return members + + def iter_pilots(self) -> Iterator[Pilot | None]: + yield from (m.pilot for m in self.members) + + def pilot_at(self, idx: int) -> Pilot | None: + return self.members[idx].pilot + + @property + def max_size(self) -> int: + return len(self.members) + + @property + def player_count(self) -> int: + return len([m for m in self.members if m.is_player]) + + @property + def missing_pilots(self) -> int: + return len([m for m in self.members if m.pilot is None]) + + def resize(self, new_size: int) -> None: + if self.max_size > new_size: + self.flight.squadron.return_pilots( + [m.pilot for m in self.members[new_size:] if m.pilot is not None] + ) + self.members = self.members[:new_size] + return + if self.max_size: + loadout = self.members[0].loadout.clone() + else: + loadout = Loadout.default_for(self.flight) + for _ in range(new_size - self.max_size): + member = FlightMember(self.flight.squadron.claim_available_pilot(), loadout) + member.use_custom_loadout = loadout.is_custom + self.members.append(member) + + def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: + if pilot is not None: + self.flight.squadron.claim_pilot(pilot) + if (current_pilot := self.pilot_at(index)) is not None: + self.flight.squadron.return_pilot(current_pilot) + self.members[index].pilot = pilot + + def clear(self) -> None: + self.flight.squadron.return_pilots( + [p for p in self.iter_pilots() if p is not None] + ) + + def use_same_loadout_for_all_members(self) -> None: + if not self.members: + return + loadout = self.members[0].loadout + for member in self.members[1:]: + member.loadout = loadout.clone() diff --git a/game/ato/flightroster.py b/game/ato/flightroster.py index 6f7b6326..a9ef5016 100644 --- a/game/ato/flightroster.py +++ b/game/ato/flightroster.py @@ -1,29 +1,30 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Optional, TYPE_CHECKING +from game.ato.iflightroster import IFlightRoster + if TYPE_CHECKING: from game.squadrons import Squadron, Pilot -class FlightRoster: +class FlightRoster(IFlightRoster): def __init__(self, squadron: Squadron, initial_size: int = 0) -> None: self.squadron = squadron self.pilots: list[Optional[Pilot]] = [] self.resize(initial_size) + def iter_pilots(self) -> Iterator[Pilot | None]: + yield from self.pilots + + def pilot_at(self, idx: int) -> Pilot | None: + return self.pilots[idx] + @property def max_size(self) -> int: return len(self.pilots) - @property - def player_count(self) -> int: - return len([p for p in self.pilots if p is not None and p.player]) - - @property - def missing_pilots(self) -> int: - return len([p for p in self.pilots if p is None]) - def resize(self, new_size: int) -> None: if self.max_size > new_size: self.squadron.return_pilots( diff --git a/game/ato/iflightroster.py b/game/ato/iflightroster.py new file mode 100644 index 00000000..32231445 --- /dev/null +++ b/game/ato/iflightroster.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Optional, TYPE_CHECKING, Iterator + +if TYPE_CHECKING: + from game.squadrons import Pilot + + +class IFlightRoster(ABC): + @abstractmethod + def iter_pilots(self) -> Iterator[Pilot | None]: + ... + + @abstractmethod + def pilot_at(self, idx: int) -> Pilot | None: + ... + + @property + @abstractmethod + def max_size(self) -> int: + ... + + @abstractmethod + def resize(self, new_size: int) -> None: + ... + + @abstractmethod + def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: + ... + + @abstractmethod + def clear(self) -> None: + ... diff --git a/game/ato/loadouts.py b/game/ato/loadouts.py index f770522f..a0930f43 100644 --- a/game/ato/loadouts.py +++ b/game/ato/loadouts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import datetime import logging from collections.abc import Iterable @@ -35,6 +36,11 @@ class Loadout: def derive_custom(self, name: str) -> Loadout: return Loadout(name, self.pylons, self.date, is_custom=True) + def clone(self) -> Loadout: + return Loadout( + self.name, dict(self.pylons), copy.deepcopy(self.date), self.is_custom + ) + def has_weapon_of_type(self, weapon_type: WeaponType) -> bool: for weapon in self.pylons.values(): if weapon is not None and weapon.weapon_group.type is weapon_type: diff --git a/game/data/weapons.py b/game/data/weapons.py index 73b8d0d7..13df6d87 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Iterator, Optional, Any, ClassVar import yaml -from dcs.unitgroup import FlyingGroup +from dcs.flyingunit import FlyingUnit from dcs.weapons_data import weapon_ids from game.dcs.aircrafttype import AircraftType @@ -235,10 +235,10 @@ class Pylon: # configuration. return weapon in self.allowed or weapon.clsid == "" - def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None: + def equip(self, unit: FlyingUnit, weapon: Weapon) -> None: if not self.can_equip(weapon): logging.error(f"Pylon {self.number} cannot equip {weapon.name}") - group.load_pylon(self.make_pydcs_assignment(weapon), self.number) + unit.load_pylon(self.make_pydcs_assignment(weapon), self.number) def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment: return self.number, weapon.pydcs_data diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 12913f3f..437c31c1 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -24,6 +24,7 @@ from .aircraftbehavior import AircraftBehavior from .aircraftpainter import AircraftPainter from .flightdata import FlightData from .waypoints import WaypointGenerator +from ...ato.flightmember import FlightMember if TYPE_CHECKING: from game import Game @@ -62,13 +63,13 @@ class FlightGroupConfigurator: AircraftBehavior(self.flight.flight_type).apply_to(self.flight, self.group) AircraftPainter(self.flight, self.group).apply_livery() self.setup_props() - self.setup_payload() + self.setup_payloads() self.setup_fuel() flight_channel = self.setup_radios() laser_codes: list[Optional[int]] = [] - for unit, pilot in zip(self.group.units, self.flight.roster.pilots): - self.configure_flight_member(unit, pilot, laser_codes) + for unit, member in zip(self.group.units, self.flight.iter_members()): + self.configure_flight_member(unit, member, laser_codes) divert = None if self.flight.divert is not None: @@ -128,11 +129,10 @@ class FlightGroupConfigurator: ) def configure_flight_member( - self, unit: FlyingUnit, pilot: Optional[Pilot], laser_codes: list[Optional[int]] + self, unit: FlyingUnit, member: FlightMember, laser_codes: list[Optional[int]] ) -> None: - player = pilot is not None and pilot.player - self.set_skill(unit, pilot) - if self.flight.loadout.has_weapon_of_type(WeaponTypeEnum.TGP) and player: + self.set_skill(unit, member) + if member.loadout.has_weapon_of_type(WeaponTypeEnum.TGP) and member.is_player: laser_codes.append(self.laser_code_registry.get_next_laser_code()) else: laser_codes.append(None) @@ -176,9 +176,9 @@ class FlightGroupConfigurator: ) ) - def set_skill(self, unit: FlyingUnit, pilot: Optional[Pilot]) -> None: - if pilot is None or not pilot.player: - unit.skill = self.skill_level_for(unit, pilot) + def set_skill(self, unit: FlyingUnit, member: FlightMember) -> None: + if not member.is_player: + unit.skill = self.skill_level_for(unit, member.pilot) return if self.use_client: @@ -215,15 +215,18 @@ class FlightGroupConfigurator: return levels[new_level] def setup_props(self) -> None: - for prop_id, value in self.flight.props.items(): - for unit in self.group.units: + for unit, member in zip(self.group.units, self.flight.iter_members()): + for prop_id, value in member.properties.items(): unit.set_property(prop_id, value) - def setup_payload(self) -> None: - for p in self.group.units: - p.pylons.clear() + def setup_payloads(self) -> None: + for unit, member in zip(self.group.units, self.flight.iter_members()): + self.setup_payload(unit, member) - loadout = self.flight.loadout + def setup_payload(self, unit: FlyingUnit, member: FlightMember) -> None: + unit.pylons.clear() + + loadout = member.loadout if self.game.settings.restrict_weapons_by_date: loadout = loadout.degrade_for_date(self.flight.unit_type, self.game.date) @@ -231,7 +234,7 @@ class FlightGroupConfigurator: if weapon is None: continue pylon = Pylon.for_aircraft(self.flight.unit_type, pylon_number) - pylon.equip(self.group, weapon) + pylon.equip(unit, weapon) def setup_fuel(self) -> None: fuel = self.flight.state.estimate_fuel() @@ -242,7 +245,7 @@ class FlightGroupConfigurator: "starting fuel to 100kg." ) fuel = 100 - for unit, pilot in zip(self.group.units, self.flight.roster.pilots): + for unit, pilot in zip(self.group.units, self.flight.roster.iter_pilots()): if pilot is not None and pilot.player: unit.fuel = fuel elif (max_takeoff_fuel := self.flight.max_takeoff_fuel()) is not None: diff --git a/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py b/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py index f98527ed..e1ec9738 100644 --- a/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py +++ b/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py @@ -28,7 +28,7 @@ class OcaRunwayIngressBuilder(PydcsWaypointBuilder): # for more details. # The LGB work around assumes the Airfield position in DCS is on a runway, which seems # to be the case for most if not all airfields. - if self.flight.loadout.has_weapon_of_type(WeaponType.LGB): + if self.flight.any_member_has_weapon_of_type(WeaponType.LGB): waypoint.tasks.append( Bombing( position=target.position, diff --git a/game/missiongenerator/aircraft/waypoints/seadingress.py b/game/missiongenerator/aircraft/waypoints/seadingress.py index 3070c43e..4893ff4e 100644 --- a/game/missiongenerator/aircraft/waypoints/seadingress.py +++ b/game/missiongenerator/aircraft/waypoints/seadingress.py @@ -10,7 +10,7 @@ from dcs.task import ( SwitchWaypoint, WeaponType as DcsWeaponType, ) -from game.ato.flightstate import InFlight + from game.data.weapons import WeaponType from game.theater import TheaterGroundObject from .pydcswaypointbuilder import PydcsWaypointBuilder @@ -36,7 +36,7 @@ class SeadIngressBuilder(PydcsWaypointBuilder): ) continue - if self.flight.loadout.has_weapon_of_type(WeaponType.ARM): + if self.flight.any_member_has_weapon_of_type(WeaponType.ARM): # Special handling for ARM Weapon types: # The SEAD flight will Search for the targeted group and then engage it # if it is found only. This will prevent AI from having huge problems @@ -47,7 +47,7 @@ class SeadIngressBuilder(PydcsWaypointBuilder): engage_task.params["groupAttack"] = True engage_task.params["expend"] = Expend.All.value waypoint.tasks.append(engage_task) - elif self.flight.loadout.has_weapon_of_type(WeaponType.DECOY): + elif self.flight.any_member_has_weapon_of_type(WeaponType.DECOY): # Special handling for DECOY weapon types: # - Specify that DECOY weapon type is used in AttackGroup task so that # the flight actually launches the decoy. See link below for details @@ -86,7 +86,7 @@ class SeadIngressBuilder(PydcsWaypointBuilder): # INGRESS point to the SPLIT point. This tasking prevents the flights continuing to # overfly the target. See link below for the details of this issue # https://github.com/dcs-liberation/dcs_liberation/issues/2781 - if self.flight.loadout.has_weapon_of_type(WeaponType.DECOY): + if self.flight.any_member_has_weapon_of_type(WeaponType.DECOY): switch_waypoint_task = SwitchWaypoint( self.generated_waypoint_idx, self.generated_waypoint_idx + 2 ) diff --git a/game/sim/missionresultsprocessor.py b/game/sim/missionresultsprocessor.py index 7ab7c0c6..7410781c 100644 --- a/game/sim/missionresultsprocessor.py +++ b/game/sim/missionresultsprocessor.py @@ -60,7 +60,7 @@ class MissionResultsProcessor: def _commit_pilot_experience(ato: AirTaskingOrder) -> None: for package in ato.packages: for flight in package.flights: - for idx, pilot in enumerate(flight.roster.pilots): + for idx, pilot in enumerate(flight.roster.iter_pilots()): if pilot is None: logging.error( f"Cannot award experience to pilot #{idx} of {flight} " diff --git a/game/unitmap.py b/game/unitmap.py index d9a20d70..9ad12ec2 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -10,10 +10,10 @@ from dcs.triggers import TriggerZone from dcs.unit import Unit from dcs.unitgroup import FlyingGroup, VehicleGroup, ShipGroup +from game.ato.flight import Flight from game.dcs.groundunittype import GroundUnitType from game.squadrons import Pilot from game.theater import Airfield, ControlPoint, TheaterUnit -from game.ato.flight import Flight from game.theater.theatergroup import SceneryUnit if TYPE_CHECKING: @@ -69,7 +69,7 @@ class UnitMap: self.airlifts: Dict[str, AirliftUnits] = {} def add_aircraft(self, group: FlyingGroup[Any], flight: Flight) -> None: - for pilot, unit in zip(flight.roster.pilots, group.units): + for pilot, unit in zip(flight.roster.iter_pilots(), group.units): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. name = str(unit.name) diff --git a/qt_ui/blocksignals.py b/qt_ui/blocksignals.py new file mode 100644 index 00000000..8019946a --- /dev/null +++ b/qt_ui/blocksignals.py @@ -0,0 +1,13 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +from PySide6.QtWidgets import QWidget + + +@contextmanager +def block_signals(widget: QWidget) -> Iterator[None]: + blocked = widget.blockSignals(True) + try: + yield + finally: + widget.blockSignals(blocked) diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index 60162aa9..4208618d 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -125,7 +125,7 @@ class AircraftInventoryData: flight_type = flight.flight_type.value target = flight.package.target.name for idx in range(0, num_units): - pilot = flight.roster.pilots[idx] + pilot = flight.roster.pilot_at(idx) if pilot is None: pilot_name = "Unassigned" player = "" diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index aa657ded..9183c19e 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -90,9 +90,10 @@ class QFlightCreator(QDialog): roster = None else: roster = FlightRoster( - squadron, initial_size=self.flight_size_spinner.value() + squadron, + initial_size=self.flight_size_spinner.value(), ) - self.roster_editor = FlightRosterEditor(roster) + self.roster_editor = FlightRosterEditor(squadron, roster) self.flight_size_spinner.valueChanged.connect(self.roster_editor.resize) self.squadron_selector.currentIndexChanged.connect(self.on_squadron_changed) roster_layout = QHBoxLayout() @@ -234,7 +235,7 @@ class QFlightCreator(QDialog): self.roster_editor.replace(None) if squadron is not None: self.roster_editor.replace( - FlightRoster(squadron, self.flight_size_spinner.value()) + squadron, FlightRoster(squadron, self.flight_size_spinner.value()) ) self.on_departure_changed(squadron.location) diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py index a115e10f..db4d5ce9 100644 --- a/qt_ui/windows/mission/flight/QFlightPlanner.py +++ b/qt_ui/windows/mission/flight/QFlightPlanner.py @@ -18,6 +18,9 @@ class QFlightPlanner(QTabWidget): game, package_model, flight ) self.payload_tab = QFlightPayloadTab(flight, game) + self.general_settings_tab.flight_size_changed.connect( + self.payload_tab.resize_for_flight + ) self.waypoint_tab = QFlightWaypointTab(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") diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py index efba55cd..65823950 100644 --- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py +++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py @@ -6,37 +6,72 @@ from PySide6.QtWidgets import ( QVBoxLayout, QScrollArea, QWidget, + QSpinBox, + QCheckBox, ) from game import Game from game.ato.flight import Flight +from game.ato.flightmember import FlightMember from game.ato.loadouts import Loadout +from qt_ui.widgets.QLabeledWidget import QLabeledWidget from .QLoadoutEditor import QLoadoutEditor from .propertyeditor import PropertyEditor class DcsLoadoutSelector(QComboBox): - def __init__(self, flight: Flight) -> None: + def __init__(self, flight: Flight, member: FlightMember) -> None: super().__init__() for loadout in Loadout.iter_for(flight): self.addItem(loadout.name, loadout) self.model().sort(0) - self.setDisabled(flight.loadout.is_custom) - if flight.loadout.is_custom: + self.setDisabled(member.loadout.is_custom) + if member.loadout.is_custom: self.setCurrentText(Loadout.default_for(flight).name) else: - self.setCurrentText(flight.loadout.name) + self.setCurrentText(member.loadout.name) + + +class FlightMemberSelector(QSpinBox): + def __init__(self, flight: Flight, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.flight = flight + self.setMinimum(0) + self.setMaximum(flight.count - 1) + + @property + def selected_member(self) -> FlightMember: + return self.flight.roster.members[self.value()] class QFlightPayloadTab(QFrame): def __init__(self, flight: Flight, game: Game): super(QFlightPayloadTab, self).__init__() self.flight = flight - self.payload_editor = QLoadoutEditor(flight, game) + self.payload_editor = QLoadoutEditor( + flight, self.flight.roster.members[0], game + ) self.payload_editor.toggled.connect(self.on_custom_toggled) layout = QVBoxLayout() + self.member_selector = FlightMemberSelector(self.flight, self) + self.member_selector.valueChanged.connect(self.rebind_to_selected_member) + layout.addLayout(QLabeledWidget("Flight member:", self.member_selector)) + self.same_loadout_for_all_checkbox = QCheckBox( + "Use same loadout for all flight members" + ) + self.same_loadout_for_all_checkbox.setChecked( + self.flight.use_same_loadout_for_all_members + ) + self.same_loadout_for_all_checkbox.toggled.connect(self.on_same_loadout_toggled) + layout.addWidget(self.same_loadout_for_all_checkbox) + layout.addWidget( + QLabel( + "Warning: AI flights should use the same loadout for all members." + ) + ) + scroll_content = QWidget() scrolling_layout = QVBoxLayout() scroll_content.setLayout(scrolling_layout) @@ -54,8 +89,13 @@ class QFlightPayloadTab(QFrame): docsText.setAlignment(Qt.AlignCenter) docsText.setOpenExternalLinks(True) - scrolling_layout.addLayout(PropertyEditor(self.flight)) - self.loadout_selector = DcsLoadoutSelector(flight) + self.property_editor = PropertyEditor( + self.flight, self.member_selector.selected_member + ) + scrolling_layout.addLayout(self.property_editor) + self.loadout_selector = DcsLoadoutSelector( + flight, self.member_selector.selected_member + ) self.loadout_selector.currentIndexChanged.connect(self.on_new_loadout) scrolling_layout.addWidget(self.loadout_selector) scrolling_layout.addWidget(self.payload_editor) @@ -63,8 +103,27 @@ class QFlightPayloadTab(QFrame): self.setLayout(layout) + def resize_for_flight(self) -> None: + self.member_selector.setMaximum(self.flight.count - 1) + def reload_from_flight(self) -> None: - self.loadout_selector.setCurrentText(self.flight.loadout.name) + self.loadout_selector.setCurrentText( + self.member_selector.selected_member.loadout.name + ) + + def rebind_to_selected_member(self) -> None: + member = self.member_selector.selected_member + self.property_editor.set_flight_member(member) + self.loadout_selector.setCurrentText(member.loadout.name) + self.loadout_selector.setDisabled(member.loadout.is_custom) + self.payload_editor.set_flight_member(member) + if self.member_selector.value() != 0: + self.loadout_selector.setDisabled( + self.flight.use_same_loadout_for_all_members + ) + self.payload_editor.setDisabled( + self.flight.use_same_loadout_for_all_members + ) def loadout_at(self, index: int) -> Loadout: loadout = self.loadout_selector.itemData(index) @@ -79,13 +138,25 @@ class QFlightPayloadTab(QFrame): return loadout def on_new_loadout(self, index: int) -> None: - self.flight.loadout = self.loadout_at(index) + self.member_selector.selected_member.loadout = self.loadout_at(index) self.payload_editor.reset_pylons() def on_custom_toggled(self, use_custom: bool) -> None: self.loadout_selector.setDisabled(use_custom) + member = self.member_selector.selected_member + member.use_custom_loadout = use_custom if use_custom: - self.flight.loadout = self.flight.loadout.derive_custom("Custom") + member.loadout = member.loadout.derive_custom("Custom") else: - self.flight.loadout = self.current_loadout() + member.loadout = self.current_loadout() self.payload_editor.reset_pylons() + + def on_same_loadout_toggled(self, checked: bool) -> None: + self.flight.use_same_loadout_for_all_members = checked + if self.member_selector.value(): + self.loadout_selector.setDisabled(checked) + self.payload_editor.setDisabled(checked) + if checked: + self.flight.roster.use_same_loadout_for_all_members() + if self.member_selector.value(): + self.rebind_to_selected_member() diff --git a/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py b/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py index 31b5b661..5c74bebf 100644 --- a/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py +++ b/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator + from PySide6.QtWidgets import ( QGridLayout, QGroupBox, @@ -7,18 +9,21 @@ from PySide6.QtWidgets import ( ) from game import Game -from game.data.weapons import Pylon from game.ato.flight import Flight +from game.ato.flightmember import FlightMember +from game.data.weapons import Pylon +from qt_ui.blocksignals import block_signals from qt_ui.windows.mission.flight.payload.QPylonEditor import QPylonEditor class QLoadoutEditor(QGroupBox): - def __init__(self, flight: Flight, game: Game) -> None: + def __init__(self, flight: Flight, flight_member: FlightMember, game: Game) -> None: super().__init__("Use custom loadout") self.flight = flight + self.flight_member = flight_member self.game = game self.setCheckable(True) - self.setChecked(flight.loadout.is_custom) + self.setChecked(flight_member.loadout.is_custom) vbox = QVBoxLayout(self) layout = QGridLayout(self) @@ -27,17 +32,27 @@ class QLoadoutEditor(QGroupBox): label = QLabel(f"{pylon.number}") label.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) layout.addWidget(label, i, 0) - layout.addWidget(QPylonEditor(game, flight, pylon), i, 1) + layout.addWidget(QPylonEditor(game, flight, flight_member, pylon), i, 1) vbox.addLayout(layout) vbox.addStretch() self.setLayout(vbox) - for i in self.findChildren(QPylonEditor): - i.set_from(self.flight.loadout) + for pylon_editor in self.iter_pylon_editors(): + pylon_editor.set_from(self.flight_member.loadout) + + def iter_pylon_editors(self) -> Iterator[QPylonEditor]: + yield from self.findChildren(QPylonEditor) + + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + with block_signals(self): + self.setChecked(self.flight_member.use_custom_loadout) + for pylon_editor in self.iter_pylon_editors(): + pylon_editor.set_flight_member(flight_member) def reset_pylons(self) -> None: - self.flight.use_custom_loadout = self.isChecked() + self.flight_member.use_custom_loadout = self.isChecked() if not self.isChecked(): - for i in self.findChildren(QPylonEditor): - i.set_from(self.flight.loadout) + for pylon_editor in self.iter_pylon_editors(): + pylon_editor.set_from(self.flight_member.loadout) diff --git a/qt_ui/windows/mission/flight/payload/QPylonEditor.py b/qt_ui/windows/mission/flight/payload/QPylonEditor.py index 355404e8..5e3d013c 100644 --- a/qt_ui/windows/mission/flight/payload/QPylonEditor.py +++ b/qt_ui/windows/mission/flight/payload/QPylonEditor.py @@ -5,20 +5,24 @@ from typing import Optional from PySide6.QtWidgets import QComboBox from game import Game -from game.data.weapons import Pylon, Weapon from game.ato.flight import Flight +from game.ato.flightmember import FlightMember from game.ato.loadouts import Loadout +from game.data.weapons import Pylon, Weapon class QPylonEditor(QComboBox): - def __init__(self, game: Game, flight: Flight, pylon: Pylon) -> None: + def __init__( + self, game: Game, flight: Flight, flight_member: FlightMember, pylon: Pylon + ) -> None: super().__init__() self.flight = flight + self.flight_member = flight_member self.pylon = pylon self.game = game self.has_added_clean_item = False - current = self.flight.loadout.pylons.get(self.pylon.number) + current = self.flight_member.loadout.pylons.get(self.pylon.number) self.addItem("None", None) if self.game.settings.restrict_weapons_by_date: @@ -35,7 +39,7 @@ class QPylonEditor(QComboBox): def on_pylon_change(self): selected: Optional[Weapon] = self.currentData() - self.flight.loadout.pylons[self.pylon.number] = selected + self.flight_member.loadout.pylons[self.pylon.number] = selected if selected is None: logging.debug(f"Pylon {self.pylon.number} emptied") @@ -70,5 +74,9 @@ class QPylonEditor(QComboBox): return "None" return weapon.name + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + self.set_from(self.flight_member.loadout) + def set_from(self, loadout: Loadout) -> None: self.setCurrentText(self.matching_weapon_name(loadout)) diff --git a/qt_ui/windows/mission/flight/payload/propertycheckbox.py b/qt_ui/windows/mission/flight/payload/propertycheckbox.py index 5f2623e3..f5d1caee 100644 --- a/qt_ui/windows/mission/flight/payload/propertycheckbox.py +++ b/qt_ui/windows/mission/flight/payload/propertycheckbox.py @@ -1,21 +1,31 @@ from PySide6.QtWidgets import QCheckBox from dcs.unitpropertydescription import UnitPropertyDescription -from game.ato import Flight +from game.ato.flightmember import FlightMember from .missingpropertydataerror import MissingPropertyDataError class PropertyCheckBox(QCheckBox): - def __init__(self, flight: Flight, prop: UnitPropertyDescription) -> None: + def __init__( + self, flight_member: FlightMember, prop: UnitPropertyDescription + ) -> None: super().__init__() - self.flight = flight + self.flight_member = flight_member self.prop = prop if prop.default is None: raise MissingPropertyDataError("default cannot be None") - self.setChecked(self.flight.props.get(self.prop.identifier, self.prop.default)) + self.setChecked( + self.flight_member.properties.get(self.prop.identifier, self.prop.default) + ) self.toggled.connect(self.on_toggle) def on_toggle(self, checked: bool) -> None: - self.flight.props[self.prop.identifier] = checked + self.flight_member.properties[self.prop.identifier] = checked + + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + self.setChecked( + self.flight_member.properties.get(self.prop.identifier, self.prop.default) + ) diff --git a/qt_ui/windows/mission/flight/payload/propertycombobox.py b/qt_ui/windows/mission/flight/payload/propertycombobox.py index 4780b881..0d407982 100644 --- a/qt_ui/windows/mission/flight/payload/propertycombobox.py +++ b/qt_ui/windows/mission/flight/payload/propertycombobox.py @@ -1,14 +1,16 @@ from PySide6.QtWidgets import QComboBox from dcs.unitpropertydescription import UnitPropertyDescription -from game.ato import Flight +from game.ato.flightmember import FlightMember from .missingpropertydataerror import MissingPropertyDataError class PropertyComboBox(QComboBox): - def __init__(self, flight: Flight, prop: UnitPropertyDescription) -> None: + def __init__( + self, flight_member: FlightMember, prop: UnitPropertyDescription + ) -> None: super().__init__() - self.flight = flight + self.flight_member = flight_member self.prop = prop if prop.values is None: @@ -16,7 +18,9 @@ class PropertyComboBox(QComboBox): if prop.default is None: raise MissingPropertyDataError("default cannot be None") - current_value = self.flight.props.get(self.prop.identifier, self.prop.default) + current_value = self.flight_member.properties.get( + self.prop.identifier, self.prop.default + ) for ident, text in self.prop.values.items(): self.addItem(text, ident) @@ -26,4 +30,12 @@ class PropertyComboBox(QComboBox): self.currentIndexChanged.connect(self.on_selection_changed) def on_selection_changed(self, _index: int) -> None: - self.flight.props[self.prop.identifier] = self.currentData() + self.flight_member.properties[self.prop.identifier] = self.currentData() + + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + self.setCurrentText( + self.flight_member.properties.get( + self.prop.identifier, self.prop.values[self.prop.default] + ) + ) diff --git a/qt_ui/windows/mission/flight/payload/propertyeditor.py b/qt_ui/windows/mission/flight/payload/propertyeditor.py index 8b5ce30f..55bff470 100644 --- a/qt_ui/windows/mission/flight/payload/propertyeditor.py +++ b/qt_ui/windows/mission/flight/payload/propertyeditor.py @@ -1,9 +1,11 @@ import logging +from typing import Callable from PySide6.QtWidgets import QGridLayout, QLabel, QWidget from dcs.unitpropertydescription import UnitPropertyDescription from game.ato import Flight +from game.ato.flightmember import FlightMember from .missingpropertydataerror import MissingPropertyDataError from .propertycheckbox import PropertyCheckBox from .propertycombobox import PropertyComboBox @@ -16,9 +18,10 @@ class UnhandledControlTypeError(RuntimeError): class PropertyEditor(QGridLayout): - def __init__(self, flight: Flight) -> None: + def __init__(self, flight: Flight, flight_member: FlightMember) -> None: super().__init__() - self.flight = flight + self.flight_member = flight_member + self.flight_member_update_listeners: list[Callable[[FlightMember], None]] = [] for row, prop in enumerate(flight.unit_type.iter_props()): if prop.label is None: @@ -56,12 +59,23 @@ class PropertyEditor(QGridLayout): # "checkbox", "comboList", "groupbox", "label", "slider", "spinbox" match prop.control: case "checkbox": - return PropertyCheckBox(self.flight, prop) + widget = PropertyCheckBox(self.flight_member, prop) + self.flight_member_update_listeners.append(widget.set_flight_member) + return widget case "comboList": - return PropertyComboBox(self.flight, prop) + widget = PropertyComboBox(self.flight_member, prop) + self.flight_member_update_listeners.append(widget.set_flight_member) + return widget case "groupbox" | "label": return None case "slider" | "spinbox": - return PropertySpinBox(self.flight, prop) + widget = PropertySpinBox(self.flight_member, prop) + self.flight_member_update_listeners.append(widget.set_flight_member) + return widget case _: raise UnhandledControlTypeError(prop.control) + + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + for listener in self.flight_member_update_listeners: + listener(self.flight_member) diff --git a/qt_ui/windows/mission/flight/payload/propertyspinbox.py b/qt_ui/windows/mission/flight/payload/propertyspinbox.py index 71a0e41f..f9a52913 100644 --- a/qt_ui/windows/mission/flight/payload/propertyspinbox.py +++ b/qt_ui/windows/mission/flight/payload/propertyspinbox.py @@ -1,14 +1,16 @@ from PySide6.QtWidgets import QSpinBox from dcs.unitpropertydescription import UnitPropertyDescription -from game.ato import Flight +from game.ato.flightmember import FlightMember from .missingpropertydataerror import MissingPropertyDataError class PropertySpinBox(QSpinBox): - def __init__(self, flight: Flight, prop: UnitPropertyDescription) -> None: + def __init__( + self, flight_member: FlightMember, prop: UnitPropertyDescription + ) -> None: super().__init__() - self.flight = flight + self.flight_member = flight_member self.prop = prop if prop.minimum is None: @@ -20,9 +22,17 @@ class PropertySpinBox(QSpinBox): self.setMinimum(prop.minimum) self.setMaximum(prop.maximum) - self.setValue(self.flight.props.get(self.prop.identifier, self.prop.default)) + self.setValue( + self.flight_member.properties.get(self.prop.identifier, self.prop.default) + ) self.valueChanged.connect(self.on_value_changed) def on_value_changed(self, value: int) -> None: - self.flight.props[self.prop.identifier] = value + self.flight_member.properties[self.prop.identifier] = value + + def set_flight_member(self, flight_member: FlightMember) -> None: + self.flight_member = flight_member + self.setValue( + self.flight_member.properties.get(self.prop.identifier, self.prop.default) + ) diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index 7aa07c6c..5e41a54c 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py @@ -14,17 +14,22 @@ from PySide6.QtWidgets import ( ) from game import Game -from game.squadrons.pilot import Pilot -from game.ato.flightroster import FlightRoster from game.ato.flight import Flight +from game.ato.flightroster import FlightRoster +from game.ato.iflightroster import IFlightRoster +from game.squadrons import Squadron +from game.squadrons.pilot import Pilot from qt_ui.models import PackageModel class PilotSelector(QComboBox): available_pilots_changed = Signal() - def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: + def __init__( + self, squadron: Squadron | None, roster: Optional[IFlightRoster], idx: int + ) -> None: super().__init__() + self.squadron = squadron self.roster = roster self.pilot_index = idx self.rebuild() @@ -40,10 +45,13 @@ class PilotSelector(QComboBox): self.setDisabled(True) return + if self.squadron is None: + raise RuntimeError("squadron cannot be None if roster is set") + self.setEnabled(True) self.addItem("Unassigned", None) - choices = list(self.roster.squadron.available_pilots) - current_pilot = self.roster.pilots[self.pilot_index] + choices = list(self.squadron.available_pilots) + current_pilot = self.roster.pilot_at(self.pilot_index) if current_pilot is not None: choices.append(current_pilot) # Put players first, otherwise alphabetically. @@ -71,23 +79,26 @@ class PilotSelector(QComboBox): # The roster resize is handled separately, so we have no pilots to remove. return pilot = self.itemData(index) - if pilot == self.roster.pilots[self.pilot_index]: + if pilot == self.roster.pilot_at(self.pilot_index): return self.roster.set_pilot(self.pilot_index, pilot) self.available_pilots_changed.emit() - def replace(self, new_roster: Optional[FlightRoster]) -> None: + def replace(self, squadron: Squadron, new_roster: Optional[FlightRoster]) -> None: + self.squadron = squadron self.roster = new_roster self.rebuild() class PilotControls(QHBoxLayout): - def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: + def __init__( + self, squadron: Squadron | None, roster: Optional[FlightRoster], idx: int + ) -> None: super().__init__() self.roster = roster self.pilot_index = idx - self.selector = PilotSelector(roster, idx) + self.selector = PilotSelector(squadron, roster, idx) self.selector.currentIndexChanged.connect(self.on_pilot_changed) self.addWidget(self.selector) @@ -95,8 +106,8 @@ class PilotControls(QHBoxLayout): self.player_checkbox.setToolTip("Checked if this pilot is a player.") self.on_pilot_changed(self.selector.currentIndex()) enabled = False - if self.roster is not None and self.roster.squadron is not None: - enabled = self.roster.squadron.aircraft.flyable + if self.roster is not None and squadron is not None: + enabled = squadron.aircraft.flyable self.player_checkbox.setEnabled(enabled) self.addWidget(self.player_checkbox) @@ -106,7 +117,7 @@ class PilotControls(QHBoxLayout): def pilot(self) -> Optional[Pilot]: if self.roster is None or self.pilot_index >= self.roster.max_size: return None - return self.roster.pilots[self.pilot_index] + return self.roster.pilot_at(self.pilot_index) def on_player_toggled(self, checked: bool) -> None: pilot = self.pilot @@ -140,19 +151,19 @@ class PilotControls(QHBoxLayout): finally: self.player_checkbox.blockSignals(False) - def replace(self, new_roster: Optional[FlightRoster]) -> None: + def replace(self, squadron: Squadron, new_roster: Optional[FlightRoster]) -> None: self.roster = new_roster if self.roster is None or self.pilot_index >= self.roster.max_size: self.disable_and_clear() else: self.enable_and_reset() - self.selector.replace(new_roster) + self.selector.replace(squadron, new_roster) class FlightRosterEditor(QVBoxLayout): MAX_PILOTS = 4 - def __init__(self, roster: Optional[FlightRoster]) -> None: + def __init__(self, squadron: Squadron | None, roster: IFlightRoster | None) -> None: super().__init__() self.roster = roster @@ -165,7 +176,7 @@ class FlightRosterEditor(QVBoxLayout): return callback - controls = PilotControls(roster, pilot_idx) + controls = PilotControls(squadron, roster, pilot_idx) controls.selector.available_pilots_changed.connect( make_reset_callback(pilot_idx) ) @@ -188,15 +199,17 @@ class FlightRosterEditor(QVBoxLayout): for controls in self.pilot_controls[new_size:]: controls.disable_and_clear() - def replace(self, new_roster: Optional[FlightRoster]) -> None: + def replace(self, squadron: Squadron, new_roster: Optional[FlightRoster]) -> None: if self.roster is not None: self.roster.clear() self.roster = new_roster for controls in self.pilot_controls: - controls.replace(new_roster) + controls.replace(squadron, new_roster) class QFlightSlotEditor(QGroupBox): + flight_resized = Signal(int) + def __init__(self, package_model: PackageModel, flight: Flight, game: Game): super().__init__("Slots") self.package_model = package_model @@ -223,7 +236,7 @@ class QFlightSlotEditor(QGroupBox): layout.addWidget(QLabel(str(self.flight.squadron)), 1, 1) layout.addWidget(QLabel("Assigned pilots:"), 2, 0) - self.roster_editor = FlightRosterEditor(flight.roster) + self.roster_editor = FlightRosterEditor(flight.squadron, flight.roster) layout.addLayout(self.roster_editor, 2, 1) self.setLayout(layout) @@ -246,3 +259,4 @@ class QFlightSlotEditor(QGroupBox): self.flight.resize(old_count) return self.roster_editor.resize(new_count) + self.flight_resized.emit(new_count) diff --git a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py index 6fa993f1..b3a753e3 100644 --- a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py +++ b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py @@ -16,7 +16,7 @@ from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import ( class QGeneralFlightSettingsTab(QFrame): - on_flight_settings_changed = Signal() + flight_size_changed = Signal() def __init__(self, game: Game, package_model: PackageModel, flight: Flight): super().__init__() @@ -24,7 +24,9 @@ class QGeneralFlightSettingsTab(QFrame): layout = QGridLayout() layout.addWidget(QFlightTypeTaskInfo(flight), 0, 0) layout.addWidget(FlightPlanPropertiesGroup(game, package_model, flight), 1, 0) - layout.addWidget(QFlightSlotEditor(package_model, flight, game), 2, 0) + self.flight_slot_editor = QFlightSlotEditor(package_model, flight, game) + self.flight_slot_editor.flight_resized.connect(self.flight_size_changed) + layout.addWidget(self.flight_slot_editor, 2, 0) layout.addWidget(QFlightStartType(package_model, flight), 3, 0) layout.addWidget(QFlightCustomName(flight), 4, 0) vstretch = QVBoxLayout() diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 30d380ee..416e5d23 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -174,9 +174,10 @@ class QFlightWaypointTab(QFrame): QMessageBox.critical( self, "Could not recreate flight", str(ex), QMessageBox.Ok ) - if not self.flight.loadout.is_custom: - self.flight.loadout = Loadout.default_for(self.flight) - self.loadout_changed.emit() + for member in self.flight.iter_members(): + if not member.loadout.is_custom: + member.loadout = Loadout.default_for(self.flight) + self.loadout_changed.emit() self.flight_waypoint_list.update_list() self.on_change()