diff --git a/Assets/Editor/TextSpeedSettingsTests.cs b/Assets/Editor/TextSpeedSettingsTests.cs new file mode 100644 index 000000000..06429d97c --- /dev/null +++ b/Assets/Editor/TextSpeedSettingsTests.cs @@ -0,0 +1,75 @@ +using NUnit.Framework; + +namespace AibisDream.SystemEditor.Tests +{ + public sealed class TextSpeedSettingsTests + { + [SetUp] + public void SetUp() + { + TextSpeedSettings.Reset(); + } + + [TearDown] + public void TearDown() + { + TextSpeedSettings.Reset(); + } + + [TestCase("0.5", TextSpeedSettings.SlowMultiplier, TextSpeedSettings.SlowValue)] + [TestCase("0.50", TextSpeedSettings.SlowMultiplier, TextSpeedSettings.SlowValue)] + [TestCase("1", TextSpeedSettings.DefaultMultiplier, TextSpeedSettings.DefaultValue)] + [TestCase("1.0", TextSpeedSettings.DefaultMultiplier, TextSpeedSettings.DefaultValue)] + [TestCase("3", TextSpeedSettings.FastMultiplier, TextSpeedSettings.FastValue)] + [TestCase("3.0", TextSpeedSettings.FastMultiplier, TextSpeedSettings.FastValue)] + public void TryNormalize_SupportedNumericValue_ReturnsCanonicalValue( + string rawValue, + float expectedMultiplier, + string expectedCanonicalValue) + { + var result = TextSpeedSettings.TryNormalize( + rawValue, + out var multiplier, + out var canonicalValue); + + Assert.That(result, Is.True); + Assert.That(multiplier, Is.EqualTo(expectedMultiplier)); + Assert.That(canonicalValue, Is.EqualTo(expectedCanonicalValue)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("invalid")] + [TestCase("NaN")] + [TestCase("Infinity")] + [TestCase("-1")] + [TestCase("0")] + [TestCase("2")] + [TestCase("4")] + public void TryNormalize_InvalidOrUnsupportedValue_ReturnsDefault( + string rawValue) + { + var result = TextSpeedSettings.TryNormalize( + rawValue, + out var multiplier, + out var canonicalValue); + + Assert.That(result, Is.False); + Assert.That(multiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier)); + Assert.That(canonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue)); + } + + [Test] + public void Reset_AfterApplyingFastValue_RestoresDefault() + { + Assert.That(TextSpeedSettings.ApplyStoredValue("3"), Is.True); + Assert.That(TextSpeedSettings.CurrentMultiplier, Is.EqualTo(TextSpeedSettings.FastMultiplier)); + + TextSpeedSettings.Reset(); + + Assert.That(TextSpeedSettings.CurrentMultiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier)); + Assert.That(TextSpeedSettings.CurrentCanonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue)); + } + } +} diff --git a/Assets/Editor/TextSpeedSettingsTests.cs.meta b/Assets/Editor/TextSpeedSettingsTests.cs.meta new file mode 100644 index 000000000..254f726e6 --- /dev/null +++ b/Assets/Editor/TextSpeedSettingsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58d2e4c8752446518dc1357dd586e504 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Dialog System/LineSyncToken.cs b/Assets/Scripts/Dialog System/LineSyncToken.cs index ee3b5f69c..06688e311 100644 --- a/Assets/Scripts/Dialog System/LineSyncToken.cs +++ b/Assets/Scripts/Dialog System/LineSyncToken.cs @@ -112,11 +112,12 @@ namespace AibisDream private void Advance() { - EnumEventSystem.Global.Send(DialogEventEnum.LineEnd); OnAdvance?.Invoke(); OnAdvance = null; _nextStep?.Invoke(); state = LineSyncState.Advanced; + // LineEnd 表示当前行的所有推进回调已经完成,可安全替换其 UI 资源。 + EnumEventSystem.Global.Send(DialogEventEnum.LineEnd); _completion.TrySetResult(true); } diff --git a/Assets/Scripts/FixSystemNew/Screen System/FixPanel/ScreenPanel.cs b/Assets/Scripts/FixSystemNew/Screen System/FixPanel/ScreenPanel.cs index 897de43cb..0bd343466 100644 --- a/Assets/Scripts/FixSystemNew/Screen System/FixPanel/ScreenPanel.cs +++ b/Assets/Scripts/FixSystemNew/Screen System/FixPanel/ScreenPanel.cs @@ -2,7 +2,6 @@ using AibisDream.UI; using AibisDream.Framework; using System; -using System.Globalization; using TMPro; namespace AibisDream.FixSystem @@ -52,7 +51,7 @@ namespace AibisDream.FixSystem title.overrideColorTags = true; title.color = isWarning ? WarningRedColor : AmberColor; title.text = token.lineInfo.character.GetActorName(); - logText.SetTypewriterSpeed(GetTextSpeedValue()); + logText.SetTypewriterSpeed(TextSpeedSettings.CurrentMultiplier); token.TextStart(); logText.AddText(token.lineInfo.lineText); } @@ -62,12 +61,6 @@ namespace AibisDream.FixSystem } } - private float GetTextSpeedValue() - { - var textSpeedStr = SettingLoader.Instance.TryRead("TextSpeed", out string textSpeed) ? textSpeed : "1"; - return float.TryParse(textSpeedStr, NumberStyles.Float, CultureInfo.InvariantCulture, out float result) ? result : 1f; - } - public void ClearScreen() { logText.Clear(); diff --git a/Assets/Scripts/Game Loop/SettingLoader.cs b/Assets/Scripts/Game Loop/SettingLoader.cs index d46a15b2e..fbe144f44 100644 --- a/Assets/Scripts/Game Loop/SettingLoader.cs +++ b/Assets/Scripts/Game Loop/SettingLoader.cs @@ -21,6 +21,8 @@ namespace AibisDream private SettingLoader() { + // Enter Play Mode 关闭 Domain Reload 时静态状态可能保留,加载配置前先恢复默认值。 + TextSpeedSettings.Reset(); // 初始化设置文件(如果不存在) InitSettingFile(); // 读取数据 @@ -66,7 +68,10 @@ namespace AibisDream AudioManager.Instance.SetVolume(float.Parse(value, CultureInfo.InvariantCulture)); break; case "TextSpeed": - EnumEventSystem.Global.Send(SettingChangeEvent.TextSpeed, value); + TextSpeedSettings.ApplyStoredValue(value); + EnumEventSystem.Global.Send( + SettingChangeEvent.TextSpeed, + TextSpeedSettings.CurrentCanonicalValue); break; case "Resolution": // TODO 调整分辨率 diff --git a/Assets/Scripts/Game Loop/TextSpeedSettings.cs b/Assets/Scripts/Game Loop/TextSpeedSettings.cs new file mode 100644 index 000000000..9ea76f9b8 --- /dev/null +++ b/Assets/Scripts/Game Loop/TextSpeedSettings.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using UnityEngine; + +namespace AibisDream +{ + /// + /// 文字速度设置的运行时快照与规范化规则。 + /// 各文字视图在开始新行时读取 CurrentMultiplier,确保设置不会改变正在播放的行。 + /// + public static class TextSpeedSettings + { + public const float SlowMultiplier = 0.5f; + public const float DefaultMultiplier = 1f; + public const float FastMultiplier = 3f; + + public const string SlowValue = "0.5"; + public const string DefaultValue = "1.0"; + public const string FastValue = "3.0"; + + private static readonly HashSet WarnedInvalidValues = new(StringComparer.Ordinal); + + public static float CurrentMultiplier { get; private set; } = DefaultMultiplier; + public static string CurrentCanonicalValue { get; private set; } = DefaultValue; + + public static void Reset() + { + CurrentMultiplier = DefaultMultiplier; + CurrentCanonicalValue = DefaultValue; + WarnedInvalidValues.Clear(); + } + + public static bool ApplyStoredValue(string value) + { + var isValid = TryNormalize(value, out var multiplier, out var canonicalValue); + CurrentMultiplier = multiplier; + CurrentCanonicalValue = canonicalValue; + + if (!isValid) + { + WarnInvalidValueOnce(value); + } + + return isValid; + } + + public static bool TryNormalize(string value, out float multiplier, out string canonicalValue) + { + multiplier = DefaultMultiplier; + canonicalValue = DefaultValue; + + if (string.IsNullOrWhiteSpace(value) || + !float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) || + float.IsNaN(parsed) || + float.IsInfinity(parsed)) + { + return false; + } + + if (Mathf.Approximately(parsed, SlowMultiplier)) + { + multiplier = SlowMultiplier; + canonicalValue = SlowValue; + return true; + } + + if (Mathf.Approximately(parsed, DefaultMultiplier)) + { + multiplier = DefaultMultiplier; + canonicalValue = DefaultValue; + return true; + } + + if (Mathf.Approximately(parsed, FastMultiplier)) + { + multiplier = FastMultiplier; + canonicalValue = FastValue; + return true; + } + + return false; + } + + private static void WarnInvalidValueOnce(string value) + { + var warningKey = value ?? ""; + if (!WarnedInvalidValues.Add(warningKey)) + { + return; + } + + Debug.LogWarning( + $"[TextSpeedSettings] 不支持的文字速度 '{warningKey}',本次运行使用默认值 {DefaultValue}。"); + } + } +} diff --git a/Assets/Scripts/Game Loop/TextSpeedSettings.cs.meta b/Assets/Scripts/Game Loop/TextSpeedSettings.cs.meta new file mode 100644 index 000000000..e5d34d347 --- /dev/null +++ b/Assets/Scripts/Game Loop/TextSpeedSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7bc89b836ad94a929a395d48a94f17f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/Components/TerminalText.cs b/Assets/Scripts/UI/Components/TerminalText.cs index 016b8cc1e..b1ed3c3ee 100644 --- a/Assets/Scripts/UI/Components/TerminalText.cs +++ b/Assets/Scripts/UI/Components/TerminalText.cs @@ -248,6 +248,7 @@ namespace AibisDream.UI { if (typewriter != null) { + typewriter.resetTypingSpeedAtStartup = false; typewriter.SetTypewriterSpeed(speed); } } diff --git a/Assets/Scripts/UI/DialogUI/Bubbles/Bubble.cs b/Assets/Scripts/UI/DialogUI/Bubbles/Bubble.cs index 758bcea8c..4e01c45f0 100644 --- a/Assets/Scripts/UI/DialogUI/Bubbles/Bubble.cs +++ b/Assets/Scripts/UI/DialogUI/Bubbles/Bubble.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using AibisDream.Framework; using AibisDream.Kit; using AibisDream.Utility; @@ -60,7 +59,6 @@ namespace AibisDream SetStyle(data.bubbleStyle, data.pivotType); // 注册事件 _typewriter.onTextShowed.AddListener(InvokeLineShown); - EnumEventSystem.Global.Register(SettingChangeEvent.TextSpeed, OnTextSpeedChanged); } private void InvokeLineShown() @@ -88,16 +86,6 @@ namespace AibisDream : $"{bubbleStyle.bubbleSpriteAddress}[{pivotType}]"; _bubbleImage.sprite = ResourceKit.LoadAssetSync(spritePath); - - // 初始化文字速度 - var speedStr = SettingLoader.Instance.TryRead("TextSpeed", out string textSpeed) ? textSpeed : "1"; - OnTextSpeedChanged(speedStr); - } - - private void OnTextSpeedChanged(string value) - { - float speed = float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float result) ? result : 1f; - _typewriter.SetTypewriterSpeed(speed); } private void InitRefs() @@ -135,9 +123,16 @@ namespace AibisDream public void ShowLine(string line) { HandleLayout(line); + ApplyTextSpeedSnapshot(); _typewriter.ShowText(line); } + private void ApplyTextSpeedSnapshot() + { + _typewriter.resetTypingSpeedAtStartup = false; + _typewriter.SetTypewriterSpeed(TextSpeedSettings.CurrentMultiplier); + } + public bool SkipLine() { if (_typewriter.isShowingText) diff --git a/Assets/Scripts/UI/DialogUI/CenterText.cs b/Assets/Scripts/UI/DialogUI/CenterText.cs index b2653ac09..5a575b6d3 100644 --- a/Assets/Scripts/UI/DialogUI/CenterText.cs +++ b/Assets/Scripts/UI/DialogUI/CenterText.cs @@ -38,10 +38,17 @@ namespace AibisDream syncToken.OnAdvance += AfterAdvance; // 播放文字 + ApplyTextSpeedSnapshot(); syncToken.TextStart(); content.ShowText(syncToken.lineInfo.lineText); } + private void ApplyTextSpeedSnapshot() + { + content.resetTypingSpeedAtStartup = false; + content.SetTypewriterSpeed(TextSpeedSettings.CurrentMultiplier); + } + private async void AfterAdvance() { _autoHideToken = new CancellationTokenSource(); diff --git a/Assets/Scripts/UI/DialogUI/DialogUIManager.cs b/Assets/Scripts/UI/DialogUI/DialogUIManager.cs index 40f7d674a..9c8020846 100644 --- a/Assets/Scripts/UI/DialogUI/DialogUIManager.cs +++ b/Assets/Scripts/UI/DialogUI/DialogUIManager.cs @@ -13,6 +13,7 @@ namespace AibisDream [SerializeField] private TMP_Text widthEstimation; private CanvasGroup _canvasGroup; + private BubbleSlotGroupData? _pendingBubbleData; public override void OnSingletonInit() { @@ -25,19 +26,24 @@ namespace AibisDream EnumEventSystem.Global.Register(DialogEventEnum.LineStart, AddDialogueLine); EnumEventSystem.Global.Register(DialogEventEnum.LineHistoryAppend, AddDialogueLine); EnumEventSystem.Global.Register(DialogEventEnum.OptionSelected, AddDialogueLine); + EnumEventSystem.Global.Register(DialogEventEnum.LineEnd, ApplyPendingBubbleLoad); EnumEventSystem.Global.Register(EventEnum.NextYarn, CleanDialogHistory); EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, CleanDialogHistory); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, ClearPendingBubbleLoad); EnumEventSystem.Global.Register(GameLifecycleEvent.SessionPaused, CloseCanvas); EnumEventSystem.Global.Register(GameLifecycleEvent.SessionResumed, OpenCanvas); } public override void OnSingletonDestroy() { + ClearPendingBubbleLoad(); EnumEventSystem.Global.UnRegister(DialogEventEnum.LineStart, AddDialogueLine); EnumEventSystem.Global.UnRegister(DialogEventEnum.LineHistoryAppend, AddDialogueLine); EnumEventSystem.Global.UnRegister(DialogEventEnum.OptionSelected, AddDialogueLine); + EnumEventSystem.Global.UnRegister(DialogEventEnum.LineEnd, ApplyPendingBubbleLoad); EnumEventSystem.Global.UnRegister(EventEnum.NextYarn, CleanDialogHistory); EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, CleanDialogHistory); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, ClearPendingBubbleLoad); EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionPaused, CloseCanvas); EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionResumed, OpenCanvas); } @@ -99,11 +105,45 @@ namespace AibisDream } public void LoadBubbles(BubbleSlotGroupData data) + { + var token = DialogController.Instance != null + ? DialogController.Instance.lineSyncToken + : null; + + if (token != null && token.state != LineSyncState.Advanced) + { + // 同一行内的连续请求采用最后一次请求,当前气泡保留到台词推进。 + _pendingBubbleData = data; + return; + } + + _pendingBubbleData = null; + ApplyBubbleLoadImmediately(data); + } + + private void ApplyPendingBubbleLoad() + { + if (!_pendingBubbleData.HasValue) + { + return; + } + + var data = _pendingBubbleData.Value; + _pendingBubbleData = null; + ApplyBubbleLoadImmediately(data); + } + + private void ApplyBubbleLoadImmediately(BubbleSlotGroupData data) { lineView.LoadBubbleData(data); optionView.LoadBubbles(data); } + private void ClearPendingBubbleLoad() + { + _pendingBubbleData = null; + } + public float EstimateTextWidth(string text, float fontSize) { widthEstimation.fontSize = fontSize; diff --git a/Assets/Scripts/UI/DialogUI/OnelineBox.cs b/Assets/Scripts/UI/DialogUI/OnelineBox.cs index 8e2ca88cf..ed4df0ffd 100644 --- a/Assets/Scripts/UI/DialogUI/OnelineBox.cs +++ b/Assets/Scripts/UI/DialogUI/OnelineBox.cs @@ -46,6 +46,7 @@ namespace AibisDream var lineText = GetOneLineText(syncToken.lineInfo); HandleLayout(lineText); + ApplyTextSpeedSnapshot(); // 注册事件(UI 就绪后再挂回调) OnTextShowed += syncToken.TextShown; @@ -56,6 +57,12 @@ namespace AibisDream content.ShowText(lineText); } + private void ApplyTextSpeedSnapshot() + { + content.resetTypingSpeedAtStartup = false; + content.SetTypewriterSpeed(TextSpeedSettings.CurrentMultiplier); + } + private string GetOneLineText(LineInfo lineInfo) { if (string.IsNullOrEmpty(lineInfo.character.GetActorName())) @@ -172,4 +179,4 @@ namespace AibisDream HideDialog(); } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/UI/Terminal/TerminalSettingPanel.cs b/Assets/Scripts/UI/Terminal/TerminalSettingPanel.cs index 5d995b3d7..d84bcedfc 100644 --- a/Assets/Scripts/UI/Terminal/TerminalSettingPanel.cs +++ b/Assets/Scripts/UI/Terminal/TerminalSettingPanel.cs @@ -40,9 +40,9 @@ namespace AibisDream.UI private static readonly UIFormOption[] TextSpeedOptions = { - new UIFormOption { prop = "0.5", label = "setting_textSpeed_slow" }, - new UIFormOption { prop = "1.0", label = "setting_textSpeed_default" }, - new UIFormOption { prop = "3.0", label = "setting_textSpeed_fast" }, + new UIFormOption { prop = TextSpeedSettings.SlowValue, label = "setting_textSpeed_slow" }, + new UIFormOption { prop = TextSpeedSettings.DefaultValue, label = "setting_textSpeed_default" }, + new UIFormOption { prop = TextSpeedSettings.FastValue, label = "setting_textSpeed_fast" }, }; public void SetOwner(ITerminalPanelHost owner) @@ -134,10 +134,7 @@ namespace AibisDream.UI volumeSlider?.SetValue(volume); } - if (settings.TryGetValue("TextSpeed", out var textSpeed)) - { - textSpeedOption?.SetValue(textSpeed); - } + textSpeedOption?.SetValue(TextSpeedSettings.CurrentCanonicalValue); if (settings.TryGetValue("WindowMode", out var windowMode)) {