using System.Collections.Generic; using System.Linq; using Ichni.Story.UI; using Sirenix.OdinInspector; using UniRx; using UnityEngine; using UnityEngine.Serialization; using UnityEngine.UI; namespace Ichni.Story { /// /// 故事树控制器。从 一次性全量构建所有 block 视图与连接线, /// 布局由每个 block 的 (gridColumn, gridRow) 静态决定;每个 block 的 /// 由"已完成集合 + 解锁条件"实时推导。 /// 存档只保存已完成 block 的 id 集合。 /// 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; /// /// 重要 TextBlock 的 Prefab。保留 FormerlySerializedAs,使旧场景中的 textBlockPrefab 引用 /// 自动迁移到此字段;在新 Prefab 配置完成前,已有 Important Block 不会失去引用。 /// [FormerlySerializedAs("textBlockPrefab")] [Tooltip("对应 TextBlockImportance.Important。Prefab 自行定义实际尺寸,例如 600 × 375。")] public GameObject importantTextBlockPrefab; /// /// 次要 TextBlock 的 Prefab。由 StoryBlockDefinition.textImportance 选择; /// Prefab 自行定义实际尺寸,例如 400 × 200。 /// [Tooltip("对应 TextBlockImportance.Secondary。Prefab 自行定义实际尺寸,例如 400 × 200。")] public GameObject secondaryTextBlockPrefab; public GameObject songBlockPrefab; public GameObject tutorialBlockPrefab; public GameObject connectorPrefab; [Header("Layout Settings")] /// /// 相邻叙事列之间的固定距离。它是 StoryData.gridColumn 的坐标步距, /// 不等于也不会覆盖任何 Block Prefab 的宽度。 /// 默认值为 Important TextBlock 宽度 600 加最小横向留白 120。 /// [Tooltip("StoryData.gridColumn 的水平步距,不会改变任何 Prefab 的实际宽度。")] public float columnStep = 720f; /// /// 相邻路线行之间的固定距离。它是 StoryData.gridRow 的坐标步距, /// 不等于也不会覆盖任何 Block Prefab 的高度。 /// 默认值为 Important TextBlock 高度 375 加最小纵向留白 150。 /// [Tooltip("StoryData.gridRow 的垂直步距,不会改变任何 Prefab 的实际高度。")] public float rowStep = 525f; [Tooltip("按所有 Block 的真实边界自动归一化后的左侧留白。")] public float marginLeft = 50f; /// /// 为剧情页左侧常驻 UI(当前为 Helper)预留的额外水平空间。 /// 最左 Block 的真实左边缘会被放置在 加本值的位置; /// 默认 400,等价于在原有布局基础上将整棵剧情树向右平移 400 单位。 /// [Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1500,会让所有 Block 整体向右移动 1500。")] public float helperReservedLeftSpace = 1500f; 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 blocks = new List(); public List connectors = new List(); private string _chapterIndex; private StoryData _storyData; // 由 SetUpBackground 根据最左侧 Block 的真实边界计算。 // 未来 Timeline 在独立 UI 容器中生成 Marker 时,也必须叠加此偏移量才能与 Block 中心对齐。 private float _layoutHorizontalOffset; /// 当前已构建章节的 StoryData(供对话控制器切换 YarnProject 等使用)。 public StoryData ActiveStoryData => _storyData; /// 当前已构建章节的索引。 public string ActiveChapterIndex => _chapterIndex; /// /// 当前章节的自动横向留白偏移。它不是 StoryData 的内容,而是根据本次实例化后的 /// 真实 Prefab 尺寸计算出的运行时布局结果。 /// public float LayoutHorizontalOffset => _layoutHorizontalOffset; /// /// 获取指定叙事列在 BlockContainer 中的最终中心 X 坐标。 /// Timeline Marker 必须使用此方法,而不是自行只计算 gridColumn * columnStep, /// 才能在不同尺寸 Block 自动留白后继续与其中心对齐。 /// public float GetColumnCenterX(float gridColumn) { return _layoutHorizontalOffset + gridColumn * columnStep; } // 当前章节已完成的 block id 集合(状态推导的唯一进度来源) private readonly HashSet _completed = new HashSet(); 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 ───────────────────────────────────────────────────────────────── /// /// 构建指定章节的故事树:全量生成所有 block 与连接线,状态由存档中的已完成集合推导。 /// 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 (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); // 全量生成所有 block(位置按网格换算,状态按已完成集合 + 解锁条件推导) foreach (StoryBlockDefinition def in _storyData.blocks) GenerateBlock(def); // 全量生成所有连接线 foreach (StoryBlockDefinition def in _storyData.blocks) 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 ────────────────────────────────────────────────────────── /// /// 按定义生成一个 block 视图:位置由网格换算,状态由推导得出。 /// 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; } StoryBlockView view = Instantiate(prefab, content).GetComponent(); if (view == null) { Debug.LogError($"[StoryTreeController] 预制体 '{prefab.name}' 缺少 StoryBlockView 组件。"); return null; } view.Initialize(def, GetGridPosition(def), DeriveState(def)); blocks.Add(view); return view; } /// /// 在两个已存在的 block 之间生成连接线。 /// 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(); 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 ───────────────────────────────────────────────────── /// /// 依据“禁用条件 + 已完成集合 + 解锁条件”推导单个 Block 的状态。 /// Forbidden Condition 满足时优先显示 Forbidden;否则依次为 Completed、Current、Locked。 /// Unlock Condition 未配置时视为无条件满足(章节起始即为 Current)。 /// 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; if (def.unlockCondition == null || !def.unlockCondition.IsConfigured || def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted)) { return StoryBlockState.Current; } return StoryBlockState.Locked; } /// /// 重新推导并应用所有 block 的状态(在完成某 block 或变量变化后调用)。 /// public void RefreshAllStates() { foreach (StoryBlockView view in blocks) { StoryBlockDefinition def = _storyData.GetBlock(view.blockId); if (def == null) continue; view.ApplyState(DeriveState(def)); } storyTimeline?.RefreshMarkerStates(); } /// /// 标记某 block 完成,推导重算并保存。对话系统(阶段 2)在对话结束时调用。 /// public void OnBlockCompleted(string blockId) { if (!_completed.Add(blockId)) { // 剧情变量可能在 block 已完成后被外部系统修正;即使完成集合没有变化, // 也要重新推导依赖变量的后续 Block 状态。 RefreshAllStates(); return; } RefreshAllStates(); SaveChapter(); } private bool IsBlockCompleted(string blockId) => _completed.Contains(blockId); // 阶段 2 将改由 StoryVariableStorage 提供;此处统一经 StoryVariables 读取, // 以便后续切换到按章节保存的变量容器时不再让故事树直接依赖存档结构。 private int GetVariable(string variableName) { return StoryVariables.GetInt(variableName); } // ── Persistence ───────────────────────────────────────────────────────── /// /// 保存当前章节进度(已完成 block 集合)。选项由对话系统维护、不在此覆盖。 /// [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); } // ── Layout ──────────────────────────────────────────────────────────────── /// /// 将 Block 的静态叙事坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的预归一化中心位置。 /// 约定列中心向右为正,行中心向下为正、向上为负(anchoredPosition.y 取负)。 /// columnStep / rowStep 只定义坐标锚点的间距,Block 的真实尺寸始终由各自 Prefab 保持; /// 因而 Important、Secondary、Song、Tutorial 及未来新增类型可使用不同尺寸。 /// gridColumn / gridRow 支持负数与小数,供静态剧情排版微调;Timeline 应通过 /// 查询横向位置,以保持标记和 Block 对齐。 /// private Vector2 GetGridPosition(StoryBlockDefinition def) { float x = GetColumnCenterX(def.gridColumn); float y = -def.gridRow * rowStep; return new Vector2(x, y); } /// /// 根据所有 Block 的真实矩形边界归一化横向位置并设置 Content(BlockContainer) 尺寸。 /// Block 采用中心 Pivot:anchoredPosition 表示 Block 中心。因此会先用 /// centerX ± width / 2 求取左右边界,再把全体 Block 平移到左侧留白与 Helper 预留区之后。 /// 该步骤允许不同尺寸的 Important / Secondary / Song / Tutorial Block 共用相同的 /// 逻辑列中心,且不会因最左 Block 的宽度不同而被 Content 裁切。 /// 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); } /// /// 重算所有连接线曲线(在 block 位置确定后调用)。 /// 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); 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; if (content != null) content.sizeDelta = Vector2.zero; } /// /// 编辑器测试用:以当前章节重新构建故事树。 /// [Button] public void DebugBuildCurrentChapter() { if (ChapterSelectionManager.instance != null && ChapterSelectionManager.instance.currentChapter != null) { BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex); } else { Debug.LogWarning("[StoryTreeController] 当前没有选中的章节。"); } } /// /// 编辑器测试用:模拟完成当前选中的 block(验证解锁重算与存档),阶段 2 前用于替代对话。 /// [Button] public void DebugCompleteCurrentBlock() { if (currentBlock == null) { Debug.LogWarning("[StoryTreeController] currentBlock 为空,请先点击一个 block。"); return; } OnBlockCompleted(currentBlock.blockId); } /// /// 编辑器测试用:清空当前章节进度并重建(全部回到未完成的初始推导状态)。 /// [Button] public void DebugResetChapterProgress() { if (string.IsNullOrEmpty(_chapterIndex)) { Debug.LogWarning("[StoryTreeController] 尚未构建任何章节。"); return; } // TutorialBlock 的后续解锁由全局剧情变量驱动;只清空 completedBlockIds // 会造成“节点未完成但后续仍解锁”的错误测试结果,因此同时清除本章节声明的教程变量。 StoryProgress.ClearTutorialProgressForChapter(_storyData); _completed.Clear(); SaveChapter(); BuildChapter(_chapterIndex); } } }