Files
agame/docs/联机框架使用指南.md
2026-10-02 10:34:57 +08:00

28 KiB
Raw Permalink Blame History

联机框架使用指南

面向「完全没接触过联机」的开发者的入门文档。 读完你应该能做到三件事:跑起联机、给自己新做的功能加上同步、把传输层换成 Steam P2P。


0. 一句话总览

这个框架把玩家角色拆成两种身份:一个是「我在操作的角色」,其余都是「别人在操作的角色」。

  • 我在操作的那个:读键盘鼠标、跑物理、开相机。
  • 别人的那些:什么都不算,只是把「对面发过来的位置和朝向」照着摆一遍。

联机要做的全部事情,就是让每台机器对同一份状态达成一致。其余都是细节。


1. 先搞懂四个概念(不用懂网络)

1.1 对等端(peer)与 peer id

联机里的每一台机器叫一个 peer,各自有一个数字编号 peer id。 本项目用的是 ENet(客户端-服务器模型),所以:

角色 peer id 说明
主机(host) 1 同时是「服务器」和「一个玩家」
第 1 个加入的人 随机大数(如 1334014715)
第 2 个加入的人 另一个随机大数

注意:ENet 不是「人人平等」的 P2P,它是主机当服务器。主机退出 = 所有人掉线。 Steam 那套才是真正的 P2P(第 5 章),但上层代码完全一样。

1.2 authority(中文:权威方)

每个节点都有一个 authority,表示「谁有权说了算」。 默认是 1(服务器)。

  • is_multiplayer_authority() 返回 true → 这个节点归我管,我可以改它。
  • 返回 false → 这个节点是别人的镜像,我改了也会被对方的下一次同步覆盖回去。

这是全篇最重要的一条规则。你新增任何同步逻辑前,都要先问:

这个值该由谁来改?

1.3 复制(replication)与 RPC

两种同步手段,分工明确:

复制(Replication) RPC(远程过程调用)
适合 持续变化的状态:位置、朝向、血量、姓名 一次性事件:开了一枪、捡到东西、发一句话
机制 引擎按固定频率自动把属性值发出去 你主动调用一个函数,它在别的机器上跑
丢包 丢了无所谓,下一次同步会补上最新值 用 reliable 保证必达
本项目用在哪 Player/Sync(位置 + 朝向) 目前还没用,你加功能时才会用到

一句话记法:状态用复制,事件用 RPC。

1.4 节点路径必须一致

RPC 和复制都是靠节点路径找到对面对应的节点。所以:

  • 玩家节点在各端的名字必须一样(本项目用 str(peer_id) 当名字)。
  • 别在运行时给节点随机改名,否则对端会报 Node not found。

2. 框架由哪些文件组成

E:\GD_Projects\agame\
├── autoload\
│   ├── net.tscn            ← 联机总入口(Autoload 名:Net,指向这个场景)
│   │      Net (CanvasLayer)          ← 挂 net.gd
│   │        ├─ Menu  (CenterContainer)   ← 主菜单:标题/地址框/两个按钮/状态/提示
│   │        └─ Hud   (Control)           ← 游戏内 HUD:状态 + 操作提示
│   └── net.gd              ← 管传输层、开局/加入/断开、玩家生成回收;
│                              UI 是节点配置,这里只改文字、切显隐
├── world\
│   ├── main.tscn           ← 世界根场景
│   │      Main (Node3D)
│   │        ├─ Ground / Sun / Environment
│   │        └─ MultiplayerSpawner   ← 玩家由它动态生成,场景里不再硬编码 Player
│   └── main.gd             ← 只做一件事:告诉 Spawner「生成玩家时用 player.tscn,
│                              并把那个玩家的 authority 设成对应的 peer id」
├── player\
│   ├── player.tscn         ← 玩家场景
│   │      Player (CharacterBody3D)
│   │        ├─ Collision
│   │        ├─ CameraRig → Camera              ← 第一人称,远端玩家会被删掉这一支
│   │        ├─ Body → Mesh                      ← 视觉层,换角色模型挂这里
│   │        ├─ Sync (MultiplayerSynchronizer)   ← 负责同步位置和朝向
│   │        └─ HealthLabel (Label3D)            ← 头顶血条(3D 世界里的文字,跟着角色走)
│   ├── player.gd           ← 加了「本地 / 远端」分支
│   └── player_sync.tres    ← 同步配置:到底同步哪几个属性
└── tests\
    └── test_net_replication.gd  ← 回归测试,防止同步配置被静默改坏

