Story流程+本地化
This commit is contained in:
8
Assets/Scripts/Localization.meta
Normal file
8
Assets/Scripts/Localization.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8f8be71675942d9a9ed85d520cc4659
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
134
Assets/Scripts/Localization/LocalizationTextService.cs
Normal file
134
Assets/Scripts/Localization/LocalizationTextService.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Localization;
|
||||
using UnityEngine.Localization.Settings;
|
||||
using UnityEngine.Localization.Tables;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
|
||||
namespace Ichni.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// 项目运行时代码读取 Unity Localization 文本的统一入口。
|
||||
/// <para>静态 Prefab 文本仍应使用 <c>LocalizeStringEvent</c>;只有运行时生成、Yarn 命令、Story 数据等无法直接挂组件的文本才调用本服务。</para>
|
||||
/// <para>同步接口绝不强制 Addressables 完成加载:Localization 尚未就绪时直接返回 fallback,避免在场景切换或 UI 点击中制造卡顿。</para>
|
||||
/// </summary>
|
||||
public static class LocalizationTextService
|
||||
{
|
||||
private static readonly HashSet<string> ReportedFallbacks = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 在 Unity Localization 已初始化时立即解析文本;无法解析时返回调用方提供的 fallback。
|
||||
/// <para>fallback 传入 <c>null</c> 时回退显示 Key,适合开发阶段快速定位缺失条目;传入空字符串时会保留空字符串。</para>
|
||||
/// </summary>
|
||||
public static string Resolve(
|
||||
TableReference tableReference,
|
||||
string entryKey,
|
||||
string fallback = null,
|
||||
params object[] arguments)
|
||||
{
|
||||
string resolvedFallback = GetFallback(entryKey, fallback);
|
||||
if (string.IsNullOrEmpty(entryKey))
|
||||
{
|
||||
return resolvedFallback;
|
||||
}
|
||||
|
||||
AsyncOperationHandle<LocalizationSettings> initializationOperation = LocalizationSettings.InitializationOperation;
|
||||
if (!initializationOperation.IsDone || initializationOperation.Status != AsyncOperationStatus.Succeeded)
|
||||
{
|
||||
ReportFallbackOnce(tableReference, entryKey, "Unity Localization 尚未完成初始化");
|
||||
return resolvedFallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string localizedText = LocalizationSettings.StringDatabase.GetLocalizedString(
|
||||
tableReference,
|
||||
entryKey,
|
||||
null,
|
||||
FallbackBehavior.UseProjectSettings,
|
||||
arguments);
|
||||
if (!string.IsNullOrEmpty(localizedText))
|
||||
{
|
||||
return localizedText;
|
||||
}
|
||||
|
||||
ReportFallbackOnce(tableReference, entryKey, "String Table 未返回有效文本");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportFallbackOnce(tableReference, entryKey, exception.Message);
|
||||
}
|
||||
|
||||
return resolvedFallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步解析文本。该接口只等待 Unity 官方 <see cref="LocalizationSettings.InitializationOperation"/> 和字符串加载操作,
|
||||
/// 不创建额外 Bootstrap,也不使用 <c>WaitForCompletion</c> 阻塞主线程。
|
||||
/// </summary>
|
||||
public static async Task<string> ResolveAsync(
|
||||
TableReference tableReference,
|
||||
string entryKey,
|
||||
string fallback = null,
|
||||
params object[] arguments)
|
||||
{
|
||||
string resolvedFallback = GetFallback(entryKey, fallback);
|
||||
if (string.IsNullOrEmpty(entryKey))
|
||||
{
|
||||
return resolvedFallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AsyncOperationHandle<LocalizationSettings> initializationOperation = LocalizationSettings.InitializationOperation;
|
||||
await initializationOperation.Task;
|
||||
if (initializationOperation.Status != AsyncOperationStatus.Succeeded)
|
||||
{
|
||||
ReportFallbackOnce(tableReference, entryKey, "Unity Localization 初始化失败");
|
||||
return resolvedFallback;
|
||||
}
|
||||
|
||||
AsyncOperationHandle<string> textOperation = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(
|
||||
tableReference,
|
||||
entryKey,
|
||||
null,
|
||||
FallbackBehavior.UseProjectSettings,
|
||||
arguments);
|
||||
await textOperation.Task;
|
||||
if (textOperation.Status == AsyncOperationStatus.Succeeded && !string.IsNullOrEmpty(textOperation.Result))
|
||||
{
|
||||
return textOperation.Result;
|
||||
}
|
||||
|
||||
ReportFallbackOnce(tableReference, entryKey, "String Table 异步加载失败或未返回有效文本");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportFallbackOnce(tableReference, entryKey, exception.Message);
|
||||
}
|
||||
|
||||
return resolvedFallback;
|
||||
}
|
||||
|
||||
private static string GetFallback(string entryKey, string fallback)
|
||||
{
|
||||
return fallback ?? entryKey ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同一 Table/Key 的回退原因只输出一次,避免 Helper、Timeline 等重复刷新 UI 时刷屏。
|
||||
/// </summary>
|
||||
private static void ReportFallbackOnce(TableReference tableReference, string entryKey, string reason)
|
||||
{
|
||||
string diagnosticKey = $"{tableReference}:{entryKey}";
|
||||
if (!ReportedFallbacks.Add(diagnosticKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[LocalizationTextService] 无法解析 '{diagnosticKey}',将显示 fallback。原因:{reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d611ba2aff044f89da2f79bc04cf75a
|
||||
@@ -66,8 +66,8 @@ namespace Ichni
|
||||
// 真正进入 GameScene 时,MenuManager.EnterGame 会补充 SongSelection 返回目标。
|
||||
if (PrepareGameLaunch(chapter, song, difficulty, MenuReturnDestination.None))
|
||||
{
|
||||
// 只有玩家真正确认“进入游戏”且命中剧情目标歌曲时,才完成对应 SongBlock。
|
||||
// 单纯从剧情打开选曲、浏览其它歌曲或返回菜单都不会写入剧情进度。
|
||||
// 玩家确认进入游戏即写入 SongSaves.isTried;StoryTree 会根据该统一歌曲存档
|
||||
// 自动同步对应 SongBlock 的完成状态。单纯浏览歌曲或返回菜单不会写入任何进度。
|
||||
StoryManager.instance?.HandleSongSelectionConfirmed(chapter, song);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,12 +239,10 @@ namespace Ichni
|
||||
|
||||
private MenuReturnDestination ResolveCurrentReturnDestination()
|
||||
{
|
||||
// 剧情 SongBlock 仅在玩家确认进入指定目标歌曲后才要求返回剧情;
|
||||
// 普通选曲与从剧情入口改选其它歌曲都继续返回选曲页。
|
||||
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
|
||||
return context != null && context.isActive && context.returnToStoryAfterGame
|
||||
? MenuReturnDestination.Story
|
||||
: MenuReturnDestination.SongSelection;
|
||||
// 所有从 SongSelection 启动的常规歌曲游玩都统一返回 SongSelection。
|
||||
// Story SongBlock 的完成状态由 SongSaves.isTried 自动恢复,无需在 GameScene 返回时
|
||||
// 重新打开 StoryPage;只有 TutorialFlowController 会显式传入 Story 返回目标。
|
||||
return MenuReturnDestination.SongSelection;
|
||||
}
|
||||
|
||||
private void HandleGameSceneLoadFailure(string reason)
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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 或其它剧情入口写入同名变量后,存档值才会覆盖这里的默认值。
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(); // 第二次点击:推进到下一行
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 无论从何种歌曲入口启动,都会返回 SongSelection;TutorialBlock 仍通过其独立
|
||||
/// 的 <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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}' 的对话。");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Ichni.Diagnostics;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Localization.Settings;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity Localization 的统一启动入口。
|
||||
/// <para>
|
||||
/// 本类只包装 Unity 官方提供的 <see cref="LocalizationSettings.InitializationOperation"/>,
|
||||
/// 不自行初始化 Addressables、不查询 Locale Label,也不复制 Localization Package 的内部加载流程。
|
||||
/// 因此 Settings、Yarn 与其它本地化消费者始终等待同一个由 Unity 管理的初始化操作。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LocalizationBootstrap
|
||||
{
|
||||
public enum InitializationState
|
||||
{
|
||||
NotStarted,
|
||||
Initializing,
|
||||
Ready,
|
||||
Failed
|
||||
}
|
||||
|
||||
private static AsyncOperationHandle<LocalizationSettings> _initializationOperation;
|
||||
|
||||
public static InitializationState State { get; private set; } = InitializationState.NotStarted;
|
||||
public static bool IsReady => State == InitializationState.Ready;
|
||||
public static bool HasFailed => State == InitializationState.Failed;
|
||||
public static string FailureReason { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unity Localization 官方初始化 Handle。
|
||||
/// 获取该属性会以幂等方式启动初始化,但不会同步阻塞主线程。
|
||||
/// </summary>
|
||||
public static AsyncOperationHandle<LocalizationSettings> InitializationOperation
|
||||
{
|
||||
get
|
||||
{
|
||||
BeginInitialization();
|
||||
return _initializationOperation;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在关闭 Domain Reload 的 Editor 配置下,清理上一次 Play Mode 留下的项目侧状态。
|
||||
/// Localization Package 会自行重建其内部 Handle,本类不持有也不释放 Package 的资源。
|
||||
/// </summary>
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetState()
|
||||
{
|
||||
_initializationOperation = default;
|
||||
State = InitializationState.NotStarted;
|
||||
FailureReason = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 Unity 官方流程获取唯一的 Localization 初始化操作。
|
||||
/// 可以由多个系统重复调用;已经启动后不会创建第二条加载链。
|
||||
/// </summary>
|
||||
public static void BeginInitialization()
|
||||
{
|
||||
if (State != InitializationState.NotStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 不设置 InitializeSynchronously,也不直接访问 SelectedLocale。
|
||||
// InitializationOperation 会异步完成 Locale 选择及所有配置为 Preload 的数据库初始化。
|
||||
_initializationOperation = LocalizationSettings.InitializationOperation;
|
||||
State = InitializationState.Initializing;
|
||||
Log("开始等待 Unity Localization 官方 InitializationOperation。");
|
||||
RefreshState();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Fail("无法创建 Unity Localization 初始化操作。", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新初始化结果。供 YarnTask 等不能直接 yield AsyncOperationHandle 的异步流程调用。
|
||||
/// </summary>
|
||||
public static void RefreshState()
|
||||
{
|
||||
if (State != InitializationState.Initializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_initializationOperation.IsValid())
|
||||
{
|
||||
Fail("Unity Localization 返回了无效的 InitializationOperation Handle。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_initializationOperation.IsDone)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initializationOperation.Status != AsyncOperationStatus.Succeeded)
|
||||
{
|
||||
Fail("Unity Localization 初始化失败。", _initializationOperation.OperationException);
|
||||
return;
|
||||
}
|
||||
|
||||
LocaleReady();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 供 MonoBehaviour 协程等待 Unity Localization 初始化。
|
||||
/// 此处直接 yield 官方 Handle,不使用轮询超时或自建 Addressables 状态机。
|
||||
/// </summary>
|
||||
public static IEnumerator WaitForInitialization()
|
||||
{
|
||||
BeginInitialization();
|
||||
|
||||
if (State == InitializationState.Initializing)
|
||||
{
|
||||
yield return _initializationOperation;
|
||||
RefreshState();
|
||||
}
|
||||
}
|
||||
|
||||
private static void LocaleReady()
|
||||
{
|
||||
State = InitializationState.Ready;
|
||||
FailureReason = null;
|
||||
|
||||
// SelectedLocale 只有在完整 InitializationOperation 成功后才允许同步读取。
|
||||
string localeCode = LocalizationSettings.SelectedLocale != null
|
||||
? LocalizationSettings.SelectedLocale.Identifier.Code
|
||||
: "<null>";
|
||||
Log($"Unity Localization 初始化完成。SelectedLocale={localeCode}");
|
||||
}
|
||||
|
||||
private static void Fail(string message, Exception exception = null)
|
||||
{
|
||||
FailureReason = exception == null ? message : $"{message} Exception={exception}";
|
||||
State = InitializationState.Failed;
|
||||
|
||||
Debug.LogError($"[LocalizationBootstrap] {FailureReason}");
|
||||
BuildHangTracer.Log("LOCALIZATION_BOOTSTRAP", $"[Failed] {FailureReason}");
|
||||
}
|
||||
|
||||
private static void Log(string message)
|
||||
{
|
||||
Debug.Log($"[LocalizationBootstrap] {message}");
|
||||
BuildHangTracer.Log("LOCALIZATION_BOOTSTRAP", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a3bb067e72f4b1ca56834f12479b841
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,18 +1,19 @@
|
||||
using Ichni.Menu;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Diagnostics;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Localization;
|
||||
using UnityEngine.Localization.Settings;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
// MenuManager.Start 会直接读取已加载的设置,因此 SettingsManager 必须先完成 Start。
|
||||
[DefaultExecutionOrder(-10000)]
|
||||
public class SettingsManager : SerializedMonoBehaviour
|
||||
{
|
||||
@@ -23,11 +24,15 @@ namespace Ichni
|
||||
private const string BalancedQualityName = "Balanced";
|
||||
private const string HighFidelityQualityName = "High Fidelity";
|
||||
|
||||
/// <summary>新建存档、无效 Code 与无法迁移的旧数据统一回退到的 Locale。</summary>
|
||||
public const string DefaultLocaleCode = "zh-CN";
|
||||
|
||||
/// <summary>
|
||||
/// 与设置界面 Dropdown 顺序严格一致的 Locale Code。
|
||||
/// 不使用 AvailableLocales.Locales 的返回顺序,因为 Addressables Catalog 的条目顺序不属于稳定存档契约。
|
||||
/// 旧版 <see cref="SettingsSaveData.languageIndex"/> 与设置页面下拉框的固定对应关系。
|
||||
/// <para>该数组不从 Addressables Catalog 动态读取;Catalog 条目顺序不是稳定的存档契约。</para>
|
||||
/// <para>新存档不会保存数组索引,而是保存 <see cref="SettingsSaveData.languageCode"/>。保留本数组仅为迁移旧 ES3 数据与驱动当前下拉框。</para>
|
||||
/// </summary>
|
||||
private static readonly string[] SupportedLocaleCodes =
|
||||
private static readonly string[] LegacyLanguageIndexToCode =
|
||||
{
|
||||
"zh-CN",
|
||||
"en",
|
||||
@@ -49,10 +54,10 @@ namespace Ichni
|
||||
// 记录相机在玩家关闭后处理前的原始状态。重新开启时必须恢复原始值,不能无条件将所有相机改为开启。
|
||||
private readonly Dictionary<int, bool> postProcessingCameraStates = new Dictionary<int, bool>();
|
||||
|
||||
// 语言切换请求统一由一个协程串行处理。协程运行期间的新请求只覆盖目标索引。
|
||||
// 语言切换请求统一由一个协程串行处理。协程运行期间的新请求只覆盖目标 Locale Code。
|
||||
private Coroutine applyLanguageSettingsCoroutine;
|
||||
private bool isLanguageRoutineRunning;
|
||||
private int pendingLanguageIndex = -1;
|
||||
private string pendingLanguageCode;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -106,6 +111,7 @@ namespace Ichni
|
||||
public void LoadGameSettings()
|
||||
{
|
||||
string savePath = Application.persistentDataPath + "/GameData/" + SettingsSaveFileName;
|
||||
bool migratedLegacyLanguageIndex = false;
|
||||
if (ES3.FileExists(savePath))
|
||||
{
|
||||
// 保留原有 ES3 Key,确保本次数据模型重命名不会让已有本地设置失效。
|
||||
@@ -115,10 +121,17 @@ namespace Ichni
|
||||
{
|
||||
settingsSaveData = CreateDefaultSettingsSaveData();
|
||||
}
|
||||
Debug.Log($"[SettingsManager] 已加载存档:{savePath},当前语言索引={settingsSaveData.languageIndex}");
|
||||
|
||||
migratedLegacyLanguageIndex = MigrateLanguageCodeIfNeeded();
|
||||
ApplyAudioSettings();
|
||||
ApplyGraphicSettings();
|
||||
ApplyLanguageSettings();
|
||||
|
||||
// 旧语言索引只应参与一次迁移;立刻写回稳定 Code,避免每次启动都重复依赖旧字段。
|
||||
if (migratedLegacyLanguageIndex)
|
||||
{
|
||||
SaveGameSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -253,6 +266,8 @@ namespace Ichni
|
||||
private SettingsSaveData CreateDefaultSettingsSaveData()
|
||||
{
|
||||
SettingsSaveData defaultSettings = new SettingsSaveData();
|
||||
defaultSettings.languageCode = DefaultLocaleCode;
|
||||
defaultSettings.languageIndex = GetLanguageDropdownIndex(DefaultLocaleCode);
|
||||
defaultSettings.graphicsQualityPreset = GetGraphicsQualityPreset(QualitySettings.GetQualityLevel());
|
||||
#if UNITY_STANDALONE || UNITY_EDITOR
|
||||
defaultSettings.displayMode = GetGraphicsDisplayMode(Screen.fullScreenMode);
|
||||
@@ -346,35 +361,47 @@ namespace Ichni
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将当前语言索引应用到 Unity Localization 管线。
|
||||
/// 返回当前存档语言在设置下拉框中的显示索引。
|
||||
/// <para>该索引只服务 UI;持久化始终使用 <see cref="SettingsSaveData.languageCode"/>。</para>
|
||||
/// </summary>
|
||||
public int GetLanguageDropdownIndex()
|
||||
{
|
||||
return GetLanguageDropdownIndex(GetSavedLanguageCode());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由设置下拉框提交玩家的新语言选择。
|
||||
/// <para>所有 UI 必须经由此方法写入语言,避免重新将易变的下拉索引保存为存档契约。</para>
|
||||
/// </summary>
|
||||
public void SetLanguageByDropdownIndex(int languageIndex)
|
||||
{
|
||||
string localeCode = GetLocaleCodeFromDropdownIndex(languageIndex);
|
||||
settingsSaveData.languageCode = localeCode;
|
||||
|
||||
// 继续维护旧字段,便于仍在本地的旧存档、开发调试和降级检查;运行时不再读取它。
|
||||
settingsSaveData.languageIndex = GetLanguageDropdownIndex(localeCode);
|
||||
ApplyLanguageSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将当前语言 Code 应用到 Unity Localization 管线。
|
||||
/// 本方法只等待 Unity 官方 InitializationOperation,并在其完成后应用玩家最新一次语言选择。
|
||||
/// </summary>
|
||||
public void ApplyLanguageSettings()
|
||||
{
|
||||
if (settingsSaveData == null)
|
||||
{
|
||||
BuildHangTracer.Log("SETTINGS_MGR", "[ApplyLanguageSettings] settingsSaveData 为空,取消语言切换。");
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLanguageIndex = settingsSaveData.languageIndex;
|
||||
pendingLanguageCode = GetSavedLanguageCode();
|
||||
|
||||
if (isLanguageRoutineRunning)
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_MGR",
|
||||
$"[ApplyLanguageSettings] 已有语言切换流程运行,更新待处理索引为 {pendingLanguageIndex}。");
|
||||
return;
|
||||
}
|
||||
|
||||
isLanguageRoutineRunning = true;
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_MGR",
|
||||
$"[ApplyLanguageSettings] 启动语言切换流程,目标索引={pendingLanguageIndex}。");
|
||||
|
||||
//LocalizationSettings.SelectedLocale = LocalizationSettings.AvailableLocales.GetLocale(SupportedLocaleCodes[pendingLanguageIndex]);
|
||||
|
||||
applyLanguageSettingsCoroutine = StartCoroutine(ApplyLanguageSettingsRoutine());
|
||||
}
|
||||
|
||||
@@ -386,68 +413,40 @@ namespace Ichni
|
||||
// 避开 Dropdown.onValueChanged 当前同步调用栈。
|
||||
yield return null;
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] 等待 Unity Localization InitializationOperation。" +
|
||||
$"CurrentState={LocalizationBootstrap.State}");
|
||||
|
||||
yield return LocalizationBootstrap.WaitForInitialization();
|
||||
if (!LocalizationBootstrap.IsReady)
|
||||
// 直接等待 Unity Localization 官方初始化句柄。无需再维护一套平行的
|
||||
// Bootstrap 状态机;当 Addressables/场景异步队列可正常推进时,该句柄会自行完成。
|
||||
AsyncOperationHandle<LocalizationSettings> initializationOperation =
|
||||
LocalizationSettings.InitializationOperation;
|
||||
yield return initializationOperation;
|
||||
if (initializationOperation.Status != AsyncOperationStatus.Succeeded)
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] Unity Localization 初始化失败,取消语言切换。" +
|
||||
$"Reason={LocalizationBootstrap.FailureReason}");
|
||||
pendingLanguageIndex = -1;
|
||||
if (initializationOperation.OperationException != null)
|
||||
{
|
||||
Debug.LogException(initializationOperation.OperationException, this);
|
||||
}
|
||||
|
||||
Debug.LogError("[SettingsManager] Unity Localization 初始化失败,取消语言切换。", this);
|
||||
pendingLanguageCode = null;
|
||||
yield break;
|
||||
}
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] Unity Localization 已就绪。启动 Locale=" +
|
||||
$"{LocalizationSettings.SelectedLocale?.Identifier.Code ?? "<null>"}");
|
||||
|
||||
// 处理流程运行期间收到的最新请求。
|
||||
while (pendingLanguageIndex >= 0)
|
||||
while (!string.IsNullOrEmpty(pendingLanguageCode))
|
||||
{
|
||||
int requestedLanguageIndex = pendingLanguageIndex;
|
||||
pendingLanguageIndex = -1;
|
||||
|
||||
if (requestedLanguageIndex < 0 ||
|
||||
requestedLanguageIndex >= SupportedLocaleCodes.Length)
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] 语言索引无效。Index={requestedLanguageIndex}," +
|
||||
$"SupportedCount={SupportedLocaleCodes.Length}。回退到简体中文。");
|
||||
|
||||
// 旧版本存档或损坏存档可能留下越界索引。将它修正为稳定的默认值,
|
||||
// 避免首次启动时保持一个不受 SettingsSave 控制的 Locale。
|
||||
requestedLanguageIndex = 0;
|
||||
settingsSaveData.languageIndex = 0;
|
||||
}
|
||||
|
||||
string targetLocaleCode = SupportedLocaleCodes[requestedLanguageIndex];
|
||||
string targetLocaleCode = pendingLanguageCode;
|
||||
pendingLanguageCode = null;
|
||||
Locale targetLocale = LocalizationSettings.AvailableLocales.GetLocale(targetLocaleCode);
|
||||
Locale currentLocale = LocalizationSettings.SelectedLocale;
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] 准备切换 Locale:" +
|
||||
$"{currentLocale?.Identifier.Code ?? "<null>"} -> " +
|
||||
$"{targetLocale?.Identifier.Code ?? "<null>"}");
|
||||
|
||||
if (targetLocale == null)
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] Localization Settings 中不存在 Locale '{targetLocaleCode}',取消本次切换。");
|
||||
Debug.LogWarning(
|
||||
$"[SettingsManager] Localization Settings 中不存在 Locale '{targetLocaleCode}'。");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLocale == targetLocale)
|
||||
{
|
||||
BuildHangTracer.Log("SETTINGS_COROUTINE", "[Coroutine] 目标 Locale 与当前 Locale 相同,跳过切换。");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -456,35 +455,90 @@ namespace Ichni
|
||||
// setter 会同步更新 SelectedLocale,并通知 LocalizedString / LocalizedAsset 按需刷新。
|
||||
// 初始化已在本协程开头完成,因此此处不会触发同步 Locale 加载。
|
||||
yield return null;
|
||||
|
||||
Locale appliedLocale = LocalizationSettings.SelectedLocale;
|
||||
if (appliedLocale == targetLocale)
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"<<< [CRITICAL] SelectedLocale 已切换:{targetLocale.Identifier.Code}");
|
||||
}
|
||||
else
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] SelectedLocale 校验失败。Expected={targetLocale.Identifier.Code}," +
|
||||
$"Actual={appliedLocale?.Identifier.Code ?? "<null>"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
applyLanguageSettingsCoroutine = null;
|
||||
isLanguageRoutineRunning = false;
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] 语言切换流程结束。PendingIndex={pendingLanguageIndex}");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将未保存过 Locale Code 的旧 ES3 数据迁移为稳定 Code。
|
||||
/// <para>迁移使用旧版本的固定下拉排序,而不是当前 Addressables 的 Locale 列表顺序;因此旧玩家的语言不会被错误映射。</para>
|
||||
/// </summary>
|
||||
/// <returns>本次是否写入了新的 <see cref="SettingsSaveData.languageCode"/>。</returns>
|
||||
private bool MigrateLanguageCodeIfNeeded()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settingsSaveData.languageCode))
|
||||
{
|
||||
string normalizedCode = NormalizeLocaleCode(settingsSaveData.languageCode);
|
||||
bool wasNormalized = normalizedCode != settingsSaveData.languageCode;
|
||||
settingsSaveData.languageCode = normalizedCode;
|
||||
settingsSaveData.languageIndex = GetLanguageDropdownIndex(normalizedCode);
|
||||
return wasNormalized;
|
||||
}
|
||||
|
||||
string migratedCode = GetLocaleCodeFromDropdownIndex(settingsSaveData.languageIndex);
|
||||
Debug.Log($"[SettingsManager] 已将旧语言索引 {settingsSaveData.languageIndex} 迁移为 Locale Code '{migratedCode}'。", this);
|
||||
settingsSaveData.languageCode = migratedCode;
|
||||
settingsSaveData.languageIndex = GetLanguageDropdownIndex(migratedCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>读取并规范化当前存档的 Locale Code。空值或未知 Code 始终回退到 <see cref="DefaultLocaleCode"/>。</summary>
|
||||
private string GetSavedLanguageCode()
|
||||
{
|
||||
string normalizedCode = NormalizeLocaleCode(settingsSaveData.languageCode);
|
||||
settingsSaveData.languageCode = normalizedCode;
|
||||
settingsSaveData.languageIndex = GetLanguageDropdownIndex(normalizedCode);
|
||||
return normalizedCode;
|
||||
}
|
||||
|
||||
private static string GetLocaleCodeFromDropdownIndex(int languageIndex)
|
||||
{
|
||||
if (languageIndex < 0 || languageIndex >= LegacyLanguageIndexToCode.Length)
|
||||
{
|
||||
Debug.LogWarning($"[SettingsManager] 语言下拉索引 {languageIndex} 超出范围,回退到 {DefaultLocaleCode}。");
|
||||
return DefaultLocaleCode;
|
||||
}
|
||||
|
||||
return LegacyLanguageIndexToCode[languageIndex];
|
||||
}
|
||||
|
||||
private static int GetLanguageDropdownIndex(string localeCode)
|
||||
{
|
||||
for (int index = 0; index < LegacyLanguageIndexToCode.Length; index++)
|
||||
{
|
||||
if (LegacyLanguageIndexToCode[index] == localeCode)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string NormalizeLocaleCode(string localeCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(localeCode))
|
||||
{
|
||||
return DefaultLocaleCode;
|
||||
}
|
||||
|
||||
foreach (string supportedCode in LegacyLanguageIndexToCode)
|
||||
{
|
||||
if (string.Equals(supportedCode, localeCode, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return supportedCode;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[SettingsManager] Locale Code '{localeCode}' 不在首发语言列表中,回退到 {DefaultLocaleCode}。");
|
||||
return DefaultLocaleCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断本谱面是否启用全屏判定。谱面强制要求的配置优先于玩家开关。
|
||||
/// </summary>
|
||||
|
||||
@@ -119,6 +119,20 @@ namespace Ichni
|
||||
public int beatmapOffset = 0;
|
||||
|
||||
// Localization
|
||||
/// <summary>
|
||||
/// 玩家选择的稳定 Locale Code,例如 <c>zh-CN</c>、<c>ja</c>。
|
||||
/// <para>这是设置存档的唯一语言契约:语言列表的显示顺序可以调整,已保存的语言不会因此指向其它 Locale。</para>
|
||||
/// <para>旧存档没有该字段时,SettingsManager 会只使用一次 <see cref="languageIndex"/> 映射为 Code,并在下次保存时写入本字段。</para>
|
||||
/// </summary>
|
||||
// 不设置字段默认值:ES3 反序列化旧存档时,缺失字段必须保持 null,
|
||||
// SettingsManager 才能辨认它是旧版数据并按 languageIndex 正确迁移。
|
||||
public string languageCode;
|
||||
|
||||
/// <summary>
|
||||
/// 旧版设置存档的语言下拉索引,仅用于迁移既有 ES3 数据。
|
||||
/// <para>禁止新增运行时代码依赖该字段;请通过 SettingsManager 的语言 API 读写 <see cref="languageCode"/>。</para>
|
||||
/// </summary>
|
||||
[FormerlySerializedAs("language")]
|
||||
public int languageIndex = 0;
|
||||
|
||||
// Developer settings. 这些字段会在后续分类重构时迁出正式 Release 设置。
|
||||
|
||||
@@ -25,10 +25,21 @@ namespace Ichni.UI
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void FadeIn(float duration = 0.5f, bool ignoreTimeScale = false, UnityAction action = null)
|
||||
/// <summary>
|
||||
/// 显示页面并播放淡入动画。
|
||||
/// <paramref name="beforeFadeAction"/> 在 Canvas 已激活、保持完全透明且输入仍被阻断时执行,
|
||||
/// 适用于需要先完成视口定位、动态布局或数据绑定,再让玩家看见页面的场景。
|
||||
/// </summary>
|
||||
public virtual void FadeIn(float duration = 0.5f, bool ignoreTimeScale = false,
|
||||
UnityAction action = null, UnityAction beforeFadeAction = null)
|
||||
{
|
||||
mainCanvasGroup.gameObject.SetActive(true);
|
||||
// 明确重置为不可见、不可交互状态,保证 beforeFadeAction 的任何布局变化都不会闪现在屏幕上。
|
||||
mainCanvasGroup.alpha = 0f;
|
||||
mainCanvasGroup.interactable = false;
|
||||
mainCanvasGroup.blocksRaycasts = false;
|
||||
fadeInStartAction?.Invoke();
|
||||
beforeFadeAction?.Invoke();
|
||||
|
||||
Tweener fade = mainCanvasGroup.DOFade(1f, duration).OnComplete(() =>
|
||||
{
|
||||
@@ -66,4 +77,4 @@ namespace Ichni.UI
|
||||
fade.Play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Diagnostics;
|
||||
using Ichni.Menu;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -22,20 +19,16 @@ namespace Ichni.UI
|
||||
MenuManager.instance.settingsUIPage.settingsWindowController.buttonsContainer.gameObject.SetActive(false);
|
||||
};
|
||||
|
||||
languageDropdown.SetUp(gameSettings.languageIndex, MenuManager.instance.displayLanguageList, "Menu UI/Settings_Language");
|
||||
languageDropdown.SetUp(SettingsManager.instance.GetLanguageDropdownIndex(), MenuManager.instance.displayLanguageList, "Menu UI/Settings_Language");
|
||||
languageDropdown.updateValueAction = () =>
|
||||
{
|
||||
BuildHangTracer.Log("UI_DROPDOWN", $"[Dropdown] 玩家选择语言索引: {languageDropdown.selectedIndex}");
|
||||
gameSettings.languageIndex = languageDropdown.selectedIndex;
|
||||
BuildHangTracer.Log("UI_DROPDOWN", "[Dropdown] 即将调用 SettingsManager.ApplyLanguageSettings()");
|
||||
SettingsManager.instance.ApplyLanguageSettings();
|
||||
BuildHangTracer.Log("UI_DROPDOWN", "[Dropdown] SettingsManager.ApplyLanguageSettings() 调用完成返回");
|
||||
SettingsManager.instance.SetLanguageByDropdownIndex(languageDropdown.selectedIndex);
|
||||
};
|
||||
}
|
||||
|
||||
public override void SetValuesFromSettings()
|
||||
{
|
||||
languageDropdown.SetValue(gameSettings.languageIndex);
|
||||
languageDropdown.SetValue(SettingsManager.instance.GetLanguageDropdownIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ namespace Ichni.Menu
|
||||
public void SetBeatmapInfo(BeatmapSave beatmapSave)
|
||||
{
|
||||
accuracyText.text = $"{beatmapSave.accuracy * 100:F2}%";
|
||||
|
||||
if (!beatmapSave.isAllPerfect)
|
||||
{
|
||||
fullComboMark.gameObject.SetActive(beatmapSave.isFullCombo);
|
||||
}
|
||||
|
||||
allPerfectMark.gameObject.SetActive(beatmapSave.isAllPerfect);
|
||||
// 两种成绩徽记互斥显示:All Perfect 的优先级高于 Full Combo。
|
||||
// 必须每次都显式设置两个对象;仅在非 All Perfect 时更新 Full Combo 会让 Prefab
|
||||
// 初始状态或上一次选择谱面的显示状态残留,造成首次进入时同时出现两个徽记。
|
||||
bool showAllPerfect = beatmapSave.isAllPerfect;
|
||||
fullComboMark.gameObject.SetActive(!showAllPerfect && beatmapSave.isFullCombo);
|
||||
allPerfectMark.gameObject.SetActive(showAllPerfect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Ichni.RhythmGame;
|
||||
using Ichni.Story;
|
||||
using Ichni.UI;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.UI.Extensions;
|
||||
@@ -12,6 +13,9 @@ namespace Ichni.Menu
|
||||
public partial class SongSelectionUIPage : UIPageBase
|
||||
{
|
||||
public Button backButton;
|
||||
|
||||
[Tooltip("从选曲页进入当前章节剧情树的按钮。会优先定位当前选中歌曲对应的 SongBlock;未找到时显示剧情开头。")]
|
||||
public Button enterStoryButton;
|
||||
|
||||
public Gradient2 backgroundFrames;
|
||||
public float framePositionOffset = -1;
|
||||
@@ -44,21 +48,48 @@ namespace Ichni.Menu
|
||||
MenuInputManager.OnMoveDown += songListController.GoToNextTab;
|
||||
};
|
||||
|
||||
backButton.onClick.AddListener(() =>
|
||||
{
|
||||
FadeOut();
|
||||
SongSelectionManager.instance.StopPreviewSong();
|
||||
backButton.onClick.AddListener(ReturnToChapterSelection);
|
||||
|
||||
// 仅当本次选曲由剧情 SongBlock 发起时返回剧情页;普通选曲保持原有的章节选择返回逻辑。
|
||||
if (StoryManager.instance != null && StoryManager.instance.TryReturnFromSongSelection())
|
||||
StoryManager.instance.storyUIPage.FadeIn();
|
||||
else
|
||||
MenuManager.instance.chapterSelectionUIPage.FadeIn();
|
||||
});
|
||||
if (enterStoryButton != null)
|
||||
enterStoryButton.onClick.AddListener(OpenStoryForSelectedSong);
|
||||
|
||||
songInfoUI.SetUp();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
backButton.onClick.RemoveListener(ReturnToChapterSelection);
|
||||
|
||||
if (enterStoryButton != null)
|
||||
enterStoryButton.onClick.RemoveListener(OpenStoryForSelectedSong);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 选曲页的全局返回语义固定为回到章节选择页。
|
||||
/// 即使本次页面最初由 Story SongBlock 打开,也不再把 Back 视为“返回剧情树”;
|
||||
/// 玩家需要使用专门的 <see cref="enterStoryButton"/> 进入剧情。
|
||||
/// </summary>
|
||||
private void ReturnToChapterSelection()
|
||||
{
|
||||
FadeOut();
|
||||
SongSelectionManager.instance.StopPreviewSong();
|
||||
|
||||
// Story SongBlock 入口上下文只服务于一次“进入游戏后返回剧情”的流程。
|
||||
// 玩家主动离开选曲页时必须清除它,避免之后普通选曲误继承旧的目标歌曲与返回目的地。
|
||||
InformationTransistor.instance?.storySongEntryContext?.Clear();
|
||||
MenuManager.instance.chapterSelectionUIPage.FadeIn();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从选曲页打开当前章节的剧情树。若当前歌曲在 StoryData 中存在对应 SongBlock,
|
||||
/// StoryManager 会把视口定位到该 Block;未选中歌曲或没有对应 Block 时则显示剧情开头。
|
||||
/// </summary>
|
||||
private void OpenStoryForSelectedSong()
|
||||
{
|
||||
ChapterSelectionUnit chapter = ChapterSelectionManager.instance?.currentChapter;
|
||||
StoryManager.instance?.TryOpenStoryForSong(chapter, selectedSong);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Sequence framesSeq = DOTween.Sequence();
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// 专用于 Standalone Build 的实时落盘诊断日志工具。
|
||||
/// 绕过 Unity 默认日志缓冲区,直接将日志强行追加写入独立文本文件,
|
||||
/// 确保即使游戏卡死(未响应),卡死前最后一条日志也能 100% 写入磁盘。
|
||||
/// </summary>
|
||||
public static class BuildHangTracer
|
||||
{
|
||||
private static string _logFilePath;
|
||||
|
||||
public static string LogFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(_logFilePath))
|
||||
{
|
||||
_logFilePath = Path.Combine(Application.persistentDataPath, "build_hang_trace.txt");
|
||||
}
|
||||
return _logFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 强行立即追加一行日志到磁盘独立文件
|
||||
/// </summary>
|
||||
public static void Log(string tag, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
|
||||
string logLine = $"[{timestamp}] [{tag}] {message}{Environment.NewLine}";
|
||||
File.AppendAllText(LogFilePath, logLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 静默忽略日志写入本身的异常
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空之前的日志文件并记录初始化标志
|
||||
/// </summary>
|
||||
public static void ClearLog()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(LogFilePath))
|
||||
{
|
||||
File.Delete(LogFilePath);
|
||||
}
|
||||
Log("INIT", $"--- 诊断日志已重置。日志存储路径:{LogFilePath} ---");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 静默忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bebb1ee94395f2648b7cf5892a16c9fd
|
||||
Reference in New Issue
Block a user