剧情+对话完善

This commit is contained in:
SoulliesOfficial
2026-07-21 15:24:42 -04:00
parent 8f230831e9
commit 810d019619
161 changed files with 7271 additions and 1893 deletions

View File

@@ -3,6 +3,7 @@ using System.Reflection;
using Ichni.Story.UI;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization;
using Yarn.Unity;
using Yarn.Unity.UnityLocalization;
@@ -11,7 +12,7 @@ namespace Ichni.Story.Dialogue
/// <summary>
/// 故事树 block 与 Yarn <see cref="DialogueRunner"/> 之间的桥接控制器。
/// <para>点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、
/// 重算解锁并存档、依次执行结束回调(如歌曲解锁提示,阶段 4 使用)。</para>
/// 重算解锁并存档、依次执行结束回调(如歌曲解锁提示)。</para>
/// </summary>
public class StoryDialogueController : MonoBehaviour
{
@@ -24,21 +25,66 @@ 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 命令(阶段 4
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令
/// 把"解锁提示"排队到对话结束后弹出。
/// </summary>
[System.NonSerialized] public List<UnityAction> dialogueEndActions = new List<UnityAction>();
private readonly List<UnityAction> _dialogueEndActions = new List<UnityAction>();
// 当前正在播放对话的 block id对话结束时据此标记完成
private string _activeBlockId;
// 当前 TextBlock 开始前的章节快照。它只服务于本次“中途退出对话”的恢复,
// 不会被写入 ES3也不会影响歌曲成绩、解锁 Key、Settings 或其它章节。
private ChapterStorySave _activeDialogueSnapshot;
private string _activeDialogueChapterIndex;
private bool _isCancellingDialogue;
/// <summary>
/// 当前对话对应的 TextBlock ID。Yarn 选项记忆与变量指令使用它定位所属章节 Block
/// 不应由其它系统直接赋值。
/// </summary>
public string ActiveBlockId => _activeBlockId;
/// <summary>
/// 当前是否存在尚未提交的 TextBlock 对话事务。
/// Yarn 的全局副作用(例如 grant_unlock、unlock_song、show_message必须据此决定延迟到正常结束后执行
/// 从而避免玩家按 Back 时留下已解锁内容或孤立弹窗。
/// </summary>
public bool IsDialogueTransactionActive =>
!string.IsNullOrEmpty(_activeBlockId) &&
GameSaveManager.instance?.StorySaveModule?.IsChapterTransactionActive(_activeDialogueChapterIndex) == true;
private void Awake()
{
instance = this;
EnsureUnityLocalisedLineProvider();
}
private void Start()
{
// 这些引用不会由 StoryData 自检覆盖,因为它们属于场景运行时装配而非单个章节资产。
// 仅输出可定位 Warning不阻断编辑器下的部分独立测试流程。
if (dialogueRunner == null)
{
Debug.LogWarning("[StoryDialogueController] 未配置 DialogueRunnerTextBlock 无法启动对话。", this);
return;
}
if (dialogueRunner.GetComponent<VNDialoguePresenter>() == null)
Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 VNDialoguePresenter台词和选项不会显示。", dialogueRunner);
if (dialogueRunner.GetComponent<UnityLocalisedLineProvider>() == null)
Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 UnityLocalisedLineProvider本地化台词无法解析。", dialogueRunner);
if (storyUIPage == null)
Debug.LogWarning("[StoryDialogueController] 未配置 StoryUIPage对话开始/结束时不会切换剧情树页面。", this);
}
private void OnEnable()
{
if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null)
@@ -51,6 +97,12 @@ namespace Ichni.Story.Dialogue
dialogueRunner.onDialogueComplete.RemoveListener(HandleDialogueComplete);
}
private void OnDestroy()
{
if (instance == this)
instance = null;
}
/// <summary>
/// 播放某文本块对应的 Yarn 节点。若对话已在进行、引用缺失或节点名为空则忽略。
/// </summary>
@@ -79,13 +131,106 @@ namespace Ichni.Story.Dialogue
// 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner
EnsureProjectForCurrentChapter();
// 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);
return;
}
StoryTreeController treeController = StoryManager.instance?.treeController;
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
string chapterIndex = treeController?.ActiveChapterIndex;
if (treeController == null || saveModule == null || string.IsNullOrEmpty(chapterIndex))
{
Debug.LogError("[StoryDialogueController] 当前章节存档上下文未就绪,拒绝开始可回滚的 TextBlock 对话。");
return;
}
// 必须在任何 Yarn 命令、选项或 Marker 快照写入前完成复制并开启事务。
// 事务期间所有剧情变化仅停留在内存;正常结束才提交,中途 Back 则完整恢复本副本。
_activeDialogueSnapshot = StorySaveCloneUtility.CloneChapter(saveModule.GetChapter(chapterIndex));
_activeDialogueChapterIndex = chapterIndex;
if (!saveModule.BeginChapterTransaction(chapterIndex))
{
_activeDialogueSnapshot = null;
_activeDialogueChapterIndex = null;
return;
}
_activeBlockId = block.blockId;
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
StoryProgress.BeginBlockMutation(_activeBlockId);
if (storyUIPage != null)
storyUIPage.FadeOut();
// StartDialogue 返回 YarnTask此处即发即忘完成由 onDialogueComplete 处理
dialogueRunner.StartDialogue(block.YarnNodeName).Forget();
// 启动异常会回滚本次章节事务;正常结束仍由 onDialogueComplete 统一提交。
StartDialogueSafely(block.YarnNodeName).Forget();
}
/// <summary>
/// 以受保护方式启动 Yarn。缺失 Provider、Presenter 等启动级异常不应让 StorySave 的事务永久处于活动状态,
/// 因而会被转换为 Failed 流程并完整恢复进入对话前的章节快照。
/// </summary>
private async YarnTask StartDialogueSafely(string yarnNodeName)
{
try
{
await dialogueRunner.StartDialogue(yarnNodeName);
}
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 对话失败");
}
}
/// <summary>
/// 取消当前 TextBlock 对话并恢复进入前的章节剧情快照。
/// <para>Yarn 的 <see cref="DialogueRunner.Stop"/> 仍会触发 onDialogueComplete
/// 因此此处先标记取消状态,完成回调会走恢复流程而不是误将 Block 标记为 Completed。</para>
/// </summary>
public bool CancelActiveDialogue()
{
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId) ||
dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
{
return false;
}
_isCancellingDialogue = true;
_dialogueEndActions.Clear();
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
return true;
}
/// <summary>
/// 将一个全局副作用排入“本次 TextBlock 成功提交后”执行的队列。
/// <para>没有活动对话事务时会立即执行,便于未来在非 TextBlock 环境复用同一批 Yarn 命令。</para>
/// </summary>
public void ExecuteAfterDialogueCommit(UnityAction action)
{
if (action == null)
return;
if (IsDialogueTransactionActive)
{
_dialogueEndActions.Add(action);
return;
}
action.Invoke();
}
// 将 DialogueRunner 的 YarnProject 切换为当前已构建章节的 StoryData.yarnProject若不同且未在运行
@@ -115,16 +260,25 @@ namespace Ichni.Story.Dialogue
if (field == null) return;
// 已经是 UnityLocalisedLineProvider 则无需处理
if (field.GetValue(dialogueRunner) is UnityLocalisedLineProvider) return;
UnityLocalisedLineProvider provider =
dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
UnityLocalisedLineProvider provider = field.GetValue(dialogueRunner) as UnityLocalisedLineProvider;
provider ??= dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
if (provider != null)
{
field.SetValue(dialogueRunner, provider);
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
if (field.GetValue(dialogueRunner) != provider)
{
field.SetValue(dialogueRunner, provider);
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
}
// 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
{
@@ -134,24 +288,35 @@ namespace Ichni.Story.Dialogue
private void HandleDialogueComplete()
{
// 持久化对话过程中写入的全局剧情变量
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
GameSaveManager.instance.StorySaveModule.SaveVariables();
// Failed 流程已经清理 activeBlockId 后,底层 Runner 仍可能随后送达完成事件;此时绝不能误提交。
if (string.IsNullOrEmpty(_activeBlockId))
return;
// 标记 block 完成 → 重算解锁 → 存档OnBlockCompleted 内部已去重并保存章节进度)
if (_isCancellingDialogue)
{
RestoreCancelledDialogue();
return;
}
// 标记 Block 完成 → 重算解锁 → 保存完整章节容器(含变量、选项与回滚快照)。
if (!string.IsNullOrEmpty(_activeBlockId) &&
StoryManager.instance != null && StoryManager.instance.treeController != null)
{
StoryManager.instance.treeController.OnBlockCompleted(_activeBlockId);
}
StoryProgress.EndBlockMutation(_activeBlockId);
_activeBlockId = null;
// 所有本次 TextBlock 的变量、选项和完成状态至此才一次性写入 ES3。
GameSaveManager.instance?.StorySaveModule?.CommitChapterTransaction(_activeDialogueChapterIndex);
ClearDialogueSessionState();
// 依次执行并清空结束回调(如歌曲解锁提示)
if (dialogueEndActions.Count > 0)
if (_dialogueEndActions.Count > 0)
{
List<UnityAction> pending = new List<UnityAction>(dialogueEndActions);
dialogueEndActions.Clear();
List<UnityAction> pending = new List<UnityAction>(_dialogueEndActions);
_dialogueEndActions.Clear();
foreach (UnityAction action in pending)
{
action?.Invoke();
@@ -164,6 +329,50 @@ namespace Ichni.Story.Dialogue
{
storyUIPage.FadeIn();
}
DialogueHistory.Clear();
}
/// <summary>
/// 处理 Back 触发的 Yarn 停止回调:放弃运行期间的全部章节剧情改动,
/// 并只刷新当前已实例化的 StoryTree保留玩家拖动剧情树后的视口位置。
/// </summary>
private void RestoreCancelledDialogue()
{
RestoreInterruptedDialogue("玩家中途退出对话");
}
/// <summary>
/// 取消和失败共用的事务回滚出口。该方法具有幂等性:无活动 Block 时直接返回,
/// 因而底层 Yarn 在 Stop/异常后重复触发完成事件也不会把已回滚的数据再次提交。
/// </summary>
private void RestoreInterruptedDialogue(string reason)
{
if (string.IsNullOrEmpty(_activeBlockId))
return;
string interruptedBlockId = _activeBlockId;
string chapterIndex = _activeDialogueChapterIndex;
StoryTreeController treeController = StoryManager.instance?.treeController;
Debug.LogWarning($"[StoryDialogueController] {reason},已回滚 TextBlock '{interruptedBlockId}' 的章节剧情事务。");
StoryProgress.EndBlockMutation(interruptedBlockId);
GameSaveManager.instance?.StorySaveModule?.RollbackChapterTransaction(
chapterIndex, _activeDialogueSnapshot);
treeController?.ReloadActiveChapterProgress();
_dialogueEndActions.Clear();
DialogueHistory.Clear();
_activeBlockId = null;
ClearDialogueSessionState();
storyUIPage?.FadeIn();
}
private void ClearDialogueSessionState()
{
_activeDialogueSnapshot = null;
_activeDialogueChapterIndex = null;
_isCancellingDialogue = false;
}
}
}