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 issues = new List(); public IReadOnlyList 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( 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( IReadOnlyList items, Func idSelector, FrameAnimationValidationTargetType targetType, FrameAnimationValidationReport report) { var validIds = new List(); 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(); 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>(); 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(); 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(); 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 使用。"); } } } }