技术博客

MultiplayerSpawner 的节点镜像:spawn 出来的节点怎么在两端对齐

第 120 / 149 篇 · 2025-05-13 · godot · 网络同步多人游戏源码分析

七-1 到七-8 把 SceneMultiplayer 的分层、RPC 编码、属性同步都说了一遍,但有一个问题没回答:对端的场景树里本来没有这颗子弹/这个敌人,光同步属性同步什么?答案是 4.0 引入的 MultiplayerSpawner——它把"节点本身的增删"也变成了网络事件。这篇讲这个节点的镜像机制,源码在 scene/multiplayer/multiplayer_spawner.cpp(4.3 约 500 行)。

设计动机:节点生命周期不是属性

3.x 的做法是在代码里手写 rpc:权威端 spawn 时手动 rpc("spawn_bullet", pos),对端手动 load(...).instance() 再 add_child。能跑,但每个项目重复造同一套轮子,而且容易写错——忘了设名字、忘了设 authority、属性初始值没同步过去,全是对端"看得见却动不了"的灵异 bug。4.0 把这件事收进引擎:谁 add_child,对端就建节点;谁 queue_free,对端就删。声明式,靠约定而不是手写调用。

它怎么发现节点增删

Spawner 本身不轮询。它订阅的是 SceneTree 的 node_added / node_removed 两个全局信号——场景树里每挂一个节点、每摘一个节点都会广播。回调里它做两件事:检查新节点是不是挂在 spawn_path 指向的子树里,再检查这个 spawner 是不是该节点的 authority(Node::get_multiplayer_authority())。两个条件都过,才进入 spawn 流程。

这个实现有个性能上的暗示:node_added 是全局信号,工程里任何节点挂接都会触发所有 spawner 的检查,代价是一次路径前缀比较。普通规模无感,但如果你有几百个 spawner(比如每个房间实体挂一个)又高频挂接节点,这里会积出可观的开销——官方文档也建议一个场景里放少量 spawner、让它们盯较大的子树,而不是每类实体一个。

spawn 消息里到底带了什么

权威端触发 spawn 后,SceneMultiplayer 的 replication 层发出一条内部 RPC(MultiplayerSpawner 上的 _spawn_node,any_peer 调用但收到时校验 authority),载荷四个部分:

  • spawnable 序号:int,指向 spawner add_spawnable_scene() 注册的白名单下标,不是场景路径字符串——路径只在白名单里出现一次,省带宽;
  • 节点名字:StringName,对端建出来的节点必须同名,后续 synchronizer 用名字寻址;
  • 父节点路径:NodePath,相对于场景树根,定位挂到哪儿;
  • 自定义数据:默认空。如果你配了 spawn_function(4.x 里的自定义生成函数),权威端先调 _get_spawn_data() 拿到一个 Variant(一般是 Dictionary),塞进消息,对端收到后调你的 _spawn_custom(data) 用这份数据造节点——用于非 PackedScene 的程序化生成。

对端的执行顺序是:反序列化 → 从白名单取 PackedScene → instantiate()(或走自定义函数)→ add_child → 设回同名 → 然后走 normal 的 enter_tree 流程。注意最后一步:属性初值不是 spawner 管的,是挂在这颗新节点上的 MultiplayerSynchronizer 在 spawn 后紧跟一条初始状态同步(七-2 说的 delta 压缩那套,首次全量)把血量、位置这些补上。所以一个可复制的实体,标准是"PackedScene 里同时放 Spawner 配置 + Synchronizer",少了后者,对端建出来的就是默认值毛坯。

MultiplayerSpawner 镜像流程:权威端 add_child 到对端重建 权威端(服务器) add_child(子弹入树) 触发 node_added 全局信号 收集 spawn 载荷 白名单序号 + 名字 + 父路径 可靠广播 _spawn_node 走七-3 的 RPC 编码 对端(客户端) 校验 authority 非权威 spawn 直接拒 instantiate / 自定义生成 设回同名,add_child Synchronizer 初始状态 补全血量、位置等属性 约束:spawnable 白名单两端必须一致(按下标引用,不是路径);节点必须同名 仅 authority 端增删会复制;客户端在 spawn_path 下 add_child 会被拒并打警告

典型 desync 与对策

翻 issue 列表和自己踩过的坑,spawner 相关的 desync 集中在四类:

白名单不一致。spawnable 列表存在场景文件的 spawner 节点上,按下标配对。两端列表顺序不同(比如一边手抖调了 add_spawnable_scene 的顺序),序号 2 在两端指向不同场景——建出来一个"错体"。测试期用 assert 打印两边列表,上线后这玩意儿没法热修。

名字冲突。对端同名兄弟已存在时 spawn 失败,报错是一行 ERROR: Unable to retrieve spawn node。高发场景:权威端快速反复 spawn/free 同名实体(名字由 add_child 自动去重成 Bullet2、回收后又用回 Bullet),对端时序稍慢就撞上。稳妥做法是实体名由权威端显式指定全局唯一(如带上自增 id),别依赖自动命名。

客户端越权。客户端在 spawn_path 下 add_child 不会同步,只会打警告——这是安全特性不是 bug:如果客户端 spawn 的东西也能进树,等于把场景树写权限给了对端。想要客户端生成(比如客户端特效)就放到 spawner 盯不到的地方。

spawn 与 free 竞态。权威端 spawn 后同一帧 free,对端可能先收到 despawn 再收到 spawn(不同通道时序)。replication 层做了缓冲:despawn 消息带父路径,spawn 执行前检查目标节点是否已被标记删除。自己写自定义 spawn 函数时别绕开这个检查。

和 Synchronizer 的边界划在哪

记住分工:Spawner 管"有没有这个节点",Synchronizer 管"这个节点的属性是什么"。spawn 消息之后紧跟的初始状态同步是 synchronizer 的 spawn property 集(public_visibility 为 true 的 replication 配置)。所以一个实体要同步给特定客户端(七-2 的可见性),两边都受 visibility 过滤:不可见的客户端连 spawn 消息都收不到——这颗节点在对端不存在,这比"建出来再藏"干净得多。

本文基于 Godot 4.2–4.4,源码 scene/multiplayer/multiplayer_spawner.cpp、scene/multiplayer/scene_multiplayer.cpp;内部 RPC 与 authority 校验细节以 4.x 主线为准。