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

@@ -27,6 +27,16 @@ namespace Ichni.Story
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
public List<StoryBlockDefinition> blocks = new List<StoryBlockDefinition>();
/// <summary>
/// 章节顶部 Timeline 的静态标记定义。
/// 标记的横向位置始终由 <see cref="StoryTimelineMarkerDefinition.anchorBlockId"/> 指向的
/// Block 的 <c>gridColumn</c> 推导,不在这里重复保存坐标,避免剧情树与 Timeline 脱节。
/// </summary>
[LabelText("Timeline Markers")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[InfoBox("每个标记锚定到一个 TextBlock运行时使用该 Block 的 Grid Column 对齐 Timeline。TextBlock 为 Current / Completed 时显示Locked / Forbidden 时隐藏。未配置标记时,现有章节行为不变。")]
public List<StoryTimelineMarkerDefinition> timelineMarkers = new List<StoryTimelineMarkerDefinition>();
// ── Queries ─────────────────────────────────────────────────────────────
/// <summary>
@@ -70,24 +80,30 @@ namespace Ichni.Story
private const float PreviewViewportHeight = 380f;
private const float PreviewPaddingLeft = 24f;
private const float PreviewTangent = 44f;
// 详细版:较大方块,标注 id / 类型 / 标题
private const float DetailCellWidth = 150f;
private const float DetailCellHeight = 72f;
private const float DetailStepX = 210f;
private const float DetailStepY = 120f;
// 预览与运行时共用同一套“列 / 行代表 Block 中心点”的布局语义。
// 这里的尺寸直接对应当前四种 Block Prefab 的设计尺寸;若未来修改 Prefab 尺寸,必须同步更新本组常量。
private const float ImportantBlockWidth = 600f;
private const float ImportantBlockHeight = 375f;
private const float SecondaryBlockWidth = 400f;
private const float SecondaryBlockHeight = 200f;
private const float SongBlockWidth = 400f;
private const float SongBlockHeight = 100f;
private const float TutorialBlockWidth = 400f;
private const float TutorialBlockHeight = 100f;
// 简略版:很小的方块,仅标注 Block ID
private const float SimpleCellWidth = 62f;
private const float SimpleCellHeight = 26f;
private const float SimpleStepX = 92f;
private const float SimpleStepY = 42f;
// 与 StoryTreeController 默认 columnStep / rowStep 保持比例一致,
// 因而预览中的相邻列、行间距也能反映运行时的实际布局关系。
private const float RuntimeColumnStep = 720f;
private const float RuntimeRowStep = 525f;
private const float DetailedPreviewScale = 0.25f;
private const float SimplePreviewScale = 0.10f;
/// <summary>
/// 在 Inspector 顶部绘制只读的故事树结构预览(流程图样式,连线左右相连)。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block
/// 支持负数与小数坐标,(0,0) 对应左侧中部。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
/// </summary>
[PropertyOrder(-10)]
[OnInspectorGUI]
@@ -110,10 +126,9 @@ namespace Ichni.Story
EditorGUILayout.EndHorizontal();
bool simple = _previewMode == StoryPreviewMode.Simple;
float cellW = simple ? SimpleCellWidth : DetailCellWidth;
float cellH = simple ? SimpleCellHeight : DetailCellHeight;
float stepX = simple ? SimpleStepX : DetailStepX;
float stepY = simple ? SimpleStepY : DetailStepY;
float previewScale = simple ? SimplePreviewScale : DetailedPreviewScale;
float stepX = RuntimeColumnStep * previewScale;
float stepY = RuntimeRowStep * previewScale;
Rect area = GUILayoutUtility.GetRect(0f, PreviewViewportHeight, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(area, new Color(0.12f, 0.12f, 0.14f, 1f));
@@ -123,26 +138,34 @@ namespace Ichni.Story
// 裁剪到预览区,超出部分(平移后)不外溢到其它 Inspector 元素
GUI.BeginClip(area);
{
// (0,0) 网格点 = 视口左侧中线(叠加平移量)
float originX = PreviewPaddingLeft + _previewPan.x;
// (0,0) 是 Block 的逻辑中心点。为避免第一列 Important Block 被左侧裁掉,
// 初始视图预留半个最大 Block 宽度;这不改变 StoryData 的坐标语义。
float originX = PreviewPaddingLeft + ImportantBlockWidth * previewScale * 0.5f + _previewPan.x;
float originY = area.height * 0.5f + _previewPan.y;
// 计算各 block 矩形(左中对齐网格点,与运行时 pivot(0,0.5) 一致),按 blockId 索引
// 计算各 Block 的实际比例矩形。运行时同样以中心 Pivot 放置,因此 gridColumn / gridRow
// 在预览中也对应矩形中心,而不是左边缘。
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || string.IsNullOrEmpty(block.blockId)) continue;
if (!HasPreviewableBlockId(block)) continue;
float px = originX + block.gridColumn * stepX; // 列向右为正(可负、可小数)
float py = originY + block.gridRow * stepY; // 行向下为正、向上为负
cellRects[block.blockId] = new Rect(px, py - cellH * 0.5f, cellW, cellH);
Vector2 blockSize = GetPreviewBlockSize(block, previewScale);
float px = originX + block.gridColumn * stepX; // 列向右为正(可负、可小数)
float py = originY + block.gridRow * stepY; // 行向下为正、向上为负
cellRects[block.blockId] = new Rect(
px - blockSize.x * 0.5f,
py - blockSize.y * 0.5f,
blockSize.x,
blockSize.y);
}
// 先画连线:左右相连的流程图(起点右中 → 终点左中,水平切线)
// 先画连线:与 BlockConnectorView 相同,起点右中 → 以两个 Block 中心的中点列折线 → 终点左中。
// 不使用端口中点作为转折列,避免不同宽度 Block 在同一列时出现不一致的转折位置。
Handles.BeginGUI();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
foreach (string nextId in block.nextBlockIds)
{
@@ -150,11 +173,12 @@ namespace Ichni.Story
Vector3 start = new Vector3(fromRect.xMax, fromRect.center.y, 0f);
Vector3 end = new Vector3(toRect.xMin, toRect.center.y, 0f);
Vector3 startTan = start + Vector3.right * PreviewTangent;
Vector3 endTan = end + Vector3.left * PreviewTangent;
float midX = (fromRect.center.x + toRect.center.x) * 0.5f;
Vector3 mid1 = new Vector3(midX, start.y, 0f);
Vector3 mid2 = new Vector3(midX, end.y, 0f);
Handles.DrawBezier(start, end, startTan, endTan,
new Color(0.82f, 0.82f, 0.88f, 1f), null, simple ? 2f : 3f);
Handles.color = new Color(0.82f, 0.82f, 0.88f, 1f);
Handles.DrawAAPolyLine(simple ? 2f : 3f, start, mid1, mid2, end);
}
}
Handles.EndGUI();
@@ -171,22 +195,58 @@ namespace Ichni.Story
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
EditorGUI.DrawRect(rect, GetTypeColor(block.blockType));
string label = simple
? block.blockId
: $"{block.blockId}\n[{block.blockType}]\n{block.GetDisplayTitle()}";
string label = GetPreviewLabel(block, simple, rect.height);
GUI.Label(rect, label, labelStyle);
}
}
GUI.EndClip();
EditorGUILayout.HelpBox(
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负)(0,0) 位于左侧中部,支持负数与小数。",
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负);坐标代表 Block 中心点。预览按当前 Prefab 尺寸比例绘制Important 600×375、Secondary 400×200、Song / Tutorial 400×100。",
MessageType.None);
}
/// <summary>
/// 返回一个 Block 在 Inspector 预览中的尺寸。所有类型使用同一缩放比例,
/// 因而宽高比及不同 Block 之间的体积关系与运行时 Prefab 保持一致。
/// </summary>
private static Vector2 GetPreviewBlockSize(StoryBlockDefinition block, float previewScale)
{
Vector2 runtimeSize = block.blockType switch
{
StoryBlockType.Text when block.textImportance == TextBlockImportance.Important =>
new Vector2(ImportantBlockWidth, ImportantBlockHeight),
StoryBlockType.Text => new Vector2(SecondaryBlockWidth, SecondaryBlockHeight),
StoryBlockType.Song => new Vector2(SongBlockWidth, SongBlockHeight),
StoryBlockType.Tutorial => new Vector2(TutorialBlockWidth, TutorialBlockHeight),
_ => Vector2.zero
};
return runtimeSize * previewScale;
}
/// <summary>
/// 根据可用高度选择预览标签密度。Song / Tutorial 的真实比例高度较小,
/// 因此只显示 Block ID避免文字撑破矩形而掩盖实际体积关系。
/// </summary>
private static string GetPreviewLabel(StoryBlockDefinition block, bool simple, float blockHeight)
{
if (simple || blockHeight < 32f)
{
return block.blockId;
}
if (blockHeight < 60f)
{
return $"{block.blockId}\n[{block.blockType}]";
}
return $"{block.blockId}\n[{block.blockType}]\n{block.GetDisplayTitle()}";
}
/// <summary>
/// 处理预览区内的鼠标拖拽平移。
/// </summary>
@@ -220,6 +280,17 @@ namespace Ichni.Story
}
}
/// <summary>
/// 判断 Block 能否参与 Inspector 中的故事树预览。
/// 新增 Block 时Odin 会先创建一个尚未填写 blockId 的临时数据项;它还不能作为
/// Dictionary 的 Key也不应在预览中显示或连接。统一在三个预览阶段过滤避免后续
/// Draw / TryGetValue 阶段再次把空引用传入 Dictionary。
/// </summary>
private static bool HasPreviewableBlockId(StoryBlockDefinition block)
{
return block != null && !string.IsNullOrWhiteSpace(block.blockId);
}
/// <summary>
/// 预览中不同 block 类型的填充色。
/// </summary>