Story流程+本地化

This commit is contained in:
SoulliesOfficial
2026-07-24 17:56:30 -04:00
parent b0e0a7d5aa
commit de70870682
250 changed files with 5719 additions and 272966 deletions

View File

@@ -63,11 +63,6 @@ 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)
@@ -121,6 +116,7 @@ namespace Ichni.Story
}
ValidateTextBlockYarnNodes(issues);
ValidateUniqueSongBlockIds(issues);
HashSet<string> reachableBlockIds = ValidateStoryGraph(blockIds, issues);
foreach (StoryTimelineMarkerDefinition marker in timelineMarkers)
@@ -262,6 +258,43 @@ namespace Ichni.Story
/// 对 StoryData 的连接图做只读的结构检查:入口、不可达 Block、环路、非向前连接与终点。
/// 这些检查不评价具体剧情质量,只定位最容易造成“永远无法游玩”或“无法结束章节”的配置问题。
/// </summary>
/// <summary>
/// SongSelection -> Story 的定向入口以 SongItemData.songName 匹配 SongBlock.songName。
/// 同一章节内重复配置同一 Song ID 时,运行时只能按列表顺序定位第一个 Block
/// 因此在编辑阶段输出 Warning让配置者消除这个导航歧义。
/// </summary>
private void ValidateUniqueSongBlockIds(List<string> issues)
{
Dictionary<string, List<string>> blockIdsBySongId = new Dictionary<string, List<string>>();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || block.blockType != StoryBlockType.Song ||
string.IsNullOrWhiteSpace(block.songName))
{
continue;
}
if (!blockIdsBySongId.TryGetValue(block.songName, out List<string> referencedBlocks))
{
referencedBlocks = new List<string>();
blockIdsBySongId[block.songName] = referencedBlocks;
}
referencedBlocks.Add(block.blockId);
}
foreach (KeyValuePair<string, List<string>> pair in blockIdsBySongId)
{
if (pair.Value.Count <= 1)
continue;
issues.Add(
$"[Warning] Song ID '{pair.Key}' 被多个 SongBlock 引用({string.Join(", ", pair.Value)})。" +
"SongSelection -> Story 只能定位列表中的第一个 Block请保留每章每首歌唯一的 SongBlock。"
);
}
}
private HashSet<string> ValidateStoryGraph(HashSet<string> blockIds, List<string> issues)
{
Dictionary<string, List<string>> adjacency = new Dictionary<string, List<string>>();

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
using Yarn.Unity;
namespace Ichni.Story
@@ -23,11 +22,6 @@ 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,10 +1,7 @@
using System.Collections;
using System.Collections.Generic;
using Ichni.Story.UI;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization.Tables;
using UnityEngine.ResourceManagement.AsyncOperations;
using Yarn.Unity;
using Yarn.Unity.UnityLocalization;
@@ -40,9 +37,6 @@ 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
@@ -109,23 +103,23 @@ namespace Ichni.Story.Dialogue
{
if (block == null) return;
Debug.Log($"[YarnDiag] StoryDialogueController.PlayBlock: block={block.blockId}, node={block.YarnNodeName}");
if (dialogueRunner == null)
{
Debug.LogError("[YarnDiag] [ERROR] 未配置 DialogueRunner无法开始对话");
Debug.LogError("[StoryDialogueController] 未配置 DialogueRunner无法开始对话", this);
return;
}
if (dialogueRunner.IsDialogueRunning || _dialogueStartupCoroutine != null)
if (dialogueRunner.IsDialogueRunning)
{
Debug.LogWarning("[YarnDiag] [WARNING] 已有对话正在运行或启动,忽略本次点击");
Debug.LogWarning("[StoryDialogueController] 已有对话正在运行,忽略本次点击。", dialogueRunner);
return;
}
if (string.IsNullOrEmpty(block.YarnNodeName))
{
Debug.LogWarning($"[YarnDiag] [WARNING] block '{block.blockId}' 未配置 yarnNodeName无法开始对话");
Debug.LogWarning(
$"[StoryDialogueController] Block '{block.blockId}' 未配置 Yarn Node无法开始对话。",
block);
return;
}
@@ -136,14 +130,15 @@ namespace Ichni.Story.Dialogue
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($"[YarnDiag] [ERROR] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'", dialogueRunner);
Debug.LogError(
$"[StoryDialogueController] Yarn Project 中不存在节点 '{block.YarnNodeName}'" +
$"已取消启动 Block '{block.blockId}'。",
dialogueRunner);
return;
}
@@ -171,116 +166,20 @@ namespace Ichni.Story.Dialogue
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
StoryProgress.BeginBlockMutation(_activeBlockId);
// Unity Localization 的 AsyncOperationHandle 原生支持协程等待。
// 先在项目侧显式加载当前章节台词表,再启动 Yarn不修改 Yarn Spinner Package
// 也不把 Unity Addressables Handle 转换为 YarnTask。
_dialogueStartupCoroutine = StartCoroutine(StartDialogueSafely(block.YarnNodeName));
}
/// <summary>
/// 使用 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 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
{
// 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)
{
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();
StartDialogueSafely(block.YarnNodeName).Forget();
}
/// <summary>
/// 调用 Yarn Spinner 的公开 <see cref="DialogueRunner.StartDialogue"/> API。
/// 返回时表示所有 Presenter 已完成 OnDialogueStartedAsync且 Runner 已调用 Dialogue.Continue
/// 通过 Yarn Spinner 的公开 API 启动对话。台词表的定位与加载由
/// <see cref="UnityLocalisedLineProvider"/> 统一负责,项目代码不再重复持有或预加载同一张表
/// 启动异常会恢复进入 TextBlock 前的章节快照,避免留下未提交事务。
/// </summary>
private async YarnTask StartDialogueRunnerSafely(string yarnNodeName)
private async YarnTask StartDialogueSafely(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)
{
@@ -290,12 +189,10 @@ namespace Ichni.Story.Dialogue
private void FailDialogueStartup(string reason, System.Exception exception = null)
{
_dialogueStartupCoroutine = null;
if (exception != null)
Debug.LogException(exception, dialogueRunner);
Debug.LogError($"[YarnDiag] {reason}", dialogueRunner);
Debug.LogError($"[StoryDialogueController] {reason}", dialogueRunner);
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
{
@@ -318,14 +215,6 @@ namespace Ichni.Story.Dialogue
return false;
}
if (_dialogueStartupCoroutine != null)
{
StopCoroutine(_dialogueStartupCoroutine);
_dialogueStartupCoroutine = null;
RestoreInterruptedDialogue("玩家在对话启动阶段中途退出");
return true;
}
if (dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
return false;

View File

@@ -94,8 +94,6 @@ namespace Ichni.Story.Dialogue
public override YarnTask OnDialogueStartedAsync()
{
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 开始。");
// 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。
_isFastForwarding = false;
_typewriterComplete = false;
@@ -104,7 +102,6 @@ namespace Ichni.Story.Dialogue
DialogueHistory.BeginSession();
Page.PlayFadeIn();
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 完成。");
return YarnTask.CompletedTask;
}
@@ -112,8 +109,6 @@ namespace Ichni.Story.Dialogue
{
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);
@@ -130,7 +125,8 @@ namespace Ichni.Story.Dialogue
if (string.IsNullOrEmpty(fullText) && !string.IsNullOrEmpty(line.RawText))
{
fullText = line.RawText;
Debug.LogWarning($"[YarnDiag] [WARNING] 台词 '{line.TextID}' 本地化解析结果为空,回退使用 RawText: '{fullText}'");
Debug.LogWarning(
$"[VNDialoguePresenter] 台词 '{line.TextID}' 的本地化结果为空,已回退使用 Yarn RawText。");
}
// 记录的是本次实际展示时已经解析完成的本地化文本,而非 Yarn Key。
DialogueHistory.AddLine(line.CharacterName, fullText);
@@ -291,37 +287,30 @@ 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)
{
Debug.LogError("[YarnDiag] [ERROR] OnAdvanceButtonClicked 拦截_runner 为 null");
return;
}
if (!_typewriterComplete)
{
Debug.Log("[YarnDiag] RequestHurryUpLine (跳过打字效果)");
_runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果
}
else
{
Debug.Log("[YarnDiag] RequestNextLine (推进下一行)");
_runner.RequestNextLine(); // 第二次点击:推进到下一行
}
}

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using DG.Tweening;
using Ichni.Localization;
using UnityEngine;
using UnityEngine.Localization.Settings;
using UnityEngine.UI;
namespace Ichni.Story
@@ -162,8 +162,8 @@ namespace Ichni.Story
_activeTalk = Instantiate(helperTalkPrefab, helperTalkContainer);
_activeTalk.Closed += HandleTalkClosed;
_activeTalk.Show(
GetLocalizedText(helperData, helperData.displayNameKey, helperData.name),
GetLocalizedText(helperData, contentKey, contentKey));
LocalizationTextService.Resolve(helperData.localizationTable, helperData.displayNameKey, helperData.name),
LocalizationTextService.Resolve(helperData.localizationTable, contentKey, contentKey));
}
/// <summary>
@@ -322,24 +322,6 @@ namespace Ichni.Story
visualPresenter?.ApplyHelper(helperData);
}
/// <summary>本地化失败时显示 Key确保测试阶段仍能定位缺失条目而不是得到空白气泡。</summary>
private string GetLocalizedText(StoryHelperData helperData, string key, string fallback)
{
if (string.IsNullOrEmpty(key) || helperData == null)
return fallback;
try
{
string localized = LocalizationSettings.StringDatabase.GetLocalizedString(helperData.localizationTable, key);
return string.IsNullOrEmpty(localized) ? fallback : localized;
}
catch (Exception exception)
{
Debug.LogWarning($"[StoryHelper] 无法本地化 Key '{key}',将回退显示 '{fallback}'。{exception.Message}", this);
return fallback;
}
}
private void CacheBaseTransforms()
{
if (idleRoot != null)

View File

@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Ichni.Diagnostics;
using Ichni.Localization;
using Ichni.Menu.UI;
using TMPro;
using UnityEngine;
@@ -410,50 +410,26 @@ namespace Ichni.Story.UI
return string.Empty;
}
try
{
string localizedText = LocalizationSettings.StringDatabase.GetLocalizedString(localizationTable, labelKey);
return string.IsNullOrEmpty(localizedText) ? labelKey : localizedText;
}
catch (Exception exception)
{
Debug.LogWarning($"[StoryTimeline] 无法本地化 Label Key '{labelKey}',将回退显示 Key。{exception.Message}");
return labelKey;
}
return LocalizationTextService.Resolve(localizationTable, labelKey, labelKey);
}
private async void OnSelectedLocaleChanged(Locale newLocale)
private async void OnSelectedLocaleChanged(Locale _)
{
BuildHangTracer.Log("TIMELINE_EVENT", $"[OnSelectedLocaleChanged] 收到语言切换通知: newLocale={(newLocale != null ? newLocale.Identifier.Code : "null")}_markers.Count={_markers.Count}");
foreach (RuntimeMarker marker in _markers)
{
if (string.IsNullOrEmpty(marker.definition.labelKey)) continue;
try
string localizedLabel = await LocalizationTextService.ResolveAsync(
localizationTable,
marker.definition.labelKey,
marker.definition.labelKey);
// 异步等待结束后,防范对象在此期间被销毁。
if (marker.view != null)
{
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);
marker.view.SetLabel(localizedLabel);
}
}
BuildHangTracer.Log("TIMELINE_EVENT", "[OnSelectedLocaleChanged] 全部 Marker 更新完成。");
}
/// <summary>

