ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity餐厅经营游戏开发:状态机、协程与对象池实战指南

Unity餐厅经营游戏开发:状态机、协程与对象池实战指南 简介一套基于Unity引擎的C#本科毕业设计源码对应一款支持联机的餐厅经营游戏适合计算机或游戏方向毕业生、Unity初学者参考学习。压缩包共916个文件、约226.81MB以142个C#脚本为核心配合FBX模型、Prefab预制体、Mat材质、PNG贴图及MP3音频等美术资源还包含DLL依赖库、Unity场景和SQL数据库文件可支撑项目运行与二次开发。已有5000人浏览学习。项目采用服务端、客户端、共享工程三个工程协同架构服务端负责数据库管理与数据转发客户端控制游戏表现共享工程封装公用变量与方法核心单例模式设计有效降低代码耦合便于理解多人联机状态同步机制。随包附带的说明文档与完整目录结构能为毕业设计文档撰写、答辩演示和同类游戏开发提供直接参考。1. 餐厅经营源码真正值钱的是状态、数据与对象池不是贴图餐厅经营游戏是 Unity 毕设里看起来简单、做起来最容易翻车的品类之一。你花一周把点餐、上菜、收银的界面画出来以为答辩稳了老师一句“顾客同时点单时卡死怎么办”就能把你问住。高分和低分的差别通常不在 UI 美术而在有没有把“经营循环”用 C# 代码真正控制住顾客状态流转、菜品库存扣减、金币结算、离线存档每一项都是可追问的得分点。这篇笔记把这类源码拆成四块能直接落地的内容状态机驱动的点餐流程、uGUI 菜单网格、顾客对象池、存档与避坑。这里没有下载链接只有你自己能照着搭起来的代码骨架。打算做毕设、课程设计或者想快速理解一份餐厅经营源码的人按下面顺序读就够了。2. 从点餐到收银用状态机和协程把流程写成不会卡死的代码2.1 为什么别在 Update 里写状态判断餐厅最核心的矛盾是同一时间有七八个顾客各自处在不同阶段。如果你在 Update 里写“if(顾客.状态 点餐) 加 if(顾客.状态 用餐)”逻辑一多就到处是 if 嵌套而且“等待出餐”这类时间性状态要靠倒计时变量手动减减到一半切出去再回来就全对不上。更麻烦的是同一帧内多个顾客同时触发结算时Update 里的结算顺序会直接影响金币结果单步调试根本追不过来。我做完一套成品源码后最大的体会是状态机加协程这个组合比任何“高级架构”都适合本科毕设。状态只定义“现在在干什么”协程负责“干完自动跳到下一个状态”。一个顾客从进店到离店是一条从头跑到尾的协程中间用 yield 挂起不需要帧循环去逐帧轮询也不存在“漏掉某帧判断”的问题。答辩时老师问“顾客流程怎么控制”你可以直接照着状态枚举画一条链路出来比讲一堆设计模式更让人信服。2.2 顾客状态枚举与协程主流程从进店到离店一条线先用枚举把顾客生命周期的每个环节定下来再给每个顾客一个结构体保存经营参数。public enum CustomerState { Entering, // 进店 Seating, // 入座 Ordering, // 点餐 Cooking, // 等餐 Eating, // 用餐 Paying, // 结账 Leaving // 离店 } public class Customer { public int tableId; // 坐哪桌 public int orderCount; // 点几个菜 public float patience; // 耐心值超时离店 }接下来是核心服务协程。先把主链路平铺出来方便看状态顺序正式源码里会把“等餐”拆成独立事件避免顾客干等固定秒数。public IEnumerator ServeCustomer(Customer c) { c.state CustomerState.Entering; yield return MoveToSeat(c); // 走到空桌子 c.state CustomerState.Seating; yield return new WaitForSeconds(orderTime); // 看菜单 c.state CustomerState.Ordering; yield return StartOrder(c); // 把订单发给厨房 c.state CustomerState.Cooking; yield return new WaitForSeconds(cookDuration); // 出餐 c.state CustomerState.Eating; yield return new WaitForSeconds(dineDuration); // 用餐 c.state CustomerState.Paying; yield return new WaitForSeconds(payDuration); // 收银 c.state CustomerState.Leaving; }逻辑说明每一段 yield 都让出控制权状态之间的推进由协程自动完成。MoveToSeat 和 StartOrder 内部可以是子协程不影响当前顾客继续等待。真正上线跑的时候StartOrder 这一行会卡住所有玩家的视线所以等餐环节必须换一种设计。参数上orderTime 设 3~5 秒太短顾客还没看清菜单就跳走了dineDuration 设 8~10 秒。耐心值 patience 是另一个独立倒计时我一般放在 Customer 自己的 Update 里扣减超时就主动进入 Leaving和这条服务协程并行这样不会因为主协程卡住导致顾客死等。2.3 厨房异步出餐别让顾客在 Cooking 状态干等固定秒数餐厅后厨要同时处理多桌订单如果让每个顾客在 Cooking 状态等 cookDuration 固定秒数高峰期会出现所有顾客同一秒站起来要菜厨房完全应付不过来。常见做法是让顾客只管“等菜”真正出菜由厨房组件驱动。厨房收到订单后启动自己的协程菜品完成时通过 C# event 通知顾客。public class Kitchen : MonoBehaviour { public event System.Actionint OnDishReady; public void CookForTable(int tableId, float cookTime) { StartCoroutine(CookRoutine(tableId, cookTime)); } private IEnumerator CookRoutine(int tableId, float cookTime) { yield return new WaitForSeconds(cookTime); OnDishReady?.Invoke(tableId); } }逻辑说明顾客协程在 Cooking 状态处等待一个信号而不是自己数秒。厨房同时开启多份协程每份负责一桌哪个菜先好就先通知哪桌。顾客只需要监听 OnDishReady判断 tableId 是不是自己再切换到 Eating。参数上大盘菜 8~12 秒、小吃 3~5 秒把这一层开销放在 FoodItem 的 cookTime 字段上而不是写死在顾客协程里。这样后厨出菜节奏就和玩家的操作压力彻底解耦了。2.4 timeScale 暂停的坑WaitForSeconds 会跟着暂停一起卡死用协程最常见的翻车点是暂停。Unity 的 Time.timeScale 设为 0 时WaitForSeconds 不再计时如果你用“暂停面板”把全局 timeScale 归零所有顾客的协程会全部冻结看起来就像游戏卡死。老师第一次点暂停再点继续发现顾客全部停在原状态十几秒追问一句“暂停为什么会影响顾客”这就是答辩现场。我一般不在餐厅经营里让顾客跟着暂停走。暂停菜单只锁定玩家输入不修改 timeScale顾客流程继续跑玩家回来后发现自己错过了一波客流经营压力反而更真实。如果一定要做“完全暂停”把顾客协程里的 WaitForSeconds 换成 WaitForSecondsRealtime或者用下面的循环绕开 timeScalepublic IEnumerator HandlePause() { while (Time.timeScale 0f) yield return null; }还要注意“暂停菜单”和“切后台”是两回事。切后台用 OnApplicationPause不要和菜单暂停共用一套 timeScale否则手机锁屏再开顾客状态会乱成一锅粥。注意timeScale 只影响受缩放的对象。你可能会看到某些 UI 动画用了 unscaledDeltaTime两者混用时表现不一致排查时先分清哪些逻辑受 timeScale 控制。3. 菜单与金币用 uGUI 网格和格式化文本撑起经营界面3.1 库存与金币刷新数字文本别在 Update 里重建字符串餐厅游戏的 UI 高频点是金币、当日营收、菜品剩余份数。常见错误是在 Update 里直接写 moneyText.text money.ToString()这样能跑但会产生大量字符串分配金币没变化时也在刷新。更隐蔽的问题是每次 ToString 都会创建临时对象游戏跑到后期会变成 GC 压力源。我把数值变化收敛到一个方法里只有库存、金币、好感度三者之一真的变化时才调用 RefreshPanel()。判断用一个脏标记避免每帧都重复执行void Update() { if (_lastCoins ! game.Coins || _lastStock ! game.TotalStock) { _lastCoins game.Coins; _lastStock game.TotalStock; RefreshPanel(); } }金币数字超过一万后还要考虑显示宽度。我常用 FormatNumber 把 12800 转成“1.3万”这样资产负债表瞬间短一截public static string FormatNumber(int value) { if (value 10000) return (value / 10000f).ToString(0.0) 万; return value.ToString(); }逻辑说明ToString(0.0) 保留一位小数避免出现“1.2345万”这种溢出格子的文本小于一万时直接用整数玩家读取速度最快。如果你想做数字滚轮动画DOTween 里来回插值数字时最终写回文本的也应该是这个格式化方法不要在动画内部重建字符串。金币刷新这块代码简不简单是一回事答辩时能说出“我控制住了 GC Alloc”是比 UI 好看更实际的加分项。3.2 菜品网格用 GridLayoutGroup 打底卡片复用而不是销毁重建菜谱页、仓库页在餐厅经营里长得都像网格。uGUI 里最省事的做法是 ScrollRect 加 GridLayoutGroup 挂在 Content 上设置好 Cell Size、Spacing 和 Constraint 后子物体自动排布。这比任何第三方控件都稳定也最能解释给答辩老师听。关键是刷新时要“先复用再补充”而不是销毁重建public void Refresh(ListFoodItem foods) { for (int i 0; i grid.childCount; i) { var card grid.GetChild(i).GetComponentFoodCard(); bool active i foods.Count; card.gameObject.SetActive(active); if (active) card.Bind(foods[i]); } while (grid.childCount foods.Count) Instantiate(cardPrefab, grid.transform); }逻辑说明先隐藏多余卡片再补建缺失卡片这样同一批卡片在刷新时不会触发大量 InstantiateScrollRect 的位置也不会因为 Content 被整体重建而跳回顶部。如果你想把“背包物品”和“菜单菜品”放在同一个容器里只需要在切换时调用不同的 Refresh 重绑数据源。参数上我一般用 Cell Size 120x160、Spacing 4x8、Constraint 固定每行 4 个超过一屏交给 ScrollRect 滚动。这个组合在 900x1600 的竖屏 UI 下最不容易出空白。另一种是 Constraint 设成 Flexible让列数跟着 Content 宽度走横屏时自动变多列但毕设场景下固定列数更好控制也方便做分页。分页要简单一点的话把 pageSize 定为每页数量切换页时用 GetRange 截取当前页数据再刷新int pageIndex; public void ShowPage(int page) { int start page * pageSize; int count Mathf.Min(pageSize, foods.Count - start); pageIndex page; Refresh(foods.GetRange(start, count)); }这里的 pageSize 我设 12 或 16配合每行 4 个正好三到四行。要注意 GetRange 在剩余数量不足时不能越界所以 count 用 Mathf.Min 收住。3.3 下单扣库存结账加金币两个时机不能写反金币和库存的联动里最容易翻车的点是时机。菜品份数要在“下单完成”时扣减不能等吃完再扣金币要在“顾客完成支付”时才增加不能上菜时就到账。这两个顺序写反就会出现“顾客吃完了但库存没少金币还在涨”的账目漏洞。我一般会让订单组件在 StartOrder 成功后立刻扣库存并抛一个 OnStockChanged 事件出来刷新 UI金币结算单独放在收银流程的支付成功分支里由收银台组件调用。这样库存、金币、订单三块数据各有一位负责人不会在一个方法里纠缠。老师如果问“账目怎么保证一致”把这条链路讲清楚就够了。4. 顾客生成与销毁对象池与批次参数控制住 GC 和难度曲线4.1 为什么用对象池而不是直接 Instantiate/Destroy餐厅每隔几秒来一批顾客玩到高峰期一桌接一桌。如果在每批生成时 Instantiate顾客离开时 DestroyGC 压力会随时间累积而且 Destroy 的瞬间如果顾客身上还挂着协程控制台会刷出一整屏 MissingReferenceException。对象池的思路是预先创建一批顾客预制体不活跃时 SetActive(false) 收回去下次 Spawn 直接激活复用。这个动作同时解决内存分配和“空引用”两个问题。public class CustomerPool { private QueueCustomer _free new QueueCustomer(); public Customer Spawn() { Customer c _free.Count 0 ? _free.Dequeue() : CreateNew(); c.gameObject.SetActive(true); return c; } public void Despawn(Customer c) { c.StopAllCoroutines(); c.gameObject.SetActive(false); _free.Enqueue(c); } }逻辑说明Despawn 里的 StopAllCoroutines 是很多人漏掉的一行。顾客离开时旧协程可能还没走完直接 SetActive(false) 再激活旧状态会接着跑出现“顾客自动完成点餐、自动结账”的诡异表现。队列容量建议设置为同时接待上限加 4防止高峰期新顾客 Spawn 时临时新建造成卡顿。如果你有普通顾客和 VIP 顾客两种预制体记得分开两个池子别混着复用。4.2 批次生成参数难度曲线不是随机数顾客到达不能靠纯随机数否则会出现三分钟没人来、下一秒挤进来八个人的局面。我把生成逻辑做成一个 BatchSpawner用三个参数控制节奏单批人数、批次间隔、同时接待上限。前中期让单批 5~8 人、间隔 8~15 秒玩家有足够时间上菜后期把间隔压到 4~6 秒人数提到 8~10压力才会上来。public void SpawnBatch(int count, float interval) { StartCoroutine(SpawnRoutine(count, interval)); } private IEnumerator SpawnRoutine(int count, float interval) { int spawned 0; while (spawned count) { if (GameManager.Instance.ActiveCustomers maxActive) { yield return new WaitForSeconds(0.5f); continue; } var c _pool.Spawn(); StartCoroutine(ServeCustomer(c)); spawned; yield return new WaitForSeconds(interval); } }逻辑说明spawned 控制总批次人数interval 控制每个顾客到达间隔当同时接待人数打满时让生成循环短暂等待而不是无限堆积。这些参数放在 ScriptableObject 上而不是直接写死改难度曲线时不用动代码。答辩被问“难度怎么调”时直接把这个参数表调出来讲比说“凭感觉调”强不少。4.3 顾客离店动画让“逐渐消失”不破坏回收顾客离店如果直接 SetActive(false)模型和 UI 的透明渐变都会跳帧。我一般在顾客预制体上挂一个 CanvasGroupLeaving 状态下先播放退出动画播放完再调用 Despawn。关键细节是淡出过程中要关闭顾客的点击交互防止“消失了一半还能被拖拽”Despawn 前还要把动画组件、事件监听全部清干净确保下次从池子里拿出来是一个全新的角色。实现上可以给顾客一个 LeaveAndDespawn 协程public IEnumerator LeaveAndDespawn() { state CustomerState.Leaving; while (canvasGroup.alpha 0f) { canvasGroup.alpha - Time.deltaTime / fadeDuration; yield return null; } _pool.Despawn(this); }逻辑说明fadeDuration 我设 0.3~0.5 秒太短看不出淡出太长会拖慢座位释放。动画结束只走对象池的 Despawn不走场景里手动 Destroy配合前面的 StopAllCoroutines才能保证“逐渐消失”和“回收入池”这两条路径只有一条会执行。对象池解决了生命周期问题这层小逻辑解决的是最后一步的边界少了它高频率翻台时会出现顾客瞬移、卡片串数据等灵异现象。5. 存档与避坑经营数据落盘与 4 个现场翻车点的排查5.1 存档选型PlayerPrefs 加 JSON 最省事但要套 Wrapper本科毕设做存档用 PlayerPrefs 存整个游戏已经够了。它本质是键值存储只可靠处理字符串、整数和浮点所以复杂对象要先序列化成 JSON。C# 自带的 JsonUtility 有个老毛病不能直接序列化 List 一序列化就出来空数组。写过 C# 序列化的人都有印象这不怪 UnityString 是 .NET 顺手能序列化List 是泛型集合JsonUtility 一直支持得不够好。解决办法是先包一层。[Serializable] public class GameSaveData { public int coins; public int reputation; public Listint menuUnlocked new Listint(); } [Serializable] public class SaveWrapper { public GameSaveData data; } public static class SaveManager { public static void Save(GameSaveData data) { var wrapper new SaveWrapper { data data }; PlayerPrefs.SetString(save, JsonUtility.ToJson(wrapper)); PlayerPrefs.Save(); } public static GameSaveData Load() { if (!PlayerPrefs.HasKey(save)) return NewGame(); try { var wrapper JsonUtility.FromJsonSaveWrapper(PlayerPrefs.GetString(save)); return wrapper.data; } catch { return NewGame(); } } }逻辑说明SaveWrapper 是专门给 JsonUtility 做的壳因为 JsonUtility 对根节点数组和字典支持很差但对“包含一个对象”的包装类支持良好。Load 里的 try-catch 一定要留存档文件损坏时回退默认值比抛异常让整个游戏崩掉健康得多。参数上PlayerPrefs.Save() 在部分平台是同步写盘频繁调用会有 IO 开销。我一般只在每日结算、手动存档、退出游戏三个时机调用不每个金币变动都存。再给存档结构里加一个 version 字段Load 时校验版本如果以后迭代菜品表老档还能自动迁移。注意如果菜品数据量很大别硬塞进 PlayerPrefs直接写一个 JSON 文件到 Application.persistentDataPath 更合适。PlayerPrefs 适合几十 KB 的小档不适合存图鉴和整个菜单表。5.2 踩坑 1顾客卡在“等待结账”不动现象顾客吃完饭站在收银台前后面所有顾客全部堵住过几分钟屏幕上一串人静止。原因最开始只给服务员做了状态机没给结账流程设计队列收银台一次只能处理一个人但 Paying 状态没有等待逻辑两个顾客同时进入就互相覆盖。解决给收银台加一个容量信号量用 int 记录当前正在结账的人数Paying 状态进入前先等空位。这样比实现一套完整队列省事得多yield return new WaitWhile(() cashierBusy cashierSlots); cashierBusy; // 执行结账逻辑 cashierBusy--;逻辑说明cashierSlots 设为 1 就是单收银台设为 2 就是双收银台。信号量方式的好处是回收和排队逻辑都收敛在同一个人计数器上不会出现“两顾客同时到达但只有一个买单”的并发问题。5.3 踩坑 2场景切换后动态生成的 UI 全部报空引用现象从主场景切到经营场景再切回来动态生成的卡片全没了点击报“Object reference not set”。原因代码里用的是场景中手动拖拽的引用动态对象在场景卸载时被销毁场景再加载后引用不会自动恢复。这个问题通常不在第一轮测试出现而是玩到中途切换场景时才暴露排查起来很费劲。解决凡是动态生成的 UI 预制体都放进 Resources 文件夹运行时用 Resources.Load 实例化不在场景里留硬引用。这样场景切换只影响场景自带物体动态 UI 随时能从资源重新加载。这个改动顺手还解决了场景文件被误改后脚本丢失的问题。5.4 踩坑 3存档读档后菜品解锁状态回退现象今天解锁了第三道菜明天打开存档变回第一道。原因Enum 在 JsonUtility 里按数字存储如果中途调过枚举顺序旧存档里的数字对应关系就会错位某些非法数值没被拦截直接进了游戏逻辑。这个坑最容易在“菜品/顾客类型”这类枚举上出现因为开发中加新菜是常态。解决Load 后统一做一次合法性校验不在 Enum.IsDefined 范围内的值回退默认。更稳的做法是写映射字典把枚举值转成字符串后存档。字符串占空间多一点但彻底告别“换版本读错档”的问题。5.5 踩坑 4同一个订单被提交两次厨房出双份菜现象厨房同时出两份同款菜库存扣了两次顾客还吃不完而且金币结算看起来没问题账对不上。原因下单按钮的点击事件里 StartCoroutine 没有做互斥顾客在 Ordering 状态停留期间按钮被连点两次协程也跟着起了两次。解决在协程入口用一个 bool 做互斥协程结束再放开if (_isOrdering) return; _isOrdering true; yield return StartCoroutine(ServeCustomer(c)); _isOrdering false;逻辑说明餐厅经营的订单提交务必做防抖。这道防线看起来简单但它同时挡住了双击、长按、鼠标中键连点三种真实输入比在按钮上写“点击后禁用又要定时恢复”的方案干净得多。答辩证如果问重复提交把这个互斥逻辑讲清楚能少一段黑匣子的追问。6. 验证经营循环模拟营业一天再盯住 Profiler 的 GC Alloc 收尾6.1 做一个只在编辑器里运行的自动营业模拟器手动打一局很难踩出“库存变负、金币暴涨”这些边界问题。我习惯在 Unity 菜单栏里加一项自动化营业模拟让游戏自动生成顾客、自动送单、自动结账连续跑几十批后输出经营数据[MenuItem(Tools/模拟营业一天)] public static void RunSimulation() { var game FindObjectOfTypeGameManager(); var sw System.Diagnostics.Stopwatch.StartNew(); for (int batch 0; batch 20; batch) { game.SpawnBatch(6, 2.0f); } sw.Stop(); Debug.Log($模拟耗时 {sw.ElapsedMilliseconds} ms金币 {game.Coins}负库存 {game.NegativeStockCount} 次); }逻辑说明SpawnBatch 是第四章说到的批次生成入口传参是单批 6 人、间隔 2 秒。Stopwatch 记录时间跑完打印金币和负库存次数只有金币不变负、库存不为负、离店顾客数等于生成数才算通过。这个菜单项只在编辑器存在不影响打包后玩家体验是纯开发工具。6.2 Profiler 盯两个热点协程 MoveNext 和字符串拼接餐厅经营最容易产生 GC 的地方不是 Instantiate而是每帧字符串拼接和不合理协程状态机的 MoveNext。打开 Profiler 的 CPU Usage 模块看每帧 GC Alloc 列如果数值随顾客数量线性增长多半是金币文本在 Update 里反复 ToString或者协程每一步都构建了临时数组。把文本刷新改成缓存 StringBuilder 后GC 会肉眼可见地掉下去。6.3 粒子特效记得先停止再回池餐厅经营如果加了冒烟、火焰这类烹饪粒子特效回收时不能只 SetActive(false)要先把 ParticleSystem 的 emitting 关掉再回池否则粒子系统会在后台继续模拟形成越玩越慢的现象。这是网上搜“粒子特效内存泄露 unity”时最常见的原因也是我在对象池框架里加注释最多的地方。我自己的教训是每次改完数值表先跑一遍模拟营业再开 Profiler 确认 GC Alloc 没有异常增长最后才交到导师手里。这套验证习惯帮我少在答辩现场翻车希望帮到你。本文还有配套的精品资源点击获取
返回列表