Files
ichni_Official/Assets/Scripts/NewStorySystem/Data/StoryData.cs
SoulliesOfficial c30bb258b1 Story排版
2026-07-20 16:56:04 -04:00

310 lines
14 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 Sirenix.OdinInspector;
using UnityEngine;
using Yarn.Unity;
#if UNITY_EDITOR
using UnityEditor;
using Sirenix.Utilities.Editor;
#endif
namespace Ichni.Story
{
/// <summary>
/// 单个章节的故事树数据,以 ScriptableObject 形式存储所有 block 定义与关联资产引用。
/// 布局完全由每个 block 的 (gridColumn, gridRow) 静态决定,运行时全量展示。
/// </summary>
[CreateAssetMenu(fileName = "StoryData", menuName = "Ichni/Story/New/StoryData")]
public class StoryData : SerializedScriptableObject
{
[LabelText("Chapter Index")]
public string chapterIndex;
[LabelText("Yarn Project")]
[InfoBox("该章节对应的 YarnProject 编译资产,阶段 2 接线。")]
public YarnProject yarnProject;
[LabelText("Blocks")]
[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>
/// 根据 blockId 获取 block 定义,未找到时返回 null。
/// </summary>
public StoryBlockDefinition GetBlock(string blockId)
{
foreach (StoryBlockDefinition block in blocks)
{
if (block.blockId == blockId)
return block;
}
return null;
}
/// <summary>
/// 获取指定 blockId 的所有直接后继 block 定义。
/// </summary>
public IEnumerable<StoryBlockDefinition> GetNextBlocks(string blockId)
{
StoryBlockDefinition source = GetBlock(blockId);
if (source == null) yield break;
foreach (string nextId in source.nextBlockIds)
{
StoryBlockDefinition next = GetBlock(nextId);
if (next != null)
yield return next;
}
}
// ── Editor Preview ────────────────────────────────────────────────────────
#if UNITY_EDITOR
private enum StoryPreviewMode { Detailed, Simple }
// 编辑器 UI 临时状态(不参与序列化)
[System.NonSerialized] private StoryPreviewMode _previewMode = StoryPreviewMode.Detailed;
[System.NonSerialized] private Vector2 _previewPan;
[System.NonSerialized] private bool _isPanningPreview;
private const float PreviewViewportHeight = 380f;
private const float PreviewPaddingLeft = 24f;
// 预览与运行时共用同一套“列 / 行代表 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;
// 与 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。
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
/// </summary>
[PropertyOrder(-10)]
[OnInspectorGUI]
private void DrawStoryPreview()
{
SirenixEditorGUI.Title("Story Tree Preview", null, TextAlignment.Left, true);
if (blocks == null || blocks.Count == 0)
{
EditorGUILayout.HelpBox("尚未定义任何 block。", MessageType.Info);
return;
}
// 顶部工具条:模式切换 + 复位视图
EditorGUILayout.BeginHorizontal();
_previewMode = (StoryPreviewMode)GUILayout.Toolbar(
(int)_previewMode, new[] { "Detailed", "Simple" }, GUILayout.Height(20f));
if (GUILayout.Button("Reset View", GUILayout.Width(90f), GUILayout.Height(20f)))
_previewPan = Vector2.zero;
EditorGUILayout.EndHorizontal();
bool simple = _previewMode == StoryPreviewMode.Simple;
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));
HandlePreviewPanning(area);
// 裁剪到预览区,超出部分(平移后)不外溢到其它 Inspector 元素
GUI.BeginClip(area);
{
// (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 放置,因此 gridColumn / gridRow
// 在预览中也对应矩形中心,而不是左边缘。
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block)) continue;
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 (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
foreach (string nextId in block.nextBlockIds)
{
if (string.IsNullOrEmpty(nextId) || !cellRects.TryGetValue(nextId, out Rect toRect)) continue;
Vector3 start = new Vector3(fromRect.xMax, fromRect.center.y, 0f);
Vector3 end = new Vector3(toRect.xMin, toRect.center.y, 0f);
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.color = new Color(0.82f, 0.82f, 0.88f, 1f);
Handles.DrawAAPolyLine(simple ? 2f : 3f, start, mid1, mid2, end);
}
}
Handles.EndGUI();
// 再画 block 方块
GUIStyle labelStyle = new GUIStyle(EditorStyles.boldLabel)
{
alignment = TextAnchor.MiddleCenter,
wordWrap = true,
richText = false,
fontSize = simple ? 9 : 11,
normal = { textColor = Color.white }
};
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
EditorGUI.DrawRect(rect, GetTypeColor(block.blockType));
string label = GetPreviewLabel(block, simple, rect.height);
GUI.Label(rect, label, labelStyle);
}
}
GUI.EndClip();
EditorGUILayout.HelpBox(
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负);坐标代表 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>
private void HandlePreviewPanning(Rect area)
{
Event e = Event.current;
switch (e.type)
{
case EventType.MouseDown:
if (area.Contains(e.mousePosition))
{
_isPanningPreview = true;
e.Use();
}
break;
case EventType.MouseDrag:
if (_isPanningPreview)
{
_previewPan += e.delta;
GUI.changed = true;
e.Use();
}
break;
case EventType.MouseUp:
if (_isPanningPreview)
{
_isPanningPreview = false;
e.Use();
}
break;
}
}
/// <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>
private static Color GetTypeColor(StoryBlockType type)
{
return type switch
{
StoryBlockType.Text => new Color(0.20f, 0.42f, 0.72f, 1f), // 蓝
StoryBlockType.Song => new Color(0.62f, 0.30f, 0.66f, 1f), // 紫
StoryBlockType.Tutorial => new Color(0.28f, 0.56f, 0.36f, 1f), // 绿
_ => new Color(0.4f, 0.4f, 0.4f, 1f)
};
}
#endif
}
}