using Ichni.Menu; using System.Collections; using System.Collections.Generic; using Sirenix.OdinInspector; using UnityEngine; using UnityEngine.Localization; using UnityEngine.Localization.Settings; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.SceneManagement; using UnityEngine.Serialization; namespace Ichni { // MenuManager.Start 会直接读取已加载的设置,因此 SettingsManager 必须先完成 Start。 [DefaultExecutionOrder(-10000)] public class SettingsManager : SerializedMonoBehaviour { private const string SettingsSaveFileName = "SettingsSave.json"; private const string SettingsSaveKey = "SettingsSave"; private const string PerformantQualityName = "Performant"; private const string BalancedQualityName = "Balanced"; private const string HighFidelityQualityName = "High Fidelity"; /// 新建存档、无效 Code 与无法迁移的旧数据统一回退到的 Locale。 public const string DefaultLocaleCode = "zh-CN"; /// /// 旧版 与设置页面下拉框的固定对应关系。 /// 该数组不从 Addressables Catalog 动态读取;Catalog 条目顺序不是稳定的存档契约。 /// 新存档不会保存数组索引,而是保存 。保留本数组仅为迁移旧 ES3 数据与驱动当前下拉框。 /// private static readonly string[] LegacyLanguageIndexToCode = { "zh-CN", "en", "zh-TW", "ja", "ko", "vi-VN", "th" }; public static SettingsManager instance; [FormerlySerializedAs("settingsPage")] public SettingsUIPage settingsUIPage; [FormerlySerializedAs("gameSettings")] public SettingsSaveData settingsSaveData; // 记录相机在玩家关闭后处理前的原始状态。重新开启时必须恢复原始值,不能无条件将所有相机改为开启。 private readonly Dictionary postProcessingCameraStates = new Dictionary(); // 语言切换请求统一由一个协程串行处理。协程运行期间的新请求只覆盖目标 Locale Code。 private Coroutine applyLanguageSettingsCoroutine; private bool isLanguageRoutineRunning; private string pendingLanguageCode; private void Awake() { if (instance == null) { instance = this; DontDestroyOnLoad(gameObject); SceneManager.sceneLoaded += OnSceneLoaded; RenderPipelineManager.beginCameraRendering += ApplyPostProcessingSettingBeforeCameraRender; } else { Destroy(gameObject); } } private void OnDestroy() { if (instance != this) { return; } SceneManager.sceneLoaded -= OnSceneLoaded; RenderPipelineManager.beginCameraRendering -= ApplyPostProcessingSettingBeforeCameraRender; if (applyLanguageSettingsCoroutine != null) { StopCoroutine(applyLanguageSettingsCoroutine); applyLanguageSettingsCoroutine = null; } isLanguageRoutineRunning = false; instance = null; } private void Start() { LoadGameSettings(); } [Button] public void SaveGameSettings() { string savePath = Application.persistentDataPath + "/GameData/" + SettingsSaveFileName; ES3.Save(SettingsSaveKey, settingsSaveData, savePath); } [Button] public void LoadGameSettings() { string savePath = Application.persistentDataPath + "/GameData/" + SettingsSaveFileName; bool migratedLegacyLanguageIndex = false; if (ES3.FileExists(savePath)) { // 保留原有 ES3 Key,确保本次数据模型重命名不会让已有本地设置失效。 settingsSaveData = ES3.Load(SettingsSaveKey, savePath); } else { settingsSaveData = CreateDefaultSettingsSaveData(); } migratedLegacyLanguageIndex = MigrateLanguageCodeIfNeeded(); ApplyAudioSettings(); ApplyGraphicSettings(); ApplyLanguageSettings(); // 旧语言索引只应参与一次迁移;立刻写回稳定 Code,避免每次启动都重复依赖旧字段。 if (migratedLegacyLanguageIndex) { SaveGameSettings(); } } /// /// 将音频分类的当前设置写入 Wwise RTPC。 /// public void ApplyAudioSettings() { AkUnitySoundEngine.SetRTPCValue("MasterVolume", settingsSaveData.masterVolume); AkUnitySoundEngine.SetRTPCValue("MusicVolume", settingsSaveData.musicVolume); AkUnitySoundEngine.SetRTPCValue("SoundFXVolume", settingsSaveData.soundEffectVolume); AkUnitySoundEngine.SetRTPCValue("UIVolume", settingsSaveData.uiVolume); ApplyInGameAudioEffectsSettings(); } /// /// 应用游戏内音乐特效开关。关闭时立即清除歌曲正在使用的高通与低通滤波, /// 防止设置变更后残留上一帧的 RTPC 值。 /// public void ApplyInGameAudioEffectsSettings() { if (settingsSaveData.enableInGameAudioEffects) { return; } GameManager.Instance?.songPlayer?.ResetInGameAudioEffects(); } /// /// 将现有图形设置应用到运行时。更细的 Quality、VSync 与平台策略会在 Graphics 分类重构时接入。 /// public void ApplyGraphicSettings() { int qualityLevelIndex = GetQualityLevelIndex(settingsSaveData.graphicsQualityPreset); if (QualitySettings.GetQualityLevel() != qualityLevelIndex) { QualitySettings.SetQualityLevel(qualityLevelIndex, true); } Application.targetFrameRate = settingsSaveData.targetFrame; UniversalRenderPipelineAsset currentUrpAsset = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; currentUrpAsset.renderScale = 0.5f + 0.1f * settingsSaveData.renderScaleLevel; ApplyDisplaySettings(); ApplyPostProcessingSettings(); } /// /// 应用后处理总开关与 Bloom 档位。 /// 关闭总开关时,已存在的 URP 相机会直接关闭 renderPostProcessing,并关闭 GameScene 的 Global Volume。 /// 这样既不会执行原生后处理,也不会执行 ScriptablePostProcessor 的自定义 Render Pass。 /// public void ApplyPostProcessingSettings() { bool enablePostProcessing = settingsSaveData.enablePostProcessing; UniversalAdditionalCameraData[] cameraDataList = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (UniversalAdditionalCameraData cameraData in cameraDataList) { int instanceId = cameraData.GetInstanceID(); if (!postProcessingCameraStates.ContainsKey(instanceId)) { postProcessingCameraStates.Add(instanceId, cameraData.renderPostProcessing); } cameraData.renderPostProcessing = enablePostProcessing && postProcessingCameraStates[instanceId]; } if (PostProcessingManager.Instance != null) { PostProcessingManager.Instance.ApplySettings(enablePostProcessing, settingsSaveData.bloomQuality); } } /// /// 处理场景切换后新加载的相机与 Global Volume。SettingsManager 会跨场景保留, /// 因此不能只在首次读取存档时应用一次图形设置。 /// private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode) { if (settingsSaveData != null) { ApplyPostProcessingSettings(); } } /// /// 兜底处理运行时晚于场景加载创建的相机(例如 Theme 或演出对象中的相机)。 /// 仅在玩家关闭后处理时执行,保证该相机在首次渲染前也不会运行后处理。 /// private void ApplyPostProcessingSettingBeforeCameraRender(ScriptableRenderContext context, Camera camera) { if (settingsSaveData == null || settingsSaveData.enablePostProcessing || !camera.TryGetComponent(out UniversalAdditionalCameraData cameraData)) { return; } int instanceId = cameraData.GetInstanceID(); if (!postProcessingCameraStates.ContainsKey(instanceId)) { postProcessingCameraStates.Add(instanceId, cameraData.renderPostProcessing); } cameraData.renderPostProcessing = false; } /// /// 将 VSync、窗口模式和分辨率立即应用到 Standalone / Unity Editor。 /// 移动端由系统管理显示模式与分辨率,因此不执行这些设置。 /// public void ApplyDisplaySettings() { #if UNITY_STANDALONE || UNITY_EDITOR QualitySettings.vSyncCount = settingsSaveData.vSyncEnabled ? 1 : 0; if (settingsSaveData.displayResolutionWidth <= 0 || settingsSaveData.displayResolutionHeight <= 0) { settingsSaveData.displayResolutionWidth = Screen.width; settingsSaveData.displayResolutionHeight = Screen.height; } Screen.SetResolution(settingsSaveData.displayResolutionWidth, settingsSaveData.displayResolutionHeight, GetFullScreenMode(settingsSaveData.displayMode)); #endif } /// /// 首次创建设置存档时,继承 Project Settings 为当前平台指定的默认 Quality Level。 /// 之后玩家的选择由 SettingsSaveData 持久化,不会在下次启动时被平台默认值覆盖。 /// private SettingsSaveData CreateDefaultSettingsSaveData() { SettingsSaveData defaultSettings = new SettingsSaveData(); defaultSettings.languageCode = DefaultLocaleCode; defaultSettings.languageIndex = GetLanguageDropdownIndex(DefaultLocaleCode); defaultSettings.graphicsQualityPreset = GetGraphicsQualityPreset(QualitySettings.GetQualityLevel()); #if UNITY_STANDALONE || UNITY_EDITOR defaultSettings.displayMode = GetGraphicsDisplayMode(Screen.fullScreenMode); defaultSettings.displayResolutionWidth = Screen.width; defaultSettings.displayResolutionHeight = Screen.height; #endif return defaultSettings; } private FullScreenMode GetFullScreenMode(GraphicsDisplayMode displayMode) { return displayMode switch { GraphicsDisplayMode.Fullscreen => FullScreenMode.ExclusiveFullScreen, GraphicsDisplayMode.Windowed => FullScreenMode.Windowed, _ => FullScreenMode.FullScreenWindow }; } private GraphicsDisplayMode GetGraphicsDisplayMode(FullScreenMode fullScreenMode) { return fullScreenMode switch { FullScreenMode.ExclusiveFullScreen => GraphicsDisplayMode.Fullscreen, FullScreenMode.Windowed => GraphicsDisplayMode.Windowed, _ => GraphicsDisplayMode.BorderlessFullscreen }; } /// /// 将存档枚举映射到 Project Settings 中同名的 Quality Level。 /// 若配置缺失,回退到 Balanced;这是移动端的首发默认档位。 /// private int GetQualityLevelIndex(GraphicsQualityPreset qualityPreset) { string qualityName = GetQualityName(qualityPreset); string[] qualityNames = QualitySettings.names; for (int index = 0; index < qualityNames.Length; index++) { if (qualityNames[index] == qualityName) { return index; } } Debug.LogWarning($"[SettingsManager] 未找到 Quality Level '{qualityName}',回退至 Balanced。"); return FindQualityLevelOrFallback(BalancedQualityName); } /// /// 将当前 Quality Level 索引转换为可保存的枚举。未知的自定义档位统一按 Balanced 保存。 /// private GraphicsQualityPreset GetGraphicsQualityPreset(int qualityLevelIndex) { string[] qualityNames = QualitySettings.names; if (qualityLevelIndex < 0 || qualityLevelIndex >= qualityNames.Length) { return GraphicsQualityPreset.Balanced; } return qualityNames[qualityLevelIndex] switch { PerformantQualityName => GraphicsQualityPreset.Performant, HighFidelityQualityName => GraphicsQualityPreset.HighFidelity, _ => GraphicsQualityPreset.Balanced }; } private int FindQualityLevelOrFallback(string qualityName) { string[] qualityNames = QualitySettings.names; for (int index = 0; index < qualityNames.Length; index++) { if (qualityNames[index] == qualityName) { return index; } } return Mathf.Clamp(QualitySettings.GetQualityLevel(), 0, qualityNames.Length - 1); } private string GetQualityName(GraphicsQualityPreset qualityPreset) { return qualityPreset switch { GraphicsQualityPreset.Performant => PerformantQualityName, GraphicsQualityPreset.HighFidelity => HighFidelityQualityName, _ => BalancedQualityName }; } /// /// 返回当前存档语言在设置下拉框中的显示索引。 /// 该索引只服务 UI;持久化始终使用 /// public int GetLanguageDropdownIndex() { return GetLanguageDropdownIndex(GetSavedLanguageCode()); } /// /// 由设置下拉框提交玩家的新语言选择。 /// 所有 UI 必须经由此方法写入语言,避免重新将易变的下拉索引保存为存档契约。 /// public void SetLanguageByDropdownIndex(int languageIndex) { string localeCode = GetLocaleCodeFromDropdownIndex(languageIndex); settingsSaveData.languageCode = localeCode; // 继续维护旧字段,便于仍在本地的旧存档、开发调试和降级检查;运行时不再读取它。 settingsSaveData.languageIndex = GetLanguageDropdownIndex(localeCode); ApplyLanguageSettings(); } /// /// 将当前语言 Code 应用到 Unity Localization 管线。 /// 本方法只等待 Unity 官方 InitializationOperation,并在其完成后应用玩家最新一次语言选择。 /// public void ApplyLanguageSettings() { if (settingsSaveData == null) { return; } pendingLanguageCode = GetSavedLanguageCode(); if (isLanguageRoutineRunning) { return; } isLanguageRoutineRunning = true; applyLanguageSettingsCoroutine = StartCoroutine(ApplyLanguageSettingsRoutine()); } private IEnumerator ApplyLanguageSettingsRoutine() { // try/finally 确保协程正常结束、被 StopCoroutine 或发生异常时,运行状态都会被清除。 try { // 避开 Dropdown.onValueChanged 当前同步调用栈。 yield return null; // 直接等待 Unity Localization 官方初始化句柄。无需再维护一套平行的 // Bootstrap 状态机;当 Addressables/场景异步队列可正常推进时,该句柄会自行完成。 AsyncOperationHandle initializationOperation = LocalizationSettings.InitializationOperation; yield return initializationOperation; if (initializationOperation.Status != AsyncOperationStatus.Succeeded) { if (initializationOperation.OperationException != null) { Debug.LogException(initializationOperation.OperationException, this); } Debug.LogError("[SettingsManager] Unity Localization 初始化失败,取消语言切换。", this); pendingLanguageCode = null; yield break; } // 处理流程运行期间收到的最新请求。 while (!string.IsNullOrEmpty(pendingLanguageCode)) { string targetLocaleCode = pendingLanguageCode; pendingLanguageCode = null; Locale targetLocale = LocalizationSettings.AvailableLocales.GetLocale(targetLocaleCode); Locale currentLocale = LocalizationSettings.SelectedLocale; if (targetLocale == null) { Debug.LogWarning( $"[SettingsManager] Localization Settings 中不存在 Locale '{targetLocaleCode}'。"); continue; } if (currentLocale == targetLocale) { continue; } LocalizationSettings.SelectedLocale = targetLocale; // setter 会同步更新 SelectedLocale,并通知 LocalizedString / LocalizedAsset 按需刷新。 // 初始化已在本协程开头完成,因此此处不会触发同步 Locale 加载。 yield return null; } } finally { applyLanguageSettingsCoroutine = null; isLanguageRoutineRunning = false; } } /// /// 将未保存过 Locale Code 的旧 ES3 数据迁移为稳定 Code。 /// 迁移使用旧版本的固定下拉排序,而不是当前 Addressables 的 Locale 列表顺序;因此旧玩家的语言不会被错误映射。 /// /// 本次是否写入了新的 private bool MigrateLanguageCodeIfNeeded() { if (!string.IsNullOrWhiteSpace(settingsSaveData.languageCode)) { string normalizedCode = NormalizeLocaleCode(settingsSaveData.languageCode); bool wasNormalized = normalizedCode != settingsSaveData.languageCode; settingsSaveData.languageCode = normalizedCode; settingsSaveData.languageIndex = GetLanguageDropdownIndex(normalizedCode); return wasNormalized; } string migratedCode = GetLocaleCodeFromDropdownIndex(settingsSaveData.languageIndex); Debug.Log($"[SettingsManager] 已将旧语言索引 {settingsSaveData.languageIndex} 迁移为 Locale Code '{migratedCode}'。", this); settingsSaveData.languageCode = migratedCode; settingsSaveData.languageIndex = GetLanguageDropdownIndex(migratedCode); return true; } /// 读取并规范化当前存档的 Locale Code。空值或未知 Code 始终回退到 private string GetSavedLanguageCode() { string normalizedCode = NormalizeLocaleCode(settingsSaveData.languageCode); settingsSaveData.languageCode = normalizedCode; settingsSaveData.languageIndex = GetLanguageDropdownIndex(normalizedCode); return normalizedCode; } private static string GetLocaleCodeFromDropdownIndex(int languageIndex) { if (languageIndex < 0 || languageIndex >= LegacyLanguageIndexToCode.Length) { Debug.LogWarning($"[SettingsManager] 语言下拉索引 {languageIndex} 超出范围,回退到 {DefaultLocaleCode}。"); return DefaultLocaleCode; } return LegacyLanguageIndexToCode[languageIndex]; } private static int GetLanguageDropdownIndex(string localeCode) { for (int index = 0; index < LegacyLanguageIndexToCode.Length; index++) { if (LegacyLanguageIndexToCode[index] == localeCode) { return index; } } return 0; } private static string NormalizeLocaleCode(string localeCode) { if (string.IsNullOrWhiteSpace(localeCode)) { return DefaultLocaleCode; } foreach (string supportedCode in LegacyLanguageIndexToCode) { if (string.Equals(supportedCode, localeCode, System.StringComparison.OrdinalIgnoreCase)) { return supportedCode; } } Debug.LogWarning($"[SettingsManager] Locale Code '{localeCode}' 不在首发语言列表中,回退到 {DefaultLocaleCode}。"); return DefaultLocaleCode; } /// /// 判断本谱面是否启用全屏判定。谱面强制要求的配置优先于玩家开关。 /// public bool ShouldUseFullScreenJudge() { return settingsSaveData.judgeType || InformationTransistor.instance?.difficulty?.ForceFullScreenJudge == true; } } }