View File

@@ -124,18 +124,77 @@ namespace Ichni.Story
}
/// <summary>
/// 选曲页确认启动游戏时调用。只有章节和稳定歌曲 ID 都命中剧情入口目标时,
/// 才将对应 SongBlock 标记为完成,并要求 GameScene 返回剧情页。
/// 从 SongSelection 页进入当前章节的 StoryPage。
/// <para>若 <paramref name="song"/> 在该章节 StoryData 中存在唯一的 SongBlock视口会定位到它
/// 没有对应 SongBlock 或当前未选中歌曲时,视口回到剧情树开头。</para>
/// <para>这不是 Story SongBlock 的“返回”操作,因此会清除一次性选曲上下文,且不会写入歌曲、剧情或选项存档。</para>
/// </summary>
public bool TryOpenStoryForSong(ChapterSelectionUnit chapter, SongItemData song)
{
if (chapter == null || treeController == null || storyUIPage == null ||
MenuManager.instance == null || MenuManager.instance.songSelectionUIPage == null ||
ChapterSelectionManager.instance == null)
{
Debug.LogWarning("[StoryManager] 从选曲页打开剧情失败:章节、页面或 StoryTree 引用未就绪。");
return false;
}
if (GetStoryData(chapter.chapterIndex) == null)
{
Debug.LogWarning($"[StoryManager] 章节 '{chapter.chapterIndex}' 未登记 StoryData无法从选曲页打开剧情。");
return false;
}
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
if (unlockSaveModule == null || !unlockSaveModule.CanAccessChapter(chapter))
{
Debug.LogWarning($"[StoryManager] 章节 '{chapter.chapterIndex}' 尚不可进入剧情,已取消从选曲页跳转。");
return false;
}
// 此入口只负责浏览剧情。若此前由 SongBlock 打开选曲页,主动进入剧情时应放弃旧的
// “目标歌曲 -> GameScene 后返回原 Block”上下文避免下一次普通游玩误命中旧目标。
InformationTransistor.instance?.storySongEntryContext?.Clear();
ChapterSelectionManager.instance.currentChapter = chapter;
AudioManager.SetSwitch(chapter.chapterSwitch);
OpenChapter(chapter.chapterIndex);
StoryBlockView targetBlock = FindSongBlockView(song);
// 与 SongSelection 的 Back 保持一致:离开选曲页进入剧情时,必须立即停止当前歌曲试听。
// StoryPage 不承载试听音频,若遗漏该步骤会让歌曲预览跨页面持续播放。
SongSelectionManager.instance?.StopPreviewSong();
MenuManager.instance.songSelectionUIPage.FadeOut();
// StoryPage 激活后、淡入前先完成定位。UIPageBase 此时保持 alpha = 0
// 因此玩家不会先看到剧情树开头、再跳到目标 SongBlock。
storyUIPage.FadeIn(beforeFadeAction: () =>
{
if (targetBlock != null)
treeController.FocusBlock(targetBlock);
else
treeController.FocusStart();
});
return true;
}
/// <summary>
/// 选曲页确认启动游戏时调用。
/// <para>
/// SongBlock 的完成状态统一由 <c>SongSaves.isTried</c> 判断,并已在
/// <c>PrepareGameLaunch</c> 中写入;这里仅清理由 StoryPage 发起的临时选曲上下文。
/// GameScene 无论从何种歌曲入口启动,都会返回 SongSelectionTutorialBlock 仍通过其独立
/// 的 <c>MenuReturnDestination.Story</c> 流程返回剧情。
/// </para>
/// </summary>
public void HandleSongSelectionConfirmed(ChapterSelectionUnit chapter, SongItemData song)
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.IsTargetSong(chapter, song))
if (context == null || !context.isActive)
return;
// isTried 已在 PrepareGameLaunch 中落盘;此处不再直接写 StorySave避免 SongBlock
// 同时拥有一次选曲上下文与歌曲存档两套完成来源
context.returnToStoryAfterGame = true;
// 玩家已经确认进入 GameScene无论选择的是入口歌曲还是改选了其它歌曲旧的剧情入口
// 上下文都不能遗留到下一次选曲。否则它可能在后续流程中错误影响页面返回或焦点恢复
context.Clear();
}
/// <summary>
@@ -191,6 +250,29 @@ namespace Ichni.Story
}).AddTo(this);
}
/// <summary>
/// 在当前已构建的 StoryTree 中查找歌曲对应的 SongBlock 视图。
/// StoryData 自检会提示同章节重复 Song ID运行时仍采用第一个匹配项作为防御性回退
/// 以避免错误配置直接阻断玩家进入剧情页。
/// </summary>
private StoryBlockView FindSongBlockView(SongItemData song)
{
if (song == null || string.IsNullOrWhiteSpace(song.songName) || treeController == null)
return null;
foreach (StoryBlockView view in treeController.blocks)
{
StoryBlockDefinition definition = treeController.GetBlockDefinition(view.blockId);
if (definition != null && definition.blockType == StoryBlockType.Song &&
definition.songName == song.songName)
{
return view;
}
}
return null;
}
/// <summary>
/// 清除全部剧情存档(所有章节故事树、章节变量与由剧情授予的内容解锁 Key
/// </summary>

