剧情+对话完善
This commit is contained in:
780
Assets/Scripts/NewStorySystem/Data/StoryData.Editor.cs
Normal file
780
Assets/Scripts/NewStorySystem/Data/StoryData.Editor.cs
Normal file
@@ -0,0 +1,780 @@
|
||||
#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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user