剧情+对话完善
This commit is contained in:
@@ -7,7 +7,7 @@ namespace Ichni.Story.Dialogue
|
||||
/// 【临时 / 调试用】将 Yarn 对话行以"本地化后的文本"输出到 Console 的呈现器。
|
||||
/// <para>用途:在没有 VN 对话 UI 的情况下,验证整条 Yarn 管线是否跑通,以及
|
||||
/// Unity Localization 是否正确返回当前语言的文本(点击文本块即可在 Console 看到结果)。</para>
|
||||
/// <para>阶段 3 接入 <c>VNDialoguePresenter</c> 后,可从 DialogueRunner 的呈现器列表移除本组件并删除此脚本。</para>
|
||||
/// <para>正式运行使用 <c>VNDialoguePresenter</c>;本组件只应在需要观察本地化解析结果时临时启用。</para>
|
||||
/// </summary>
|
||||
public class ConsoleLinePresenter : DialoguePresenterBase
|
||||
{
|
||||
|
||||
56
Assets/Scripts/NewStorySystem/Dialogue/DialogueHistory.cs
Normal file
56
Assets/Scripts/NewStorySystem/Dialogue/DialogueHistory.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>对话记录中一条已经实际显示或采用的内容。</summary>
|
||||
[Serializable]
|
||||
public sealed class DialogueHistoryEntry
|
||||
{
|
||||
public string speakerName;
|
||||
public string content;
|
||||
public bool isChoice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前一次 TextBlock 对话专用的临时记录。
|
||||
/// <para>记录不会写入 ES3;开始下一次对话、正常结束或点击 Back 取消时都会清空,
|
||||
/// 因而它只承担 DialogUIPage 内的“本次对话回顾”职责。</para>
|
||||
/// </summary>
|
||||
public static class DialogueHistory
|
||||
{
|
||||
private static readonly List<DialogueHistoryEntry> EntriesInternal = new();
|
||||
|
||||
public static IReadOnlyList<DialogueHistoryEntry> Entries => EntriesInternal;
|
||||
|
||||
public static void BeginSession() => EntriesInternal.Clear();
|
||||
|
||||
public static void AddLine(string speakerName, string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return;
|
||||
|
||||
EntriesInternal.Add(new DialogueHistoryEntry
|
||||
{
|
||||
speakerName = speakerName ?? string.Empty,
|
||||
content = content,
|
||||
isChoice = false
|
||||
});
|
||||
}
|
||||
|
||||
public static void AddChoice(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return;
|
||||
|
||||
EntriesInternal.Add(new DialogueHistoryEntry
|
||||
{
|
||||
speakerName = string.Empty,
|
||||
content = content,
|
||||
isChoice = true
|
||||
});
|
||||
}
|
||||
|
||||
public static void Clear() => EntriesInternal.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fd7c83e0353e4fb7b912fb9996f64738
|
||||
86
Assets/Scripts/NewStorySystem/Dialogue/StoryChoiceMemory.cs
Normal file
86
Assets/Scripts/NewStorySystem/Dialogue/StoryChoiceMemory.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// Yarn 选项的章节内记忆服务。
|
||||
/// <para>玩家第一次完成某组选项后,保存其 <see cref="DialogueOption.TextID"/>;再次进入同一 TextBlock 时,
|
||||
/// 若该选项仍存在且可用,则不显示选择 UI,直接返回该 Option 给 Yarn。</para>
|
||||
/// <para>记录随 Timeline 回滚快照一起恢复,因此回滚到选项之前会让玩家重新选择。</para>
|
||||
/// </summary>
|
||||
public static class StoryChoiceMemory
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据来源 Block 与当前全部 Option Text ID 生成选项组 Key。
|
||||
/// Text ID 已由 Yarn 编译结果稳定生成;排序使纯显示顺序调整不会把同一组选项误认为新选项组。
|
||||
/// </summary>
|
||||
public static string CreateChoiceKey(string sourceBlockId, DialogueOption[] options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceBlockId) || options == null || options.Length == 0 ||
|
||||
options.Any(option => option == null || string.IsNullOrWhiteSpace(option.TextID)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string optionSignature = string.Join("|", options
|
||||
.Select(option => option.TextID)
|
||||
.OrderBy(textId => textId, StringComparer.Ordinal));
|
||||
return sourceBlockId + "::" + optionSignature;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找已保存的选项。若 Yarn 改稿后旧 Text ID 已不存在或暂时不可用,
|
||||
/// 会删除陈旧记录并回退为普通选择界面,避免自动选择错误路线。
|
||||
/// </summary>
|
||||
public static bool TryGetRememberedOption(string sourceBlockId, DialogueOption[] options,
|
||||
out DialogueOption rememberedOption)
|
||||
{
|
||||
rememberedOption = null;
|
||||
StoryTreeController treeController = StoryManager.instance?.treeController;
|
||||
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
|
||||
string choiceKey = CreateChoiceKey(sourceBlockId, options);
|
||||
if (treeController == null || saveModule == null || string.IsNullOrEmpty(treeController.ActiveChapterIndex) ||
|
||||
string.IsNullOrEmpty(choiceKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!saveModule.TryGetChoice(treeController.ActiveChapterIndex, choiceKey, out StoryChoiceRecord record))
|
||||
return false;
|
||||
|
||||
rememberedOption = options.FirstOrDefault(option => option != null && option.IsAvailable &&
|
||||
option.TextID == record.selectedOptionTextId);
|
||||
if (rememberedOption != null)
|
||||
return true;
|
||||
|
||||
saveModule.RemoveChoice(treeController.ActiveChapterIndex, choiceKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存玩家首次确认的选项。调用时先捕获所属 Block 的 Marker 快照,
|
||||
/// 保证该选项及其随后写入的路线变量都能被重开流程一起回滚。
|
||||
/// </summary>
|
||||
public static void RememberChoice(string sourceBlockId, DialogueOption[] options, DialogueOption selectedOption)
|
||||
{
|
||||
StoryTreeController treeController = StoryManager.instance?.treeController;
|
||||
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
|
||||
string choiceKey = CreateChoiceKey(sourceBlockId, options);
|
||||
if (treeController == null || saveModule == null || selectedOption == null ||
|
||||
string.IsNullOrEmpty(treeController.ActiveChapterIndex) || string.IsNullOrEmpty(choiceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoryProgress.TryCaptureActiveBlockSnapshot();
|
||||
saveModule.SetChoice(treeController.ActiveChapterIndex, new StoryChoiceRecord
|
||||
{
|
||||
sourceBlockId = sourceBlockId,
|
||||
choiceKey = choiceKey,
|
||||
selectedOptionTextId = selectedOption.TextID
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d73ac4c0c2034a04a7e701a64a3f8dd9
|
||||
@@ -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] 未配置 DialogueRunner;TextBlock 无法启动对话。", 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,13 @@ namespace Ichni.Story.Dialogue
|
||||
|
||||
public override async YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
// 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。
|
||||
_isFastForwarding = false;
|
||||
_typewriterComplete = false;
|
||||
|
||||
// 对话记录只保留当前一次 TextBlock;每次 Yarn 对话真正开始时清空上一段残留内容。
|
||||
DialogueHistory.BeginSession();
|
||||
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
@@ -119,6 +126,8 @@ namespace Ichni.Story.Dialogue
|
||||
|
||||
// ── 打字效果 ──
|
||||
string fullText = line.TextWithoutCharacterName.Text;
|
||||
// 记录的是本次实际展示时已经解析完成的本地化文本,而非 Yarn Key。
|
||||
DialogueHistory.AddLine(line.CharacterName, fullText);
|
||||
_typewriterComplete = false;
|
||||
|
||||
if (Page.dialogueText != null)
|
||||
@@ -163,6 +172,16 @@ namespace Ichni.Story.Dialogue
|
||||
{
|
||||
if (Page == null) return null;
|
||||
|
||||
// 同一 TextBlock 的选项已被玩家选择过时,直接把原 Option 返回给 Yarn,
|
||||
// 让后续指令和分支仍由 Yarn 正常执行,而不是在 C# 中复制分支逻辑。
|
||||
string sourceBlockId = StoryDialogueController.instance?.ActiveBlockId;
|
||||
if (StoryChoiceMemory.TryGetRememberedOption(sourceBlockId, options, out DialogueOption rememberedOption))
|
||||
{
|
||||
// 已记忆选项在普通回顾与快进时都会自动采用;记录页仍应如实显示本次经过的分支。
|
||||
DialogueHistory.AddChoice(rememberedOption.Line.TextWithoutCharacterName.Text);
|
||||
return rememberedOption;
|
||||
}
|
||||
|
||||
// 遇到选项强制打断快进
|
||||
_isFastForwarding = false;
|
||||
|
||||
@@ -173,9 +192,12 @@ namespace Ichni.Story.Dialogue
|
||||
// 显示选项容器
|
||||
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(true);
|
||||
|
||||
_optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
|
||||
YarnTaskCompletionSource<DialogueOption?> optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
|
||||
_optionTcs = optionTcs;
|
||||
|
||||
WaitForExternalCancelAsync(token.NextContentToken).Forget();
|
||||
// 将本组选项自己的 TCS 传入取消监听,避免上一组选项的 Token 在稍后取消时
|
||||
// 错误结束下一组选项。_optionTcs 只用于外部 Back 流程定位当前正在等待的选择。
|
||||
WaitForExternalCancelAsync(token.NextContentToken, optionTcs).Forget();
|
||||
|
||||
// ── 配置每个选项按钮 ──
|
||||
for (int i = 0; i < Page.choiceButtons.Length; i++)
|
||||
@@ -183,13 +205,24 @@ namespace Ichni.Story.Dialogue
|
||||
if (Page.choiceButtons[i] == null) continue;
|
||||
|
||||
if (i < options.Length)
|
||||
Page.choiceButtons[i].Setup(options[i], opt => _optionTcs.TrySetResult(opt));
|
||||
{
|
||||
Page.choiceButtons[i].Setup(options[i], opt =>
|
||||
{
|
||||
// 先写入选项记忆,再结束异步等待;这样即使紧接着 Yarn 修改路线变量,
|
||||
// Timeline 快照也仍然代表“本组选择之前”的状态。
|
||||
StoryChoiceMemory.RememberChoice(sourceBlockId, options, opt);
|
||||
DialogueHistory.AddChoice(opt.Line.TextWithoutCharacterName.Text);
|
||||
optionTcs.TrySetResult(opt);
|
||||
});
|
||||
}
|
||||
else
|
||||
Page.choiceButtons[i].Cleanup();
|
||||
}
|
||||
|
||||
// ── 等待玩家选择 ──
|
||||
DialogueOption? selected = await _optionTcs.Task;
|
||||
DialogueOption? selected = await optionTcs.Task;
|
||||
if (_optionTcs == optionTcs)
|
||||
_optionTcs = null;
|
||||
|
||||
// 清理所有按钮并隐藏选项容器
|
||||
foreach (ChoiceButton btn in Page.choiceButtons)
|
||||
@@ -202,6 +235,10 @@ namespace Ichni.Story.Dialogue
|
||||
|
||||
public override async YarnTask OnDialogueCompleteAsync()
|
||||
{
|
||||
// 无论正常完成还是被 StoryDialogueController 通过 Stop 取消,都要结束本次快进状态。
|
||||
_isFastForwarding = false;
|
||||
_typewriterComplete = true;
|
||||
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
@@ -210,10 +247,48 @@ namespace Ichni.Story.Dialogue
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开对话记录前的 UI 收束操作。
|
||||
/// 停止自动快进,并补全当前正在打字的一句,但绝不请求下一句,
|
||||
/// 因而 History 覆盖层打开期间不会改变 Yarn 的剧情进度。
|
||||
/// </summary>
|
||||
public void PrepareForHistory()
|
||||
{
|
||||
_isFastForwarding = false;
|
||||
|
||||
if (!_typewriterComplete && _runner != null)
|
||||
_runner.RequestHurryUpLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 DialogUIPage 的 Back 取消流程收起所有临时交互。
|
||||
/// 真正终止 Yarn 和恢复章节快照由 <see cref="StoryDialogueController"/> 负责;
|
||||
/// 本方法不写入任何剧情变量或选项。
|
||||
/// </summary>
|
||||
public void CancelCurrentPresentation()
|
||||
{
|
||||
_isFastForwarding = false;
|
||||
_typewriterComplete = true;
|
||||
_optionTcs?.TrySetResult(null);
|
||||
|
||||
if (Page == null)
|
||||
return;
|
||||
|
||||
foreach (ChoiceButton button in Page.choiceButtons)
|
||||
button?.Cleanup();
|
||||
|
||||
if (Page.choiceFrame != null)
|
||||
Page.choiceFrame.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 内部方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnAdvanceButtonClicked()
|
||||
{
|
||||
// 未记忆选项必须由 ChoiceButton 明确选择;推进按钮不能越过当前选择。
|
||||
if (_optionTcs != null)
|
||||
return;
|
||||
|
||||
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
|
||||
if (_isFastForwarding)
|
||||
{
|
||||
@@ -231,6 +306,10 @@ namespace Ichni.Story.Dialogue
|
||||
|
||||
private void OnFastForwardButtonClicked()
|
||||
{
|
||||
// 快进可以跨过已经记忆的选项,但不能跳过正在等待玩家首次决定的选项。
|
||||
if (_optionTcs != null)
|
||||
return;
|
||||
|
||||
_isFastForwarding = true;
|
||||
|
||||
if (_runner == null) return;
|
||||
@@ -263,10 +342,12 @@ namespace Ichni.Story.Dialogue
|
||||
}
|
||||
}
|
||||
|
||||
private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken)
|
||||
private static async YarnTask WaitForExternalCancelAsync(
|
||||
CancellationToken externalToken,
|
||||
YarnTaskCompletionSource<DialogueOption?> optionTcs)
|
||||
{
|
||||
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
|
||||
_optionTcs?.TrySetResult(null);
|
||||
optionTcs.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user