rokojori_action_library/Tools/Editor/NodeDuplicator.cs

365 lines
10 KiB
C#
Raw Normal View History

2026-10-07 08:29:15 +00:00
#if TOOLS
using Godot;
using System.Collections.Generic;
namespace Rokojori.Tools
{
using Rokojori;
// Like Node.Duplicate(), but [Export] Node/array/list members that point outside the
// duplicated subtree (parent, siblings, cousins, ...) keep pointing at the original
// external target instead of being nulled out. Members pointing inside the subtree are
// remapped to the corresponding duplicated node, same as native duplicate already does.
//
// Scene instances (nodes with a SceneFilePath, like an instanced Cyli.tscn) are not
// copied node by node, they are re-instantiated from their scene with the editor edit
// state, so the copy stays an instance of that scene instead of being flattened into
// the edited scene. Node pairing is done by relative path, never by iteration index,
// because the copy's subtree can legitimately differ from the live one in the editor.
2026-10-07 08:29:15 +00:00
public static class NodeDuplicator
{
static readonly HashSet<string> nonCopyableProperties = new HashSet<string>()
{
"name",
"owner",
"script",
"scene_file_path",
"unique_name_in_owner",
"multiplayer"
};
2026-10-07 08:29:15 +00:00
public static Node DuplicateDeep( Node original )
{
if ( original == null )
{
return null;
}
var parent = original.GetParent();
if ( parent == null )
{
original.LogError( "Duplicate Deep needs a parent node, can't duplicate the scene root." );
return null;
}
var originalNodes = GetSubtree( original );
var duplicate = CreateCopy( original );
2026-10-07 08:29:15 +00:00
if ( duplicate == null )
{
return null;
}
parent.AddChild( duplicate, true );
2026-10-07 08:29:15 +00:00
parent.MoveChild( duplicate, original.GetIndex() + 1 );
var map = MapByPath( original, originalNodes, duplicate );
AddMissingNodes( original, originalNodes, duplicate, map );
AssignOwners( original, originalNodes, map );
RemapExternalReferences( originalNodes, map );
2026-10-07 08:29:15 +00:00
duplicate.LogInfo( "Duplicated", original.Name, "as", duplicate.Name );
return duplicate;
}
static List<Node> GetSubtree( Node root )
2026-10-07 08:29:15 +00:00
{
var nodes = new List<Node>();
NodesWalker.Get().Iterate( root, n => nodes.Add( n ), false );
return nodes;
}
static Node CreateCopy( Node original )
{
var scenePath = original.SceneFilePath;
if ( string.IsNullOrEmpty( scenePath ) )
2026-10-07 08:29:15 +00:00
{
return original.Duplicate( (int) Node.DuplicateFlags.Default );
}
var scene = ResourceLoader.Load<PackedScene>( scenePath );
if ( scene == null )
{
original.LogError( "Couldn't load scene for duplication:", scenePath, "using plain duplicate." );
return original.Duplicate( (int) Node.DuplicateFlags.Default );
2026-10-07 08:29:15 +00:00
}
var copy = scene.Instantiate<Node>( PackedScene.GenEditState.Instance );
2026-10-07 08:29:15 +00:00
if ( copy == null )
2026-10-07 08:29:15 +00:00
{
original.LogError( "Couldn't instantiate scene for duplication:", scenePath );
return null;
2026-10-07 08:29:15 +00:00
}
copy.Name = original.Name;
CopyStoredProperties( original, copy );
return copy;
2026-10-07 08:29:15 +00:00
}
// An instanced scene only stores overrides on top of its scene, so the freshly
// instantiated copy has to receive the original's own property values.
static void CopyStoredProperties( Node original, Node copy )
2026-10-07 08:29:15 +00:00
{
foreach ( var propertyInfo in original.GetPropertyList() )
{
var dict = (Godot.Collections.Dictionary) propertyInfo;
if ( ! IsStored( dict ) )
{
continue;
}
2026-10-07 08:29:15 +00:00
var name = dict[ "name" ].AsString();
2026-10-07 08:29:15 +00:00
if ( string.IsNullOrEmpty( name ) || nonCopyableProperties.Contains( name ) )
{
continue;
}
copy.Set( name, original.Get( name ) );
}
}
2026-10-07 08:29:15 +00:00
// Pairs original nodes with copied nodes by their path relative to the subtree root.
// Nodes missing in the copy are left out instead of shifting all following pairs.
static Dictionary<Node,Node> MapByPath( Node originalRoot, List<Node> originalNodes, Node duplicateRoot )
{
var map = new Dictionary<Node,Node>();
foreach ( var node in originalNodes )
2026-10-07 08:29:15 +00:00
{
if ( node == originalRoot )
{
map[ node ] = duplicateRoot;
continue;
}
var counterpart = duplicateRoot.GetNodeOrNull( originalRoot.GetPathTo( node ) );
if ( counterpart == null )
{
continue;
}
map[ node ] = counterpart;
2026-10-07 08:29:15 +00:00
}
return map;
}
// Nodes that only exist in the live original, like children created at editor runtime
// by tool scripts or nodes added to a non editable instance, are not part of the
// re-instantiated copy. They get recreated at the same place so the copy matches the
// original and references to them can be remapped.
static void AddMissingNodes( Node originalRoot, List<Node> originalNodes, Node duplicateRoot, Dictionary<Node,Node> map )
{
var added = new List<string>();
var unresolved = new List<string>();
// originalNodes is in pre order, so a parent is always recreated before its children.
foreach ( var original in originalNodes )
2026-10-07 08:29:15 +00:00
{
if ( map.ContainsKey( original ) )
{
continue;
}
var relativePath = originalRoot.GetPathTo( original ).ToString();
var counterpart = duplicateRoot.GetNodeOrNull( relativePath );
if ( counterpart != null )
{
// Came in as a child of an already recreated node.
map[ original ] = counterpart;
continue;
}
var originalParent = original.GetParent();
if ( originalParent == null || ! map.TryGetValue( originalParent, out var duplicateParent ) )
{
unresolved.Add( relativePath );
continue;
}
var copy = CreateCopy( original );
if ( copy == null )
{
unresolved.Add( relativePath );
continue;
}
copy.Name = original.Name;
duplicateParent.AddChild( copy );
duplicateParent.MoveChild( copy, Mathf.Min( original.GetIndex(), duplicateParent.GetChildCount() - 1 ) );
map[ original ] = copy;
added.Add( relativePath );
}
if ( added.Count > 0 )
{
duplicateRoot.LogInfo(
"Recreated", added.Count, "nodes missing in the copy:", string.Join( ", ", added )
);
}
if ( unresolved.Count > 0 )
{
duplicateRoot.LogError(
"Couldn't recreate", unresolved.Count, "nodes, references to them are kept as is:",
string.Join( ", ", unresolved )
);
}
}
// Mirrors the original's ownership instead of owning everything, so the inner nodes
// of an instanced scene stay owned by their instance root.
static void AssignOwners( Node originalRoot, List<Node> originalNodes, Dictionary<Node,Node> map )
{
foreach ( var original in originalNodes )
{
if ( ! map.TryGetValue( original, out var duplicate ) )
{
continue;
}
var owner = original == originalRoot ? originalRoot.Owner : original.Owner;
if ( owner == null )
{
continue;
}
duplicate.Owner = Remap( owner, map );
}
}
static void RemapExternalReferences( List<Node> originalNodes, Dictionary<Node,Node> map )
{
foreach ( var original in originalNodes )
{
if ( ! map.TryGetValue( original, out var duplicate ) )
{
continue;
}
RemapNodeExports( original, duplicate, map );
2026-10-07 08:29:15 +00:00
}
}
static void RemapNodeExports( Node original, Node duplicate, Dictionary<Node, Node> map )
{
var properties = original.GetPropertyList();
foreach ( var propertyInfo in properties )
{
var dict = (Godot.Collections.Dictionary) propertyInfo;
if ( ! IsStored( dict ) )
{
continue;
}
var name = dict[ "name" ].AsString();
if ( string.IsNullOrEmpty( name ) )
{
continue;
}
var type = (Variant.Type) dict[ "type" ].AsInt32();
if ( type == Variant.Type.Object )
{
RemapSingleProperty( original, duplicate, name, map );
}
else if ( type == Variant.Type.Array )
{
RemapArrayProperty( original, duplicate, name, map );
}
}
}
static bool IsStored( Godot.Collections.Dictionary propertyInfo )
{
if ( ! propertyInfo.ContainsKey( "usage" ) )
{
return false;
}
var usage = propertyInfo[ "usage" ].AsInt64();
return ( usage & (long) PropertyUsageFlags.Storage ) != 0;
}
static void RemapSingleProperty( Node original, Node duplicate, string name, Dictionary<Node, Node> map )
{
var value = original.Get( name );
if ( value.VariantType != Variant.Type.Object )
{
return;
}
if ( value.AsGodotObject() is not Node targetNode )
{
return;
}
duplicate.Set( name, Remap( targetNode, map ) );
2026-10-07 08:29:15 +00:00
}
static void RemapArrayProperty( Node original, Node duplicate, string name, Dictionary<Node, Node> map )
{
var value = original.Get( name );
if ( value.VariantType != Variant.Type.Array )
{
return;
}
var array = value.AsGodotArray();
var containsNodes = false;
2026-10-07 08:29:15 +00:00
for ( var i = 0; i < array.Count; i++ )
{
var element = array[ i ];
if ( element.VariantType != Variant.Type.Object || element.AsGodotObject() is not Node elementNode )
{
continue;
}
array[ i ] = Remap( elementNode, map );
containsNodes = true;
2026-10-07 08:29:15 +00:00
}
if ( containsNodes )
2026-10-07 08:29:15 +00:00
{
duplicate.Set( name, array );
}
}
static Node Remap( Node originalTarget, Dictionary<Node, Node> map )
{
if ( originalTarget != null && map.TryGetValue( originalTarget, out var mapped ) )
2026-10-07 08:29:15 +00:00
{
return mapped;
}
return originalTarget;
}
}
}
#endif