这是 v0.1.0:主体玩法能完整跑完一局,但出生点重叠的 bug 还没修(第六节写了原因和修复方向)。 写作纪律和站点其他案例一致:没有实测数据就不写结论,没测的地方标「待补」。
一、一页速览
| 项 | 内容 |
|---|---|
| 项目定位 | 多人联机的完整案例:一局 90 秒,4 人合作,局域网直连 |
| 我的角色 | 独立完成:玩法拆分、权威边界设计、全部 C# 实现、联调与自测 |
| 玩法闭环 | 从容器台拿食材 → 切菜台处理 → 灶台烹饪 → 装盘 → 配送台得分,90 秒内尽可能多完成订单 |
| 场景流程 | 主菜单 → 大厅 → 角色选择 → 游戏(5 个场景,含一个加载场景) |
| 技术栈 | Unity 2022.3 LTS、URP、NGO 1.12.2、Input System、TextMeshPro |
| 联机形态 | Host / Client 直连(局域网);UGS 的 Lobby 与 Relay 代码已预留但未启用 |
| 验证方式 | ParrelSync 多开手测 + 连接审批的边界用例 + 断线 / 踢人场景演练 |
| 当前状态 | 可玩;出生点 bug 未修,反作弊与跨网联机未做 |
二、背景:为什么选这个题材
单机 Demo 练的是「逻辑写对」,联机 Demo 练的是「状态在谁的机器上算数」。做饭类玩法恰好是后者的压力测试:一局里同时存在共享状态(锅里的东西、盘子里的组合、订单进度)和独占状态(手里拿着什么、站在哪),任何一处权威划错,表现都是「物品凭空出现或消失」这种最难查的 bug。
所以目标从一开始就不是玩法量,而是把一条多人链路完整走通一遍,并且明确写下每一处取舍的代价。v0.1.0 的验收定义只有一句话:4 个人在同一个局域网里,从主菜单一路玩到结算,中间不掉线、不出现物品错乱。
三、我的职责
- 定权威边界:先决定「位置」和「交互」分别归谁算,再写任何一行网络代码。这条决定影响后面所有实现,改的成本最高。
- 拆玩法为数据:食材、配方、加工转换规则做成 ScriptableObject,新增一个菜品只加资产、不改代码。
- 管网络对象生命周期:所有「拿起 / 放下 / 销毁」都走服务端,客户端只发请求,用
NetworkObjectReference定位目标。 - 守边界用例:满员再连、游戏中途加入、玩家掉线时手里有东西、Host 踢人,这几个场景每次改完都要复跑一遍。
- 写文档与自测流程:README 里固化了 LFS、编辑器版本、ParrelSync 多开步骤——换台机器能一把跑起来,才算这个项目是可交付的。
四、技术选择与理由
| 面临的问题 | 我的选择 | 为什么 | 代价 / 取舍 |
|---|---|---|---|
| 联机框架 | Netcode for GameObjects 1.12.2 | 官方路线,NetworkObject / NetworkList / ServerRpc 这套原语够用,不必自己维护协议层 | 生态比 Mirror 小;客户端权威组件官方只给了示例,得自己落地 |
| 移动该由谁算 | 客户端权威(ClientNetworkTransform,OnIsServerAuthoritative 返回 false) | 手感优先:本地输入立即生效,不吃 RTT,四人同屏操作才不会「粘手」 | 服务端无法阻止位置作弊,任何安全或计分逻辑都不能依赖 transform |
| 交互该由谁算 | 服务端权威(ServerRpc 改状态,再同步回去) | 物品复制、凭空消失这类 bug 只能靠单点裁决避免 | 每次拿放多一次往返;必须处理「请求到达时状态已经变了」 |
| 网络对象怎么引用 | NetworkObjectReference + TryGet | 组件引用不能过网,只有 NetworkObject 的引用可以 | 每个 Rpc 都要判解析失败,并且要早返回,不能崩 |
| 配置数据怎么同步 | 只传「列表里的索引」(KitchenObjectListSO + int) | 资产本身不需要同步,传一个小整数就够 | 双端资产顺序必须完全一致,靠同一份工程和构建来保证 |
| 玩家身份 | PlayerPrefs 里持久化的 Guid + NetworkList<PlayerData> | 重进能认回同一个人,颜色槽位不重复分配 | 清缓存即丢失;没有账号体系,也不打算做 |
| 大厅与传输 | 本地直连(Unity Transport) | 局域网 Demo 不需要外部服务,省掉账号、配额和一堆线上配置 | 只能同网段玩;UGS Lobby / Relay 的代码留着但没启用 |
| 本地多开怎么测 | ParrelSync 克隆工程 | 两个编辑器窗口各自是独立进程,比 Build 一轮快得多 | 克隆工程要单独同步资产,改完 Prefab 得记得两边都对 |
最容易被忽略的是前两行的配合关系:移动给了客户端,交互就必须全部收回服务端。如果两边都想「顺手」,就会出现客户端认为自己拿到了菜、服务端认为容器台还空着,然后两边各自渲染一个——这类不一致不会报错,只会让玩家觉得「游戏在抽风」。
五、结构:两条权威边界
场景是一条直线,状态都挂在常驻的 KitchenGameMultiplayer 上:
| |
KitchenGameMultiplayer(DontDestroyOnLoad)持有NetworkList<PlayerData>:谁在线、叫什么、什么颜色。列表变了就抛事件,UI 各层各自订阅。Player只在自己是 Owner 时跑Update(移动 + 交互射线),别人的移动靠ClientNetworkTransform同步过来。- 各类操作台(容器台、切菜台、灶台、盘子台、配送台、垃圾桶)都继承
BaseCounter,各自持有「上面放了什么」,状态只允许服务端改。
移动侧只需要一行声明,把权威交出去:
| |
交互侧反过来,客户端只发意图,服务端校验后再改状态:
| |
这几处 return 不是防御性编程的装饰。并发请求下「父对象已经被别人占了」「目标在我解析前就被销毁了」都是正常时序,不是异常——早期版本把它们当成不该发生的情况,结果就是一个空引用把连接打断。
六、验证结果
| 主张 | 怎么测 | 结果 |
|---|---|---|
| 4 人能在局域网玩完一局 | ParrelSync 四开,Host 建局、三个 Client 加入,跑到结算 | 手测通过;没有记录帧率与延迟数据 |
| 满员后第 5 人连不进来 | 第 5 个客户端尝试加入 | 拒绝,ConnectionApprovalResponse.Reason 为「大厅已满!」 |
| 游戏开始后不能再加入 | 进入 GameScene 后新客户端连接 | 拒绝,Reason 为「游戏已经开始!」;提示是否真的显示在客户端,待验证 |
| 颜色不会重复分配 | 四人依次选同一个颜色 | 服务端 IsColorAvailable 拒绝重复;满四色时 GetFirstUnusedColorId 返回 -1,GetPlayerColor 回落到 0 号色 |
| 玩家掉线时手里的物品不会永久占位 | 拿着菜时断开一个客户端 | 服务端在断开回调里销毁其手上的 KitchenObject(代码路径已确认,未做重复性压测) |
| 客户端不能凭空生成物品 | 直接调 SpawnKitchenObjectServerRpc 传非法索引 / 已占用的父对象 | 三条 return 全部拦截,服务端状态不变(未做 fuzz) |
| 踢人后新玩家出生点重叠 | 四人开满 → 踢掉 ClientId=2 → 新玩家加入 | ❌ 未修复,v0.1.0 已知问题 |
| 网络带宽与延迟表现 | — | 待补:没采集过,不写结论 |
已知问题:出生点按 ClientId 取,而不是按槽位
| |
OwnerClientId 是网络层分配的递增 ID,踢人之后不会复用。四人开满时是 0~3;踢掉 2 号再进来一个新人,新人拿到 4,4 >= 4 命中 clamp,于是和还在 3 号点的人叠在一起:
| 阶段 | 在线 ClientId | 实际占用的出生点 |
|---|---|---|
| 满员 | 0, 1, 2, 3 | 点 0, 1, 2, 3 |
| 踢掉 ClientId=2 | 0, 1, 3 | 点 0, 1, 3 |
| 新玩家加入(ClientId=4) | 0, 1, 3, 4 | 点 0, 1, 3, 3 |
修复方向有两条,我倾向第二条:
- 用
playerDataNetworkList里的槽位索引(0~3)代替OwnerClientId——改动最小,但服务端和客户端的列表顺序必须严格一致。 - 由服务端在生成玩家时决定出生点并下发给该客户端——顺带解决一个更隐蔽的问题:
OnNetworkSpawn里设的transform.position会被客户端的ClientNetworkTransform覆盖,谁先执行决定了玩家最终站在哪。
七、复盘
- 网络 ID 不是业务索引。 出生点 bug 的本质是把
ClientId当成「第几个玩家」用了。凡是「按编号索引资源」的地方,都要问一句:这个编号会不会跳、会不会重复、会不会被回收。 - 权威边界要一次划完,不能局部妥协。 移动给客户端是为了手感,代价是位置不可信。这个代价本身可以接受,但它连带规定了一件事:计分和订单判定必须只看服务端状态,否则整套作弊防线就是空的。
- 并发下的「正常时序」比异常更值得防。
DestroyKitchenObjectServerRpc里那句「对象已经销毁就直接返回」的注释是拿一次空引用崩溃换来的。联机代码里,请求到达时世界已经变了是常态。 - 还有一处同类隐患没修:
GetPlayerDataIndexFromClientId找不到时返回 -1,而SetPlayerNameServerRpc直接拿它索引列表。断线和新玩家同步的时序撞上就会越界,属于「概率低但一定会发生」的一类。 - 静态事件记得反注册:
Player的静态事件目前只重置了一个(RestStaticData,名字还拼错了),场景重载后旧订阅者会继续被调用。 - 仓库卫生也是交付的一部分:根目录误提交了一个 37 KB 的
stdout日志文件,README 里的 clone 地址还是旧账号名。这类小事不影响运行,但会让第一次来的人克隆失败。
八、相关文章
- Unity 多人联网:Mirror 与 NGO:两款框架的对比,NGO 的
NetworkObject/ Rpc 概念在这里展开过 - Unity 客户端基础小框架:单机侧的同款取舍——哪些能力该收进骨架