542 lines
22 KiB
C#
542 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace AibisDream.FrameAnimation
|
|
{
|
|
public enum FrameAnimationValidationSeverity
|
|
{
|
|
Info,
|
|
Warning,
|
|
Error
|
|
}
|
|
|
|
public enum FrameAnimationValidationTargetType
|
|
{
|
|
Graph,
|
|
Clip,
|
|
Node,
|
|
Edge,
|
|
Flow,
|
|
ImportSource
|
|
}
|
|
|
|
public enum FrameAnimationValidationCode
|
|
{
|
|
GraphMissing,
|
|
PlayableIdEmpty,
|
|
PlayableIdDuplicate,
|
|
ClipMissing,
|
|
ClipFramesEmpty,
|
|
FrameMissing,
|
|
FrameDurationInvalid,
|
|
SpeedInvalid,
|
|
InternalIdInvalid,
|
|
InternalIdDuplicate,
|
|
NodeClipMissing,
|
|
EdgeNodeMissing,
|
|
EdgeConditionInvalid,
|
|
EdgeMultiplicityInvalid,
|
|
EdgeSelfReference,
|
|
EdgeCycle,
|
|
NodeTerminalHasSuccessor,
|
|
FlowEntryMissing,
|
|
FlowEntryDuplicate,
|
|
DefaultPlayableInvalid,
|
|
NodeUnused,
|
|
ImportSourceReferenceMissing,
|
|
ImportedClipMissingFromSource
|
|
}
|
|
|
|
public sealed class FrameAnimationValidationIssue
|
|
{
|
|
public FrameAnimationValidationSeverity Severity { get; }
|
|
public FrameAnimationValidationCode Code { get; }
|
|
public FrameAnimationValidationTargetType TargetType { get; }
|
|
public string TargetId { get; }
|
|
public string Message { get; }
|
|
|
|
internal FrameAnimationValidationIssue(
|
|
FrameAnimationValidationSeverity severity,
|
|
FrameAnimationValidationCode code,
|
|
FrameAnimationValidationTargetType targetType,
|
|
string targetId,
|
|
string message)
|
|
{
|
|
Severity = severity;
|
|
Code = code;
|
|
TargetType = targetType;
|
|
TargetId = targetId ?? string.Empty;
|
|
Message = message ?? string.Empty;
|
|
}
|
|
}
|
|
|
|
public sealed class FrameAnimationValidationReport
|
|
{
|
|
private readonly List<FrameAnimationValidationIssue> issues = new List<FrameAnimationValidationIssue>();
|
|
|
|
public IReadOnlyList<FrameAnimationValidationIssue> Issues => issues;
|
|
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
|
|
|
|
internal void Add(
|
|
FrameAnimationValidationSeverity severity,
|
|
FrameAnimationValidationCode code,
|
|
FrameAnimationValidationTargetType targetType,
|
|
string targetId,
|
|
string message)
|
|
{
|
|
issues.Add(new FrameAnimationValidationIssue(severity, code, targetType, targetId, message));
|
|
}
|
|
}
|
|
|
|
public static class FrameAnimationGraphValidator
|
|
{
|
|
public static FrameAnimationValidationReport Validate(FrameAnimationGraph graph)
|
|
{
|
|
var report = new FrameAnimationValidationReport();
|
|
if (graph == null)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.GraphMissing,
|
|
FrameAnimationValidationTargetType.Graph,
|
|
string.Empty,
|
|
"FrameAnimationGraph 为空。");
|
|
return report;
|
|
}
|
|
|
|
ValidatePlayableIds(graph, report);
|
|
ValidateClips(graph, report);
|
|
ValidateImportAssociations(graph, report);
|
|
ValidateInternalIds(graph.Nodes, node => node?.InternalId, FrameAnimationValidationTargetType.Node, report);
|
|
ValidateInternalIds(graph.Edges, edge => edge?.InternalId, FrameAnimationValidationTargetType.Edge, report);
|
|
ValidateInternalIds(
|
|
graph.ImportSources,
|
|
source => source?.InternalId,
|
|
FrameAnimationValidationTargetType.ImportSource,
|
|
report);
|
|
ValidateNodesAndEdges(graph, report);
|
|
ValidateFlows(graph, report);
|
|
ValidateDefaultPlayable(graph, report);
|
|
ValidateUnusedNodes(graph, report);
|
|
return report;
|
|
}
|
|
|
|
private static void ValidateImportAssociations(
|
|
FrameAnimationGraph graph,
|
|
FrameAnimationValidationReport report)
|
|
{
|
|
var sourceIds = new HashSet<string>(
|
|
graph.ImportSources
|
|
.Where(source => source != null)
|
|
.Select(source => source.InternalId));
|
|
|
|
foreach (var clip in graph.Clips.Where(clip => clip != null && clip.IsImported))
|
|
{
|
|
var importInfo = clip.ImportInfo;
|
|
if (importInfo == null ||
|
|
string.IsNullOrWhiteSpace(importInfo.ImportSourceId) ||
|
|
!sourceIds.Contains(importInfo.ImportSourceId))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.ImportSourceReferenceMissing,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
clip.Id,
|
|
$"Imported Clip '{clip.Id}' 没有指向本 Graph 中有效的 ImportSource。");
|
|
continue;
|
|
}
|
|
|
|
if (importInfo.IsMissingFromSource)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.ImportedClipMissingFromSource,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
clip.Id,
|
|
$"Imported Clip '{clip.Id}' 对应的源 Tag '{importInfo.SourceTagName}' 已消失。");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidatePlayableIds(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
var entries = new List<(string id, FrameAnimationValidationTargetType type)>();
|
|
foreach (var clip in graph.Clips)
|
|
{
|
|
if (clip == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(clip.Id))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.PlayableIdEmpty,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
string.Empty,
|
|
"FrameClip playable id 不能为空。");
|
|
}
|
|
else
|
|
{
|
|
entries.Add((clip.Id, FrameAnimationValidationTargetType.Clip));
|
|
}
|
|
}
|
|
|
|
foreach (var flow in graph.Flows)
|
|
{
|
|
if (flow == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(flow.Id))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.PlayableIdEmpty,
|
|
FrameAnimationValidationTargetType.Flow,
|
|
string.Empty,
|
|
"AnimationFlow playable id 不能为空。");
|
|
}
|
|
else
|
|
{
|
|
entries.Add((flow.Id, FrameAnimationValidationTargetType.Flow));
|
|
}
|
|
}
|
|
|
|
foreach (var group in entries.GroupBy(entry => entry.id).Where(group => group.Count() > 1))
|
|
{
|
|
foreach (var entry in group)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.PlayableIdDuplicate,
|
|
entry.type,
|
|
group.Key,
|
|
$"playable id '{group.Key}' 在 Clip / Flow 统一命名空间中重复。");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateClips(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
for (var clipIndex = 0; clipIndex < graph.Clips.Count; clipIndex++)
|
|
{
|
|
var clip = graph.Clips[clipIndex];
|
|
if (clip == null)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.ClipMissing,
|
|
FrameAnimationValidationTargetType.Graph,
|
|
graph.Id,
|
|
$"Graph clips[{clipIndex}] 引用了空 Clip。");
|
|
continue;
|
|
}
|
|
|
|
if (!FrameAnimationValueUtility.IsValidSpeed(clip.Speed))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.SpeedInvalid,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
clip.Id,
|
|
$"Clip '{clip.Id}' 的 speed 必须是有限且不小于 0 的数值。");
|
|
}
|
|
|
|
if (clip.FrameCount == 0)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.ClipFramesEmpty,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
clip.Id,
|
|
$"Clip '{clip.Id}' 没有任何 Frame。");
|
|
continue;
|
|
}
|
|
|
|
for (var frameIndex = 0; frameIndex < clip.Frames.Count; frameIndex++)
|
|
{
|
|
var frame = clip.Frames[frameIndex];
|
|
if (frame == null)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.FrameMissing,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
clip.Id,
|
|
$"Clip '{clip.Id}' 的第 {frameIndex} 帧为空数据对象。");
|
|
}
|
|
else if (frame.DurationMs <= 0)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.FrameDurationInvalid,
|
|
FrameAnimationValidationTargetType.Clip,
|
|
clip.Id,
|
|
$"Clip '{clip.Id}' 的第 {frameIndex} 帧 durationMs 必须大于 0。");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateInternalIds<T>(
|
|
IReadOnlyList<T> items,
|
|
Func<T, string> idSelector,
|
|
FrameAnimationValidationTargetType targetType,
|
|
FrameAnimationValidationReport report)
|
|
{
|
|
var validIds = new List<string>();
|
|
foreach (var item in items)
|
|
{
|
|
var id = idSelector(item);
|
|
if (!FrameAnimationValueUtility.IsValidInternalId(id))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.InternalIdInvalid,
|
|
targetType,
|
|
id,
|
|
$"{targetType} internalId 必须是不可变的 N 格式 GUID。");
|
|
}
|
|
else
|
|
{
|
|
validIds.Add(id);
|
|
}
|
|
}
|
|
|
|
foreach (var duplicate in validIds.GroupBy(id => id).Where(group => group.Count() > 1))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.InternalIdDuplicate,
|
|
targetType,
|
|
duplicate.Key,
|
|
$"{targetType} internalId '{duplicate.Key}' 重复。");
|
|
}
|
|
}
|
|
|
|
private static void ValidateNodesAndEdges(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
var nodeCounts = graph.Nodes
|
|
.Where(node => node != null)
|
|
.GroupBy(node => node.InternalId ?? string.Empty)
|
|
.ToDictionary(group => group.Key, group => group.Count());
|
|
var outgoing = graph.Edges
|
|
.Where(edge => edge != null)
|
|
.GroupBy(edge => edge.FromNodeId ?? string.Empty)
|
|
.ToDictionary(group => group.Key, group => group.ToList());
|
|
|
|
foreach (var node in graph.Nodes)
|
|
{
|
|
if (node == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (node.Type != AnimationNodeType.Clip)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.NodeClipMissing,
|
|
FrameAnimationValidationTargetType.Node,
|
|
node.InternalId,
|
|
$"Node '{node.InternalId}' 使用了第一版不支持的节点类型。");
|
|
}
|
|
|
|
var clipMatches = graph.Clips.Count(clip => clip != null && clip.Id == node.ClipId);
|
|
if (clipMatches != 1)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.NodeClipMissing,
|
|
FrameAnimationValidationTargetType.Node,
|
|
node.InternalId,
|
|
$"Node '{node.InternalId}' 的 clipId '{node.ClipId}' 必须唯一指向一个 Clip。");
|
|
}
|
|
|
|
if (node.SpeedOverride.HasValue &&
|
|
!FrameAnimationValueUtility.IsValidSpeed(node.SpeedOverride.Value))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.SpeedInvalid,
|
|
FrameAnimationValidationTargetType.Node,
|
|
node.InternalId,
|
|
$"Node '{node.InternalId}' 的 speedOverride 必须是有限且不小于 0 的数值。");
|
|
}
|
|
|
|
var outgoingCount = outgoing.TryGetValue(node.InternalId ?? string.Empty, out var edges)
|
|
? edges.Count
|
|
: 0;
|
|
if (outgoingCount > 1)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.EdgeMultiplicityInvalid,
|
|
FrameAnimationValidationTargetType.Node,
|
|
node.InternalId,
|
|
$"Node '{node.InternalId}' 第一版最多只能有一个后继 Edge。");
|
|
}
|
|
|
|
if (node.EndBehaviorOverride.HasValue && outgoingCount > 0)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.NodeTerminalHasSuccessor,
|
|
FrameAnimationValidationTargetType.Node,
|
|
node.InternalId,
|
|
$"Node '{node.InternalId}' 已设置终点结束行为,不能同时连接后继 Edge。");
|
|
}
|
|
}
|
|
|
|
foreach (var edge in graph.Edges)
|
|
{
|
|
if (edge == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var fromValid = nodeCounts.TryGetValue(edge.FromNodeId ?? string.Empty, out var fromCount) && fromCount == 1;
|
|
var toValid = nodeCounts.TryGetValue(edge.ToNodeId ?? string.Empty, out var toCount) && toCount == 1;
|
|
if (!fromValid || !toValid)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.EdgeNodeMissing,
|
|
FrameAnimationValidationTargetType.Edge,
|
|
edge.InternalId,
|
|
$"Edge '{edge.InternalId}' 的起点或终点没有唯一对应的 Node。");
|
|
}
|
|
|
|
if (edge.Condition != AnimationEdgeCondition.Always)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.EdgeConditionInvalid,
|
|
FrameAnimationValidationTargetType.Edge,
|
|
edge.InternalId,
|
|
$"Edge '{edge.InternalId}' 第一版只支持 Always 条件。");
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(edge.FromNodeId) && edge.FromNodeId == edge.ToNodeId)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.EdgeSelfReference,
|
|
FrameAnimationValidationTargetType.Edge,
|
|
edge.InternalId,
|
|
$"Edge '{edge.InternalId}' 不允许自连接。");
|
|
}
|
|
}
|
|
|
|
ValidateCycles(graph, report);
|
|
}
|
|
|
|
private static void ValidateCycles(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
var topology = new FrameAnimationGraphTopology(graph);
|
|
var reported = new HashSet<string>();
|
|
foreach (var edge in graph.Edges.Where(edge => edge != null && edge.FromNodeId != edge.ToNodeId))
|
|
{
|
|
if (topology.WouldCreateCycle(edge.FromNodeId, edge.ToNodeId) &&
|
|
reported.Add(edge.FromNodeId ?? string.Empty))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.EdgeCycle,
|
|
FrameAnimationValidationTargetType.Node,
|
|
edge.FromNodeId,
|
|
$"从 Node '{edge.FromNodeId}' 可达的 Edge 形成了环路。");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateFlows(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
var entryOwners = new Dictionary<string, List<string>>();
|
|
foreach (var flow in graph.Flows)
|
|
{
|
|
if (flow == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var matches = graph.Nodes.Count(node => node != null && node.InternalId == flow.EntryNodeId);
|
|
if (matches != 1)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.FlowEntryMissing,
|
|
FrameAnimationValidationTargetType.Flow,
|
|
flow.Id,
|
|
$"Flow '{flow.Id}' 的入口没有唯一对应的 Node。");
|
|
}
|
|
|
|
var entryNodeId = flow.EntryNodeId ?? string.Empty;
|
|
if (!entryOwners.TryGetValue(entryNodeId, out var owners))
|
|
{
|
|
owners = new List<string>();
|
|
entryOwners.Add(entryNodeId, owners);
|
|
}
|
|
owners.Add(flow.Id);
|
|
}
|
|
|
|
foreach (var pair in entryOwners.Where(pair => pair.Value.Count > 1))
|
|
{
|
|
foreach (var flowId in pair.Value)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.FlowEntryDuplicate,
|
|
FrameAnimationValidationTargetType.Flow,
|
|
flowId,
|
|
$"Node '{pair.Key}' 不能同时作为多个 Flow 的入口。");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateDefaultPlayable(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
var defaultId = graph.Settings?.DefaultPlayableId;
|
|
var matches = graph.Clips.Count(clip => clip != null && clip.Id == defaultId) +
|
|
graph.Flows.Count(flow => flow != null && flow.Id == defaultId);
|
|
if (string.IsNullOrWhiteSpace(defaultId) || matches != 1)
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Error,
|
|
FrameAnimationValidationCode.DefaultPlayableInvalid,
|
|
FrameAnimationValidationTargetType.Graph,
|
|
graph.Id,
|
|
"defaultPlayableId 必须唯一指向一个 Clip 或 Flow。");
|
|
}
|
|
}
|
|
|
|
private static void ValidateUnusedNodes(FrameAnimationGraph graph, FrameAnimationValidationReport report)
|
|
{
|
|
var reachable = new HashSet<string>();
|
|
var topology = new FrameAnimationGraphTopology(graph);
|
|
|
|
foreach (var flow in graph.Flows.Where(flow => flow != null))
|
|
{
|
|
foreach (var node in topology.GetReachable(flow.EntryNodeId).Nodes)
|
|
{
|
|
reachable.Add(node.InternalId ?? string.Empty);
|
|
}
|
|
}
|
|
|
|
foreach (var node in graph.Nodes.Where(node => node != null && !reachable.Contains(node.InternalId)))
|
|
{
|
|
report.Add(
|
|
FrameAnimationValidationSeverity.Warning,
|
|
FrameAnimationValidationCode.NodeUnused,
|
|
FrameAnimationValidationTargetType.Node,
|
|
node.InternalId,
|
|
$"Node '{node.InternalId}' 未被任何 Flow 使用。");
|
|
}
|
|
}
|
|
}
|
|
}
|