This commit is contained in:
SoulliesOfficial
2026-07-24 03:43:11 -04:00
parent 48e7364981
commit fe00ecfcc7
90 changed files with 9610 additions and 461 deletions

View File

@@ -63,6 +63,11 @@ namespace Ichni.Story
issues.Add("[Warning] Yarn Project 未配置TextBlock 无法开始对应的 Yarn 节点。");
}
if (yarnLineStringTable == null || yarnLineStringTable.IsEmpty)
{
issues.Add("[Error] Yarn Line String Table 未配置Player 中无法在启动对话前加载当前 Locale 的台词表。");
}
foreach (StoryVariableDefinition variable in initialVariables)
{
if (variable == null)
@@ -777,4 +782,3 @@ namespace Ichni.Story
}
#endif

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
using Yarn.Unity;
namespace Ichni.Story
@@ -22,6 +23,11 @@ namespace Ichni.Story
[Tooltip("该章节对应的 YarnProject 编译资产。TextBlock 会在运行时从此项目中执行配置的 Yarn Node。")]
public YarnProject yarnProject;
[TitleGroup("Chapter Settings")]
[LabelText("Yarn Line String Table")]
[Tooltip("该章节 Yarn 台词对应的 Unity Localization String Table。运行时会在启动对话前显式加载当前 Locale 的表;此配置必须与 DialogueRunner 上 UnityLocalisedLineProvider 使用的表一致。")]
public LocalizedStringTable yarnLineStringTable;
/// <summary>
/// 本章节永久剧情变量的默认值。它们只属于本章节,且不会直接写入存档;
/// 玩家第一次通过 Yarn 或其它剧情入口写入同名变量后,存档值才会覆盖这里的默认值。

View File

