diff --git a/game/db.py b/game/db.py
index 809a43b1..c091a399 100644
--- a/game/db.py
+++ b/game/db.py
@@ -1442,11 +1442,11 @@ def unit_task(unit: UnitType) -> Optional[Task]:
return None
-def find_unittype(for_task: Task, country_name: str) -> List[Type[UnitType]]:
+def find_unittype(for_task: Type[MainTask], country_name: str) -> List[Type[UnitType]]:
return [x for x in UNIT_BY_TASK[for_task] if x in FACTIONS[country_name].units]
-MANPADS: List[VehicleType] = [
+MANPADS: List[Type[VehicleType]] = [
AirDefence.MANPADS_SA_18_Igla_Grouse,
AirDefence.MANPADS_SA_18_Igla_S_Grouse,
AirDefence.MANPADS_Stinger,
diff --git a/game/game.py b/game/game.py
index 22598504..9dcf5006 100644
--- a/game/game.py
+++ b/game/game.py
@@ -34,6 +34,7 @@ from .settings import Settings
from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import MissileSiteGroundObject
from .threatzones import ThreatZones
+from .transfers import PendingTransfers
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
@@ -121,6 +122,8 @@ class Game:
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
+ self._transfers = PendingTransfers()
+
self.sanitize_sides()
self.on_load()
@@ -151,6 +154,14 @@ class Game:
# Regenerate any state that was not persisted.
self.on_load()
+ @property
+ def transfers(self) -> PendingTransfers:
+ try:
+ return self._transfers
+ except AttributeError:
+ self._transfers = PendingTransfers()
+ return self._transfers
+
def generate_conditions(self) -> Conditions:
return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
@@ -264,6 +275,8 @@ class Game:
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
+ self.transfers.complete_transfers()
+
self.process_enemy_income()
self.process_player_income()
diff --git a/game/transfers.py b/game/transfers.py
new file mode 100644
index 00000000..cc2e7665
--- /dev/null
+++ b/game/transfers.py
@@ -0,0 +1,76 @@
+import logging
+from dataclasses import dataclass
+from typing import Dict, List, Type
+
+from dcs.unittype import VehicleType
+from game.theater import ControlPoint
+
+
+@dataclass
+class TransferOrder:
+ """The base type of all transfer orders.
+
+ A transfer order can transfer multiple units of multiple types.
+ """
+
+ #: The location the units are transferring from.
+ origin: ControlPoint
+
+ #: The location the units are transferring to.
+ destination: ControlPoint
+
+ #: True if the transfer order belongs to the player.
+ player: bool
+
+
+@dataclass
+class RoadTransferOrder(TransferOrder):
+ """A transfer order that moves units by road."""
+
+ #: The units being transferred.
+ units: Dict[Type[VehicleType], int]
+
+
+class PendingTransfers:
+ def __init__(self) -> None:
+ self.pending_transfers: List[RoadTransferOrder] = []
+
+ @property
+ def pending_transfer_count(self) -> int:
+ return len(self.pending_transfers)
+
+ def transfer_at_index(self, index: int) -> RoadTransferOrder:
+ return self.pending_transfers[index]
+
+ def new_transfer(self, transfer: RoadTransferOrder) -> None:
+ transfer.origin.base.commit_losses(transfer.units)
+ self.pending_transfers.append(transfer)
+
+ def cancel_transfer(self, transfer: RoadTransferOrder) -> None:
+ self.pending_transfers.remove(transfer)
+ transfer.origin.base.commision_units(transfer.units)
+
+ def complete_transfers(self) -> None:
+ for transfer in self.pending_transfers:
+ self.complete_transfer(transfer)
+ self.pending_transfers.clear()
+
+ @staticmethod
+ def complete_transfer(transfer: RoadTransferOrder) -> None:
+ if transfer.player == transfer.destination.captured:
+ logging.info(
+ f"Units transferred from {transfer.origin.name} to "
+ f"{transfer.destination.name}"
+ )
+ transfer.destination.base.commision_units(transfer.units)
+ elif transfer.player == transfer.origin.captured:
+ logging.info(
+ f"{transfer.destination.name} was captured. Transferring units are "
+ f"returning to {transfer.origin.name}"
+ )
+ transfer.origin.base.commision_units(transfer.units)
+ else:
+ logging.info(
+ f"Both {transfer.origin.name} and {transfer.destination.name} were "
+ "captured. Units were surrounded and captured during transfer."
+ )
diff --git a/qt_ui/delegate_helpers.py b/qt_ui/delegate_helpers.py
new file mode 100644
index 00000000..0c437310
--- /dev/null
+++ b/qt_ui/delegate_helpers.py
@@ -0,0 +1,13 @@
+from contextlib import contextmanager
+from typing import ContextManager
+
+from PySide2.QtGui import QPainter
+
+
+@contextmanager
+def painter_context(painter: QPainter) -> ContextManager[None]:
+ try:
+ painter.save()
+ yield
+ finally:
+ painter.restore()
diff --git a/qt_ui/models.py b/qt_ui/models.py
index cdc594d6..bd7d2bc8 100644
--- a/qt_ui/models.py
+++ b/qt_ui/models.py
@@ -1,4 +1,6 @@
"""Qt data models for game objects."""
+from __future__ import annotations
+
import datetime
from typing import Any, Callable, Dict, Iterator, Optional, TypeVar
@@ -12,11 +14,12 @@ from PySide2.QtGui import QIcon
from game import db
from game.game import Game
+from game.theater.missiontarget import MissionTarget
+from game.transfers import RoadTransferOrder
from gen.ato import AirTaskingOrder, Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.uiconstants import AIRCRAFT_ICONS
-from game.theater.missiontarget import MissionTarget
class DeletableChildModelManager:
@@ -285,6 +288,63 @@ class AtoModel(QAbstractListModel):
yield self.package_models.acquire(package)
+class TransferModel(QAbstractListModel):
+ """The model for a ground unit transfer."""
+
+ TransferRole = Qt.UserRole
+
+ def __init__(self, game_model: GameModel) -> None:
+ super().__init__()
+ self.game_model = game_model
+
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+ return self.game_model.game.transfers.pending_transfer_count
+
+ def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
+ if not index.isValid():
+ return None
+ transfer = self.transfer_at_index(index)
+ if role == Qt.DisplayRole:
+ return self.text_for_transfer(transfer)
+ if role == Qt.DecorationRole:
+ return self.icon_for_transfer(transfer)
+ elif role == TransferModel.TransferRole:
+ return transfer
+ return None
+
+ @staticmethod
+ def text_for_transfer(transfer: RoadTransferOrder) -> str:
+ """Returns the text that should be displayed for the transfer."""
+ count = sum(transfer.units.values())
+ origin = transfer.origin.name
+ destination = transfer.destination.name
+ return f"Transfer of {count} units from {origin} to {destination}"
+
+ @staticmethod
+ def icon_for_transfer(_transfer: RoadTransferOrder) -> Optional[QIcon]:
+ """Returns the icon that should be displayed for the transfer."""
+ return None
+
+ def new_transfer(self, transfer: RoadTransferOrder) -> None:
+ """Updates the game with the new unit transfer."""
+ self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
+ # TODO: Needs to regenerate base inventory tab.
+ self.game_model.game.transfers.new_transfer(transfer)
+ self.endInsertRows()
+
+ def cancel_transfer_at_index(self, index: QModelIndex) -> None:
+ """Cancels the planned unit transfer at the given index."""
+ transfer = self.transfer_at_index(index)
+ self.beginRemoveRows(QModelIndex(), index.row(), index.row())
+ # TODO: Needs to regenerate base inventory tab.
+ self.game_model.game.transfers.cancel_transfer(transfer)
+ self.endRemoveRows()
+
+ def transfer_at_index(self, index: QModelIndex) -> RoadTransferOrder:
+ """Returns the transfer located at the given index."""
+ return self.game_model.game.transfers.transfer_at_index(index.row())
+
+
class GameModel:
"""A model for the Game object.
@@ -294,6 +354,7 @@ class GameModel:
def __init__(self, game: Optional[Game]) -> None:
self.game: Optional[Game] = game
+ self.transfer_model = TransferModel(self)
if self.game is None:
self.ato_model = AtoModel(self.game, AirTaskingOrder())
self.red_ato_model = AtoModel(self.game, AirTaskingOrder())
diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py
index 68834d28..f43657f7 100644
--- a/qt_ui/widgets/QTopPanel.py
+++ b/qt_ui/widgets/QTopPanel.py
@@ -4,6 +4,7 @@ from datetime import timedelta
from typing import List, Optional
from PySide2.QtWidgets import (
+ QDialog,
QFrame,
QGroupBox,
QHBoxLayout,
@@ -22,6 +23,7 @@ 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.GameUpdateSignal import GameUpdateSignal
+from qt_ui.windows.PendingTransfersDialog import PendingTransfersDialog
from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow
from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow
from qt_ui.windows.stats.QStatsWindow import QStatsWindow
@@ -32,6 +34,8 @@ class QTopPanel(QFrame):
def __init__(self, game_model: GameModel):
super(QTopPanel, self).__init__()
self.game_model = game_model
+ self.dialog: Optional[QDialog] = None
+
self.setMaximumHeight(70)
self.init_ui()
GameUpdateSignal.get_instance().gameupdated.connect(self.setGame)
@@ -61,6 +65,11 @@ class QTopPanel(QFrame):
self.factionsInfos = QFactionsInfos(self.game)
+ self.transfers = QPushButton("Transfers")
+ self.transfers.setDisabled(True)
+ self.transfers.setProperty("style", "btn-primary")
+ self.transfers.clicked.connect(self.open_transfers)
+
self.settings = QPushButton("Settings")
self.settings.setDisabled(True)
self.settings.setIcon(CONST.ICONS["Settings"])
@@ -77,6 +86,7 @@ class QTopPanel(QFrame):
self.buttonBox = QGroupBox("Misc")
self.buttonBoxLayout = QHBoxLayout()
+ self.buttonBoxLayout.addWidget(self.transfers)
self.buttonBoxLayout.addWidget(self.settings)
self.buttonBoxLayout.addWidget(self.statistics)
self.buttonBox.setLayout(self.buttonBoxLayout)
@@ -106,6 +116,7 @@ class QTopPanel(QFrame):
if game is None:
return
+ self.transfers.setEnabled(True)
self.settings.setEnabled(True)
self.statistics.setEnabled(True)
@@ -121,13 +132,17 @@ class QTopPanel(QFrame):
else:
self.proceedButton.setEnabled(True)
+ def open_transfers(self):
+ self.dialog = PendingTransfersDialog(self.game_model)
+ self.dialog.show()
+
def openSettings(self):
- self.subwindow = QSettingsWindow(self.game)
- self.subwindow.show()
+ self.dialog = QSettingsWindow(self.game)
+ self.dialog.show()
def openStatisticsWindow(self):
- self.subwindow = QStatsWindow(self.game)
- self.subwindow.show()
+ self.dialog = QStatsWindow(self.game)
+ self.dialog.show()
def passTurn(self):
start = timeit.default_timer()
diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py
index fa5e7072..93b170bb 100644
--- a/qt_ui/widgets/ato.py
+++ b/qt_ui/widgets/ato.py
@@ -1,7 +1,6 @@
"""Widgets for displaying air tasking orders."""
import logging
-from contextlib import contextmanager
-from typing import ContextManager, Optional
+from typing import Optional
from PySide2.QtCore import (
QItemSelectionModel,
@@ -32,11 +31,11 @@ from PySide2.QtWidgets import (
QVBoxLayout,
)
-from game import db
from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
+from ..delegate_helpers import painter_context
from ..models import AtoModel, GameModel, NullListModel, PackageModel
@@ -312,15 +311,6 @@ class QFlightPanel(QGroupBox):
self.flight_list.delete_flight(index)
-@contextmanager
-def painter_context(painter: QPainter) -> ContextManager[None]:
- try:
- painter.save()
- yield
- finally:
- painter.restore()
-
-
class PackageDelegate(QStyledItemDelegate):
FONT_SIZE = 12
HMARGIN = 4
diff --git a/qt_ui/windows/PendingTransfersDialog.py b/qt_ui/windows/PendingTransfersDialog.py
new file mode 100644
index 00000000..6892debf
--- /dev/null
+++ b/qt_ui/windows/PendingTransfersDialog.py
@@ -0,0 +1,189 @@
+from typing import Optional
+
+from PySide2.QtCore import (
+ QItemSelection,
+ QItemSelectionModel,
+ QModelIndex,
+ QSize,
+ Qt,
+)
+from PySide2.QtGui import QContextMenuEvent, QFont, QFontMetrics, QIcon, QPainter
+from PySide2.QtWidgets import (
+ QAbstractItemView,
+ QAction,
+ QDialog,
+ QHBoxLayout,
+ QListView,
+ QMenu,
+ QPushButton,
+ QStyle,
+ QStyleOptionViewItem,
+ QStyledItemDelegate,
+ QVBoxLayout,
+)
+
+from game.transfers import RoadTransferOrder
+from qt_ui.delegate_helpers import painter_context
+from qt_ui.models import GameModel, TransferModel
+
+
+class TransferDelegate(QStyledItemDelegate):
+ FONT_SIZE = 10
+ HMARGIN = 4
+ VMARGIN = 4
+
+ def __init__(self, transfer_model: TransferModel) -> None:
+ super().__init__()
+ self.transfer_model = transfer_model
+
+ def get_font(self, option: QStyleOptionViewItem) -> QFont:
+ font = QFont(option.font)
+ font.setPointSize(self.FONT_SIZE)
+ return font
+
+ @staticmethod
+ def transfer(index: QModelIndex) -> RoadTransferOrder:
+ return index.data(TransferModel.TransferRole)
+
+ def first_row_text(self, index: QModelIndex) -> str:
+ return self.transfer_model.data(index, Qt.DisplayRole)
+
+ def second_row_text(self, index: QModelIndex) -> str:
+ transfer = self.transfer(index)
+ return f"Currently at {transfer.origin}. Arrives at destination in 1 turn."
+
+ 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 PendingTransfersList(QListView):
+ """List view for displaying the pending unit transfers."""
+
+ def __init__(self, transfer_model: TransferModel) -> None:
+ super().__init__()
+ self.transfer_model = transfer_model
+
+ self.setItemDelegate(TransferDelegate(self.transfer_model))
+ self.setModel(self.transfer_model)
+ self.selectionModel().setCurrentIndex(
+ self.transfer_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
+ )
+
+ # self.setIconSize(QSize(91, 24))
+ self.setSelectionBehavior(QAbstractItemView.SelectItems)
+
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
+ index = self.indexAt(event.pos())
+
+ menu = QMenu("Menu")
+
+ delete_action = QAction("Cancel")
+ delete_action.triggered.connect(lambda: self.cancel_transfer(index))
+ menu.addAction(delete_action)
+
+ menu.exec_(event.globalPos())
+
+ def cancel_transfer(self, index: QModelIndex) -> None:
+ """Cancels the given transfer order."""
+ self.transfer_model.cancel_transfer_at_index(index)
+
+
+class PendingTransfersDialog(QDialog):
+ """Dialog window showing all scheduled transfers for the player."""
+
+ def __init__(self, game_model: GameModel, parent=None) -> None:
+ super().__init__(parent)
+ self.transfer_model = game_model.transfer_model
+
+ self.setMinimumSize(1000, 440)
+ self.setWindowTitle(f"Pending Transfers")
+ # TODO: self.setWindowIcon()
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ self.transfer_list = PendingTransfersList(self.transfer_model)
+ self.transfer_list.selectionModel().selectionChanged.connect(
+ self.on_selection_changed
+ )
+ layout.addWidget(self.transfer_list)
+
+ button_layout = QHBoxLayout()
+ layout.addLayout(button_layout)
+
+ button_layout.addStretch()
+
+ self.cancel_button = QPushButton("Cancel Transfer")
+ self.cancel_button.setProperty("style", "btn-danger")
+ self.cancel_button.clicked.connect(self.on_cancel_transfer)
+ self.cancel_button.setEnabled(self.transfer_model.rowCount() > 0)
+ button_layout.addWidget(self.cancel_button)
+
+ def on_cancel_transfer(self) -> None:
+ """Cancels the selected transfer order."""
+ self.transfer_model.cancel_transfer_at_index(self.transfer_list.currentIndex())
+
+ def on_selection_changed(
+ self, selected: QItemSelection, _deselected: QItemSelection
+ ) -> None:
+ """Updates the state of the delete button."""
+ self.cancel_button.setEnabled(not selected.empty())
diff --git a/qt_ui/windows/basemenu/NewUnitTransferDialog.py b/qt_ui/windows/basemenu/NewUnitTransferDialog.py
new file mode 100644
index 00000000..4df54d31
--- /dev/null
+++ b/qt_ui/windows/basemenu/NewUnitTransferDialog.py
@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+import logging
+from collections import defaultdict
+from typing import Callable, Dict, Type
+
+from PySide2.QtCore import Qt
+from PySide2.QtWidgets import (
+ QComboBox,
+ QDialog,
+ QFrame,
+ QGridLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QSpacerItem,
+ QVBoxLayout,
+ QWidget,
+)
+from dcs.task import PinpointStrike
+from dcs.unittype import UnitType
+
+from game import db
+from game.theater import ControlPoint
+from game.transfers import RoadTransferOrder
+from qt_ui.models import GameModel
+from qt_ui.widgets.QLabeledWidget import QLabeledWidget
+
+
+class TransferDestinationComboBox(QComboBox):
+ def __init__(self, game_model: GameModel, origin: ControlPoint) -> None:
+ super().__init__()
+
+ for cp in game_model.game.theater.controlpoints:
+ if cp != origin and cp.captured and not cp.is_global:
+ self.addItem(cp.name, cp)
+ self.model().sort(0)
+ self.setCurrentIndex(0)
+
+
+class UnitTransferList(QFrame):
+ def __init__(self, cp: ControlPoint, game_model: GameModel):
+ super().__init__(self)
+ self.cp = cp
+ self.game_model = game_model
+
+ self.bought_amount_labels = {}
+ self.existing_units_labels = {}
+
+ main_layout = QVBoxLayout()
+ self.setLayout(main_layout)
+
+ scroll_content = QWidget()
+ task_box_layout = QGridLayout()
+ scroll_content.setLayout(task_box_layout)
+
+ units_column = sorted(
+ cp.base.armor,
+ key=lambda u: db.unit_get_expanded_info(
+ self.game_model.game.player_country, u, "name"
+ ),
+ )
+
+ count = 0
+ for count, unit_type in enumerate(units_column):
+ self.add_purchase_row(unit_type, task_box_layout, count)
+ stretch = QVBoxLayout()
+ stretch.addStretch()
+ task_box_layout.addLayout(stretch, count, 0)
+
+ scroll_content.setLayout(task_box_layout)
+ scroll = QScrollArea()
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(scroll_content)
+ main_layout.addWidget(scroll)
+
+
+class TransferDestinationPanel(QVBoxLayout):
+ def __init__(self, label: str, origin: ControlPoint, game_model: GameModel) -> None:
+ super().__init__()
+
+ self.source_combo_box = TransferDestinationComboBox(game_model, origin)
+ self.addLayout(QLabeledWidget(label, self.source_combo_box))
+
+ @property
+ def changed(self):
+ return self.source_combo_box.currentIndexChanged
+
+ @property
+ def current(self) -> ControlPoint:
+ return self.source_combo_box.currentData()
+
+
+class TransferControls(QGroupBox):
+ def __init__(
+ self,
+ increase_text: str,
+ on_increase: Callable[[TransferControls], None],
+ decrease_text: str,
+ on_decrease: Callable[[TransferControls], None],
+ initial_amount: int = 0,
+ disabled: bool = False,
+ ) -> None:
+ super().__init__()
+
+ self.quantity = initial_amount
+
+ self.setProperty("style", "buy-box")
+ self.setMaximumHeight(36)
+ self.setMinimumHeight(36)
+ layout = QHBoxLayout()
+ self.setLayout(layout)
+
+ decrease = QPushButton(decrease_text)
+ decrease.setProperty("style", "btn-sell")
+ decrease.setDisabled(disabled)
+ decrease.setMinimumSize(16, 16)
+ decrease.setMaximumSize(16, 16)
+ decrease.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
+ decrease.clicked.connect(lambda: on_decrease(self))
+ layout.addWidget(decrease)
+
+ self.count_label = QLabel()
+ self.count_label.setSizePolicy(
+ QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ )
+ self.set_quantity(initial_amount)
+ layout.addWidget(self.count_label)
+
+ increase = QPushButton(increase_text)
+ increase.setProperty("style", "btn-buy")
+ increase.setDisabled(disabled)
+ increase.setMinimumSize(16, 16)
+ increase.setMaximumSize(16, 16)
+ increase.clicked.connect(lambda: on_increase(self))
+ increase.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
+ layout.addWidget(increase)
+
+ def set_quantity(self, quantity: int) -> None:
+ self.quantity = quantity
+ self.count_label.setText(f"{self.quantity}")
+
+
+class ScrollingUnitTransferGrid(QFrame):
+ def __init__(self, cp: ControlPoint, game_model: GameModel) -> None:
+ super().__init__()
+ self.cp = cp
+ self.game_model = game_model
+ self.transfers: Dict[Type[UnitType, int]] = defaultdict(int)
+
+ main_layout = QVBoxLayout()
+
+ scroll_content = QWidget()
+ task_box_layout = QGridLayout()
+
+ unit_types = set(
+ db.find_unittype(PinpointStrike, self.game_model.game.player_name)
+ )
+ sorted_units = sorted(
+ {u for u in unit_types if self.cp.base.total_units_of_type(u)},
+ key=lambda u: db.unit_get_expanded_info(
+ self.game_model.game.player_country, u, "name"
+ ),
+ )
+ for row, unit_type in enumerate(sorted_units):
+ self.add_unit_row(unit_type, task_box_layout, row)
+ stretch = QVBoxLayout()
+ stretch.addStretch()
+ task_box_layout.addLayout(stretch, task_box_layout.count(), 0)
+
+ scroll_content.setLayout(task_box_layout)
+ scroll = QScrollArea()
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(scroll_content)
+ main_layout.addWidget(scroll)
+ self.setLayout(main_layout)
+
+ def add_unit_row(
+ self,
+ unit_type: Type[UnitType],
+ layout: QGridLayout,
+ row: int,
+ ) -> None:
+ exist = QGroupBox()
+ exist.setProperty("style", "buy-box")
+ exist.setMaximumHeight(36)
+ exist.setMinimumHeight(36)
+ origin_inventory_layout = QHBoxLayout()
+ exist.setLayout(origin_inventory_layout)
+
+ origin_inventory = self.cp.base.total_units_of_type(unit_type)
+
+ unit_name = QLabel(
+ ""
+ + db.unit_get_expanded_info(
+ self.game_model.game.player_country, unit_type, "name"
+ )
+ + ""
+ )
+ unit_name.setSizePolicy(
+ QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ )
+
+ origin_inventory_label = QLabel(str(origin_inventory))
+ origin_inventory_label.setSizePolicy(
+ QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ )
+
+ def increase(controls: TransferControls):
+ nonlocal origin_inventory
+ nonlocal origin_inventory_label
+ if not origin_inventory:
+ return
+
+ self.transfers[unit_type] += 1
+ origin_inventory -= 1
+ controls.set_quantity(self.transfers[unit_type])
+ origin_inventory_label.setText(str(origin_inventory))
+
+ def decrease(controls: TransferControls):
+ nonlocal origin_inventory
+ nonlocal origin_inventory_label
+ if not controls.quantity:
+ return
+
+ self.transfers[unit_type] -= 1
+ origin_inventory += 1
+ controls.set_quantity(self.transfers[unit_type])
+ origin_inventory_label.setText(str(origin_inventory))
+
+ transfer_controls = TransferControls("->", increase, "<-", decrease)
+
+ origin_inventory_layout.addWidget(unit_name)
+ origin_inventory_layout.addItem(
+ QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum)
+ )
+ origin_inventory_layout.addWidget(origin_inventory_label)
+ origin_inventory_layout.addItem(
+ QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum)
+ )
+
+ layout.addWidget(exist, row, 1)
+ layout.addWidget(transfer_controls, row, 2)
+
+
+class NewUnitTransferDialog(QDialog):
+ def __init__(
+ self,
+ game_model: GameModel,
+ origin: ControlPoint,
+ parent=None,
+ ) -> None:
+ super().__init__(parent)
+ self.origin = origin
+ self.setWindowTitle(f"New unit transfer from {origin.name}")
+
+ self.game_model = game_model
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ self.dest_panel = TransferDestinationPanel("Destination:", origin, game_model)
+ self.dest_panel.changed.connect(self.on_destination_changed)
+ layout.addLayout(self.dest_panel)
+
+ self.transfer_panel = ScrollingUnitTransferGrid(origin, game_model)
+ layout.addWidget(self.transfer_panel)
+
+ self.submit_button = QPushButton("Create Transfer Order", parent=self)
+ self.submit_button.clicked.connect(self.on_submit)
+ self.submit_button.setProperty("style", "start-button")
+ layout.addWidget(self.submit_button)
+
+ def on_destination_changed(self, index: int) -> None:
+ # Rebuild the transfer panel to reset everything. It's easier to recreate the
+ # panel itself than to clear the grid layout in the panel.
+ self.layout().removeWidget(self.transfer_panel)
+ self.layout().removeWidget(self.submit_button)
+ self.transfer_panel = ScrollingUnitTransferGrid(self.origin, self.game_model)
+ self.layout().addWidget(self.transfer_panel)
+ self.layout().addWidget(self.submit_button)
+
+ def on_submit(self) -> None:
+ transfers = {}
+ for unit_type, count in self.transfer_panel.transfers.items():
+ if not count:
+ continue
+
+ logging.info(
+ f"Transferring {count} {unit_type.id} from "
+ f"{self.transfer_panel.cp.name} to {self.dest_panel.current.name}"
+ )
+ transfers[unit_type] = count
+
+ self.game_model.transfer_model.new_transfer(
+ RoadTransferOrder(
+ player=True,
+ origin=self.transfer_panel.cp,
+ destination=self.dest_panel.current,
+ units=transfers,
+ )
+ )
+ self.close()
diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py
index a4793b97..ec6f03dc 100644
--- a/qt_ui/windows/basemenu/QBaseMenu2.py
+++ b/qt_ui/windows/basemenu/QBaseMenu2.py
@@ -19,6 +19,7 @@ from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour
+from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog
class QBaseMenu2(QDialog):
@@ -88,6 +89,11 @@ class QBaseMenu2(QDialog):
runway_attack_button.setProperty("style", "btn-danger")
runway_attack_button.clicked.connect(self.new_package)
+ if self.cp.captured and not self.cp.is_global:
+ transfer_button = QPushButton("Transfer Units")
+ bottom_row.addWidget(transfer_button)
+ transfer_button.clicked.connect(self.open_transfer_dialog)
+
self.budget_display = QLabel(
QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget)
)
@@ -180,5 +186,8 @@ class QBaseMenu2(QDialog):
def new_package(self) -> None:
Dialog.open_new_package_dialog(self.cp, parent=self.window())
+ def open_transfer_dialog(self) -> None:
+ NewUnitTransferDialog(self.game_model, self.cp, parent=self.window()).show()
+
def update_budget(self, game: Game) -> None:
self.budget_display.setText(QRecruitBehaviour.BUDGET_FORMAT.format(game.budget))
diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py
index 97ef9b2a..1e429895 100644
--- a/qt_ui/windows/basemenu/QRecruitBehaviour.py
+++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py
@@ -1,17 +1,22 @@
import logging
-from typing import Type
+from typing import Callable, Set, Type
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
+ QFrame,
+ QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLayout,
QPushButton,
+ QScrollArea,
QSizePolicy,
QSpacerItem,
+ QVBoxLayout,
+ QWidget,
)
-from dcs.unittype import UnitType
+from dcs.unittype import FlyingType, UnitType
from game import db
from game.event import UnitsDeliveryEvent
@@ -27,13 +32,11 @@ class QRecruitBehaviour:
existing_units_labels = None
bought_amount_labels = None
maximum_units = -1
- recruitable_types = []
BUDGET_FORMAT = "Available Budget: ${:.2f}M"
def __init__(self) -> None:
self.bought_amount_labels = {}
self.existing_units_labels = {}
- self.recruitable_types = []
self.update_available_budget()
@property
@@ -195,9 +198,3 @@ class QRecruitBehaviour:
Set the maximum number of units that can be bought
"""
self.maximum_units = maximum_units
-
- def set_recruitable_types(self, recruitables_types):
- """
- Set the maximum number of units that can be bought
- """
- self.recruitables_types = recruitables_types
diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
index 53f6cf69..970e1c6c 100644
--- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
+++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
@@ -34,7 +34,6 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
# Determine maximum number of aircrafts that can be bought
self.set_maximum_units(self.cp.total_aircraft_parking)
- self.set_recruitable_types([CAP, CAS])
self.bought_amount_labels = {}
self.existing_units_labels = {}