Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Editor/DaerDContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ public void SetLayer(int index)
{
if (Controller == null) return;
var count = Controller.layers.Length;
if (count == 0) { LayerIndex = 0; return; }
LayerIndex = Mathf.Clamp(index, 0, count - 1);
// With zero layers there is nothing to show, but the path / selection / listeners
// must still be reset so the UI doesn't keep displaying a layer that no longer exists.
LayerIndex = count == 0 ? 0 : Mathf.Clamp(index, 0, count - 1);
Selection = null;
ClearBlendTreePath();
RebuildPath();
Expand Down
18 changes: 18 additions & 0 deletions Editor/Graph/AnimatorGraphView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,22 @@ void OnKeyDown(KeyDownEvent evt)
else PasteSelection();
evt.StopPropagation();
}
else if (evt.keyCode == KeyCode.D)
{
DuplicateSelectedStates();
evt.StopPropagation();
}
}

/// <summary>Duplicates the selected states in place (Ctrl+D), keeping their internal transitions.</summary>
void DuplicateSelectedStates()
{
var states = GetSelectedStates();
if (states.Count == 0) return;
var created = StateDuplicator.Duplicate(_context.CurrentStateMachine, states, new Vector2(40f, 40f));
if (created.Count == 0) return;
_sync.RequestRebuild();
_context.Select(created[0]);
}

/// <summary>Non-default transition edges (each holding ≥1 transition) in the current selection.</summary>
Expand Down Expand Up @@ -534,6 +550,8 @@ public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
stateCount > 0 ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);
evt.menu.AppendAction("Paste State(s)", _ => _sync.PasteStates(graphPosition),
StateClipboard.HasData ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);
evt.menu.AppendAction("Duplicate State(s)", _ => DuplicateSelectedStates(),
stateCount > 0 ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);
evt.menu.AppendAction("Delete", _ => DeleteCurrentSelection(),
HasDeletableSelection() ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);

Expand Down
25 changes: 15 additions & 10 deletions Editor/Graph/BlendTreeGraphView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ public void Rebuild()
var root = _context.CurrentBlendTree;
if (root == null) return;

// Two-pass: measure subtree heights, then place nodes column-by-column.
float totalHeight = MeasureSubtree(root);
BuildSubtree(root, 0, 0f, totalHeight, isFocusedRoot: true);
// Two-pass: measure subtree heights, then place nodes column-by-column. Both passes
// share path-based recursion guards so a (defensively handled) self-nested tree is
// rendered once as a leaf instead of recursing forever.
float totalHeight = MeasureSubtree(root, new HashSet<BlendTree>());
BuildSubtree(root, 0, 0f, totalHeight, isFocusedRoot: true, new HashSet<BlendTree>());

// Re-apply the current DaerD selection so the visible blue outline survives
// edit-driven rebuilds. Without this, every BlendTreePanel edit would silently
Expand All @@ -90,41 +92,43 @@ public void Rebuild()
}

/// <summary>Returns the vertical space needed to lay out <paramref name="tree"/> and its descendants.</summary>
float MeasureSubtree(BlendTree tree)
float MeasureSubtree(BlendTree tree, HashSet<BlendTree> path)
{
if (tree == null || tree.children == null || tree.children.Length == 0)
if (tree == null || tree.children == null || tree.children.Length == 0 || !path.Add(tree))
return NodeHeight + SiblingPadding;
float total = 0f;
foreach (var child in tree.children)
{
total += child.motion is BlendTree nested
? MeasureSubtree(nested)
? MeasureSubtree(nested, path)
: NodeHeight + SiblingPadding;
}
path.Remove(tree);
// The parent slot itself must be at least one row tall so a node with a single
// child still claims room for its own visual.
return Mathf.Max(total, NodeHeight + SiblingPadding);
}

void BuildSubtree(BlendTree tree, int column, float top, float allocated, bool isFocusedRoot)
void BuildSubtree(BlendTree tree, int column, float top, float allocated, bool isFocusedRoot,
HashSet<BlendTree> path)
{
float centerY = top + allocated * 0.5f;
var rootNode = BuildRootNode(tree, isFocusedRoot);
rootNode.SetPosition(new Rect(column * ColumnWidth, centerY - NodeHeight * 0.5f, 0f, 0f));

if (tree.children == null || tree.children.Length == 0) return;
if (tree.children == null || tree.children.Length == 0 || !path.Add(tree)) return;

float cursor = top;
for (int i = 0; i < tree.children.Length; i++)
{
var child = tree.children[i];
float childSlot = child.motion is BlendTree nested
? MeasureSubtree(nested)
? MeasureSubtree(nested, path)
: NodeHeight + SiblingPadding;

if (child.motion is BlendTree nestedTree)
{
BuildSubtree(nestedTree, column + 1, cursor, childSlot, isFocusedRoot: false);
BuildSubtree(nestedTree, column + 1, cursor, childSlot, isFocusedRoot: false, path);
LinkParentToChild(rootNode, _treeNodes[nestedTree]);
}
else
Expand All @@ -136,6 +140,7 @@ void BuildSubtree(BlendTree tree, int column, float top, float allocated, bool i
}
cursor += childSlot;
}
path.Remove(tree);
}