数据流(创建主机 → 有人加入)

主机                             客户端
 │ 点「创建主机」                     │ 点「加入主机」
 │ ENetMultiplayerPeer.create_server │ ENetMultiplayerPeer.create_client
 │        ↓                          │        ↓
 │ multiplayer.multiplayer_peer = peer ←── 同一个多人系统,只是 peer 不同
 │        ↓                          │
 │ 服务器收到 peer_connected(id)      │
 │ Net._spawn_player(id)             │
 │        ↓                          │
 │ MultiplayerSpawner.spawn(id) ─────→ 客户端收到「生成一个玩家」的指令
 │        ↓                          │        ↓
 │ 各端都执行 spawn_function          │ 各端都执行 spawn_function
 │        ↓                          │        ↓
 │ 设 authority = id(递归到子节点)   │ 设 authority = id(递归到子节点)
 │        ↓                          │        ↓
 │ id≠我 → 远端回放                   │ id=我 → 本地操控(读输入、开相机)

3. 把联机跑起来

3.1 方式一:编辑器里双开(最方便)

Godot 编辑器菜单 调试 → 运行多个实例 → 2 个实例(Debug → Run Multiple Instances),然后:

  1. 第一个窗口点「创建主机」。
  2. 第二个窗口地址栏填 127.0.0.1,点「加入主机」。
  3. 两个窗口里都应该看到两个胶囊体,鼠标各自控制自己的那个。

3.2 方式二:命令行双开

我在 net.gd 里加了命令行参数入口,方便自动化测试:

# 假设 Godot 在 E:\Godot_v4.7.2-stable_mono_win64\
$g = 'E:\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe'

# 主机
Start-Process $g -ArgumentList @('--path','E:\GD_Projects\agame','--','--host')
# 加入
Start-Process $g -ArgumentList @('--path','E:\GD_Projects\agame','--','--join','127.0.0.1')

想抓日志(Godot 在 Windows 上不往重定向的 stdout 写日志,必须用 --log-file):

Start-Process $g -ArgumentList @(
  '--headless','--log-file','C:\temp\host.log',
  '--path','E:\GD_Projects\agame','--','--host'
) -PassThru

3.3 方式三:局域网真机

  1. 主机那台机器查自己的内网 IP(ipconfig,看 192.168.x.x 或 10.x.x.x)。
  2. 客户端在地址栏填这个 IP,点加入。
  3. 连不上先查 Windows 防火墙是否拦了游戏进程,以及端口 7000(Net.PORT)是否放行。

3.4 一切正常时你该看到什么

主机日志                              客户端日志
[Net] 主机已启动,端口 7000            [Net] 正在连接 127.0.0.1:7000
[Net] 本端 1 生成玩家 1(本地操控)      [Net] 已连接,等待主机生成角色
[Net] 本端 1 生成玩家 1334014715(远端回放)  [Net] 本端 1334014715 生成玩家 1(远端回放)
                                      [Net] 本端 1334014715 生成玩家 1334014715(本地操控)

两边日志互为镜像,而且每一端恰好有一个「本地操控」——这就是联机正常的标志。

游戏里:F10 断开,Esc 释放鼠标。


4. 日常开发:我新做的功能怎么同步?

4.0 先做决策

拿到一个需求,先按下表判断,不要一上来就写 @rpc。

你的需求 用什么 去哪看
血量、蓝量、名字、皮肤 id、瞄准方向这类状态 复制 配方 A(4.1)
开火、拾取、受击、播放某个动画、聊天这类一次性的动作 RPC 配方 B(4.2)
子弹、掉落物、怪物这类运行时才出现/消失的东西 第二个 MultiplayerSpawner 配方 C(4.3)
刷怪规则、计分、房间设置这类必须由一个人说了算的东西 服务器权威 + RPC 配方 D(4.4)

