feat(frameAnima): 动画系统前四个阶段
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
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 使用。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user