Files
ichni_Official/Assets/Scripts/NewStorySystem/Tree/StoryTreeController.cs
SoulliesOfficial 810d019619 剧情+对话完善
2026-07-21 15:24:42 -04:00

770 lines
35 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.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 的 contentlocalScale 必须为 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 采用中心 PivotanchoredPosition 表示 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);
}
}
}