@@ -1,9 +1,10 @@
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using Ichni.Story.UI;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
using UnityEngine.ResourceManagement.AsyncOperations;
using Yarn.Unity;
using Yarn.Unity.UnityLocalization;
@@ -25,10 +26,6 @@ namespace Ichni.Story.Dialogue
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
public StoryUIPage storyUIPage;
[Header("Localization")]
[Tooltip("所有章节 Yarn 台词共用的 Unity Localization String Table建议命名 YarnLines。留空时保留当前 UnityLocalisedLineProvider 的既有表配置。")]
public LocalizedStringTable yarnLinesTable;
/// <summary>
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令
/// 把"解锁提示"排队到对话结束后弹出。
@@ -43,6 +40,9 @@ namespace Ichni.Story.Dialogue
private ChapterStorySave _activeDialogueSnapshot;
private string _activeDialogueChapterIndex;
private bool _isCancellingDialogue;
private Coroutine _dialogueStartupCoroutine;
private const float DialogueTableLoadTimeoutSeconds = 30f;
/// <summary>
/// 当前对话对应的 TextBlock ID。Yarn 选项记忆与变量指令使用它定位所属章节 Block
@@ -62,7 +62,6 @@ namespace Ichni.Story.Dialogue
private void Awake()
{
instance = this;
EnsureUnityLocalisedLineProvider();
}
private void Start()
@@ -110,33 +109,41 @@ namespace Ichni.Story.Dialogue
{
if (block == null) return;
Debug.Log($"[YarnDiag] StoryDialogueController.PlayBlock: block={block.blockId}, node={block.YarnNodeName}");
if (dialogueRunner == null)
{
Debug.LogError("[StoryDialogueController] 未配置 DialogueRunner无法开始对话");
Debug.LogError("[YarnDiag] [ERROR] 未配置 DialogueRunner无法开始对话");
return;
}
if (dialogueRunner.IsDialogueRunning)
if (dialogueRunner.IsDialogueRunning || _dialogueStartupCoroutine != null)
{
Debug.LogWarning("[StoryDialogueController] 已有对话在进行中,忽略本次点击");
Debug.LogWarning("[YarnDiag] [WARNING] 已有对话正在运行或启动,忽略本次点击");
return;
}
if (string.IsNullOrEmpty(block.YarnNodeName))
{
Debug.LogWarning($"[StoryDialogueController] block '{block.blockId}' 未配置 yarnNodeName无法开始对话");
Debug.LogWarning($"[YarnDiag] [WARNING] block '{block.blockId}' 未配置 yarnNodeName无法开始对话");
return;
}
// 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner
// 将 DialogueRunner 的 YarnProject 对齐到当前章节,并验证场景中持久化的 LineProvider 配置。
EnsureProjectForCurrentChapter();
if (!ValidateUnityLocalisedLineProvider())
{
return;
}
Debug.Log($"[YarnDiag] YarnProject={dialogueRunner.YarnProject?.name}, NodesCount={(dialogueRunner.YarnProject?.Program != null ? dialogueRunner.YarnProject.Program.Nodes.Count : 0)}, LineProviderType={dialogueRunner.LineProvider.GetType().FullName}");
// StartDialogue 对缺失 Node 只会在内部抛出/记录错误;必须在开启事务前预检,
// 否则会留下一个没有机会提交或回滚的半开事务。
if (dialogueRunner.YarnProject?.Program == null ||
!dialogueRunner.YarnProject.Program.Nodes.ContainsKey(block.YarnNodeName))
{
Debug.LogError($"[StoryDialogueController] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'", dialogueRunner);
Debug.LogError($"[YarnDiag] [ERROR] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'", dialogueRunner);
return;
}
@@ -164,35 +171,139 @@ namespace Ichni.Story.Dialogue
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
StoryProgress.BeginBlockMutation(_activeBlockId);
if (storyUIPage != null)
storyUIPage.FadeOut();
// 启动异常会回滚本次章节事务;正常结束仍由 onDialogueComplete 统一提交。
StartDialogueSafely(block.YarnNodeName).Forget();
// Unity Localization 的 AsyncOperationHandle 原生支持协程等待。
// 先在项目侧显式加载当前章节台词表,再启动 Yarn不修改 Yarn Spinner Package
// 也不把 Unity Addressables Handle 转换为 YarnTask。
_dialogueStartupCoroutine = StartCoroutine(StartDialogueSafely(block.YarnNodeName));
}
/// <summary>
/// 以受保护方式启动 Yarn。缺失 Provider、Presenter 等启动级异常不应让 StorySave 的事务永久处于活动状态,
/// 因而会被转换为 Failed 流程并完整恢复进入对话前的章节快照。
/// 使用 Unity Localization 的公开 API 加载当前章节台词表。
/// <para>
/// <see cref="UnityLocalisedLineProvider.PrepareForLinesAsync"/> 在 Yarn Spinner 3.2.1 中是 no-op
/// 不能用来预载 String Table因此这里直接等待 <see cref="UnityEngine.Localization.LocalizedStringTable.GetTableAsync"/>。
/// </para>
/// </summary>
private async YarnTask StartDialogueSafely(string yarnNodeName)
private IEnumerator StartDialogueSafely(string yarnNodeName)
{
LocalizationBootstrap.BeginInitialization();
while (!LocalizationBootstrap.IsReady && !LocalizationBootstrap.HasFailed)
{
LocalizationBootstrap.RefreshState();
yield return null;
}
if (!LocalizationBootstrap.IsReady)
{
FailDialogueStartup($"Localization 初始化失败:{LocalizationBootstrap.FailureReason}");
yield break;
}
StoryData storyData = StoryManager.instance?.treeController?.ActiveStoryData;
if (storyData?.yarnLineStringTable == null || storyData.yarnLineStringTable.IsEmpty)
{
FailDialogueStartup(
$"章节 '{storyData?.chapterIndex ?? "<null>"}' 未配置 Yarn Line String Table。");
yield break;
}
AsyncOperationHandle<StringTable> tableOperation;
//Debug.Log(storyData.yarnLineStringTable.GetTable());
try
{
await dialogueRunner.StartDialogue(yarnNodeName);
// LocalizedStringTable 会自动使用 LocalizationSettings.SelectedLocale。
// 这与 UnityLocalisedLineProvider 后续读取台词时使用的 Locale 保持一致。
Debug.Log($"[YarnDiag] 开始加载 Yarn 台词表。Table={storyData.yarnLineStringTable.TableReference}, Locale={UnityEngine.Localization.Settings.LocalizationSettings.SelectedLocale?.Identifier.Code ?? "<null>"}");
tableOperation = storyData.yarnLineStringTable.GetTableAsync();
}
catch (System.Exception exception)
{
Debug.LogException(exception, dialogueRunner);
// 启动阶段可能已经让部分 Presenter 进入展示状态;先请求停止,再立即清理事务。
// 后续若收到 Runner 的完成事件,会因 activeBlockId 已被清空而被安全忽略。
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
{
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
}
RestoreInterruptedDialogue("启动 Yarn 对话失败");
FailDialogueStartup("创建 Yarn 台词表加载操作失败。", exception);
yield break;
}
Debug.Log(
$"[YarnDiag] 开始加载章节台词表。Chapter={storyData.chapterIndex}, " +
$"Table={storyData.yarnLineStringTable.TableReference}, " +
$"Locale={UnityEngine.Localization.Settings.LocalizationSettings.SelectedLocale?.Identifier.Code ?? "<null>"}");
float loadStartedAt = Time.realtimeSinceStartup;
float nextProgressLogAt = loadStartedAt + 5f;
while (!tableOperation.IsDone)
{
float elapsed = Time.realtimeSinceStartup - loadStartedAt;
if (elapsed >= DialogueTableLoadTimeoutSeconds)
{
FailDialogueStartup(
$"加载 Yarn 台词表超时。Table={storyData.yarnLineStringTable.TableReference}, " +
$"Elapsed={elapsed:F1}s, Percent={tableOperation.PercentComplete:P0}");
yield break;
}
if (Time.realtimeSinceStartup >= nextProgressLogAt)
{
Debug.LogWarning(
$"[YarnDiag] Yarn 台词表仍在加载。Elapsed={elapsed:F1}s, " +
$"Percent={tableOperation.PercentComplete:P0}");
nextProgressLogAt += 5f;
}
yield return null;
}
if (tableOperation.Status != AsyncOperationStatus.Succeeded || tableOperation.Result == null)
{
FailDialogueStartup(
$"Yarn 台词表加载失败。Table={storyData.yarnLineStringTable.TableReference}",
tableOperation.OperationException);
yield break;
}
Debug.Log(
$"[YarnDiag] 章节台词表加载完成。Locale={tableOperation.Result.LocaleIdentifier.Code}, " +
$"Entries={tableOperation.Result.Count}");
_dialogueStartupCoroutine = null;
storyUIPage?.FadeOut();
StartDialogueRunnerSafely(yarnNodeName).Forget();
}
/// <summary>
/// 调用 Yarn Spinner 的公开 <see cref="DialogueRunner.StartDialogue"/> API。
/// 返回时表示所有 Presenter 已完成 OnDialogueStartedAsync且 Runner 已调用 Dialogue.Continue。
/// </summary>
private async YarnTask StartDialogueRunnerSafely(string yarnNodeName)
{
try
{
Debug.Log($"[YarnDiag] 调用 DialogueRunner.StartDialogue。Node={yarnNodeName}");
await dialogueRunner.StartDialogue(yarnNodeName);
Debug.Log(
$"[YarnDiag] DialogueRunner 初始启动完成。Node={yarnNodeName}, " +
$"IsDialogueRunning={dialogueRunner.IsDialogueRunning}");
}
catch (System.Exception exception)
{
FailDialogueStartup("启动 Yarn 对话失败。", exception);
}
}
private void FailDialogueStartup(string reason, System.Exception exception = null)
{
_dialogueStartupCoroutine = null;
if (exception != null)
Debug.LogException(exception, dialogueRunner);
Debug.LogError($"[YarnDiag] {reason}", dialogueRunner);
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
{
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
}
RestoreInterruptedDialogue(reason);
}
/// <summary>
@@ -202,12 +313,22 @@ namespace Ichni.Story.Dialogue
/// </summary>
public bool CancelActiveDialogue()
{
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId) ||
dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId))
{
return false;
}
if (_dialogueStartupCoroutine != null)
{
StopCoroutine(_dialogueStartupCoroutine);
_dialogueStartupCoroutine = null;
RestoreInterruptedDialogue("玩家在对话启动阶段中途退出");
return true;
}
if (dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
return false;
_isCancellingDialogue = true;
_dialogueEndActions.Clear();
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
@@ -246,44 +367,29 @@ namespace Ichni.Story.Dialogue
}
/// <summary>
/// DialogueRunner.lineProvider 为 [SerializeReference] internal 字段
/// Inspector 的 "Use Unity Localised Line Provider" 按钮有时无法将 MonoBehaviour
/// 引用正确持久化到该字段;此方法在运行时用反射补救,确保使用正确的 Provider。
/// 验证 DialogueRunner 序列化的 LineProvider 是否为 UnityLocalisedLineProvider
/// <para>
/// LineProvider 应通过 Yarn Spinner Inspector 持久化配置;运行时不使用反射修改 Package 内部字段,
/// 这样 Editor、Mono 与 IL2CPP Build 使用完全相同的装配结果。
/// </para>
/// </summary>
private void EnsureUnityLocalisedLineProvider()
private bool ValidateUnityLocalisedLineProvider()
{
if (dialogueRunner == null) return;
FieldInfo field = typeof(DialogueRunner).GetField(
"lineProvider",
BindingFlags.NonPublic | BindingFlags.Instance);
if (field == null) return;
UnityLocalisedLineProvider provider = field.GetValue(dialogueRunner) as UnityLocalisedLineProvider;
provider ??= dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
if (provider != null)
if (dialogueRunner == null)
{
if (field.GetValue(dialogueRunner) != provider)
{
field.SetValue(dialogueRunner, provider);
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
}
return false;
}
// Yarn 的 Localized Line Provider 本身只持有一个 String Table 引用;统一使用 YarnLines
// 可避免在切换章节时依赖反射替换不同表。未配置时不覆盖旧场景,便于分步迁移现有 Chapter0_Lines。
if (yarnLinesTable != null && !yarnLinesTable.IsEmpty)
{
FieldInfo tableField = typeof(UnityLocalisedLineProvider).GetField(
"stringsTable", BindingFlags.NonPublic | BindingFlags.Instance);
tableField?.SetValue(provider, yarnLinesTable);
}
}
else
if (dialogueRunner.LineProvider is UnityLocalisedLineProvider)
{
Debug.LogWarning("[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable.");
return true;
}
Debug.LogError(
"[StoryDialogueController] DialogueRunner.lineProvider 未配置为 UnityLocalisedLineProvider。" +
"请在 Yarn Spinner Inspector 中使用 Unity Localization Line Provider并配置台词 String Table。",
dialogueRunner);
return false;
}
private void HandleDialogueComplete()

