diff --git a/Assets/Editor/AudioGlobalParameterSnapshotTests.cs b/Assets/Editor/AudioGlobalParameterSnapshotTests.cs new file mode 100644 index 000000000..82b6dfd04 --- /dev/null +++ b/Assets/Editor/AudioGlobalParameterSnapshotTests.cs @@ -0,0 +1,260 @@ +using System.Collections.Generic; +using System.Linq; +using AibisDream.Kit; +using AibisDream.SaveSystem; +using FMOD; +using FMOD.Studio; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AibisDream.SystemEditor.Tests +{ + public sealed class AudioGlobalParameterSnapshotTests + { + [Test] + public void SetFromYarn_TracksOnlySuccessfulUniqueNames() + { + var api = new FakeFmodGlobalParameterApi(); + api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND; + var registry = new YarnGlobalParameterRegistry(api); + + Assert.That(registry.SetFromYarn("reverb", 0.4f), Is.EqualTo(RESULT.OK)); + Assert.That(registry.SetFromYarn("REVERB", 0.7f), Is.EqualTo(RESULT.OK)); + Assert.That( + registry.SetFromYarn("missing", 1f), + Is.EqualTo(RESULT.ERR_EVENT_NOTFOUND)); + + Assert.That(registry.TrackedNames, Has.Count.EqualTo(1)); + Assert.That(registry.TrackedNames.Single(), Is.EqualTo("reverb")); + Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.False); + Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.False); + } + + [Test] + public void CaptureFinalValues_ReadsFmodFinalValueAndRetainsFailuresForRetry() + { + var api = new FakeFmodGlobalParameterApi(); + api.Reads["stage"] = new ParameterRead(9f, 2.37f, RESULT.OK); + api.Reads["missing"] = new ParameterRead(0f, 0f, RESULT.ERR_EVENT_NOTFOUND); + var registry = new YarnGlobalParameterRegistry(api); + registry.SetFromYarn("stage", 9f); + registry.SetFromYarn("missing", 1f); + var warnings = new List(); + + var snapshot = registry.CaptureFinalValues(warnings.Add); + + Assert.That(snapshot, Has.Count.EqualTo(1)); + Assert.That(snapshot["stage"], Is.EqualTo(2.37f)); + Assert.That(snapshot["stage"], Is.Not.EqualTo(9f)); + Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage", "missing" })); + Assert.That(warnings, Has.Count.EqualTo(1)); + StringAssert.Contains("missing", warnings[0]); + } + + [Test] + public void ResetToDefaults_RemovesOnlyParametersThatFmodResetSuccessfully() + { + var goodId = new PARAMETER_ID { data1 = 1, data2 = 2 }; + var failedId = new PARAMETER_ID { data1 = 3, data2 = 4 }; + var api = new FakeFmodGlobalParameterApi(); + api.Descriptions["good"] = new ParameterDescriptionRead( + new PARAMETER_DESCRIPTION { id = goodId, defaultvalue = 0.25f }, + RESULT.OK); + api.Descriptions["failed"] = new ParameterDescriptionRead( + new PARAMETER_DESCRIPTION { id = failedId, defaultvalue = 0.75f }, + RESULT.OK); + api.SetByIdResults[failedId] = RESULT.ERR_INVALID_PARAM; + var registry = new YarnGlobalParameterRegistry(api); + registry.SetFromYarn("good", 1f); + registry.SetFromYarn("failed", 1f); + var warnings = new List(); + + registry.ResetToDefaults(warnings.Add); + + Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "failed" })); + Assert.That(api.SetByIdCalls, Has.Count.EqualTo(2)); + var goodReset = api.SetByIdCalls.Single(call => call.Id.Equals(goodId)); + Assert.That(goodReset.Value, Is.EqualTo(0.25f)); + Assert.That(goodReset.IgnoreSeekSpeed, Is.True); + Assert.That(warnings, Has.Count.EqualTo(1)); + StringAssert.Contains("failed", warnings[0]); + } + + [Test] + public void RestoreFinalValues_JumpsImmediatelyThenNextYarnTargetUsesSeekSpeed() + { + var api = new FakeFmodGlobalParameterApi(); + api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND; + var registry = new YarnGlobalParameterRegistry(api); + var warnings = new List(); + + registry.RestoreFinalValues( + new Dictionary + { + ["stage"] = 2.37f, + ["missing"] = 1f + }, + warnings.Add); + registry.SetFromYarn("stage", 3f); + + Assert.That(api.SetByNameCalls, Has.Count.EqualTo(3)); + Assert.That(api.SetByNameCalls[0].Value, Is.EqualTo(2.37f)); + Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.True); + Assert.That(api.SetByNameCalls[1].Name, Is.EqualTo("missing")); + Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.True); + Assert.That(api.SetByNameCalls[2].Value, Is.EqualTo(3f)); + Assert.That(api.SetByNameCalls[2].IgnoreSeekSpeed, Is.False); + Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage" })); + Assert.That(warnings, Has.Count.EqualTo(1)); + StringAssert.Contains("missing", warnings[0]); + } + + [Test] + public void AudioSnapshotDto_RoundTripsFinalValuesWithoutChangingSchema() + { + var source = new SaveSnapshot(); + source.sections[SnapshotProviderIds.Audio] = new AudioSnapshotDto + { + ambState = "Dream", + yarnGlobalParameters = new Dictionary + { + ["reverb"] = 0.42f, + ["hs1LogStage"] = 2.37f + } + }; + + var json = JsonConvert.SerializeObject(source); + var restored = JsonConvert.DeserializeObject(json); + var audio = ((JObject)restored.sections[SnapshotProviderIds.Audio]) + .ToObject(); + + Assert.That(restored.schemaVersion, Is.EqualTo(2)); + Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(2)); + Assert.That(audio.yarnGlobalParameters["reverb"], Is.EqualTo(0.42f)); + Assert.That(audio.yarnGlobalParameters["hs1LogStage"], Is.EqualTo(2.37f)); + } + + private readonly struct SetByNameCall + { + internal SetByNameCall(string name, float value, bool ignoreSeekSpeed) + { + Name = name; + Value = value; + IgnoreSeekSpeed = ignoreSeekSpeed; + } + + internal string Name { get; } + internal float Value { get; } + internal bool IgnoreSeekSpeed { get; } + } + + private readonly struct SetByIdCall + { + internal SetByIdCall(PARAMETER_ID id, float value, bool ignoreSeekSpeed) + { + Id = id; + Value = value; + IgnoreSeekSpeed = ignoreSeekSpeed; + } + + internal PARAMETER_ID Id { get; } + internal float Value { get; } + internal bool IgnoreSeekSpeed { get; } + } + + private readonly struct ParameterRead + { + internal ParameterRead(float value, float finalValue, RESULT result) + { + Value = value; + FinalValue = finalValue; + Result = result; + } + + internal float Value { get; } + internal float FinalValue { get; } + internal RESULT Result { get; } + } + + private readonly struct ParameterDescriptionRead + { + internal ParameterDescriptionRead( + PARAMETER_DESCRIPTION description, + RESULT result) + { + Description = description; + Result = result; + } + + internal PARAMETER_DESCRIPTION Description { get; } + internal RESULT Result { get; } + } + + private sealed class FakeFmodGlobalParameterApi : IFmodGlobalParameterApi + { + internal readonly Dictionary SetByNameResults = + new(System.StringComparer.OrdinalIgnoreCase); + internal readonly Dictionary Reads = + new(System.StringComparer.OrdinalIgnoreCase); + internal readonly Dictionary Descriptions = + new(System.StringComparer.OrdinalIgnoreCase); + internal readonly Dictionary SetByIdResults = new(); + internal readonly List SetByNameCalls = new(); + internal readonly List SetByIdCalls = new(); + + public RESULT SetParameterByName( + string name, + float value, + bool ignoreSeekSpeed) + { + SetByNameCalls.Add(new SetByNameCall(name, value, ignoreSeekSpeed)); + return SetByNameResults.TryGetValue(name, out var result) + ? result + : RESULT.OK; + } + + public RESULT GetParameterByName( + string name, + out float value, + out float finalValue) + { + if (Reads.TryGetValue(name, out var read)) + { + value = read.Value; + finalValue = read.FinalValue; + return read.Result; + } + + value = 0f; + finalValue = 0f; + return RESULT.OK; + } + + public RESULT GetParameterDescriptionByName( + string name, + out PARAMETER_DESCRIPTION description) + { + if (Descriptions.TryGetValue(name, out var read)) + { + description = read.Description; + return read.Result; + } + + description = default; + return RESULT.ERR_EVENT_NOTFOUND; + } + + public RESULT SetParameterById( + PARAMETER_ID id, + float value, + bool ignoreSeekSpeed) + { + SetByIdCalls.Add(new SetByIdCall(id, value, ignoreSeekSpeed)); + return SetByIdResults.TryGetValue(id, out var result) + ? result + : RESULT.OK; + } + } + } +} diff --git a/Assets/Editor/AudioGlobalParameterSnapshotTests.cs.meta b/Assets/Editor/AudioGlobalParameterSnapshotTests.cs.meta new file mode 100644 index 000000000..9f9fda781 --- /dev/null +++ b/Assets/Editor/AudioGlobalParameterSnapshotTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e120d9cfce5a4723a6e640b3cc3ce29b diff --git a/Assets/Scripts/Framework/AudioKit/AudioManager.cs b/Assets/Scripts/Framework/AudioKit/AudioManager.cs index 49ac3deb7..e10957eb3 100644 --- a/Assets/Scripts/Framework/AudioKit/AudioManager.cs +++ b/Assets/Scripts/Framework/AudioKit/AudioManager.cs @@ -42,6 +42,7 @@ namespace AibisDream.Kit private EventInstance _voiceInstance; private readonly Dictionary _musicDict = new(); private readonly Dictionary _sfxDict = new(); + private YarnGlobalParameterRegistry _yarnGlobalParameters; #endregion @@ -52,6 +53,9 @@ namespace AibisDream.Kit private AmbState? _ambStateBeforePauseMenu; public AmbState CurrentAmbState => _currentAmbState; + private YarnGlobalParameterRegistry YarnGlobalParameters => + _yarnGlobalParameters ??= new YarnGlobalParameterRegistry( + new RuntimeFmodGlobalParameterApi()); #endregion @@ -124,7 +128,8 @@ namespace AibisDream.Kit var dto = new AudioSnapshotDto { - ambState = ambState.ToString() + ambState = ambState.ToString(), + yarnGlobalParameters = YarnGlobalParameters.CaptureFinalValues(Debug.LogWarning) }; foreach (var pair in _musicDict) @@ -142,21 +147,34 @@ namespace AibisDream.Kit return dto; } - public void RestoreSnapshot(AudioSnapshotDto data) + public void RestoreSnapshot(AudioSnapshotDto data, Action warn = null) { + warn ??= Debug.LogWarning; + YarnGlobalParameters.ResetToDefaults(warn); + foreach (var musicEvent in _musicDict.Values) { + musicEvent.stop(STOP_MODE.IMMEDIATE); musicEvent.release(); } foreach (var sfxEvent in _sfxDict.Values) { + sfxEvent.stop(STOP_MODE.IMMEDIATE); sfxEvent.release(); } _musicDict.Clear(); _sfxDict.Clear(); + if (!string.IsNullOrEmpty(data.ambState) + && Enum.TryParse(data.ambState, out var ambState)) + { + ChangeAmb(ambState); + } + + YarnGlobalParameters.RestoreFinalValues(data.yarnGlobalParameters, warn); + if (data.musicPaths != null) { foreach (var music in data.musicPaths) @@ -176,13 +194,6 @@ namespace AibisDream.Kit _sfxDict[sfx.Key] = instance; } } - - if (!string.IsNullOrEmpty(data.ambState) - && Enum.TryParse(data.ambState, out var ambState)) - { - ChangeAmb(ambState); - } - } public void PauseAll() @@ -250,6 +261,16 @@ namespace AibisDream.Kit RuntimeManager.StudioSystem.setParameterByName(paramName, value); } + public void SetYarnGlobalParam(string paramName, float value) + { + var result = YarnGlobalParameters.SetFromYarn(paramName, value); + if (result != RESULT.OK) + { + Debug.LogWarning( + $"[AudioManager] Yarn 全局参数 {paramName ?? "(null)"} 设置失败:{result}。"); + } + } + public void SetMainMenuStage(int stage) { SetGlobalParam(MainMenuStageParamName, stage); @@ -267,6 +288,7 @@ namespace AibisDream.Kit private void OnGameStart() { + YarnGlobalParameters.ResetToDefaults(Debug.LogWarning); SetMainMenuStage(3); } @@ -300,6 +322,7 @@ namespace AibisDream.Kit _musicDict.Clear(); _sfxDict.Clear(); + YarnGlobalParameters.ResetToDefaults(Debug.LogWarning); ChangeAmb(menuAmbState); } diff --git a/Assets/Scripts/Framework/AudioKit/AudioYarnCommand.cs b/Assets/Scripts/Framework/AudioKit/AudioYarnCommand.cs index efec908f6..92a598c64 100644 --- a/Assets/Scripts/Framework/AudioKit/AudioYarnCommand.cs +++ b/Assets/Scripts/Framework/AudioKit/AudioYarnCommand.cs @@ -66,7 +66,7 @@ namespace AibisDream.Kit [YarnCommand("set_global_param")] public static void SetGlobalParam(string paramName, float value) { - AudioManager.Instance.SetGlobalParam(paramName, value); + AudioManager.Instance.SetYarnGlobalParam(paramName, value); } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Framework/AudioKit/YarnGlobalParameterRegistry.cs b/Assets/Scripts/Framework/AudioKit/YarnGlobalParameterRegistry.cs new file mode 100644 index 000000000..d89ec1149 --- /dev/null +++ b/Assets/Scripts/Framework/AudioKit/YarnGlobalParameterRegistry.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using FMOD; +using FMOD.Studio; +using FMODUnity; + +namespace AibisDream.Kit +{ + internal interface IFmodGlobalParameterApi + { + RESULT SetParameterByName(string name, float value, bool ignoreSeekSpeed); + RESULT GetParameterByName(string name, out float value, out float finalValue); + RESULT GetParameterDescriptionByName(string name, out PARAMETER_DESCRIPTION description); + RESULT SetParameterById(PARAMETER_ID id, float value, bool ignoreSeekSpeed); + } + + internal sealed class RuntimeFmodGlobalParameterApi : IFmodGlobalParameterApi + { + public RESULT SetParameterByName(string name, float value, bool ignoreSeekSpeed) + { + return RuntimeManager.StudioSystem.setParameterByName(name, value, ignoreSeekSpeed); + } + + public RESULT GetParameterByName(string name, out float value, out float finalValue) + { + return RuntimeManager.StudioSystem.getParameterByName(name, out value, out finalValue); + } + + public RESULT GetParameterDescriptionByName( + string name, + out PARAMETER_DESCRIPTION description) + { + return RuntimeManager.StudioSystem.getParameterDescriptionByName(name, out description); + } + + public RESULT SetParameterById(PARAMETER_ID id, float value, bool ignoreSeekSpeed) + { + return RuntimeManager.StudioSystem.setParameterByID(id, value, ignoreSeekSpeed); + } + } + + /// + /// 只记录曾被 Yarn set_global_param 成功修改的参数名。 + /// 参数值与默认值始终以 FMOD Studio System 为唯一来源。 + /// + internal sealed class YarnGlobalParameterRegistry + { + private readonly IFmodGlobalParameterApi _api; + private readonly HashSet _trackedNames = + new(StringComparer.OrdinalIgnoreCase); + + internal YarnGlobalParameterRegistry(IFmodGlobalParameterApi api) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + } + + internal IReadOnlyCollection TrackedNames => _trackedNames; + + internal RESULT SetFromYarn(string name, float value) + { + if (string.IsNullOrWhiteSpace(name)) + { + return RESULT.ERR_INVALID_PARAM; + } + + var result = _api.SetParameterByName(name, value, ignoreSeekSpeed: false); + if (result == RESULT.OK) + { + _trackedNames.Add(name); + } + + return result; + } + + internal Dictionary CaptureFinalValues(Action warn) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in _trackedNames) + { + var result = _api.GetParameterByName(name, out _, out var finalValue); + if (result == RESULT.OK) + { + values[name] = finalValue; + continue; + } + + warn?.Invoke( + $"[AudioManager] 捕获 Yarn 全局参数 {name} 失败:{result}。本次存档跳过该参数。"); + } + + return values; + } + + internal void ResetToDefaults(Action warn) + { + var names = new List(_trackedNames); + foreach (var name in names) + { + var descriptionResult = + _api.GetParameterDescriptionByName(name, out var description); + if (descriptionResult != RESULT.OK) + { + warn?.Invoke( + $"[AudioManager] 查询 Yarn 全局参数 {name} 默认值失败:{descriptionResult}。保留追踪以便稍后重试。"); + continue; + } + + var resetResult = _api.SetParameterById( + description.id, + description.defaultvalue, + ignoreSeekSpeed: true); + if (resetResult != RESULT.OK) + { + warn?.Invoke( + $"[AudioManager] 重置 Yarn 全局参数 {name} 失败:{resetResult}。保留追踪以便稍后重试。"); + continue; + } + + _trackedNames.Remove(name); + } + } + + internal void RestoreFinalValues( + IReadOnlyDictionary values, + Action warn) + { + if (values == null) + { + return; + } + + foreach (var pair in values) + { + var result = _api.SetParameterByName( + pair.Key, + pair.Value, + ignoreSeekSpeed: true); + if (result == RESULT.OK) + { + _trackedNames.Add(pair.Key); + continue; + } + + warn?.Invoke( + $"[AudioManager] 恢复 Yarn 全局参数 {pair.Key} 失败:{result}。跳过该参数。"); + } + } + } +} diff --git a/Assets/Scripts/Framework/AudioKit/YarnGlobalParameterRegistry.cs.meta b/Assets/Scripts/Framework/AudioKit/YarnGlobalParameterRegistry.cs.meta new file mode 100644 index 000000000..5eacdc051 --- /dev/null +++ b/Assets/Scripts/Framework/AudioKit/YarnGlobalParameterRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe8472981b434b1d94e494cca8e3801d diff --git a/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs b/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs index abf44e1b1..1cd63e8a8 100644 --- a/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs +++ b/Assets/Scripts/SaveSystem/Providers/AudioSnapshotProvider.cs @@ -37,7 +37,7 @@ namespace AibisDream.SaveSystem return; } - AudioManager.Instance.RestoreSnapshot(audio); + AudioManager.Instance.RestoreSnapshot(audio, context.Warn); } } } diff --git a/Assets/Scripts/SaveSystem/SaveSnapshot.cs b/Assets/Scripts/SaveSystem/SaveSnapshot.cs index 92dcb1299..a9961d27f 100644 --- a/Assets/Scripts/SaveSystem/SaveSnapshot.cs +++ b/Assets/Scripts/SaveSystem/SaveSnapshot.cs @@ -123,7 +123,10 @@ namespace AibisDream.SaveSystem public bool isTalking; } - /// sections["audio"]:FMOD 音乐/SFX 终态与 AMB 状态(不记播放进度)。 + /// + /// sections["audio"]:FMOD 音乐/SFX 终态、AMB 状态与 Yarn 全局参数 finalValue + /// (不记 Event 播放进度)。 + /// [Serializable] public class AudioSnapshotDto { @@ -134,6 +137,9 @@ namespace AibisDream.SaveSystem /// 枚举名。 public string ambState; + + /// 由 Yarn set_global_param 修改过的参数名 → 捕获时的 FMOD finalValue。 + public Dictionary yarnGlobalParameters = new(); } ///