上一次实现玩家死亡与复活处理并检测游戏失败之后, 出现两个多人游戏中的报错, 今天来详细了解错误原因, 以及最佳的处理方式
问题分析与处理
问题1: Client上的Player移动过程中死亡, Server同步报错
上次也简单说过, 其实这是一个多人游戏中常见的"时序"问题
在服务器权威模型中, Server拥有除了InputComponent组件以外所有Node的authority, 包括所有Player节点
Player节点的生成与销毁由Server端的MultiplayerSpawner管理
当Server检测到Client的Player死亡时, 直接调用queue_free(), 释放了Server上属于Client的Player节点
而这个释放通过MultiplayerSpawner同步有一个网络延时, 在延时过程中, Client的Player还存在, InputComponent还在持续向服务器同步输入信息
Client同步释放Player节点, 而服务器接受到来自InputComponent的输入同步, 却找不到需要同步信息的节点路径, 产生报错
plaintext
时间轴 ↓
|
| 【初始状态】
| Player已生成,Client通过InputComp的Synchronizer持续同步输入到Server
|
|=====================================================================
| 【场景1:Server检测Player死亡,释放节点】
|
| [Server] ▶ 检测到对应Client的Player死亡
| [Server] ▶ 调用 queue_free() 释放 Player 节点
| (Player销毁 → 子节点InputComp同步销毁)
|
|=====================================================================
| 【场景2:Spawner同步销毁,Client仍在同步输入】
|
| [Spawner] ▶ 捕获Player销毁事件
| [Spawner] → 【网络】向Client同步 Player 销毁消息
|
| || (并行执行)
|
| [Client] → 【网络】通过InputComp的Synchronizer
| 持续发送输入同步信息到Server
|
|=====================================================================
| 【场景3:Client释放Player,Server收到过期输入→报错】
|
| [Client] ← 【网络】收到Spawner的Player销毁消息
| [Client] ▶ 同步释放本地 Player + InputComp 节点
|
| || (并行执行)
|
| [Server] ← 【网络】收到Client发送的 InputComp 输入同步
| [Server] × 【报错触发】
| 原因:目标InputComp/Player已被Server提前销毁,同步目标不存在
|
时间轴 ↓
多轮次游戏玩家死亡与重生的最佳实践
我们可以通过RPC先通知Client停止输入, 之后等到Client确认之后再释放Player, 但是对于这种有轮次的多人游戏, 最好的方式是不要在回合轮转期间销毁(queue_free)并重新生成(instantiate)玩家节点
因为: ++节点销毁和重建在网络同步中成本极高++ , 它会触发MultiplayerSpawner的重新广播, 重建MultiplayerSynchronizer的连接
最好的处理方式是:
- Player死亡: 禁用碰撞和输入, 速度归0, 隐藏显示
- Player重生: 重新设置Player位置, 重置Player状态数据, 启用碰撞和输入, 打开显示,
gdscript
func _player_died() -> void:
print("[peer %s] Player %s died!" % [multiplayer.get_unique_id(), input_peer_id])
velocity = Vector2.ZERO
process_mode = Node.PROCESS_MODE_DISABLED
is_dead = true
set_player_visible.rpc(false)
died.emit()
func revive(pos: Vector2) -> void:
print("[peer %s] Player %s revive!" % [multiplayer.get_unique_id(), input_peer_id])
global_position = pos
velocity = Vector2.ZERO
process_mode = Node.PROCESS_MODE_INHERIT
health_component.reset()
set_player_visible.rpc(true)
is_dead = false
在外部main.gd中检测到Player死亡时同时记录peer_id和player实例, 方便在复活时调用revive方法
问题2: 在物理回调中不允许更改/删除碰撞体
这是Godot的限制, 而且我们在碰到类似问题时, 往往调用链非常长, 不容易找到源头
在该游戏中, 这个错误发生在HurtboxComponent与HitboxComponent发生碰撞后, 进行一系列的判断, 最终更改场景, 删除了游戏场景中所有的节点(包括多个碰撞体)
注意, 后面的所有操作都是在一次碰撞事件的回调中执行的, Godot的物理引擎不希望在进行物理检测和处理时别的碰撞体还在变来变去, 还突然消失了, 因此产生报错
物理回调事件处理的最佳实践
既然物理回调中不能直接修改碰撞体, 我们可以在帧结束后操作, 比如使用call_defered调用来调用需要更改碰撞体的函数
但是这样又出现一个问题, 不容易分清后续哪个函数调用中包含碰撞体修改, 而且容易忘记
我们稍加思考, 又想到一种方案: 新建一个函数处理所有物理碰撞后的事件, 并且在物理碰撞回调中仅通过call_defered来调用这一个单独的方法, 让后续所有操作都在帧结束时执行
恭喜! 非常接近了, 这样已经能解决问题, 但是Godot引擎其实提供了一种更简单的方式实现上述效果
我们可以自定义信号连接的触发方式
gdscript
func _ready() -> void:
if is_multiplayer_authority():
area_entered.connect(_on_area_entered, CONNECT_DEFERRED)
else:
process_mode = Node.PROCESS_MODE_DISABLED
注意信号的connect的第二个参数CONNECT_DEFERRED, 很多小伙伴不知道connect还有第二个参数(我也是才知道)
CONNECT_DEFERRED表示回调函数触发时以call_defered调用
除此之外, 还有
- CONNECT_ONE_SHOT
- CONNECT_APPEND_SOURCE_OBJECT
等非常有用的模式, 可以查阅文档Object.ConnectFlags
没有报错的优雅
经过修改之后, 连续进行多轮游戏, 失败重来, 或者玩家死亡重生, 都没有一行报错, 警告都没有, 舒服
