This commit is contained in:
2026-09-22 16:56:52 +08:00
parent db60e35766
commit cac2f7d87b
2 changed files with 1852 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+616
View File
@@ -0,0 +1,616 @@
# 联机框架使用指南
面向「完全没接触过联机」的开发者的入门文档。
读完你应该能做到三件事:跑起联机、给自己新做的功能加上同步、把传输层换成 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.gd ← 联机总入口(Autoload 名:Net)
│ 管传输层、开局/加入/断开、玩家生成回收、菜单和 HUD
├── 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 → SpringArm → Camera ← 远端玩家会被删掉这一支
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
│ │ └─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
│ ├── 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` 里加了命令行参数入口,方便自动化测试:
```powershell
# 假设 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`**):
```powershell
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 里看到,同步本身不要求:
```gdscript
@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 步**,改血量时,**只能在权威端改**:
```gdscript
if is_multiplayer_authority():
health -= 10
```
如果你在非权威端直接 `health -= 10`,效果会闪一下又被对端的旧值覆盖回来。
> `replication_mode = 2`(只在变化时同步)能省流量,但要注意它靠对比上一帧的值判断,对浮点数频繁微变的属性效果不好。
### 4.2 配方 B:新增一个一次性事件(RPC)
RPC 的本质是「调用一个函数,它在别的机器上也会跑一遍」。Godot 用注解 `@rpc` 标记哪些函数可以被远程调用。
```gdscript
## 三项参数的含义:
## 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()
```
**怎么调用**:
```gdscript
_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` 的脚本给子弹容器,注册生成函数:
```gdscript
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 会自动把「销毁」这件事同步给其他端:
```gdscript
bullet.queue_free()
```
> **别在多台机器上各自生成**。只有服务器(或该物体的权威端)能调 `spawn()`,其他人通过同步收到。
### 4.4 配方 D:把关键逻辑交给服务器
现在的模型是「每个人管自己」,好处是简单、无延迟;坏处是**玩家能改自己的客户端作弊**(比如改速度、瞬移)。
哪类必须搬给服务器:涉及胜负、资源、其他玩家的判定。
```gdscript
## 客户端:我要捡这个箱子
@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`:
```gdscript
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」。
```gdscript
## 创建主机
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
```
```gdscript
# ---------------- 主机:建大厅 ----------------
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 的建议加同步字段即可。
---
## 附:一页速查
```gdscript
# 传输层(只在 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)
```