770 lines
35 KiB
C#
770 lines
35 KiB
C#
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using Ichni.Menu;
|
||
using Ichni.Story.UI;
|
||
using Sirenix.OdinInspector;
|
||
using UniRx;
|
||
using UnityEngine;
|
||
using UnityEngine.Serialization;
|
||
using UnityEngine.UI;
|
||
|
||
namespace Ichni.Story
|
||
{
|
||
/// <summary>
|
||
/// 故事树控制器。从 <see cref="StoryData"/> 一次性全量构建所有 block 视图与连接线,
|
||
/// 布局由每个 block 的 (gridColumn, gridRow) 静态决定;每个 block 的
|
||
/// <see cref="StoryBlockState"/> 由"已完成集合 + 解锁条件"实时推导。
|
||
/// 存档以章节为单位保存已完成 Block、Yarn 选项、永久剧情变量与 Timeline 回滚快照。
|
||
/// </summary>
|
||
public class StoryTreeController : MonoBehaviour
|
||
{
|
||
[Header("UI References")]
|
||
[Tooltip("blocks 与 connectors 共用的单一容器(即 ScrollRect 的 content,localScale 必须为 1)")]
|
||
public RectTransform content;
|
||
|
||
[Tooltip("承载剧情树拖动的 ScrollRect。StoryTimeline 监听其横向位置,以同步顶部 Marker。")]
|
||
public ScrollRect storyScrollRect;
|
||
|
||
[Tooltip("固定在剧情页顶部的 StoryTimeline。未配置时剧情树仍可独立运行。")]
|
||
public StoryTimelineController storyTimeline;
|
||
|
||
/// <summary>
|
||
/// 重要 TextBlock 的 Prefab。保留 FormerlySerializedAs,使旧场景中的 textBlockPrefab 引用
|
||
/// 自动迁移到此字段;在新 Prefab 配置完成前,已有 Important Block 不会失去引用。
|
||
/// </summary>
|
||
[FormerlySerializedAs("textBlockPrefab")]
|
||
[Tooltip("对应 TextBlockImportance.Important。Prefab 自行定义实际尺寸,例如 600 × 375。")]
|
||
public GameObject importantTextBlockPrefab;
|
||
|
||
/// <summary>
|
||
/// 次要 TextBlock 的 Prefab。由 StoryBlockDefinition.textImportance 选择;
|
||
/// Prefab 自行定义实际尺寸,例如 400 × 200。
|
||
/// </summary>
|
||
[Tooltip("对应 TextBlockImportance.Secondary。Prefab 自行定义实际尺寸,例如 400 × 200。")]
|
||
public GameObject secondaryTextBlockPrefab;
|
||
|
||
public GameObject songBlockPrefab;
|
||
public GameObject tutorialBlockPrefab;
|
||
public GameObject connectorPrefab;
|
||
|
||
[Header("Layout Settings")]
|
||
/// <summary>
|
||
/// 相邻叙事列之间的固定距离。它是 StoryData.gridColumn 的坐标步距,
|
||
/// 不等于也不会覆盖任何 Block Prefab 的宽度。
|
||
/// 默认值为 Important TextBlock 宽度 600 加最小横向留白 120。
|
||
/// </summary>
|
||
[Tooltip("StoryData.gridColumn 的水平步距,不会改变任何 Prefab 的实际宽度。")]
|
||
public float columnStep = 720f;
|
||
|
||
/// <summary>
|
||
/// 相邻路线行之间的固定距离。它是 StoryData.gridRow 的坐标步距,
|
||
/// 不等于也不会覆盖任何 Block Prefab 的高度。
|
||
/// 默认值为 Important TextBlock 高度 375 加最小纵向留白 150。
|
||
/// </summary>
|
||
[Tooltip("StoryData.gridRow 的垂直步距,不会改变任何 Prefab 的实际高度。")]
|
||
public float rowStep = 525f;
|
||
|
||
[Tooltip("按所有 Block 的真实边界自动归一化后的左侧留白。")]
|
||
public float marginLeft = 50f;
|
||
|
||
/// <summary>
|
||
/// 为剧情页左侧常驻 UI(当前为 Helper)预留的额外水平空间。
|
||
/// 最左 Block 的真实左边缘会被放置在 <see cref="marginLeft"/> 加本值的位置;
|
||
/// 默认 1600,等价于在原有布局基础上将整棵剧情树向右平移 1600 单位。
|
||
/// </summary>
|
||
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1600,会让所有 Block 整体向右移动 1600。")]
|
||
public float helperReservedLeftSpace = 1600f;
|
||
|
||
public float marginRight = 50f;
|
||
public float marginTop = 50f;
|
||
public float marginBottom = 50f;
|
||
public float minContentWidth = 2560f;
|
||
public float minContentHeight = 1440f;
|
||
|
||
[Header("Runtime")]
|
||
public StoryBlockView currentBlock;
|
||
public List<StoryBlockView> blocks = new List<StoryBlockView>();
|
||
public List<BlockConnectorView> connectors = new List<BlockConnectorView>();
|
||
|
||
private string _chapterIndex;
|
||
private StoryData _storyData;
|
||
|
||
// 由 SetUpBackground 根据最左侧 Block 的真实边界计算。
|
||
// 未来 Timeline 在独立 UI 容器中生成 Marker 时,也必须叠加此偏移量才能与 Block 中心对齐。
|
||
private float _layoutHorizontalOffset;
|
||
|
||
/// <summary>当前已构建章节的 StoryData(供对话控制器切换 YarnProject 等使用)。</summary>
|
||
public StoryData ActiveStoryData => _storyData;
|
||
|
||
/// <summary>当前已构建章节的索引。</summary>
|
||
public string ActiveChapterIndex => _chapterIndex;
|
||
|
||
/// <summary>
|
||
/// 当前章节的自动横向留白偏移。它不是 StoryData 的内容,而是根据本次实例化后的
|
||
/// 真实 Prefab 尺寸计算出的运行时布局结果。
|
||
/// </summary>
|
||
public float LayoutHorizontalOffset => _layoutHorizontalOffset;
|
||
|
||
/// <summary>
|
||
/// 获取指定叙事列在 BlockContainer 中的最终中心 X 坐标。
|
||
/// Timeline Marker 必须使用此方法,而不是自行只计算 gridColumn * columnStep,
|
||
/// 才能在不同尺寸 Block 自动留白后继续与其中心对齐。
|
||
/// </summary>
|
||
public float GetColumnCenterX(float gridColumn)
|
||
{
|
||
return _layoutHorizontalOffset + gridColumn * columnStep;
|
||
}
|
||
|
||
// 当前章节已完成的 block id 集合(状态推导的唯一进度来源)
|
||
private readonly HashSet<string> _completed = new HashSet<string>();
|
||
|
||
private void Start()
|
||
{
|
||
// 存档系统未就绪时跳过;正式流程由 StoryManager.OpenChapter 在进入剧情页时触发构建。
|
||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||
return;
|
||
|
||
if (ChapterSelectionManager.instance != null &&
|
||
ChapterSelectionManager.instance.currentChapter != null)
|
||
{
|
||
BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||
}
|
||
}
|
||
|
||
// ── Build ─────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 构建指定章节的故事树:全量生成所有 block 与连接线,状态由存档中的已完成集合推导。
|
||
/// </summary>
|
||
public void BuildChapter(string chapterIndex)
|
||
{
|
||
_chapterIndex = chapterIndex;
|
||
_storyData = StoryManager.instance != null ? StoryManager.instance.GetStoryData(chapterIndex) : null;
|
||
|
||
if (_storyData == null)
|
||
{
|
||
Debug.LogError($"[StoryTreeController] 找不到章节 '{chapterIndex}' 的 StoryData,请在 StoryManager.storyDatas 中登记。");
|
||
return;
|
||
}
|
||
|
||
if (_storyData.chapterIndex != chapterIndex)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[StoryTreeController] StoryManager 使用 Key '{chapterIndex}' 打开 StoryData '{_storyData.name}'," +
|
||
$"但该资产的 Chapter Index 为 '{_storyData.chapterIndex}'。请让 StoryData、StoryManager 与 ChapterSelectionUnit 使用同一个稳定章节 ID。",
|
||
_storyData);
|
||
}
|
||
|
||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||
{
|
||
Debug.LogWarning("[StoryTreeController] 存档系统尚未就绪(GameSaveManager.StorySaveModule 为空),跳过构建。");
|
||
return;
|
||
}
|
||
|
||
ClearTree();
|
||
|
||
// 载入已完成集合(新章节为空)
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(chapterIndex);
|
||
_completed.Clear();
|
||
foreach (string id in save.completedBlockIds)
|
||
_completed.Add(id);
|
||
|
||
// SongBlock 的完成来源是 SongSaves.isTried,而非一次性的选曲上下文。先同步再生成视图,
|
||
// 使首次进入剧情页、从 GameScene 返回以及重启游戏后的状态保持一致。
|
||
SynchronizeSongBlockAttempts();
|
||
|
||
// 全量生成所有 block(位置按网格换算,状态按已完成集合 + 解锁条件推导)
|
||
foreach (StoryBlockDefinition def in _storyData.blocks)
|
||
{
|
||
if (def != null)
|
||
GenerateBlock(def);
|
||
}
|
||
|
||
// 全量生成所有连接线
|
||
foreach (StoryBlockDefinition def in _storyData.blocks)
|
||
{
|
||
if (def?.nextBlockIds == null)
|
||
continue;
|
||
|
||
foreach (string nextId in def.nextBlockIds)
|
||
GenerateConnector(def.blockId, nextId);
|
||
}
|
||
|
||
SetUpBackground();
|
||
|
||
// 连线在下一帧刷新(见 RefreshConnectors):等待 Unity 完成 Canvas 布局,
|
||
// 使 block 端口的世界坐标稳定,避免端口坐标尚未生效时塌缩到同一点。
|
||
RefreshConnectors();
|
||
|
||
// Timeline 是故事树的固定顶部投影:必须在 Block 真实边界与 Content 留白均确定后再构建,
|
||
// 才能使用锚定 TextBlock 的最终视觉中心进行横向同步。
|
||
storyTimeline?.Build(this, _storyData, storyScrollRect);
|
||
}
|
||
|
||
// ── Generation ──────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 按定义生成一个 block 视图:位置由网格换算,状态由推导得出。
|
||
/// </summary>
|
||
public StoryBlockView GenerateBlock(StoryBlockDefinition def)
|
||
{
|
||
GameObject prefab = def.blockType switch
|
||
{
|
||
StoryBlockType.Text => def.textImportance == TextBlockImportance.Important
|
||
? importantTextBlockPrefab
|
||
: secondaryTextBlockPrefab,
|
||
StoryBlockType.Song => songBlockPrefab,
|
||
StoryBlockType.Tutorial => tutorialBlockPrefab,
|
||
_ => null
|
||
};
|
||
|
||
if (prefab == null)
|
||
{
|
||
string prefabDescription = def.blockType == StoryBlockType.Text
|
||
? $"TextBlockImportance.{def.textImportance}"
|
||
: def.blockType.ToString();
|
||
Debug.LogError($"[StoryTreeController] block '{def.blockId}' 的 {prefabDescription} 未配置预制体。");
|
||
return null;
|
||
}
|
||
|
||
GameObject blockObject = Instantiate(prefab, content);
|
||
StoryBlockView view = blockObject.GetComponent<StoryBlockView>();
|
||
if (view == null)
|
||
{
|
||
Debug.LogError($"[StoryTreeController] 预制体 '{prefab.name}' 缺少 StoryBlockView 组件。");
|
||
Destroy(blockObject);
|
||
return null;
|
||
}
|
||
|
||
view.Initialize(def, GetGridPosition(def), DeriveState(def));
|
||
blocks.Add(view);
|
||
return view;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在两个已存在的 block 之间生成连接线。
|
||
/// </summary>
|
||
public void GenerateConnector(string fromBlockId, string toBlockId)
|
||
{
|
||
StoryBlockView from = GetBlockView(fromBlockId);
|
||
StoryBlockView to = GetBlockView(toBlockId);
|
||
|
||
if (from == null || to == null)
|
||
{
|
||
Debug.LogWarning($"[StoryTreeController] 无法连线:'{fromBlockId}' -> '{toBlockId}'(block 不存在)。");
|
||
return;
|
||
}
|
||
|
||
// 连接线与 block 同处一个容器(content);置为第一个子物体,渲染在所有 block 之后方。
|
||
GameObject connectorObject = Instantiate(connectorPrefab, content);
|
||
connectorObject.transform.SetAsFirstSibling();
|
||
|
||
BlockConnectorView connector = connectorObject.GetComponent<BlockConnectorView>();
|
||
if (connector == null)
|
||
{
|
||
Debug.LogError($"[StoryTreeController] 连接线预制体 '{connectorPrefab.name}' 缺少 BlockConnectorView 组件。");
|
||
Destroy(connectorObject);
|
||
return;
|
||
}
|
||
|
||
// 仅记录起止 block 引用;曲线在布局刷新后由 RefreshConnectors 统一计算。
|
||
connector.startBlock = from;
|
||
connector.endBlock = to;
|
||
connectors.Add(connector);
|
||
}
|
||
|
||
// ── State / Unlocking ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 依据“禁用条件 + 已完成集合 + 自动连接条件 + 手写解锁条件”推导单个 Block 的状态。
|
||
/// Forbidden Condition 满足时优先显示 Forbidden;否则依次为 Completed、Current、Locked。
|
||
/// 自动连接条件与手写 Unlock Condition 按 AND 合并;没有前置连接的章节起始 Block 不受自动条件限制。
|
||
/// </summary>
|
||
private StoryBlockState DeriveState(StoryBlockDefinition def)
|
||
{
|
||
if (def.forbiddenCondition != null &&
|
||
def.forbiddenCondition.IsSatisfied(GetVariable, IsBlockCompleted))
|
||
{
|
||
return StoryBlockState.Forbidden;
|
||
}
|
||
|
||
if (_completed.Contains(def.blockId))
|
||
return StoryBlockState.Completed;
|
||
|
||
bool explicitUnlockSatisfied = def.unlockCondition == null ||
|
||
!def.unlockCondition.IsConfigured ||
|
||
def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted);
|
||
if (AreConnectedBlockRequirementsSatisfied(def) && explicitUnlockSatisfied)
|
||
{
|
||
return StoryBlockState.Current;
|
||
}
|
||
|
||
return StoryBlockState.Locked;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重新推导并应用所有 block 的状态(在完成某 block 或变量变化后调用)。
|
||
/// </summary>
|
||
public void RefreshAllStates()
|
||
{
|
||
foreach (StoryBlockView view in blocks)
|
||
{
|
||
StoryBlockDefinition def = _storyData.GetBlock(view.blockId);
|
||
if (def == null) continue;
|
||
|
||
view.ApplyState(DeriveState(def));
|
||
}
|
||
|
||
storyTimeline?.RefreshMarkerStates();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从当前章节存档重新同步已完成 Block 集合,并刷新既有 Tree 与 Timeline 状态。
|
||
/// <para>供中途退出 TextBlock 对话时使用:不重建 UI,也不改变玩家当前拖动到的剧情树位置。</para>
|
||
/// </summary>
|
||
public void ReloadActiveChapterProgress()
|
||
{
|
||
if (string.IsNullOrEmpty(_chapterIndex) || GameSaveManager.instance?.StorySaveModule == null)
|
||
return;
|
||
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||
_completed.Clear();
|
||
foreach (string completedId in save.completedBlockIds)
|
||
_completed.Add(completedId);
|
||
|
||
RefreshAllStates();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将当前章节所有已尝试歌曲同步为已完成的 Story SongBlock。
|
||
/// <para>SongSaves 是“玩家已确认进入该歌曲”的唯一持久化事实来源;StorySave 仅缓存由该事实
|
||
/// 推导出的剧情 Block 完成集合,从而保证原有的解锁条件、Timeline Marker 与章节回滚机制仍可复用。</para>
|
||
/// <para>该同步只会补充完成项,绝不从 StorySave 中移除 SongBlock。这样 Timeline 回滚不会撤销已经尝试过的歌曲,
|
||
/// 符合“重开章节不回滚游戏记录”的存档边界。</para>
|
||
/// </summary>
|
||
public void SynchronizeSongBlockAttempts()
|
||
{
|
||
if (_storyData == null || string.IsNullOrWhiteSpace(_chapterIndex) ||
|
||
GameSaveManager.instance?.SongSaveModule == null ||
|
||
GameSaveManager.instance?.StorySaveModule == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
ChapterSelectionUnit chapter = ChapterSelectionManager.instance?.chapters
|
||
.FirstOrDefault(candidate => candidate != null && candidate.chapterIndex == _chapterIndex);
|
||
if (chapter == null)
|
||
return;
|
||
|
||
bool changed = false;
|
||
foreach (StoryBlockDefinition definition in _storyData.blocks)
|
||
{
|
||
if (definition == null || definition.blockType != StoryBlockType.Song ||
|
||
string.IsNullOrWhiteSpace(definition.songName))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var songSave = GameSaveManager.instance.SongSaveModule.GetSongStatusSave(definition.songName);
|
||
if (songSave != null && songSave.isTried)
|
||
changed |= _completed.Add(definition.blockId);
|
||
}
|
||
|
||
if (!changed)
|
||
return;
|
||
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||
save.completedBlockIds = _completed.ToList();
|
||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 标记某 Block 完成,推导重算并保存。
|
||
/// 若该 Block 位于某个 TimelineMarker 所在列,会先保存“该列之前”的快照,
|
||
/// 因而通过 Marker 重开时能同时回滚完成状态、选项和章节变量。
|
||
/// </summary>
|
||
public void OnBlockCompleted(string blockId)
|
||
{
|
||
EnsureRollbackSnapshotsForBlock(blockId);
|
||
|
||
if (!_completed.Add(blockId))
|
||
{
|
||
// 剧情变量可能在 block 已完成后被外部系统修正;即使完成集合没有变化,
|
||
// 也要重新推导依赖变量的后续 Block 状态。
|
||
RefreshAllStates();
|
||
return;
|
||
}
|
||
|
||
RefreshAllStates();
|
||
SaveChapter();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询当前已构建章节中指定 Block 是否完成。
|
||
/// 除故事树自身外,Helper 对话等需要复用 <see cref="StoryCondition"/> 的系统也应通过此只读入口
|
||
/// 提供 BlockCompletedCondition 的求值结果,避免复制第二份完成状态。
|
||
/// </summary>
|
||
public bool IsBlockCompleted(string blockId) =>
|
||
!string.IsNullOrEmpty(blockId) && _completed.Contains(blockId);
|
||
|
||
/// <summary>
|
||
/// 从 StoryData 的所有 <see cref="StoryBlockDefinition.nextBlockIds"/> 反向推导本 Block 的直接前置节点,
|
||
/// 不额外保存第二份图数据,避免连接线和解锁规则在编辑后脱节。
|
||
/// </summary>
|
||
private bool AreConnectedBlockRequirementsSatisfied(StoryBlockDefinition target)
|
||
{
|
||
if (target.connectedBlockUnlockRule == ConnectedBlockUnlockRule.IgnoreConnections ||
|
||
_storyData?.blocks == null)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
bool hasPredecessor = false;
|
||
bool anyPredecessorCompleted = false;
|
||
foreach (StoryBlockDefinition candidate in _storyData.blocks)
|
||
{
|
||
if (candidate == null || candidate.nextBlockIds == null ||
|
||
!candidate.nextBlockIds.Contains(target.blockId))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
hasPredecessor = true;
|
||
bool isCompleted = IsBlockCompleted(candidate.blockId);
|
||
anyPredecessorCompleted |= isCompleted;
|
||
|
||
if (target.connectedBlockUnlockRule == ConnectedBlockUnlockRule.RequireAllDirectPredecessors &&
|
||
!isCompleted)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 没有任何前置连接的 Block 是章节起始节点,自动条件不应把它锁住。
|
||
if (!hasPredecessor)
|
||
return true;
|
||
|
||
return target.connectedBlockUnlockRule == ConnectedBlockUnlockRule.RequireAllDirectPredecessors ||
|
||
anyPredecessorCompleted;
|
||
}
|
||
|
||
// StoryVariables 已统一桥接到当前章节变量容器;故事树只关心数值结果,
|
||
// 不直接依赖 ES3 的序列化结构。
|
||
private int GetVariable(string variableName)
|
||
{
|
||
return StoryVariables.GetInt(variableName);
|
||
}
|
||
|
||
// ── Persistence ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 保存当前章节进度(已完成 block 集合)。选项由对话系统维护、不在此覆盖。
|
||
/// </summary>
|
||
[Button]
|
||
public void SaveChapter()
|
||
{
|
||
if (string.IsNullOrEmpty(_chapterIndex)) return;
|
||
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||
save.chapterIndex = _chapterIndex;
|
||
save.completedBlockIds = _completed.ToList();
|
||
|
||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||
}
|
||
|
||
// ── Timeline 回滚 ───────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 在指定 Block 所在列第一次被执行前,为该列上的所有 TimelineMarker 建立快照。
|
||
/// 同列存在多个 Block 时,最先进入的 Block 创建一次快照;后续 Block 不会覆盖它,
|
||
/// 因此无论分支以何种顺序执行,回滚边界都稳定地位于整列之前。
|
||
/// </summary>
|
||
public bool EnsureRollbackSnapshotsForBlock(string blockId)
|
||
{
|
||
if (string.IsNullOrEmpty(_chapterIndex) || _storyData == null ||
|
||
GameSaveManager.instance?.StorySaveModule == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
StoryBlockDefinition sourceBlock = _storyData.GetBlock(blockId);
|
||
if (sourceBlock == null || _storyData.timelineMarkers == null)
|
||
return false;
|
||
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||
bool changed = false;
|
||
foreach (StoryTimelineMarkerDefinition marker in _storyData.timelineMarkers)
|
||
{
|
||
if (marker == null || string.IsNullOrWhiteSpace(marker.markerId) ||
|
||
save.markerRollbackSnapshots.ContainsKey(marker.markerId))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
StoryBlockDefinition anchorBlock = _storyData.GetBlock(marker.anchorBlockId);
|
||
if (anchorBlock == null || !Mathf.Approximately(anchorBlock.gridColumn, sourceBlock.gridColumn))
|
||
continue;
|
||
|
||
save.markerRollbackSnapshots[marker.markerId] = new StoryRollbackSnapshot
|
||
{
|
||
markerId = marker.markerId,
|
||
markerColumn = anchorBlock.gridColumn,
|
||
completedBlockIds = _completed.ToList(),
|
||
selectedChoices = StorySaveCloneUtility.CloneChoices(save.selectedChoices),
|
||
storyVariables = StorySaveCloneUtility.CloneVariables(save.storyVariables)
|
||
};
|
||
changed = true;
|
||
}
|
||
|
||
if (changed)
|
||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||
|
||
return changed;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将当前章节恢复到指定 TimelineMarker 所在列之前的状态。
|
||
/// 目标列及其右侧列的快照同时失效并被删除,因为它们依赖于被重置的剧情历史。
|
||
/// </summary>
|
||
public bool TryRollbackToMarker(string markerId)
|
||
{
|
||
if (string.IsNullOrEmpty(_chapterIndex) || _storyData == null ||
|
||
GameSaveManager.instance?.StorySaveModule == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
StoryTimelineMarkerDefinition marker = _storyData.timelineMarkers?
|
||
.FirstOrDefault(item => item != null && item.markerId == markerId);
|
||
StoryBlockDefinition anchorBlock = marker == null ? null : _storyData.GetBlock(marker.anchorBlockId);
|
||
if (marker == null || anchorBlock == null)
|
||
{
|
||
Debug.LogWarning($"[StoryTreeController] 未找到可回滚的 TimelineMarker '{markerId}'。");
|
||
return false;
|
||
}
|
||
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||
if (!save.markerRollbackSnapshots.TryGetValue(markerId, out StoryRollbackSnapshot snapshot) || snapshot == null)
|
||
{
|
||
// 旧开发存档没有新快照时,宁可明确拒绝,也不以“当前已完成状态”伪造快照而造成无效回滚。
|
||
Debug.LogWarning($"[StoryTreeController] Marker '{markerId}' 缺少回滚快照。请先清除该章节剧情存档并重新游玩到此时间点。");
|
||
return false;
|
||
}
|
||
|
||
_completed.Clear();
|
||
foreach (string completedId in snapshot.completedBlockIds)
|
||
_completed.Add(completedId);
|
||
|
||
save.completedBlockIds = snapshot.completedBlockIds.ToList();
|
||
save.selectedChoices = StorySaveCloneUtility.CloneChoices(snapshot.selectedChoices);
|
||
save.storyVariables = StorySaveCloneUtility.CloneVariables(snapshot.storyVariables);
|
||
|
||
// 回滚点本身和后续列会被重新游玩,旧快照不再代表新的故事历史。
|
||
foreach (StoryTimelineMarkerDefinition candidate in _storyData.timelineMarkers)
|
||
{
|
||
if (candidate == null)
|
||
continue;
|
||
|
||
StoryBlockDefinition candidateAnchor = _storyData.GetBlock(candidate.anchorBlockId);
|
||
if (candidateAnchor != null && candidateAnchor.gridColumn >= anchorBlock.gridColumn)
|
||
save.markerRollbackSnapshots.Remove(candidate.markerId);
|
||
}
|
||
|
||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||
BuildChapter(_chapterIndex);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 供 Timeline 决定 Marker 是否允许点击。只有已创建“该列之前”快照的 Marker 才能真正回滚;
|
||
/// Current 状态但尚未执行到该列的 Marker 仍会显示时间文本,但不会提供无意义的重开操作。
|
||
/// </summary>
|
||
public bool HasRollbackSnapshot(string markerId)
|
||
{
|
||
return !string.IsNullOrEmpty(_chapterIndex) &&
|
||
GameSaveManager.instance?.StorySaveModule != null &&
|
||
GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex)
|
||
.markerRollbackSnapshots.ContainsKey(markerId);
|
||
}
|
||
|
||
// ── Layout ────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 将 Block 的静态叙事坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的预归一化中心位置。
|
||
/// 约定列中心向右为正,行中心向下为正、向上为负(anchoredPosition.y 取负)。
|
||
/// <para>columnStep / rowStep 只定义坐标锚点的间距,Block 的真实尺寸始终由各自 Prefab 保持;
|
||
/// 因而 Important、Secondary、Song、Tutorial 及未来新增类型可使用不同尺寸。</para>
|
||
/// <para>gridColumn / gridRow 支持负数与小数,供静态剧情排版微调;Timeline 应通过
|
||
/// <see cref="GetColumnCenterX"/> 查询横向位置,以保持标记和 Block 对齐。</para>
|
||
/// </summary>
|
||
private Vector2 GetGridPosition(StoryBlockDefinition def)
|
||
{
|
||
float x = GetColumnCenterX(def.gridColumn);
|
||
float y = -def.gridRow * rowStep;
|
||
return new Vector2(x, y);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据所有 Block 的真实矩形边界归一化横向位置并设置 Content(BlockContainer) 尺寸。
|
||
/// <para>Block 采用中心 Pivot:anchoredPosition 表示 Block 中心。因此会先用
|
||
/// centerX ± width / 2 求取左右边界,再把全体 Block 平移到左侧留白与 Helper 预留区之后。</para>
|
||
/// <para>该步骤允许不同尺寸的 Important / Secondary / Song / Tutorial Block 共用相同的
|
||
/// 逻辑列中心,且不会因最左 Block 的宽度不同而被 Content 裁切。</para>
|
||
/// </summary>
|
||
public void SetUpBackground()
|
||
{
|
||
if (blocks.Count == 0)
|
||
{
|
||
_layoutHorizontalOffset = 0f;
|
||
content.sizeDelta = new Vector2(minContentWidth, minContentHeight);
|
||
return;
|
||
}
|
||
|
||
float minLeft = float.PositiveInfinity;
|
||
float maxRight = float.NegativeInfinity;
|
||
float maxVertical = 0f; // 距垂直中线的最大延伸(上下取较大者,用于对称扩展 content 高度)
|
||
|
||
foreach (StoryBlockView block in blocks)
|
||
{
|
||
RectTransform rect = block.blockRect;
|
||
Vector2 pos = rect.anchoredPosition;
|
||
Vector2 size = rect.sizeDelta;
|
||
|
||
float leftEdge = pos.x - size.x * 0.5f;
|
||
float rightEdge = pos.x + size.x * 0.5f;
|
||
float topExtent = pos.y + size.y * 0.5f;
|
||
float bottomExtent = -(pos.y - size.y * 0.5f);
|
||
|
||
if (leftEdge < minLeft) minLeft = leftEdge;
|
||
if (rightEdge > maxRight) maxRight = rightEdge;
|
||
if (topExtent > maxVertical) maxVertical = topExtent;
|
||
if (bottomExtent > maxVertical) maxVertical = bottomExtent;
|
||
}
|
||
|
||
// 用真实最左边缘归一化所有 Block。这样 gridColumn = 0 不再要求作者预先知道
|
||
// 最宽 Prefab 的一半宽度;未来加入更宽的 Song / Tutorial Block 也会自动留出左侧空间。
|
||
// helperReservedLeftSpace 额外为左侧常驻 Helper 保留可视区域,避免第一列被遮挡。
|
||
float targetLeftEdge = marginLeft + helperReservedLeftSpace;
|
||
float horizontalCorrection = targetLeftEdge - minLeft;
|
||
if (!Mathf.Approximately(horizontalCorrection, 0f))
|
||
{
|
||
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);
|
||
|
||
content.sizeDelta = new Vector2(width, height);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重算所有连接线曲线(在 block 位置确定后调用)。
|
||
/// </summary>
|
||
public void RefreshConnectors()
|
||
{
|
||
Observable.NextFrame().Subscribe(_ =>
|
||
{
|
||
foreach (BlockConnectorView connector in connectors)
|
||
{
|
||
connector.SetCurve();
|
||
}
|
||
|
||
// 同一帧中 Content 的尺寸与 Block 的世界坐标已稳定,刷新 Timeline 的首次对齐位置。
|
||
storyTimeline?.RefreshMarkerPositions();
|
||
}).AddTo(this);
|
||
}
|
||
|
||
// ── Helpers / Debug ─────────────────────────────────────────────────────
|
||
|
||
public StoryBlockView GetBlockView(string blockId) =>
|
||
blocks.FirstOrDefault(b => b.blockId == blockId);
|
||
|
||
/// <summary>
|
||
/// 为 Timeline、Yarn 和调试工具提供当前章节静态 Block 定义的查询入口。
|
||
/// </summary>
|
||
public StoryBlockDefinition GetBlockDefinition(string blockId) => _storyData?.GetBlock(blockId);
|
||
|
||
private void ClearTree()
|
||
{
|
||
storyTimeline?.Clear();
|
||
|
||
foreach (StoryBlockView block in blocks)
|
||
if (block != null) Destroy(block.gameObject);
|
||
blocks.Clear();
|
||
|
||
foreach (BlockConnectorView connector in connectors)
|
||
if (connector != null) Destroy(connector.gameObject);
|
||
connectors.Clear();
|
||
|
||
_layoutHorizontalOffset = 0f;
|
||
currentBlock = null;
|
||
|
||
if (content != null)
|
||
content.sizeDelta = Vector2.zero;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 编辑器测试用:以当前章节重新构建故事树。
|
||
/// </summary>
|
||
[Button]
|
||
public void DebugBuildCurrentChapter()
|
||
{
|
||
if (ChapterSelectionManager.instance != null &&
|
||
ChapterSelectionManager.instance.currentChapter != null)
|
||
{
|
||
BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[StoryTreeController] 当前没有选中的章节。");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 编辑器测试用:模拟完成当前选中的 Block,用于快速验证解锁重算与存档。
|
||
/// </summary>
|
||
[Button]
|
||
public void DebugCompleteCurrentBlock()
|
||
{
|
||
if (currentBlock == null)
|
||
{
|
||
Debug.LogWarning("[StoryTreeController] currentBlock 为空,请先点击一个 block。");
|
||
return;
|
||
}
|
||
|
||
OnBlockCompleted(currentBlock.blockId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 编辑器测试用:清空当前章节进度并重建(全部回到未完成的初始推导状态)。
|
||
/// </summary>
|
||
[Button]
|
||
public void DebugResetChapterProgress()
|
||
{
|
||
if (string.IsNullOrEmpty(_chapterIndex))
|
||
{
|
||
Debug.LogWarning("[StoryTreeController] 尚未构建任何章节。");
|
||
return;
|
||
}
|
||
|
||
// 章节变量、Yarn 选项和 Marker 快照都属于当前章节;调试重置必须一并清空,
|
||
// 才不会出现“Block 未完成但分支/教程变量仍然已设置”的伪状态。
|
||
_completed.Clear();
|
||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||
save.completedBlockIds.Clear();
|
||
save.selectedChoices.Clear();
|
||
save.storyVariables = new StoryVariablesSave();
|
||
save.markerRollbackSnapshots.Clear();
|
||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||
BuildChapter(_chapterIndex);
|
||
}
|
||
}
|
||
}
|