View File

@@ -92,8 +92,10 @@ namespace Ichni.Story.Dialogue
// ── DialoguePresenterBase 实现 ───────────────────────────────────────────
public override async YarnTask OnDialogueStartedAsync()
public override YarnTask OnDialogueStartedAsync()
{
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 开始。");
// 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。
_isFastForwarding = false;
_typewriterComplete = false;
@@ -101,18 +103,17 @@ namespace Ichni.Story.Dialogue
// 对话记录只保留当前一次 TextBlock每次 Yarn 对话真正开始时清空上一段残留内容。
DialogueHistory.BeginSession();
if (Page != null)
{
var tcs = new YarnTaskCompletionSource<bool>();
Page.PlayFadeIn(() => tcs.TrySetResult(true));
await tcs.Task;
}
Page.PlayFadeIn();
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 完成。");
return YarnTask.CompletedTask;
}
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
if (Page == null) return;
Debug.Log($"[YarnDiag] VNDialoguePresenter.RunLineAsync: LineID='{line.TextID}', Speaker='{line.CharacterName}', Text='{line.TextWithoutCharacterName.Text}', RawText='{line.RawText}'");
// 确保选项区不可见;台词区始终可见
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
@@ -126,6 +127,11 @@ namespace Ichni.Story.Dialogue
// ── 打字效果 ──
string fullText = line.TextWithoutCharacterName.Text;
if (string.IsNullOrEmpty(fullText) && !string.IsNullOrEmpty(line.RawText))
{
fullText = line.RawText;
Debug.LogWarning($"[YarnDiag] [WARNING] 台词 '{line.TextID}' 本地化解析结果为空,回退使用 RawText: '{fullText}'");
}
// 记录的是本次实际展示时已经解析完成的本地化文本,而非 Yarn Key。
DialogueHistory.AddLine(line.CharacterName, fullText);
_typewriterComplete = false;
@@ -285,23 +291,39 @@ namespace Ichni.Story.Dialogue
private void OnAdvanceButtonClicked()
{
Debug.Log($"[YarnDiag] OnAdvanceButtonClicked 推进按钮被点击_runner={(_runner != null ? _runner.name : "null")}, _typewriterComplete={_typewriterComplete}, _isFastForwarding={_isFastForwarding}, _optionTcs={(_optionTcs != null)}");
// 未记忆选项必须由 ChoiceButton 明确选择;推进按钮不能越过当前选择。
if (_optionTcs != null)
{
Debug.LogWarning("[YarnDiag] OnAdvanceButtonClicked 被拦截:正在等待选项选择。");
return;
}
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
if (_isFastForwarding)
{
Debug.Log("[YarnDiag] OnAdvanceButtonClicked 打断快进模式。");
_isFastForwarding = false;
return;
}
if (_runner == null) return;
if (_runner == null)
{
Debug.LogError("[YarnDiag] [ERROR] OnAdvanceButtonClicked 拦截_runner 为 null");
return;
}
if (!_typewriterComplete)
{
Debug.Log("[YarnDiag] RequestHurryUpLine (跳过打字效果)");
_runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果
}
else
{
Debug.Log("[YarnDiag] RequestNextLine (推进下一行)");
_runner.RequestNextLine(); // 第二次点击:推进到下一行
}
}
private void OnFastForwardButtonClicked()

