godot-game-one 的联机模式只支持双人协作(Host + 1 Client),但同步复杂度不低——50+ 个敌人、80+ 颗子弹、Boss 护盾、激光特效,全都要在 22ms 内同步一次。这篇文章拆解 main.gd 里 1811 行网络同步代码的核心设计。

同步频率与数据格式

1
2
3
4
5
# main.gd
const ENTITY_SYNC_INTERVAL: float = 0.022 # 位置同步间隔(~45Hz)
const ENTITY_SYNC_STRIDE: int = 3 # 位置数据步长 [eid, x, y]
const ENTITY_SHIELD_SYNC_STRIDE: int = 3 # 护盾数据步长 [eid, shield, max_shield]
const ENTITY_ROTATION_SYNC_STRIDE: int = 2 # 旋转数据步长 [eid, rotation]

所有实体数据用 PackedFloat32Array 打包——不是 Dictionary、不是 JSON,是连续的 Float32 数组。20Hz 同步 50 个实体的位置,单包只有 ~600 字节。

批量打包:一个数组塞所有实体

Host 端每 22ms 调用一次 _batch_sync_entity_positions(),把所有实体的位置打包成一个数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# main.gd
func _batch_sync_entity_positions() -> void:
if _entities.is_empty():
return
var data := PackedFloat32Array()
var shield_data := PackedFloat32Array()
var rotation_data := PackedFloat32Array()
for eid: int in _entities:
var node = _entities[eid]
if is_instance_valid(node):
data.append(eid as float)
data.append(node.global_position.x)
data.append(node.global_position.y)
# 护盾和旋转数据也一并打包
if node.has_method("get_network_shield"):
shield_data.append(eid as float)
shield_data.append(float(node.get_network_shield()))
shield_data.append(float(node.get_network_max_shield()))
if node.has_method("get_network_rotation"):
rotation_data.append(eid as float)
rotation_data.append(float(node.get_network_rotation()))
if data.size() > 0:
_rpc_sync_entity_positions.rpc(data)
if shield_data.size() > 0:
_rpc_sync_entity_shields.rpc(shield_data)
if rotation_data.size() > 0:
_rpc_sync_entity_rotations.rpc(rotation_data)

一次调用发送三个独立的 RPC:位置(高频)、护盾(中频)、旋转(低频)。每种数据用不同的步长(stride)解包。

Client 端解包:首包直接赋值,后续插值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# main.gd
@rpc("authority", "unreliable", "call_remote")
func _rpc_sync_entity_positions(data: PackedFloat32Array) -> void:
var i := 0
while i + ENTITY_SYNC_STRIDE - 1 < data.size():
var eid := int(data[i])
var x := data[i + 1]
var y := data[i + 2]
i += ENTITY_SYNC_STRIDE
if not _entities.has(eid) or not is_instance_valid(_entities[eid]):
continue
var node: Node2D = _entities[eid] as Node2D
var target_pos := Vector2(x, y)
if node.get_meta("net_sync_inited", false) == false:
node.global_position = target_pos # 首包直接赋值
node.set_meta("net_sync_inited", true)
_entity_target_positions[eid] = target_pos

关键设计:首包直接赋值(net_sync_inited 标记),后续包只更新目标位置,由 _apply_entity_interpolation 在 _physics_process 中插值。

插值:防止画面抖动

Host 每 22ms 发一次位置,如果 Client 直接赋值,画面会像幻灯片一样卡顿。插值让实体平滑移动:

1
2
3
4
5
6
7
8
9
10
11
# main.gd
func _apply_entity_interpolation(delta: float) -> void:
var alpha: float = clampf(delta * 18.0, 0.0, 1.0)
for eid in _entity_target_positions.keys():
if not _entities.has(eid):
continue
var node: Node2D = _entities[eid] as Node2D
if node == null or not is_instance_valid(node):
continue
var target: Vector2 = _entity_target_positions[eid]
node.global_position = node.global_position.lerp(target, alpha)

alpha = clampf(delta * 18.0, 0.0, 1.0) 的含义:每帧向目标位置移动 18×delta 的比例。60fps 时 alpha ≈ 0.3,意味着每帧移动当前差距的 30%——永远不会到达目标(渐近收敛),但视觉上足够平滑。

子弹同步:事件驱动,不走定时器

子弹不走定时同步,而是事件驱动——生成时一次性广播:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# main.gd
func register_bullet_spawn(pos, angle, damage, is_player, level, speed, color, owner_peer_id) -> void:
if not NetworkManager.is_online():
return
_pending_bullet_spawns.append([pos.x, pos.y, angle, damage, is_player, level, speed, color.r, color.g, color.b, owner_peer_id])

func _flush_bullet_spawns() -> void:
if _pending_bullet_spawns.is_empty():
return
var data := _pending_bullet_spawns.duplicate()
_pending_bullet_spawns.clear()
if multiplayer.is_server():
_rpc_spawn_bullets.rpc(data) # Host 广播给所有 Client
else:
_rpc_spawn_bullets.rpc_id(1, data) # Client 上报给 Host

每帧 flush 一次,把所有待发送的子弹打包成一个 Array 发出去。unreliable_ordered 保证顺序但允许丢包——丢一颗子弹比卡一帧好。

Client 端的”幽灵子弹”

Client 收到子弹数据后,创建的是”幽灵子弹”——只负责视觉表现,不触发碰撞检测:

1
2
3
# main.gd — _rpc_spawn_bullets
if not multiplayer.is_server() and bullet.has_method("set_network_ghost"):
bullet.set_network_ghost(true)

bullet.gd 中幽灵子弹跳过所有碰撞回调:

1
2
3
4
5
6
# bullet.gd
func _on_body_entered(body: Node2D) -> void:
if not GameState.game_running:
return
if NetworkManager.is_online() and _is_network_ghost:
return # 幽灵子弹不处理碰撞

Host 端的子弹是”权威子弹”,负责碰撞检测和伤害计算。Client 端的子弹只是视觉副本。

重同步机制

网络丢包可能导致 Client 的实体状态和 Host 不一致。_despawned_entity_ids 记录已被销毁的实体 ID,当 Client 收到未知实体的数据时,主动请求重同步:

1
2
3
4
5
# main.gd — _rpc_sync_entity_positions 内部
if not _entities.has(eid) or not is_instance_valid(_entities[eid]):
if not _despawned_entity_ids.has(eid):
_request_entity_snapshot_from_host() # 请求 Host 发送完整快照
continue

同步策略总结

数据类型 频率 可靠性 格式 说明
实体位置 ~45Hz unreliable PackedFloat32Array 高频低延迟
实体血量 10Hz unreliable PackedFloat32Array 低频节省带宽
护盾值 随位置同步 reliable PackedFloat32Array 只同步有护盾的实体
朝向 随位置同步 unreliable PackedFloat32Array 只同步需要朝向的实体
子弹生成 事件驱动 unreliable_ordered Array 每帧 flush 一次
Boss 阶段 事件驱动 reliable RPC 参数 阶段切换时广播

联机游戏的同步不是”把所有数据发过去”,是”把最少的数据用最合适的方式发过去”。