diff --git a/game/settings.py b/game/settings.py deleted file mode 100644 index 3bb557bf..00000000 --- a/game/settings.py +++ /dev/null @@ -1,119 +0,0 @@ -from dataclasses import dataclass, field -from datetime import timedelta -from enum import Enum, unique -from typing import Dict, Optional, Any - -from dcs.forcedoptions import ForcedOptions - - -@unique -class AutoAtoBehavior(Enum): - Disabled = "Disabled" - Never = "Never assign player pilots" - Default = "No preference" - Prefer = "Prefer player pilots" - - -@dataclass -class Settings: - version: Optional[str] = None - - # Difficulty settings - # AI Difficulty - player_skill: str = "Good" - enemy_skill: str = "Average" - enemy_vehicle_skill: str = "Average" - player_income_multiplier: float = 1.0 - enemy_income_multiplier: float = 1.0 - invulnerable_player_pilots: bool = True - # Mission Difficulty - night_disabled: bool = False - manpads: bool = True - # Mission Restrictions - labels: str = "Full" - map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All - external_views_allowed: bool = True - battle_damage_assessment: Optional[bool] = None - - # Campaign management - # General - restrict_weapons_by_date: bool = False - disable_legacy_aewc: bool = True - disable_legacy_tanker: bool = True - # Pilots and Squadrons - ai_pilot_levelling: bool = True - #: Feature flag for squadron limits. - enable_squadron_pilot_limits: bool = False - #: The maximum number of pilots a squadron can have at one time. Changing this after - #: the campaign has started will have no immediate effect; pilots already in the - #: squadron will not be removed if the limit is lowered and pilots will not be - #: immediately created if the limit is raised. - squadron_pilot_limit: int = 12 - #: The number of pilots a squadron can replace per turn. - squadron_replenishment_rate: int = 4 - # HQ Automation - automate_runway_repair: bool = False - automate_front_line_reinforcements: bool = False - automate_aircraft_reinforcements: bool = False - auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default - auto_ato_player_missions_asap: bool = True - automate_front_line_stance: bool = True - reserves_procurement_target: int = 10 - - # Mission Generator - # Gameplay - supercarrier: bool = False - generate_marks: bool = True - generate_dark_kneeboard: bool = False - never_delay_player_flights: bool = False - default_start_type: str = "Cold" - # Mission specific - desired_player_mission_duration: timedelta = timedelta(minutes=60) - # Performance - perf_smoke_gen: bool = True - perf_smoke_spacing = 1600 - perf_red_alert_state: bool = True - perf_artillery: bool = True - perf_moving_units: bool = True - perf_infantry: bool = True - perf_destroyed_units: bool = True - # Performance culling - perf_culling: bool = False - perf_culling_distance: int = 100 - perf_do_not_cull_carrier = True - - # Cheating - show_red_ato: bool = False - enable_frontline_cheats: bool = False - enable_base_capture_cheat: bool = False - - # LUA Plugins system - plugins: Dict[str, bool] = field(default_factory=dict) - - only_player_takeoff: bool = True # Legacy parameter do not use - - @staticmethod - def plugin_settings_key(identifier: str) -> str: - return f"plugins.{identifier}" - - def initialize_plugin_option(self, identifier: str, default_value: bool) -> None: - try: - self.plugin_option(identifier) - except KeyError: - self.set_plugin_option(identifier, default_value) - - def plugin_option(self, identifier: str) -> bool: - return self.plugins[self.plugin_settings_key(identifier)] - - def set_plugin_option(self, identifier: str, enabled: bool) -> None: - self.plugins[self.plugin_settings_key(identifier)] = enabled - - def __setstate__(self, state: dict[str, Any]) -> None: - # __setstate__ is called with the dict of the object being unpickled. We - # can provide save compatibility for new settings options (which - # normally would not be present in the unpickled object) by creating a - # new settings object, updating it with the unpickled state, and - # updating our dict with that. - new_state = Settings().__dict__ - new_state.update(state) - self.__dict__.update(new_state) diff --git a/game/settings/__init__.py b/game/settings/__init__.py new file mode 100644 index 00000000..f0e7c7c5 --- /dev/null +++ b/game/settings/__init__.py @@ -0,0 +1,5 @@ +from .booleanoption import BooleanOption +from .boundedfloatoption import BoundedFloatOption +from .choicesoption import ChoicesOption +from .optiondescription import OptionDescription +from .settings import AutoAtoBehavior, Settings diff --git a/game/settings/booleanoption.py b/game/settings/booleanoption.py new file mode 100644 index 00000000..9fdf6589 --- /dev/null +++ b/game/settings/booleanoption.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + +from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY + + +@dataclass(frozen=True) +class BooleanOption(OptionDescription): + pass + + +def boolean_option( + text: str, + page: str, + section: str, + detail: Optional[str] = None, + **kwargs: Any, +) -> bool: + return field( + metadata={SETTING_DESCRIPTION_KEY: BooleanOption(page, section, text, detail)}, + **kwargs, + ) diff --git a/game/settings/boundedfloatoption.py b/game/settings/boundedfloatoption.py new file mode 100644 index 00000000..0e6e6ee2 --- /dev/null +++ b/game/settings/boundedfloatoption.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + +from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY + + +@dataclass(frozen=True) +class BoundedFloatOption(OptionDescription): + min: float + max: float + divisor: int + + +def bounded_float_option( + text: str, + page: str, + section: str, + min: float, + max: float, + divisor: int, + detail: Optional[str] = None, + **kwargs: Any, +) -> float: + return field( + metadata={ + SETTING_DESCRIPTION_KEY: BoundedFloatOption( + page, section, text, detail, min, max, divisor + ) + }, + **kwargs, + ) diff --git a/game/settings/choicesoption.py b/game/settings/choicesoption.py new file mode 100644 index 00000000..9e6f590a --- /dev/null +++ b/game/settings/choicesoption.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field +from typing import Any, Generic, Iterable, Mapping, Optional, TypeVar, Union + +from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY + +ValueT = TypeVar("ValueT") + + +@dataclass(frozen=True) +class ChoicesOption(OptionDescription, Generic[ValueT]): + choices: dict[str, ValueT] + + def text_for_value(self, value: ValueT) -> str: + for text, _value in self.choices.items(): + if value == _value: + return text + raise ValueError(f"{self} does not contain {value}") + + +def choices_option( + text: str, + page: str, + section: str, + choices: Union[Iterable[str], Mapping[str, ValueT]], + detail: Optional[str] = None, + **kwargs: Any, +) -> ValueT: + if not isinstance(choices, Mapping): + choices = {c: c for c in choices} + return field( + metadata={ + SETTING_DESCRIPTION_KEY: ChoicesOption( + page, + section, + text, + detail, + dict(choices), + ) + }, + **kwargs, + ) diff --git a/game/settings/optiondescription.py b/game/settings/optiondescription.py new file mode 100644 index 00000000..edd3a78b --- /dev/null +++ b/game/settings/optiondescription.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from typing import Optional + + +SETTING_DESCRIPTION_KEY = "DCS_LIBERATION_SETTING_DESCRIPTION_KEY" + + +@dataclass(frozen=True) +class OptionDescription: + page: str + section: str + text: str + detail: Optional[str] diff --git a/game/settings/settings.py b/game/settings/settings.py new file mode 100644 index 00000000..794ed58e --- /dev/null +++ b/game/settings/settings.py @@ -0,0 +1,247 @@ +from collections import Iterator +from dataclasses import Field, dataclass, field, fields +from datetime import timedelta +from enum import Enum, unique +from typing import Any, Dict, Optional + +from dcs.forcedoptions import ForcedOptions + +from .booleanoption import boolean_option +from .boundedfloatoption import bounded_float_option +from .choicesoption import choices_option +from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY +from .skilloption import skill_option + + +@unique +class AutoAtoBehavior(Enum): + Disabled = "Disabled" + Never = "Never assign player pilots" + Default = "No preference" + Prefer = "Prefer player pilots" + + +DIFFICULTY_PAGE = "Difficulty" + +AI_DIFFICULTY_SECTION = "AI Difficulty" +MISSION_DIFFICULTY_SECTION = "Mission Difficulty" +MISSION_RESTRICTIONS_SECTION = "Mission Restrictions" + + +@dataclass +class Settings: + version: Optional[str] = None + + # Difficulty settings + # AI Difficulty + player_skill: str = skill_option( + "Player coalition skill", + page=DIFFICULTY_PAGE, + section=AI_DIFFICULTY_SECTION, + default="Good", + ) + enemy_skill: str = skill_option( + "Enemy coalition skill", + page=DIFFICULTY_PAGE, + section=AI_DIFFICULTY_SECTION, + default="Average", + ) + enemy_vehicle_skill: str = skill_option( + "Enemy AA and vehicles skill", + page=DIFFICULTY_PAGE, + section=AI_DIFFICULTY_SECTION, + default="Average", + ) + player_income_multiplier: float = bounded_float_option( + "Player income multiplier", + page=DIFFICULTY_PAGE, + section=AI_DIFFICULTY_SECTION, + min=0, + max=5, + divisor=10, + default=1.0, + ) + enemy_income_multiplier: float = bounded_float_option( + "Enemy income multiplier", + page=DIFFICULTY_PAGE, + section=AI_DIFFICULTY_SECTION, + min=0, + max=5, + divisor=10, + default=1.0, + ) + invulnerable_player_pilots: bool = boolean_option( + "Player pilots cannot be killed", + page=DIFFICULTY_PAGE, + section=AI_DIFFICULTY_SECTION, + detail=( + "Aircraft are vulnerable, but the player's pilot will be returned to the " + "squadron at the end of the mission" + ), + default=True, + ) + # Mission Difficulty + manpads: bool = boolean_option( + "Manpads on frontlines", + page=DIFFICULTY_PAGE, + section=MISSION_DIFFICULTY_SECTION, + default=True, + ) + night_disabled: bool = boolean_option( + "No night missions", + page=DIFFICULTY_PAGE, + section=MISSION_DIFFICULTY_SECTION, + default=False, + ) + # Mission Restrictions + labels: str = choices_option( + "In game labels", + page=DIFFICULTY_PAGE, + section=MISSION_RESTRICTIONS_SECTION, + choices=["Full", "Abbreviated", "Dot Only", "Neutral Dot", "Off"], + default="Full", + ) + map_coalition_visibility: ForcedOptions.Views = choices_option( + "Map visibility options", + page=DIFFICULTY_PAGE, + section=MISSION_RESTRICTIONS_SECTION, + choices={ + "All": ForcedOptions.Views.All, + "Fog of war": ForcedOptions.Views.Allies, + "Allies only": ForcedOptions.Views.OnlyAllies, + "Own aircraft only": ForcedOptions.Views.MyAircraft, + "Map only": ForcedOptions.Views.OnlyMap, + }, + default=ForcedOptions.Views.All, + ) + external_views_allowed: bool = boolean_option( + "Allow external views", + DIFFICULTY_PAGE, + MISSION_RESTRICTIONS_SECTION, + default=True, + ) + battle_damage_assessment: Optional[bool] = choices_option( + "Battle damage assessment", + page=DIFFICULTY_PAGE, + section=MISSION_RESTRICTIONS_SECTION, + choices={"Player preference": None, "Enforced on": True, "Enforced off": False}, + default=None, + ) + + # Campaign management + # General + restrict_weapons_by_date: bool = False + disable_legacy_aewc: bool = True + disable_legacy_tanker: bool = True + # Pilots and Squadrons + ai_pilot_levelling: bool = True + #: Feature flag for squadron limits. + enable_squadron_pilot_limits: bool = False + #: The maximum number of pilots a squadron can have at one time. Changing this after + #: the campaign has started will have no immediate effect; pilots already in the + #: squadron will not be removed if the limit is lowered and pilots will not be + #: immediately created if the limit is raised. + squadron_pilot_limit: int = 12 + #: The number of pilots a squadron can replace per turn. + squadron_replenishment_rate: int = 4 + # HQ Automation + automate_runway_repair: bool = False + automate_front_line_reinforcements: bool = False + automate_aircraft_reinforcements: bool = False + auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default + auto_ato_player_missions_asap: bool = True + automate_front_line_stance: bool = True + reserves_procurement_target: int = 10 + + # Mission Generator + # Gameplay + supercarrier: bool = False + generate_marks: bool = True + generate_dark_kneeboard: bool = False + never_delay_player_flights: bool = False + default_start_type: str = "Cold" + # Mission specific + desired_player_mission_duration: timedelta = timedelta(minutes=60) + # Performance + perf_smoke_gen: bool = True + perf_smoke_spacing = 1600 + perf_red_alert_state: bool = True + perf_artillery: bool = True + perf_moving_units: bool = True + perf_infantry: bool = True + perf_destroyed_units: bool = True + # Performance culling + perf_culling: bool = False + perf_culling_distance: int = 100 + perf_do_not_cull_carrier = True + + # Cheating + show_red_ato: bool = False + enable_frontline_cheats: bool = False + enable_base_capture_cheat: bool = False + + # LUA Plugins system + plugins: Dict[str, bool] = field(default_factory=dict) + + only_player_takeoff: bool = True # Legacy parameter do not use + + @staticmethod + def plugin_settings_key(identifier: str) -> str: + return f"plugins.{identifier}" + + def initialize_plugin_option(self, identifier: str, default_value: bool) -> None: + try: + self.plugin_option(identifier) + except KeyError: + self.set_plugin_option(identifier, default_value) + + def plugin_option(self, identifier: str) -> bool: + return self.plugins[self.plugin_settings_key(identifier)] + + def set_plugin_option(self, identifier: str, enabled: bool) -> None: + self.plugins[self.plugin_settings_key(identifier)] = enabled + + def __setstate__(self, state: dict[str, Any]) -> None: + # __setstate__ is called with the dict of the object being unpickled. We + # can provide save compatibility for new settings options (which + # normally would not be present in the unpickled object) by creating a + # new settings object, updating it with the unpickled state, and + # updating our dict with that. + new_state = Settings().__dict__ + new_state.update(state) + self.__dict__.update(new_state) + + @classmethod + def _field_description(cls, settings_field: Field[Any]) -> OptionDescription: + return settings_field.metadata[SETTING_DESCRIPTION_KEY] + + @classmethod + def pages(cls) -> Iterator[str]: + seen: set[str] = set() + for settings_field in cls._user_fields(): + description = cls._field_description(settings_field) + if description.page not in seen: + yield description.page + seen.add(description.page) + + @classmethod + def sections(cls, page: str) -> Iterator[str]: + seen: set[str] = set() + for settings_field in cls._user_fields(): + description = cls._field_description(settings_field) + if description.page == page and description.section not in seen: + yield description.section + seen.add(description.section) + + @classmethod + def fields(cls, page: str, section: str) -> Iterator[tuple[str, OptionDescription]]: + for settings_field in cls._user_fields(): + description = cls._field_description(settings_field) + if description.page == page and description.section == section: + yield settings_field.name, description + + @classmethod + def _user_fields(cls) -> Iterator[Field[Any]]: + for settings_field in fields(cls): + if SETTING_DESCRIPTION_KEY in settings_field.metadata: + yield settings_field diff --git a/game/settings/skilloption.py b/game/settings/skilloption.py new file mode 100644 index 00000000..f13473dc --- /dev/null +++ b/game/settings/skilloption.py @@ -0,0 +1,20 @@ +from typing import Any, Optional + +from .choicesoption import choices_option + + +def skill_option( + text: str, + page: str, + section: str, + detail: Optional[str] = None, + **kwargs: Any, +) -> str: + return choices_option( + text, + page, + section, + ["Average", "Good", "High", "Excellent"], + detail=detail, + **kwargs, + ) diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index 761cbc19..f250216c 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -100,6 +100,7 @@ def load_icons(): ICONS["Missile"] = QPixmap( "./resources/ui/misc/" + get_theme_icons() + "/missile.png" ) + ICONS["Difficulty"] = ICONS["Missile"] ICONS["Cheat"] = QPixmap("./resources/ui/misc/" + get_theme_icons() + "/cheat.png") ICONS["Plugins"] = QPixmap( "./resources/ui/misc/" + get_theme_icons() + "/plugins.png" diff --git a/qt_ui/widgets/floatspinners.py b/qt_ui/widgets/floatspinners.py index 54f4dccd..a6f13906 100644 --- a/qt_ui/widgets/floatspinners.py +++ b/qt_ui/widgets/floatspinners.py @@ -3,21 +3,27 @@ from typing import Optional from PySide2.QtWidgets import QSpinBox -class TenthsSpinner(QSpinBox): +class FloatSpinner(QSpinBox): def __init__( self, - minimum: Optional[int] = None, - maximum: Optional[int] = None, - initial: Optional[int] = None, + divisor: int, + minimum: Optional[float] = None, + maximum: Optional[float] = None, + initial: Optional[float] = None, ) -> None: super().__init__() + self.divisor = divisor if minimum is not None: - self.setMinimum(minimum) + self.setMinimum(int(minimum * divisor)) if maximum is not None: - self.setMaximum(maximum) + self.setMaximum(int(maximum * divisor)) if initial is not None: - self.setValue(initial) + self.setValue(int(initial * divisor)) def textFromValue(self, val: int) -> str: - return f"X {val / 10:.1f}" + return f"X {val / self.divisor:.1f}" + + @property + def real_value(self) -> float: + return self.value() / self.divisor diff --git a/qt_ui/widgets/spinsliders.py b/qt_ui/widgets/spinsliders.py index 93d3017e..0b85f6da 100644 --- a/qt_ui/widgets/spinsliders.py +++ b/qt_ui/widgets/spinsliders.py @@ -1,30 +1,33 @@ +from datetime import timedelta +from typing import Optional + from PySide2 import QtWidgets from PySide2.QtCore import Qt -from PySide2.QtWidgets import QGridLayout, QLabel, QSlider -from typing import Optional -from qt_ui.widgets.floatspinners import TenthsSpinner -from datetime import timedelta +from PySide2.QtWidgets import QSlider, QHBoxLayout + +from qt_ui.widgets.floatspinners import FloatSpinner -class TenthsSpinSlider(QGridLayout): - def __init__(self, label: str, minimum: int, maximum: int, initial: int) -> None: +class FloatSpinSlider(QHBoxLayout): + def __init__( + self, minimum: float, maximum: float, initial: float, divisor: int + ) -> None: super().__init__() - self.addWidget(QLabel(label), 0, 0) slider = QSlider(Qt.Horizontal) - slider.setMinimum(minimum) - slider.setMaximum(maximum) + slider.setMinimum(int(minimum * divisor)) + slider.setMaximum(int(maximum * divisor)) slider.setValue(initial) - self.spinner = TenthsSpinner(minimum, maximum, initial) + self.spinner = FloatSpinner(divisor, minimum, maximum, initial) slider.valueChanged.connect(lambda x: self.spinner.setValue(x)) self.spinner.valueChanged.connect(lambda x: slider.setValue(x)) - self.addWidget(slider, 1, 0) - self.addWidget(self.spinner, 1, 1) + self.addWidget(slider) + self.addWidget(self.spinner) @property def value(self) -> float: - return self.spinner.value() / 10 + return self.spinner.real_value class TimeInputs(QtWidgets.QGridLayout): diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 75644959..7be0cc76 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -15,7 +15,7 @@ from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from game.factions.faction import Faction from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar -from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner +from qt_ui.widgets.spinsliders import FloatSpinSlider, TimeInputs, CurrencySpinner from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog from qt_ui.windows.newgame.QCampaignList import QCampaignList @@ -472,11 +472,12 @@ class DifficultyAndAutomationOptions(QtWidgets.QWizardPage): economy_layout = QtWidgets.QVBoxLayout() economy_group.setLayout(economy_layout) - player_income = TenthsSpinSlider("Player income multiplier", 0, 50, 10) + # TODO: Put labels back. + player_income = FloatSpinSlider(0, 5, 1, divisor=10) self.registerField("player_income_multiplier", player_income.spinner) economy_layout.addLayout(player_income) - enemy_income = TenthsSpinSlider("Enemy income multiplier", 0, 50, 10) + enemy_income = FloatSpinSlider(0, 5, 1, divisor=10) self.registerField("enemy_income_multiplier", enemy_income.spinner) economy_layout.addLayout(enemy_income) diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index c7717a74..3deb82d3 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -1,4 +1,5 @@ import logging +import textwrap from typing import Callable from PySide2.QtCore import QItemSelectionModel, QPoint, QSize, Qt @@ -18,13 +19,19 @@ from PySide2.QtWidgets import ( QVBoxLayout, QWidget, ) -from dcs.forcedoptions import ForcedOptions import qt_ui.uiconstants as CONST from game.game import Game -from game.settings import Settings, AutoAtoBehavior +from game.settings import ( + Settings, + AutoAtoBehavior, + OptionDescription, + BooleanOption, + ChoicesOption, + BoundedFloatOption, +) from qt_ui.widgets.QLabeledWidget import QLabeledWidget -from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs +from qt_ui.widgets.spinsliders import FloatSpinSlider, TimeInputs from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine from qt_ui.windows.settings.plugins import PluginOptionsPage, PluginsPage @@ -321,6 +328,86 @@ class StartTypeComboBox(QComboBox): self.settings.default_start_type = value +class AutoSettingsLayout(QGridLayout): + def __init__(self, page: str, section: str, settings: Settings) -> None: + super().__init__() + self.settings = settings + + for row, (name, description) in enumerate(Settings.fields(page, section)): + self.add_label(row, description) + if isinstance(description, BooleanOption): + self.add_checkbox_for(row, name) + elif isinstance(description, ChoicesOption): + self.add_combobox_for(row, name, description) + elif isinstance(description, BoundedFloatOption): + self.add_float_spin_slider_for(row, name, description) + else: + raise TypeError(f"Unhandled option type: {description}") + + def add_label(self, row: int, description: OptionDescription) -> None: + text = description.text + if description.detail is not None: + wrapped = "
".join(textwrap.wrap(description.detail, width=55)) + text += f"
{wrapped}" + label = QLabel(text) + self.addWidget(label, row, 0) + + def add_checkbox_for(self, row: int, name: str) -> None: + def on_toggle(value: bool) -> None: + self.settings.__dict__[name] = value + + checkbox = QCheckBox() + checkbox.setChecked(self.settings.__dict__[name]) + checkbox.toggled.connect(on_toggle) + self.addWidget(checkbox, row, 1, Qt.AlignRight) + + def add_combobox_for(self, row: int, name: str, description: ChoicesOption) -> None: + combobox = QComboBox() + + def on_changed(index: int) -> None: + self.settings.__dict__[name] = combobox.itemData(index) + + for text, value in description.choices.items(): + combobox.addItem(text, value) + combobox.setCurrentText( + description.text_for_value(self.settings.__dict__[name]) + ) + combobox.currentIndexChanged.connect(on_changed) + self.addWidget(combobox, row, 1, Qt.AlignRight) + + def add_float_spin_slider_for( + self, row: int, name: str, description: BoundedFloatOption + ) -> None: + spinner = FloatSpinSlider( + description.min, + description.max, + self.settings.__dict__[name], + divisor=description.divisor, + ) + self.addLayout(spinner, row, 1, Qt.AlignRight) + + +class AutoSettingsGroup(QGroupBox): + def __init__(self, page: str, section: str, settings: Settings) -> None: + super().__init__(section) + self.setLayout(AutoSettingsLayout(page, section, settings)) + + +class AutoSettingsPageLayout(QVBoxLayout): + def __init__(self, page: str, settings: Settings) -> None: + super().__init__() + self.setAlignment(Qt.AlignTop) + + for section in Settings.sections(page): + self.addWidget(AutoSettingsGroup(page, section, settings)) + + +class AutoSettingsPage(QWidget): + def __init__(self, page: str, settings: Settings) -> None: + super().__init__() + self.setLayout(AutoSettingsPageLayout(page, settings)) + + class QSettingsWindow(QDialog): def __init__(self, game: Game): super().__init__() @@ -330,6 +417,10 @@ class QSettingsWindow(QDialog): self.pluginsOptionsPage = None self.campaign_management_page = QWidget() + self.pages: dict[str, AutoSettingsPage] = {} + for page in Settings.pages(): + self.pages[page] = AutoSettingsPage(page, game.settings) + self.setModal(True) self.setWindowTitle("Settings") self.setWindowIcon(CONST.ICONS["Settings"]) @@ -349,13 +440,16 @@ class QSettingsWindow(QDialog): self.categoryList.setIconSize(QSize(32, 32)) - self.initDifficultyLayout() - difficulty = QStandardItem("Difficulty") - difficulty.setIcon(CONST.ICONS["Missile"]) - difficulty.setEditable(False) - difficulty.setSelectable(True) - self.categoryModel.appendRow(difficulty) - self.right_layout.addWidget(self.difficultyPage) + for name, page in self.pages.items(): + page_item = QStandardItem(name) + if name in CONST.ICONS: + page_item.setIcon(CONST.ICONS[name]) + else: + page_item.setIcon(CONST.ICONS["Generator"]) + page_item.setEditable(False) + page_item.setSelectable(True) + self.categoryModel.appendRow(page_item) + self.right_layout.addWidget(page) self.init_campaign_management_layout() campaign_management = QStandardItem("Campaign Management") @@ -414,182 +508,6 @@ class QSettingsWindow(QDialog): def init(self): pass - def initDifficultyLayout(self): - - self.difficultyPage = QWidget() - self.difficultyLayout = QVBoxLayout() - self.difficultyLayout.setAlignment(Qt.AlignTop) - self.difficultyPage.setLayout(self.difficultyLayout) - - # DCS AI difficulty settings - self.aiDifficultySettings = QGroupBox("AI Difficulty") - self.aiDifficultyLayout = QGridLayout() - self.playerCoalitionSkill = QComboBox() - self.enemyCoalitionSkill = QComboBox() - self.enemyAASkill = QComboBox() - for skill in CONST.SKILL_OPTIONS: - self.playerCoalitionSkill.addItem(skill) - self.enemyCoalitionSkill.addItem(skill) - self.enemyAASkill.addItem(skill) - - self.playerCoalitionSkill.setCurrentIndex( - CONST.SKILL_OPTIONS.index(self.game.settings.player_skill) - ) - self.enemyCoalitionSkill.setCurrentIndex( - CONST.SKILL_OPTIONS.index(self.game.settings.enemy_skill) - ) - self.enemyAASkill.setCurrentIndex( - CONST.SKILL_OPTIONS.index(self.game.settings.enemy_vehicle_skill) - ) - - self.player_income = TenthsSpinSlider( - "Player income multiplier", - 0, - 50, - int(self.game.settings.player_income_multiplier * 10), - ) - self.player_income.spinner.valueChanged.connect(self.applySettings) - self.enemy_income = TenthsSpinSlider( - "Enemy income multiplier", - 0, - 50, - int(self.game.settings.enemy_income_multiplier * 10), - ) - self.enemy_income.spinner.valueChanged.connect(self.applySettings) - - self.playerCoalitionSkill.currentIndexChanged.connect(self.applySettings) - self.enemyCoalitionSkill.currentIndexChanged.connect(self.applySettings) - self.enemyAASkill.currentIndexChanged.connect(self.applySettings) - - # Mission generation settings related to difficulty - self.missionSettings = QGroupBox("Mission Difficulty") - self.missionLayout = QGridLayout() - - self.manpads = QCheckBox() - self.manpads.setChecked(self.game.settings.manpads) - self.manpads.toggled.connect(self.applySettings) - - self.noNightMission = QCheckBox() - self.noNightMission.setChecked(self.game.settings.night_disabled) - self.noNightMission.toggled.connect(self.applySettings) - - # DCS Mission options - self.missionRestrictionsSettings = QGroupBox("Mission Restrictions") - self.missionRestrictionsLayout = QGridLayout() - - self.difficultyLabel = QComboBox() - [self.difficultyLabel.addItem(t) for t in CONST.LABELS_OPTIONS] - self.difficultyLabel.setCurrentIndex( - CONST.LABELS_OPTIONS.index(self.game.settings.labels) - ) - self.difficultyLabel.currentIndexChanged.connect(self.applySettings) - - self.mapVisibiitySelection = QComboBox() - self.mapVisibiitySelection.addItem("All", ForcedOptions.Views.All) - if self.game.settings.map_coalition_visibility == ForcedOptions.Views.All: - self.mapVisibiitySelection.setCurrentIndex(0) - self.mapVisibiitySelection.addItem("Fog of War", ForcedOptions.Views.Allies) - if self.game.settings.map_coalition_visibility == ForcedOptions.Views.Allies: - self.mapVisibiitySelection.setCurrentIndex(1) - self.mapVisibiitySelection.addItem( - "Allies Only", ForcedOptions.Views.OnlyAllies - ) - if ( - self.game.settings.map_coalition_visibility - == ForcedOptions.Views.OnlyAllies - ): - self.mapVisibiitySelection.setCurrentIndex(2) - self.mapVisibiitySelection.addItem( - "Own Aircraft Only", ForcedOptions.Views.MyAircraft - ) - if ( - self.game.settings.map_coalition_visibility - == ForcedOptions.Views.MyAircraft - ): - self.mapVisibiitySelection.setCurrentIndex(3) - self.mapVisibiitySelection.addItem("Map Only", ForcedOptions.Views.OnlyMap) - if self.game.settings.map_coalition_visibility == ForcedOptions.Views.OnlyMap: - self.mapVisibiitySelection.setCurrentIndex(4) - self.mapVisibiitySelection.currentIndexChanged.connect(self.applySettings) - - self.ext_views = QCheckBox() - self.ext_views.setChecked(self.game.settings.external_views_allowed) - self.ext_views.toggled.connect(self.applySettings) - - self.battleDamageAssessment = QComboBox() - self.battleDamageAssessment.addItem("Player preference", None) - self.battleDamageAssessment.addItem("Enforced on", True) - self.battleDamageAssessment.addItem("Enforced off", False) - if self.game.settings.battle_damage_assessment is None: - self.battleDamageAssessment.setCurrentIndex(0) - elif self.game.settings.battle_damage_assessment is True: - self.battleDamageAssessment.setCurrentIndex(1) - else: - self.battleDamageAssessment.setCurrentIndex(2) - self.battleDamageAssessment.currentIndexChanged.connect(self.applySettings) - - def set_invulnerable_player_pilots(checked: bool) -> None: - self.game.settings.invulnerable_player_pilots = checked - - invulnerable_player_pilots_label = QLabel( - "Player pilots cannot be killed
" - "Aircraft are vulnerable, but the player's pilot will be
" - "returned to the squadron at the end of the mission
" - ) - - invulnerable_player_pilots_checkbox = QCheckBox() - invulnerable_player_pilots_checkbox.setChecked( - self.game.settings.invulnerable_player_pilots - ) - invulnerable_player_pilots_checkbox.toggled.connect( - set_invulnerable_player_pilots - ) - - self.aiDifficultyLayout.addWidget(QLabel("Player coalition skill"), 0, 0) - self.aiDifficultyLayout.addWidget( - self.playerCoalitionSkill, 0, 1, Qt.AlignRight - ) - self.aiDifficultyLayout.addWidget(QLabel("Enemy coalition skill"), 1, 0) - self.aiDifficultyLayout.addWidget(self.enemyCoalitionSkill, 1, 1, Qt.AlignRight) - self.aiDifficultyLayout.addWidget(QLabel("Enemy AA and vehicles skill"), 2, 0) - self.aiDifficultyLayout.addWidget(self.enemyAASkill, 2, 1, Qt.AlignRight) - self.aiDifficultyLayout.addLayout(self.player_income, 3, 0) - self.aiDifficultyLayout.addLayout(self.enemy_income, 4, 0) - self.aiDifficultyLayout.addWidget(invulnerable_player_pilots_label, 5, 0) - self.aiDifficultyLayout.addWidget( - invulnerable_player_pilots_checkbox, 5, 1, Qt.AlignRight - ) - self.aiDifficultySettings.setLayout(self.aiDifficultyLayout) - self.difficultyLayout.addWidget(self.aiDifficultySettings) - - self.missionLayout.addWidget(QLabel("Manpads on frontlines"), 0, 0) - self.missionLayout.addWidget(self.manpads, 0, 1, Qt.AlignRight) - self.missionLayout.addWidget(QLabel("No night missions"), 1, 0) - self.missionLayout.addWidget(self.noNightMission, 1, 1, Qt.AlignRight) - self.missionSettings.setLayout(self.missionLayout) - self.difficultyLayout.addWidget(self.missionSettings) - - self.missionRestrictionsLayout.addWidget(QLabel("In Game Labels"), 0, 0) - self.missionRestrictionsLayout.addWidget( - self.difficultyLabel, 0, 1, Qt.AlignRight - ) - self.missionRestrictionsLayout.addWidget(QLabel("Map visibility options"), 1, 0) - self.missionRestrictionsLayout.addWidget( - self.mapVisibiitySelection, 1, 1, Qt.AlignRight - ) - self.missionRestrictionsLayout.addWidget(QLabel("Allow external views"), 2, 0) - self.missionRestrictionsLayout.addWidget(self.ext_views, 2, 1, Qt.AlignRight) - - self.missionRestrictionsLayout.addWidget( - QLabel("Battle damage assessment"), 3, 0 - ) - self.missionRestrictionsLayout.addWidget( - self.battleDamageAssessment, 3, 1, Qt.AlignRight - ) - - self.missionRestrictionsSettings.setLayout(self.missionRestrictionsLayout) - self.difficultyLayout.addWidget(self.missionRestrictionsSettings) - def init_campaign_management_layout(self) -> None: campaign_layout = QVBoxLayout() campaign_layout.setAlignment(Qt.AlignTop)