1315 lines
49 KiB
Markdown
1315 lines
49 KiB
Markdown
# 联机实战教程 · 由浅入深
|
||
|
||
这是**动手做**的教程,不是参考手册。每一关都是一次完整的「改代码 → 双开验证」循环。
|
||
|
||
配套文档:`docs/联机框架使用指南.md`(概念解释与速查)。忘了某个概念时回去翻。
|
||
|
||
**做完这九关,你会拥有**:血量同步、颜色认领、头顶名牌、挥手特效、攻击判定、子弹系统、服务器权威血量、远端玩家平滑插值、玩家列表 HUD。
|
||
|
||
---
|
||
|
||
## 关卡总览
|
||
|
||
| 关卡 | 难度 | 你要实现的 | 新概念 | 改动文件 |
|
||
|---|---|---|---|---|
|
||
| 0 | 热身 | 跑起来并看懂日志 | peer / authority | 无 |
|
||
| 1 | ★ | 自己按 H 扣血,两端血条一致 | 属性复制 | `player.tscn`、`player.gd`、`player_sync.tres` |
|
||
| 2 | ★ | 每个玩家一个随机颜色 | authority 归属 | `player.gd`、`player_sync.tres` |
|
||
| 3 | ★★ | 头顶显示自己的名牌 | 本地输入如何进网络 | `net.tscn`、`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.tscn`、`net.gd` |
|
||
|
||
> **UI 一律用节点配置,不用代码创建。** 联机菜单和 HUD 都在 `res://autoload/net.tscn` 里搭好,
|
||
> 涉及 UI 的关卡(3、9)都是「在编辑器里加节点 + 连信号」,脚本里只写 `@onready` 引用和状态更新。
|
||
|
||
**按键占用表**(教程自己定的,别和已有的冲突):
|
||
|
||
| 键 | 用途 | 出现在 |
|
||
|---|---|---|
|
||
| `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.tscn`、`player/player.gd`、`player/player_sync.tres`
|
||
|
||
### 第 1 步:加属性 + 血条(节点配置)
|
||
|
||
**先说 UI 的规矩**:本项目所有 UI 都用**节点配置**,不在代码里 `Label.new()`。所以血条要先在编辑器里搭出来。
|
||
|
||
**1a. 在 `player.tscn` 里加血条节点**
|
||
|
||
1. 打开 `res://player/player.tscn`。
|
||
2. 选中根节点 `Player`,添加子节点,类型 **`Label3D`**,改名 `HealthLabel`。
|
||
> 用 `Label3D` 而不是 `CanvasLayer` + `Label`:`Label3D` 是**3D 世界里的文字**,
|
||
> 挂在哪就跟着哪走,所以会自动待在玩家头顶,不需要每帧做屏幕投影。
|
||
3. 在 Inspector 里设:
|
||
|
||
| 属性 | 值 | 为什么 |
|
||
|---|---|---|
|
||
| `Transform → Position` | `(0, 2.05, 0)` | 头顶上方。胶囊高 1.8、中心在 y=0.9,头顶约在 1.8 |
|
||
| `Text` | `HP 100 / 100` | 运行时被脚本覆盖,这里只是编辑器预览 |
|
||
| `Billboard` | `Enabled` | 永远面朝相机。**不开的话文字会跟着角色转,从背后看是镜像的** |
|
||
| `No Depth Test` | 开 | 被别的物体挡住时也能看见 |
|
||
| `Font Size` | `64` | |
|
||
| `Pixel Size` | `0.005` | 字号→世界单位:64 × 0.005 ≈ 0.32 米高 |
|
||
| `Outline Size` | `8` | 描边,远看和杂乱背景上也能读 |
|
||
|
||
4. `Ctrl+S` 保存。
|
||
|
||
层级应该是:
|
||
|
||
```
|
||
Player (CharacterBody3D)
|
||
├─ Collision / Body / CameraRig / Sync
|
||
└─ HealthLabel (Label3D) ← 头顶血条
|
||
```
|
||
|
||
**1b. 在 `player.gd` 里加属性和节点引用**
|
||
|
||
```gdscript
|
||
@export_group("属性")
|
||
## 血量。用 setter 是为了「值一变就刷新 UI」,不管这个值是本地改的还是同步过来的
|
||
@export var health: int = 100:
|
||
set(value):
|
||
health = clampi(value, 0, 100)
|
||
_refresh_health_label()
|
||
|
||
## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
|
||
@onready var _health_label: Label3D = $HealthLabel
|
||
```
|
||
|
||
`clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。
|
||
|
||
**1c. 远端玩家的血条要不要显示?**
|
||
|
||
因为血条挂在**每个玩家节点**上,而 `health` 是同步过来的,所以默认行为是
|
||
**每个人的血量都显示在他自己头顶**,你能看到别人还剩多少血。这通常正是想要的效果,不用额外写代码。
|
||
|
||
> 对比一下两种做法:如果血条是**屏幕坐标**的 `CanvasLayer` + `Label`(像左上角那种),
|
||
> 就必须在远端分支里 `hide()` 掉,否则 N 份血条会叠在同一个屏幕位置、显示别人的血量。
|
||
> 换成 `Label3D` 之后这个坑自然消失了 —— 这也是「让 UI 待在它该待的世界位置」的好处。
|
||
|
||
如果你**只想看自己的**,在 `_ready()` 的远端分支里加一行:
|
||
|
||
```gdscript
|
||
if not _is_local:
|
||
camera_rig.queue_free()
|
||
_health_label.visible = false # ← 只在需要「只看自己」时加
|
||
set_physics_process(false)
|
||
set_process_unhandled_input(false)
|
||
return
|
||
```
|
||
|
||
本地分支末尾记得补刷一次:
|
||
|
||
```gdscript
|
||
# ...相机初始化
|
||
set_mouse_captured(true)
|
||
_refresh_health_label() # 补刷一次(原因见下面的提示)
|
||
```
|
||
|
||
**1d. 刷新函数**
|
||
|
||
放在文件底部:
|
||
|
||
```gdscript
|
||
func _refresh_health_label() -> void:
|
||
# 同步可能在 _ready() 之前就把 health 写进来了,那时 _health_label 还是 null,直接跳过
|
||
if _health_label == null:
|
||
return
|
||
_health_label.text = "HP %d / 100" % health
|
||
```
|
||
|
||
> **为什么 setter 要判空、`_ready()` 末尾还要再刷一次?**
|
||
> `@onready` 变量是在 `_ready()` 被调用**之前**才赋值的。而同步写入可能来得更早
|
||
> (节点刚进场景树时)。那一次 setter 会因为 `_health_label == null` 被跳过,
|
||
> 所以本地分支末尾要补一次 `_refresh_health_label()`,把当前值显示出来。
|
||
|
||
> **和关卡 3 的名牌不冲突**:关卡 3 会在 `(0, 2.3, 0)` 加一个 `Label3D` 名牌,
|
||
> 血量放在 `2.05`,名牌放它上面,两个叠不到一起。
|
||
|
||
### 第 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.tscn`、`autoload/net.gd`、`player/player.gd`、`player/player_sync.tres`
|
||
|
||
**新概念**:**本地输入 → 写进 authority 端自己的同步字段 → 自动传播。**
|
||
|
||
这一关之后你会发现:只要「改的是自己的字段」,就不需要任何 RPC。
|
||
|
||
### 第 1 步:菜单加一个昵称输入框
|
||
|
||
UI 是**节点配置**(在 `res://autoload/net.tscn` 里搭好的),所以这一步不写创建控件的代码,是在编辑器里加一个节点。
|
||
|
||
1. 在文件系统面板双击打开 `res://autoload/net.tscn`。
|
||
2. 选中 `Menu/Panel/Box/AddressEdit`(地址输入框),在场景菜单里选 **添加子节点**,类型 `LineEdit`,改名 `NameEdit`。
|
||
3. 在 Inspector 里设:
|
||
- `Placeholder Text` = `你的名字`
|
||
- `Custom Minimum Size` = `240 × 0`(和上面的地址框对齐)
|
||
4. 拖动节点,把它排到 `AddressEdit` **下面**(同级节点的顺序就是显示顺序)。
|
||
5. `Ctrl+S` 保存场景。
|
||
|
||
然后在 `net.gd` 里加引用和状态变量:
|
||
|
||
```gdscript
|
||
## 玩家给自己起的名字,空的话用 peer id
|
||
var player_name: String = ""
|
||
|
||
@onready var _name_edit: LineEdit = $Menu/Panel/Box/NameEdit
|
||
```
|
||
|
||
> `$Menu/Panel/Box/NameEdit` 就是你在场景里搭出来的节点路径。改完 UI 后如果路径变了(比如移动了节点),这里也要跟着改——否则运行时报 `null` 实例访问错误。
|
||
|
||
最后在 `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.tscn`、`autoload/net.gd`
|
||
|
||
这一关没有新概念,但把前面所有链路串起来:**spawn → 同步昵称 → UI 显示**。
|
||
|
||
### 第 1 步:HUD 加一行列表
|
||
|
||
还是在 `res://autoload/net.tscn` 里加节点:
|
||
|
||
1. 打开 `res://autoload/net.tscn`。
|
||
2. 选中 `Hud/Box/StatusLabel`,添加子节点,类型 `Label`,改名 `PlayerList`。
|
||
3. 把它排到 `StatusLabel` **下面**。
|
||
4. 再给 `Hud/Box` 加一个子节点,类型 `Timer`:
|
||
- `Wait Time` = `0.5`
|
||
- `Autostart` = `true`
|
||
5. 在 Inspector 的 **节点 → 信号** 面板里,把 `Hud/Box/Timer` 的 `timeout` 连到 `Net` 的 `_refresh_player_list`。
|
||
6. `Ctrl+S` 保存。
|
||
|
||
然后在 `net.gd` 里加引用(**不用再写 `Timer.new()` / `Label.new()`**):
|
||
|
||
```gdscript
|
||
@onready var _player_list: Label = $Hud/Box/PlayerList
|
||
```
|
||
|
||
> **为什么用 Timer 而不是每帧刷新**:字符串拼接 + 排序是很便宜但不必要的开销,
|
||
> 一秒钟刷两次已经足够。养成「UI 按需刷新」的习惯,比事后优化省事得多。
|
||
|
||
### 第 2 步:实现刷新
|
||
|
||
```gdscript
|
||
func _refresh_player_list() -> void:
|
||
if not _hud.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 步:进出房间也立刻刷一下
|
||
|
||
在 `_show_game_ui()` 和 `_show_menu_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` 里两个函数。
|