元某人也元某人也

胡闹厨房:四人合作烹饪联机游戏

本文目录

这是 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 上:

1
MainMenuScene → LobbyScene → CharacterSelectScene → GameScene
  • KitchenGameMultiplayer(DontDestroyOnLoad)持有 NetworkList<PlayerData>:谁在线、叫什么、什么颜色。列表变了就抛事件,UI 各层各自订阅。
  • Player 只在自己是 Owner 时跑 Update(移动 + 交互射线),别人的移动靠 ClientNetworkTransform 同步过来。
  • 各类操作台(容器台、切菜台、灶台、盘子台、配送台、垃圾桶)都继承 BaseCounter,各自持有「上面放了什么」,状态只允许服务端改。

移动侧只需要一行声明,把权威交出去:

1
2
3
4
5
6
7
8
[DisallowMultipleComponent]
public class ClientNetworkTransform : NetworkTransform
{
    protected override bool OnIsServerAuthoritative()
    {
        return false; // Owner 客户端写位置,服务端只做转发
    }
}

交互侧反过来,客户端只发意图,服务端校验后再改状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[ServerRpc(RequireOwnership = false)]
private void SpawnKitchenObjectServerRpc(int kitchenObjectSOIndex,
    NetworkObjectReference parentReference)
{
    KitchenObjectSO so = GetKitchenObjectSOFromIndex(kitchenObjectSOIndex);
    if (so == null) return;                                   // 索引非法:静默丢弃

    if (!parentReference.TryGet(out NetworkObject parent)) return;  // 对象已销毁
    if (!parent.TryGetComponent(out IKitchenObjectParent holder)) return;
    if (holder.HasKitchenObject()) return;                     // 慢一步的请求,输给先到的

    var spawned = Instantiate(so.prefab);
    spawned.GetComponent<NetworkObject>().Spawn(true);          // 服务端生成,客户端跟随
    spawned.GetComponent<KitchenObject>().SetKitchenObjectParent(holder);
}

这几处 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 取,而不是按槽位

1
2
3
4
5
6
7
// Player.OnNetworkSpawn()  ——  v0.1.0 的实际代码
int spawnIndex = (int)OwnerClientId;
if (spawnIndex >= spawnPoints.Count)
{
    spawnIndex = spawnPoints.Count - 1;   // 超出范围就固定到最后一个点
}
transform.position = spawnPoints[spawnIndex];

OwnerClientId 是网络层分配的递增 ID,踢人之后不会复用。四人开满时是 0~3;踢掉 2 号再进来一个新人,新人拿到 4,4 >= 4 命中 clamp,于是和还在 3 号点的人叠在一起:

阶段在线 ClientId实际占用的出生点
满员0, 1, 2, 3点 0, 1, 2, 3
踢掉 ClientId=20, 1, 3点 0, 1, 3
新玩家加入(ClientId=4)0, 1, 3, 4点 0, 1, 3, 3

修复方向有两条,我倾向第二条:

  1. 用 playerDataNetworkList 里的槽位索引(0~3)代替 OwnerClientId——改动最小,但服务端和客户端的列表顺序必须严格一致。
  2. 由服务端在生成玩家时决定出生点并下发给该客户端——顺带解决一个更隐蔽的问题:OnNetworkSpawn 里设的 transform.position 会被客户端的 ClientNetworkTransform 覆盖,谁先执行决定了玩家最终站在哪。

七、复盘

  • 网络 ID 不是业务索引。 出生点 bug 的本质是把 ClientId 当成「第几个玩家」用了。凡是「按编号索引资源」的地方,都要问一句:这个编号会不会跳、会不会重复、会不会被回收。
  • 权威边界要一次划完,不能局部妥协。 移动给客户端是为了手感,代价是位置不可信。这个代价本身可以接受,但它连带规定了一件事:计分和订单判定必须只看服务端状态,否则整套作弊防线就是空的。
  • 并发下的「正常时序」比异常更值得防。 DestroyKitchenObjectServerRpc 里那句「对象已经销毁就直接返回」的注释是拿一次空引用崩溃换来的。联机代码里,请求到达时世界已经变了是常态。
  • 还有一处同类隐患没修:GetPlayerDataIndexFromClientId 找不到时返回 -1,而 SetPlayerNameServerRpc 直接拿它索引列表。断线和新玩家同步的时序撞上就会越界,属于「概率低但一定会发生」的一类。
  • 静态事件记得反注册:Player 的静态事件目前只重置了一个(RestStaticData,名字还拼错了),场景重载后旧订阅者会继续被调用。
  • 仓库卫生也是交付的一部分:根目录误提交了一个 37 KB 的 stdout 日志文件,README 里的 clone 地址还是旧账号名。这类小事不影响运行,但会让第一次来的人克隆失败。

八、相关文章

仅用于交流学习,记录学习心得