BlendTreeRootNode BuildRootNode(BlendTree tree, bool isFocusedRoot)
Expand Down
52 changes: 35 additions & 17 deletions Editor/Graph/GraphSync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ class GraphSync
readonly Dictionary<AnimatorStateMachine, SubStateMachineNode> _ssmNodes = new Dictionary<AnimatorStateMachine, SubStateMachineNode>();
readonly List<TransitionEdge> _edges = new List<TransitionEdge>();

// States / machines nested anywhere inside a top-level child sub-state machine, mapped to
// that child's node. Lets transitions targeting a deep state (common for Any State
// transitions) render as an edge to the sub-state machine, like Unity's own editor.
readonly Dictionary<AnimatorState, SubStateMachineNode> _nestedStateOwners = new Dictionary<AnimatorState, SubStateMachineNode>();
readonly Dictionary<AnimatorStateMachine, SubStateMachineNode> _nestedMachineOwners = new Dictionary<AnimatorStateMachine, SubStateMachineNode>();

SpecialNode _entryNode, _exitNode, _anyStateNode;
bool _rebuildScheduled;
int _runtimeStateHash;
Expand Down Expand Up @@ -53,6 +59,8 @@ public void Rebuild()

_stateNodes.Clear();
_ssmNodes.Clear();
_nestedStateOwners.Clear();
_nestedMachineOwners.Clear();
_edges.Clear();
foreach (var element in _graphView.graphElements.ToList())
_graphView.RemoveElement(element);
Expand Down Expand Up @@ -83,6 +91,13 @@ public void Rebuild()
var node = new SubStateMachineNode(childSm, () => _context.EnterStateMachine(childSm));
_ssmNodes[childSm] = node;
AddNode(node, child.position);

foreach (var descendant in childSm.SelfAndDescendants())
{
if (descendant != childSm) _nestedMachineOwners[descendant] = node;
foreach (var cs in descendant.states)
if (cs.state != null) _nestedStateOwners[cs.state] = node;
}
}

if (sm.defaultState != null && _stateNodes.TryGetValue(sm.defaultState, out var defaultNode))
Expand All @@ -107,6 +122,11 @@ public void Rebuild()
edge.Refresh();

RestoreSelection(capturedSelection);
// Deleting a node can leave the shared selection pointing at a destroyed object
// (the silent selection restore above never writes back to the context). Clear it
// so the inspector falls back to the overview instead of touching a dead reference.
if (_context.Selection is Object destroyed && destroyed == null)
_context.Select(null);
RefreshRuntimeHighlight();
_context.NotifyGraphRebuilt();
}
Expand All @@ -121,10 +141,17 @@ GraphNodeBase ResolveDestination(AnimatorTransitionBase transition)
{
if (transition == null) return null;
if (transition.isExit) return _exitNode;
if (transition.destinationState != null && _stateNodes.TryGetValue(transition.destinationState, out var sn))
return sn;
if (transition.destinationStateMachine != null && _ssmNodes.TryGetValue(transition.destinationStateMachine, out var mn))
return mn;
if (transition.destinationState != null)
{
if (_stateNodes.TryGetValue(transition.destinationState, out var sn)) return sn;
// Destination lives inside a child sub-state machine: draw the edge to that node.
if (_nestedStateOwners.TryGetValue(transition.destinationState, out var owner)) return owner;
}
if (transition.destinationStateMachine != null)
{
if (_ssmNodes.TryGetValue(transition.destinationStateMachine, out var mn)) return mn;
if (_nestedMachineOwners.TryGetValue(transition.destinationStateMachine, out var owner)) return owner;
}
return null;
}

Expand Down Expand Up @@ -723,26 +750,16 @@ public void SetDefaultState(AnimatorState state)
RequestRebuild();
}

static string MakeUniqueName(AnimatorStateMachine sm, string baseName)
{
var taken = new HashSet<string>();
foreach (var cs in sm.states)
if (cs.state != null) taken.Add(cs.state.name);
foreach (var cm in sm.stateMachines)
if (cm.stateMachine != null) taken.Add(cm.stateMachine.name);
if (!taken.Contains(baseName)) return baseName;
int i = 1;
while (taken.Contains(baseName + " " + i)) i++;
return baseName + " " + i;
}
static string MakeUniqueName(AnimatorStateMachine sm, string baseName) =>
StateDuplicator.MakeUniqueName(sm, baseName);

