mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Initial squadrons implementation.
Doesn't actually do anything yet, but squadrons are created for each aircraft type and pilots will be created as needed to fill flights. https://github.com/dcs-liberation/dcs_liberation/issues/276
This commit is contained in:
parent
6b30f47588
commit
4147d2f684
@ -8,6 +8,7 @@ Saves from 2.5 are not compatible with 3.0.
|
||||
* **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them.
|
||||
* **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn.
|
||||
* **[Campaign]** Non-control point FOBs will no longer spawn.
|
||||
* **[Campaign]** (WIP) Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information.
|
||||
* **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions.
|
||||
* **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP.
|
||||
* **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time.
|
||||
|
||||
@ -1459,6 +1459,13 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
|
||||
return None
|
||||
|
||||
|
||||
def flying_type_from_name(name: str) -> Optional[Type[FlyingType]]:
|
||||
unit_type = plane_map.get(name)
|
||||
if unit_type is not None:
|
||||
return unit_type
|
||||
return helicopter_map.get(name)
|
||||
|
||||
|
||||
def unit_type_of(unit: Unit) -> UnitType:
|
||||
if isinstance(unit, Vehicle):
|
||||
return vehicle_map[unit.type]
|
||||
|
||||
@ -27,6 +27,9 @@ from pydcs_extensions.mod_units import MODDED_VEHICLES, MODDED_AIRPLANES
|
||||
|
||||
@dataclass
|
||||
class Faction:
|
||||
#: List of locales to use when generating random names. If not set, Faker will
|
||||
#: choose the default locale.
|
||||
locales: Optional[List[str]]
|
||||
|
||||
# Country used by this faction
|
||||
country: str = field(default="")
|
||||
@ -132,8 +135,7 @@ class Faction:
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
|
||||
|
||||
faction = Faction()
|
||||
faction = Faction(locales=json.get("locales"))
|
||||
|
||||
faction.country = json.get("country", "/")
|
||||
if faction.country not in [c.name for c in country_dict.values()]:
|
||||
|
||||
24
game/game.py
24
game/game.py
@ -4,12 +4,13 @@ import random
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Iterator
|
||||
|
||||
from dcs.action import Coalition
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import CAP, CAS, PinpointStrike
|
||||
from dcs.vehicles import AirDefence
|
||||
from faker import Faker
|
||||
|
||||
from game import db
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
@ -33,6 +34,7 @@ from .navmesh import NavMesh
|
||||
from .procurement import AircraftProcurementRequest, ProcurementAi
|
||||
from .profiling import logged_duration
|
||||
from .settings import Settings
|
||||
from .squadrons import Pilot, AirWing
|
||||
from .theater import ConflictTheater
|
||||
from .theater.bullseye import Bullseye
|
||||
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
@ -140,6 +142,12 @@ class Game:
|
||||
|
||||
self.sanitize_sides()
|
||||
|
||||
self.blue_faker = Faker(self.player_faction.locales)
|
||||
self.red_faker = Faker(self.enemy_faction.locales)
|
||||
|
||||
self.blue_air_wing = AirWing(self, player=True)
|
||||
self.red_air_wing = AirWing(self, player=False)
|
||||
|
||||
self.on_load()
|
||||
|
||||
def __getstate__(self) -> Dict[str, Any]:
|
||||
@ -150,6 +158,8 @@ class Game:
|
||||
del state["red_threat_zone"]
|
||||
del state["blue_navmesh"]
|
||||
del state["red_navmesh"]
|
||||
del state["blue_faker"]
|
||||
del state["red_faker"]
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Dict[str, Any]) -> None:
|
||||
@ -205,6 +215,16 @@ class Game:
|
||||
return self.player_faction
|
||||
return self.enemy_faction
|
||||
|
||||
def faker_for(self, player: bool) -> Faker:
|
||||
if player:
|
||||
return self.blue_faker
|
||||
return self.red_faker
|
||||
|
||||
def air_wing_for(self, player: bool) -> AirWing:
|
||||
if player:
|
||||
return self.blue_air_wing
|
||||
return self.red_air_wing
|
||||
|
||||
def country_for(self, player: bool) -> str:
|
||||
if player:
|
||||
return self.player_country
|
||||
@ -286,6 +306,8 @@ class Game:
|
||||
ObjectiveDistanceCache.set_theater(self.theater)
|
||||
self.compute_conflicts_position()
|
||||
self.compute_threat_zones()
|
||||
self.blue_faker = Faker(self.faction_for(player=True).locales)
|
||||
self.red_faker = Faker(self.faction_for(player=False).locales)
|
||||
|
||||
def reset_ato(self) -> None:
|
||||
self.blue_ato.clear()
|
||||
|
||||
145
game/squadrons.py
Normal file
145
game/squadrons.py
Normal file
@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Type, Tuple, List, TYPE_CHECKING, Optional, Iterable
|
||||
|
||||
import yaml
|
||||
from dcs.unittype import FlyingType
|
||||
from faker import Faker
|
||||
|
||||
from game.db import flying_type_from_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PilotRecord:
|
||||
missions_flown: int = field(default=0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pilot:
|
||||
name: str
|
||||
player: bool = field(default=False)
|
||||
alive: bool = field(default=True)
|
||||
record: PilotRecord = field(default_factory=PilotRecord)
|
||||
|
||||
@classmethod
|
||||
def random(cls, faker: Faker) -> Pilot:
|
||||
return Pilot(faker.name())
|
||||
|
||||
|
||||
@dataclass
|
||||
class Squadron:
|
||||
name: str
|
||||
nickname: str
|
||||
role: str
|
||||
aircraft: Type[FlyingType]
|
||||
mission_types: Tuple[FlightType, ...]
|
||||
pilots: List[Pilot]
|
||||
available_pilots: List[Pilot] = field(init=False, hash=False, compare=False)
|
||||
|
||||
# We need a reference to the Game so that we can access the Faker without needing to
|
||||
# persist it to the save game, or having to reconstruct it (it's not cheap) each
|
||||
# time we create or load a squadron.
|
||||
game: Game = field(hash=False, compare=False)
|
||||
player: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.available_pilots = list(self.pilots)
|
||||
|
||||
def claim_available_pilot(self) -> Optional[Pilot]:
|
||||
if not self.available_pilots:
|
||||
self.enlist_new_pilots(1)
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_pilot(self, pilot: Pilot) -> None:
|
||||
if pilot not in self.available_pilots:
|
||||
raise ValueError(
|
||||
f"Cannot assign {pilot} to {self} because they are not available"
|
||||
)
|
||||
self.available_pilots.remove(pilot)
|
||||
|
||||
def return_pilot(self, pilot: Pilot) -> None:
|
||||
self.available_pilots.append(pilot)
|
||||
|
||||
def return_pilots(self, pilots: Iterable[Pilot]) -> None:
|
||||
self.available_pilots.extend(pilots)
|
||||
|
||||
def enlist_new_pilots(self, count: int) -> None:
|
||||
new_pilots = [Pilot(self.faker.name()) for _ in range(count)]
|
||||
self.pilots.extend(new_pilots)
|
||||
self.available_pilots.extend(new_pilots)
|
||||
|
||||
def return_all_pilots(self) -> None:
|
||||
self.available_pilots = self.pilots
|
||||
|
||||
@property
|
||||
def faker(self) -> Faker:
|
||||
return self.game.faker_for(self.player)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.pilots)
|
||||
|
||||
def pilot_at_index(self, index: int) -> Pilot:
|
||||
return self.pilots[index]
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
with path.open() as squadron_file:
|
||||
data = yaml.load(squadron_file)
|
||||
|
||||
unit_type = flying_type_from_name(data["aircraft"])
|
||||
if unit_type is None:
|
||||
raise KeyError(f"Could not find any aircraft with the ID {unit_type}")
|
||||
|
||||
return Squadron(
|
||||
name=data["name"],
|
||||
nickname=data["nickname"],
|
||||
role=data["role"],
|
||||
aircraft=unit_type,
|
||||
mission_types=tuple(FlightType.from_name(n) for n in data["mission_types"]),
|
||||
pilots=[Pilot(n) for n in data.get("pilots", [])],
|
||||
game=game,
|
||||
player=player,
|
||||
)
|
||||
|
||||
|
||||
class AirWing:
|
||||
def __init__(self, game: Game, player: bool) -> None:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
self.game = game
|
||||
self.player = player
|
||||
self.squadrons = {
|
||||
aircraft: [
|
||||
Squadron(
|
||||
name=f"Squadron {num + 1:03}",
|
||||
nickname="The Unremarkable",
|
||||
role="Flying Squadron",
|
||||
aircraft=aircraft,
|
||||
mission_types=tuple(FlightType),
|
||||
pilots=[],
|
||||
game=game,
|
||||
player=player,
|
||||
)
|
||||
]
|
||||
for num, aircraft in enumerate(game.faction_for(player).aircrafts)
|
||||
}
|
||||
|
||||
def squadron_for(self, aircraft: Type[FlyingType]) -> Squadron:
|
||||
return self.squadrons[aircraft][0]
|
||||
|
||||
def squadron_at_index(self, index: int) -> Squadron:
|
||||
return list(itertools.chain.from_iterable(self.squadrons.values()))[index]
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(len(s) for s in self.squadrons.values())
|
||||
@ -251,10 +251,11 @@ class AirliftPlanner:
|
||||
else:
|
||||
transfer = self.transfer
|
||||
|
||||
player = inventory.control_point.captured
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.game.country_for(inventory.control_point.captured),
|
||||
unit_type,
|
||||
self.game.country_for(player),
|
||||
self.game.air_wing_for(player).squadron_for(unit_type),
|
||||
flight_size,
|
||||
FlightType.TRANSPORT,
|
||||
self.game.settings.default_start_type,
|
||||
|
||||
@ -1020,7 +1020,7 @@ class AircraftConflictGenerator:
|
||||
flight = Flight(
|
||||
Package(control_point),
|
||||
faction.country,
|
||||
aircraft,
|
||||
self.game.air_wing_for(control_point.captured).squadron_for(aircraft),
|
||||
1,
|
||||
FlightType.BARCAP,
|
||||
"Cold",
|
||||
|
||||
@ -22,8 +22,10 @@ from typing import (
|
||||
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game.factions.faction import Faction
|
||||
from game.infos.information import Information
|
||||
from game.procurement import AircraftProcurementRequest
|
||||
from game.squadrons import AirWing
|
||||
from game.theater import (
|
||||
Airfield,
|
||||
ControlPoint,
|
||||
@ -182,6 +184,7 @@ class PackageBuilder:
|
||||
location: MissionTarget,
|
||||
closest_airfields: ClosestAirfields,
|
||||
global_inventory: GlobalAircraftInventory,
|
||||
air_wing: AirWing,
|
||||
is_player: bool,
|
||||
package_country: str,
|
||||
start_type: str,
|
||||
@ -189,6 +192,7 @@ class PackageBuilder:
|
||||
) -> None:
|
||||
self.closest_airfields = closest_airfields
|
||||
self.is_player = is_player
|
||||
self.air_wing = air_wing
|
||||
self.package_country = package_country
|
||||
self.package = Package(location, auto_asap=asap)
|
||||
self.allocator = AircraftAllocator(
|
||||
@ -217,7 +221,7 @@ class PackageBuilder:
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.package_country,
|
||||
aircraft,
|
||||
self.air_wing.squadron_for(aircraft),
|
||||
plan.num_aircraft,
|
||||
plan.task,
|
||||
start_type,
|
||||
@ -850,18 +854,13 @@ class CoalitionMissionPlanner:
|
||||
|
||||
def plan_mission(self, mission: ProposedMission, reserves: bool = False) -> None:
|
||||
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
|
||||
|
||||
if self.is_player:
|
||||
package_country = self.game.player_country
|
||||
else:
|
||||
package_country = self.game.enemy_country
|
||||
|
||||
builder = PackageBuilder(
|
||||
mission.location,
|
||||
self.objective_finder.closest_airfields_to(mission.location),
|
||||
self.game.aircraft_inventory,
|
||||
self.game.air_wing_for(self.is_player),
|
||||
self.is_player,
|
||||
package_country,
|
||||
self.game.country_for(self.is_player),
|
||||
self.game.settings.default_start_type,
|
||||
mission.asap,
|
||||
)
|
||||
|
||||
@ -10,6 +10,7 @@ from dcs.unit import Unit
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game import db
|
||||
from game.squadrons import Pilot, Squadron
|
||||
from game.theater.controlpoint import ControlPoint, MissionTarget
|
||||
from game.utils import Distance, meters
|
||||
from gen.flights.loadouts import Loadout
|
||||
@ -71,6 +72,13 @@ class FlightType(Enum):
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def from_name(cls, name: str) -> FlightType:
|
||||
for value in cls:
|
||||
if name == value:
|
||||
return value
|
||||
raise KeyError(f"No FlightType with name {name}")
|
||||
|
||||
|
||||
class FlightWaypointType(Enum):
|
||||
"""Enumeration of waypoint types.
|
||||
@ -197,7 +205,7 @@ class Flight:
|
||||
self,
|
||||
package: Package,
|
||||
country: str,
|
||||
unit_type: Type[FlyingType],
|
||||
squadron: Squadron,
|
||||
count: int,
|
||||
flight_type: FlightType,
|
||||
start_type: str,
|
||||
@ -209,8 +217,8 @@ class Flight:
|
||||
) -> None:
|
||||
self.package = package
|
||||
self.country = country
|
||||
self.unit_type = unit_type
|
||||
self.count = count
|
||||
self.squadron = squadron
|
||||
self.pilots = [squadron.claim_available_pilot() for _ in range(count)]
|
||||
self.departure = departure
|
||||
self.arrival = arrival
|
||||
self.divert = divert
|
||||
@ -235,6 +243,14 @@ class Flight:
|
||||
package=package, flight=self, custom_waypoints=[]
|
||||
)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.pilots)
|
||||
|
||||
@property
|
||||
def unit_type(self) -> Type[FlyingType]:
|
||||
return self.squadron.aircraft
|
||||
|
||||
@property
|
||||
def from_cp(self) -> ControlPoint:
|
||||
return self.departure
|
||||
@ -243,6 +259,27 @@ class Flight:
|
||||
def points(self) -> List[FlightWaypoint]:
|
||||
return self.flight_plan.waypoints[1:]
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
if self.count > new_size:
|
||||
self.squadron.return_pilots(
|
||||
p for p in self.pilots[new_size:] if p is not None
|
||||
)
|
||||
self.pilots = self.pilots[:new_size]
|
||||
return
|
||||
self.pilots.extend(
|
||||
[
|
||||
self.squadron.claim_available_pilot()
|
||||
for _ in range(new_size - self.count)
|
||||
]
|
||||
)
|
||||
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
if pilot is not None:
|
||||
self.squadron.claim_pilot(pilot)
|
||||
if (current_pilot := self.pilots[index]) is not None:
|
||||
self.squadron.return_pilot(current_pilot)
|
||||
self.pilots[index] = pilot
|
||||
|
||||
def __repr__(self):
|
||||
name = db.unit_type_name(self.unit_type)
|
||||
if self.custom_name:
|
||||
|
||||
@ -21,6 +21,7 @@ from dcs.unit import Unit
|
||||
from shapely.geometry import Point as ShapelyPoint
|
||||
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.squadrons import Pilot
|
||||
from game.theater import (
|
||||
Airfield,
|
||||
ControlPoint,
|
||||
@ -857,11 +858,7 @@ class FlightPlanBuilder:
|
||||
self.game = game
|
||||
self.package = package
|
||||
self.is_player = is_player
|
||||
if is_player:
|
||||
faction = self.game.player_faction
|
||||
else:
|
||||
faction = self.game.enemy_faction
|
||||
self.doctrine: Doctrine = faction.doctrine
|
||||
self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine
|
||||
self.threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
|
||||
def populate_flight_plan(
|
||||
|
||||
3
mypy.ini
3
mypy.ini
@ -5,6 +5,9 @@ namespace_packages = True
|
||||
follow_imports=silent
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-faker.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-PIL.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ from PySide2.QtGui import QIcon
|
||||
|
||||
from game import db
|
||||
from game.game import Game
|
||||
from game.squadrons import Squadron, Pilot
|
||||
from game.theater.missiontarget import MissionTarget
|
||||
from game.transfers import TransferOrder
|
||||
from gen.ato import AirTaskingOrder, Package
|
||||
@ -366,6 +367,87 @@ class TransferModel(QAbstractListModel):
|
||||
return self.game_model.game.transfers.transfer_at_index(index.row())
|
||||
|
||||
|
||||
class AirWingModel(QAbstractListModel):
|
||||
"""The model for an air wing."""
|
||||
|
||||
SquadronRole = Qt.UserRole
|
||||
|
||||
def __init__(self, game_model: GameModel, player: bool) -> None:
|
||||
super().__init__()
|
||||
self.game_model = game_model
|
||||
self.player = player
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
return self.game_model.game.air_wing_for(self.player).size
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
|
||||
if not index.isValid():
|
||||
return None
|
||||
squadron = self.squadron_at_index(index)
|
||||
if role == Qt.DisplayRole:
|
||||
return self.text_for_squadron(squadron)
|
||||
if role == Qt.DecorationRole:
|
||||
return self.icon_for_squadron(squadron)
|
||||
elif role == AirWingModel.SquadronRole:
|
||||
return squadron
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def text_for_squadron(squadron: Squadron) -> str:
|
||||
"""Returns the text that should be displayed for the squadron."""
|
||||
return squadron.name
|
||||
|
||||
@staticmethod
|
||||
def icon_for_squadron(_squadron: Squadron) -> Optional[QIcon]:
|
||||
"""Returns the icon that should be displayed for the squadron."""
|
||||
return None
|
||||
|
||||
def squadron_at_index(self, index: QModelIndex) -> Squadron:
|
||||
"""Returns the squadron located at the given index."""
|
||||
return self.game_model.game.air_wing_for(self.player).squadron_at_index(
|
||||
index.row()
|
||||
)
|
||||
|
||||
|
||||
class SquadronModel(QAbstractListModel):
|
||||
"""The model for a squadron."""
|
||||
|
||||
PilotRole = Qt.UserRole
|
||||
|
||||
def __init__(self, squadron: Squadron) -> None:
|
||||
super().__init__()
|
||||
self.squadron = squadron
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
return self.squadron.size
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
|
||||
if not index.isValid():
|
||||
return None
|
||||
pilot = self.pilot_at_index(index)
|
||||
if role == Qt.DisplayRole:
|
||||
return self.text_for_pilot(pilot)
|
||||
if role == Qt.DecorationRole:
|
||||
return self.icon_for_pilot(pilot)
|
||||
elif role == SquadronModel.PilotRole:
|
||||
return pilot
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def text_for_pilot(pilot: Pilot) -> str:
|
||||
"""Returns the text that should be displayed for the pilot."""
|
||||
return pilot.name
|
||||
|
||||
@staticmethod
|
||||
def icon_for_pilot(_pilot: Pilot) -> Optional[QIcon]:
|
||||
"""Returns the icon that should be displayed for the pilot."""
|
||||
return None
|
||||
|
||||
def pilot_at_index(self, index: QModelIndex) -> Pilot:
|
||||
"""Returns the pilot located at the given index."""
|
||||
return self.squadron.pilot_at_index(index.row())
|
||||
|
||||
|
||||
class GameModel:
|
||||
"""A model for the Game object.
|
||||
|
||||
@ -376,6 +458,7 @@ class GameModel:
|
||||
def __init__(self, game: Optional[Game]) -> None:
|
||||
self.game: Optional[Game] = game
|
||||
self.transfer_model = TransferModel(self)
|
||||
self.blue_air_wing_model = AirWingModel(self, player=True)
|
||||
if self.game is None:
|
||||
self.ato_model = AtoModel(self, AirTaskingOrder())
|
||||
self.red_ato_model = AtoModel(self, AirTaskingOrder())
|
||||
|
||||
@ -21,6 +21,7 @@ from qt_ui.widgets.QConditionsWidget import QConditionsWidget
|
||||
from qt_ui.widgets.QFactionsInfos import QFactionsInfos
|
||||
from qt_ui.widgets.QIntelBox import QIntelBox
|
||||
from qt_ui.widgets.clientslots import MaxPlayerCount
|
||||
from qt_ui.windows.AirWingDialog import AirWingDialog
|
||||
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
|
||||
from qt_ui.windows.PendingTransfersDialog import PendingTransfersDialog
|
||||
from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow
|
||||
@ -63,6 +64,11 @@ class QTopPanel(QFrame):
|
||||
|
||||
self.factionsInfos = QFactionsInfos(self.game)
|
||||
|
||||
self.air_wing = QPushButton("Air Wing")
|
||||
self.air_wing.setDisabled(True)
|
||||
self.air_wing.setProperty("style", "btn-primary")
|
||||
self.air_wing.clicked.connect(self.open_air_wing)
|
||||
|
||||
self.transfers = QPushButton("Transfers")
|
||||
self.transfers.setDisabled(True)
|
||||
self.transfers.setProperty("style", "btn-primary")
|
||||
@ -84,6 +90,7 @@ class QTopPanel(QFrame):
|
||||
|
||||
self.buttonBox = QGroupBox("Misc")
|
||||
self.buttonBoxLayout = QHBoxLayout()
|
||||
self.buttonBoxLayout.addWidget(self.air_wing)
|
||||
self.buttonBoxLayout.addWidget(self.transfers)
|
||||
self.buttonBoxLayout.addWidget(self.settings)
|
||||
self.buttonBoxLayout.addWidget(self.statistics)
|
||||
@ -114,6 +121,7 @@ class QTopPanel(QFrame):
|
||||
if game is None:
|
||||
return
|
||||
|
||||
self.air_wing.setEnabled(True)
|
||||
self.transfers.setEnabled(True)
|
||||
self.settings.setEnabled(True)
|
||||
self.statistics.setEnabled(True)
|
||||
@ -130,6 +138,10 @@ class QTopPanel(QFrame):
|
||||
else:
|
||||
self.proceedButton.setEnabled(True)
|
||||
|
||||
def open_air_wing(self):
|
||||
self.dialog = AirWingDialog(self.game_model, self.window())
|
||||
self.dialog.show()
|
||||
|
||||
def open_transfers(self):
|
||||
self.dialog = PendingTransfersDialog(self.game_model)
|
||||
self.dialog.show()
|
||||
|
||||
@ -562,10 +562,17 @@ class QLiberationMap(QGraphicsView, LiberationMap):
|
||||
origin = self.game.theater.enemy_points()[0]
|
||||
|
||||
package = Package(target)
|
||||
for squadron_list in self.game.air_wing_for(player=True).squadrons.values():
|
||||
squadron = squadron_list[0]
|
||||
break
|
||||
else:
|
||||
logging.error("Player has no squadrons?")
|
||||
return
|
||||
|
||||
flight = Flight(
|
||||
package,
|
||||
self.game.player_country if player else self.game.enemy_country,
|
||||
F_16C_50,
|
||||
self.game.country_for(player),
|
||||
squadron,
|
||||
2,
|
||||
task,
|
||||
start_type="Warm",
|
||||
|
||||
154
qt_ui/windows/AirWingDialog.py
Normal file
154
qt_ui/windows/AirWingDialog.py
Normal file
@ -0,0 +1,154 @@
|
||||
from typing import Optional
|
||||
|
||||
from PySide2.QtCore import (
|
||||
QItemSelectionModel,
|
||||
QModelIndex,
|
||||
QSize,
|
||||
Qt,
|
||||
)
|
||||
from PySide2.QtGui import QFont, QFontMetrics, QIcon, QPainter
|
||||
from PySide2.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QDialog,
|
||||
QListView,
|
||||
QStyle,
|
||||
QStyleOptionViewItem,
|
||||
QStyledItemDelegate,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from game.squadrons import Squadron
|
||||
from qt_ui.delegate_helpers import painter_context
|
||||
from qt_ui.models import GameModel, AirWingModel, SquadronModel
|
||||
from qt_ui.windows.SquadronDialog import SquadronDialog
|
||||
|
||||
|
||||
class SquadronDelegate(QStyledItemDelegate):
|
||||
FONT_SIZE = 10
|
||||
HMARGIN = 4
|
||||
VMARGIN = 4
|
||||
|
||||
def __init__(self, air_wing_model: AirWingModel) -> None:
|
||||
super().__init__()
|
||||
self.air_wing_model = air_wing_model
|
||||
|
||||
def get_font(self, option: QStyleOptionViewItem) -> QFont:
|
||||
font = QFont(option.font)
|
||||
font.setPointSize(self.FONT_SIZE)
|
||||
return font
|
||||
|
||||
@staticmethod
|
||||
def squadron(index: QModelIndex) -> Squadron:
|
||||
return index.data(AirWingModel.SquadronRole)
|
||||
|
||||
def first_row_text(self, index: QModelIndex) -> str:
|
||||
return self.air_wing_model.data(index, Qt.DisplayRole)
|
||||
|
||||
def second_row_text(self, index: QModelIndex) -> str:
|
||||
return self.squadron(index).nickname
|
||||
|
||||
def paint(
|
||||
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
||||
) -> None:
|
||||
# Draw the list item with all the default selection styling, but with an
|
||||
# invalid index so text formatting is left to us.
|
||||
super().paint(painter, option, QModelIndex())
|
||||
|
||||
rect = option.rect.adjusted(
|
||||
self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
|
||||
)
|
||||
|
||||
with painter_context(painter):
|
||||
painter.setFont(self.get_font(option))
|
||||
|
||||
icon: Optional[QIcon] = index.data(Qt.DecorationRole)
|
||||
if icon is not None:
|
||||
icon.paint(
|
||||
painter,
|
||||
rect,
|
||||
Qt.AlignLeft | Qt.AlignVCenter,
|
||||
self.icon_mode(option),
|
||||
self.icon_state(option),
|
||||
)
|
||||
|
||||
rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0)
|
||||
painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index))
|
||||
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
|
||||
painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index))
|
||||
|
||||
@staticmethod
|
||||
def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode:
|
||||
if not (option.state & QStyle.State_Enabled):
|
||||
return QIcon.Disabled
|
||||
elif option.state & QStyle.State_Selected:
|
||||
return QIcon.Selected
|
||||
elif option.state & QStyle.State_Active:
|
||||
return QIcon.Active
|
||||
return QIcon.Normal
|
||||
|
||||
@staticmethod
|
||||
def icon_state(option: QStyleOptionViewItem) -> QIcon.State:
|
||||
return QIcon.On if option.state & QStyle.State_Open else QIcon.Off
|
||||
|
||||
@staticmethod
|
||||
def icon_size(option: QStyleOptionViewItem) -> QSize:
|
||||
icon_size: Optional[QSize] = option.decorationSize
|
||||
if icon_size is None:
|
||||
return QSize(0, 0)
|
||||
else:
|
||||
return icon_size
|
||||
|
||||
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
|
||||
left = self.icon_size(option).width() + self.HMARGIN
|
||||
metrics = QFontMetrics(self.get_font(option))
|
||||
first = metrics.size(0, self.first_row_text(index))
|
||||
second = metrics.size(0, self.second_row_text(index))
|
||||
text_width = max(first.width(), second.width())
|
||||
return QSize(
|
||||
left + text_width + 2 * self.HMARGIN,
|
||||
first.height() + second.height() + 2 * self.VMARGIN,
|
||||
)
|
||||
|
||||
|
||||
class SquadronList(QListView):
|
||||
"""List view for displaying the air wing's squadrons."""
|
||||
|
||||
def __init__(self, air_wing_model: AirWingModel) -> None:
|
||||
super().__init__()
|
||||
self.air_wing_model = air_wing_model
|
||||
self.dialog: Optional[SquadronDialog] = None
|
||||
|
||||
self.setItemDelegate(SquadronDelegate(self.air_wing_model))
|
||||
self.setModel(self.air_wing_model)
|
||||
self.selectionModel().setCurrentIndex(
|
||||
self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
|
||||
)
|
||||
|
||||
# self.setIconSize(QSize(91, 24))
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectItems)
|
||||
self.doubleClicked.connect(self.on_double_click)
|
||||
|
||||
def on_double_click(self, index: QModelIndex) -> None:
|
||||
if not index.isValid():
|
||||
return
|
||||
self.dialog = SquadronDialog(
|
||||
SquadronModel(self.air_wing_model.squadron_at_index(index)), self
|
||||
)
|
||||
self.dialog.show()
|
||||
|
||||
|
||||
class AirWingDialog(QDialog):
|
||||
"""Dialog window showing the player's air wing."""
|
||||
|
||||
def __init__(self, game_model: GameModel, parent) -> None:
|
||||
super().__init__(parent)
|
||||
self.air_wing_model = game_model.blue_air_wing_model
|
||||
|
||||
self.setMinimumSize(1000, 440)
|
||||
self.setWindowTitle(f"Air Wing")
|
||||
# TODO: self.setWindowIcon()
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
layout.addWidget(SquadronList(self.air_wing_model))
|
||||
@ -190,7 +190,7 @@ class PendingTransfersDialog(QDialog):
|
||||
def can_cancel(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid():
|
||||
return False
|
||||
return self.transfer_model.transfer_at_index(index).player
|
||||
return self.transfer_model.pilot_at_index(index).player
|
||||
|
||||
def on_selection_changed(
|
||||
self, selected: QItemSelection, _deselected: QItemSelection
|
||||
|
||||
142
qt_ui/windows/SquadronDialog.py
Normal file
142
qt_ui/windows/SquadronDialog.py
Normal file
@ -0,0 +1,142 @@
|
||||
from typing import Optional
|
||||
|
||||
from PySide2.QtCore import (
|
||||
QItemSelectionModel,
|
||||
QModelIndex,
|
||||
QSize,
|
||||
Qt,
|
||||
)
|
||||
from PySide2.QtGui import QFont, QFontMetrics, QIcon, QPainter
|
||||
from PySide2.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QDialog,
|
||||
QListView,
|
||||
QStyle,
|
||||
QStyleOptionViewItem,
|
||||
QStyledItemDelegate,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from game.squadrons import Pilot
|
||||
from qt_ui.delegate_helpers import painter_context
|
||||
from qt_ui.models import SquadronModel
|
||||
|
||||
|
||||
class PilotDelegate(QStyledItemDelegate):
|
||||
FONT_SIZE = 10
|
||||
HMARGIN = 4
|
||||
VMARGIN = 4
|
||||
|
||||
def __init__(self, squadron_model: SquadronModel) -> None:
|
||||
super().__init__()
|
||||
self.squadron_model = squadron_model
|
||||
|
||||
def get_font(self, option: QStyleOptionViewItem) -> QFont:
|
||||
font = QFont(option.font)
|
||||
font.setPointSize(self.FONT_SIZE)
|
||||
return font
|
||||
|
||||
@staticmethod
|
||||
def pilot(index: QModelIndex) -> Pilot:
|
||||
return index.data(SquadronModel.PilotRole)
|
||||
|
||||
def first_row_text(self, index: QModelIndex) -> str:
|
||||
return self.squadron_model.data(index, Qt.DisplayRole)
|
||||
|
||||
def second_row_text(self, index: QModelIndex) -> str:
|
||||
return "Alive" if self.pilot(index).alive else "Dead"
|
||||
|
||||
def paint(
|
||||
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
||||
) -> None:
|
||||
# Draw the list item with all the default selection styling, but with an
|
||||
# invalid index so text formatting is left to us.
|
||||
super().paint(painter, option, QModelIndex())
|
||||
|
||||
rect = option.rect.adjusted(
|
||||
self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
|
||||
)
|
||||
|
||||
with painter_context(painter):
|
||||
painter.setFont(self.get_font(option))
|
||||
|
||||
icon: Optional[QIcon] = index.data(Qt.DecorationRole)
|
||||
if icon is not None:
|
||||
icon.paint(
|
||||
painter,
|
||||
rect,
|
||||
Qt.AlignLeft | Qt.AlignVCenter,
|
||||
self.icon_mode(option),
|
||||
self.icon_state(option),
|
||||
)
|
||||
|
||||
rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0)
|
||||
painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index))
|
||||
line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
|
||||
painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index))
|
||||
|
||||
@staticmethod
|
||||
def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode:
|
||||
if not (option.state & QStyle.State_Enabled):
|
||||
return QIcon.Disabled
|
||||
elif option.state & QStyle.State_Selected:
|
||||
return QIcon.Selected
|
||||
elif option.state & QStyle.State_Active:
|
||||
return QIcon.Active
|
||||
return QIcon.Normal
|
||||
|
||||
@staticmethod
|
||||
def icon_state(option: QStyleOptionViewItem) -> QIcon.State:
|
||||
return QIcon.On if option.state & QStyle.State_Open else QIcon.Off
|
||||
|
||||
@staticmethod
|
||||
def icon_size(option: QStyleOptionViewItem) -> QSize:
|
||||
icon_size: Optional[QSize] = option.decorationSize
|
||||
if icon_size is None:
|
||||
return QSize(0, 0)
|
||||
else:
|
||||
return icon_size
|
||||
|
||||
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
|
||||
left = self.icon_size(option).width() + self.HMARGIN
|
||||
metrics = QFontMetrics(self.get_font(option))
|
||||
first = metrics.size(0, self.first_row_text(index))
|
||||
second = metrics.size(0, self.second_row_text(index))
|
||||
text_width = max(first.width(), second.width())
|
||||
return QSize(
|
||||
left + text_width + 2 * self.HMARGIN,
|
||||
first.height() + second.height() + 2 * self.VMARGIN,
|
||||
)
|
||||
|
||||
|
||||
class PilotList(QListView):
|
||||
"""List view for displaying a squadron's pilots."""
|
||||
|
||||
def __init__(self, squadron_model: SquadronModel) -> None:
|
||||
super().__init__()
|
||||
self.squadron_model = squadron_model
|
||||
|
||||
self.setItemDelegate(PilotDelegate(self.squadron_model))
|
||||
self.setModel(self.squadron_model)
|
||||
self.selectionModel().setCurrentIndex(
|
||||
self.squadron_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
|
||||
)
|
||||
|
||||
# self.setIconSize(QSize(91, 24))
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectItems)
|
||||
|
||||
|
||||
class SquadronDialog(QDialog):
|
||||
"""Dialog window showing a squadron."""
|
||||
|
||||
def __init__(self, squadron_model: SquadronModel, parent) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self.setMinimumSize(1000, 440)
|
||||
self.setWindowTitle(squadron_model.squadron.name)
|
||||
# TODO: self.setWindowIcon()
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
layout.addWidget(PilotList(squadron_model))
|
||||
@ -175,7 +175,7 @@ class QFlightCreator(QDialog):
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.country,
|
||||
aircraft,
|
||||
self.game.air_wing_for(player=True).squadron_for(aircraft),
|
||||
size,
|
||||
task,
|
||||
self.start_type.currentText(),
|
||||
|
||||
@ -1,17 +1,75 @@
|
||||
import logging
|
||||
|
||||
from PySide2.QtCore import Signal
|
||||
from PySide2.QtWidgets import QLabel, QGroupBox, QSpinBox, QGridLayout
|
||||
from PySide2.QtCore import Signal, QModelIndex
|
||||
from PySide2.QtWidgets import QLabel, QGroupBox, QSpinBox, QGridLayout, QComboBox
|
||||
|
||||
from game import Game
|
||||
from gen.flights.flight import Flight
|
||||
from qt_ui.models import PackageModel
|
||||
|
||||
|
||||
class PilotSelector(QComboBox):
|
||||
available_pilots_changed = Signal()
|
||||
|
||||
def __init__(self, flight: Flight, idx: int) -> None:
|
||||
super().__init__()
|
||||
self.flight = flight
|
||||
self.pilot_index = idx
|
||||
|
||||
self.rebuild(initial_build=True)
|
||||
|
||||
def _do_rebuild(self) -> None:
|
||||
self.clear()
|
||||
if self.pilot_index >= self.flight.count:
|
||||
self.addItem("No aircraft", None)
|
||||
self.setDisabled(True)
|
||||
return
|
||||
|
||||
self.setEnabled(True)
|
||||
self.addItem("Unassigned", None)
|
||||
choices = list(self.flight.squadron.available_pilots)
|
||||
current_pilot = self.flight.pilots[self.pilot_index]
|
||||
if current_pilot is not None:
|
||||
choices.append(current_pilot)
|
||||
for pilot in sorted(choices, key=lambda p: p.name):
|
||||
self.addItem(pilot.name, pilot)
|
||||
if current_pilot is None:
|
||||
self.setCurrentText("Unassigned")
|
||||
return
|
||||
self.setCurrentText(current_pilot.name)
|
||||
self.currentIndexChanged.connect(self.replace_pilot)
|
||||
|
||||
def rebuild(self, initial_build: bool = False) -> None:
|
||||
current_selection = self.currentData()
|
||||
|
||||
# The contents of the selector depend on the selection of the other selectors
|
||||
# for the flight, so changing the selection of one causes each selector to
|
||||
# rebuild. A rebuild causes a selection change, so if we don't block signals
|
||||
# during a rebuild we'll never stop rebuilding. Block signals during the rebuild
|
||||
# and emit signals if anything actually changes afterwards.
|
||||
self.blockSignals(True)
|
||||
try:
|
||||
self._do_rebuild()
|
||||
finally:
|
||||
self.blockSignals(False)
|
||||
|
||||
new_selection = self.currentData()
|
||||
if not initial_build and current_selection != new_selection:
|
||||
self.currentIndexChanged.emit(self.currentIndex())
|
||||
self.currentTextChanged.emit(self.currentText())
|
||||
|
||||
def replace_pilot(self, index: QModelIndex) -> None:
|
||||
if self.itemText(index) == "No aircraft":
|
||||
# The roster resize is handled separately, so we have no pilots to remove.
|
||||
return
|
||||
pilot = self.itemData(index)
|
||||
if pilot == self.flight.pilots[self.pilot_index]:
|
||||
return
|
||||
self.flight.set_pilot(self.pilot_index, pilot)
|
||||
self.available_pilots_changed.emit()
|
||||
|
||||
|
||||
class QFlightSlotEditor(QGroupBox):
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, package_model: PackageModel, flight: Flight, game: Game):
|
||||
super().__init__("Slots")
|
||||
self.package_model = package_model
|
||||
@ -49,33 +107,49 @@ class QFlightSlotEditor(QGroupBox):
|
||||
layout.addWidget(self.client_count, 1, 0)
|
||||
layout.addWidget(self.client_count_spinner, 1, 1)
|
||||
|
||||
layout.addWidget(QLabel("Squadron:"), 2, 0)
|
||||
layout.addWidget(QLabel(self.flight.squadron.name), 2, 1)
|
||||
|
||||
layout.addWidget(QLabel("Assigned pilots:"), 3, 0)
|
||||
self.pilot_selectors = []
|
||||
for pilot_idx, row in enumerate(range(3, 7)):
|
||||
selector = PilotSelector(self.flight, pilot_idx)
|
||||
selector.available_pilots_changed.connect(self.reset_pilot_selectors)
|
||||
self.pilot_selectors.append(selector)
|
||||
layout.addWidget(selector, row, 1)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def reset_pilot_selectors(self) -> None:
|
||||
for selector in self.pilot_selectors:
|
||||
selector.rebuild()
|
||||
|
||||
def _changed_aircraft_count(self):
|
||||
self.game.aircraft_inventory.return_from_flight(self.flight)
|
||||
old_count = self.flight.count
|
||||
self.flight.count = int(self.aircraft_count_spinner.value())
|
||||
new_count = int(self.aircraft_count_spinner.value())
|
||||
try:
|
||||
self.game.aircraft_inventory.claim_for_flight(self.flight)
|
||||
except ValueError:
|
||||
# The UI should have prevented this, but if we ran out of aircraft
|
||||
# then roll back the inventory change.
|
||||
difference = self.flight.count - old_count
|
||||
difference = new_count - self.flight.count
|
||||
available = self.inventory.available(self.flight.unit_type)
|
||||
logging.error(
|
||||
f"Could not add {difference} additional aircraft to "
|
||||
f"{self.flight} because {self.flight.from_cp} has only "
|
||||
f"{self.flight} because {self.flight.departure} has only "
|
||||
f"{available} {self.flight.unit_type} remaining"
|
||||
)
|
||||
self.flight.count = old_count
|
||||
self.game.aircraft_inventory.claim_for_flight(self.flight)
|
||||
self.changed.emit()
|
||||
return
|
||||
|
||||
self.flight.resize(new_count)
|
||||
self._cap_client_count()
|
||||
self.reset_pilot_selectors()
|
||||
|
||||
def _changed_client_count(self):
|
||||
self.flight.client_count = int(self.client_count_spinner.value())
|
||||
self._cap_client_count()
|
||||
self.package_model.update_tot()
|
||||
self.changed.emit()
|
||||
|
||||
def _cap_client_count(self):
|
||||
if self.flight.client_count > self.flight.count:
|
||||
|
||||
@ -21,9 +21,6 @@ class QFlightWaypointList(QTableView):
|
||||
|
||||
header = self.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
|
||||
if len(self.flight.points) > 0:
|
||||
self.selectedPoint = self.flight.points[0]
|
||||
self.update_list()
|
||||
|
||||
self.selectionModel().setCurrentIndex(
|
||||
|
||||
@ -5,6 +5,7 @@ certifi==2020.12.5
|
||||
cfgv==3.2.0
|
||||
click==7.1.2
|
||||
distlib==0.3.1
|
||||
Faker==8.2.1
|
||||
filelock==3.0.12
|
||||
future==0.18.2
|
||||
identify==1.5.13
|
||||
@ -23,6 +24,7 @@ pyinstaller-hooks-contrib==2021.1
|
||||
pyparsing==2.4.7
|
||||
pyproj==3.0.1
|
||||
PySide2==5.15.2
|
||||
python-dateutil==2.8.1
|
||||
pywin32-ctypes==0.2.0
|
||||
PyYAML==5.4.1
|
||||
regex==2020.11.13
|
||||
@ -30,6 +32,7 @@ Shapely==1.7.1
|
||||
shiboken2==5.15.2
|
||||
six==1.15.0
|
||||
tabulate==0.8.7
|
||||
text-unidecode==1.3
|
||||
toml==0.10.2
|
||||
typed-ast==1.4.2
|
||||
typing-extensions==3.7.4.3
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"name": "Russia 2010",
|
||||
"authors": "Khopa",
|
||||
"description": "<p>Russian army in the early 2010s.</p>",
|
||||
"locales": ["ru_RU"],
|
||||
"aircrafts": [
|
||||
"MiG_29S",
|
||||
"MiG_31",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user