4.1 配方 A:新增一个持续同步的状态

例子:给玩家加血量 health。

第 1 步,在 player/player.gd 里声明变量。加 @export 是为了能在 Inspector 里看到,同步本身不要求:

@export var health: int = 100

第 2 步,把这个属性加进同步配置 player/player_sync.tres。两种改法,任选:

  • 图形化:打开 player.tscn → 选中 Sync 节点 → Inspector 里 Replication Config 点开那个 .tres → Add property → 选 health → 勾上 Spawn → Replication Mode 选 Always。

  • 直接改文本,在文件末尾追加三行(编号接着往下排):

    properties/2/path = NodePath(".:health")
    properties/2/spawn = true
    properties/2/replication_mode = 1
    

    三个字段的含义:

    字段 含义
    path 属性路径,相对 Sync.root_path(本项目是 ..,也就是 Player 本级)。.:health = Player 自己的 health;Body:rotation = Player/Body 的 rotation
    spawn 生成时是否把这个值一起带过去。必须 true,否则新加入的人看到的是代码里的默认值而不是当前值
    replication_mode 0 = 从不同步,1 = 每帧同步,2 = 值变了才同步

第 3 步,改血量时,只能在权威端改:

if is_multiplayer_authority():
    health -= 10

如果你在非权威端直接 health -= 10,效果会闪一下又被对端的旧值覆盖回来。

replication_mode = 2(只在变化时同步)能省流量,但要注意它靠对比上一帧的值判断,对浮点数频繁微变的属性效果不好。

4.2 配方 B:新增一个一次性事件(RPC)

RPC 的本质是「调用一个函数,它在别的机器上也会跑一遍」。Godot 用注解 @rpc 标记哪些函数可以被远程调用。

## 三项参数的含义:
##   1. 谁可以调用: "authority"(默认,只有权威方能调)| "any_peer"(谁都能调)
##   2. 本地是否也执行:"call_local"(本地也跑)| "call_remote"(只跑远程,默认)
##   3. 可靠性与顺序:"reliable"(必达,默认)| "unreliable"(丢了就算)
##                  | "unreliable_ordered"(只保留最新的,位置类数据用这个)
@rpc("any_peer", "reliable")
func request_shoot(target_position: Vector3) -> void:
    # 请求会同时到达所有人,但只有权威端需要处理,其余端直接丢掉
    if not is_multiplayer_authority():
        return
    # 校验放这里:冷却、弹药、距离、视线……不通过就直接 return
    if _cooldown > 0.0:
        return
    _cooldown = 0.5
    # 校验通过,由权威端广播结果
    _broadcast_shoot.rpc(target_position)


## "authority" + "call_local" 是最常用的组合:
## 只有权威端能发起,且发起方自己也执行一遍(保证本地表现和远程一致)
@rpc("authority", "call_local", "reliable")
func _broadcast_shoot(target_position: Vector3) -> void:
    spawn_muzzle_flash()
    play_shoot_sound()

怎么调用:

_broadcast_shoot.rpc(target_position)              # 发给所有 peer(本地按 call_local 决定跑不跑)
_broadcast_shoot.rpc_id(some_peer_id, pos)         # 只发给指定 peer

三个必须知道的坑:

  1. 路径一致性:@rpc 函数所在节点的路径必须在所有端完全相同。所以别给节点随机改名。
  2. 别用 @rpc 传节点引用。传 Vector3、int、String、Array、Dictionary 这类纯数据。要传「哪个对象」就传它的 name 或你自己编的 id,对端自己查。
  3. 请求(request)和结果(broadcast)要分成两个函数。别让「客户端直接执行动作」——那样人人都能作弊。本项目现在的玩家位置是客户端权威的(简单但不安全),如果你要做多人对战,参考配方 D 把关键逻辑搬到服务器。

4.3 配方 C:同步运行时生成/销毁的物体

一个 MultiplayerSpawner 只管一种节点。 要加子弹,就再加一个 spawner。

