Files
agame/docs/联机实战教程-由浅入深.md
2026-09-22 17:51:52 +08:00

49 KiB
Raw Permalink Blame History

联机实战教程 · 由浅入深

这是动手做的教程,不是参考手册。每一关都是一次完整的「改代码 → 双开验证」循环。

配套文档: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 个实例,或者用命令行:

$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() 第一行加一句临时观察:

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 里加属性和节点引用

@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() 的远端分支里加一行:

	if not _is_local:
		camera_rig.queue_free()
		_health_label.visible = false      # ← 只在需要「只看自己」时加
		set_physics_process(false)
		set_process_unhandled_input(false)
		return

本地分支末尾记得补刷一次:

	# ...相机初始化
	set_mouse_captured(true)
	_refresh_health_label()         # 补刷一次(原因见下面的提示)

1d. 刷新函数

放在文件底部:

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"): 附近):

	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 步:加属性

## 身体颜色。由本机(权威端)随机认领,其他端跟着看
@export var body_color: Color = Color.WHITE:
	set(value):
		body_color = value
		_apply_body_color()

第 2 步:把颜色涂到模型上

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() 之前)加:

	# 我是这个节点的权威,所以由我来决定颜色;其他端只是接收
	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 里加引用和状态变量:

## 玩家给自己起的名字,空的话用 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() 的开头各加一行,把输入框的值记下来:

	player_name = _name_edit.text.strip_edges()

加在开头是因为:加入成功后菜单会被隐藏,那时再读输入框就不合适了。

第 2 步:Player 加昵称字段

player.gd:

## 玩家昵称。由本机自己填,然后同步给所有人
@export var nickname: String = "":
	set(value):
		nickname = value
		_refresh_nameplate()

var _nameplate: Label3D = null

建名牌 + 刷新:

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 之前:

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 底部加:

## 挥手特效:我自己发起,所有人(包括我自己)都播一遍。
## "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() 里再加一条:

	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 最上面加一行:

class_name PlayerController
extends CharacterBody3D

world/main.gd 最上面加一行:

class_name GameWorld
extends Node3D

保存后编辑器会自动扫描注册,不用重启。

第 2 步:给自己的 player.gd 加「接收伤害」

## 扣血入口。任何端都可能调它,但只有「这个节点的主人」真正生效
func receive_damage(amount: int) -> void:
	if not _is_local:
		return
	health = maxi(0, health - amount)

第 3 步:在 Player 上加请求函数

## 第一段:客户端 → 服务器。申请攻击某个玩家
@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 里加:

## 第二段:服务器 → 所有人。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() 再加一条:

	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)

和查找函数:

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:

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:

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 里加:

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 没问题,但限流必须在服务器上跑,不能受本地玩家判断影响:

func _physics_process(delta: float) -> void:
	if _fire_cooldown > 0.0:
		_fire_cooldown -= delta
	if not _is_local:
		return
	# ...原来的重力 / 跳跃 / 移动

按键:

	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() 里:

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() 换成这个——判断条件从「我是不是本地玩家」变成了「我是不是服务器」:

## 扣血入口。现在只有服务器会调它 —— health 的权威端就是服务器
func apply_server_damage(amount: int) -> void:
	if not multiplayer.is_server():
		return
	health = maxi(0, health - amount)

world/main.gd 的广播相应改成:

@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 里加命中检测。判定只在服务器做,然后由服务器调用广播:

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:

## 专门用来被同步的位置。它不代表显示位置,只代表「目标位置」
@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() 之后)加:

	move_and_slide()

	# 权威端:把真实位置发布出去(这一步不需要 is_multiplayer_authority 判断,
	# 因为远端玩家的 _physics_process 已经被关掉了)
	_net_position = global_position

第 4 步:远端每渲染帧朝目标逼近

远端玩家的 _physics_process 是关着的,所以要挂在 _process 上。在 _ready() 里启用它(远端分支):

	if not _is_local:
		camera_rig.queue_free()
		set_physics_process(false)
		set_process_unhandled_input(false)
		set_process(true)          # ← 新增:让远端玩家跑 _process 做插值
		return

然后加:

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()):

@onready var _player_list: Label = $Hud/Box/PlayerList

为什么用 Timer 而不是每帧刷新:字符串拼接 + 排序是很便宜但不必要的开销, 一秒钟刷两次已经足够。养成「UI 按需刷新」的习惯,比事后优化省事得多。

第 2 步:实现刷新

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() 末尾都加:

	_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 里两个函数。