Files
ichni_Official/Assets/Scripts/Settings/SettingsManager.cs
SoulliesOfficial 923fc5359f 发包版本
2026-07-26 09:32:51 -04:00

603 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ichni.Menu;
using Ichni.UI;
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";
/// <summary>新建存档、无效 Code 与无法迁移的旧数据统一回退到的 Locale。</summary>
public const string DefaultLocaleCode = "zh-CN";
/// <summary>
/// 旧版 <see cref="SettingsSaveData.languageIndex"/> 与设置页面下拉框的固定对应关系。
/// <para>该数组不从 Addressables Catalog 动态读取Catalog 条目顺序不是稳定的存档契约。</para>
/// <para>新存档不会保存数组索引,而是保存 <see cref="SettingsSaveData.languageCode"/>。保留本数组仅为迁移旧 ES3 数据与驱动当前下拉框。</para>
/// </summary>
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<int, bool> postProcessingCameraStates = new Dictionary<int, bool>();
// 语言切换请求统一由一个协程串行处理。协程运行期间的新请求只覆盖目标 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;
bool migratedUiEffectsSetting = false;
if (ES3.FileExists(savePath))
{
// 保留原有 ES3 Key确保本次数据模型重命名不会让已有本地设置失效。
settingsSaveData = ES3.Load<SettingsSaveData>(SettingsSaveKey, savePath);
}
else
{
settingsSaveData = CreateDefaultSettingsSaveData();
}
migratedLegacyLanguageIndex = MigrateLanguageCodeIfNeeded();
migratedUiEffectsSetting = MigrateUiEffectsSettingIfNeeded();
ApplyAudioSettings();
ApplyGraphicSettings();
ApplyLanguageSettings();
// 旧语言索引只应参与一次迁移;立刻写回稳定 Code避免每次启动都重复依赖旧字段。
if (migratedLegacyLanguageIndex || migratedUiEffectsSetting)
{
SaveGameSettings();
}
}
/// <summary>
/// 将音频分类的当前设置写入 Wwise RTPC。
/// </summary>
public void ApplyAudioSettings()
{
AkUnitySoundEngine.SetRTPCValue("MasterVolume", settingsSaveData.masterVolume);
AkUnitySoundEngine.SetRTPCValue("MusicVolume", settingsSaveData.musicVolume);
AkUnitySoundEngine.SetRTPCValue("SoundFXVolume", settingsSaveData.soundEffectVolume);
AkUnitySoundEngine.SetRTPCValue("UIVolume", settingsSaveData.uiVolume);
ApplyInGameAudioEffectsSettings();
}
/// <summary>
/// 应用游戏内音乐特效开关。关闭时立即清除歌曲正在使用的高通与低通滤波,
/// 防止设置变更后残留上一帧的 RTPC 值。
/// </summary>
public void ApplyInGameAudioEffectsSettings()
{
if (settingsSaveData.enableInGameAudioEffects)
{
return;
}
GameManager.Instance?.songPlayer?.ResetInGameAudioEffects();
}
/// <summary>
/// 将现有图形设置应用到运行时。更细的 Quality、VSync 与平台策略会在 Graphics 分类重构时接入。
/// </summary>
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();
ApplyUiEffectsSettings();
}
/// <summary>
/// 应用后处理总开关与 Bloom 档位。
/// 关闭总开关时,已存在的 URP 相机会直接关闭 <c>renderPostProcessing</c>,并关闭 GameScene 的 Global Volume。
/// 这样既不会执行原生后处理,也不会执行 ScriptablePostProcessor 的自定义 Render Pass。
/// </summary>
public void ApplyPostProcessingSettings()
{
bool enablePostProcessing = settingsSaveData.enablePostProcessing;
UniversalAdditionalCameraData[] cameraDataList =
FindObjectsByType<UniversalAdditionalCameraData>(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);
}
}
/// <summary>
/// 应用 UI 特效总开关。
/// <para>
/// 仅处理显式挂载 <see cref="UiExpandingContoursEffect"/> 的装饰性 UI Image而不修改共享材质本身。
/// 这样关闭特效时会移除对应 Shader 的绘制开销,同时保留页面布局和其它 UI 元素。
/// </para>
/// <para>
/// 使用 Include 是为了让当前处于隐藏状态的 SongSelectionPage、SettingsPage 也同步保存目标状态;
/// 它们下次显示时无需再经过一帧特效可见的过渡。
/// </para>
/// </summary>
public void ApplyUiEffectsSettings()
{
if (settingsSaveData == null)
{
return;
}
UiExpandingContoursEffect[] effectTargets =
FindObjectsByType<UiExpandingContoursEffect>(FindObjectsInactive.Include, FindObjectsSortMode.None);
foreach (UiExpandingContoursEffect effectTarget in effectTargets)
{
effectTarget.ApplyEnabledState(settingsSaveData.enableUiEffects);
}
}
/// <summary>
/// 处理场景切换后新加载的相机与 Global Volume。SettingsManager 会跨场景保留,
/// 因此不能只在首次读取存档时应用一次图形设置。
/// </summary>
private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
{
if (settingsSaveData != null)
{
ApplyPostProcessingSettings();
ApplyUiEffectsSettings();
}
}
/// <summary>
/// 兜底处理运行时晚于场景加载创建的相机(例如 Theme 或演出对象中的相机)。
/// 仅在玩家关闭后处理时执行,保证该相机在首次渲染前也不会运行后处理。
/// </summary>
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;
}
/// <summary>
/// 将 VSync、窗口模式和分辨率立即应用到 Standalone / Unity Editor。
/// 移动端由系统管理显示模式与分辨率,因此不执行这些设置。
/// </summary>
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
}
/// <summary>
/// 首次创建设置存档时,继承 Project Settings 为当前平台指定的默认 Quality Level。
/// 之后玩家的选择由 SettingsSaveData 持久化,不会在下次启动时被平台默认值覆盖。
/// </summary>
private SettingsSaveData CreateDefaultSettingsSaveData()
{
SettingsSaveData defaultSettings = new SettingsSaveData();
defaultSettings.uiEffectsSettingInitialized = true;
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;
}
/// <summary>
/// 将旧版 SettingsSave 中不存在的 UI 特效开关迁移为默认开启。
/// <para>
/// 不提升 Schema Version项目尚未发布且本字段只补齐一个布尔默认值。迁移标记会随即写回 ES3
/// 之后玩家主动关闭 UI 效果时不会被再次覆盖。
/// </para>
/// </summary>
private bool MigrateUiEffectsSettingIfNeeded()
{
if (settingsSaveData.uiEffectsSettingInitialized)
{
return false;
}
settingsSaveData.enableUiEffects = true;
settingsSaveData.uiEffectsSettingInitialized = true;
return true;
}
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
};
}
/// <summary>
/// 将存档枚举映射到 Project Settings 中同名的 Quality Level。
/// 若配置缺失,回退到 Balanced这是移动端的首发默认档位。
/// </summary>
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);
}
/// <summary>
/// 将当前 Quality Level 索引转换为可保存的枚举。未知的自定义档位统一按 Balanced 保存。
/// </summary>
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
};
}
/// <summary>
/// 返回当前存档语言在设置下拉框中的显示索引。
/// <para>该索引只服务 UI持久化始终使用 <see cref="SettingsSaveData.languageCode"/>。</para>
/// </summary>
public int GetLanguageDropdownIndex()
{
return GetLanguageDropdownIndex(GetSavedLanguageCode());
}
/// <summary>
/// 由设置下拉框提交玩家的新语言选择。
/// <para>所有 UI 必须经由此方法写入语言,避免重新将易变的下拉索引保存为存档契约。</para>
/// </summary>
public void SetLanguageByDropdownIndex(int languageIndex)
{
string localeCode = GetLocaleCodeFromDropdownIndex(languageIndex);
settingsSaveData.languageCode = localeCode;
// 继续维护旧字段,便于仍在本地的旧存档、开发调试和降级检查;运行时不再读取它。
settingsSaveData.languageIndex = GetLanguageDropdownIndex(localeCode);
ApplyLanguageSettings();
}
/// <summary>
/// 将当前语言 Code 应用到 Unity Localization 管线。
/// 本方法只等待 Unity 官方 InitializationOperation并在其完成后应用玩家最新一次语言选择。
/// </summary>
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<LocalizationSettings> 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;
}
}
/// <summary>
/// 将未保存过 Locale Code 的旧 ES3 数据迁移为稳定 Code。
/// <para>迁移使用旧版本的固定下拉排序,而不是当前 Addressables 的 Locale 列表顺序;因此旧玩家的语言不会被错误映射。</para>
/// </summary>
/// <returns>本次是否写入了新的 <see cref="SettingsSaveData.languageCode"/>。</returns>
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;
}
/// <summary>读取并规范化当前存档的 Locale Code。空值或未知 Code 始终回退到 <see cref="DefaultLocaleCode"/>。</summary>
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;
}
/// <summary>
/// 判断本谱面是否启用全屏判定。谱面强制要求的配置优先于玩家开关。
/// </summary>
public bool ShouldUseFullScreenJudge()
{
return settingsSaveData.judgeType || InformationTransistor.instance?.difficulty?.ForceFullScreenJudge == true;
}
}
}