diff --git a/game/persistency.py b/game/persistency.py index e64734fe..06df4d82 100644 --- a/game/persistency.py +++ b/game/persistency.py @@ -27,6 +27,10 @@ def base_path() -> str: return _dcs_saved_game_folder +def settings_dir() -> Path: + return Path(base_path()) / "Retribution" / "Settings" + + def save_dir() -> Path: return Path(base_path()) / "Retribution" / "Saves" diff --git a/game/settings/settings.py b/game/settings/settings.py index df2eced5..562a10f7 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -16,6 +16,8 @@ from .skilloption import skill_option from ..ato.starttype import StartType from ..weather import NightMissions +Views = ForcedOptions.Views + @unique class AutoAtoBehavior(Enum): @@ -126,18 +128,18 @@ class Settings: choices=["Full", "Abbreviated", "Dot Only", "Neutral Dot", "Off"], default="Full", ) - map_coalition_visibility: ForcedOptions.Views = choices_option( + map_coalition_visibility: 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, + "All": Views.All, + "Fog of war": Views.Allies, + "Allies only": Views.OnlyAllies, + "Own aircraft only": Views.MyAircraft, + "Map only": Views.OnlyMap, }, - default=ForcedOptions.Views.All, + default=Views.All, ) external_views_allowed: bool = boolean_option( "Allow external views", @@ -673,6 +675,15 @@ class Settings: self.plugins[self.plugin_settings_key(identifier)] = value def __setstate__(self, state: dict[str, Any]) -> None: + # restore Enum & timedelta types + for key, value in state.items(): + if isinstance(self.__dict__.get(key), timedelta) and isinstance(value, int): + state[key] = timedelta(minutes=value) + elif isinstance(self.__dict__.get(key), Enum) and isinstance(value, str): + state[key] = eval(value) + elif isinstance(value, dict): + state[key] = self.obj_hook(value) + # __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 @@ -716,3 +727,22 @@ class Settings: for settings_field in fields(cls): if SETTING_DESCRIPTION_KEY in settings_field.metadata: yield settings_field + + @staticmethod + def default_json(obj: Any) -> Any: + # Known types that don't like being serialized, + # so we introduce our own implementation... + if isinstance(obj, Enum): + return {"Enum": str(obj)} + elif isinstance(obj, timedelta): + return {"timedelta": round(obj.seconds / 60)} + return obj + + @staticmethod + def obj_hook(obj: Any) -> Any: + if (value := obj.get("Enum")) is not None: + return eval(value) + elif (value := obj.get("timedelta")) is not None: + return timedelta(minutes=value) + else: + return obj diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 5d24b9d9..abac09a7 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -1,6 +1,8 @@ +import json import logging import textwrap from typing import Callable +import zipfile from PySide2.QtCore import QItemSelectionModel, QPoint, QSize, Qt from PySide2.QtGui import QStandardItem, QStandardItemModel @@ -19,10 +21,12 @@ from PySide2.QtWidgets import ( QStackedLayout, QVBoxLayout, QWidget, + QFileDialog, ) import qt_ui.uiconstants as CONST from game.game import Game +from game.persistency import settings_dir from game.server import EventStream from game.settings import ( BooleanOption, @@ -332,6 +336,13 @@ class QSettingsWindow(QDialog): self.layout.addWidget(self.categoryList, 0, 0, 1, 1) self.layout.addLayout(self.right_layout, 0, 1, 5, 1) + load = QPushButton("Load Settings") + load.clicked.connect(self.load_settings) + self.layout.addWidget(load, 1, 0, 1, 1) + save = QPushButton("Save Settings") + save.clicked.connect(self.save_settings) + self.layout.addWidget(save, 2, 0, 1, 1) + self.setLayout(self.layout) def initCheatLayout(self): @@ -387,3 +398,41 @@ class QSettingsWindow(QDialog): def onSelectionChanged(self): index = self.categoryList.selectionModel().currentIndex().row() self.right_layout.setCurrentIndex(index) + + def load_settings(self): + sd = settings_dir() + if not sd.exists(): + sd.mkdir() + fd = QFileDialog(caption="Load Settings", directory=str(sd), filter="*.zip") + if fd.exec_(): + zipfilename = fd.selectedFiles()[0] + with zipfile.ZipFile(zipfilename, "r") as zf: + filename = zipfilename.split("/")[-1].replace(".zip", ".json") + settings = json.loads( + zf.read(filename).decode("utf-8"), + object_hook=self.game.settings.obj_hook, + ) + self.game.settings.__setstate__(settings) + self.close() + new = QSettingsWindow(self.game) + new.exec_() + + def save_settings(self): + sd = settings_dir() + if not sd.exists(): + sd.mkdir() + fd = QFileDialog(caption="Save Settings", directory=str(sd), filter="*.zip") + fd.setAcceptMode(QFileDialog.AcceptSave) + if fd.exec_(): + zipfilename = fd.selectedFiles()[0] + with zipfile.ZipFile(zipfilename, "w", zipfile.ZIP_DEFLATED) as zf: + filename = zipfilename.split("/")[-1].replace(".zip", ".json") + zf.writestr( + filename, + json.dumps( + self.game.settings.__dict__, + indent=2, + default=self.game.settings.default_json, + ), + zipfile.ZIP_DEFLATED, + )