Basic implementation of road based transfers.

This adds the models and UIs for creating ground unit transfer orders.
Most of the feature is still missing:

* The AI doesn't do them.
* Transfers can move across the whole map in one turn.
* Transfers between disconnected bases are allowed.
* Transfers are not modeled in the simulation, so they can't be
  interdicted.

https://github.com/Khopa/dcs_liberation/issues/824
This commit is contained in:
Dan Albert
2021-02-15 12:49:36 -08:00
parent b65d178cf1
commit e9ff554f39
12 changed files with 702 additions and 30 deletions

View File

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

View File

@@ -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"<b>{self.quantity}</b>")
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(
"<b>"
+ db.unit_get_expanded_info(
self.game_model.game.player_country, unit_type, "name"
)
+ "</b>"
)
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()

View File

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

View File

@@ -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: <b>${:.2f}M</b>"
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

View File

@@ -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 = {}