Unity餐厅游戏毕设:C#分层架构与SQLite工程实践
简介本资源是一套完整的本科毕业设计项目面向计算机、软件工程及数字媒体技术等专业学生提供基于Unity引擎的联机餐厅经营游戏从开发到交付的全流程实践方案。项目采用C#语言开发集成TCP/IP网络通信实现多客户端联机交互MySQL数据库持久化存储游戏数据并借助Blender完成角色与场景建模涵盖服务端、客户端及共享工程三大模块结构清晰、工程规范适合作为课程设计、毕设参考或Unity网络编程进阶学习范例。压缩包共922个文件含142个C#脚本核心逻辑、33个FBX模型Blender导出、21个Prefab场景对象预制体、44个PNG纹理及7个MP3音效等整体224.19MB内容完整可直接运行调试。已有275人学习下载资源附带毕业设计文档、演示视频及可执行程序便于理解架构设计、快速复现效果并拓展二次开发。1. 为什么一个用 Unity 做的 C# 餐厅经营游戏能稳过本科毕设答辩——它不是“小游戏”而是软工全流程的微型沙盒很多同学拿到“基于 Unity 的餐厅经营游戏”这个毕设题目时第一反应是不就是拖几个预制体、写点点击逻辑真动手才发现光是让顾客从门口走进来、走到空桌、坐下、点单、等上菜、吃完离店这六个动作闭环就卡在状态机设计、路径寻路冲突、UI 与数据模型同步三座大山前。更别说数据库要存菜品分类、库存变动、订单流水、员工排班Unity 端要对接 SQLite 或轻量服务层毕业设计文档里还得讲清 MVC 分层怎么切、UML 图怎么画、测试用例怎么覆盖边界条件。这不是炫技项目而是一个被压缩到 3 个月周期里的、带真实数据流和用户交互的完整软件工程切片——它考的不是你会不会写StartCoroutine而是你能不能把“顾客想吃番茄牛腩面”这个业务需求一锤一钉地落到 C# 类设计、Unity 场景事件、SQL 表结构、以及答辩 PPT 的第 7 页流程图上。适合那些已经学完 C# 基础、接触过 Unity 编辑器但没做过跨模块联调的同学它不挑战图形学底层但足够暴露工程习惯缺陷它不强制用云服务但天然倒逼你理解本地持久化与内存管理的边界。2. 从零搭起可运行骨架C# 脚本分层 Unity 场景组织 SQLite 数据库初始化做毕设最怕“写到一半发现架构崩了”。我带过的几届学生里翻车最多的是把所有逻辑塞进一个RestaurantManager.cs里结果改个上菜逻辑要翻 800 行、查个库存 bug 要在 UI 和后端之间跳 5 次断点。下面这套三层轻量结构是我用在模拟项目 X 中验证过的最小可行骨架兼顾答辩展示性和代码可维护性。2.1 核心分层设计为什么不用 MonoBehaviour 直接操作数据库Unity 的 MonoBehaviour 是游戏对象生命周期载体但它不是数据访问入口。直接在OnClick()里写INSERT INTO orders...会导致三个硬伤一是编辑器 Play 模式重启后数据库连接丢失SQLite 连接非持久二是无法单元测试[Test]方法不能依赖GameObject三是答辩时老师问“如果换 MySQL 怎么办”你答不出——因为数据逻辑和表现逻辑彻底耦合。正确做法是划出清晰边界Model 层纯 C# 类无 Unity 引用Dish.cs、Order.cs、Table.cs只定义属性和业务规则如Order.Status只能是Pending/Preparing/Served/CompletedService 层C# 类引用 Model不引用 UnityEngineOrderService.cs提供CreateOrder(),UpdateOrderStatus()InventoryService.cs提供ConsumeIngredient()—— 所有数据库操作集中在此View 层MonoBehaviour 子类OrderPanelController.cs负责监听按钮点击、调用OrderService.CreateOrder()、更新TextMeshProUGUI显示绝不碰 SQL 字符串提示Service 层类必须声明为public class OrderService而非public class OrderService : MonoBehaviour。这是答辩时老师一眼识别你是否理解分层的关键细节。2.2 Unity 场景组织用 Prefab Tag Layer 控制复杂度餐厅场景容易变成“一堆没命名的 Cube 和 Text”。按功能拆解并打标签能让答辩演示时快速定位问题对象类型Prefab 名称TagLayer说明顾客Customer_WalkInCustomerGameplay包含 NavMeshAgent、Animator、CustomerState 组件餐桌Table_4SeatsTableEnvironmentBoxCollider 设为 Trigger用于检测顾客靠近收银台Counter_CashierCounterUICanvas 下的 Button 绑定OnOrderSubmit()关键操作选中Customer_WalkInPrefab → Inspector 顶部 Tag 下拉选Customer→ Layer 下拉选Gameplay→ 点击右下角齿轮图标 → “Set Layer for Children” 全选。这样后续用FindGameObjectsWithTag(Customer)就能精准获取所有顾客实例避免FindObjectsOfTypeCustomerController()因继承链混乱导致漏对象。2.3 SQLite 数据库初始化用 DB Browser 创建C# 仅负责读写别在 Unity 里手写建表 SQL。用免费工具 DB Browser for SQLite 注意不是 SQLiteStudio可视化建库再让 C# 加载。这是毕设最稳妥的方案——老师检查源码时看到.db文件比看到一堆CREATE TABLE IF NOT EXISTS字符串更有说服力。建库步骤以restaurant.db为例新建数据库 → 保存为Assets/Resources/database/restaurant.db创建dishes表字段id INTEGER PRIMARY KEY,name TEXT NOT NULL,price REAL,category TEXT,stock INTEGER DEFAULT 0创建orders表字段id INTEGER PRIMARY KEY,table_id INTEGER,status TEXT CHECK(status IN (Pending,Preparing,Served,Completed)),created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP创建order_items表多对多关联字段order_id INTEGER,dish_id INTEGER,quantity INTEGERC# 初始化代码放在GameManager.cs的Awake()中// Assets/Scripts/Services/DatabaseInitializer.cs using System.Data.SQLite; using UnityEngine; public static class DatabaseInitializer { private static string dbPath Assets/Resources/database/restaurant.db; public static void Initialize() { if (!System.IO.File.Exists(dbPath)) { Debug.LogError(数据库文件不存在 dbPath); return; } try { using (var conn new SQLiteConnection($Data Source{dbPath};Version3;)) { conn.Open(); // 验证关键表是否存在 using (var cmd conn.CreateCommand()) { cmd.CommandText SELECT name FROM sqlite_master WHERE typetable AND namedishes; var result cmd.ExecuteScalar(); if (result null) { Debug.LogError(dishes 表未创建请用 DB Browser 检查 restaurant.db); return; } } Debug.Log(数据库初始化成功已检测到 dishes 表); } } catch (System.Exception e) { Debug.LogError(数据库连接失败 e.Message); } } }逻辑说明dbPath必须用Assets/Resources/路径Unity 才能在 Build 后通过Application.streamingAssetsPath正确定位见 4.2 节SQLiteConnection不要声明为全局变量每次操作新建连接、用完立即Dispose()避免多线程冲突毕设单线程够用ExecuteScalar()检查表存在性比SELECT COUNT(*)更轻量且能提前暴露建库疏漏3. 让顾客“活起来”的核心机制NavMesh 导航 状态机 事件总线解耦餐厅经营游戏的“灵魂”不在菜单多华丽而在顾客行为是否符合现实逻辑不会穿墙、不会卡在收银台、点单后会等待、上菜后会反馈。这需要三个技术点咬合NavMesh 提供物理移动能力有限状态机FSM定义行为阶段事件总线Event Bus切断模块依赖。3.1 用 NavMesh Surface 替代旧版 NavMesh Bake适配 Unity 2021Unity 2020 后 NavMesh 系统重构旧教程里“Select All → Bake”已失效。正确流程在场景中创建空 GameObject命名为NavMeshSurface添加组件NavMeshSurface不是NavMeshAgent设置Root Object为包含所有可行走地面的父物体如Environment空对象勾选Use Geometry → Renderers自动识别地板 MeshRenderer点击右下角Bake按钮需确保场景中有 Light否则 Bake 失败注意Bake 前务必检查地板模型的Scale是否为 (1,1,1)。曾有学生因导入模型缩放为 (0.01,0.01,0.01)Bake 出的导航网格细密如针尖顾客移动一帧走 0.001 米调试半小时才发现是缩放惹的祸。3.2 顾客状态机用枚举 协程实现拒绝嵌套 if-elseCustomerState.cs定义四个核心状态public enum CustomerStateType { Entering, // 从门口走向大厅 Choosing, // 在餐桌间徘徊选择空位 Ordering, // 坐下后点击菜单 Eating, // 上菜后等待进食 Leaving // 吃完结账离店 }关键逻辑在CustomerController.cs的主协程中private IEnumerator StateMachine() { while (isActiveAndEnabled) { switch (currentState) { case CustomerStateType.Entering: yield return StartCoroutine(MoveToLobby()); currentState CustomerStateType.Choosing; break; case CustomerStateType.Choosing: yield return StartCoroutine(FindEmptyTable()); if (targetTable ! null) { currentState CustomerStateType.Ordering; // 触发事件顾客已就座通知收银台刷新可点单列表 EventBus.Trigger(new CustomerSeatedEvent { TableId targetTable.TableId }); } break; case CustomerStateType.Ordering: yield return new WaitForSeconds(1.5f); // 模拟点单思考时间 PlaceOrder(); // 调用 OrderService.CreateOrder() currentState CustomerStateType.Eating; break; case CustomerStateType.Eating: yield return new WaitForSeconds(Random.Range(8f, 15f)); // 随机用餐时长 currentState CustomerStateType.Leaving; break; case CustomerStateType.Leaving: yield return StartCoroutine(MoveToExit()); Destroy(gameObject); yield break; // 退出协程顾客对象销毁 } yield return null; } }参数说明MoveToLobby()内部调用agent.SetDestination(lobbyPosition)lobbyPosition是大厅中心点坐标FindEmptyTable()遍历所有GameObject.FindGameObjectsWithTag(Table)检查其TableComponent.IsOccupied falsePlaceOrder()中生成Order对象后必须调用OrderService.CreateOrder(order)而非直接操作数据库3.3 用静态事件总线解耦一行代码通知全系统避免FindObjectOfTypeCashierPanel().RefreshOrderList()这种强依赖。创建轻量事件总线// Assets/Scripts/Events/EventBus.cs public static class EventBus { private static readonly DictionarySystem.Type, Delegate _eventHandlers new(); public static void SubscribeT(ActionT handler) where T : struct { var eventType typeof(T); if (_eventHandlers.ContainsKey(eventType)) { _eventHandlers[eventType] (ActionT)_eventHandlers[eventType] handler; } else { _eventHandlers[eventType] handler; } } public static void TriggerT(T eventData) where T : struct { var eventType typeof(T); if (_eventHandlers.TryGetValue(eventType, out var handler)) { ((ActionT)handler).Invoke(eventData); } } }在CashierPanelController.cs的Start()中订阅void Start() { EventBus.SubscribeCustomerSeatedEvent(OnCustomerSeated); } private void OnCustomerSeated(CustomerSeatedEvent e) { Debug.Log($收到事件顾客已就座于 {e.TableId}刷新点单面板); RefreshOrderList(); // 更新 UI 列表 }这样当顾客坐到 3 号桌CustomerController触发事件CashierPanel自动响应——两者代码文件完全不 import 彼此答辩时老师问“如果增加厨房大屏显示待做订单怎么改”你只需新写一个KitchenDisplayController订阅同一事件无需动顾客或收银台代码。4. 数据持久化落地SQLite 本地存储 Unity Build 后路径适配 关键字段校验毕设答辩常被问“你这数据库是存在哪的打包成 EXE 后还能用吗” 如果答“存在Application.dataPath”基本等于交卷。必须明确告诉老师数据文件随包发布读写路径在不同平台自动适配且关键业务字段有数据库级约束。4.1 Build 后路径迁移从Resources到StreamingAssets的必经之路Resources文件夹在 Build 后会被打包进 AssetBundle不可写入。而 SQLite 需要读写.db文件必须用StreamingAssets。迁移步骤将Assets/Resources/database/restaurant.db移动到Assets/StreamingAssets/database/restaurant.db修改DatabaseInitializer.cs中的dbPath// Build 前编辑器模式 #if UNITY_EDITOR private static string dbPath Assets/StreamingAssets/database/restaurant.db; // Build 后Windows/Mac #else private static string dbPath System.IO.Path.Combine(Application.streamingAssetsPath, database/restaurant.db); #endif但Application.streamingAssetsPath在 Windows 构建后指向xxx_Data\StreamingAssets而 SQLite 连接字符串不支持反斜杠\需标准化路径private static string GetDbPath() { #if UNITY_EDITOR return Assets/StreamingAssets/database/restaurant.db; #else var path System.IO.Path.Combine(Application.streamingAssetsPath, database, restaurant.db); return path.Replace(\\, /); // 强制转为正斜杠 #endif }提示Build 前务必在 Player Settings → Other Settings → Configuration → Scripting Runtime Version 选.NET 4.x Equivalent否则System.Data.SQLiteDLL 会报MissingMethodException。4.2 数据库字段校验用 CHECK 约束堵住业务漏洞学生常犯的错订单状态存成字符串pending但代码里写if (status Pending)大小写不一致导致逻辑失效。用 SQLite 的CHECK约束强制规范CREATE TABLE orders ( id INTEGER PRIMARY KEY, table_id INTEGER, status TEXT CHECK(status IN (Pending, Preparing, Served, Completed)), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这样当 C# 代码执行cmd.CommandText INSERT INTO orders (table_id, status) VALUES (tableId, status); cmd.Parameters.AddWithValue(tableId, 5); cmd.Parameters.AddWithValue(status, pending); // 小写SQLite 会直接抛出异常Constraint failed: CHECK constraint failed: orders而不是静默插入脏数据。你在OrderService.CreateOrder()中捕获该异常就能在日志里精准定位是哪行代码传错了状态值。4.3 库存扣减的原子性用事务包裹多表操作点一份番茄牛腩面需同时order_items表插入一条记录dishes表的stock字段减 1若减库存时发现stock 0整个订单必须回滚用事务保证public bool CreateOrder(Order order, ListOrderItem items) { using (var conn new SQLiteConnection($Data Source{GetDbPath()};Version3;)) { conn.Open(); using (var transaction conn.BeginTransaction()) { try { // 1. 插入订单主表 using (var cmd conn.CreateCommand()) { cmd.Transaction transaction; cmd.CommandText INSERT INTO orders (table_id, status) VALUES (tableId, status); cmd.Parameters.AddWithValue(tableId, order.TableId); cmd.Parameters.AddWithValue(status, order.Status); cmd.ExecuteNonQuery(); order.Id (int)cmd.LastInsertRowId; } // 2. 插入订单明细并扣减库存 foreach (var item in items) { // 先查当前库存 int currentStock; using (var checkCmd conn.CreateCommand()) { checkCmd.Transaction transaction; checkCmd.CommandText SELECT stock FROM dishes WHERE id dishId; checkCmd.Parameters.AddWithValue(dishId, item.DishId); currentStock Convert.ToInt32(checkCmd.ExecuteScalar()); } if (currentStock item.Quantity) { throw new InvalidOperationException($菜品 {item.DishId} 库存不足当前 {currentStock}需 {item.Quantity}); } // 扣减库存 using (var updateCmd conn.CreateCommand()) { updateCmd.Transaction transaction; updateCmd.CommandText UPDATE dishes SET stock stock - quantity WHERE id dishId; updateCmd.Parameters.AddWithValue(quantity, item.Quantity); updateCmd.Parameters.AddWithValue(dishId, item.DishId); updateCmd.ExecuteNonQuery(); } // 插入明细 using (var insertCmd conn.CreateCommand()) { insertCmd.Transaction transaction; insertCmd.CommandText INSERT INTO order_items (order_id, dish_id, quantity) VALUES (orderId, dishId, quantity); insertCmd.Parameters.AddWithValue(orderId, order.Id); insertCmd.Parameters.AddWithValue(dishId, item.DishId); insertCmd.Parameters.AddWithValue(quantity, item.Quantity); insertCmd.ExecuteNonQuery(); } } transaction.Commit(); return true; } catch { transaction.Rollback(); return false; } } } }关键点所有SQLiteCommand必须显式设置cmd.Transaction transaction否则不在事务内throw new InvalidOperationException后transaction.Rollback()自动触发无需手动调用Convert.ToInt32()前必须判空ExecuteScalar()返回null时会崩加一层?? 0更安全5. 毕设答辩高频雷区与血泪避坑指南5 条真实翻车记录毕设答辩不是代码跑通就行老师会盯着你设计里的“工程破绽”。以下是我在模拟项目 X 和某高校毕设指导中总结的 5 个高频翻车点每条都附真实现象、根因分析和可立即执行的修复命令。5.1 现象Unity Editor 里数据库读写正常Build 成 EXE 后报 “Unable to open the database file”原因Application.streamingAssetsPath在 Windows 构建后指向xxx.exe\..\xxx_Data\StreamingAssets但 SQLite 连接字符串若含空格如路径为C:\My Game\restaurant.db未用双引号包裹驱动解析失败。解决修改连接字符串强制用双引号包裹路径// 错误写法空格导致解析失败 string connectionString $Data Source{dbPath};Version3;; // 正确写法用双引号包裹路径 string connectionString $Data Source\{dbPath}\;Version3;;提示dbPath本身已用Replace(\\, /)标准化此处双引号是防空格不是防斜杠。5.2 现象顾客在餐桌前无限循环“坐下→起身→再坐下”NavMeshAgent 报 “Failed to create agent because it is not on the navmesh”原因餐桌BoxCollider的Is Trigger未勾选导致NavMeshAgent在尝试SetDestination(table.transform.position)时目标点被 Collider 阻挡计算出的导航点落在桌面下方Y 值为负而 NavMesh 只铺在 Y0 平面。解决选中所有餐桌 Prefab → Inspector → BoxCollider → 勾选Is Trigger→ 点击右下角齿轮 → “Apply to Prefab”。然后在FindEmptyTable()方法中用Physics.OverlapSphere(table.transform.position, 0.5f, tableLayerMask)替代Raycast确保只检测触发器范围内的空桌。5.3 现象点单后收银台 UI 列表不刷新但 Debug.Log 显示事件已触发原因EventBus.SubscribeT()在Start()中调用但CashierPanelController的Start()执行时机早于CustomerController的Start()导致事件触发时订阅者尚未注册。解决将订阅逻辑移到OnEnable()确保组件启用时一定完成订阅private void OnEnable() { EventBus.SubscribeCustomerSeatedEvent(OnCustomerSeated); } private void OnDisable() { EventBus.UnsubscribeCustomerSeatedEvent(OnCustomerSeated); }5.4 现象订单状态从 “Preparing” 切到 “Served” 后顾客不开始吃饭一直卡在准备态原因状态切换代码写在OrderService.UpdateOrderStatus()中但CustomerController未监听订单状态变更事件仍靠轮询Order.Status而轮询间隔yield return new WaitForSeconds(0.5f)导致感知延迟。解决在OrderService中增加状态变更事件public static class OrderService { public static event ActionOrder OnOrderStatusChanged; public static void UpdateOrderStatus(int orderId, string newStatus) { // ... 数据库更新逻辑 var updatedOrder GetOrderById(orderId); OnOrderStatusChanged?.Invoke(updatedOrder); // 主动通知 } }CustomerController在Start()中订阅private void Start() { OrderService.OnOrderStatusChanged OnOrderUpdated; } private void OnOrderUpdated(Order order) { if (order.Id this.orderId order.Status Served) { currentState CustomerStateType.Eating; } }5.5 现象答辩演示时连续快速点单 5 次程序崩溃报 “SQLiteConnection is busy”原因多个协程并发调用OrderService.CreateOrder()共用同一个SQLiteConnection实例而 SQLite 不支持多线程写入。解决每个数据库操作新建连接用using确保及时释放// 错误全局 connection 实例 // private static SQLiteConnection connection; // 正确每次操作新建 public bool CreateOrder(Order order, ListOrderItem items) { using (var conn new SQLiteConnection($Data Source\{GetDbPath()}\;Version3;)) { conn.Open(); // ... 操作 } // 自动 Dispose连接关闭 }6. 让答辩老师眼前一亮的三个进阶技巧可视化调试 自动化测试 文档自动生成毕设不是“能跑就行”而是“跑得明白、改得清楚、讲得透彻”。以下三个技巧不增加核心功能但能极大提升工程专业感在答辩最后 5 分钟成为记忆点。6.1 用 Gizmos 绘制实时导航路径让“看不见的逻辑”可视化老师看不到 NavMeshAgent 内部计算但能看到你画出的绿色路径线。在CustomerController.cs中添加private void OnDrawGizmosSelected() { if (agent ! null agent.path ! null agent.path.status NavMeshPathStatus.PathComplete) { Gizmos.color Color.green; var corners agent.path.corners; for (int i 0; i corners.Length - 1; i) { Gizmos.DrawLine(corners[i], corners[i 1]); } } }效果选中顾客 GameObject 时Scene 视图中实时显示其规划的绿色导航线。答辩时老师问“你怎么知道导航没卡住”你直接框选顾客绿色线条跃然而出——这比说一百句“我用了 NavMesh” 有力得多。6.2 用 NUnit 写 3 个关键测试用例证明你的 Service 层可靠Unity Test Runner 支持 NUnit。在Assets/Tests/OrderServiceTests.cs中写using NUnit.Framework; using System.Collections.Generic; public class OrderServiceTests { [Test] public void CreateOrder_WithSufficientStock_ReturnsTrue() { // Arrange var order new Order { TableId 1 }; var items new ListOrderItem { new OrderItem { DishId 1, Quantity 2 } }; // Act var result OrderService.CreateOrder(order, items); // Assert Assert.IsTrue(result); // 额外断言查数据库确认 stock 已扣减 var dish DishService.GetDishById(1); Assert.AreEqual(8, dish.Stock); // 原库存 10扣 2 后剩 8 } [Test] public void CreateOrder_WithInsufficientStock_ThrowsException() { // Arrange var order new Order { TableId 1 }; var items new ListOrderItem { new OrderItem { DishId 1, Quantity 15 } }; // 库存仅 10 // Act Assert var ex Assert.ThrowsSystem.InvalidOperationException(() OrderService.CreateOrder(order, items) ); Assert.That(ex.Message, Does.Contain(库存不足)); } }提示测试前先在SetUp方法中调用DatabaseInitializer.Initialize()清空测试库确保每次测试环境干净。6.3 用 Doxygen 注释生成 API 文档3 行注释换来 10 页 PDF在OrderService.cs方法上方加 XML 注释/// summary /// 创建新订单并扣减对应菜品库存 /// /summary /// param nameorder订单主信息TableId 必须有效/param /// param nameitems订单明细列表DishId 必须存在于 dishes 表/param /// returns成功返回 true库存不足或数据库异常返回 false/returns /// exception crefSystem.InvalidOperationException当菜品库存不足时抛出/exception public static bool CreateOrder(Order order, ListOrderItem items) { // ... }安装 Doxygen GUI 工具配置INPUT Assets/Scripts/Services/RECURSIVE YESGENERATE_HTML NOGENERATE_LATEX YES点击 Run → 输出refman.pdf。答辩时把 PDF 打开到OrderService章节指着方法签名说“这是我的核心服务接口输入、输出、异常全部契约化定义。”——老师立刻明白你懂什么是工业级代码规范。最后说一句血泪经验毕设不是写得越多越好而是每个模块都经得起追问。我见过最漂亮的答辩是学生被问“为什么用 SQLite 而不用 Realm”他打开OrderService.cs指着using System.Data.SQLite说“因为 Realm 需要额外的 native plugin而 Unity 官方文档明确建议对于小型本地数据库SQLite 的 .NET Standard 支持最稳定且便于老师用 DB Browser 直接检查数据。”——没有高谈阔论只有精准引用这就是工程师的底气。希望帮到你。本文还有配套的精品资源点击获取