第 1 步,在 main.tscn 里找个容器当子弹的父节点(也可以直接用 Main),挂一个新的 MultiplayerSpawner:

Main
 ├─ MultiplayerSpawner        spawn_path = ..     → 生成玩家
 ├─ Bullets (Node3D)          ← 新建,用来收纳子弹
 │   └─ BulletSpawner (MultiplayerSpawner)   spawn_path = ..  → 生成子弹
 └─ ...

spawn_path 要指向子弹的父节点(相对 spawner 自己)。路径在每台机器上都一样,这是硬要求。

第 2 步,写一个类似 world/main.gd 的脚本给子弹容器,注册生成函数:

extends Node3D

const BULLET_SCENE: PackedScene = preload("res://combat/bullet.tscn")

@onready var spawner: MultiplayerSpawner = $BulletSpawner


func _ready() -> void:
    spawner.spawn_function = _create_bullet


## 这个函数会在每一端各跑一次,参数 data 由 spawn() 的调用方决定
func _create_bullet(data: Dictionary) -> Node:
    var bullet := BULLET_SCENE.instantiate()
    bullet.name = str(data["id"])            # 名字必须各端一致,用你自己编的唯一 id
    bullet.global_position = data["position"]
    bullet.direction = data["direction"]
    return bullet


## 只允许权威端/服务器调用
func fire(position: Vector3, direction: Vector3) -> void:
    if not multiplayer.is_server():
        return
    spawner.spawn({"id": _next_id(), "position": position, "direction": direction})

第 3 步,销毁就是 queue_free()——spawner 会自动把「销毁」这件事同步给其他端:

bullet.queue_free()

别在多台机器上各自生成。只有服务器(或该物体的权威端)能调 spawn(),其他人通过同步收到。

4.4 配方 D:把关键逻辑交给服务器

现在的模型是「每个人管自己」,好处是简单、无延迟;坏处是玩家能改自己的客户端作弊(比如改速度、瞬移)。

哪类必须搬给服务器:涉及胜负、资源、其他玩家的判定。

## 客户端:我要捡这个箱子
@rpc("any_peer", "reliable")
func request_pickup(item_name: String) -> void:
    if not multiplayer.is_server():
        return
    var node := get_node_or_null("../Items/" + item_name)
    if node == null or node.claimed:
        return                                        # 服务器校验:不存在或已被捡走
    node.claimed = true                               # 服务器记状态
    _play_pickup.rpc(item_name)                       # 再广播给所有人


## 服务器 → 所有人:这个箱子没了
@rpc("authority", "call_local", "reliable")
func _play_pickup(item_name: String) -> void:
    var node := get_node_or_null("../Items/" + item_name)
    if node != null:
        node.queue_free()

常用的判断函数:

函数 含义
multiplayer.is_server() 我是不是服务器(主机是,客户端不是)
multiplayer.get_unique_id() 我的 peer id
multiplayer.get_peers() 我知道的其他 peer id 列表
is_multiplayer_authority() 当前节点归不归我管

4.5 想让别的玩家看到我的模型/动画

好消息:不需要任何额外工作。player.tscn 是同一份场景,你只要把模型挂到 Body 节点下面,所有端都会看到:

Player
 └─ Body            ← 换角色模型就挂这里;它是一个 Node3D,本体 Player 不跟着转
     └─ Mesh / 骨骼模型

要注意的是驱动方式:

  • 「转身」现在同步的是 Body:rotation(视觉层的朝向),不是 Player:rotation。因为 player.gd 里本体是不旋转的(只把 Body 转过去)。同步错了这一项,远端玩家的朝向就会一直是初始值。
  • 远端玩家的动画不能靠输入驱动(远端玩家读不到键盘)。正确做法:把「状态」同步出去(比如 @export var move_speed: float),动画机只读这个状态。

4.6 五个必踩的坑(收藏这一段)

