class_name PlayerSettingsManager extends Node const SETTINGS_VERSION: int = 1 const SETTINGS_PATH: String = "user://player_settings.json" const TEMP_PATH: String = "user://player_settings.json.tmp" const BACKUP_PATH: String = "user://player_settings.json.backup" signal settings_changed(settings: PlayerSettings) var current_settings: PlayerSettings = PlayerSettings.new() var _is_dirty: bool = false func load_settings() -> bool: _recover_interrupted_write() if not FileAccess.file_exists(SETTINGS_PATH): current_settings = PlayerSettings.new() emit_signal("settings_changed", current_settings) return true var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ) if file == null: push_warning("Unable to open player settings; using defaults.") current_settings = PlayerSettings.new() emit_signal("settings_changed", current_settings) return false var json := JSON.new() var error: Error = json.parse(file.get_as_text()) file.close() if error != OK or typeof(json.data) != TYPE_DICTIONARY: return _use_defaults_after_corruption("Player settings JSON is malformed.") var data: Dictionary = json.data if _read_integer(data.get("settings_version"), -1) != SETTINGS_VERSION: return _use_defaults_after_corruption("Player settings version is unsupported.") if ( typeof(data.get("accessibility")) != TYPE_DICTIONARY or typeof(data.get("camera")) != TYPE_DICTIONARY ): return _use_defaults_after_corruption("Player settings structure is invalid.") var accessibility: Dictionary = data["accessibility"] var camera: Dictionary = data["camera"] if ( typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL or typeof(camera.get("invert_vertical")) != TYPE_BOOL ): return _use_defaults_after_corruption("Player settings values are invalid.") var loaded := PlayerSettings.new() loaded.auto_click_enabled = accessibility["auto_click_enabled"] loaded.auto_click_interval = _read_float( accessibility.get("auto_click_interval"), -1.0 ) loaded.mouse_camera_sensitivity = _read_float( camera.get("mouse_sensitivity"), -1.0 ) loaded.controller_camera_sensitivity = _read_float( camera.get("controller_sensitivity"), -1.0 ) loaded.invert_camera_y = camera["invert_vertical"] if not loaded.is_valid(): return _use_defaults_after_corruption("Player settings values are invalid.") current_settings = loaded _is_dirty = false emit_signal("settings_changed", current_settings) return true func apply_settings(settings: PlayerSettings) -> bool: if settings == null or not settings.is_valid(): return false var previous: PlayerSettings = current_settings current_settings = settings.copy() _is_dirty = true if not save_now(): current_settings = previous return false emit_signal("settings_changed", current_settings) return true func save_now() -> bool: if current_settings == null or not current_settings.is_valid(): return false var data: Dictionary = { "settings_version": SETTINGS_VERSION, "accessibility": { "auto_click_enabled": current_settings.auto_click_enabled, "auto_click_interval": current_settings.auto_click_interval, }, "camera": { "mouse_sensitivity": current_settings.mouse_camera_sensitivity, "controller_sensitivity": current_settings.controller_camera_sensitivity, "invert_vertical": current_settings.invert_camera_y, }, } var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(data, "\t")) file.flush() var write_error: Error = file.get_error() file.close() if write_error != OK: _remove_if_present(TEMP_PATH) return false var had_primary: bool = FileAccess.file_exists(SETTINGS_PATH) _remove_if_present(BACKUP_PATH) if had_primary and not _rename_file(SETTINGS_PATH, BACKUP_PATH): _remove_if_present(TEMP_PATH) return false if not _rename_file(TEMP_PATH, SETTINGS_PATH): if had_primary: _rename_file(BACKUP_PATH, SETTINGS_PATH) return false _remove_if_present(BACKUP_PATH) _is_dirty = false return true func save_if_dirty() -> bool: return not _is_dirty or save_now() func _exit_tree() -> void: save_if_dirty() func _use_defaults_after_corruption(message: String) -> bool: push_warning(message) current_settings = PlayerSettings.new() _is_dirty = false emit_signal("settings_changed", current_settings) return false func _recover_interrupted_write() -> void: if FileAccess.file_exists(SETTINGS_PATH): _remove_if_present(TEMP_PATH) _remove_if_present(BACKUP_PATH) return if FileAccess.file_exists(BACKUP_PATH): _rename_file(BACKUP_PATH, SETTINGS_PATH) _remove_if_present(TEMP_PATH) func _read_integer(value: Variant, fallback: int) -> int: if typeof(value) == TYPE_INT: return int(value) if typeof(value) == TYPE_FLOAT and is_equal_approx(value, round(value)): return int(round(value)) return fallback func _read_float(value: Variant, fallback: float) -> float: if typeof(value) not in [TYPE_FLOAT, TYPE_INT]: return fallback var result: float = float(value) return result if is_finite(result) else fallback func _rename_file(from_path: String, to_path: String) -> bool: return DirAccess.rename_absolute( ProjectSettings.globalize_path(from_path), ProjectSettings.globalize_path(to_path) ) == OK func _remove_if_present(path: String) -> bool: if not FileAccess.file_exists(path): return true return DirAccess.remove_absolute( ProjectSettings.globalize_path(path) ) == OK