class_name ABGEntrySettingsStore extends RefCounted const SETTINGS_VERSION := 2 const DEFAULT_PATH := "user://aetherbound_guild_settings.json" const TEXT_SCALES := [1.0, 1.15, 1.3] const SAFE_AREA_PRESETS := [0, 24, 48] var path := DEFAULT_PATH func defaults() -> Dictionary: return { "settings_version": SETTINGS_VERSION, "setup_completed": false, "locale": "zh_CN", "safe_area": 24, "text_scale": 1.0, "brightness": 1.0, "color_cues": true, "reduced_motion": false, "reduced_flashes": false, "shake_intensity": 1.0, "hit_pause": true, "particle_density": "standard", "captions": true, "high_contrast": false, "low_power": false, "master_muted": false, "music_volume": 0.8, "ambience_volume": 0.8, "sfx_volume": 0.9, "voice_volume": 0.8, "dynamic_range": "standard", "caption_detail": "critical", "hold_duration": 0.6, "vibration": true, }.duplicate(true) func load_settings() -> Dictionary: if not FileAccess.file_exists(path): return {"ok": true, "code": "defaults", "settings": defaults()} var parser := JSON.new() var parse_error := parser.parse(FileAccess.get_file_as_string(path)) if parse_error != OK or not parser.data is Dictionary: return {"ok": false, "code": "SETTINGS_FILE_INVALID"} if int(parser.data.get("settings_version", 0)) == 1: var migrated := defaults() for key in parser.data.keys(): if migrated.has(key): migrated[key] = parser.data[key] migrated.settings_version = SETTINGS_VERSION var migration_save := save_settings(migrated) if not migration_save.ok: return {"ok": false, "code": "SETTINGS_MIGRATION_FAILED"} return {"ok": true, "code": "migrated_v1", "settings": migrated} var validation := validate(parser.data) if not validation.ok: return validation return {"ok": true, "code": "loaded", "settings": parser.data.duplicate(true)} func save_settings(value: Dictionary) -> Dictionary: var validation := validate(value) if not validation.ok: return validation var candidate_path := "%s.candidate" % path var candidate := FileAccess.open(candidate_path, FileAccess.WRITE) if candidate == null: return {"ok": false, "code": "SETTINGS_WRITE_UNAVAILABLE"} candidate.store_string(JSON.stringify(value)) candidate.close() var verify_parser := JSON.new() if verify_parser.parse(FileAccess.get_file_as_string(candidate_path)) != OK or not verify_parser.data is Dictionary or not validate(verify_parser.data).ok: DirAccess.remove_absolute(candidate_path) return {"ok": false, "code": "SETTINGS_WRITE_VERIFY_FAILED"} var backup_path := "%s.bak" % path if FileAccess.file_exists(backup_path): DirAccess.remove_absolute(backup_path) if FileAccess.file_exists(path): var backup_error := DirAccess.rename_absolute(path, backup_path) if backup_error != OK: DirAccess.remove_absolute(candidate_path) return {"ok": false, "code": "SETTINGS_BACKUP_FAILED"} var promote_error := DirAccess.rename_absolute(candidate_path, path) if promote_error != OK: if FileAccess.file_exists(backup_path) and not FileAccess.file_exists(path): DirAccess.rename_absolute(backup_path, path) return {"ok": false, "code": "SETTINGS_PROMOTE_FAILED"} return {"ok": true, "code": "saved", "settings": value.duplicate(true)} func validate(value: Dictionary) -> Dictionary: var required := defaults().keys() for key in required: if not value.has(key): return {"ok": false, "code": "SETTINGS_FIELD_MISSING"} if int(value.settings_version) != SETTINGS_VERSION: return {"ok": false, "code": "SETTINGS_VERSION_UNSUPPORTED"} if String(value.locale) not in ["en", "zh_CN"]: return {"ok": false, "code": "SETTINGS_LOCALE_INVALID"} if int(value.safe_area) not in SAFE_AREA_PRESETS: return {"ok": false, "code": "SETTINGS_SAFE_AREA_INVALID"} if not _float_in(float(value.text_scale), TEXT_SCALES): return {"ok": false, "code": "SETTINGS_TEXT_SCALE_INVALID"} if String(value.dynamic_range) not in ["wide", "standard", "night"]: return {"ok": false, "code": "SETTINGS_DYNAMIC_RANGE_INVALID"} if String(value.caption_detail) not in ["critical", "full"]: return {"ok": false, "code": "SETTINGS_CAPTION_DETAIL_INVALID"} if String(value.particle_density) not in ["low", "standard"]: return {"ok": false, "code": "SETTINGS_PARTICLES_INVALID"} if float(value.brightness) < 0.7 or float(value.brightness) > 1.2: return {"ok": false, "code": "SETTINGS_BRIGHTNESS_INVALID"} if float(value.shake_intensity) < 0.0 or float(value.shake_intensity) > 1.0: return {"ok": false, "code": "SETTINGS_SHAKE_INVALID"} for key in ["music_volume", "ambience_volume", "sfx_volume", "voice_volume"]: if float(value[key]) < 0.0 or float(value[key]) > 1.0: return {"ok": false, "code": "SETTINGS_VOLUME_INVALID"} if float(value.hold_duration) < 0.3 or float(value.hold_duration) > 1.5: return {"ok": false, "code": "SETTINGS_HOLD_INVALID"} return {"ok": true, "code": "valid"} func _float_in(value: float, options: Array) -> bool: for option in options: if is_equal_approx(value, float(option)): return true return false