Files
agame/docs/联机框架使用指南.md
T
2026-09-22 17:51:52 +08:00

622 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 联机框架使用指南
面向「完全没接触过联机」的开发者的入门文档。
读完你应该能做到三件事:跑起联机、给自己新做的功能加上同步、把传输层换成 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 → SpringArm → 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` 里加了命令行参数入口,方便自动化测试:
```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)
```