Story排版

This commit is contained in:
SoulliesOfficial
2026-07-20 16:56:04 -04:00
parent 04ec368a59
commit c30bb258b1
107 changed files with 7794 additions and 969 deletions

View File

@@ -4,6 +4,8 @@ using Ichni.Story.UI;
using Sirenix.OdinInspector;
using UniRx;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace Ichni.Story
{
@@ -18,19 +20,60 @@ namespace Ichni.Story
[Header("UI References")]
[Tooltip("blocks 与 connectors 共用的单一容器(即 ScrollRect 的 contentlocalScale 必须为 1")]
public RectTransform content;
public GameObject textBlockPrefab;
[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")]
[Tooltip("网格单元格尺寸(决定相邻列/行的步距,全局统一)")]
public Vector2 cellSize = new Vector2(400f, 200f);
/// <summary>
/// 相邻叙事列之间的固定距离。它是 StoryData.gridColumn 的坐标步距,
/// 不等于也不会覆盖任何 Block Prefab 的宽度。
/// 默认值为 Important TextBlock 宽度 600 加最小横向留白 120。
/// </summary>
[Tooltip("StoryData.gridColumn 的水平步距,不会改变任何 Prefab 的实际宽度。")]
public float columnStep = 720f;
[Tooltip("相邻单元格之间的间隔(水平 x、垂直 y")]
public Vector2 cellSpacing = new Vector2(120f, 150f);
/// <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"/> 加本值的位置;
/// 默认 400等价于在原有布局基础上将整棵剧情树向右平移 400 单位。
/// </summary>
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1500会让所有 Block 整体向右移动 1500。")]
public float helperReservedLeftSpace = 1500f;
public float marginRight = 50f;
public float marginTop = 50f;
public float marginBottom = 50f;
@@ -45,12 +88,32 @@ namespace Ichni.Story
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>();
@@ -111,6 +174,10 @@ namespace Ichni.Story
// 连线在下一帧刷新(见 RefreshConnectors等待 Unity 完成 Canvas 布局,
// 使 block 端口的世界坐标稳定,避免端口坐标尚未生效时塌缩到同一点。
RefreshConnectors();
// Timeline 是故事树的固定顶部投影:必须在 Block 真实边界与 Content 留白均确定后再构建,
// 才能使用锚定 TextBlock 的最终视觉中心进行横向同步。
storyTimeline?.Build(this, _storyData, storyScrollRect);
}
// ── Generation ──────────────────────────────────────────────────────────
@@ -122,7 +189,9 @@ namespace Ichni.Story
{
GameObject prefab = def.blockType switch
{
StoryBlockType.Text => textBlockPrefab,
StoryBlockType.Text => def.textImportance == TextBlockImportance.Important
? importantTextBlockPrefab
: secondaryTextBlockPrefab,
StoryBlockType.Song => songBlockPrefab,
StoryBlockType.Tutorial => tutorialBlockPrefab,
_ => null
@@ -130,7 +199,10 @@ namespace Ichni.Story
if (prefab == null)
{
Debug.LogError($"[StoryTreeController] block 类型 {def.blockType} 未配置预制体。");
string prefabDescription = def.blockType == StoryBlockType.Text
? $"TextBlockImportance.{def.textImportance}"
: def.blockType.ToString();
Debug.LogError($"[StoryTreeController] block '{def.blockId}' 的 {prefabDescription} 未配置预制体。");
return null;
}
@@ -181,17 +253,27 @@ namespace Ichni.Story
// ── State / Unlocking ─────────────────────────────────────────────────────
/// <summary>
/// 依据"已完成集合 + 解锁条件"推导单个 block 的状态。
/// 已完成 → Completed否则满足解锁条件 → Current;否则 → Locked。
/// 解锁条件为空视为无条件满足(章节起始即为 Current
/// 依据“禁用条件 + 已完成集合 + 解锁条件推导单个 Block 的状态。
/// Forbidden Condition 满足时优先显示 Forbidden否则依次为 Completed、CurrentLocked。
/// Unlock Condition 未配置时视为无条件满足(章节起始即为 Current
/// </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;
if (def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
if (def.unlockCondition == null ||
!def.unlockCondition.IsConfigured ||
def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
{
return StoryBlockState.Current;
}
return StoryBlockState.Locked;
}
@@ -208,6 +290,8 @@ namespace Ichni.Story
view.ApplyState(DeriveState(def));
}
storyTimeline?.RefreshMarkerStates();
}
/// <summary>
@@ -229,18 +313,11 @@ namespace Ichni.Story
private bool IsBlockCompleted(string blockId) => _completed.Contains(blockId);
// 阶段 2 将改由 StoryVariableStorage 提供;此处从全局变量存档读取作占位
// 阶段 2 将改由 StoryVariableStorage 提供;此处统一经 StoryVariables 读取,
// 以便后续切换到按章节保存的变量容器时不再让故事树直接依赖存档结构。
private int GetVariable(string variableName)
{
StoryVariablesSave vars = GameSaveManager.instance.StorySaveModule.variables;
if (vars.floatVariables.TryGetValue(variableName, out float f))
return Mathf.RoundToInt(f);
if (vars.boolVariables.TryGetValue(variableName, out bool b))
return b ? 1 : 0;
return 0;
return StoryVariables.GetInt(variableName);
}
// ── Persistence ─────────────────────────────────────────────────────────
@@ -263,30 +340,38 @@ namespace Ichni.Story
// ── Layout ────────────────────────────────────────────────────────────────
/// <summary>
/// 将 block 的网格坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的 anchoredPosition
/// 约定左侧中线为原点:列向右为正行向下为正、向上为负anchoredPosition.y 取负)。
/// gridColumn / gridRow 支持负数与小数,可用于微调 block 位置。
/// 将 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 = marginLeft + def.gridColumn * (cellSize.x + cellSpacing.x);
float y = -def.gridRow * (cellSize.y + cellSpacing.y);
float x = GetColumnCenterX(def.gridColumn);
float y = -def.gridRow * rowStep;
return new Vector2(x, y);
}
/// <summary>
/// 根据 block 分布计算并设置 content(BlockContainer) 尺寸。
/// block 采用左中 pivotanchoredPosition.x 为左边缘y 相对垂直中线(上正下负)。
/// 根据所有 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 maxRight = 0f;
float minLeft = float.PositiveInfinity;
float maxRight = float.NegativeInfinity;
float maxVertical = 0f; // 距垂直中线的最大延伸(上下取较大者,用于对称扩展 content 高度)
foreach (StoryBlockView block in blocks)
@@ -295,15 +380,34 @@ namespace Ichni.Story
Vector2 pos = rect.anchoredPosition;
Vector2 size = rect.sizeDelta;
float rightEdge = pos.x + size.x;
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);
@@ -321,6 +425,9 @@ namespace Ichni.Story
{
connector.SetCurve();
}
// 同一帧中 Content 的尺寸与 Block 的世界坐标已稳定,刷新 Timeline 的首次对齐位置。
storyTimeline?.RefreshMarkerPositions();
}).AddTo(this);
}
@@ -331,6 +438,8 @@ namespace Ichni.Story
private void ClearTree()
{
storyTimeline?.Clear();
foreach (StoryBlockView block in blocks)
if (block != null) Destroy(block.gameObject);
blocks.Clear();
@@ -339,6 +448,8 @@ namespace Ichni.Story
if (connector != null) Destroy(connector.gameObject);
connectors.Clear();
_layoutHorizontalOffset = 0f;
if (content != null)
content.sizeDelta = Vector2.zero;
}