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

781 lines
36 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.
#if UNITY_EDITOR
using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEditor;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// StoryData 的编辑器扩展:集中存放只读 Validation 与故事树 Preview。
/// 与运行时数据查询拆分后,修改剧情结构时无需在同一文件中穿过大量 Inspector 绘制代码。
/// </summary>
public partial class StoryData
{
private enum StoryPreviewMode { Detailed, Simple }
// 编辑器 UI 临时状态(不参与序列化)
[System.NonSerialized] private StoryPreviewMode _previewMode = StoryPreviewMode.Detailed;
[System.NonSerialized] private Vector2 _previewPan;
[System.NonSerialized] private bool _isPanningPreview;
[System.NonSerialized] private bool _isPreviewExpanded;
[System.NonSerialized] private bool _requestPreviewFit;
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;
[PropertyOrder(-9)]
[Button("Validate Story Data", ButtonSizes.Medium)]
[Tooltip("只读检查当前 StoryData 的引用、ID 与必要字段,不会自动修改资产,也不会检查歌曲/谱面的导入内容。")]
private void ValidateStoryData()
{
List<string> issues = new List<string>();
HashSet<string> blockIds = new HashSet<string>();
HashSet<string> variableKeys = new HashSet<string>();
HashSet<string> markerIds = new HashSet<string>();
if (string.IsNullOrWhiteSpace(chapterIndex))
{
issues.Add("[Error] Chapter Index 为空。");
}
if (yarnProject == null)
{
issues.Add("[Warning] Yarn Project 未配置TextBlock 无法开始对应的 Yarn 节点。");
}
foreach (StoryVariableDefinition variable in initialVariables)
{
if (variable == null)
{
issues.Add("[Warning] Initial Variables 中存在空项。");
continue;
}
if (!IsValidStableKey(variable.key))
{
issues.Add($"[Warning] Initial Variable Key '{variable.key}' 为空或不符合小写英文、数字、下划线的命名规则。");
continue;
}
if (!variableKeys.Add(variable.key))
{
issues.Add($"[Error] Initial Variable Key '{variable.key}' 重复。");
}
}
foreach (StoryBlockDefinition block in blocks)
{
if (block == null)
{
issues.Add("[Warning] Blocks 中存在空项。");
continue;
}
if (string.IsNullOrWhiteSpace(block.blockId))
{
issues.Add("[Error] 存在未填写 Block ID 的 Block。");
continue;
}
if (!blockIds.Add(block.blockId))
{
issues.Add($"[Error] Block ID '{block.blockId}' 重复。");
}
}
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || string.IsNullOrWhiteSpace(block.blockId))
{
continue;
}
ValidateBlock(block, blockIds, issues);
ValidateCondition(block.unlockCondition, $"Block '{block.blockId}' Unlock Condition", blockIds, issues);
ValidateCondition(block.forbiddenCondition, $"Block '{block.blockId}' Forbidden Condition", blockIds, issues);
}
ValidateTextBlockYarnNodes(issues);
HashSet<string> reachableBlockIds = ValidateStoryGraph(blockIds, issues);
foreach (StoryTimelineMarkerDefinition marker in timelineMarkers)
{
if (marker == null)
{
issues.Add("[Warning] Timeline Markers 中存在空项。");
continue;
}
if (!IsValidStableKey(marker.markerId))
{
issues.Add($"[Error] Timeline Marker ID '{marker.markerId}' 为空或不符合命名规则。");
}
else if (!markerIds.Add(marker.markerId))
{
issues.Add($"[Error] Timeline Marker ID '{marker.markerId}' 重复。");
}
StoryBlockDefinition anchor = GetBlock(marker.anchorBlockId);
if (anchor == null)
{
issues.Add($"[Error] Timeline Marker '{marker.markerId}' 指向不存在的 Anchor Block '{marker.anchorBlockId}'。");
}
else if (anchor.blockType != StoryBlockType.Text)
{
issues.Add($"[Error] Timeline Marker '{marker.markerId}' 的 Anchor Block '{marker.anchorBlockId}' 不是 TextBlock。");
}
if (string.IsNullOrWhiteSpace(marker.labelKey))
{
issues.Add($"[Warning] Timeline Marker '{marker.markerId}' 未配置 Label Key。");
}
}
ValidateTimelineMarkerAnchors(reachableBlockIds, issues);
ValidateExclusiveRouteGroups(blockIds, issues);
ValidateLegacyYarnSyntax(issues);
if (issues.Count == 0)
{
Debug.Log($"[StoryData] '{name}' validation passed.", this);
}
else
{
// Unity Console 的折叠列表不会为单条日志中的换行自动增加行高,
// 把整份报告拼成多行字符串会导致文本重叠。摘要与每个问题分别输出,
// 既保持 Console 可读,也能让使用者逐条双击定位所属 StoryData。
Debug.LogWarning($"[StoryData] '{name}' validation found {issues.Count} issue(s). See the following entries.", this);
for (int index = 0; index < issues.Count; index++)
{
string issue = issues[index];
string message = $"[StoryData] '{name}' [{index + 1}/{issues.Count}] {issue}";
if (issue.StartsWith("[Error]"))
Debug.LogError(message, this);
else
Debug.LogWarning(message, this);
}
}
}
/// <summary>
/// 检查单个 Block 的必要配置。该方法只报告明确会导致运行时入口失效的字段,
/// 不检查歌曲或谱面导入结果,以免把内容制作流程变成阻断式校验。
/// </summary>
private static void ValidateBlock(StoryBlockDefinition block, HashSet<string> blockIds, List<string> issues)
{
HashSet<string> nextIds = new HashSet<string>();
foreach (string nextId in block.nextBlockIds)
{
if (string.IsNullOrWhiteSpace(nextId))
{
issues.Add($"[Warning] Block '{block.blockId}' 的 Next Blocks 中存在空 ID。");
}
else if (!blockIds.Contains(nextId))
{
issues.Add($"[Error] Block '{block.blockId}' 指向不存在的 Next Block '{nextId}'。");
}
else if (!nextIds.Add(nextId))
{
issues.Add($"[Warning] Block '{block.blockId}' 重复指向 Next Block '{nextId}'。");
}
}
switch (block.blockType)
{
case StoryBlockType.Text when string.IsNullOrWhiteSpace(block.yarnNodeName):
issues.Add($"[Warning] TextBlock '{block.blockId}' 未配置 Yarn Node。");
break;
case StoryBlockType.Song when string.IsNullOrWhiteSpace(block.songName):
issues.Add($"[Warning] SongBlock '{block.blockId}' 未配置 Song ID。");
break;
case StoryBlockType.Tutorial:
if (string.IsNullOrWhiteSpace(block.tutorialKey))
issues.Add($"[Warning] TutorialBlock '{block.blockId}' 未配置 Tutorial Key。");
if (string.IsNullOrWhiteSpace(block.tutorialProgressVariable))
issues.Add($"[Warning] TutorialBlock '{block.blockId}' 未配置 Progress Variable。");
if (block.tutorialDifficultySaveId < 0)
issues.Add($"[Warning] TutorialBlock '{block.blockId}' 未配置有效的 Difficulty Save ID。");
break;
}
}
/// <summary>
/// 校验 TextBlock 的 Yarn 节点是否真实存在于已编译的 YarnProject及是否被多个 Block 共用。
/// 后者并非必然错误,但通常意味着复制 Block 后忘记替换 Node因此仅报告 Warning。
/// </summary>
private void ValidateTextBlockYarnNodes(List<string> issues)
{
if (yarnProject?.Program == null)
return;
Dictionary<string, List<string>> blockIdsByNode = new Dictionary<string, List<string>>();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || block.blockType != StoryBlockType.Text || string.IsNullOrWhiteSpace(block.yarnNodeName))
continue;
if (!yarnProject.Program.Nodes.ContainsKey(block.yarnNodeName))
issues.Add($"[Error] TextBlock '{block.blockId}' 指向的 Yarn Node '{block.yarnNodeName}' 不存在于当前 Yarn Project。" );
if (!blockIdsByNode.TryGetValue(block.yarnNodeName, out List<string> referencedBlocks))
{
referencedBlocks = new List<string>();
blockIdsByNode[block.yarnNodeName] = referencedBlocks;
}
referencedBlocks.Add(block.blockId);
}
foreach (KeyValuePair<string, List<string>> pair in blockIdsByNode)
{
if (pair.Value.Count > 1)
issues.Add($"[Warning] Yarn Node '{pair.Key}' 被多个 TextBlock 共用:{string.Join(", ", pair.Value)}。确认这是有意复用,而非遗漏修改 Node。" );
}
}
/// <summary>
/// 对 StoryData 的连接图做只读的结构检查:入口、不可达 Block、环路、非向前连接与终点。
/// 这些检查不评价具体剧情质量,只定位最容易造成“永远无法游玩”或“无法结束章节”的配置问题。
/// </summary>
private HashSet<string> ValidateStoryGraph(HashSet<string> blockIds, List<string> issues)
{
Dictionary<string, List<string>> adjacency = new Dictionary<string, List<string>>();
Dictionary<string, int> indegree = new Dictionary<string, int>();
foreach (string id in blockIds)
{
adjacency[id] = new List<string>();
indegree[id] = 0;
}
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || !blockIds.Contains(block.blockId))
continue;
foreach (string nextId in block.nextBlockIds.Where(id => !string.IsNullOrWhiteSpace(id) && blockIds.Contains(id)).Distinct())
{
adjacency[block.blockId].Add(nextId);
indegree[nextId]++;
StoryBlockDefinition next = GetBlock(nextId);
if (next != null && next.gridColumn <= block.gridColumn)
issues.Add($"[Warning] Block '{block.blockId}' -> '{nextId}' 没有指向更靠后的 Column请确认这不是意外的回退连接或循环。" );
}
}
List<string> entries = indegree.Where(pair => pair.Value == 0).Select(pair => pair.Key).ToList();
if (entries.Count == 0 && blockIds.Count > 0)
issues.Add("[Error] 剧情连接图没有入口 Block所有 Block 都存在前置连接)。");
else if (entries.Count > 1)
issues.Add($"[Warning] 剧情连接图存在 {entries.Count} 个入口 Block{string.Join(", ", entries)}。确认它们均由条件或设计意图控制。" );
HashSet<string> reachable = new HashSet<string>();
Stack<string> pending = new Stack<string>(entries);
while (pending.Count > 0)
{
string current = pending.Pop();
if (!reachable.Add(current))
continue;
foreach (string next in adjacency[current])
pending.Push(next);
}
foreach (string id in blockIds.Where(id => !reachable.Contains(id)))
issues.Add($"[Warning] Block '{id}' 从任何入口都不可达;若依赖纯变量条件进入,请确认存在外部入口。" );
List<string> terminalBlocks = adjacency.Where(pair => pair.Value.Count == 0).Select(pair => pair.Key).ToList();
if (blockIds.Count > 0 && terminalBlocks.Count == 0)
issues.Add("[Warning] 剧情连接图不存在终点 Block请确认循环路线可以按预期结束。" );
HashSet<string> visiting = new HashSet<string>();
HashSet<string> visited = new HashSet<string>();
foreach (string entry in entries)
DetectCycle(entry, adjacency, visiting, visited, issues);
return reachable;
}
private static void DetectCycle(string blockId, Dictionary<string, List<string>> adjacency,
HashSet<string> visiting, HashSet<string> visited, List<string> issues)
{
if (visited.Contains(blockId))
return;
if (!visiting.Add(blockId))
{
issues.Add($"[Warning] 剧情连接图检测到包含 Block '{blockId}' 的循环。请确认该循环有明确的变量出口。" );
return;
}
foreach (string next in adjacency[blockId])
DetectCycle(next, adjacency, visiting, visited, issues);
visiting.Remove(blockId);
visited.Add(blockId);
}
/// <summary>检查多个 Marker 是否锚定到同一 Block通常表示重复配置不阻止特殊的多标签时间点。</summary>
private void ValidateTimelineMarkerAnchors(HashSet<string> reachableBlockIds, List<string> issues)
{
foreach (IGrouping<string, StoryTimelineMarkerDefinition> group in timelineMarkers
.Where(marker => marker != null && !string.IsNullOrWhiteSpace(marker.anchorBlockId))
.GroupBy(marker => marker.anchorBlockId))
{
if (group.Count() > 1)
issues.Add($"[Warning] 多个 Timeline Marker 锚定到 Block '{group.Key}'{string.Join(", ", group.Select(marker => marker.markerId))}。确认这是有意的多标签时间点。" );
if (!reachableBlockIds.Contains(group.Key))
issues.Add($"[Warning] Timeline Marker 锚定的 Block '{group.Key}' 从任何入口都不可达Marker 将永远不会按正常流程显示。" );
}
}
/// <summary>
/// 检查互斥结局的编辑期声明。由于 Forbidden Condition 可以是任意变量表达式,
/// 自检无法数学证明条件完全互斥;它只检查显式分组、引用、终点形态和最基本的禁用条件覆盖。
/// </summary>
private void ValidateExclusiveRouteGroups(HashSet<string> blockIds, List<string> issues)
{
HashSet<string> groupIds = new HashSet<string>();
foreach (StoryRouteValidationGroup group in exclusiveRouteGroups)
{
if (group == null)
{
issues.Add("[Warning] Exclusive Route Groups 中存在空项。");
continue;
}
if (!IsValidStableKey(group.groupId))
issues.Add($"[Error] Exclusive Route Group ID '{group.groupId}' 为空或不符合命名规则。" );
else if (!groupIds.Add(group.groupId))
issues.Add($"[Error] Exclusive Route Group ID '{group.groupId}' 重复。" );
if (group.terminalBlockIds == null || group.terminalBlockIds.Count < 2)
{
issues.Add($"[Warning] Exclusive Route Group '{group.groupId}' 至少应包含两个互斥终点 Block。" );
continue;
}
HashSet<string> seenTerminalIds = new HashSet<string>();
foreach (string terminalId in group.terminalBlockIds)
{
if (!seenTerminalIds.Add(terminalId))
{
issues.Add($"[Warning] Exclusive Route Group '{group.groupId}' 重复包含 Block '{terminalId}'。" );
continue;
}
StoryBlockDefinition terminal = GetBlock(terminalId);
if (terminal == null || !blockIds.Contains(terminalId))
{
issues.Add($"[Error] Exclusive Route Group '{group.groupId}' 引用了不存在的 Block '{terminalId}'。" );
continue;
}
if (terminal.nextBlockIds.Any(id => !string.IsNullOrWhiteSpace(id)))
issues.Add($"[Warning] Exclusive Route Group '{group.groupId}' 的终点 Block '{terminalId}' 仍配置了 Next Block。" );
if (terminal.forbiddenCondition == null || !terminal.forbiddenCondition.IsConfigured)
issues.Add($"[Warning] 互斥终点 Block '{terminalId}' 未配置 Forbidden Condition请人工确认其它路线完成后会禁用它。" );
}
}
}
/// <summary>
/// 侦测已废弃的 Yarn 临时变量语法。新台本应只使用项目提供的 set_* / get_* 永久变量命令;
/// C0 旧台本仍可能保留历史语法,故当前只输出 Warning不自动修改任何文本资产。
/// </summary>
private void ValidateLegacyYarnSyntax(List<string> issues)
{
if (yarnProject == null)
return;
string projectPath = AssetDatabase.GetAssetPath(yarnProject);
foreach (string path in AssetDatabase.GetDependencies(projectPath, true).Where(path => path.EndsWith(".yarn")))
{
TextAsset yarnSource = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
if (yarnSource == null)
continue;
string[] lines = yarnSource.text.Split('\n');
for (int index = 0; index < lines.Length; index++)
{
string line = lines[index].TrimStart();
if (line.StartsWith("<<declare ") || line.StartsWith("<<set $"))
issues.Add($"[Warning] {path}:{index + 1} 使用了已废弃的 Yarn 临时变量语法 '{line}'。请改用 set_* / get_* 永久变量接口。" );
}
}
}
/// <summary>
/// 遍历通用条件树,检查会造成条件无法按预期求值的空引用或不存在 Block 引用。
/// VariableCondition 只检查变量名是否为空:未在 Initial Variables 声明的变量允许由 Yarn 在运行时首次写入。
/// </summary>
private static void ValidateCondition(StoryCondition condition, string owner, HashSet<string> blockIds, List<string> issues)
{
if (condition?.root != null)
{
ValidateConditionNode(condition.root, owner, blockIds, issues);
}
}
private static void ValidateConditionNode(StoryConditionNode node, string owner, HashSet<string> blockIds, List<string> issues)
{
switch (node)
{
case AllOfCondition allOf:
if (allOf.conditions == null || allOf.conditions.Count == 0)
issues.Add($"[Warning] {owner} 的 All Of 没有子条件,会恒为 true。");
else
ValidateConditionChildren(allOf.conditions, owner, blockIds, issues);
break;
case AnyOfCondition anyOf:
if (anyOf.conditions == null || anyOf.conditions.Count == 0)
issues.Add($"[Warning] {owner} 的 Any Of 没有子条件,会恒为 false。");
else
ValidateConditionChildren(anyOf.conditions, owner, blockIds, issues);
break;
case NotCondition not:
if (not.condition == null)
issues.Add($"[Warning] {owner} 的 Not 没有子条件,会恒为 true。");
else
ValidateConditionNode(not.condition, owner, blockIds, issues);
break;
case BlockCompletedCondition blockCompleted:
if (string.IsNullOrWhiteSpace(blockCompleted.blockId) || !blockIds.Contains(blockCompleted.blockId))
issues.Add($"[Error] {owner} 引用了不存在的 Block Completed ID '{blockCompleted.blockId}'。");
break;
case VariableCondition variable when string.IsNullOrWhiteSpace(variable.variableName):
issues.Add($"[Error] {owner} 存在未填写 Variable Name 的 Variable Compare。");
break;
case null:
issues.Add($"[Warning] {owner} 包含空条件节点。");
break;
}
}
private static void ValidateConditionChildren(List<StoryConditionNode> children, string owner, HashSet<string> blockIds, List<string> issues)
{
foreach (StoryConditionNode child in children)
{
ValidateConditionNode(child, owner, blockIds, issues);
}
}
private static bool IsValidStableKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
foreach (char character in value)
{
if (!(character is >= 'a' and <= 'z') &&
!(character is >= '0' and <= '9') &&
character != '_')
{
return false;
}
}
return true;
}
/// <summary>
/// 在 Inspector 顶部绘制只读的故事树结构预览(流程图样式,连线左右相连)。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block。
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
/// </summary>
[PropertyOrder(-10)]
[OnInspectorGUI]
private void DrawStoryPreview()
{
_isPreviewExpanded = EditorGUILayout.Foldout(
_isPreviewExpanded,
new GUIContent(
"Story Tree Preview",
"只读预览:按 StoryData 的 Column / Row、连接关系和当前 Block 尺寸约定绘制。展开后可拖动平移视图。"
),
true
);
if (!_isPreviewExpanded)
{
return;
}
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("Fit View", GUILayout.Width(80f), GUILayout.Height(20f)))
_requestPreviewFit = true;
if (GUILayout.Button("Reset", GUILayout.Width(60f), 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));
// 工具栏按钮在取得最终 Preview 区域后才可执行精确 Fit用当前 Event 的热键标记延迟到这里处理。
if (_requestPreviewFit)
{
FitPreviewView(area, previewScale);
_requestPreviewFit = false;
}
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();
}
/// <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>
/// 将当前 StoryData 的真实预览边界居中到可视区域。
/// 只修改 Inspector 的临时平移量,不会修改任何 Block 的 gridColumn / gridRow。
/// </summary>
private void FitPreviewView(Rect area, float previewScale)
{
if (blocks == null || blocks.Count == 0)
{
_previewPan = Vector2.zero;
return;
}
float stepX = RuntimeColumnStep * previewScale;
float stepY = RuntimeRowStep * previewScale;
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity;
float maxY = float.NegativeInfinity;
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block))
{
continue;
}
Vector2 size = GetPreviewBlockSize(block, previewScale);
float centerX = block.gridColumn * stepX;
float centerY = block.gridRow * stepY;
minX = Mathf.Min(minX, centerX - size.x * 0.5f);
maxX = Mathf.Max(maxX, centerX + size.x * 0.5f);
minY = Mathf.Min(minY, centerY - size.y * 0.5f);
maxY = Mathf.Max(maxY, centerY + size.y * 0.5f);
}
if (float.IsPositiveInfinity(minX))
{
_previewPan = Vector2.zero;
return;
}
// DrawStoryPreview 的初始原点位于左侧 Padding + 半个最大 Block 宽度;
// 此处只补偿该基础原点与内容包围盒中心之间的差值。
float baseOriginX = PreviewPaddingLeft + ImportantBlockWidth * previewScale * 0.5f;
_previewPan = new Vector2(
area.width * 0.5f - baseOriginX - (minX + maxX) * 0.5f,
-(minY + maxY) * 0.5f
);
}
/// <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