View File

@@ -326,7 +326,7 @@ namespace Ichni.Story
/// <summary>本地化失败时显示 Key确保测试阶段仍能定位缺失条目而不是得到空白气泡。</summary>
private string GetLocalizedText(StoryHelperData helperData, string key, string fallback)
{
if (string.IsNullOrEmpty(key))
if (string.IsNullOrEmpty(key) || helperData == null)
return fallback;
try

View File

@@ -76,7 +76,7 @@ namespace Ichni.Story
if (MenuManager.instance == null || InformationTransistor.instance == null ||
!MenuManager.instance.CanBeginGame)
{
Debug.LogWarning("[TutorialFlowController] MenuManager、InformationTransistor 或 GameScene 预加载未就绪,无法进入教程。");
Debug.LogWarning("[TutorialFlowController] MenuManager、InformationTransistor 未就绪,或菜单已经接受其它进入请求,无法进入教程。");
return false;
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Ichni.Diagnostics;
using Ichni.Menu.UI;
using TMPro;
using UnityEngine;
@@ -421,12 +422,38 @@ namespace Ichni.Story.UI
}
}
private void OnSelectedLocaleChanged(Locale _)
private async void OnSelectedLocaleChanged(Locale newLocale)
{
BuildHangTracer.Log("TIMELINE_EVENT", $"[OnSelectedLocaleChanged] 收到语言切换通知: newLocale={(newLocale != null ? newLocale.Identifier.Code : "null")}_markers.Count={_markers.Count}");
foreach (RuntimeMarker marker in _markers)
{
marker.view.SetLabel(GetLocalizedLabel(marker.definition.labelKey));
if (string.IsNullOrEmpty(marker.definition.labelKey)) continue;
try
{
BuildHangTracer.Log("TIMELINE_EVENT", $"[OnSelectedLocaleChanged] 开始异步获取 Key '{marker.definition.labelKey}' 的本地化文本...");
var handle = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(localizationTable, marker.definition.labelKey);
while (!handle.IsDone)
{
await System.Threading.Tasks.Task.Yield();
}
BuildHangTracer.Log("TIMELINE_EVENT", $"[OnSelectedLocaleChanged] 获取 Key '{marker.definition.labelKey}' 完成Result='{handle.Result}'");
// 异步等待结束后,防范对象在此期间被销毁
if (marker.view != null)
{
marker.view.SetLabel(string.IsNullOrEmpty(handle.Result) ? marker.definition.labelKey : handle.Result);
}
}
catch (System.Exception exception)
{
BuildHangTracer.Log("TIMELINE_EVENT", $"[OnSelectedLocaleChanged] 异常: {exception.Message}");
Debug.LogWarning($"[StoryTimeline] 无法本地化 Label Key '{marker.definition.labelKey}'。{exception.Message}");
if (marker.view != null) marker.view.SetLabel(marker.definition.labelKey);
}
}
BuildHangTracer.Log("TIMELINE_EVENT", "[OnSelectedLocaleChanged] 全部 Marker 更新完成。");
}
/// <summary>

View File

@@ -103,6 +103,8 @@ namespace Ichni.Story.UI
protected override void OnClicked()
{
Debug.Log($"[YarnDiag] TextBlockView.OnClicked: blockId={blockId}, yarnNodeName={YarnNodeName}");
if (StoryDialogueController.instance == null)
{
Debug.LogWarning($"[TextBlockView] 场景中缺少 StoryDialogueController无法播放 block '{blockId}' 的对话。");

View File

@@ -91,12 +91,12 @@ namespace Ichni.Story.UI
public void PlayFadeIn(UnityAction onComplete = null)
{
FadeIn(0.2f, false, onComplete);
FadeIn(0.2f, true, onComplete);
}
public void PlayFadeOut(UnityAction onComplete = null)
{
FadeOut(0.2f, false, onComplete);
FadeOut(0.2f, true, onComplete);
}
private void OnBackButtonClicked()