Files
agame/docs/联机实战教程-由浅入深.md
T

1260 lines
45 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.
# 联机实战教程 · 由浅入深
这是**动手做**的教程,不是参考手册。每一关都是一次完整的「改代码 → 双开验证」循环。
配套文档:`docs/联机框架使用指南.md`(概念解释与速查)。忘了某个概念时回去翻。
**做完这九关,你会拥有**:血量同步、颜色认领、头顶名牌、挥手特效、攻击判定、子弹系统、服务器权威血量、远端玩家平滑插值、玩家列表 HUD。
---
## 关卡总览
| 关卡 | 难度 | 你要实现的 | 新概念 | 改动文件 |
|---|---|---|---|---|
| 0 | 热身 | 跑起来并看懂日志 | peer / authority | 无 |
| 1 | ★ | 自己按 H 扣血,两端血条一致 | 属性复制 | `player.gd`、`player_sync.tres` |
| 2 | ★ | 每个玩家一个随机颜色 | authority 归属 | `player.gd`、`player_sync.tres` |
| 3 | ★★ | 头顶显示自己的名牌 | 本地输入如何进网络 | `net.gd`、`player.gd`、`player_sync.tres` |
| 4 | ★★ | 按 G 让所有端看到挥手光球 | 第一个 RPC | `player.gd` |
| 5 | ★★★ | 按 K 打最近的玩家,对方掉血 | 两段式 RPC + 服务器校验 | `player.gd`、`main.gd` |
| 6 | ★★★ | 按 J 发射子弹 | 第二个 MultiplayerSpawner | `main.tscn`、`world/bullets.gd` 等 |
| 7 | ★★★★ | 子弹命中扣血,且改不掉 | 服务器权威 + 多 Synchronizer | `player.tscn`、`main.gd`、`player.gd` |
| 8 | ★★★★ | 走动的远端玩家不再一跳一跳 | 自己写插值 | `player.gd`、`player_sync.tres` |
| 9 | ★★★ | 左上角显示所有玩家名字 | 把整条链路串起来 | `net.gd` |
**按键占用表**(教程自己定的,别和已有的冲突):
| 键 | 用途 | 出现在 |
|---|---|---|
| `H` | 给自己扣 10 点血 | 关卡 1 |
| `G` | 挥手特效 | 关卡 4 |
| `K` | 攻击最近的玩家 | 关卡 5 |
| `J` | 发射子弹 | 关卡 6 |
| `F10` | 断开联机 | 框架自带 |
| `Esc` | 释放/锁定鼠标 | 框架自带 |
---
## 关卡 0 · 热身:先看清楚现状
**目标**:在没有改任何代码之前,先建立「一台机器上跑着两个世界」的直觉。
**第 1 步**:编辑器里 *调试 → 运行多个实例 → 2 个实例*,或者用命令行:
```powershell
$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')
```
第一个窗口点「创建主机」,第二个填 `127.0.0.1` 点「加入主机」。
**第 2 步**:在 `player/player.gd` 的 `_ready()` **第一行**加一句临时观察:
```gdscript
func _ready() -> void:
print("[观察] 我是 %d,这个节点归我管吗?%s,我的名字是 %s"
% [multiplayer.get_unique_id(), is_multiplayer_authority(), name])
```
**第 3 步**:再跑一次双开,看控制台。两边的输出应该类似:
```
主机: [观察] 我是 1,这个节点归我管吗?true,我的名字是 1
[观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715
客户端:[观察] 我是 1334014715,这个节点归我管吗?false,我的名字是 1
[观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715
```
**你要记住的三件事**:
1. 每台机器上都有**两个**玩家节点(自己一个 + 别人一个)。
2. 每台机器上**恰好有一个**节点的 `is_multiplayer_authority()` 是 `true`——那就是你正在操作的角色。
3. `name` 就是 peer id。这是各端找到「对面那个节点」的唯一依据。
**收尾**:把那句 `print` 删掉(或者留着,它只占用一行日志)。
---
## 关卡 1 · 最简单:同步一个数字(血量)
**目标**:按 `H` 给自己扣 10 点血,两台机器的血条显示一致。
**改动**:`player/player.gd`、`player/player_sync.tres`
### 第 1 步:加属性 + 一个只给本地玩家看的血条
在 `player.gd` 顶部的导出变量区(`@export_group("移动")` 前面)加:
```gdscript
## 血量。用 setter 是为了「值一变就刷新 UI」,不管这个值是本地改的还是同步过来的
@export var health: int = 100:
set(value):
health = clampi(value, 0, 100)
_refresh_health_label()
var _health_label: Label = null
```
`clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。
在 `_ready()` 的**本地分支**里(`set_mouse_captured(true)` 之后)加上建血条的代码:
```gdscript
_make_health_label()
```
然后在文件底部加这三个函数:
```gdscript
func _make_health_label() -> void:
var layer := CanvasLayer.new()
_health_label = Label.new()
_health_label.position = Vector2(16, 56)
layer.add_child(_health_label)
add_child(layer)
_refresh_health_label()
func _refresh_health_label() -> void:
# 远端玩家没有血条,_health_label 是 null,这里直接跳过
if _health_label == null:
return
_health_label.text = "HP %d / 100" % health
```
> **为什么 setter 和 `_health_label` 要判空?**
> 因为同步是在节点刚进场景树时就把值写进来的,可能**早于** `_ready()` 里建血条那一步。
> 所以 setter 里的东西必须能容忍「UI 还不存在」。
### 第 2 步:把 health 加进同步配置
打开 `player/player_sync.tres`,在末尾追加三行:
```
properties/2/path = NodePath(".:health")
properties/2/spawn = true
properties/2/replication_mode = 1
```
(原本 `properties/0` 和 `properties/1` 分别是 `.:position` 和 `Body:rotation`,编号接着往下排。)
**三个字段的意思**:
| 字段 | 值 | 含义 |
|---|---|---|
| `path` | `.:health` | `.` 表示 `Sync.root_path` 指向的节点(也就是 Player 自己),`:health` 是它的属性 |
| `spawn` | `true` | 生成玩家时把当前值一起带过去。**后加入的人才能看到正确的血量而不是默认值** |
| `replication_mode` | `1` | `1` = 每帧都同步。血量变化不频繁,改成 `2`(只在变化时同步)更省流量 |
### 第 3 步:按键扣血
在 `player.gd` 的 `_unhandled_input()` 里加一条分支(放在 `elif event.is_action_pressed("jump"):` 附近):
```gdscript
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
# 只有权威端能改被同步的属性,别人改的会被覆盖回去
if is_multiplayer_authority():
health -= 10
```
`event.echo` 是「长按重复触发」,不过滤的话按住 H 一秒钟能掉几百血。
### 验证
双开 → 在 A 窗口按几次 `H`。
- A 窗口左上角:`HP 70 / 100`
- B 窗口看**A 的角色**:也是 `HP 70 / 100`(B 自己没有血条,因为是 A 的血)
然后**第三个实例后加入**(双开后等一会儿再开第三个,走 `--join`),新加入的人应该立刻看到 A 的当前血量而不是 100——这就是 `spawn = true` 的作用。
### 常见错误
| 现象 | 原因 |
|---|---|
| 按 H 数字掉了一下又弹回来 | 漏了 `if is_multiplayer_authority()`,本地改的值被同步覆盖 |
| 后加入的人看到的是 100 | `spawn = false` |
| 完全不动,且没报错 | `.tres` 里的键名写错(比如把 `replication_mode` 拼错),mode 落到 0 |
**最后跑一下回归测试**确认没写坏配置:让 AI 执行 `test_run`,或者 `tests/test_net_replication.gd` 应该报同步字段数量从 2 变成 3。
### 小挑战
把 `replication_mode` 改成 `2`,观察行为有没有变化;再改成 `0`,看会发生什么。
---
## 关卡 2 · authority 归属:每个玩家一个颜色
**目标**:每台机器上的自己都是随机颜色,其他人都能看到你的颜色。
**改动**:`player/player.gd`、`player/player_sync.tres`
**新概念**:**被同步的属性由 authority 端决定,其他端只是观众。**
### 第 1 步:加属性
```gdscript
## 身体颜色。由本机(权威端)随机认领,其他端跟着看
@export var body_color: Color = Color.WHITE:
set(value):
body_color = value
_apply_body_color()
```
### 第 2 步:把颜色涂到模型上
```gdscript
func _apply_body_color() -> void:
# 注意:这里用 get_node_or_null 而不是 @onready。
# 因为同步可能在 _ready() 之前就把值写进来了,@onready 变量那时还是 null。
var mesh := get_node_or_null("Body/Mesh") as MeshInstance3D
if mesh == null:
return
var mat := StandardMaterial3D.new()
mat.albedo_color = body_color
mat.roughness = 0.6
mesh.material_override = mat
```
> `material_override` 是「只影响这一个实例」的材质。如果直接改 `Surface Material Override`
> 里那个共享材质,所有玩家都会变成同一个颜色——这是新手最容易踩的坑。
### 第 3 步:本地玩家认领自己的颜色
在 `_ready()` 的**本地分支**里(`_make_health_label()` 之前)加:
```gdscript
# 我是这个节点的权威,所以由我来决定颜色;其他端只是接收
body_color = Color.from_hsv(randf(), 0.65, 0.9)
```
**注意这一行必须放在 `if not _is_local: return` 之后。** 放到前面的话,远端玩家的颜色也会被本地随机覆盖,然后又被同步覆盖回来,表现为颜色闪烁。
### 验证
双开。两个窗口看到的是**两种不同颜色**,而且顺序一致:A 看到的「A 是蓝、B 是绿」和 B 看到的完全对应。
如果两边看到的同一角色颜色不一样,说明你把 `body_color = ...` 写到了远端分支里。
### 小挑战
把随机改成「按 peer id 查表」,让同一个客户端每次进来颜色固定。
---
## 关卡 3 · 本地输入怎么进网络:头顶名牌
**目标**:菜单里填一个昵称,进入游戏后每个玩家头顶显示自己的名字。
**改动**:`autoload/net.gd`、`player/player.gd`、`player/player_sync.tres`
**新概念**:**本地输入 → 写进 authority 端自己的同步字段 → 自动传播。**
这一关之后你会发现:只要「改的是自己的字段」,就不需要任何 RPC。
### 第 1 步:菜单加一个昵称输入框
`net.gd` 里加一个变量(放在 `var _address_edit` 旁边):
```gdscript
var _name_edit: LineEdit = null
## 玩家给自己起的名字,空的话用 peer id
var player_name: String = ""
```
在 `_build_menu()` 里,`_address_edit` 那段**下面**插入:
```gdscript
_name_edit = LineEdit.new()
_name_edit.placeholder_text = "你的名字"
_name_edit.custom_minimum_size = Vector2(240, 0)
box.add_child(_name_edit)
```
然后在 `host_game()` 和 `join_game()` 的**开头**各加一行,把输入框的值记下来:
```gdscript
player_name = _name_edit.text.strip_edges()
```
> 加在开头是因为:加入成功后菜单会被隐藏,那时就读不到输入框了。
### 第 2 步:Player 加昵称字段
`player.gd`:
```gdscript
## 玩家昵称。由本机自己填,然后同步给所有人
@export var nickname: String = "":
set(value):
nickname = value
_refresh_nameplate()
var _nameplate: Label3D = null
```
建名牌 + 刷新:
```gdscript
func _make_nameplate() -> void:
_nameplate = Label3D.new()
_nameplate.position = Vector3(0, 2.3, 0)
_nameplate.billboard = BaseMaterial3D.BILLBOARD_ENABLED # 永远面朝相机
_nameplate.no_depth_test = true # 被挡住时也能看见
_nameplate.outline_size = 8
_nameplate.font_size = 48
_nameplate.pixel_size = 0.005
add_child(_nameplate)
_refresh_nameplate()
func _refresh_nameplate() -> void:
if _nameplate == null:
return
_nameplate.text = nickname if not nickname.is_empty() else ("玩家 " + name)
```
`billboard` 让标签自动面向相机,不加的话它会跟着 Player 的朝向转,从背后看就是镜像的。
### 第 3 步:本地玩家给自己起名
**远端和本地都要建名牌**(名牌是所有人都要看的),所以 `_make_nameplate()` 要放在 `_ready()` 的**最前面**,在 `if not _is_local: return` **之前**:
```gdscript
func _ready() -> void:
_is_local = is_multiplayer_authority()
_make_nameplate() # ← 名牌是所有玩家都要显示的东西,要放在 is_local 判断之前
if not _is_local:
camera_rig.queue_free()
set_physics_process(false)
set_process_unhandled_input(false)
return
# 下面几行是前面几关攒下来的,只有本地玩家会执行
nickname = _read_player_name()
_make_health_label()
body_color = Color.from_hsv(randf(), 0.65, 0.9)
set_mouse_captured(true)
## 从 Net 单例读菜单里填的名字。用节点路径访问,不依赖 autoload 全局名
func _read_player_name() -> String:
var net := get_node_or_null("/root/Net")
if net == null:
return "玩家 " + name
var typed := str(net.get("player_name"))
if typed.is_empty():
return "玩家 " + name # 没填就用节点名(= peer id)
return typed
```
> **为什么用 `/root/Net` 而不是直接写 `Net`?**
> 新加的 autoload 在当前编辑器会话里可能还没有全局标识符(会报 `Identifier not found: Net`)。
> 用 `get_node_or_null("/root/Net")` 永远有效。
### 第 4 步:同步昵称
`player_sync.tres` 追加:
```
properties/3/path = NodePath(".:nickname")
properties/3/spawn = true
properties/3/replication_mode = 1
```
`spawn = true` 很重要:后加入的人需要立刻知道所有人的名字,而不是等下一次变动。
### 验证
双开。A 填「小明」,B 填「小红」,各自点开局/加入。
两个窗口里都应该看到:A 的角色头顶「小明」,B 的角色头顶「小红」。
再开第三个实例,不填名字直接加入,它的头顶应该是「玩家 <数字>」。
### 常见错误
| 现象 | 原因 |
|---|---|
| 名牌不显示 | `_make_nameplate()` 放在了 `if not _is_local: return` 之后 |
| 名牌从背后看是反的 | 漏了 `billboard` |
| 名字同步不过来 | `.tres` 里漏了这条,或者 `spawn = false` |
---
## 关卡 4 · 第一个 RPC:挥手特效
前四关都是「同步状态」。从这一关开始做**事件**。
**目标**:按 `G`,**所有机器**上都能看到你头顶冒出一个黄色的光球,0.6 秒后消失。
**改动**:`player/player.gd`
**新概念**:`@rpc` 注解。事件用 RPC,不用同步——因为「挥了一次手」是一个瞬间发生的事情,不是持续存在的状态。
### 第 1 步:写特效函数
`player.gd` 底部加:
```gdscript
## 挥手特效:我自己发起,所有人(包括我自己)都播一遍。
## "authority" → 只有这个节点的权威端(也就是我)能发起,别人调不动
## "call_local" → 发起方自己也执行,不然自己看不到自己的特效
## "reliable" → 必须送达,偶尔丢一个特效会很怪
@rpc("authority", "call_local", "reliable")
func play_wave() -> void:
var fx := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.22
sphere.height = 0.44
fx.mesh = sphere
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.9, 0.35)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.85, 0.2)
fx.material_override = mat
fx.position = Vector3(0, 2.1, 0)
add_child(fx)
# 往上飘 + 缩小 + 自己删掉
var tw := create_tween()
tw.tween_property(fx, "position:y", 3.0, 0.6)
tw.parallel().tween_property(fx, "scale", Vector3.ZERO, 0.6)
tw.tween_callback(fx.queue_free)
```
### 第 2 步:绑定按键
`_unhandled_input()` 里再加一条:
```gdscript
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_G:
play_wave.rpc()
```
**注意调用方式是 `play_wave.rpc()`,不是 `play_wave()`。**
- 直接写 `play_wave()` → 只有本机播特效,别人看不到。
- 写 `play_wave.rpc()` → 因为标了 `call_local`,本机也播,同时发给所有人。
### 验证
双开。在 A 窗口按 `G`:
- A 窗口:自己的头顶冒出光球
- B 窗口:**A 的角色**头顶冒出光球
在 B 窗口按 `G` 则反过来。**如果只有自己看得到,说明你漏了 `.rpc()`。**
### 常见错误
| 现象 | 原因 |
|---|---|
| 只有自己看得到 | 调用了 `play_wave()` 而不是 `play_wave.rpc()` |
| 对端报 `Node not found` | RPC 所在节点的路径在各端不一致(本项目里玩家节点名是 peer id,是安全的) |
| 报 `RPC 'play_wave' is not allowed on this node` | 忘了 `@rpc` 注解,或者用错了第一个参数 |
### 小挑战
把 `"reliable"` 改成 `"unreliable"`,多按几次,观察丢包时的表现——特效这类「丢了也无所谓」的东西用不可靠传输更省带宽。
---
## 关卡 5 · 两段式 RPC:攻击最近的玩家
**目标**:按 `K`,对离自己最近的玩家造成 10 点伤害;两台机器上双方的血量都要变。
**改动**:`player/player.gd`、`world/main.gd`
**新概念**:**两段式 RPC(请求 → 校验 → 广播)**,以及一个关键约束:
**服务器没有"权威"身份去调用别人节点上的 `authority` 模式 RPC。**
### 先想清楚一件事
关卡 1 我们说过:**被同步的属性只有权威端能改。** 玩家的血量 `health` 的权威端是**玩家自己**。
所以:A 打 B,A 的机器**不能**直接改 B 的血——改了也会被 B 的机器覆盖回来。
那怎么办?正确的做法是:**A 请求服务器 → 服务器校验 → 服务器广播 → B 自己的机器执行扣血。**
### 第 1 步:给两个脚本起类名
跨脚本调用(`main.gd` 要调 `player.gd` 的方法、`player.gd` 要调 `main.gd` 的 RPC)需要一个**类型**,
编辑器才能做编译期检查——方法名打错会立刻报错,而不是等到运行时才发现。
`player/player.gd` 最上面加一行:
```gdscript
class_name PlayerController
extends CharacterBody3D
```
`world/main.gd` 最上面加一行:
```gdscript
class_name GameWorld
extends Node3D
```
保存后编辑器会自动扫描注册,不用重启。
### 第 2 步:给自己的 player.gd 加「接收伤害」
```gdscript
## 扣血入口。任何端都可能调它,但只有「这个节点的主人」真正生效
func receive_damage(amount: int) -> void:
if not _is_local:
return
health = maxi(0, health - amount)
```
### 第 3 步:在 Player 上加请求函数
```gdscript
## 第一段:客户端 → 服务器。申请攻击某个玩家
@rpc("any_peer", "reliable")
func request_attack(target_name: String) -> void:
# 这是发给服务器的,客户端自己不需要跑
if not multiplayer.is_server():
return
# 防冒充:只允许玩家本人操作自己的角色节点
if multiplayer.get_remote_sender_id() != get_multiplayer_authority():
return
var world := get_parent() as GameWorld
if world == null:
return
var target := world.get_node_or_null(target_name) as PlayerController
if target == null:
return
# 服务器校验:距离必须在 3 米内
if global_position.distance_to(target.global_position) > 3.0:
return
# 校验通过,交给 Main 去广播。
# 注意这里是 .rpc(...),不是直接调用 —— 见下面的提示
world.broadcast_hit.rpc(target_name, 10)
```
> **`world.broadcast_hit(...)` 和 `world.broadcast_hit.rpc(...)` 的区别,是新手最容易搞错的一处。**
> 前者是普通的本地函数调用,只有你这台机器会执行,别的机器什么都不知道;
> 后者才会真正发包。**任何要跨机器传播的调用都必须带 `.rpc()`。**
> 这一处写错的现象是「代码没报错、逻辑也对,但对方完全没反应」——最难查的一类 bug。
### 第 4 步:为什么广播要放在 Main 上?
这是一个**很容易卡住人的点**。
`@rpc("authority")` 的检查规则是:**发起方的 peer id 必须等于被调用节点的 authority。**
而玩家的 authority 是**玩家自己**,不是服务器。所以服务器(peer 1)在别人节点的脚本上调用一个 `"authority"` 模式的 RPC,会被引擎直接拒绝并报错。
解决办法:**把广播函数放到一个 authority 就是服务器的节点上**。本项目的 `Main`(世界根节点)从来没被改过 authority,默认就是 1(服务器),天生合适。
在 `world/main.gd` 里加:
```gdscript
## 第二段:服务器 → 所有人。Main 的 authority 是 1(服务器),所以这里能用 "authority"
@rpc("authority", "call_local", "reliable")
func broadcast_hit(target_name: String, amount: int) -> void:
var target := get_node_or_null(target_name) as PlayerController
if target == null:
return
# 每个端都会调到,但只有目标玩家自己的机器会真正扣血(见 receive_damage)
target.receive_damage(amount)
```
### 第 5 步:按键发起
`_unhandled_input()` 再加一条:
```gdscript
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_K:
var target := _find_nearest_player()
if target != null:
request_attack.rpc_id(1, target.name)
```
和查找函数:
```gdscript
func _find_nearest_player() -> Node3D:
var best: Node3D = null
var best_dist: float = INF
for node in get_tree().get_nodes_in_group("net_player"):
if node == self:
continue
var candidate := node as Node3D
if candidate == null:
continue
var d: float = global_position.distance_to(candidate.global_position)
if d < best_dist:
best_dist = d
best = candidate
return best
```
`rpc_id(1, ...)` 就是「只发给服务器」。
### 验证
双开,让两个角色靠近(3 米内)。
1. A 按 `K` → A 自己血量不变,**B 的血量 -10**。
2. 两个窗口看到的 B 血量一致。
3. 把两个角色拉开 10 米,再按 `K` → **什么都不该发生**(服务器校验拦住了)。
### 这条链路的完整流程图
```
A 按 K
│
├─ A 的机器:request_attack.rpc_id(1, "B") ← 只发给服务器
│
服务器(A 的机器同时也是服务器,如果 A 是主机)
├─ is_server() ✓
├─ 发起者是不是 B 节点的 owner?✓
├─ 距离 ≤ 3 米?✓
└─ world.broadcast_hit.rpc("B", 10) ← 广播给所有人
│
├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做
├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10
└─ 其他机器:同上,什么都不做
```
### 这里埋着一个坑(关卡 7 会修)
现在的实现里,**伤害是「受害者自己的客户端」执行的**。所以:
> 一个改过客户端的作弊者,只要让自己的 `receive_damage()` 什么都不做,就永远打不死。
这是「去中心化 authority」模型(每人管自己)的固有缺陷。关卡 7 会把血量的所有权搬到服务器上来修它。
### 常见错误
| 现象 | 原因 |
|---|---|
| 服务器报 `RPC 'broadcast_hit' is not allowed` | 把 `broadcast_hit` 写在了 Player 上(Player 的 authority 是玩家自己,不是服务器),要放 `Main` 上 |
| 打了没反应 | 距离校验没过,或者两个角色根本不在 3 米内 |
| 血量掉了又弹回 | `receive_damage` 里漏了 `if not _is_local: return` |
---
## 关卡 6 · 动态生成:发射子弹
**目标**:按 `J` 从自己身上发射一颗子弹,直线飞行,2 秒后或飞出边界时消失。**所有机器都能看到子弹**。
**改动**:`world/main.tscn`、新建 `world/bullets.gd` 和 `combat/bullet.gd`
**新概念**:**一个 `MultiplayerSpawner` 只负责一种节点,所以子弹要再加一个。**
而且 `spawn()` **只能由服务器调用**。
### 第 1 步:在主场景里加一个子弹容器
打开 `world/main.tscn`,在根节点 `Main` 下添加:
```
Main
├─ Ground / Sun / Environment
├─ MultiplayerSpawner (已存在,生成玩家)
├─ Bullets (Node3D) ← 新增
│ └─ BulletSpawner (MultiplayerSpawner) ← 新增,作为 Bullets 的子节点
└─ ...
```
选中 `BulletSpawner`,在 Inspector 里把 **Spawn Path** 设成 `..`(也就是它的父节点 `Bullets`)。
把 `Bullets` 的 `script` 设为下面新建的 `world/bullets.gd`。
> `spawn_path` 默认是空的,**必须手动设**,否则 `spawn()` 什么都不会发生且不报错。
### 第 2 步:子弹本体(纯代码,不用建场景)
新建 `combat/bullet.gd`:
```gdscript
class_name Bullet
extends Node3D
## 子弹。整个外观用代码搭出来,所以不需要额外的 .tscn 文件。
## class_name 让别的脚本可以写 Bullet.new() 并享受编译期检查。
const SPEED: float = 25.0
const LIFETIME: float = 2.0
var direction: Vector3 = Vector3.FORWARD
var owner_peer: int = 1
var _age: float = 0.0
func _ready() -> void:
var mesh := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.08
sphere.height = 0.16
mesh.mesh = sphere
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.45, 0.2)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.35, 0.1)
mesh.material_override = mat
add_child(mesh)
func _physics_process(delta: float) -> void:
_age += delta
global_position += direction * SPEED * delta
if _age >= LIFETIME:
queue_free()
```
### 第 3 步:子弹容器
新建 `world/bullets.gd`:
```gdscript
class_name BulletPool
extends Node3D
## 子弹的生成与回收。挂在 Main/Bullets 上。
@onready var spawner: MultiplayerSpawner = $BulletSpawner
var _next_id: int = 0
func _ready() -> void:
spawner.spawn_function = _create_bullet
## 各端都会执行这个函数,所以它必须是「纯函数」:只看 data,不读本地状态
func _create_bullet(data: Dictionary) -> Node:
# Bullet 是 bullet.gd 里用 class_name 声明的,所以能直接 .new() 且有编译期检查
var bullet := Bullet.new()
bullet.name = "b%d" % data["id"] # 名字各端必须一致
bullet.global_position = data["position"]
bullet.direction = data["direction"]
bullet.owner_peer = data["owner"]
return bullet
## 只有服务器能调用它
func fire(position: Vector3, direction: Vector3, owner_peer: int) -> void:
if not multiplayer.is_server():
return
_next_id += 1
spawner.spawn({
"id": _next_id,
"position": position,
"direction": direction.normalized(),
"owner": owner_peer,
})
```
> **`spawn_function` 里不要读本地状态。** 它是各端各自执行的,一旦读了 `global_position` 这类本地值,
> 各端算出来的结果就可能不一样,现象是「两边看到的子弹位置不同」。
> 所有数据都通过 `spawn(data)` 的 `data` 传进去。
### 第 4 步:按键 → 请求服务器发射
玩家的机器不能直接 `spawn()`(那是服务器专属),所以还是两段式。在 `player.gd` 里加:
```gdscript
var _fire_cooldown: float = 0.0
## 客户端 → 服务器:我要开一枪
@rpc("any_peer", "reliable")
func request_fire(origin: Vector3, direction: Vector3) -> void:
if not multiplayer.is_server():
return
# 防冒充:只允许玩家本人操作自己的角色
if multiplayer.get_remote_sender_id() != get_multiplayer_authority():
return
# 服务器侧限流:0.25 秒一发,防连点
if _fire_cooldown > 0.0:
return
_fire_cooldown = 0.25
var bullets := get_parent().get_node_or_null("Bullets") as BulletPool
if bullets == null:
return
# 从胸口高度发射,避免子弹贴地
bullets.fire(global_position + Vector3(0, 1.2, 0), direction, get_multiplayer_authority())
```
> 把 `var _fire_cooldown` 写到这个函数**上面**,只是为了让读者先看到状态再看到逻辑;GDScript 的成员变量不分先后顺序。
冷却要在服务器侧计时,所以在 `_physics_process()` 里递减。**注意要放在 `if not _is_local: return` 之前**——主机的 `_is_local` 是 true 没问题,但限流必须在服务器上跑,不能受本地玩家判断影响:
```gdscript
func _physics_process(delta: float) -> void:
if _fire_cooldown > 0.0:
_fire_cooldown -= delta
if not _is_local:
return
# ...原来的重力 / 跳跃 / 移动
```
按键:
```gdscript
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_J:
# 朝相机看的方向发射
var dir := -camera_rig.global_transform.basis.z
request_fire.rpc_id(1, global_position + Vector3(0, 1.2, 0), dir)
```
### 验证
双开。在 A 窗口按 `J`:
- A 窗口:一颗橙色小球从自己身上飞出去
- B 窗口:**同一颗球**,同一时间,同一方向
连续按 `J` 快一点,应该被限流成 4 发/秒。
### 常见错误
| 现象 | 原因 |
|---|---|
| 子弹完全不出现,也不报错 | `BulletSpawner` 的 `spawn_path` 没设成 `..` |
| 只有主机能看到子弹 | 你在玩家脚本里直接 `spawn()` 了——客户端没有这个权限 |
| 各端子弹位置/方向不一样 | `spawn_function` 里读了本地状态(比如 `global_position`)而不是只用 `data` 里的值 |
| 子弹不消失、越积越多 | 漏了 `LIFETIME` 判断,或者用 `hide()` 而不是 `queue_free()` |
---
## 关卡 7 · 服务器权威:让血量真正说了算
**目标**:子弹打中人(3 米内算命中)→ 掉 10 点血;**且客户端改不掉自己的血量**。
**改动**:`player/player.tscn`、`world/main.gd`、`player/player.gd`、`player/player_sync.tres`
**新概念**:**同一个节点上可以挂多个 `MultiplayerSynchronizer`,各自有各自的 authority。**
这是本教程最核心的一关。关卡 5 埋的坑在这里填上。
### 思路
现在的情况:
- `Player` 节点的 authority = 玩家自己 → 它下面的 `Sync` 同步位置、颜色、昵称。这些**本来就该由玩家自己说了算**(我按了 W,我就该走)。
- 但 `health` 不该这样。血量是对战结果,必须由服务器说了算。
所以:**把 health 从 `Sync` 里拿出来,放到一个新的 `SyncStats` 里,并把 `SyncStats` 的 authority 设成 1(服务器)。**
```
Player(authority = 玩家自己)
├─ Body / CameraRig / Collision
├─ Sync (authority = 玩家自己)→ 同步 position、Body.rotation、body_color、nickname
└─ SyncStats (authority = 1 服务器)→ 同步 health ← 新增
```
### 第 1 步:新建 SyncStats 配置
新建 `player/player_stats.tres`(在文件系统面板右键 → 新建资源 → 搜 `SceneReplicationConfig`),内容:
```
[gd_resource type="SceneReplicationConfig" format=3]
[resource]
properties/0/path = NodePath(".:health")
properties/0/spawn = true
properties/0/replication_mode = 1
```
### 第 2 步:加 SyncStats 节点
打开 `player.tscn`,选中根节点 `Player`,添加子节点 `MultiplayerSynchronizer`,改名为 `SyncStats`:
- `Root Path` 保持 `..`
- `Replication Config` 选 `player_stats.tres`
### 第 3 步:把 health 从旧配置里删掉
**这一步不能漏。** 否则两个 synchronizer 抢同一个属性,行为不可预测。
打开 `player_sync.tres`,删掉:
```
properties/2/path = NodePath(".:health")
properties/2/spawn = true
properties/2/replication_mode = 1
```
**注意**:删掉中间一条之后,后面的编号要往前顶。原来的顺序是 `0=position, 1=Body:rotation, 2=health, 3=nickname`,删掉 2 之后要把 `nickname` 改成 `properties/2/...`,并且三个字段都要改:
```
properties/0/path = NodePath(".:position")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath("Body:rotation")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:nickname")
properties/2/spawn = true
properties/2/replication_mode = 1
```
最省事的做法是:**在 Inspector 里改**(打开 `Sync` 节点 → 点开 `Replication Config` → 选中 health 那行点删除),编号是它自动重排的。
### 第 4 步:把 SyncStats 的 authority 改回服务器
在 `world/main.gd` 的 `_create_player()` 里:
```gdscript
func _create_player(peer_id: int) -> Node:
var player := PLAYER_SCENE.instantiate()
player.name = str(peer_id)
player.add_to_group(PLAYER_GROUP)
# 一刀切:整个玩家(含所有子节点)归这个 peer 管
player.set_multiplayer_authority(peer_id, true)
# 再把血量那一支抢回服务器 —— 注意必须在上面那行之后
var stats := player.get_node_or_null("SyncStats")
if stats != null:
stats.set_multiplayer_authority(1)
var here := multiplayer.get_unique_id()
print("[Net] 本端 %d 生成玩家 %d%s" % [here, peer_id, "(本地操控)" if peer_id == here else "(远端回放)"])
return player
```
> **为什么必须先递归设 peer_id,再单独把 SyncStats 抢回 1?**
> 因为 `set_multiplayer_authority(id, true)` 会把所有子节点一起改掉。
> 先全设成玩家,再把要归服务器的那个挑出来覆盖——这是最不容易出错的手法。
### 第 5 步:服务器直接改血量
现在 `health` 的权威端是服务器了,服务器写它的值就会自动同步给所有人,客户端改了也会被覆盖回来。
在 `player.gd` 里,把关卡 5 写的 `receive_damage()` **换成**这个——判断条件从「我是不是本地玩家」变成了「我是不是服务器」:
```gdscript
## 扣血入口。现在只有服务器会调它 —— health 的权威端就是服务器
func apply_server_damage(amount: int) -> void:
if not multiplayer.is_server():
return
health = maxi(0, health - amount)
```
`world/main.gd` 的广播相应改成:
```gdscript
@rpc("authority", "call_local", "reliable")
func broadcast_hit(target_name: String, amount: int) -> void:
var target := get_node_or_null(target_name) as PlayerController
if target == null:
return
# 服务器直接扣。玩家自己的机器现在改不动这个值了
target.apply_server_damage(amount)
```
**这一关的全部意义就是判断条件从 `_is_local` 变成了 `is_server()`:**
| | 关卡 5 | 关卡 7 |
|---|---|---|
| `health` 的权威端 | 玩家自己 | 服务器 |
| 谁执行扣血 | 受害者的客户端 | 服务器 |
| 客户端能偷偷改血量吗 | 能(作弊) | 不能(会被服务器覆盖) |
| 谁来判定命中 | 攻击方的客户端 | 服务器 |
### 第 6 步:命中判定(子弹打中人)
在 `combat/bullet.gd` 里加命中检测。判定只在服务器做,然后由服务器调用广播:
```gdscript
const HIT_RADIUS: float = 1.0
const DAMAGE: int = 10
func _physics_process(delta: float) -> void:
_age += delta
global_position += direction * SPEED * delta
# 命中判定只在服务器做,避免各端各判一次导致结果不一致
if multiplayer.is_server():
var victim := _find_hit()
if victim != null:
var world := get_tree().current_scene as GameWorld
if world != null:
world.broadcast_hit.rpc(victim.name, DAMAGE)
queue_free()
return
if _age >= LIFETIME:
queue_free()
func _find_hit() -> PlayerController:
for node in get_tree().get_nodes_in_group("net_player"):
var candidate := node as PlayerController
if candidate == null:
continue
if candidate.name == str(owner_peer):
continue # 别打自己
# 用「到角色胶囊中心」的距离粗略判定,够用且不受碰撞层影响
var center: Vector3 = candidate.global_position + Vector3(0, 0.9, 0)
if center.distance_to(global_position) < HIT_RADIUS:
return candidate
return null
```
> `get_tree().current_scene` 就是主场景的根节点(本项目的 `Main`),比 `get_parent().get_parent()` 更稳,
> 少一个「层级一变就断」的隐患。
### 验证
双开,两个角色靠近。A 按 `J` 朝 B 开枪。
1. B 的血量应该 -10,两边显示一致。
2. **关键测试**:在 B 的窗口按 `H`(关卡 1 的自扣血键)。因为现在 `health` 的权威端是服务器,B 本地改的值**会立刻被服务器的值覆盖回来**——血量不再变化。
> 这就是服务器权威生效的标志。关卡 1 里按 H 是有效的,现在失效了,说明所有权已经转移。
3. 打不死(血量到 0 也要继续打)——因为还没有做死亡逻辑,这是正常的,留给你的挑战。
### 常见错误
| 现象 | 原因 |
|---|---|
| 血量开始跳动/闪回 | `health` 同时在 `Sync` 和 `SyncStats` 两个配置里,没从旧的删掉 |
| 血量完全同步不动 | 忘了第 4 步,`SyncStats` 的 authority 还是玩家自己 |
| 服务器改了血量但客户端不变 | `player_stats.tres` 里 `replication_mode` 是 0 |
| 子弹打不掉血 | `broadcast_hit` 的 `@rpc` 模式不是 `"authority"`,或者子弹找不到 Main 节点 |
### 别忘了更新回归测试
`tests/test_net_replication.gd` 现在只检查 `player_sync.tres`。给它补一个用例检查新配置,养成习惯——**同步配置写错不会报错,只会静默失效。**
### 小挑战
1. 加死亡逻辑:血量到 0 时,让服务器 `player.queue_free()`(玩家会从所有端消失)。重新加入需要重连。
2. 把「位置」也改成服务器权威(客户端只发输入方向,服务器算位置再同步回来)——这是防瞬移的唯一办法。
---
## 关卡 8 · 让远端玩家不再一跳一跳
**目标**:远端玩家的移动变顺滑,不再像幻灯片。
**改动**:`player/player.gd`、`player/player_sync.tres`
**新概念**:**同步的是「目标值」,显示的是「平滑逼近目标的过程」。**
### 问题在哪
现在的做法是:同步器直接把 `position` 写到远端玩家身上。网络包是按固定节奏到的,两次包之间的位置就直接跳过去,所以看起来一顿一顿。
### 解决思路
不要同步 `position` 本身,而是同步一个**只用来传值的变量**,然后每帧朝它平滑逼近:
```
权威端: _net_position ← 真实位置 (每物理帧)
↓ 同步
远端: 真实位置 ← 朝 _net_position 平滑逼近 (每渲染帧)
```
### 第 1 步:加一个中转变量
`player.gd`:
```gdscript
## 专门用来被同步的位置。它不代表显示位置,只代表「目标位置」
@export var _net_position: Vector3 = Vector3.ZERO
## 平滑速度,越大越跟手、越小越平滑
@export var interp_speed: float = 15.0
```
### 第 2 步:改同步配置
`player_sync.tres` 里把 `properties/0/path` 从 `NodePath(".:position")` 改成:
```
properties/0/path = NodePath(".:_net_position")
```
**只改这一行的 `path`,其他不动。**
### 第 3 步:权威端把真实位置写进中转变量
`player.gd` 的 `_physics_process()` 末尾(`move_and_slide()` 之后)加:
```gdscript
move_and_slide()
# 权威端:把真实位置发布出去(这一步不需要 is_multiplayer_authority 判断,
# 因为远端玩家的 _physics_process 已经被关掉了)
_net_position = global_position
```
### 第 4 步:远端每渲染帧朝目标逼近
远端玩家的 `_physics_process` 是关着的,所以要挂在 `_process` 上。在 `_ready()` 里启用它(远端分支):
```gdscript
if not _is_local:
camera_rig.queue_free()
set_physics_process(false)
set_process_unhandled_input(false)
set_process(true) # ← 新增:让远端玩家跑 _process 做插值
return
```
然后加:
```gdscript
func _process(delta: float) -> void:
if _is_local:
return
# 和帧率无关的平滑系数:每帧固定「吃掉」剩余距离的固定比例
var t := 1.0 - exp(-interp_speed * delta)
global_position = global_position.lerp(_net_position, t)
```
### 为什么是 `1.0 - exp(-speed * delta)` 而不是 `speed * delta`?
`global_position.lerp(target, 0.1)` 这种写法**和帧率有关**:60 FPS 和 144 FPS 下同样的 `0.1` 收敛速度完全不同。
`1.0 - exp(-speed * delta)` 是它的帧率无关版本:`speed` 的单位变成了「每秒收敛多少次」,物理含义清晰,换显示器也不会变。这是一个值得记住的小技巧。
### 验证
双开,让 A 按住 `W` 一直走,在 B 的窗口看 A 的角色。
- 改之前:一跳一跳的,像低帧率录像
- 改之后:连续顺滑,只是**略微滞后**于 A 自己看到的位置
滞后是正常的,是你为平滑付出的代价。`interp_speed` 调大 → 更跟手但更容易抖;调小 → 更平滑但更滞后。20 左右是个不错的起点。
**同时要确认**:A 自己的角色**没有**变卡(本地玩家走 `_physics_process`,不该受插值影响)。
### 常见错误
| 现象 | 原因 |
|---|---|
| 远端玩家完全不动了 | 忘了把 `_net_position` 加进同步配置,或者漏了 `set_process(true)` |
| 远端玩家抽搐得更厉害了 | 插值写在了 `_physics_process` 里,和同步写入打架 |
| 自己也变卡了 | 漏了 `if _is_local: return`,把自己的真实位置也往目标值上拉 |
| 远端玩家像在冰上飘 | `interp_speed` 太小(比如 2.0) |
### 小挑战
做一个**真正的插值**:记录最近两次收到的 `_net_position` 和时间戳,渲染时按「当前时间 - 100ms」在这两点之间取值。这样能消除抖动,代价是固定 100ms 延迟。
---
## 关卡 9 · 收尾:玩家列表 HUD
**目标**:左上角实时显示「谁在房间里」,用昵称。
**改动**:`autoload/net.gd`
这一关没有新概念,但把前面所有链路串起来:**spawn → 同步昵称 → UI 显示**。
### 第 1 步:HUD 加一行列表
`net.gd` 里加变量:
```gdscript
var _player_list: Label = null
```
`_build_hud()` 里,在 `_hud_status` 后面插入:
```gdscript
_player_list = Label.new()
box.add_child(_player_list)
```
再在 `_build_hud()` 末尾加一个定时刷新(别每帧拼字符串):
```gdscript
var timer := Timer.new()
timer.wait_time = 0.5
timer.autostart = true
timer.timeout.connect(_refresh_player_list)
add_child(timer)
```
### 第 2 步:实现刷新
```gdscript
func _refresh_player_list() -> void:
if _player_list == null or _hud_root == null or not _hud_root.visible:
return
# 用 PackedStringArray 而不是 Array[String]:
# String.join() 要的就是 PackedStringArray,传 Array[String] 会在运行时转换失败
var lines := PackedStringArray()
for node in get_tree().get_nodes_in_group(PLAYER_GROUP):
# 名字是同步过来的字段,所以这里读到的是「那个玩家自己起的名字」。
# 用 get() 而不是 node.nickname:node 是 Node 类型,静态访问拿不到脚本变量
var display := str(node.get("nickname"))
if display.is_empty():
display = "玩家 " + str(node.name)
lines.append("· %s(peer %s)" % [display, node.name])
lines.sort()
_player_list.text = "房间里 %d 人\n" % lines.size() + "\n".join(lines)
```
### 第 3 步:进出房间也刷一下
在 `_enter_game()` 和 `_leave_game_ui()` 末尾都加:
```gdscript
_refresh_player_list()
```
这样不用等半秒。
### 验证
双开,两个窗口都填不同的名字。两边的列表应该都是:
```
房间里 2 人
· 小明(peer 1)
· 小红(peer 1334014715)
```
开第三个实例加入 → 2 秒内三个窗口都变成 3 人。
让其中一个按 `F10` 断开 → 其他窗口在 1~2 秒内变回 2 人。
### 小挑战
在列表里标出「房主」(`peer 1`)和「你自己」(`multiplayer.get_unique_id()`)。
---
## 附录 A · 每关通用的自测三步
任何一关做完,都按这三步验证,不要只看自己那一台:
1. **双开看镜像**:A 做的操作,B 那边有没有对应表现?
2. **第三实例后加入**:等游戏跑起来之后再开第三个(`-- --join 127.0.0.1`),检查**初始值**是否正确。
凡是和 `spawn = true` 有关的问题(血量、昵称、颜色),只有这一步能测出来。
3. **跑一遍回归测试**:让 AI 执行 `test_run`。同步配置类的错误**不会报错**,只会静默失效,测试是唯一的防线。
## 附录 B · 排错速查
| 症状 | 90% 的原因 |
|---|---|
| 属性同步完全不动,无报错 | `.tres` 键名写错 / 该属性不在任何同步配置里 / 两个配置抢同一属性 |
| 改了值又弹回去 | 你在非权威端改了一个被同步的属性 |
| 远端玩家僵在原地 | 同步器 authority 设错(`set_multiplayer_authority` 漏了 `true`) |
| 多个视角打架 | 远端玩家没 `camera_rig.queue_free()` |
| 远端玩家抽搐/穿地 | 远端玩家还在跑 `_physics_process()` |
| RPC 报 `Node not found` | RPC 所在节点路径各端不一致 |
| RPC 报 `not allowed on this node` | `@rpc` 第一个参数和发起方身份不匹配(见关卡 5 第 3 步) |
| 只有自己看得到效果 | 调用时漏了 `.rpc()` |
| `Identifier not found: Net` | autoload 全局名在当前编辑器会话里还没生效,改用 `get_node_or_null("/root/Net")` |
## 附录 C · 关卡之后可以做的方向
按「投入产出比」排序:
1. **死亡与复活**:血量归零 → 服务器 `queue_free()` → 做一个复活按钮重新加入。
2. **命中的视觉反馈**:受击闪红、飘伤害数字(用关卡 4 的 RPC 模式广播)。
3. **完整的服务器权威**:位置也改成「客户端发输入、服务器算位置」,彻底杜绝瞬移作弊。
4. **真正的插值**(关卡 8 的挑战):加 100ms 渲染延迟,消除抖动。
5. **断线重连**:需要把玩家状态从「一个 peer 一个节点」改成「一个 session 一个节点」。
6. **换 Steam P2P**:见 `docs/联机框架使用指南.md` 第 5 章,只改 `net.gd` 里两个函数。