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))
{