godot-game-one 的联机模式目标是:鸿蒙设备间通过近场发现配对,进入实时协作射击。但 Godot 4 原生不支持鸿蒙,ENet 也无法做设备发现。解决方案是:大厅用 Harmony,战斗用 ENet。

双栈架构:谁负责什么

设计原则很简单:Harmony 只管发现,ENet 只管同步。

核心代码层:

1
2
3
4
5
6
7
8
9
10
11
# network_manager.gd
const TRANSPORT_ENET := "enet"
const TRANSPORT_HARMONY := "harmony"
var transport_mode: String = TRANSPORT_ENET

func create_host(port: int = DEFAULT_PORT, mode: String = TRANSPORT_ENET) -> Error:
var peer := ENetMultiplayerPeer.new()
var err = peer.create_server(port, MAX_CLIENTS) # MAX_CLIENTS = 1
multiplayer.multiplayer_peer = peer
transport_mode = mode
return err

HarmonyBridge 是 Autoload 单例,封装了鸿蒙原生插件的 Godot 调用。插件不可用时自动回退到 UDP 广播:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# harmony_bridge.gd
const CANDIDATE_SINGLETONS: Array[String] = [
"HarmonyNearbyGame",
]
var _plugin: Object = null
var _plugin_name: String = ""

func _ready() -> void:
refresh_plugin_binding()

func refresh_plugin_binding() -> bool:
_plugin = null
_plugin_name = ""
for singleton_name in CANDIDATE_SINGLETONS:
if Engine.has_singleton(singleton_name):
_plugin = Engine.get_singleton(singleton_name)
_plugin_name = singleton_name
break
# _plugin == null 时桌面调试走 UDP 回退

战斗同步:PackedFloat32Array 批量打包

进入战斗后,Harmony 完全退出,所有数据走 ENet。同步策略按频率分层:

数据 频率 可靠性 格式
实体位置 ~45Hz(0.022s 间隔) unreliable PackedFloat32Array [eid, x, y]
实体血量 10Hz unreliable PackedFloat32Array [eid, hp, max_hp]
护盾值 随位置同步 reliable PackedFloat32Array [eid, shield, max]
朝向 随位置同步 unreliable PackedFloat32Array [eid, rotation]
子弹生成 事件驱动 reliable Array [x, y, angle, damage, …]

批量打包的核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
func _batch_sync_entity_positions() -> void:
if _entities.is_empty():
return
var 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 data.size() > 0:
_rpc_sync_entity_positions.rpc(data)

用 PackedFloat32Array 而不是 Dictionary 或 Array,是因为 Float32 数组可以被 ENet 高效序列化,20Hz 同步 50 个实体的位置数据,单包只有 ~600 字节。

客户端插值:平滑网络抖动

Host 每约 22ms 发一次位置(ENTITY_SYNC_INTERVAL = 0.022),Client 不直接赋值,而是插值到目标位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# main.gd — Client 收到位置包
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

# main.gd — Client _physics_process 中插值
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)

为什么不用纯 Harmony?

两个原因:

  1. ENet 是 Godot 原生,@rpc 注解 + multiplayer API 已经把网络层抽象得非常好,不需要重造轮子
  2. Harmony 的 NSD 只做服务发现,不提供游戏级的实时同步能力,强行用它传游戏数据会非常低效

最好的架构不是最炫的,是最少改动的。Harmony 只替换了一个入口,其他全复用 Godot 原生能力。