# 联机实战教程 · 由浅入深 这是**动手做**的教程,不是参考手册。每一关都是一次完整的「改代码 → 双开验证」循环。 配套文档:`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` 里两个函数。