View File

@@ -47,12 +47,12 @@ namespace Ichni.Story
public GameObject tutorialBlockPrefab;
public GameObject connectorPrefab;
[Header("Layout Settings")]
/// <summary>
/// 相邻叙事列之间的固定距离。它是 StoryData.gridColumn 的坐标步距,
/// 不等于也不会覆盖任何 Block Prefab 的宽度。
/// 默认值为 Important TextBlock 宽度 600 加最小横向留白 120。
/// </summary>
[Header("Layout Settings")]
[Tooltip("StoryData.gridColumn 的水平步距,不会改变任何 Prefab 的实际宽度。")]
public float columnStep = 720f;
@@ -72,8 +72,15 @@ namespace Ichni.Story
/// 最左 Block 的真实左边缘会被放置在 <see cref="marginLeft"/> 加本值的位置;
/// 默认 1600等价于在原有布局基础上将整棵剧情树向右平移 1600 单位。
/// </summary>
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1600会让所有 Block 整体向右移动 1600。")]
public float helperReservedLeftSpace = 1600f;
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1200会让所有 Block 整体向右移动 1200。")]
public float helperReservedLeftSpace = 1200f;
/// <summary>
/// 为覆盖在剧情页底部的常驻 UI当前为 Helper预留的额外可滚动空间。
/// Content 使用垂直中心 Pivot布局时会同步将树向上平移本值的一半
/// 从而使本值完整地增加在内容下方,而非被平均分配到上下两侧。
/// </summary>
[Tooltip("为底部 Helper 等常驻 UI 预留的额外可滚动空间。值越大,树拖到顶部时越能完整显示底部 Block。")]
public float helperReservedLowerSpace = 150f;
public float marginRight = 50f;
public float marginTop = 50f;
@@ -93,6 +100,15 @@ namespace Ichni.Story
// 未来 Timeline 在独立 UI 容器中生成 Marker 时,也必须叠加此偏移量才能与 Block 中心对齐。
private float _layoutHorizontalOffset;
// Content 使用垂直中心 Pivot。此偏移把 helperReservedLowerSpace 完整分配给内容下方;
// SetUpBackground 可能重复执行,因此每次计算前都必须先撤销上一次偏移,避免位置累积漂移。
private float _layoutVerticalOffset;
// StoryTree 的“开头”由场景中 Content 的初始位置决定,而不是硬编码为 Vector2.zero。
// 因此 UI 设计调整默认视口时,不需要同步修改导航代码。
private Vector2 _initialContentPosition;
private bool _hasInitialContentPosition;
/// <summary>当前已构建章节的 StoryData供对话控制器切换 YarnProject 等使用)。</summary>
public StoryData ActiveStoryData => _storyData;
@@ -105,6 +121,15 @@ namespace Ichni.Story
/// </summary>
public float LayoutHorizontalOffset => _layoutHorizontalOffset;
private void Awake()
{
if (content == null)
return;
_initialContentPosition = content.anchoredPosition;
_hasInitialContentPosition = true;
}
/// <summary>
/// 获取指定叙事列在 BlockContainer 中的最终中心 X 坐标。
/// Timeline Marker 必须使用此方法,而不是自行只计算 gridColumn * columnStep
@@ -620,6 +645,15 @@ namespace Ichni.Story
return;
}
// 以未附加底部预留区的原始网格位置重新计算边界,保证重复布局保持稳定。
if (!Mathf.Approximately(_layoutVerticalOffset, 0f))
{
foreach (StoryBlockView block in blocks)
block.blockRect.anchoredPosition -= Vector2.up * _layoutVerticalOffset;
_layoutVerticalOffset = 0f;
}
float minLeft = float.PositiveInfinity;
float maxRight = float.NegativeInfinity;
float maxVertical = 0f; // 距垂直中线的最大延伸(上下取较大者,用于对称扩展 content 高度)
@@ -648,18 +682,29 @@ namespace Ichni.Story
float horizontalCorrection = targetLeftEdge - minLeft;
if (!Mathf.Approximately(horizontalCorrection, 0f))
{
foreach (StoryBlockView block in blocks)
block.blockRect.anchoredPosition += Vector2.right * horizontalCorrection;
foreach (StoryBlockView block in blocks) block.blockRect.anchoredPosition += Vector2.right * horizontalCorrection;
maxRight += horizontalCorrection;
}
// SetUpBackground 可能因尺寸变化被重复调用;偏移量必须累积,不能在第二次调用时
// 被归零,否则未来 Timeline 会失去与已平移 Block 的横向对齐。
_layoutHorizontalOffset += horizontalCorrection;
float width = Mathf.Max(maxRight + marginRight, minContentWidth);
float height = Mathf.Max(maxVertical * 2f + marginTop + marginBottom, minContentHeight);
float baseHeight = Mathf.Max(maxVertical * 2f + marginTop + marginBottom, minContentHeight);
float lowerReservedSpace = Mathf.Max(0f, helperReservedLowerSpace);
float height = baseHeight + lowerReservedSpace;
// 将 Content 扩展出的空间全部留在下方Content 顶边会上移 lower/2
// Block 也上移 lower/2因此顶部视觉边界不变Content 底边下移 lower/2
// 与 Block 的上移共同形成完整 lower 的新增底部拖拽空间。
_layoutVerticalOffset = lowerReservedSpace * 0.5f;
if (!Mathf.Approximately(_layoutVerticalOffset, 0f))
{
foreach (StoryBlockView block in blocks)
block.blockRect.anchoredPosition += Vector2.up * _layoutVerticalOffset;
}
content.sizeDelta = new Vector2(width, height);
}
@@ -686,6 +731,61 @@ namespace Ichni.Story
public StoryBlockView GetBlockView(string blockId) =>
blocks.FirstOrDefault(b => b.blockId == blockId);
/// <summary>
/// 将 StoryTree 视口居中到指定 Block。使用实际 RectTransform 的屏幕位置计算偏移,
/// 因而适用于不同尺寸的 Block、不同分辨率以及包含 Helper 预留区的布局。
/// </summary>
public void FocusBlock(StoryBlockView block)
{
if (block == null || block.blockRect == null || content == null || storyScrollRect == null)
{
FocusStart();
return;
}
Canvas.ForceUpdateCanvases();
RectTransform viewport = storyScrollRect.viewport != null
? storyScrollRect.viewport
: storyScrollRect.GetComponent<RectTransform>();
Canvas canvas = storyScrollRect.GetComponentInParent<Canvas>();
Camera uiCamera = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay
? canvas.worldCamera
: null;
Vector3 blockCenterWorld = block.blockRect.TransformPoint(block.blockRect.rect.center);
Vector2 blockCenterScreen = RectTransformUtility.WorldToScreenPoint(uiCamera, blockCenterWorld);
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
viewport, blockCenterScreen, uiCamera, out Vector2 blockCenterInViewport))
{
FocusStart();
return;
}
// 局部坐标原点位于 Viewport 中心时,目标在右侧意味着 Content 应向左移动;
// 直接减去该偏移即可,且不依赖 Content 或 Block 的 Pivot 配置。
content.anchoredPosition -= blockCenterInViewport;
storyScrollRect.StopMovement();
currentBlock = block;
storyTimeline?.RefreshMarkerPositions();
}
/// <summary>
/// 恢复剧情树在场景中设计的默认浏览位置,用于没有对应 SongBlock 时的安全回退。
/// </summary>
public void FocusStart()
{
if (content == null)
return;
if (_hasInitialContentPosition)
content.anchoredPosition = _initialContentPosition;
storyScrollRect?.StopMovement();
currentBlock = null;
storyTimeline?.RefreshMarkerPositions();
}
/// <summary>
/// 为 Timeline、Yarn 和调试工具提供当前章节静态 Block 定义的查询入口。
/// </summary>
@@ -704,6 +804,7 @@ namespace Ichni.Story
connectors.Clear();
_layoutHorizontalOffset = 0f;
_layoutVerticalOffset = 0f;
currentBlock = null;
if (content != null)