// ---- copy / paste ----------------------------------------------------

public void CopySelectedStates()
{
var states = new List<AnimatorState>();
foreach (var selectable in _graphView.selection)
if (selectable is StateNode sn)
if (selectable is StateNode sn && sn.State != null)
states.Add(sn.State);
if (states.Count == 0) return;
StateClipboard.Copy(states, s => _stateNodes.TryGetValue(s, out var node)
Expand Down Expand Up @@ -985,6 +1002,7 @@ public void HighlightParameter(string parameterName)
if (active)
foreach (var t in edge.Transitions)
{
if (t == null) continue;
foreach (var c in t.conditions)
if (c.parameter == parameterName) { used = true; break; }
if (used) break;
Expand Down
2 changes: 1 addition & 1 deletion Editor/Graph/SubStateMachineNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public SubStateMachineNode(AnimatorStateMachine stateMachine, Action onOpen)

RegisterCallback<MouseDownEvent>(evt =>
{
if (evt.clickCount == 2)
if (evt.clickCount == 2 && evt.button == 0)
{
onOpen?.Invoke();
evt.StopPropagation();
Expand Down
9 changes: 7 additions & 2 deletions Editor/Model/StateClipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,16 @@ class InternalTransition
public static bool HasData => _entries.Count > 0;
public static int Count => _entries.Count;

public static void Copy(IList<AnimatorState> states, Func<AnimatorState, Vector2> positionOf)
public static void Copy(IList<AnimatorState> sourceStates, Func<AnimatorState, Vector2> positionOf)
{
_entries.Clear();
_transitions.Clear();
if (states == null || states.Count == 0) return;
if (sourceStates == null) return;

var states = new List<AnimatorState>();
foreach (var s in sourceStates)
if (s != null) states.Add(s);
if (states.Count == 0) return;

var index = new Dictionary<AnimatorState, int>();
for (int i = 0; i < states.Count; i++)
Expand Down
73 changes: 73 additions & 0 deletions Editor/Model/StateDuplicator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;

namespace Yozolab.DaerD
{
/// <summary>
/// Duplicates states in place inside one state machine (Ctrl+D in the graph). Copies every
/// state field plus the transitions whose source and destination are both in the duplicated
/// set, without touching the shared copy/paste clipboards.
/// </summary>
static class StateDuplicator
{
/// <summary>
/// Creates a copy of each state, offset from the original, and returns the copies in the
/// same order as <paramref name="states"/>. Motions are shared by reference.
/// </summary>
public static List<AnimatorState> Duplicate(AnimatorStateMachine sm, IList<AnimatorState> states,
Vector2 offset)
{
var created = new List<AnimatorState>();
if (sm == null || states == null || states.Count == 0) return created;

var positions = new Dictionary<AnimatorState, Vector3>();
foreach (var cs in sm.states)
if (cs.state != null) positions[cs.state] = cs.position;

using (new UndoScope("Duplicate States"))
{
Undo.RegisterCompleteObjectUndo(sm, "Duplicate States");

var copyOf = new Dictionary<AnimatorState, AnimatorState>();
foreach (var source in states)
{
// Skip states that don't belong to this state machine (or were destroyed).
if (source == null || !positions.TryGetValue(source, out var position)) continue;
var copy = sm.AddState(MakeUniqueName(sm, source.name),
position + new Vector3(offset.x, offset.y, 0f));
StateMachineCloner.CopyStateFields(source, copy);
copyOf[source] = copy;
created.Add(copy);
}

foreach (var pair in copyOf)
foreach (var t in pair.Key.transitions)
if (t.destinationState != null && copyOf.TryGetValue(t.destinationState, out var destination))
{
var transition = pair.Value.AddTransition(destination);
TransitionClipboard.Apply(transition, TransitionClipboard.Capture(t));
}

if (created.Count > 0)
EditorUtility.SetDirty(sm);
}
return created;
}

/// <summary>A name not yet used by any state or sub-state machine directly inside <paramref name="sm"/>.</summary>
public static string MakeUniqueName(AnimatorStateMachine sm, string baseName)
{
var taken = new HashSet<string>();
foreach (var cs in sm.states)
if (cs.state != null) taken.Add(cs.state.name);
foreach (var cm in sm.stateMachines)
if (cm.stateMachine != null) taken.Add(cm.stateMachine.name);
if (!taken.Contains(baseName)) return baseName;
int i = 1;
while (taken.Contains(baseName + " " + i)) i++;
return baseName + " " + i;
}
}
}
11 changes: 11 additions & 0 deletions Editor/Model/StateDuplicator.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading