ARTICLE DETAIL

资讯详情

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

Unity餐厅经营游戏毕业设计:状态机、协程与存档架构解析

Unity餐厅经营游戏毕业设计:状态机、协程与存档架构解析 简介这是一份基于Unity引擎、采用C#语言开发的餐厅经营游戏完整毕业设计项目适合计算机相关专业学生完成毕业设计也适合零基础Unity开发者通过完整案例学习游戏开发流程。压缩包共包含900个文件大小约218MB覆盖142个C#脚本、33个FBX模型、21个预制体、24个资源文件以及DLL库、贴图、音频等类型脚本与预制体对应清晰便于理解游戏逻辑与场景组织的对应关系。项目内容包含餐厅场景搭建、UI交互系统、顾客点菜烹饪上菜支付流程、物理碰撞与动画控制、背景音乐管理等核心模块并提供性能优化、跨平台发布配置可直接在Unity中打开运行并二次开发。目前已有1418人学习下载足以证明其实用性与参考价值是系统掌握Unity游戏开发与C#编程实践的扎实素材。1. 基于Unity的餐厅经营游戏毕业设计本质是一套业务状态机的实现餐厅经营游戏在毕设选题里出现频率极高玩法一句话就能说清——顾客进门、下单、后厨做菜、上菜收钱。视觉上比3D动作游戏好糊弄但动手做才会发现卡住进度的从来不是贴图和动画而是顾客等待超时走人多个灶台同时开工的调度营业额和声誉值的计算关掉游戏再开数据还在不在这一类交叉状态的纠缠。处理这些靠的是C#面向对象设计、Unity生命周期管理、事件驱动的UI刷新和数据持久化恰好是答辩评委会连续追问的四个方向。本文不贴完整工程只把一套经得起追问的架构讲清楚订单状态机怎么写、协程与Update怎么分工、UI刷新为什么会卡、存档到底选哪种方案。零基础的同学可以照着把目录结构搭成可运行Demo有一两年Unity经验的人也能在参数和边界条件里找到可对比的写法。每一节尽量给出能直接粘贴的最小代码块但更重要的是说清楚代码背后的取舍理由——答辩时为什么这么设计往往比怎么实现更值分。2. 订单链路设计把顾客、菜品、柜台抽象成可测试的C#数据模型2.1 逻辑层与表现层分开是餐厅游戏不烂尾的前提餐厅游戏最常见的翻车姿势是把顾客直接做成一个预制体把所有属性和行为全部塞进同一个MonoBehaviour脚本里。单人写Demo时没问题但一旦要加排队、入座、点单、吃饭、结账五个阶段这个类会膨胀到上千行每改一个需求都要通读全文件改到后期甚至不敢动任何一行。我在处理这类项目时一般会强制自己遵守一条原则顾客的数据和顾客的外观是两个对象。数据用纯C#类承载不继承MonoBehaviour不依赖场景物体外观才用MonoBehaviour挂在预制体上监听数据变化去播放动画、移动位置。这样做有三个直接收益数据类可以脱离Unity编辑器写单元测试换UI皮肤、换模型完全不碰逻辑代码多人协作时两个人改同一个脚本的概率大幅下降。这条划分本身就是C#面向对象里单一职责的直接体现。答辩被问为什么这么设计时从职责分离和可测试性两个词展开说比回答看教程这么写的有说服力得多。餐厅经营看似是游戏实际上一半的工程量是在管理业务对象的生命周期和状态流转把数据与表现解耦越早后期加功能越轻松。2.2 菜品配置用ScriptableObject订单实例用普通类菜品在游戏里属于静态配置——名字、成本、售价、制作时长、需要占几个灶台这些数据在游戏运行过程中不会变化。这种数据用ScriptableObject承载最合适可以在Inspector面板直接编辑也可以导出成Asset文件让策划单独调整数值而不用改一行代码// RecipeData.cs —— 菜品静态配置作为Asset存在Project窗口不挂在场景物体上 [CreateAssetMenu(fileName NewRecipe, menuName Restaurant/Recipe)] public class RecipeData : ScriptableObject { public string recipeName; // 菜品名用于菜单和订单UI展示 public float cookTime; // 制作时长单位秒 public int price; // 售价 public int cost; // 成本计算毛利用 public bool needStove; // 是否占用灶台影响后厨调度 public Sprite icon; // 菜单图标 }订单则属于运行期实例每次顾客点单都会new一个全新的对象。订单的状态会随时间变化所以不能直接复用ScriptableObject实例否则一个订单改了状态等于所有订单一起变。订单用普通C#类承载不进场景、不挂组件public enum OrderState { Waiting, Cooking, Serving, Finished, Failed } public class Order { public RecipeData recipe; // 引用配置不存副本 public float timer; // 当前阶段剩余时间 public OrderState state; // 订单当前状态 public int seatIndex; // 占用的座位编号结算和排队要用 public Order(RecipeData recipe, int seatIndex) { this.recipe recipe; this.seatIndex seatIndex; state OrderState.Waiting; } }这里的关键点在于Order类不继承MonoBehaviour意味着它没有自己的Update回调生命周期完全由外部的OrderManager驱动。这样设计的灵活性很大想实现暂停、后台加速、回放只需要控制管理器对Tick方法的调用频率不需要去逐个找场景里的脚本。Order持有RecipeData的引用而不是复制一份菜品字段也避免了配置改了实例没同步的典型数据不一致问题。2.3 订单状态机switch枚举足够字典状态类属于过度设计订单流转是典型的有限状态机FSM。最小实现不需要引入第三方库枚举加switch在Unity项目里完全够用代码可读性也是所有方案里最好的// OrderManager.cs —— 每帧驱动所有活跃订单的状态推进 public class OrderManager : MonoBehaviour { private ListOrder activeOrders new ListOrder(); private void Update() { float dt Time.deltaTime; // 倒序遍历因为移除元素不影响前面未遍历的索引 for (int i activeOrders.Count - 1; i 0; i--) { Order order activeOrders[i]; switch (order.state) { case OrderState.Waiting: order.timer - dt; if (order.timer 0f) ChangeState(order, OrderState.Failed); // 等待超时顾客离开 break; case OrderState.Cooking: order.timer - dt; if (order.timer 0f) ChangeState(order, OrderState.Serving); // 做完了可上菜 break; case OrderState.Serving: // 等待玩家点击上菜按钮由外部调用MarkFinished触发切换 break; case OrderState.Finished: case OrderState.Failed: OnOrderEnded(order); // 释放座位、结算金币 activeOrders.RemoveAt(i); // 终结态移出活跃列表 break; } } } private void ChangeState(Order order, OrderState newState) { // 统一的退出/进入钩子可以在这里加音效、动画触发 order.state newState; } }注意代码里用了倒序遍历列表因为正序遍历时RemoveAt会让后面元素前移导致跳过一个未处理的订单——这个细节在很多C#面试题里都会出现放到答辩里解释反而成了加分点。状态机的核心价值在于每个阶段的进入条件、退出条件、时长约束都被代码固定住了不会出现顾客已经在吃饭了还在跳点单状态这种逻辑错乱。提示状态机里最容易忽略的是状态退出时要做的收尾。比如Failed时要释放座位、把排队顾客往前挪、扣除声誉值Finished时要结算金币并刷新UI。建议所有状态切换统一走ChangeState方法在里面先执行旧状态Exit逻辑再赋新值不要在多个地方直接给state字段赋值否则收尾逻辑会漏。3. 经营节奏控制计时器、协程与事件驱动的UI刷新3.1 事件系统解耦数据变了和界面要更新餐厅经营游戏的UI几乎全是响应式的金币变了要刷新顶部文本订单进度变了要刷进度条顾客入座了要刷座位状态。如果每个UI组件都在Update里每帧轮询数值场景里挂几十个脚本就不可避免出现性能浪费和改一个UI要连带改三处代码的维护噩梦。推荐的做法是给核心数据模型提供C#事件UI组件在OnEnable时订阅、OnDisable时退订数据变化时统一触发通知——这是C#事件机制在Unity里的标准应用方式// GameState.cs —— 全局经营数据单一数据源外部只能通过方法修改 public class GameState { public int Gold { get; private set; } public event System.Actionint OnGoldChanged; // 声明事件只能 / - public void AddGold(int amount) { Gold amount; OnGoldChanged?.Invoke(Gold); // 通知所有订阅者 } }UI侧的文本组件只需要订阅一次void OnEnable() GameState.Instance.OnGoldChanged Refresh; void OnDisable() GameState.Instance.OnGoldChanged - Refresh; void Refresh(int gold) goldText.text gold.ToString();事件驱动的收益很直接数据源只有一个改数据的入口统一UI不需要关心数据从哪来也不需要在Update里做逐帧比对。很多同学遇到的C#循环数据采集和UI刷新卡顿问题本质原因往往不是循环本身慢而是每次循环都直接去改Text组件的text属性导致Canvas反复重建。事件机制天然支持把高频数据变化和低频UI渲染拆开统一在事件回调里节流卡顿就消失了。3.2 协程做一次性流程Update做持续状态推进Unity里计时有三种主流写法Update里累计deltaTime、协程等待、InvokeRepeating定时调用。三种都能跑但各自适用的场景边界很清晰写法适合场景主要限制Update累计时间状态机每帧推进、持续倒计时代码需要拆散在各分支暂停逻辑要自己控制协程 IEnumerator顺序流程先延迟再执行MonoBehaviour被Disable时协程自动终止高频循环中yield有调度开销InvokeRepeating固定间隔调用的简化写法不能传参数CancelInvoke容易漏调新项目不推荐餐厅经营里顾客等待超时倒计时属于持续存在、每帧都要检查的状态我用Update推进菜品制作完成之后延迟0.5秒端上桌这种一次性延迟用协程写出来最直观IEnumerator ServeRoutine(Transform dish, Vector3 targetPos) { yield return new WaitForSeconds(0.5f); // 出餐延迟模拟端菜动作 dish.gameObject.SetActive(true); float t 0f; float duration 0.4f; Vector3 start dish.position; while (t 1f) // 手动插值移动不受Time.timeScale影响 { t Time.deltaTime / duration; dish.position Vector3.Lerp(start, targetPos, Mathf.SmoothStep(0f, 1f, t)); yield return null; } }需要特别强调的是协程不是线程它依然运行在主线程只是把连续代码拆成了多个帧片段依次执行。协程里不能做大量计算的假象经常误导新人——放在协程里的重计算该卡帧还是卡帧。需要真并行时应该用Unity Job System或C# Task但餐厅经营游戏的数据规模远不到需要那层复杂度的程度答辩时如实说协程解决的是流程编排而非并行计算反而显得认知准确。3.3 UI动态列表不刷新问题多半在LayoutRebuilder餐厅游戏的订单列表、背包列表、顾客排队列表都属于运行时动态增删的UI。很多同学用VerticalLayoutGroup加ContentSizeFitter组合后运行时Add子物体发现位置错乱、互相重叠这就是典型的unity vertical layout group没刷新问题布局组件没有在内容变化后的正确时机重建。直接调用强制重建接口能解决大多数情况// 在往列表容器添加或移除子物体后调用 LayoutRebuilder.ForceRebuildLayoutImmediate( orderListRectTransform as RectTransform );另一种更容易踩的坑和遮挡有关游戏里弹窗面板显示时点击事件穿透到下层按钮触发你不想触发的操作。排查思路是检查是否有一个全屏半透明Image挡在弹窗下面它默认勾选了Raycast Target把Image的Raycast Target取消勾选即可或者把底层Button的interactable在弹窗显示期间设为false。这套排查顺序对餐厅经营里点关闭按钮却打开了厨房面板这类Bug非常有效。4. 数据持久化与场景管理存档、读档与跨场景传递4.1 存档方案选型PlayerPrefs到SQLite的取舍餐厅经营游戏存档通常要存金币、天数、声誉、已解锁菜品、当前关卡进度属于中等结构化数据。选型时有一个比较清晰的梯度方案数据量可读性复杂查询适用场景PlayerPrefs极少量键值差不支持音量、画质设置项JsonUtility File中型存档好不支持单机游戏存档首选Newtonsoft.Json File中型存档好不支持存档结构包含Dictionary、枚举等复杂类型时SQLite大量结构化数据中支持排行榜、行为日志、多存档管理毕业设计级别的餐厅经营游戏JsonUtility加Application.persistentDataPath就足够不需要引第三方包。JsonUtility的优点是内置、对[Serializable]类直接序列化缺点是Dictionary不支持、只序列化公开字段不序列化属性、枚举序列化成数字。所以我把存档类全部定义成纯字段结构涉及键值对的时候就手动转成List包装类避免序列化跑偏。4.2 用泛型存档管理器统一读写不要在业务代码里碰File API每个业务模块各写一套存档逻辑很容易出现金币存A文件、菜品进度存B文件的碎片化问题。正确姿势是封装一个泛型存档入口全项目所有存档走同一个方法// SaveSystem.cs —— 泛型存档读写入口所有存档结构统一走这里 public static class SaveSystem { private static string GetSavePath(string fileName) { return Path.Combine(Application.persistentDataPath, fileName); } public static void SaveT(string fileName, T data) { try { string json JsonUtility.ToJson(data); // 对象序列化成JSON字符串 File.WriteAllText(GetSavePath(fileName), json); } catch (System.Exception e) { Debug.LogError($存档写入失败: {e.Message}); } } public static T LoadT(string fileName) where T : new() { string path GetSavePath(fileName); if (!File.Exists(path)) return new T(); // 首次启动返回默认存档 string json File.ReadAllText(path); return JsonUtility.FromJsonT(json); // 反序列化还原对象 } }存档数据类这样组织注意字段全部公开且带默认值[Serializable] public class SaveData { public int gold; // 当前金币 public int day; // 经营天数 public int reputation; // 声誉值影响顾客流量 public Liststring unlockedRecipes; // 存菜品id而不是菜品对象 public string playerName; // 预留的玩家名 public SaveData() // 构造函数提供默认值 { gold 500; day 1; reputation 50; unlockedRecipes new Liststring(); } }unlockedRecipes里存字符串id而不是整个RecipeData对象原因是ScriptableObject属于编辑器资源运行时的对象引用在下次启动时会失效。存档只保存钥匙启动时再根据id从Resources或Addressables加载配置这是Unity游戏存档的标准做法。如果答辩被追问为什么不用PlayerPrefs直接存金币可以顺势讲清两个点PlayerPrefs在WebGL平台依赖localStorage数据量限制明显而persistentDataPath在WebGL下对应IDBFS文件系统写入时机有异步限制桌面端和Web端不能共用一套理解方式用文件方案统一处理多平台是更稳妥的选择。4.3 跨场景单例DontDestroyOnLoad的正确姿势餐厅经营一般会有标题场景、经营场景、结算场景跨场景共享经营数据需要一个常驻管理器。最简单的实现是静态单例加DontDestroyOnLoad// GameManager.cs —— 全局单例跨场景持有经营数据 public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public SaveData CurrentSave { get; private set; } private void Awake() { // 已有实例则销毁当前对象防止场景重复加载时出现双管理器 if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); CurrentSave SaveSystem.LoadSaveData(restaurant_save.json); } private void OnApplicationQuit() { SaveGame(); } public void SaveGame() { SaveSystem.Save(restaurant_save.json, CurrentSave); } }Awake里先判断Instance是否已存在再进行销毁是防止场景重复加载造成两个管理器互相覆盖存档的经典写法。OnApplicationQuit做兜底存档可行但移动端切后台时不一定触发OnApplicationQuit所以一定要在关键业务节点显式调用SaveGame比如每天营业结算完、玩家退出经营场景前。注意DontDestroyOnLoad对象不要直接持有任何场景内物体的引用。切换场景后旧引用指向的物体已销毁访问会抛MissingReferenceException。需要引用场景物体时用事件订阅或者运行时动态查找不要存引用到常驻对象里。5. 答辩演示的验证路径性能指标、高频坑位与临场脚本5.1 自测版本盯住三个性能指标餐厅经营演示翻车多半不是玩法逻辑问题而是卡顿。自测时Build一个Development Build版本连接Profiler重点盯三组数字目标平台60FPS下帧数是否稳定Heap Used内存是否持续上涨上涨多半是对象只增不减的泄漏Draw Call总数是否在300以内。Draw Call超标时优先合并同材质贴图制作Sprite Atlas把餐具、食材、桌面的零散贴图合图视觉几乎无变化但Draw Call能砍一半。5.2 高频坑位排查清单Time.timeScale与协程营业暂停功能直接设timeScale为0后协程里的WaitForSeconds也停了后厨流程一起冻死。暂停应该只切UI层逻辑层计时用独立于timeScale的累加值。对象池缺失顾客、盘子这类高频生成销毁的对象直接Instantiate会产生明显的GC Alloc和瞬卡。用ObjectPool模式顾客和餐盘各建一个池回收时SetActive(false)取出时重置状态代码量不大但效果立竿见影。阴影开销移动端目标下大量小物体开实时阴影会让批次翻倍。经营场景里把光源Shadow Type设成No Shadow或只保留主光源阴影帧数提升非常显著。Layout组件打架VerticalLayoutGroup和ContentSizeFitter同时挂在同一节点运行时动态增删子物体会出现白屏闪烁和错位。保留LayoutGroup移除同节点的ContentSizeFitter改由代码在数据变化后调ForceRebuildLayoutImmediate。5.3 准备一个进行中的存档演示从这里开始答辩演示不要从标题界面和创建新游戏开始而是准备一个进行到第5天、金币充足、已解锁3个菜品的存档文件。开场先展示读档并说明存档文件位置接着走完一遍接单、做菜、上菜、结算的完整循环然后故意把等待时间调短触发超时离店演示状态机对异常分支的处理最后切到Profiler展示一帧内Update和UI重建的耗时分布。评委对能正确处理异常分支并且讲得清原因的演示认可度远高于只跑正常流程。被追问等待超时时间为什么取5秒时能答出这个值是按顾客耐心值分段线性递减设计的不是拍脑袋定的就已经把餐厅经营游戏从做了一个Demo拉升到了设计了一个系统的评价档位。本文还有配套的精品资源点击获取
返回列表