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:
Dan Albert 2021-05-25 00:12:14 -07:00
parent 6b30f47588
commit 4147d2f684
22 changed files with 728 additions and 41 deletions

View File

@ -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.

View File

@ -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]

View File

@ -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()]:

View File

@ -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
View 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())

View File

@ -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,

View File

@ -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",

View File

@ -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,
)

View File

@ -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:

View File

@ -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(

View File

@ -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

View File

@ -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())

View File

@ -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()

View File

@ -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",

View 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))

View File

@ -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

View 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))

View File

@ -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(),

View File

@ -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:

View File

@ -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(

View File

@ -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

View File

@ -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",