Files
ichni_Official/Assets/Scripts/NewStorySystem/Tree/StoryManager.cs
2026-07-24 17:56:30 -04:00

287 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Ichni.Menu;
using Ichni.Story.UI;
using Sirenix.OdinInspector;
using UniRx;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 剧情系统协调器(单例)。持有故事树控制器、剧情页面与章节数据引用,
/// 是外部系统(章节选择、存档等)与剧情系统交互的入口。
/// </summary>
public class StoryManager : SerializedMonoBehaviour
{
public static StoryManager instance;
[Header("References")]
public StoryTreeController treeController;
public StoryUIPage storyUIPage;
public CharacterRegistry characterRegistry;
[Tooltip("全章节共用的 Helper 对话数据。它独立于各章节 StoryData避免重复配置相同的 Helper。")]
public StoryHelperData helperData;
/// <summary>
/// 当前运行时实际使用的 Helper 数据。第一版始终由 <see cref="helperData"/> 初始化;
/// 未来的 Helper 更换界面应通过 <see cref="SetActiveHelper"/> 切换,而不要让各个 UI
/// 直接保存自己的 Helper 引用。
/// </summary>
private StoryHelperData _activeHelperData;
/// <summary>当前运行时生效的 Helper 数据。未被运行时覆盖时回退到 Inspector 默认数据。</summary>
public StoryHelperData ActiveHelperData => _activeHelperData != null ? _activeHelperData : helperData;
/// <summary>
/// 当前 Helper 被切换时触发。静态 Image、Spine 或 Live2D 的表现层均可订阅该事件刷新视觉,
/// 而无需了解具体的更换 UI 实现。
/// </summary>
public event Action<StoryHelperData> ActiveHelperChanged;
[Header("Chapter Data")]
[Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")]
public Dictionary<string, StoryData> storyDatas = new Dictionary<string, StoryData>();
private void Awake()
{
instance = this;
_activeHelperData = helperData;
}
private void OnDestroy()
{
if (instance == this)
instance = null;
}
/// <summary>
/// 设置本次运行期间的当前 Helper。
/// <para>本方法暂不写入存档:当前没有 Helper 更换功能;后续选择界面完成后,
/// 只需在其外层决定何时持久化 helperId再调用本方法即可。</para>
/// </summary>
public void SetActiveHelper(StoryHelperData data)
{
if (data == null)
{
Debug.LogWarning("[StoryManager] 尝试切换到空的 StoryHelperData已忽略。");
return;
}
if (_activeHelperData == data)
return;
_activeHelperData = data;
ActiveHelperChanged?.Invoke(_activeHelperData);
}
/// <summary>
/// 打开指定章节,构建其故事树。
/// </summary>
public void OpenChapter(string chapterIndex)
{
treeController.BuildChapter(chapterIndex);
}
/// <summary>
/// 根据章节索引获取对应的 StoryData未找到返回 null。
/// </summary>
public StoryData GetStoryData(string chapterIndex)
{
return storyDatas.TryGetValue(chapterIndex, out StoryData data) ? data : null;
}
/// <summary>
/// 从剧情 SongBlock 打开选曲页,并把“来源 Block、目标歌曲、剧情树视口位置”写入
/// 运行时上下文。此处不完成 Block只有玩家确认进入目标歌曲时才会写入剧情进度。
/// </summary>
public bool TryOpenSongBlock(SongBlockView sourceBlock, ChapterSelectionUnit chapter, SongItemData song)
{
if (sourceBlock == null || chapter == null || song == null ||
treeController == null || treeController.content == null ||
storyUIPage == null || MenuManager.instance == null ||
MenuManager.instance.songSelectionUIPage == null ||
MenuManager.instance.songSelectionUIPage.songListController == null ||
InformationTransistor.instance == null)
{
Debug.LogWarning("[StoryManager] SongBlock 缺少打开选曲所需引用,已取消本次跳转。");
return false;
}
InformationTransistor.instance.storySongEntryContext.Begin(
chapter.chapterIndex,
sourceBlock.blockId,
song.songName,
treeController.content.anchoredPosition);
// 强制歌曲只影响本次打开选曲页的初始焦点;原有章节歌曲/难度缓存仍然保留。
ChapterSelectionManager.instance.currentChapter = chapter;
MenuManager.instance.songSelectionUIPage.songListController.RequestInitialSongSelection(song);
storyUIPage.FadeOut();
MenuManager.instance.songSelectionUIPage.FadeIn();
return true;
}
/// <summary>
/// 从 SongSelection 页进入当前章节的 StoryPage。
/// <para>若 <paramref name="song"/> 在该章节 StoryData 中存在唯一的 SongBlock视口会定位到它
/// 没有对应 SongBlock 或当前未选中歌曲时,视口回到剧情树开头。</para>
/// <para>这不是 Story SongBlock 的“返回”操作,因此会清除一次性选曲上下文,且不会写入歌曲、剧情或选项存档。</para>
/// </summary>
public bool TryOpenStoryForSong(ChapterSelectionUnit chapter, SongItemData song)
{
if (chapter == null || treeController == null || storyUIPage == null ||
MenuManager.instance == null || MenuManager.instance.songSelectionUIPage == null ||
ChapterSelectionManager.instance == null)
{
Debug.LogWarning("[StoryManager] 从选曲页打开剧情失败:章节、页面或 StoryTree 引用未就绪。");
return false;
}
if (GetStoryData(chapter.chapterIndex) == null)
{
Debug.LogWarning($"[StoryManager] 章节 '{chapter.chapterIndex}' 未登记 StoryData无法从选曲页打开剧情。");
return false;
}
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
if (unlockSaveModule == null || !unlockSaveModule.CanAccessChapter(chapter))
{
Debug.LogWarning($"[StoryManager] 章节 '{chapter.chapterIndex}' 尚不可进入剧情,已取消从选曲页跳转。");
return false;
}
// 此入口只负责浏览剧情。若此前由 SongBlock 打开选曲页,主动进入剧情时应放弃旧的
// “目标歌曲 -> GameScene 后返回原 Block”上下文避免下一次普通游玩误命中旧目标。
InformationTransistor.instance?.storySongEntryContext?.Clear();
ChapterSelectionManager.instance.currentChapter = chapter;
AudioManager.SetSwitch(chapter.chapterSwitch);
OpenChapter(chapter.chapterIndex);
StoryBlockView targetBlock = FindSongBlockView(song);
// 与 SongSelection 的 Back 保持一致:离开选曲页进入剧情时,必须立即停止当前歌曲试听。
// StoryPage 不承载试听音频,若遗漏该步骤会让歌曲预览跨页面持续播放。
SongSelectionManager.instance?.StopPreviewSong();
MenuManager.instance.songSelectionUIPage.FadeOut();
// StoryPage 激活后、淡入前先完成定位。UIPageBase 此时保持 alpha = 0
// 因此玩家不会先看到剧情树开头、再跳到目标 SongBlock。
storyUIPage.FadeIn(beforeFadeAction: () =>
{
if (targetBlock != null)
treeController.FocusBlock(targetBlock);
else
treeController.FocusStart();
});
return true;
}
/// <summary>
/// 选曲页确认启动游戏时调用。
/// <para>
/// SongBlock 的完成状态统一由 <c>SongSaves.isTried</c> 判断,并已在
/// <c>PrepareGameLaunch</c> 中写入;这里仅清理由 StoryPage 发起的临时选曲上下文。
/// GameScene 无论从何种歌曲入口启动,都会返回 SongSelectionTutorialBlock 仍通过其独立
/// 的 <c>MenuReturnDestination.Story</c> 流程返回剧情。
/// </para>
/// </summary>
public void HandleSongSelectionConfirmed(ChapterSelectionUnit chapter, SongItemData song)
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.isActive)
return;
// 玩家已经确认进入 GameScene无论选择的是入口歌曲还是改选了其它歌曲旧的剧情入口
// 上下文都不能遗留到下一次选曲。否则它可能在后续流程中错误影响页面返回或焦点恢复。
context.Clear();
}
/// <summary>
/// 处理剧情入口进入选曲后的“返回”操作。返回时不重建故事树,只恢复离开前的
/// Content 位置与来源 Block 焦点;普通选曲返回则由调用方继续回到章节选择页。
/// </summary>
public bool TryReturnFromSongSelection()
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.isActive)
return false;
// SongSaves.isTried 在进入 GameScene 前已落盘。返回时立刻同步,确保后续 Block
// 的解锁条件和 Timeline 状态基于持久化的“已尝试歌曲”记录重新计算。
treeController?.SynchronizeSongBlockAttempts();
RestoreStoryTreeViewport(context.sourceBlockId, context.storyTreeContentPosition);
context.Clear();
return true;
}
/// <summary>
/// GameScene 返回 MenuScene 后调用。此时 MenuManager 已依据 InformationTransistor
/// 重建来源章节;下一帧恢复 Content 位置,确保 Canvas 布局和连接线已稳定。
/// </summary>
public void RestoreStoryAfterGameReturn()
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.isActive || !context.returnToStoryAfterGame)
return;
// SongSaves.isTried 在进入 GameScene 前已落盘。返回时立刻同步,确保后续 Block
// 的解锁条件和 Timeline 状态基于持久化的“已尝试歌曲”记录重新计算。
treeController?.SynchronizeSongBlockAttempts();
RestoreStoryTreeViewport(context.sourceBlockId, context.storyTreeContentPosition);
context.Clear();
}
/// <summary>
/// 在下一帧还原剧情树浏览位置,并使来源 Block 重新成为当前 Block。
/// 参数在调用前即被复制,因此即使运行时上下文随后清理,也不会丢失恢复数据。
/// </summary>
private void RestoreStoryTreeViewport(string sourceBlockId, Vector2 contentPosition)
{
Observable.NextFrame().Subscribe(_ =>
{
if (treeController == null || treeController.content == null)
return;
treeController.content.anchoredPosition = contentPosition;
StoryBlockView sourceBlock = treeController.GetBlockView(sourceBlockId);
if (sourceBlock != null)
treeController.currentBlock = sourceBlock;
}).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>
[Button]
public void ClearAllStorySave()
{
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
GameSaveManager.instance.UnlockSaveModule.ClearAllKeys();
}
}
}