Node.duplicate
最后同步日期:2026-04-16 | Godot 官方原文 — Node.duplicate
Node.duplicate
定义
duplicate 就像用复印机复印一份文件——你拿一个已有的节点,复制出一个一模一样的新节点。原来的节点不受任何影响,新节点是独立的"副本"。
打个比方:你在画画时画好了一个小兵角色,现在战场上需要 10 个一模一样的小兵。你当然可以重新画 10 遍,但更聪明的做法是画好一个"模板",然后用复印机复制 10 份——这就是 duplicate 干的事情。
这是 Godot 中克隆节点的核心方法,广泛用于:运行时动态生成敌人、批量复制子弹、克隆道具、复制 UI 元素等场景。
函数签名
public Node Duplicate(DuplicateFlags flags = DuplicateFlags.Groups | DuplicateFlags.Scripts | DuplicateFlags.SignalConnections | DuplicateFlags.ChildNodes)duplicate(flags: int = 15) -> Node参数说明
| 参数 | 类型 | 必需 | 说明 |
|---|---|---|---|
| flags | DuplicateFlags(C#)/ int(GDScript) | 否 | 控制复制哪些内容的标志位。默认值会复制脚本、信号连接、分组和子节点 |
DuplicateFlags 标志位详解
flags 参数决定了"复印机"到底要复制哪些东西。你可以把它想象成复印文件时的选项:要不要复印批注、要不要复印夹在里面的便利贴、要不要连同子文件夹一起复印。
| 标志位 | C# 枚举值 | GDScript 常量值 | 说明 |
|---|---|---|---|
| Scripts | DuplicateFlags.Scripts | 1 | 复制节点上挂载的脚本(默认开启) |
| Groups | DuplicateFlags.Groups | 2 | 复制节点所属的分组(默认开启) |
| SignalConnections | DuplicateFlags.SignalConnections | 4 | 复制节点的信号连接(默认开启) |
| ChildNodes | DuplicateFlags.ChildNodes | 8 | 递归复制所有子节点(默认开启) |
默认值是什么意思?
默认值 15(即 1 + 2 + 4 + 8)表示四个标志全部开启。也就是说,调用 duplicate() 不传参数时,会复制脚本、分组、信号连接以及所有子节点——是最完整的"深度复制"。
常用标志组合
| 组合 | C# 写法 | 效果 |
|---|---|---|
| 全部复制(默认) | Duplicate() 或 Duplicate(DuplicateFlags.Groups | DuplicateFlags.Scripts | DuplicateFlags.SignalConnections | DuplicateFlags.ChildNodes) | 复制脚本、分组、信号、子节点,最完整的克隆 |
| 只复制脚本 | Duplicate(DuplicateFlags.Scripts) | 只带脚本,不带分组、信号、子节点 |
| 不复制子节点 | Duplicate(DuplicateFlags.Groups | DuplicateFlags.Scripts | DuplicateFlags.SignalConnections) | 复制节点本身及其脚本、分组、信号,但不复制子节点("浅复制") |
| 什么都不复制 | Duplicate(0) | 纯粹的"空壳"复制,只复制节点属性,不带脚本、分组、信号、子节点 |
返回值
返回一个 Node 对象,即原始节点的克隆副本。
关键特性:
- 返回的副本不在场景树中——你需要手动调用
AddChild()把它挂到某个节点下面,它才会出现在游戏里。 - 副本与原始节点是完全独立的——修改副本不会影响原件,反之亦然。
- 如果
flags包含ChildNodes,所有子节点也会被递归复制。 - 返回的是
Node基类引用,C# 中可以用as或泛型方式转为具体类型。
代码示例
基础用法
最简单的用法——复制一个节点,然后把副本添加到场景树中:
假设场景树中有一个名为 TemplateEnemy 的敌人节点(带脚本、子节点等),你想克隆它:
public override void _Ready()
{
// 获取场景中已有的模板敌人节点
var template = GetNode("TemplateEnemy");
// 复制一份(使用默认 flags,完整复制)
Node clone = template.Duplicate();
// 重要:副本不会自动出现在场景树中,必须手动添加!
AddChild(clone);
// 验证:副本已经在子节点列表中
GD.Print(GetChildCount()); // 运行结果: 2(TemplateEnemy + clone)
GD.Print(clone.Name); // 运行结果: TemplateEnemy(副本默认与原件同名)
// 副本和原件是独立的
clone.Name = "ClonedEnemy";
GD.Print(template.Name); // 运行结果: TemplateEnemy(原件不受影响)
GD.Print(clone.Name); // 运行结果: ClonedEnemy
}func _ready():
# 获取场景中已有的模板敌人节点
var template = get_node("TemplateEnemy")
# 复制一份(使用默认 flags = 15,完整复制)
var clone = template.duplicate()
# 重要:副本不会自动出现在场景树中,必须手动添加!
add_child(clone)
# 验证:副本已经在子节点列表中
print(get_child_count()) # 运行结果: 2(TemplateEnemy + clone)
print(clone.name) # 运行结果: TemplateEnemy(副本默认与原件同名)
# 副本和原件是独立的
clone.name = "ClonedEnemy"
print(template.name) # 运行结果: TemplateEnemy(原件不受影响)
print(clone.name) # 运行结果: ClonedEnemy实际场景
用 duplicate 实现一个敌人刷新点:每隔一段时间从模板节点复制一个新敌人,放到随机位置。
场景树结构:
EnemySpawner(脚本挂在这里)
├── SpawnPoints
│ ├── Point1(Marker2D)
│ ├── Point2(Marker2D)
│ └── Point3(Marker2D)
└── EnemyTemplate(Sprite2D + 碰撞体 + 敌人脚本,运行时隐藏作为模板)public partial class EnemySpawner : Node2D
{
[Export] public float ExSpawnInterval = 2.0f;
[Export] public int ExMaxEnemies = 5;
private Node2D _enemyTemplate;
private Node _spawnPoints;
private Timer _spawnTimer;
private int _aliveCount = 0;
public override void _Ready()
{
_enemyTemplate = GetNode<Node2D>("EnemyTemplate");
_spawnPoints = GetNode("SpawnPoints");
// 隐藏模板节点(它只用来克隆,不直接参与游戏)
_enemyTemplate.Visible = false;
// 设置定时器
_spawnTimer = new Timer();
_spawnTimer.WaitTime = ExSpawnInterval;
_spawnTimer.Timeout += OnSpawnTimerTimeout;
AddChild(_spawnTimer);
_spawnTimer.Start();
GD.Print("敌人生成器已启动,等待刷新...");
}
private void OnSpawnTimerTimeout()
{
// 如果场上敌人已满,跳过本次刷新
if (_aliveCount >= ExMaxEnemies)
{
GD.Print("场上敌人已满,跳过刷新"); // 运行结果: 场上敌人已满,跳过刷新
return;
}
// 从模板复制一个敌人
var newEnemy = _enemyTemplate.Duplicate() as Node2D;
// 随机选择一个刷新点
int pointIndex = (int)(GD.Randi() % _spawnPoints.GetChildCount());
var spawnPoint = _spawnPoints.GetChild<Marker2D>(pointIndex);
// 设置敌人的位置和状态
newEnemy.Position = spawnPoint.Position;
newEnemy.Visible = true;
newEnemy.Name = $"Enemy_{_aliveCount}";
// 添加到场景树
AddChild(newEnemy);
_aliveCount++;
GD.Print($"敌人生成: {newEnemy.Name} 在位置 {spawnPoint.Position}");
// 运行结果: 敌人生成: Enemy_0 在位置 (150, 300)
}
}extends Node2D
@export var ex_spawn_interval: float = 2.0
@export var ex_max_enemies: int = 5
var enemy_template: Node2D
var spawn_points: Node
var _spawn_timer: Timer
var _alive_count: int = 0
func _ready():
enemy_template = get_node("EnemyTemplate")
spawn_points = get_node("SpawnPoints")
# 隐藏模板节点(它只用来克隆,不直接参与游戏)
enemy_template.visible = false
# 设置定时器
_spawn_timer = Timer.new()
_spawn_timer.wait_time = ex_spawn_interval
_spawn_timer.timeout.connect(_on_spawn_timer_timeout)
add_child(_spawn_timer)
_spawn_timer.start()
print("敌人生成器已启动,等待刷新...")
func _on_spawn_timer_timeout():
# 如果场上敌人已满,跳过本次刷新
if _alive_count >= ex_max_enemies:
print("场上敌人已满,跳过刷新") # 运行结果: 场上敌人已满,跳过刷新
return
# 从模板复制一个敌人
var new_enemy = enemy_template.duplicate() as Node2D
# 随机选择一个刷新点
var point_index = randi() % spawn_points.get_child_count()
var spawn_point = spawn_points.get_child(point_index) as Marker2D
# 设置敌人的位置和状态
new_enemy.position = spawn_point.position
new_enemy.visible = true
new_enemy.name = "Enemy_%d" % _alive_count
# 添加到场景树
add_child(new_enemy)
_alive_count += 1
print("敌人生成: %s 在位置 %s" % [new_enemy.name, spawn_point.position])
# 运行结果: 敌人生成: Enemy_0 在位置 (150, 300)进阶用法
使用不同的 flags 参数控制复制深度,以及结合对象池模式实现高效的节点复用:
public partial class BulletManager : Node2D
{
[Export] public PackedScene ExBulletScene;
[Export] public int ExPoolSize = 10;
private Node2D _bulletTemplate;
private readonly List<Node2D> _activeBullets = new();
private readonly List<Node2D> _pooledBullets = new();
public override void _Ready()
{
_bulletTemplate = GetNode<Node2D>("BulletTemplate");
_bulletTemplate.Visible = false;
// 策略一:使用默认 flags(完整复制)
// 适合子弹带有复杂子节点树和信号连接的情况
for (int i = 0; i < ExPoolSize; i++)
{
var bullet = _bulletTemplate.Duplicate() as Node2D;
_pooledBullets.Add(bullet);
}
GD.Print($"对象池已初始化,共 {_pooledBullets.Count} 颗子弹");
// 运行结果: 对象池已初始化,共 10 颗子弹
// 模拟发射一颗子弹
FireBullet(new Vector2(100, 200), new Vector2(1, 0));
GD.Print($"活跃: {_activeBullets.Count}, 池中: {_pooledBullets.Count}");
// 运行结果: 活跃: 1, 池中: 9
// 策略二:浅复制(不复制子节点)
// 如果只需要节点本身的属性,不关心它的子节点
var shallowCopy = _bulletTemplate.Duplicate(
DuplicateFlags.Scripts | DuplicateFlags.Groups
// 注意:没有 DuplicateFlags.ChildNodes,所以子节点不会被复制
) as Node2D;
AddChild(shallowCopy);
GD.Print($"浅复制子弹的子节点数: {shallowCopy.GetChildCount()}");
// 运行结果: 浅复制子弹的子节点数: 0(子节点没被复制过来)
// 策略三:空壳复制(什么都不带)
var bareCopy = _bulletTemplate.Duplicate(0) as Node2D;
AddChild(bareCopy);
GD.Print($"空壳复制 — 有脚本: {bareCopy.GetScript() != null}, 子节点数: {bareCopy.GetChildCount()}");
// 运行结果: 空壳复制 — 有脚本: False, 子节点数: 0
}
private void FireBullet(Vector2 position, Vector2 direction)
{
if (_pooledBullets.Count == 0)
{
GD.Print("对象池为空,无法发射!"); // 运行结果: 对象池为空,无法发射!
return;
}
// 从池中取出
var bullet = _pooledBullets[0];
_pooledBullets.RemoveAt(0);
// 设置位置并激活
bullet.Position = position;
bullet.Visible = true;
bullet.Name = $"ActiveBullet_{_activeBullets.Count}";
AddChild(bullet);
_activeBullets.Add(bullet);
}
}extends Node2D
@export var ex_bullet_scene: PackedScene
@export var ex_pool_size: int = 10
var bullet_template: Node2D
var _active_bullets: Array[Node2D] = []
var _pooled_bullets: Array[Node2D] = []
func _ready():
bullet_template = get_node("BulletTemplate")
bullet_template.visible = false
# 策略一:使用默认 flags(完整复制,flags = 15)
# 适合子弹带有复杂子节点树和信号连接的情况
for i in range(ex_pool_size):
var bullet = bullet_template.duplicate() as Node2D
_pooled_bullets.append(bullet)
print("对象池已初始化,共 %d 颗子弹" % _pooled_bullets.size())
# 运行结果: 对象池已初始化,共 10 颗子弹
# 模拟发射一颗子弹
fire_bullet(Vector2(100, 200), Vector2(1, 0))
print("活跃: %d, 池中: %d" % [_active_bullets.size(), _pooled_bullets.size()])
# 运行结果: 活跃: 1, 池中: 9
# 策略二:浅复制(不复制子节点)
# 如果只需要节点本身的属性,不关心它的子节点
var shallow_copy = bullet_template.duplicate(1 | 2) as Node2D
# 注意:flags = 1(Scripts) + 2(Groups),没有 8(ChildNodes)
add_child(shallow_copy)
print("浅复制子弹的子节点数: %d" % shallow_copy.get_child_count())
# 运行结果: 浅复制子弹的子节点数: 0(子节点没被复制过来)
# 策略三:空壳复制(什么都不带,flags = 0)
var bare_copy = bullet_template.duplicate(0) as Node2D
add_child(bare_copy)
print("空壳复制 — 有脚本: %s, 子节点数: %d" % [bare_copy.get_script() != null, bare_copy.get_child_count()])
# 运行结果: 空壳复制 — 有脚本: False, 子节点数: 0
func fire_bullet(position: Vector2, direction: Vector2):
if _pooled_bullets.is_empty():
print("对象池为空,无法发射!") # 运行结果: 对象池为空,无法发射!
return
# 从池中取出
var bullet = _pooled_bullets.pop_front() as Node2D
# 设置位置并激活
bullet.position = position
bullet.visible = true
bullet.name = "ActiveBullet_%d" % _active_bullets.size()
add_child(bullet)
_active_bullets.append(bullet)flags 参数是"位或"组合
flags 参数使用的是位标志(bit flags)机制,多个标志之间用 |(位或运算符)组合。C# 中可以直接用 DuplicateFlags.Scripts | DuplicateFlags.Groups 这种枚举写法;GDScript 中需要用数字 1 | 2 或者直接写相加后的结果 3。如果你只想要某几个选项,就只把它们"或"在一起。
注意事项
- 副本不会自动加入场景树:
duplicate返回的节点是一个"游离"的节点,不在任何场景树中。你必须手动调用AddChild()将其添加到某个父节点下面,它才会出现在游戏画面中并被引擎处理。这是新手最容易犯的错误——调了duplicate却忘了add_child,结果"复制了但看不见"。 - 副本与原件默认同名:克隆出来的节点和原件拥有相同的
Name。当你在同一个父节点下添加多个同名子节点时,Godot 会自动在名称后面加上@2、@3等后缀避免冲突。建议复制后立即给副本设置一个有意义的名字。 _Ready会在AddChild时触发:副本被AddChild添加到场景树后,它的_Ready()回调会被调用(前提是flags包含了Scripts)。所以副本中的_Ready初始化逻辑会正常执行。- 信号连接的复制:当
flags包含SignalConnections时,原件上通过Connect()建立的信号连接也会被复制。但要注意:如果信号连接指向的是场景树中其他节点的方法,副本的信号也会连接到同一个目标——这可能是你想要的,也可能不是。 - 不能复制已经在场景树中的节点并自动添加:
duplicate只负责"复制",不管"添加"。两步是分开的,这样设计是为了让你在添加之前有机会修改副本(比如改名字、改位置、改属性)。 - 性能提示:
duplicate是一个相对"重"的操作,尤其是包含ChildNodes标志时,引擎需要递归复制整个子节点树。如果需要频繁生成大量相同节点(比如子弹、粒子),建议预先创建一批副本作为对象池循环使用,而不是每帧都调用duplicate。 - Resource 是共享引用的:
duplicate复制的是节点树结构,但节点引用的Resource(如纹理、材质、字体等)不会被深拷贝——原件和副本会共享同一个 Resource 对象。这通常是好事(省内存),但如果你修改了副本的材质属性,原件的材质也会跟着变。如果需要独立的材质,请手动对 Resource 调用Duplicate()。 - C# 差异:C# 中方法名用 PascalCase(
Duplicate),参数类型是DuplicateFlags枚举;GDScript 中方法名用 snake_case(duplicate),参数类型是int。C# 枚举更清晰安全,GDScript 需要记住常量值或使用1 | 2 | 4 | 8这种写法。
