阶段性完成

This commit is contained in:
SoulliesOfficial
2026-07-25 13:27:53 -04:00
parent de70870682
commit a34461d31f
121 changed files with 7022 additions and 4111 deletions

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.Story
{
@@ -147,9 +148,9 @@ namespace Ichni.Story
[FoldoutGroup("$EditorHeader/Text Content")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Title Key")]
[Tooltip("该 TextBlock 标题使用的 Unity Localization Entry Key留空时视觉层使用 Block ID 作为开发阶段回退。")]
public string titleKey;
[LabelText("Title")]
[Tooltip("该 TextBlock 的 Unity Localization String Reference。请在此直接选择 String Table 与 Entry留空时视觉层使用 Block ID 作为开发阶段回退。")]
public LocalizedString title = new LocalizedString();
/// <summary>
/// TextBlock 的视觉和叙事层级。Important 用于主线转折与后续 checkpoint
@@ -229,7 +230,9 @@ namespace Ichni.Story
{
string title = blockType switch
{
StoryBlockType.Text => titleKey,
StoryBlockType.Text => this.title != null && !this.title.IsEmpty
? this.title.TableEntryReference.ToString()
: null,
StoryBlockType.Song => songName,
StoryBlockType.Tutorial => tutorialName,
_ => null

View File

@@ -3,7 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.Story
{
@@ -146,10 +148,7 @@ namespace Ichni.Story
issues.Add($"[Error] Timeline Marker '{marker.markerId}' 的 Anchor Block '{marker.anchorBlockId}' 不是 TextBlock。");
}
if (string.IsNullOrWhiteSpace(marker.labelKey))
{
issues.Add($"[Warning] Timeline Marker '{marker.markerId}' 未配置 Label Key。");
}
ValidateLocalizedString(marker.label, $"Timeline Marker '{marker.markerId}' Label", issues);
}
ValidateTimelineMarkerAnchors(reachableBlockIds, issues);
@@ -203,8 +202,10 @@ namespace Ichni.Story
switch (block.blockType)
{
case StoryBlockType.Text when string.IsNullOrWhiteSpace(block.yarnNodeName):
issues.Add($"[Warning] TextBlock '{block.blockId}' 未配置 Yarn Node。");
case StoryBlockType.Text:
if (string.IsNullOrWhiteSpace(block.yarnNodeName))
issues.Add($"[Warning] TextBlock '{block.blockId}' 未配置 Yarn Node。");
ValidateLocalizedString(block.title, $"TextBlock '{block.blockId}' Title", issues);
break;
case StoryBlockType.Song when string.IsNullOrWhiteSpace(block.songName):
issues.Add($"[Warning] SongBlock '{block.blockId}' 未配置 Song ID。");
@@ -295,6 +296,36 @@ namespace Ichni.Story
}
}
/// <summary>
/// 检查 LocalizedString 是否已选择有效的 String Table 与 Entry。
/// 只读取 Unity Localization 的 Shared Table Data不加载 Addressables也不修改资产
/// 因此可在 StoryData Inspector 中尽早发现 CSV 尚未导入或引用错误。
/// </summary>
private static void ValidateLocalizedString(
LocalizedString reference,
string owner,
List<string> issues)
{
if (reference == null || reference.IsEmpty)
{
issues.Add($"[Warning] {owner} 未配置 LocalizedString。");
return;
}
StringTableCollection tableCollection =
LocalizationEditorSettings.GetStringTableCollection(reference.TableReference);
if (tableCollection?.SharedData == null)
{
issues.Add($"[Error] {owner} 引用的 String Table '{reference.TableReference}' 不存在或无法读取 Shared Table Data。");
return;
}
if (tableCollection.SharedData.GetEntryFromReference(reference.TableEntryReference) == null)
{
issues.Add($"[Error] {owner} 引用的 Entry '{reference.TableEntryReference}' 不存在于 String Table '{tableCollection.TableCollectionName}'。");
}
}
private HashSet<string> ValidateStoryGraph(HashSet<string> blockIds, List<string> issues)
{
Dictionary<string, List<string>> adjacency = new Dictionary<string, List<string>>();

View File

@@ -1,6 +1,7 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.Story
{
@@ -8,8 +9,8 @@ namespace Ichni.Story
/// 一个章节 Timeline 标记的静态定义。
/// <para>它不保存独立的横坐标:运行时始终跟随锚定 Block 的 <c>gridColumn</c>
/// 使时间轴与剧情树在横向拖动时保持对齐。</para>
/// <para>显示文本和 ID 使用 Unity Localization 的 Entry Key本阶段不绑定具体 String Table
/// 以便后续统一迁移本地化系统时集中配置。</para>
/// <para>显示文本直接使用 <see cref="LocalizedString"/>,因此可在 Inspector 中选择
/// String Table 与 Entry并预览各语言的值。</para>
/// </summary>
[Serializable]
[InlineProperty]
@@ -37,13 +38,13 @@ namespace Ichni.Story
public string anchorBlockId;
/// <summary>
/// Timeline 上显示的本地化文本 Key。通常用于时间描述,也可用于特殊叙事文本。
/// Timeline 上显示的本地化文本。通常用于时间描述,也可用于特殊叙事文本。
/// </summary>
[HorizontalGroup("Identity")]
[LabelText("Label Key")]
[LabelWidth(54)]
[Tooltip("Timeline 上显示的 Unity Localization Entry Key通常用于时间或特殊叙事文本。")]
public string labelKey;
[LabelText("Label")]
[LabelWidth(40)]
[Tooltip("Timeline 上显示的 Unity Localization String Reference。请在此直接选择对应 String Table 与 Entry。")]
public LocalizedString label = new LocalizedString();
/// <summary>
/// 是否参与章节主线进度百分比计算。支线或纯装饰性时间标记应关闭此项。

View File

@@ -3,6 +3,8 @@ using Ichni.Menu.UI;
using Ichni.Story.UI;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
namespace Ichni.Story
{
@@ -13,6 +15,9 @@ namespace Ichni.Story
/// </summary>
public static class TutorialFlowController
{
/// <summary>教程确认弹窗统一使用 Message String Table避免业务代码内嵌可见文案。</summary>
private static readonly TableReference MessageTable = "Message";
/// <summary>
/// 由 <see cref="UI.TutorialBlockView"/> 点击时调用。配置无效或 MessageUIPage 未配置时只报错,不修改剧情进度。
/// </summary>
@@ -32,14 +37,18 @@ namespace Ichni.Story
}
// TutorialFlowController 只描述“有哪些选择、各自触发什么效果”;
// 具体按钮由通用 SelectionBox 在运行时生成,后续其它系统可复用同一 UI
messageUIPage.ShowSelectionBox(
tutorialBlock.tutorialName,
string.Empty,
new List<SelectionBoxOption>
// 具体按钮由通用 SelectionBox 在运行时生成,且按钮文案与标题均由 Message 表解析
messageUIPage.ShowLocalizedSelectionBox(
new LocalizedPopupText(MessageTable, "system_tutorial_entry_title"),
new LocalizedPopupText(MessageTable, "system_tutorial_entry_content"),
new List<LocalizedSelectionBoxOption>
{
new("游玩教程", () => PlayTutorial(tutorialBlock, chapter, song, difficulty)),
new("跳过教程", () => SkipTutorial(tutorialBlock))
new(
new LocalizedPopupText(MessageTable, "system_tutorial_enter"),
() => PlayTutorial(tutorialBlock, chapter, song, difficulty)),
new(
new LocalizedPopupText(MessageTable, "system_tutorial_skip"),
() => SkipTutorial(tutorialBlock))
});
}

View File

@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using Ichni.Localization;
using Ichni.Menu.UI;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.UI;
namespace Ichni.Story.UI
@@ -39,15 +36,13 @@ namespace Ichni.Story.UI
[Tooltip("自动在左右各扩展一个刻度周期,保证刻度条循环平移时不会从 Timeline 边缘露出空白。")]
public bool expandTickRepeatRect = true;
[Header("Localization")]
[Tooltip("StoryTimeline 的 Unity Localization String Table。Marker.labelKey 在该 Table 中查找。")]
public TableReference localizationTable;
private sealed class RuntimeMarker
{
public StoryTimelineMarkerDefinition definition;
public StoryBlockView anchorView;
public StoryTimelineMarkerView view;
public string localizedLabel;
public LocalizedString.ChangeHandler localizedLabelChanged;
}
private readonly List<RuntimeMarker> _markers = new List<RuntimeMarker>();
@@ -69,7 +64,6 @@ namespace Ichni.Story.UI
private void OnEnable()
{
LocalizationSettings.SelectedLocaleChanged += OnSelectedLocaleChanged;
SubscribeToStoryScrollRect();
// StoryPage 从对话界面返回后Canvas 与 StoryTree 会在同一帧重新启用。
@@ -79,7 +73,6 @@ namespace Ichni.Story.UI
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= OnSelectedLocaleChanged;
UnsubscribeFromStoryScrollRect();
}
@@ -124,6 +117,7 @@ namespace Ichni.Story.UI
foreach (RuntimeMarker marker in _markers)
{
UnbindLocalizedLabel(marker);
if (marker.view != null)
{
Destroy(marker.view.gameObject);
@@ -356,16 +350,19 @@ namespace Ichni.Story.UI
StoryTimelineMarkerView view = Instantiate(markerPrefab, markerContainer);
view.name = $"StoryTimelineMarker_{marker.markerId}";
view.SetLabel(GetLocalizedLabel(marker.labelKey));
RuntimeMarker runtimeMarker = new RuntimeMarker
{
definition = marker,
anchorView = anchorView,
view = view
view = view,
localizedLabel = marker.markerId
};
view.BindClick(() => RequestRollback(runtimeMarker));
_markers.Add(runtimeMarker);
// LocalizedString 首次读取是异步的;先显示稳定 Marker ID避免 Prefab 默认文字短暂闪现。
view.SetLabel(runtimeMarker.localizedLabel);
BindLocalizedLabel(runtimeMarker);
return true;
}
@@ -388,48 +385,52 @@ namespace Ichni.Story.UI
return;
}
string markerLabel = GetLocalizedLabel(marker.definition.labelKey);
messageUIPage.ShowSelectionBox(
"重开本章节",
$"将重置“{markerLabel}”所在列及其之后的剧情选择与进度。歌曲成绩、解锁内容、设置和其它章节不会受到影响。",
new List<SelectionBoxOption>
string markerLabel = marker.localizedLabel;
messageUIPage.ShowLocalizedSelectionBox(
new LocalizedPopupText("Message", "system_rollback_chapter_title"),
new LocalizedPopupText("Message", "system_rollback_chapter_content", markerLabel),
new List<LocalizedSelectionBoxOption>
{
new SelectionBoxOption("重开此时间点", () => _treeController.TryRollbackToMarker(marker.definition.markerId)),
new SelectionBoxOption("取消", () => { })
new LocalizedSelectionBoxOption(
new LocalizedPopupText("Message", "system_rollback_confirm"),
() => _treeController.TryRollbackToMarker(marker.definition.markerId)),
new LocalizedSelectionBoxOption(
new LocalizedPopupText("UI", "ui_common_cancel"),
() => { })
});
}
/// <summary>
/// 使用 StoryTimeline 指定的 Unity Localization String Table 解析 Label Key。
/// String Table 或条目尚未配置时回退显示 Key确保开发阶段仍能从 UI 上定位缺失翻译
/// 订阅 Marker 自身的 LocalizedString。LocalizedString 会负责首次异步读取与 Locale
/// 切换后的自动刷新Timeline 不再维护独立的 Table / Key 解析逻辑
/// </summary>
private string GetLocalizedLabel(string labelKey)
private static void BindLocalizedLabel(RuntimeMarker marker)
{
if (string.IsNullOrEmpty(labelKey))
if (marker?.definition?.label == null || marker.definition.label.IsEmpty)
{
return string.Empty;
marker?.view?.SetLabel(marker?.localizedLabel ?? string.Empty);
return;
}
return LocalizationTextService.Resolve(localizationTable, labelKey, labelKey);
marker.localizedLabelChanged = localizedText =>
{
marker.localizedLabel = string.IsNullOrEmpty(localizedText)
? marker.definition.markerId
: localizedText;
if (marker.view != null)
marker.view.SetLabel(marker.localizedLabel);
};
marker.definition.label.StringChanged += marker.localizedLabelChanged;
}
private async void OnSelectedLocaleChanged(Locale _)
private static void UnbindLocalizedLabel(RuntimeMarker marker)
{
foreach (RuntimeMarker marker in _markers)
{
if (string.IsNullOrEmpty(marker.definition.labelKey)) continue;
if (marker?.definition?.label == null || marker.localizedLabelChanged == null)
return;
string localizedLabel = await LocalizationTextService.ResolveAsync(
localizationTable,
marker.definition.labelKey,
marker.definition.labelKey);
// 异步等待结束后,防范对象在此期间被销毁。
if (marker.view != null)
{
marker.view.SetLabel(localizedLabel);
}
}
marker.definition.label.StringChanged -= marker.localizedLabelChanged;
marker.localizedLabelChanged = null;
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using Ichni.Story.Dialogue;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.UI;
namespace Ichni.Story.UI
@@ -24,6 +25,21 @@ namespace Ichni.Story.UI
// 当前已实例化的状态背景对象;状态切换时销毁并替换
private GameObject _currentVisual;
// 直接持有 StoryData 的 LocalizedString并订阅其 StringChanged 回调。
// Unity Localization 会在首次订阅和语言切换时自行异步刷新文本。
private LocalizedString _titleReference;
private bool _isTitleLocalizationSubscribed;
private void OnEnable()
{
StartTitleLocalization();
}
private void OnDisable()
{
StopTitleLocalization();
}
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
{
// 注意base.Initialize 末尾会调用 ApplyState(state),届时已生成对应背景
@@ -34,9 +50,43 @@ namespace Ichni.Story.UI
if (storyIdText != null)
storyIdText.text = def.blockId;
// 当前仍直接显示 titleKey正式内容接入时需要由 Unity Localization 解析玩家可见标题。
StopTitleLocalization();
_titleReference = def.title;
StartTitleLocalization();
}
/// <summary>
/// 订阅当前 TextBlock 的 LocalizedString。无需手写 Table / Key 读取或监听全局 Locale
/// LocalizedString 会遵循 Unity 官方生命周期异步加载,并在语言变化后触发 StringChanged。
/// </summary>
private void StartTitleLocalization()
{
if (_isTitleLocalizationSubscribed || titleText == null)
return;
if (_titleReference == null || _titleReference.IsEmpty)
{
titleText.text = blockId;
return;
}
_titleReference.StringChanged += ApplyLocalizedTitle;
_isTitleLocalizationSubscribed = true;
}
private void StopTitleLocalization()
{
if (!_isTitleLocalizationSubscribed || _titleReference == null)
return;
_titleReference.StringChanged -= ApplyLocalizedTitle;
_isTitleLocalizationSubscribed = false;
}
private void ApplyLocalizedTitle(string localizedTitle)
{
if (titleText != null)
titleText.text = def.titleKey;
titleText.text = string.IsNullOrEmpty(localizedTitle) ? blockId : localizedTitle;
}
/// <summary>

View File

@@ -1,6 +1,7 @@
using Ichni.Story.Dialogue;
using Ichni.Story.UI;
using Ichni.Localization;
using Ichni.Menu;
using System.Collections.Generic;
using UnityEngine;
using Yarn.Unity;
@@ -14,14 +15,17 @@ namespace Ichni.Story.YarnFunctions
// ── Yarn Commands ────────────────────────────────────────────────────────
/// <summary>
/// 兼容既有 Yarn 脚本:授予歌曲相关的解锁 Key并在当前对话结束后弹出提示。
/// 格式: &lt;&lt;unlock_song unlock_key&gt;&gt;
/// 授予歌曲相关的解锁 Key并在当前对话结束后弹出提示。
/// 格式: &lt;&lt;unlock_song unlock_key "song_id"&gt;&gt;
/// <para>Key 必须使用小写字母、数字和下划线,例如 <c>story_ch0_prologue_completed</c>。</para>
/// <para><c>song_id</c> 必须是 <see cref="SongItemData.songName"/> 的稳定标识,而不是显示名称或本地化文本。
/// 该参数用于解析玩家可见的歌曲名称,绝不能将技术 Unlock Key 显示给玩家。</para>
/// 新内容若不需要歌曲提示,优先使用 <see cref="GrantUnlock"/>。
/// </summary>
/// <param name="songUnlockKey">要授予的稳定解锁 Key而不是歌曲显示名称。</param>
/// <param name="songId">对应 <see cref="SongItemData.songName"/> 的稳定歌曲 ID。</param>
[YarnCommand("unlock_song")]
public static void UnlockSong(string songUnlockKey)
public static void UnlockSong(string songUnlockKey, string songId)
{
ExecuteAfterDialogueCommit(() =>
{
@@ -31,7 +35,7 @@ namespace Ichni.Story.YarnFunctions
}
if (MessageUIPage.instance != null)
MessageUIPage.instance.ShowUnlockMessage(songUnlockKey);
MessageUIPage.instance.ShowUnlockMessage(ResolveSongDisplayName(songId));
else
Debug.LogError("[StoryTreeCommands] 弹窗失败:场景中未找到 StoryMessageBoxUIPage 实例!");
@@ -57,7 +61,7 @@ namespace Ichni.Story.YarnFunctions
/// <summary>
/// 在对话结束后,弹出一个自定义 MessageBox 提示。
/// 会尝试使用 Unity Localization 进行翻译如果找不到对应条目则回退显示原文本Key
/// 文本 Key 会交给 MessageUIPage 的 FIFO 队列,并在该弹窗真正显示前才按当前 Locale 解析
/// 格式: &lt;&lt;show_message titleKey contentKey [tableName]&gt;&gt;
/// </summary>
/// <param name="titleKey">标题文本或本地化键</param>
@@ -66,13 +70,13 @@ namespace Ichni.Story.YarnFunctions
[YarnCommand("show_message")]
public static void ShowMessage(string titleKey, string contentKey, string tableName = "Message")
{
string translatedTitle = GetTranslatedText(tableName, titleKey);
string translatedContent = GetTranslatedText(tableName, contentKey);
Debug.Log($"[StoryTreeCommands] 准备在对话结束后显示自定义弹窗:标题='{translatedTitle}',内容='{translatedContent}'");
Debug.Log($"[StoryTreeCommands] 准备在对话结束后显示本地化弹窗Table='{tableName}'TitleKey='{titleKey}'ContentKey='{contentKey}'。");
ExecuteAfterDialogueCommit(() =>
{
if (MessageUIPage.instance != null)
MessageUIPage.instance.ShowCustomMessage(translatedTitle, translatedContent);
MessageUIPage.instance.ShowLocalizedMessage(
new LocalizedPopupText(tableName, titleKey),
new LocalizedPopupText(tableName, contentKey));
else
Debug.LogError("[StoryTreeCommands] 无法显示自定义弹窗:未找到 MessageUIPage 实例。");
});
@@ -128,9 +132,36 @@ namespace Ichni.Story.YarnFunctions
// ── 内部辅助 ─────────────────────────────────────────────────────────────
private static string GetTranslatedText(string tableName, string key)
/// <summary>
/// 以稳定 Song ID 查找当前项目中配置的歌曲显示名。当前阶段使用 <see cref="SongItemData.displaySongName"/>
/// 将来歌曲标题接入 Chapter Content 本地化后只需替换本函数内部的返回逻辑Yarn 与弹窗 API 无需修改。
/// </summary>
private static string ResolveSongDisplayName(string songId)
{
return LocalizationTextService.Resolve(tableName, key, key);
List<ChapterSelectionUnit> chapters = ChapterSelectionManager.instance?.chapters;
if (chapters != null)
{
foreach (ChapterSelectionUnit chapter in chapters)
{
if (chapter?.songs == null)
{
continue;
}
foreach (SongItemData song in chapter.songs)
{
if (song != null && song.songName == songId)
{
return string.IsNullOrWhiteSpace(song.displaySongName)
? song.songName
: song.displaySongName;
}
}
}
}
Debug.LogWarning($"[StoryTreeCommands] 无法为歌曲解锁提示解析 Song ID '{songId}',将显示该 ID。", ChapterSelectionManager.instance);
return songId ?? string.Empty;
}
}
}