From cac2f7d87b66c972444ee4eed4c6607c593f63ca Mon Sep 17 00:00:00 2001 From: yvmou Date: Tue, 22 Sep 2026 16:56:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/联机实战教程-由浅入深.md | 1236 +++++++++++++++++++++++++++++++++ docs/联机框架使用指南.md | 616 ++++++++++++++++ 2 files changed, 1852 insertions(+) create mode 100644 docs/联机实战教程-由浅入深.md create mode 100644 docs/联机框架使用指南.md diff --git a/docs/联机实战教程-由浅入深.md b/docs/联机实战教程-由浅入深.md new file mode 100644 index 0000000..855cfe7 --- /dev/null +++ b/docs/联机实战教程-由浅入深.md @@ -0,0 +1,1236 @@ +# 联机实战教程 · 由浅入深 + +这是**动手做**的教程,不是参考手册。每一关都是一次完整的「改代码 → 双开验证」循环。 + +配套文档:`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() # ← 名牌是所有玩家都要显示的东西 + if not _is_local: + camera_rig.queue_free() + ... + return + # 只有自己给自己起名 + nickname = get_node_or_null("/root/Net").player_name if ... +``` + +最后那行写清楚点,用工具函数更好: + +```gdscript + nickname = _read_player_name() +``` + +```gdscript +func _read_player_name() -> String: + var net := get_node_or_null("/root/Net") + if net == null or net.player_name.is_empty(): + return "玩家 " + name # 没填就用节点名(= peer id) + return net.player_name +``` + +> **为什么用 `/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 米?✓ + └─ parent.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` 里的 `receive_damage()` 删掉,并修改 `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) + if target == null: + return + # 服务器直接改。因为 health 的权威端就是服务器, + # 这个值会自动同步给所有人,客户端改了也会被覆盖回来 + target.health = target.health - amount +``` + +注意这里是 `target.health = ...`,**不是** `target.receive_damage(...)`。现在所有权在服务器手上,服务器说了算。 + +### 第 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_parent().get_parent() # Bullets → Main + if world != null and world.has_method("broadcast_hit"): + world.broadcast_hit.rpc(victim.name, DAMAGE) + queue_free() + return + + if _age >= LIFETIME: + queue_free() + + +func _find_hit() -> Node3D: + for node in get_tree().get_nodes_in_group("net_player"): + if node.name == str(owner_peer): + continue # 别打自己 + # 用「到角色胶囊中心」的距离粗略判定,够用且不受碰撞层影响 + var center: Vector3 = node.global_position + Vector3(0, 0.9, 0) + if center.distance_to(global_position) < HIT_RADIUS: + return node + return null +``` + +> 用距离判定而不是物理碰撞,是因为子弹和玩家都不需要额外的碰撞层配置, +> 而且**判定只发生在服务器**,天然避开了「各端物理结果不同步」的问题。 + +### 验证 + +双开,两个角色靠近。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 + var lines: Array[String] = [] + for node in get_tree().get_nodes_in_group(PLAYER_GROUP): + # 名字是同步过来的字段,所以这里读到的是「那个玩家自己起的名字」 + var display: String = 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` 里两个函数。 diff --git a/docs/联机框架使用指南.md b/docs/联机框架使用指南.md new file mode 100644 index 0000000..707c4ec --- /dev/null +++ b/docs/联机框架使用指南.md @@ -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) +```