资讯详情

联机餐厅经营游戏源码解析:Unity状态机与网络同步实战

📅 2026/9/15 17:45:28 | 华诺云谱 👁 阅读
联机餐厅经营游戏源码解析:Unity状态机与网络同步实战
简介基于Unity引擎打造的联机餐厅经营游戏完整资源包开发语言为C#面向已经掌握Unity基础操作、希望深入理解多人在线游戏通信与工程结构的开发者。项目采用服务端、客户端、共享工程三端协作设计服务端负责数据库管理和计算转发客户端依据服务端数据驱动游戏表现共享工程统一存放公共变量与方法网络层基于TCP协议数据库采用MySQL人物与场景模型由Blender制作。资源共913个文件压缩包约215.41MB核心内容包括142个C#脚本、119个运行库、33个FBX模型、21个Prefab预制体和15个材质此外配有动画、贴图、着色器、音频、场景配置及文档等基本覆盖从建模到联机运行的完整开发链资源内还包含服务端配置文件、数据库脚本和项目说明文档。已有645人浏览学习这套项目从中可以研究联机房间内的状态同步方案学习用静态类实现单例模式来降低耦合并可作为毕业设计、课程项目或独立游戏的原型基础。1. 先把餐厅经营游戏源码当分布式程序读很多人拿到一套餐厅经营源码第一反应是打开场景看菜品模型这是顺序上最大的错误。餐厅经营游戏之所以绕是因为它把两件事压在一个 Unity 工程里一个人玩时是单机模拟一旦“联机”二字进场每个客人的状态机、每笔钱的变化、每盘菜出餐的时机都要被改写成网络层能接收、能校验、能重放的共享状态。我在拿到“可以联机”的餐厅经营游戏源码时习惯先翻脚本目录再找场景先找网络消息类再看动画控制。这篇按做这类项目最常见的五段路径展开数据模型如何组织、顾客状态机怎么写、联机边界划在哪、性能和存档怎么落、最终如何自证确定性。不假设你手里有某个固定工程目标是让新手能跟着重建核心系统让熟手能直接对照检查自己项目的边界划分。适合想把单机原型扩展成开房间同玩、或者想找一个带完整业务闭环的 C# 项目练手的开发者。前者在乎联机玩法能不能跑通后者在乎代码结构能不能撑起后续迭代两者都要先在逻辑层分清“每个客人做什么”和“网络怎么传达这件事”。2. 读代码的地基餐厅经营游戏的 Unity 数据模型与 C# 结构2.1 把“一道菜多少钱”变成可配置数据联机经营游戏源码最容易写废的地方是把菜品参数散落在十几个脚本的 if 分支里。报价 18 元、出餐 8 秒、用餐 15 秒原本要在不同文件里分别维护改一次得全局搜索。我一般会先做一步重构把所有不随帧变化的业务参数搬进 ScriptableObject让数值调整和数据独立性一起解决。using UnityEngine; [CreateAssetMenu(fileName DishConfig, menuName Restaurant/DishConfig)] public class DishConfig : ScriptableObject { public string dishId; // 稳定ID联机传输只传这个不传价格 public string dishName; // 菜单展示用 [Min(0f)] public float sellPrice 18f; [Min(0.5f)] public float cookTime 8f; [Min(1f)] public float eatTime 15f; public int[] ingredientIds; // 做这道菜消耗的备料ID public Sprite icon; // 菜品图标 }这里有个很关键的参数设计dishId 是整个配置表的索引键后面联机消息里只带 ID 不带价格。价格在服务器和客户端都应该从同一份配置读取防止有人改本地内存里的价格字段来重复记账。cookTime 与 eatTime 要加到最小限制避免配置成负数导致顾客状态永远卡死。接下来需要一个全局注册表把字符串 ID 映射到实际配置对象。我习惯先用静态类做基础实现等规模变大再考虑用 Addressables 替换 Resources 加载using System.Collections.Generic; using UnityEngine; public static class GameData { private static readonly Dictionarystring, DishConfig DishMap new Dictionarystring, DishConfig(); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] public static void LoadAllConfigs() { DishMap.Clear(); DishConfig[] all Resources.LoadAllDishConfig(DishConfigs); foreach (DishConfig c in all) { if (c null) continue; if (DishMap.ContainsKey(c.dishId)) { Debug.LogWarning($DishConfig 重复 ID{c.dishId}); continue; } DishMap.Add(c.dishId, c); } } public static DishConfig GetDish(string id) { DishConfig cfg; return DishMap.TryGetValue(id, out cfg) ? cfg : null; } }RuntimeInitializeOnLoadMethod 的作用是让这段初始化代码在第一个场景加载前自动执行不依赖场景里挂某个 GameObject 的执行顺序细节在于 LoadAllConfigs 必须从 Resources 文件夹下的 “DishConfigs” 子目录加载目录结构错了会静默加载不到。重复 ID 用 warning 而不是 error是因为源码包里经常会有占位配置直接报错反而影响看主逻辑。配合这套配置可以在 Unity 的 Project 窗口右键创建所有菜品资产。下表是餐厅经营游戏里常见的配置资产清单每块改动都有明确的波及面配置资产关键字段改动影响DishConfigcookTime / sellPrice / ingredientIds顾客等餐时间、结账金额、库存消耗GuestConfigpatience / groupMax排队离席判断、同桌人数上限TableConfigseatCount / unlockLevel座位分配算法、开业进度NetConfigmaxPlayers / timeoutSec房间人数上限、掉线判定LevelConfigtargetCash / timeLimit单局胜负条件与时长把数值搬进配置表以后源码里的逻辑代码会明显变薄。联机环境下配置表的分发是另一道坎初始开发期直接打包进资源后期做热更时需要注意配置版本号最老练的方式是为每个 DishConfig 加一个 numericVersion服务器校验不匹配就拒绝进房。2.2 C# 容器选型List、Dictionary 和 Queue 各自管什么读餐厅经营源码时第二眼要看的不是类名而是容器选型。新手最容易把“所有客人”存成一个 List在 Update 里每帧暴力搜索再和 UI 列表联动刷新。十几个人没问题五六十个角色活跃时帧率波动就开始明显。我一般会把三类数据分开激活顾客列表用ListGuestSM容量预分配进程内循环查找增删时尽量不在循环体内部直接 Remove而是先标记待回收。座位、菜品、库存这类“按 ID 找对象”的用Dictionaryint, TableSlot或Dictionarystring, Stackintkey 分别是桌号和备料 ID避免 O(n) 的线性扫描。联机消息与结账事件用QueueOrderMsg保证先产生的事件先处理这样 Host 在并包重发时可以按队列顺序排重。这里有一个容易被忽略的细节List 中间移除顾客会导致后续元素的索引变化破坏调试日志的可读性。我建议如果不是严格追求性能保留顺序的移除方式把末尾元素移动到空位只适合高频战斗类游戏不适合经营类——经营游戏查流水时人走的顺序对得上日志才有意义。2.3 主循环驱动固定逻辑时钟别让经营逻辑跟着帧率走看完配置和容器下一步要确认经营逻辑用什么时钟驱动。最常见的源码缺陷是把烹饪进度写在 Update 里AI 每帧推进一次结果帧率低的客户端“做菜更慢”联机时同一个订单在不同机器上完成时间不一致。联机经营游戏需要一个固定的逻辑时钟我经常这样实现using UnityEngine; public class GameClock : MonoBehaviour { public static float LogicTime { get; private set; } public static float DeltaTime Time.fixedDeltaTime; private void FixedUpdate() { LogicTime Time.fixedDeltaTime; } private void Reset() { Time.fixedDeltaTime 0.1f; } }逻辑时钟每秒固定走 10 步比渲染帧率粗但对经营游戏足够。联机同步只需要在这些逻辑时间点上对齐状态不需要每帧对齐。渲染动画继续使用 Time.deltaTime两者解耦后无论显示帧率是 30 还是 144烹饪倒计时、收银结算都按同一套时间走。注意不要图省事直接在各处把 Time.deltaTime 换成 Time.fixedDeltaTime。FixedUpdate 在帧率波动时可能在一帧内被调用多次只有通过 GameClock.LogicTime 累加出来的时间才能作为网络时间戳和回放基准。3. 用 C# 状态机写顾客动线联机餐厅游戏的核心循环3.1 状态枚举与转换表先定协议再写逻辑顾客在经营游戏里的行为路径非常固定等位、落座、点单、等餐、用餐、结账、离店。这条链写成枚举以后调试日志、断点检查、网络消息都能引用同一个值不会出现满天飞的中文状态字符串。public enum GuestState { WaitingSeat 0, Ordering 1, CookingWaiting 2, Eating 3, Paying 4, Leaving 5 }枚举的数值要显式写出来因为状态值会进网络包和存档。后续加新状态只能追加不要在中间插入否则旧版本客户端会把新状态解析成错误行为。当前状态退出条件切换目标网络层信号WaitingSeat有空桌分配成功OrderingTableAssignOrdering玩家确认点单CookingWaitingOrderSubmitCookingWaitingHost 判定烹饪完成EatingDishServedEatingHost 判定用餐完成PayingBillRequestPayingHost 确认支付成功LeavingPayConfirmLeaving离桌动画结束回到对象池TableRelease这张表建议贴在项目 Wiki 最显眼的位置它同时是状态机代码的验收标准也是联机消息表的第一版草稿。对照表写代码不会漏状态也不会让一个状态跳变两个目标。3.2 状态机主体只做单机预演联机切换点交给消息状态机的核心类可以这样写先保证单机模式下能完整跑通using UnityEngine; [RequireComponent(typeof(GuestView))] public class GuestSM : MonoBehaviour { public GuestState state; public string guestId; public int tableId; public string dishId; public float StateTimer { get; private set; } private float _cookTime 8f; private float _eatTime 15f; private void Awake() { DishConfig cfg GameData.GetDish(dishId); if (cfg null) { Debug.LogError($Guest {guestId} 引用了不存在的菜品 {dishId}); return; } _cookTime cfg.cookTime; _eatTime cfg.eatTime; } private void Update() { StateTimer GameClock.DeltaTime; switch (state) { case GuestState.CookingWaiting: if (StateTimer _cookTime) { state GuestState.Eating; StateTimer 0f; // 这里只通知表现层播放上菜动画不直接改全局账本 } break; case GuestState.Eating: if (StateTimer _eatTime) { state GuestState.Paying; StateTimer 0f; // 单机模式直接进结算联机模式应发 PayRequest 等 Host 确认 } break; } } }这段代码把烹饪时间和用餐时间缓存在组件字段里避免每个逻辑帧都去查一次配置表。StateTimer 在状态切换时重置这是状态机最容易漏的复位点不 Reset 会出现“上一轮等餐 8 秒已经过了 6 秒切回等餐状态 2 秒后就上菜”的错乱。注意 Update 里的计时器只适合单机演示或本地预演。联机时客户端如果自己数完 8 秒就切状态Host 那边因为网络延迟还没开始处理就会产生两台机器不同的餐桌状态。严谨的做法是把“什么时间点完成”的决定权放到 Host后面第 4 章会展开。3.3 联机时的状态切换消息驱动替代本地计时出菜这类关键动作不能由客户端“看见 8 秒”自行触发而应该由 Host 在自身逻辑时钟上判断然后广播 DishServed。客户端在消息回调里切换状态public void OnDishServed(int receivedTableId, string receivedDishId) { if (state GuestState.CookingWaiting tableId receivedTableId dishId receivedDishId) { state GuestState.Eating; StateTimer 0f; _view.PlayServeAnimation(); } }条件里同时校验状态、桌号、菜品 ID 三个条件防止同一条消息因网络重发触发两次动画。实际项目中我会再叠加一条 seq 去重判断Host 端同样按 seq 去重保证每个订单只进一次账。3.4 现金流与库存用单一账本记录所有改动餐厅经营最终要算钱源码里最多的 bug 是“钱被多扣或漏记”。根源是逻辑分散在多个类里每个类各自维护金币字段。更稳妥的做法是做一个 EconomySystem所有收入、支出、扣库存都走同一个账本using System; using System.Collections.Generic; public sealed class EconomySystem { private int _cash; private readonly Dictionaryint, int _inventory new Dictionaryint, int(); public event Actionint CashChanged; // 由 Host 在订单结账时调用 public void ConfirmSettle(int price) { _cash price; CashChanged?.Invoke(_cash); } public bool ConsumeItem(int itemId, int count) { int current; if (!_inventory.TryGetValue(itemId, out current) || current count) return false; _inventory[itemId] current - count; return true; } }ConfirmSettle 传 price 而不是在内部查配置表是为了让“裁判给的才是最终售价”。以后做菜品折扣、会员价、节日活动时可以扩展 PostOrder 回调不必改这个账本方法。CashChanged 事件后面会接到 UI 刷新上这是第 5 章解决 UI 卡顿的铺垫。4. Unity 里联机餐厅经营游戏同步边界、Host 权威与消息通道4.1 决定联机模式合作共营还是参观展示“可以联机”这几个字范围太大拿到源码后第一步要分模式。合作共营是几个玩家在同一家餐厅同时服务共同上菜、共同结算对消息延迟敏感参观展示模式是各玩各的每隔几秒同步一份快照给对方看带宽成本低写权限也少。联机模式同步粒度客户端写入权限Host 责任合作共营订单级增量消息可点单、可上菜校验订单、广播全员参观展示每 2 到 5 秒一张快照只读定时生成快照并推送确定好模式才能决定消息结构。合作共营模式下同步边界要以“订单生命周期”为单位切分GuestJoin、OrderSubmit、DishServed、PayConfirm、TableRelease。不要把玩家坐标、镜头旋转这类高频字段混进来经营游戏的关键同步对象是桌子和客人不是位置。4.2 用 Unity Transport 做消息载体手写紧凑结构体底层传输网络层我一般选用 Unity Transport 这类官方网络库。它包装了 UDP 的可靠与不可靠通道适合餐厅经营这种“订单消息不能丢但动画位置可以微差”的需求。消息体不建议直接用 JsonUtility 序列化因为大量字符串分配会带来明显 GC 开销。using System.IO; public struct OrderMsg { public ushort seq; public byte tableId; public string guestId; public string dishId; public float logicTime; // 发起方从 GameClock.LogicTime 取的时间戳 public void Write(BinaryWriter w) { w.Write(seq); w.Write(tableId); w.Write(guestId); w.Write(dishId); w.Write(logicTime); } public static OrderMsg Read(BinaryReader r) { OrderMsg msg; msg.seq r.ReadUInt16(); msg.tableId r.ReadByte(); msg.guestId r.ReadString(); msg.dishId r.ReadString(); msg.logicTime r.ReadSingle(); return msg; } }手写 BinaryWriter 序列化的主要收益是可控的分配大小和紧凑的字节流。seq 是发起方递增的序号Host 拿去重和定序logicTime 记录的是逻辑时间而不是客户端实时帧时间这样即使两个客户端帧率不同消息也能在同一时间轴上排序。guestId 与 dishId 用无符号短字符串可以进一步压缩长度但对编辑器调试不友好开发期保留可读文本更好。4.3 Host 权威客户端只发意图Host 改共享状态这里要讲清楚“谁有资格改状态”。餐厅经营不像射击游戏那样可以客户端先行订单牵扯金币和库存必须由 Host 做最终裁决。客户端 A 看到某桌点完餐不能直接把桌子切到 CookingWaiting它只能把 OrderSubmit 发给 Host。public void OnReceiveOrderSubmit(NetSender sender, OrderMsg msg) { TableSlot slot _tables[msg.tableId]; if (slot.state ! TableState.WaitingOrder) return; if (slot.heldByClientId ! sender.clientId) return; slot.dishOrder msg.dishId; slot.state TableState.Cooking; BroadcastDishStart(msg); }这段校验里最重要的是第二行用网络层连接身份而不是消息体里的 guestId来判断操作者有没有这个桌子的控制权。只校验 guestId 的话恶意客户端伪造别人的 ID 就能操作别人桌子。操作权限绑定到连接身份后同一连接即使换 guestId 也无法越权。Host 在烹饪结束后广播 DishServed所有客户端收到消息再切换本地顾客状态。这个模式的代价是每次点单多一次往返延迟换来的是餐厅内所有玩家看到的桌面状态一致。4.4 加入房间与断线重连的快照策略餐厅经营联机有一个独特的进入问题客人状态是缓慢变化的新玩家中途加入如果只收后续增量消息它无法得知“3 号桌已经吃到一半”这样的历史状态。所以 RoomManager 要提供一个快照入口public sealed class RoomManager { public bool TryJoin(string accountId, out string error) { if (_clients.Count _maxPlayers) { error 房间已满; return false; } // 新玩家发送整个餐厅快照老玩家在快照基础上只收增量 SendSnapshotToNewPlayer(accountId); error null; return true; } }断线重连走的是同一个入口只是快照之后要续发重连期间的所有消息。这里要区分游客 ID 与账号 ID游客 ID 存在本地存档里重装会丢账号 ID 绑定服务端才能跨设备恢复身份。很多源码只用游客 ID导致换设备后房间绑定关系全乱。我建议至少留一个 accountId 字段给后期接入账号系统房间授权和好友邀请都用它屏蔽底层连接细节。5. 联机餐厅经营游戏落地UI 事件化刷新、WebGL 存档与对象池5.1 用事件替代轮询治 UI 刷新卡顿餐厅经营游戏 UI 上几乎必有“今日营业额”“库存剩余”“还差几份菜升级”这几类数字。最坏的做法是在 Update 里每帧读取钱数、拼字符串、赋给 Text然后整个列表重建。数据一次变化几十个UI 一帧全部重建Canvas.SendWillRenderCanvases 就成了 CPU 大头这正是“循环数据采集和 UI 刷新卡顿”的典型来源。正确做法是让数值层在业务时刻推送变化。前面 EconomySystem 已经定义了 CashChanged 事件UI 只订阅事件private void OnEnable() { _economy.CashChanged RefreshCashText; } private void OnDisable() { _economy.CashChanged - RefreshCashText; } private void RefreshCashText(int cash) { _cashText.text cash.ToString(); }事件刷新的次数从每帧一次降到每次业务变化一次。经营游戏里一秒最多也就几次结账UI 刷新频率从 60 fps 降到 5 fps 以下性能开销直接少一个数量级。更细一步可以在同一帧内合并多次 CashChanged用 dirty 标志延迟到帧末刷新一次避免多笔订单同一帧连续触发多次重绘。5.2 发布到 WebGL 时的存档写入认识 IDBFS 的边界很多餐厅经营源码会在做完联机后选择发布到 WebGL方便朋友直接打开链接玩。此时会遇到保存进度消失的问题编辑器里 File.WriteAllText 写入的内容发布到浏览器后每次刷新都回到初始状态。这个场景很容易查到类似“unity 发布 webgl 使用 idbfs 写入失败”的报错根源是浏览器没有暴露原生磁盘写接口Unity 在 WebGL 平台的持久化文件系统运行时映射到内存或 IndexedDB时机不对就存不下来。我一般用条件编译把存档路径切分成两套public static void SaveGame(string worldId, string json) { string key $restaurant_{worldId}; #if UNITY_WEBGL !UNITY_EDITOR PlayerPrefs.SetString(key, json); PlayerPrefs.Save(); #else string path System.IO.Path.Combine( Application.persistentDataPath, ${worldId}.json); System.IO.File.WriteAllText(path, json); #endif }PlayerPrefs 在 WebGL 平台底层会落到浏览器存储刷新后仍在。要注意浏览器存储有容量限制大尺寸存档超过 5 到 10 MB 会被拒绝。所以存档要分层玩家账号、金币、已解锁菜谱放进 PlayerPrefs 的小快照餐厅摆盘、访客日志这类大体积数据单独抽出去等后期接云存档再做全量备份。5.3 对象池与增量 GC联机项目最容易忽略的长期考核联机餐厅游戏一旦同时服务多个玩家单位时间顾客生成和销毁的频率会翻倍。每次 Instantiate / Destroy 一个顾客都会有预制体加载、组件初始化、GC 分配三份开销。最直接的优化是上对象池。using System.Collections.Generic; using UnityEngine; public sealed class GuestPool { private readonly StackGuestSM _pool new StackGuestSM(); public GuestSM Acquire(GuestSM prefab, Transform parent) { GuestSM guest _pool.Count 0 ? _pool.Pop() : Object.Instantiate(prefab, parent); guest.gameObject.SetActive(true); return guest; } public void Release(GuestSM guest) { guest.gameObject.SetActive(false); _pool.Push(guest); } }分配热点产生源解决方法订单字符串网络消息转 stringBinaryWriter / BinaryReader 序列化顾客预制体频繁 Instantiate / DestroyGuestPool 对象池菜品图标列表全量加载 UI分帧加载 缓存对象池在餐厅经营项目里带来的直接收益是 GC Alloc 稳定降到近 0。晚市刷五波客人用池化最多新增几十个对象不用池化会产生上百次瞬时的创建和回收峰值。联机时这个峰值会被叠加到网络消息分配上最终表现为每 10 到 15 秒一次的帧率抖动。6. 用网络模拟器验证联机餐厅源码的确定性6.1 给收发入口注入延迟与丢包联机源码本地跑不出问题一拉进真实网络就出事原因通常是本地测试缺少网络条件差异。我习惯在消息收发入口包一层 NetworkSimulator用代码模拟丢包和延迟而不是依赖真实网络碰运气。public sealed class NetworkSimulator { private readonly float _dropRate 0.05f; // 5% 丢包接近较差的Wi-Fi private readonly float _extraDelayMs 80f; // 跨城玩家的典型 RTT public bool ShouldDrop() { return Random.value _dropRate; } }在统一的发送方法里先调用 ShouldDrop如果返回 true 直接丢弃这条消息。配合 80 ms 延迟可以观察两个现象订单消息丢包后客户端有没有重发机制延迟偏高时 Host 裁决是否严格执行。如果源码里没有重发机制丢包后客人就会永远停在等待状态——这一步就能筛掉很多看起来完整的源码。6.2 录制消息并按逻辑时间回放先把网络包落到文件再用回放模式重跑状态机。录制回放要用的时间基准是第 2 章的 GameClock.LogicTime而不是真实钟表秒这样同一份日志在任何机器、任何帧率下回放结果都一样。判定标准只有一条让 A 端和 B 端吃掉同一份网络日志两边餐厅的余额、库存、客人状态一致这套源码才算从“能跑”变成“能联机”。写日志时至少保留 tableId、dishId、seq、logicTime 四个字段丢包重放的问题都能靠这四个字段定位。这套做法比盯着 Profiler 猜更接近问题本质先证明逻辑确定性再讨论延迟和带宽。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。