# 联机框架使用指南 面向「完全没接触过联机」的开发者的入门文档。 读完你应该能做到三件事:跑起联机、给自己新做的功能加上同步、把传输层换成 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.tscn ← 联机总入口(Autoload 名:Net,指向这个场景) │ │ Net (CanvasLayer) ← 挂 net.gd │ │ ├─ Menu (CenterContainer) ← 主菜单:标题/地址框/两个按钮/状态/提示 │ │ └─ Hud (Control) ← 游戏内 HUD:状态 + 操作提示 │ └── net.gd ← 管传输层、开局/加入/断开、玩家生成回收; │ UI 是节点配置,这里只改文字、切显隐 ├── 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) ← 负责同步位置和朝向 │ │ └─ Hud (CanvasLayer) ← 本地玩家的血条,远端玩家会被隐藏 │ │ └─ HealthLabel (Label) │ ├── 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) ```