现象 原因 修法
远端玩家僵在原地不动 同步配置没生效(.tres 键名写错 / replication_mode 是 0 / 没绑定) 跑 tests/test_net_replication.gd
远端玩家位置一改就弹回去 你在非权威端改了被同步的属性 加 if is_multiplayer_authority(): 判断
画面里有好几个视角在打架 多台 Camera3D 抢渲染 ���端玩家必须 camera_rig.queue_free()
远端玩家抽搐、漂移、穿地 两端都在跑 move_and_slide() 远端玩家 set_physics_process(false)
报 Node not found / 同步完全不发 节点路径各端不一致 节点名用 str(peer_id) 这类确定性值,别随机命名

另外有一条最阴的:节点 authority 不会自动继承给子节点。所以 set_multiplayer_authority() 必须传第二个参数 true:

player.set_multiplayer_authority(peer_id, true)   # true = 递归设置所有子节点

漏了这个 true,MultiplayerSynchronizer(子节点)仍然认为自己是服务器的,同步一条都不会发,而且不报任何错。


5. 改成 Steam P2P

5.0 好消息:只需要改一个文件

Net 之外的所有文件(RPC、Spawner、Synchronizer、player.gd、main.gd)一个字都不用动。 因为它们操作的都是引擎级的抽象(MultiplayerAPI),跟底层用的是 ENet 还是 Steam 无关。

要改的只有 autoload/net.gd 里的 host_game() 和 join_game() 两个函数。

