2026-07-21 15:24:42 -04:00
|
|
|
|
using System;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
using System.Collections.Generic;
|
2026-07-21 15:24:42 -04:00
|
|
|
|
using Ichni.Menu;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
using Ichni.Story.UI;
|
|
|
|
|
|
using Sirenix.OdinInspector;
|
2026-07-21 15:24:42 -04:00
|
|
|
|
using UniRx;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-07-20 16:56:04 -04:00
|
|
|
|
[Tooltip("全章节共用的 Helper 对话数据。它独立于各章节 StoryData,避免重复配置相同的 Helper。")]
|
|
|
|
|
|
public StoryHelperData helperData;
|
|
|
|
|
|
|
2026-07-21 15:24:42 -04:00
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-07-05 16:08:23 -04:00
|
|
|
|
[Header("Chapter Data")]
|
|
|
|
|
|
[Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")]
|
|
|
|
|
|
public Dictionary<string, StoryData> storyDatas = new Dictionary<string, StoryData>();
|
|
|
|
|
|
|
|
|
|
|
|
private void Awake()
|
|
|
|
|
|
{
|
|
|
|
|
|
instance = this;
|
2026-07-21 15:24:42 -04:00
|
|
|
|
_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);
|
2026-07-05 16:08:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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>
|
2026-07-21 15:24:42 -04:00
|
|
|
|
/// 从剧情 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>
|
2026-07-24 17:56:30 -04:00
|
|
|
|
/// 从 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>
|
2026-07-21 15:24:42 -04:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public void HandleSongSelectionConfirmed(ChapterSelectionUnit chapter, SongItemData song)
|
|
|
|
|
|
{
|
|
|
|
|
|
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
|
2026-07-24 17:56:30 -04:00
|
|
|
|
if (context == null || !context.isActive)
|
2026-07-21 15:24:42 -04:00
|
|
|
|
return;
|
|
|
|
|
|
|
2026-07-24 17:56:30 -04:00
|
|
|
|
// 玩家已经确认进入 GameScene,无论选择的是入口歌曲还是改选了其它歌曲,旧的剧情入口
|
|
|
|
|
|
// 上下文都不能遗留到下一次选曲。否则它可能在后续流程中错误影响页面返回或焦点恢复。
|
|
|
|
|
|
context.Clear();
|
2026-07-21 15:24:42 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 17:56:30 -04:00
|
|
|
|
/// <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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:24:42 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 清除全部剧情存档(所有章节故事树、章节变量与由剧情授予的内容解锁 Key)。
|
2026-07-05 16:08:23 -04:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Button]
|
|
|
|
|
|
public void ClearAllStorySave()
|
|
|
|
|
|
{
|
|
|
|
|
|
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
|
2026-07-18 16:51:18 -04:00
|
|
|
|
GameSaveManager.instance.UnlockSaveModule.ClearAllKeys();
|
2026-07-05 16:08:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|