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)