5.1 准备工作

  1. 装 Steam 客户端并登录。不登录,steamInitEx 会返回 { "status": 1, ... }。
  2. 拿到真实 AppID。当前代码里是 480,这是 Valve 的 Spacewar 测试 AppID,只能本机/好友小范围测试,大厅搜索、邀请等功能受限。把 autoload/steamworks.gd 第一行的 steam_app_id 改成你自己的 AppID。
  3. 在项目根目录放一个 steam_appid.txt(不是 res:// 里面,是 .godot 同级的真实路径),内容就是 AppID 数字。

    这个是 Steamworks SDK 的调试机制,只在编辑器/本地调试时需要;正式打包发布不要带上它。

  4. 启动游戏看日志,确认出现 Did Steam initialize?: { "status": 0, ... }(0 = 成功)。

status 的含义:0 OK、1 找不到 Steam 客户端、2 没有 AppID、3 版本不匹配。

5.2 最小改法:SteamID 直连(约 20 行)

跟现在的 ENet 版几乎一模一样——地址栏从「IP」变成「主机的 SteamID」。

## 创建主机
func host_game() -> void:
	var peer := SteamMultiplayerPeer.new()
	peer.set_no_delay(true)              # 实时游戏必备,关掉 Nagle 攒包
	var err := peer.create_host(0)       # 参数是虚拟端口,0 = 默认
	if err != OK:
		_set_status("创建主机失败:%s" % error_string(err))
		return
	multiplayer.multiplayer_peer = peer
	_show_game_ui()
	print("[Net] 主机已启动。你的 SteamID = %d,把它发给队友" % Steam.getSteamID())
	call_deferred("_spawn_player", 1)


## 加入主机。这里的 address 传的是主机的 SteamID(纯数字)
func join_game(address: String = "") -> void:
	var target := address.strip_edges()
	if not target.is_valid_int():
		_set_status("请填主机的 SteamID(纯数字)")
		return
	var peer := SteamMultiplayerPeer.new()
	peer.set_no_delay(true)
	var err := peer.create_client(int(target), 0)
	if err != OK:
		_set_status("连接失败:%s" % error_string(err))
		return
	multiplayer.multiplayer_peer = peer
	_set_status("正在连接 SteamID %s …" % target)

配套的小调整:

  • 菜单里的提示文字改成「填主机的 SteamID」,地址栏默认值 127.0.0.1 改成空。
  • 主机的 SteamID 建议显示在 HUD 上,方便念给队友。

代价:得手动交换 SteamID(截图、复制粘贴)。想让玩家自己搜房间,用 5.3。

5.3 完整改法:Steam 大厅(官方推荐)

思路:先用 Steam 大厅找人,再把大厅 id 交给 SteamMultiplayerPeer。

主机                                   客户端
 createLobby(PUBLIC, 4)               requestLobbyList() → lobby_match_list
      ↓ 信号 lobby_created                  ↓ 从列表里挑一个
 host_with_lobby(lobby_id)            joinLobby(lobby_id)
      ↓                                    ↓ 信号 lobby_joined
 multiplayer.multiplayer_peer = peer  connect_to_lobby(lobby_id)
                                           ↓
                                      multiplayer.multiplayer_peer = peer
# ---------------- 主机:建大厅 ----------------

func host_game_lobby(max_members: int = 4) -> void:
	Steam.lobby_created.connect(_on_lobby_created, CONNECT_ONE_SHOT)
	Steam.createLobby(Steam.LOBBY_TYPE_PUBLIC, max_members)   # 人数上限由大厅决定


func _on_lobby_created(connect_result: int, lobby_id: int) -> void:
	if connect_result != 1:                     # 1 = 成功
		_set_status("创建大厅失败:%d" % connect_result)
		return
	Steam.setLobbyData(lobby_id, "game", "agame")   # 给大厅打标签,方便筛选
	print("[Net] 大厅已创建:%d" % lobby_id)

	var peer := SteamMultiplayerPeer.new()
	peer.set_no_delay(true)
	peer.host_with_lobby(lobby_id)              # 大厅绑到 peer 上
	multiplayer.multiplayer_peer = peer
	_show_game_ui()
	call_deferred("_spawn_player", 1)


# ---------------- 客户端:找大厅 + 加入 ----------------

func refresh_lobby_list() -> void:
	Steam.lobby_match_list.connect(_on_lobby_match_list, CONNECT_ONE_SHOT)
	Steam.requestLobbyList()


func _on_lobby_match_list(lobbies: Array) -> void:
	# lobbies 是 lobby id 的数组,拿去做列表 UI
	for lobby_id in lobbies:
		print("大厅 %s,人数 %d" % [lobby_id, Steam.getNumLobbyMembers(lobby_id)])


func join_game_lobby(lobby_id: int) -> void:
	Steam.lobby_joined.connect(_on_lobby_joined)
	Steam.joinLobby(lobby_id)


func _on_lobby_joined(lobby_id: int, _permissions: int, _locked: bool, response: int) -> void:
	if response != 1:                           # 1 = 成功
		_set_status("加入大厅失败:%d" % response)
		return
	var peer := SteamMultiplayerPeer.new()
	peer.set_no_delay(true)
	peer.connect_to_lobby(lobby_id)
	multiplayer.multiplayer_peer = peer
	_show_game_ui()

上面用到的 Steam 接口(已在本项目安装的 GodotSteam 版本上逐个校验过签名,照抄即可):

接口 说明
Steam.createLobby(type, max_members) 建大厅。返回值是 void,结果通过 lobby_created 信号回来
Steam.lobby_created(connect_result, lobby_id) 信号,connect_result == 1 为成功
Steam.joinLobby(lobby_id) 加入大厅,结果通过 lobby_joined 信号回来
Steam.lobby_joined(lobby_id, permissions, locked, response) 信号,response == 1 为成功
Steam.requestLobbyList() 请求大厅列表,结果通过 lobby_match_list(lobbies: Array) 信号回来
Steam.setLobbyData / getLobbyData / getLobbyOwner / getNumLobbyMembers / getLobbyMemberByIndex 读写大厅信息
Steam.getSteamID() 取自己的 SteamID
SteamMultiplayerPeer.create_host(port=0) 当主机
SteamMultiplayerPeer.create_client(steam_id, port=0) 按 SteamID 直连
SteamMultiplayerPeer.host_with_lobby(lobby_id) 当主机并绑定大厅
SteamMultiplayerPeer.connect_to_lobby(lobby_id) 按大厅 id 加入
peer.set_no_delay(true) 关掉攒包延迟,实时游戏必须开

lobby_id 是 Steam 给的 64 位整数,显示给玩家时可以转成字符串再转回来,别自己改类型。

5.4 换成 Steam 之后要注意

  • 主机仍然是服务器。Steam P2P 只是把「怎么找到对方」换掉了,连接拓扑没变(仍然是主机=服务器、客户端之间的流量由主机中继)。所以「主机退出 = 全员掉线」这一点不变。
  • 握手是异步的。点加入后要等 1~3 秒才会真正建连;期间 multiplayer.connected_to_server 信号才触发。UI 上要有「连接中」状态。
  • 不要用 SteamID 当玩家名字,一长串数字没法看。加一个同步字段传昵称:@export var nickname: String,加入 player_sync.tres 同步即可。
  • AppID 480 的坑:第三方账号没有 Spacewar 的话,连不上或大厅搜不到。本机双开测试没问题。
  • 打包发布:删掉 steam_appid.txt,AppID 写死在代码/配置里。

6. 调试工具箱

手段 怎么用
看日志 编辑器里跑 → Output 面板;命令行跑 → 加 --log-file 路径(Windows 上重定向 stdout 抓不到输出,这是 Godot 的已知行为)
看同步有没有生效 在 _ready() 里 print(is_multiplayer_authority()),每端应该只有一个是 true
看玩家有没有生成 装了这个项目有 godot-ai 插件,可以让 AI 用 game_manage.get_scene_tree 看运行时节点树(注意:它看不到 autoload 下的 UI)
防配置被改坏 tests/test_net_replication.gd 会验证 player_sync.tres 真的有同步字段且 replication_mode == Always。这个配置写错不会报错,只会让角色僵在原地,所以值得用测试钉住
主动断言 也可以让它跑一遍:让 AI 执行 test_run,或者自己写更多 res://tests/test_*.gd(继承 McpTestSuite,方法名以 test_ 开头,用 assert_eq 之类)

7. 已知限制 / 下一步可以做什么

现在的实现是能跑通、够用、但不适合正式对战的基础版。按重要性排:

  1. 没有插值,远端玩家会一跳一跳。 同步是按固定节奏发位置,两端节奏不一致就会卡顿。修法二选一:

    • 简单:把 Sync 的 Replication Interval(replication_interval)调小,或保持默认每帧发。
    • 正规:自己写插值——远端玩家在 _process 里朝「最新收到的位置」做 lerp 平滑,而不是直接赋值。
  2. 没有服务器校验,玩家可以作弊。 现在位置是客户端权威的,改客户端就能瞬移。要防作弊得走配方 D:客户端只发输入,服务器算位置再同步回来。

  3. 没有断线重连。目前主机掉线 = 其他人全部回主菜单。要做重连需要改成「场景常驻 + 玩家按 session 恢复」。

  4. 没有大厅/房间列表(ENet 版)。换 Steam 后自带。

  5. 人数上限是 8(Net.MAX_PLAYERS)。ENet 提升这个数字要同时考虑带宽;Steam 版上限由大厅的 max_members 决定。

  6. 没有玩家昵称。节点名现在就是 str(peer_id),UI 上显示的是数字。按 5.4 的建议加同步字段即可。


附:一页速查

# 传输层(只在 net.gd 里出现)
var peer := ENetMultiplayerPeer.new()
peer.create_server(7000, 8)          # 主机
peer.create_client("192.168.1.5", 7000)   # 客户端
multiplayer.multiplayer_peer = peer

# 判断我是谁
multiplayer.is_server()              # 我是不是服务器
multiplayer.get_unique_id()          # 我的 peer id
multiplayer.get_peers()              # 其他 peer 的 id 列表
is_multiplayer_authority()           # 当前节点归不归我管

# 状态同步(改 player_sync.tres)
properties/N/path = NodePath(".:health")
properties/N/spawn = true
properties/N/replication_mode = 1    # 0 从不同步 1 每帧 2 变化时

# 事件同步(RPC)
@rpc("any_peer", "reliable")          func request_xxx(...) -> void:   # 谁都能发起
@rpc("authority", "call_local", "reliable") func do_xxx(...) -> void:  # 只有权威方发起 + 本地也跑
rpc(...) / rpc_id(peer_id, ...)

# 动态生成/销毁
spawner.spawn(data)                   # 只能在服务器/权威端调用
node.queue_free()                     # 销毁自动同步

# 授权(第二参数漏了就静默失效)
player.set_multiplayer_authority(peer_id, true)