View File

@@ -103,8 +103,6 @@ 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

@@ -1,7 +1,7 @@
using Ichni.Story.Dialogue;
using Ichni.Story.UI;
using Ichni.Localization;
using UnityEngine;
using UnityEngine.Localization.Settings;
using Yarn.Unity;
namespace Ichni.Story.YarnFunctions
@@ -34,6 +34,10 @@ namespace Ichni.Story.YarnFunctions
MessageUIPage.instance.ShowUnlockMessage(songUnlockKey);
else
Debug.LogError("[StoryTreeCommands] 弹窗失败:场景中未找到 StoryMessageBoxUIPage 实例!");
// 解锁 Key 已写入 UnlockSaveModule但当前 StoryPage 的 SongBlock 已经实例化,
// 不会自动重新读取授权状态。立刻刷新可让遮罩、按钮交互和后续 Block 状态与新存档同步。
StoryManager.instance?.treeController?.RefreshAllStates();
});
}
@@ -126,20 +130,7 @@ namespace Ichni.Story.YarnFunctions
private static string GetTranslatedText(string tableName, string key)
{
if (string.IsNullOrEmpty(key)) return string.Empty;
try
{
// 尝试从 Unity Localization 中获取翻译文本
string translated = LocalizationSettings.StringDatabase.GetLocalizedString(tableName, key);
// 若获取失败或为空,则直接原样返回 key 作为兜底文本
return string.IsNullOrEmpty(translated) ? key : translated;
}
catch
{
// 如果表不存在或其他异常,返回原 key
return key;
}
return LocalizationTextService.Resolve(tableName, key, key);
}
}
}