微调
This commit is contained in:
@@ -19,17 +19,42 @@ namespace Ichni
|
||||
|
||||
public IntReactiveProperty waitingBundleAmount;
|
||||
|
||||
// 只记录本 Manager 通过 AssetBundle.LoadFromFileAsync 加载的 Theme Bundle。
|
||||
// Addressables(包括 Unity Localization)拥有自己的 Bundle 引用计数,绝不能通过
|
||||
// AssetBundle.UnloadAllAssetBundles 代替各系统分别释放资源。
|
||||
private readonly List<AssetBundle> loadedAssetBundles = new List<AssetBundle>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
|
||||
loadedThemeBundleList = new List<ThemeBundle>();
|
||||
AssetBundle.UnloadAllAssetBundles(true);
|
||||
LoadAllThemeBundlesAbstract();
|
||||
//LoadThemeBundle("basic");
|
||||
//LoadThemeBundle("departure_to_multiverse");
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// 仅卸载本实例拥有的 Theme Bundle,不影响 Addressables 管理的 Locale、String Table
|
||||
// 或其它系统加载的 AssetBundle。
|
||||
foreach (AssetBundle bundle in loadedAssetBundles)
|
||||
{
|
||||
if (bundle != null)
|
||||
{
|
||||
bundle.Unload(true);
|
||||
}
|
||||
}
|
||||
|
||||
loadedAssetBundles.Clear();
|
||||
loadedThemeBundleList?.Clear();
|
||||
|
||||
if (instance == this)
|
||||
{
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetThemeBundle(string themeBundleName, out ThemeBundle themeBundle)
|
||||
{
|
||||
themeBundle = loadedThemeBundleList.Find(bundle => bundle.themeBundleName == themeBundleName);
|
||||
@@ -112,32 +137,51 @@ namespace Ichni
|
||||
AssetBundleCreateRequest createRequest = AssetBundle.LoadFromFileAsync(uri);
|
||||
yield return createRequest;
|
||||
AssetBundle bundle = createRequest.assetBundle;
|
||||
Object[] ob = bundle.LoadAllAssets<Object>();
|
||||
loadedThemeBundleList.Add(new ThemeBundle(themeBundleName));
|
||||
|
||||
for (int i = 0; i < ob.Length; i++)
|
||||
if (bundle == null)
|
||||
{
|
||||
if (ob[i].GetType() == typeof(GameObject))
|
||||
Debug.LogError($"Theme Bundle 加载失败:{uri}");
|
||||
waitingBundleAmount.Value--;
|
||||
yield break;
|
||||
}
|
||||
|
||||
loadedAssetBundles.Add(bundle);
|
||||
|
||||
try
|
||||
{
|
||||
Object[] ob = bundle.LoadAllAssets<Object>();
|
||||
loadedThemeBundleList.Add(new ThemeBundle(themeBundleName));
|
||||
|
||||
for (int i = 0; i < ob.Length; i++)
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_GameObject.Add(ob[i] as GameObject);
|
||||
}
|
||||
else if (ob[i].GetType() == typeof(Material))
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_Material.Add(ob[i] as Material);
|
||||
}
|
||||
else if (ob[i].GetType() == typeof(Texture2D))
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_Texture.Add(ob[i] as Texture2D);
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_Other.Add(ob[i]);
|
||||
if (ob[i].GetType() == typeof(GameObject))
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_GameObject.Add(ob[i] as GameObject);
|
||||
}
|
||||
else if (ob[i].GetType() == typeof(Material))
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_Material.Add(ob[i] as Material);
|
||||
}
|
||||
else if (ob[i].GetType() == typeof(Texture2D))
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_Texture.Add(ob[i] as Texture2D);
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedThemeBundleList[^1].assetList_Other.Add(ob[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception exception)
|
||||
{
|
||||
Debug.LogException(exception, this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
waitingBundleAmount.Value--;
|
||||
}
|
||||
|
||||
yield return new WaitForEndOfFrame();
|
||||
print(themeBundleName + " Done!");
|
||||
waitingBundleAmount.Value--;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,4 +297,4 @@ namespace Ichni
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using AK.Wwise;
|
||||
@@ -8,7 +7,6 @@ using Ichni.Story.UI;
|
||||
using Ichni.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.Serialization;
|
||||
@@ -34,14 +32,26 @@ namespace Ichni
|
||||
|
||||
public partial class MenuManager
|
||||
{
|
||||
AsyncOperation asyncOperation;
|
||||
public bool isEnteringGame;
|
||||
private const string GameSceneName = "GameScene";
|
||||
private const float DefaultTransitionDelay = 0.6f;
|
||||
|
||||
private Coroutine gameSceneLoadCoroutine;
|
||||
private AsyncOperation gameSceneLoadOperation;
|
||||
|
||||
/// <summary>
|
||||
/// 当前 MenuScene 是否已经完成 GameScene 预加载且尚未处于切场中。
|
||||
/// TutorialFlowController 在写入“已处理”剧情变量前使用此状态进行最后一次启动前检查。
|
||||
/// 是否已经接受了某个进入游戏的请求。
|
||||
/// 从确认进入开始便保持为 true,用于阻止按钮连点和多个入口同时发起场景切换。
|
||||
/// </summary>
|
||||
public bool CanBeginGame => !isEnteringGame && asyncOperation != null;
|
||||
public bool isEnteringGame { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前是否可以接受新的 GameScene 进入请求。
|
||||
/// GameScene 改为玩家确认后按需加载,因此不再依赖菜单启动阶段的预加载句柄。
|
||||
/// </summary>
|
||||
public bool CanBeginGame =>
|
||||
!isEnteringGame &&
|
||||
gameSceneLoadCoroutine == null &&
|
||||
gameSceneLoadOperation == null;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -88,8 +98,6 @@ namespace Ichni
|
||||
information?.ClearMenuReturnDestination();
|
||||
|
||||
Application.targetFrameRate = SettingsManager.instance.settingsSaveData.targetFrame;
|
||||
asyncOperation = SceneManager.LoadSceneAsync("GameScene");
|
||||
asyncOperation.allowSceneActivation = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,25 +105,17 @@ namespace Ichni
|
||||
{
|
||||
public void EnterGame()
|
||||
{
|
||||
// 剧情 SongBlock 仅在玩家确认进入指定目标歌曲后才要求返回剧情;
|
||||
// 普通选曲与从剧情入口改选其它歌曲都继续返回选曲页。
|
||||
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
|
||||
MenuReturnDestination destination = context != null && context.isActive &&
|
||||
context.returnToStoryAfterGame
|
||||
? MenuReturnDestination.Story
|
||||
: MenuReturnDestination.SongSelection;
|
||||
EnterGame(destination);
|
||||
ScheduleGameSceneLoad(ResolveCurrentReturnDestination(), 0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 激活已预加载的 GameScene,并写入唯一的菜单返回目标。
|
||||
/// 按需加载 GameScene,并写入唯一的菜单返回目标。
|
||||
/// 普通选曲使用 <see cref="MenuReturnDestination.SongSelection"/>,教程使用
|
||||
/// <see cref="MenuReturnDestination.Story"/>。
|
||||
/// </summary>
|
||||
public void EnterGame(MenuReturnDestination returnDestination)
|
||||
{
|
||||
InformationTransistor.instance?.SetMenuReturnDestination(returnDestination);
|
||||
EnterGameScene();
|
||||
ScheduleGameSceneLoad(returnDestination, 0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -127,36 +127,138 @@ namespace Ichni
|
||||
{
|
||||
if (!CanBeginGame)
|
||||
{
|
||||
Debug.LogWarning("[MenuManager] GameScene 未完成预加载或菜单已在切换,忽略启动请求。");
|
||||
Debug.LogWarning("[MenuManager] 菜单已经接受其它进入请求,忽略本次 GameScene 启动请求。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (InformationTransistor.instance == null ||
|
||||
!InformationTransistor.instance.PrepareGameLaunch(chapter, song, difficulty, returnDestination))
|
||||
if (InformationTransistor.instance == null || chapter == null || song == null || difficulty == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
isEnteringGame = true;
|
||||
if (!TryReserveGameEntry())
|
||||
return false;
|
||||
|
||||
if (!InformationTransistor.instance.PrepareGameLaunch(chapter, song, difficulty, returnDestination))
|
||||
{
|
||||
CancelGameEntryReservation();
|
||||
return false;
|
||||
}
|
||||
|
||||
sourcePage?.FadeOut();
|
||||
transitionUIPage?.FadeIn();
|
||||
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
|
||||
|
||||
Observable.Timer(TimeSpan.FromSeconds(0.6f)).Subscribe(_ => EnterGameScene());
|
||||
return ScheduleGameSceneLoad(returnDestination, DefaultTransitionDelay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在进入动画开始前预占本次场景切换。
|
||||
/// 标准 Play、快速进入和 TutorialBlock 都必须通过这里防止同一帧的重复点击。
|
||||
/// </summary>
|
||||
public bool TryReserveGameEntry()
|
||||
{
|
||||
if (!CanBeginGame)
|
||||
return false;
|
||||
|
||||
isEnteringGame = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnterGameScene()
|
||||
|
||||
/// <summary>
|
||||
/// 为已经完成自定义入场动画的入口安排 GameScene 加载。
|
||||
/// 延迟使用 realtime,确保暂停或修改 timeScale 时仍能正常进入游戏。
|
||||
/// </summary>
|
||||
public bool ScheduleCurrentGameSceneLoad(float delay = DefaultTransitionDelay)
|
||||
{
|
||||
if (asyncOperation == null)
|
||||
return ScheduleGameSceneLoad(ResolveCurrentReturnDestination(), delay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在指定转场延迟后启动 GameScene 的异步加载。
|
||||
/// 这里绝不把 allowSceneActivation 设为 false:场景开始加载后会自然完成并自动激活,
|
||||
/// 避免阻塞 Localization、Addressables 和其它 Unity AsyncOperation。
|
||||
/// </summary>
|
||||
public bool ScheduleGameSceneLoad(MenuReturnDestination returnDestination, float delay)
|
||||
{
|
||||
if (gameSceneLoadCoroutine != null || gameSceneLoadOperation != null)
|
||||
{
|
||||
Debug.LogError("[MenuManager] GameScene 尚未完成预加载,无法进入游戏。");
|
||||
isEnteringGame = false;
|
||||
return;
|
||||
Debug.LogWarning("[MenuManager] GameScene 加载已经安排或正在执行,忽略重复请求。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isEnteringGame && !TryReserveGameEntry())
|
||||
return false;
|
||||
|
||||
InformationTransistor.instance?.SetMenuReturnDestination(returnDestination);
|
||||
gameSceneLoadCoroutine = StartCoroutine(
|
||||
LoadGameSceneAfterTransition(Mathf.Max(0f, delay)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保留给既有 UnityEvent 和旧调用点的立即加载入口。
|
||||
/// 新代码通常应使用 <see cref="ScheduleCurrentGameSceneLoad"/>,把转场延迟也交给 MenuManager。
|
||||
/// </summary>
|
||||
public void EnterGameScene()
|
||||
{
|
||||
ScheduleCurrentGameSceneLoad(0f);
|
||||
}
|
||||
|
||||
private IEnumerator LoadGameSceneAfterTransition(float delay)
|
||||
{
|
||||
if (delay > 0f)
|
||||
yield return new WaitForSecondsRealtime(delay);
|
||||
|
||||
MenuInputManager.instance?.gameInput?.Menu.Disable();
|
||||
asyncOperation.allowSceneActivation = true;
|
||||
|
||||
try
|
||||
{
|
||||
gameSceneLoadOperation = SceneManager.LoadSceneAsync(
|
||||
GameSceneName, LoadSceneMode.Single);
|
||||
}
|
||||
catch (System.Exception exception)
|
||||
{
|
||||
Debug.LogException(exception, this);
|
||||
HandleGameSceneLoadFailure("创建 GameScene 异步加载操作时发生异常。");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (gameSceneLoadOperation == null)
|
||||
{
|
||||
HandleGameSceneLoadFailure(
|
||||
$"SceneManager.LoadSceneAsync 未能创建场景 '{GameSceneName}' 的加载操作。");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 不设置 allowSceneActivation=false。长时间挂起场景激活会冻结后续 AsyncOperation,
|
||||
// 导致 Addressables String Table、AssetBundle 等资源永久停留在 0%。
|
||||
while (!gameSceneLoadOperation.isDone)
|
||||
yield return null;
|
||||
}
|
||||
|
||||
private MenuReturnDestination ResolveCurrentReturnDestination()
|
||||
{
|
||||
// 剧情 SongBlock 仅在玩家确认进入指定目标歌曲后才要求返回剧情;
|
||||
// 普通选曲与从剧情入口改选其它歌曲都继续返回选曲页。
|
||||
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
|
||||
return context != null && context.isActive && context.returnToStoryAfterGame
|
||||
? MenuReturnDestination.Story
|
||||
: MenuReturnDestination.SongSelection;
|
||||
}
|
||||
|
||||
private void HandleGameSceneLoadFailure(string reason)
|
||||
{
|
||||
Debug.LogError($"[MenuManager] {reason}", this);
|
||||
gameSceneLoadCoroutine = null;
|
||||
gameSceneLoadOperation = null;
|
||||
CancelGameEntryReservation();
|
||||
MenuInputManager.instance?.gameInput?.Menu.Enable();
|
||||
}
|
||||
|
||||
private void CancelGameEntryReservation()
|
||||
{
|
||||
isEnteringGame = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ 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)
|
||||
@@ -777,4 +782,3 @@ namespace Ichni.Story
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Localization;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story
|
||||
@@ -22,6 +23,11 @@ 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,9 +1,10 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Ichni.Story.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.Localization;
|
||||
using UnityEngine.Localization.Tables;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using Yarn.Unity;
|
||||
using Yarn.Unity.UnityLocalization;
|
||||
|
||||
@@ -25,10 +26,6 @@ namespace Ichni.Story.Dialogue
|
||||
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
|
||||
public StoryUIPage storyUIPage;
|
||||
|
||||
[Header("Localization")]
|
||||
[Tooltip("所有章节 Yarn 台词共用的 Unity Localization String Table(建议命名 YarnLines)。留空时保留当前 UnityLocalisedLineProvider 的既有表配置。")]
|
||||
public LocalizedStringTable yarnLinesTable;
|
||||
|
||||
/// <summary>
|
||||
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令
|
||||
/// 把"解锁提示"排队到对话结束后弹出。
|
||||
@@ -43,6 +40,9 @@ 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,
|
||||
@@ -62,7 +62,6 @@ namespace Ichni.Story.Dialogue
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
EnsureUnityLocalisedLineProvider();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
@@ -110,33 +109,41 @@ namespace Ichni.Story.Dialogue
|
||||
{
|
||||
if (block == null) return;
|
||||
|
||||
Debug.Log($"[YarnDiag] StoryDialogueController.PlayBlock: block={block.blockId}, node={block.YarnNodeName}");
|
||||
|
||||
if (dialogueRunner == null)
|
||||
{
|
||||
Debug.LogError("[StoryDialogueController] 未配置 DialogueRunner,无法开始对话。");
|
||||
Debug.LogError("[YarnDiag] [ERROR] 未配置 DialogueRunner,无法开始对话!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogueRunner.IsDialogueRunning)
|
||||
if (dialogueRunner.IsDialogueRunning || _dialogueStartupCoroutine != null)
|
||||
{
|
||||
Debug.LogWarning("[StoryDialogueController] 已有对话在进行中,忽略本次点击。");
|
||||
Debug.LogWarning("[YarnDiag] [WARNING] 已有对话正在运行或启动,忽略本次点击!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(block.YarnNodeName))
|
||||
{
|
||||
Debug.LogWarning($"[StoryDialogueController] block '{block.blockId}' 未配置 yarnNodeName,无法开始对话。");
|
||||
Debug.LogWarning($"[YarnDiag] [WARNING] block '{block.blockId}' 未配置 yarnNodeName,无法开始对话!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner)
|
||||
// 将 DialogueRunner 的 YarnProject 对齐到当前章节,并验证场景中持久化的 LineProvider 配置。
|
||||
EnsureProjectForCurrentChapter();
|
||||
if (!ValidateUnityLocalisedLineProvider())
|
||||
{
|
||||
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($"[StoryDialogueController] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'。", dialogueRunner);
|
||||
Debug.LogError($"[YarnDiag] [ERROR] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'!", dialogueRunner);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,35 +171,139 @@ namespace Ichni.Story.Dialogue
|
||||
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
|
||||
StoryProgress.BeginBlockMutation(_activeBlockId);
|
||||
|
||||
if (storyUIPage != null)
|
||||
storyUIPage.FadeOut();
|
||||
|
||||
// 启动异常会回滚本次章节事务;正常结束仍由 onDialogueComplete 统一提交。
|
||||
StartDialogueSafely(block.YarnNodeName).Forget();
|
||||
// Unity Localization 的 AsyncOperationHandle 原生支持协程等待。
|
||||
// 先在项目侧显式加载当前章节台词表,再启动 Yarn;不修改 Yarn Spinner Package,
|
||||
// 也不把 Unity Addressables Handle 转换为 YarnTask。
|
||||
_dialogueStartupCoroutine = StartCoroutine(StartDialogueSafely(block.YarnNodeName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以受保护方式启动 Yarn。缺失 Provider、Presenter 等启动级异常不应让 StorySave 的事务永久处于活动状态,
|
||||
/// 因而会被转换为 Failed 流程并完整恢复进入对话前的章节快照。
|
||||
/// 使用 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 async YarnTask StartDialogueSafely(string yarnNodeName)
|
||||
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
|
||||
{
|
||||
await dialogueRunner.StartDialogue(yarnNodeName);
|
||||
// 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)
|
||||
{
|
||||
Debug.LogException(exception, dialogueRunner);
|
||||
// 启动阶段可能已经让部分 Presenter 进入展示状态;先请求停止,再立即清理事务。
|
||||
// 后续若收到 Runner 的完成事件,会因 activeBlockId 已被清空而被安全忽略。
|
||||
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
|
||||
{
|
||||
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
|
||||
dialogueRunner.Stop().Forget();
|
||||
}
|
||||
RestoreInterruptedDialogue("启动 Yarn 对话失败");
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用 Yarn Spinner 的公开 <see cref="DialogueRunner.StartDialogue"/> API。
|
||||
/// 返回时表示所有 Presenter 已完成 OnDialogueStartedAsync,且 Runner 已调用 Dialogue.Continue。
|
||||
/// </summary>
|
||||
private async YarnTask StartDialogueRunnerSafely(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)
|
||||
{
|
||||
FailDialogueStartup("启动 Yarn 对话失败。", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void FailDialogueStartup(string reason, System.Exception exception = null)
|
||||
{
|
||||
_dialogueStartupCoroutine = null;
|
||||
|
||||
if (exception != null)
|
||||
Debug.LogException(exception, dialogueRunner);
|
||||
|
||||
Debug.LogError($"[YarnDiag] {reason}", dialogueRunner);
|
||||
|
||||
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
|
||||
{
|
||||
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
|
||||
dialogueRunner.Stop().Forget();
|
||||
}
|
||||
|
||||
RestoreInterruptedDialogue(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -202,12 +313,22 @@ namespace Ichni.Story.Dialogue
|
||||
/// </summary>
|
||||
public bool CancelActiveDialogue()
|
||||
{
|
||||
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId) ||
|
||||
dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
|
||||
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_dialogueStartupCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_dialogueStartupCoroutine);
|
||||
_dialogueStartupCoroutine = null;
|
||||
RestoreInterruptedDialogue("玩家在对话启动阶段中途退出");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
|
||||
return false;
|
||||
|
||||
_isCancellingDialogue = true;
|
||||
_dialogueEndActions.Clear();
|
||||
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
|
||||
@@ -246,44 +367,29 @@ namespace Ichni.Story.Dialogue
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DialogueRunner.lineProvider 为 [SerializeReference] internal 字段。
|
||||
/// Inspector 的 "Use Unity Localised Line Provider" 按钮有时无法将 MonoBehaviour
|
||||
/// 引用正确持久化到该字段;此方法在运行时用反射补救,确保使用正确的 Provider。
|
||||
/// 验证 DialogueRunner 序列化的 LineProvider 是否为 UnityLocalisedLineProvider。
|
||||
/// <para>
|
||||
/// LineProvider 应通过 Yarn Spinner Inspector 持久化配置;运行时不使用反射修改 Package 内部字段,
|
||||
/// 这样 Editor、Mono 与 IL2CPP Build 使用完全相同的装配结果。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EnsureUnityLocalisedLineProvider()
|
||||
private bool ValidateUnityLocalisedLineProvider()
|
||||
{
|
||||
if (dialogueRunner == null) return;
|
||||
|
||||
FieldInfo field = typeof(DialogueRunner).GetField(
|
||||
"lineProvider",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
if (field == null) return;
|
||||
|
||||
UnityLocalisedLineProvider provider = field.GetValue(dialogueRunner) as UnityLocalisedLineProvider;
|
||||
provider ??= dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
|
||||
|
||||
if (provider != null)
|
||||
if (dialogueRunner == null)
|
||||
{
|
||||
if (field.GetValue(dialogueRunner) != provider)
|
||||
{
|
||||
field.SetValue(dialogueRunner, provider);
|
||||
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Yarn 的 Localized Line Provider 本身只持有一个 String Table 引用;统一使用 YarnLines
|
||||
// 可避免在切换章节时依赖反射替换不同表。未配置时不覆盖旧场景,便于分步迁移现有 Chapter0_Lines。
|
||||
if (yarnLinesTable != null && !yarnLinesTable.IsEmpty)
|
||||
{
|
||||
FieldInfo tableField = typeof(UnityLocalisedLineProvider).GetField(
|
||||
"stringsTable", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
tableField?.SetValue(provider, yarnLinesTable);
|
||||
}
|
||||
}
|
||||
else
|
||||
if (dialogueRunner.LineProvider is UnityLocalisedLineProvider)
|
||||
{
|
||||
Debug.LogWarning("[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable.");
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.LogError(
|
||||
"[StoryDialogueController] DialogueRunner.lineProvider 未配置为 UnityLocalisedLineProvider。" +
|
||||
"请在 Yarn Spinner Inspector 中使用 Unity Localization Line Provider,并配置台词 String Table。",
|
||||
dialogueRunner);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void HandleDialogueComplete()
|
||||
|
||||
@@ -92,8 +92,10 @@ namespace Ichni.Story.Dialogue
|
||||
|
||||
// ── DialoguePresenterBase 实现 ───────────────────────────────────────────
|
||||
|
||||
public override async YarnTask OnDialogueStartedAsync()
|
||||
public override YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 开始。");
|
||||
|
||||
// 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。
|
||||
_isFastForwarding = false;
|
||||
_typewriterComplete = false;
|
||||
@@ -101,18 +103,17 @@ namespace Ichni.Story.Dialogue
|
||||
// 对话记录只保留当前一次 TextBlock;每次 Yarn 对话真正开始时清空上一段残留内容。
|
||||
DialogueHistory.BeginSession();
|
||||
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
Page.PlayFadeIn(() => tcs.TrySetResult(true));
|
||||
await tcs.Task;
|
||||
}
|
||||
Page.PlayFadeIn();
|
||||
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 完成。");
|
||||
return YarnTask.CompletedTask;
|
||||
}
|
||||
|
||||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -126,6 +127,11 @@ namespace Ichni.Story.Dialogue
|
||||
|
||||
// ── 打字效果 ──
|
||||
string fullText = line.TextWithoutCharacterName.Text;
|
||||
if (string.IsNullOrEmpty(fullText) && !string.IsNullOrEmpty(line.RawText))
|
||||
{
|
||||
fullText = line.RawText;
|
||||
Debug.LogWarning($"[YarnDiag] [WARNING] 台词 '{line.TextID}' 本地化解析结果为空,回退使用 RawText: '{fullText}'");
|
||||
}
|
||||
// 记录的是本次实际展示时已经解析完成的本地化文本,而非 Yarn Key。
|
||||
DialogueHistory.AddLine(line.CharacterName, fullText);
|
||||
_typewriterComplete = false;
|
||||
@@ -285,23 +291,39 @@ 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) 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(); // 第二次点击:推进到下一行
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFastForwardButtonClicked()
|
||||
|
||||
@@ -326,7 +326,7 @@ namespace Ichni.Story
|
||||
/// <summary>本地化失败时显示 Key,确保测试阶段仍能定位缺失条目,而不是得到空白气泡。</summary>
|
||||
private string GetLocalizedText(StoryHelperData helperData, string key, string fallback)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
if (string.IsNullOrEmpty(key) || helperData == null)
|
||||
return fallback;
|
||||
|
||||
try
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace Ichni.Story
|
||||
if (MenuManager.instance == null || InformationTransistor.instance == null ||
|
||||
!MenuManager.instance.CanBeginGame)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] MenuManager、InformationTransistor 或 GameScene 预加载未就绪,无法进入教程。");
|
||||
Debug.LogWarning("[TutorialFlowController] MenuManager、InformationTransistor 未就绪,或菜单已经接受其它进入请求,无法进入教程。");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Diagnostics;
|
||||
using Ichni.Menu.UI;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
@@ -421,12 +422,38 @@ namespace Ichni.Story.UI
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectedLocaleChanged(Locale _)
|
||||
private async void OnSelectedLocaleChanged(Locale newLocale)
|
||||
{
|
||||
BuildHangTracer.Log("TIMELINE_EVENT", $"[OnSelectedLocaleChanged] 收到语言切换通知: newLocale={(newLocale != null ? newLocale.Identifier.Code : "null")},_markers.Count={_markers.Count}");
|
||||
|
||||
foreach (RuntimeMarker marker in _markers)
|
||||
{
|
||||
marker.view.SetLabel(GetLocalizedLabel(marker.definition.labelKey));
|
||||
if (string.IsNullOrEmpty(marker.definition.labelKey)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
BuildHangTracer.Log("TIMELINE_EVENT", "[OnSelectedLocaleChanged] 全部 Marker 更新完成。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -103,6 +103,8 @@ 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}' 的对话。");
|
||||
|
||||
@@ -91,12 +91,12 @@ namespace Ichni.Story.UI
|
||||
|
||||
public void PlayFadeIn(UnityAction onComplete = null)
|
||||
{
|
||||
FadeIn(0.2f, false, onComplete);
|
||||
FadeIn(0.2f, true, onComplete);
|
||||
}
|
||||
|
||||
public void PlayFadeOut(UnityAction onComplete = null)
|
||||
{
|
||||
FadeOut(0.2f, false, onComplete);
|
||||
FadeOut(0.2f, true, onComplete);
|
||||
}
|
||||
|
||||
private void OnBackButtonClicked()
|
||||
|
||||
158
Assets/Scripts/Settings/LocalizationBootstrap.cs
Normal file
158
Assets/Scripts/Settings/LocalizationBootstrap.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Settings/LocalizationBootstrap.cs.meta
Normal file
11
Assets/Scripts/Settings/LocalizationBootstrap.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a3bb067e72f4b1ca56834f12479b841
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,7 +1,10 @@
|
||||
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;
|
||||
@@ -10,6 +13,7 @@ using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
[DefaultExecutionOrder(-10000)]
|
||||
public class SettingsManager : SerializedMonoBehaviour
|
||||
{
|
||||
private const string SettingsSaveFileName = "SettingsSave.json";
|
||||
@@ -19,6 +23,21 @@ namespace Ichni
|
||||
private const string BalancedQualityName = "Balanced";
|
||||
private const string HighFidelityQualityName = "High Fidelity";
|
||||
|
||||
/// <summary>
|
||||
/// 与设置界面 Dropdown 顺序严格一致的 Locale Code。
|
||||
/// 不使用 AvailableLocales.Locales 的返回顺序,因为 Addressables Catalog 的条目顺序不属于稳定存档契约。
|
||||
/// </summary>
|
||||
private static readonly string[] SupportedLocaleCodes =
|
||||
{
|
||||
"zh-CN",
|
||||
"en",
|
||||
"zh-TW",
|
||||
"ja",
|
||||
"ko",
|
||||
"vi-VN",
|
||||
"th"
|
||||
};
|
||||
|
||||
public static SettingsManager instance;
|
||||
|
||||
[FormerlySerializedAs("settingsPage")]
|
||||
@@ -30,11 +49,17 @@ namespace Ichni
|
||||
// 记录相机在玩家关闭后处理前的原始状态。重新开启时必须恢复原始值,不能无条件将所有相机改为开启。
|
||||
private readonly Dictionary<int, bool> postProcessingCameraStates = new Dictionary<int, bool>();
|
||||
|
||||
// 语言切换请求统一由一个协程串行处理。协程运行期间的新请求只覆盖目标索引。
|
||||
private Coroutine applyLanguageSettingsCoroutine;
|
||||
private bool isLanguageRoutineRunning;
|
||||
private int pendingLanguageIndex = -1;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = this;
|
||||
|
||||
DontDestroyOnLoad(gameObject);
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
RenderPipelineManager.beginCameraRendering += ApplyPostProcessingSettingBeforeCameraRender;
|
||||
@@ -54,6 +79,14 @@ namespace Ichni
|
||||
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
RenderPipelineManager.beginCameraRendering -= ApplyPostProcessingSettingBeforeCameraRender;
|
||||
|
||||
if (applyLanguageSettingsCoroutine != null)
|
||||
{
|
||||
StopCoroutine(applyLanguageSettingsCoroutine);
|
||||
applyLanguageSettingsCoroutine = null;
|
||||
}
|
||||
|
||||
isLanguageRoutineRunning = false;
|
||||
instance = null;
|
||||
}
|
||||
|
||||
@@ -82,9 +115,10 @@ namespace Ichni
|
||||
{
|
||||
settingsSaveData = CreateDefaultSettingsSaveData();
|
||||
}
|
||||
|
||||
Debug.Log($"[SettingsManager] 已加载存档:{savePath},当前语言索引={settingsSaveData.languageIndex}");
|
||||
ApplyAudioSettings();
|
||||
ApplyGraphicSettings();
|
||||
ApplyLanguageSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -92,10 +126,10 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void ApplyAudioSettings()
|
||||
{
|
||||
AkSoundEngine.SetRTPCValue("MasterVolume", settingsSaveData.masterVolume);
|
||||
AkSoundEngine.SetRTPCValue("MusicVolume", settingsSaveData.musicVolume);
|
||||
AkSoundEngine.SetRTPCValue("SoundFXVolume", settingsSaveData.soundEffectVolume);
|
||||
AkSoundEngine.SetRTPCValue("UIVolume", settingsSaveData.uiVolume);
|
||||
AkUnitySoundEngine.SetRTPCValue("MasterVolume", settingsSaveData.masterVolume);
|
||||
AkUnitySoundEngine.SetRTPCValue("MusicVolume", settingsSaveData.musicVolume);
|
||||
AkUnitySoundEngine.SetRTPCValue("SoundFXVolume", settingsSaveData.soundEffectVolume);
|
||||
AkUnitySoundEngine.SetRTPCValue("UIVolume", settingsSaveData.uiVolume);
|
||||
ApplyInGameAudioEffectsSettings();
|
||||
}
|
||||
|
||||
@@ -312,14 +346,143 @@ namespace Ichni
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将当前语言索引应用到既有的 I2 与 Unity Localization 管线。
|
||||
/// 语言将于 Localization 重构时改为稳定的 Locale ID;本阶段只迁移数据承载方式。
|
||||
/// 将当前语言索引应用到 Unity Localization 管线。
|
||||
/// 本方法只等待 Unity 官方 InitializationOperation,并在其完成后应用玩家最新一次语言选择。
|
||||
/// </summary>
|
||||
public void ApplyLanguageSettings()
|
||||
{
|
||||
I2.Loc.LocalizationManager.CurrentLanguage = MenuManager.instance.languageList[settingsSaveData.languageIndex];
|
||||
LocalizationSettings.SelectedLocale = LocalizationSettings.AvailableLocales.Locales[settingsSaveData.languageIndex];
|
||||
I2.Loc.LocalizationManager.UpdateSources();
|
||||
if (settingsSaveData == null)
|
||||
{
|
||||
BuildHangTracer.Log("SETTINGS_MGR", "[ApplyLanguageSettings] settingsSaveData 为空,取消语言切换。");
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLanguageIndex = settingsSaveData.languageIndex;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private IEnumerator ApplyLanguageSettingsRoutine()
|
||||
{
|
||||
// try/finally 确保协程正常结束、被 StopCoroutine 或发生异常时,运行状态都会被清除。
|
||||
try
|
||||
{
|
||||
// 避开 Dropdown.onValueChanged 当前同步调用栈。
|
||||
yield return null;
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] 等待 Unity Localization InitializationOperation。" +
|
||||
$"CurrentState={LocalizationBootstrap.State}");
|
||||
|
||||
yield return LocalizationBootstrap.WaitForInitialization();
|
||||
if (!LocalizationBootstrap.IsReady)
|
||||
{
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] Unity Localization 初始化失败,取消语言切换。" +
|
||||
$"Reason={LocalizationBootstrap.FailureReason}");
|
||||
pendingLanguageIndex = -1;
|
||||
yield break;
|
||||
}
|
||||
|
||||
BuildHangTracer.Log(
|
||||
"SETTINGS_COROUTINE",
|
||||
$"[Coroutine] Unity Localization 已就绪。启动 Locale=" +
|
||||
$"{LocalizationSettings.SelectedLocale?.Identifier.Code ?? "<null>"}");
|
||||
|
||||
// 处理流程运行期间收到的最新请求。
|
||||
while (pendingLanguageIndex >= 0)
|
||||
{
|
||||
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];
|
||||
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}',取消本次切换。");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLocale == targetLocale)
|
||||
{
|
||||
BuildHangTracer.Log("SETTINGS_COROUTINE", "[Coroutine] 目标 Locale 与当前 Locale 相同,跳过切换。");
|
||||
continue;
|
||||
}
|
||||
|
||||
LocalizationSettings.SelectedLocale = targetLocale;
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -35,6 +35,8 @@ namespace Ichni.UI
|
||||
if (action == null)
|
||||
{
|
||||
Debug.LogError($"在InputActionAsset中未找到名为 '{actionName}' 的Action。");
|
||||
inputActions.Enable();
|
||||
waitingForInputCover.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,12 +48,20 @@ namespace Ichni.UI
|
||||
this.title = title;
|
||||
this.subTitle = subTitle;
|
||||
titleText.gameObject.SetActive(true);
|
||||
titleText.GetComponent<Localize>().SetTerm(title);
|
||||
var titleLoc = titleText.GetComponent<Localize>();
|
||||
if (titleLoc != null)
|
||||
titleLoc.SetTerm(title);
|
||||
else
|
||||
titleText.text = title;
|
||||
|
||||
if (subTitle != "")
|
||||
{
|
||||
subTitleText.gameObject.SetActive(true);
|
||||
subTitleText.GetComponent<Localize>().SetTerm(subTitle);
|
||||
var subLoc = subTitleText.GetComponent<Localize>();
|
||||
if (subLoc != null)
|
||||
subLoc.SetTerm(subTitle);
|
||||
else
|
||||
subTitleText.text = subTitle;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -15,7 +15,15 @@ namespace Ichni.UI
|
||||
{
|
||||
base.SetUp(title, subTitle);
|
||||
|
||||
buttonText.GetComponent<Localize>().SetTerm(textContent);
|
||||
var localize = buttonText.GetComponent<Localize>();
|
||||
if (localize != null)
|
||||
{
|
||||
localize.SetTerm(textContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonText.text = textContent;
|
||||
}
|
||||
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Diagnostics;
|
||||
using Ichni.Menu;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -24,8 +25,11 @@ namespace Ichni.UI
|
||||
languageDropdown.SetUp(gameSettings.languageIndex, 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() 调用完成返回");
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,14 @@ namespace Ichni.Menu
|
||||
|
||||
public partial class RebindingWindow
|
||||
{
|
||||
private const string RebindsSaveKey = "Rebinds";
|
||||
|
||||
public void LoadBindings()
|
||||
{
|
||||
// 检查EasySave中是否存在存档
|
||||
if (ES3.FileExists(rebindsFilePath))
|
||||
if (ES3.FileExists(rebindsFilePath) && ES3.KeyExists(RebindsSaveKey, rebindsFilePath))
|
||||
{
|
||||
string rebindsJson = ES3.Load<string>("Rebinds", rebindsFilePath);
|
||||
string rebindsJson = ES3.Load<string>(RebindsSaveKey, rebindsFilePath);
|
||||
inputActions.LoadBindingOverridesFromJson(rebindsJson);
|
||||
Debug.Log("键位已从EasySave加载。");
|
||||
}
|
||||
@@ -66,7 +68,7 @@ namespace Ichni.Menu
|
||||
public void SaveBindings()
|
||||
{
|
||||
string rebindsJson = inputActions.SaveBindingOverridesAsJson();
|
||||
ES3.Save("Rebinds", rebindsJson, rebindsFilePath);
|
||||
ES3.Save(RebindsSaveKey, rebindsJson, rebindsFilePath);
|
||||
Debug.Log("键位已保存到EasySave。");
|
||||
}
|
||||
}
|
||||
@@ -79,10 +81,15 @@ namespace Ichni.Menu
|
||||
// 移除所有覆盖(即恢复到Asset文件中的原始设置)
|
||||
inputActions.RemoveAllBindingOverrides();
|
||||
|
||||
// 从EasySave中删除存档
|
||||
if (ES3.KeyExists("Rebind", rebindsFilePath))
|
||||
// 从EasySave中删除存档Key与文件
|
||||
if (ES3.KeyExists(RebindsSaveKey, rebindsFilePath))
|
||||
{
|
||||
ES3.DeleteKey("Rebind", rebindsFilePath);
|
||||
ES3.DeleteKey(RebindsSaveKey, rebindsFilePath);
|
||||
}
|
||||
|
||||
if (ES3.FileExists(rebindsFilePath))
|
||||
{
|
||||
ES3.DeleteFile(rebindsFilePath);
|
||||
}
|
||||
|
||||
Debug.Log("所有键位已重置为默认值。");
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using DG.Tweening;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
@@ -51,12 +48,8 @@ namespace Ichni.Menu
|
||||
|
||||
selectedTab.RefreshUnlockState();
|
||||
|
||||
if (MenuManager.instance.isEnteringGame)
|
||||
{
|
||||
if (!MenuManager.instance.TryReserveGameEntry())
|
||||
return;
|
||||
}
|
||||
|
||||
MenuManager.instance.isEnteringGame = true;
|
||||
|
||||
InformationTransistor.instance.SetInformation(
|
||||
ChapterSelectionManager.instance.currentChapter,
|
||||
@@ -77,10 +70,7 @@ namespace Ichni.Menu
|
||||
arrowSeq.OnComplete(() =>
|
||||
{
|
||||
MenuManager.instance.transitionUIPage.FadeIn();
|
||||
Observable.Timer(TimeSpan.FromSeconds(0.6f)).Subscribe(_ =>
|
||||
{
|
||||
MenuManager.instance.EnterGame();
|
||||
});
|
||||
MenuManager.instance.ScheduleCurrentGameSceneLoad();
|
||||
});
|
||||
|
||||
arrowSeq.Play();
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using DG.Tweening;
|
||||
using Sirenix.OdinInspector;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using TMPro;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
using UnityEngine.UI;
|
||||
@@ -62,12 +59,8 @@ namespace Ichni.Menu
|
||||
return;
|
||||
}
|
||||
|
||||
if (MenuManager.instance.isEnteringGame)
|
||||
{
|
||||
if (!MenuManager.instance.TryReserveGameEntry())
|
||||
return;
|
||||
}
|
||||
|
||||
MenuManager.instance.isEnteringGame = true;
|
||||
|
||||
InformationTransistor.instance.SetInformation(
|
||||
ChapterSelectionManager.instance.currentChapter,
|
||||
@@ -78,11 +71,7 @@ namespace Ichni.Menu
|
||||
SongSelectionManager.instance.StopPreviewSong();
|
||||
|
||||
MenuManager.instance.transitionUIPage.FadeIn();
|
||||
|
||||
Observable.Timer(TimeSpan.FromSeconds(0.6f)).Subscribe(_ =>
|
||||
{
|
||||
MenuManager.instance.EnterGame();
|
||||
});
|
||||
MenuManager.instance.ScheduleCurrentGameSceneLoad();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
8
Assets/Scripts/Utilities.meta
Normal file
8
Assets/Scripts/Utilities.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5cdb3053b7ba374db5ab1b00a409bf7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
64
Assets/Scripts/Utilities/BuildHangTracer.cs
Normal file
64
Assets/Scripts/Utilities/BuildHangTracer.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
{
|
||||
// 静默忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Utilities/BuildHangTracer.cs.meta
Normal file
2
Assets/Scripts/Utilities/BuildHangTracer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bebb1ee94395f2648b7cf5892a16c9fd
|
||||
Reference in New Issue
Block a user