做美食经营类项目时,很多开发者会陷入一个共同的困境:单个功能怎么看都简单,比如“生成一个顾客”“炒一份菜”“弹一个订单面板”,但要把它们串成一套完整的经营循环,代码就开始失控了——脚本之间互相引用、状态到处修改、加一个新功能就要动老代码。如果你正卡在这个阶段,那么本篇文章应该能帮你把思路重新理清。
本文是 Unity6 美食经营类游戏完整开发教程的第三部分,重点解决“游戏骨架”问题。前两部分我们已经完成了项目初始化、场景搭建、玩家基础移动和部分交互逻辑,Part3 将围绕美食经营的核心闭环展开:顾客如何进店、如何下单、玩家如何接单做菜、如何上菜收钱、满意度如何影响评价、任务系统怎么驱动玩家持续游玩,以及店铺等级如何解锁新内容。
本部分面向已经掌握了 Unity 基础操作、C# 基本语法和 UGUI 常用组件的开发者。学完之后,你会得到一套完整的单机版经营玩法框架,可以继续扩展成剧情关卡、成就系统、食谱商店等内容。所有脚本都会给出完整代码和挂载说明,方便直接对照复现。
1. 背景与核心概念
1.1 美食经营类游戏到底在做什么
美食经营类游戏,英文通常叫 Food Management / Restaurant Simulation,玩家的核心行为是“接单 → 备餐 → 上菜 → 收钱”,同时要兼顾顾客的心情、店铺的升级和菜品的解锁。看起来简单,但一套合格的经营循环至少要回答以下问题:
- 顾客从哪里来,去哪里,等待多久会离开。
- 订单如何生成,如何判断玩家做对了菜。
- 烹饪过程中如何表现“火候”“完成度”“焦糊”。
- 上菜后如何结算收益,如何影响顾客满意度。
- 任务系统如何把零散行为变成阶段目标。
- 店铺升级如何解锁新菜谱、新顾客和新玩法。
单机版和网络版的区别在于,单机版不需要考虑服务器同步、多玩家并发,但反而更容易暴露“代码结构不良”的问题。因为没有服务器兜底,所有游戏状态都由客户端管理,一旦 Manager 满天飞、状态随意赋值,后续维护成本会迅速上升。
1.2 核心技术模块拆分
从工程角度,我会把整套系统拆成六个模块:
| 模块 | 职责 | 对应脚本示例 |
|---|---|---|
| 数据配置层 | 定义菜谱、任务、店铺等级数据 | RecipeData、TaskData |
| 订单系统 | 生成订单、校验订单、结算订单 | OrderManager、Order |
| 顾客系统 | 生成顾客、顾客状态机、满意度 | CustomerController、CustomerSpawner |
| 烹饪交互 | 锅具状态机、食材阶段、产出成品 | CookingPot |
| 上菜判定 | 检测交付物、匹配订单、加钱 | ServingCounter |
| 任务与升级 | 任务进度、奖励、解锁新内容 | TaskManager、GameManager |
这套拆法的好处是:每个模块可以通过事件通信,而不是直接互相 new 对象。后面加功能时,不需要改动整条调用链。
1.3 为什么 Part3 要重点讲“数据驱动”
很多初学者做游戏,习惯把数值写死在脚本里,比如直接在CustomerController里写money += 100,在CookingPot里写cookTime = 5f。这种做法在 Demo 阶段没问题,但一旦涉及“多个菜谱”“多种任务”“不同店铺等级”,就会变成一场灾难。
所以 Part3 会引入ScriptableObject作为数据配置载体。它本质上是 Unity 提供的一种数据容器,可以把菜谱、任务配置做成资源文件,策划不需要打开代码就能调整数值。这不只是“规范”,更是经营类游戏后续扩展的基础。
2. 环境准备与项目结构
2.1 版本说明
本文使用的是 Unity6 系列版本。Unity6 在渲染、UI 工具和脚本 API 上相比旧版本有一些变化,但经营类游戏用到的核心 API,比如MonoBehaviour、Coroutine、ScriptableObject、UGUI事件接口,在Unity 2021 LTS到Unity 6之间都保持兼容。因此如果你本地安装的是Unity 2022 LTS或Unity 6,都可以直接对照本文操作。
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
建议环境:
- Unity 6 系列(或 Unity 2022 LTS 及以上)
- 编程工具:Visual Studio Community 或 Rider
- 语言:C#
- 操作系统:Windows / macOS 均可
2.2 项目目录规划
如果你前面的 Part1、Part2 已经有自己的项目结构,可以在保持现有结构不变的基础上,新增以下目录。如果是从零开始对照,建议按下面的结构创建:
Assets/ ├── Scenes/ │ └── MainGame.unity ├── Scripts/ │ ├── Core/ │ │ ├── GameManager.cs │ │ └── GameEvents.cs │ ├── Data/ │ │ ├── RecipeData.cs │ │ └── TaskData.cs │ ├── Customer/ │ │ ├── CustomerController.cs │ │ └── CustomerSpawner.cs │ ├── Cooking/ │ │ └── CookingPot.cs │ ├── Order/ │ │ └── OrderManager.cs │ ├── Serving/ │ │ └── ServingCounter.cs │ └── Task/ │ └── TaskManager.cs ├── Prefabs/ │ ├── Customer.prefab │ ├── CookingPot.prefab │ └── RecipeItemUI.prefab └── ScriptableObjects/ ├── Recipes/ └── Tasks/目录规划的意义在于:数据、逻辑、表现分工明确。后面我们写代码时,会严格遵守“脚本放到对应文件夹”的约定。
2.3 场景中的基础物体规划
在 MainGame 场景中,你需要准备以下基础物体:
GameManager空物体:挂载全局管理器。CustomerSpawner空物体:负责按间隔生成顾客。Player:玩家角色(Part2 已实现移动)。CookingPot:可交互的锅具,至少一个。ServingCounter:上菜柜台,玩家把成品交到这里。Canvas:包含金钱文本、订单面板、任务面板、满意度提示等 UI。
这些物体的具体创建方式不唯一,你可以用空物体 + Sprite 或模型来表现。本文重点是逻辑脚本,视觉表现可以根据自己的素材灵活调整。
3. 系统架构与核心模块设计
3.1 事件系统先行
在写任何具体功能之前,我强烈建议先定义一个静态事件类。经营游戏里模块之间的通信非常多,如果用“A 脚本直接调用 B 脚本的方法”,时间长了会形成蜘蛛网一样的依赖。
事件系统的核心思路是:某个模块只负责“发出消息”,不关心谁在听;其他模块根据自己的需求“订阅消息”,收到后执行自己的逻辑。
下面创建一个GameEvents.cs:
// 文件路径:Assets/Scripts/Core/GameEvents.cs using System; namespace FoodGame.Core { public static class GameEvents { // 订单变化 public static Action<int> OnOrderCountChanged; // 金钱变化 public static Action<int> OnMoneyChanged; // 顾客满意度变化 public static Action<string, float> OnCustomerSatisfactionChanged; // 烹饪状态变化 public static Action<string> OnCookingStateChanged; // 任务进度变化 public static Action<string, int> OnTaskProgressChanged; // 店铺等级变化 public static Action<int> OnLevelUp; public static void NotifyOrderCountChanged(int count) { OnOrderCountChanged?.Invoke(count); } public static void NotifyMoneyChanged(int money) { OnMoneyChanged?.Invoke(money); } public static void NotifyCustomerSatisfactionChanged(string customerName, float value) { OnCustomerSatisfactionChanged?.Invoke(customerName, value); } public static void NotifyCookingStateChanged(string state) { OnCookingStateChanged?.Invoke(state); } public static void NotifyTaskProgressChanged(string taskId, int progress) { OnTaskProgressChanged?.Invoke(taskId, progress); } public static void NotifyLevelUp(int level) { OnLevelUp?.Invoke(level); } } }这里的Action委托是 C# 内置的,?.Invoke表示“如果有订阅者才触发”,避免了空引用报错。在实际项目中,你可以把事件封装得更复杂,但当前版本已经够用。
3.2 GameManager 全局状态
GameManager负责管理金钱、当前订单数、店铺等级和游戏暂停状态。它采用单例模式,方便其他模块访问,但我们要控制访问范围:不是所有脚本都可以随意改金钱,只有订单结算、任务奖励等特定模块才允许调用对应方法。
// 文件路径:Assets/Scripts/Core/GameManager.cs using UnityEngine; using FoodGame.Core; namespace FoodGame.Core { public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [Header("经营数值")] [SerializeField] private int startMoney = 100; [SerializeField] private int startLevel = 1; public int Money { get; private set; } public int CurrentLevel { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); Money = startMoney; CurrentLevel = startLevel; } public bool CanAfford(int amount) { return Money >= amount; } public bool TrySpendMoney(int amount) { if (!CanAfford(amount)) return false; Money -= amount; GameEvents.NotifyMoneyChanged(Money); return true; } public void AddMoney(int amount) { Money += amount; GameEvents.NotifyMoneyChanged(Money); } public void SetLevel(int newLevel) { CurrentLevel = newLevel; GameEvents.NotifyLevelUp(CurrentLevel); } public void AddLevel(int amount) { SetLevel(CurrentLevel + amount); } } }注意DontDestroyOnLoad的使用:在单机版中,如果后续要切换场景,全局管理器不应该被销毁。如果你暂时只有一个场景,这行代码也不会造成问题。
3.3 为什么需要“数据驱动”的存档扩展
在单机经营游戏里,玩家随时可能关掉游戏。如果你只把金钱存在内存中,下次启动就丢失了。虽然 Part3 不要求立刻实现完整存档,但在架构上应该预留位置:所有经营状态尽量集中在GameManager,数据配置尽量走ScriptableObject,这样后续做 JSON 序列化或 PlayerPrefs 存档时,不需要大改游戏逻辑。
4. 菜谱与订单系统开发实战
4.1 菜谱数据建模
在经营游戏里,“菜谱”是最核心的数据。一个菜谱通常包含以下信息:
- 菜名。
- 图标。
- 制作时长。
- 所需食材。
- 售卖价格。
- 解锁等级。
用ScriptableObject定义:
// 文件路径:Assets/Scripts/Data/RecipeData.cs using UnityEngine; using System.Collections.Generic; namespace FoodGame.Data { [CreateAssetMenu(fileName = "NewRecipe", menuName = "FoodGame/Recipe")] public class RecipeData : ScriptableObject { public string recipeId; public string recipeName; [TextArea] public string description; public Sprite icon; public float cookDuration = 5f; public int price = 20; public int unlockLevel = 1; [Header("所需食材")] public List<string> requiredIngredients = new List<string>(); public bool IsUnlocked(int playerLevel) { return playerLevel >= unlockLevel; } } }创建菜谱资源的方式:在 Project 窗口右键 → Create → FoodGame → Recipe,即可生成一个.asset文件。这样我们可以在 Inspector 里直观地配数值。
4.2 菜谱库 RecipeBook
菜谱需要被集中管理,方便订单系统随机选取。我们创建一个RecipeBook,持有所有菜谱列表,并提供查询方法。
// 文件路径:Assets/Scripts/Data/RecipeBook.cs using System.Collections.Generic; using UnityEngine; using FoodGame.Core; namespace FoodGame.Data { public class RecipeBook : MonoBehaviour { [SerializeField] private List<RecipeData> allRecipes = new List<RecipeData>(); public List<RecipeData> AllRecipes => allRecipes; public RecipeData GetRecipeById(string id) { foreach (var recipe in allRecipes) { if (recipe.recipeId == id) return recipe; } return null; } public List<RecipeData> GetUnlockedRecipes(int playerLevel) { var result = new List<RecipeData>(); foreach (var recipe in allRecipes) { if (recipe.IsUnlocked(playerLevel)) result.Add(recipe); } return result; } public RecipeData GetRandomUnlockedRecipe(int playerLevel) { var unlocked = GetUnlockedRecipes(playerLevel); if (unlocked.Count == 0) return null; return unlocked[Random.Range(0, unlocked.Count)]; } } }在场景中创建一个空物体挂载RecipeBook,然后把菜谱资源拖进All Recipes列表。它是“静态配置”,不需要单例,其他系统可以通过 Inspector 引用它。
4.3 订单 Order 与 OrderManager
订单是一次“顾客需求”的实例化。订单包含的内容有:订单编号、菜谱引用、顾客名称、下单时间、状态。
// 文件路径:Assets/Scripts/Order/Order.cs using FoodGame.Data; namespace FoodGame.Order { public enum OrderState { Waiting, // 等待制作 Completed, // 已完成 Failed // 超时或取消 } public class Order { public int OrderId { get; private set; } public RecipeData Recipe { get; private set; } public string CustomerName { get; private set; } public float TimeLimit { get; private set; } public float ElapsedTime { get; set; } public OrderState State { get; set; } public Order(int orderId, RecipeData recipe, string customerName, float timeLimit) { OrderId = orderId; Recipe = recipe; CustomerName = customerName; TimeLimit = timeLimit; ElapsedTime = 0f; State = OrderState.Waiting; } public bool IsExpired() { return ElapsedTime >= TimeLimit; } } }OrderManager负责创建订单、更新订单计时、结算订单。它不关心顾客怎么生成,只负责“订单数据”本身。
// 文件路径:Assets/Scripts/Order/OrderManager.cs using System.Collections.Generic; using UnityEngine; using FoodGame.Core; using FoodGame.Data; namespace FoodGame.Order { public class OrderManager : MonoBehaviour { [SerializeField] private RecipeBook recipeBook; [SerializeField] private float baseTimeLimit = 30f; private List<Order> activeOrders = new List<Order>(); private int nextOrderId = 1; public IReadOnlyList<Order> ActiveOrders => activeOrders; public Order CreateOrder(string customerName) { int playerLevel = GameManager.Instance != null ? GameManager.Instance.CurrentLevel : 1; RecipeData recipe = null; if (recipeBook != null) { recipe = recipeBook.GetRandomUnlockedRecipe(playerLevel); } if (recipe == null) { Debug.LogWarning("没有可用的已解锁菜谱,无法创建订单"); return null; } Order order = new Order(nextOrderId, recipe, customerName, baseTimeLimit); nextOrderId++; activeOrders.Add(order); GameEvents.NotifyOrderCountChanged(activeOrders.Count); return order; } private void Update() { for (int i = activeOrders.Count - 1; i >= 0; i--) { Order order = activeOrders[i]; if (order.State != OrderState.Waiting) continue; order.ElapsedTime += Time.deltaTime; if (order.IsExpired()) { FailOrder(order); } } } public Order GetOrderById(int orderId) { foreach (var order in activeOrders) { if (order.OrderId == orderId) return order; } return null; } public bool CompleteOrder(int orderId, GameObject deliverItem) { Order order = GetOrderById(orderId); if (order == null || order.State != OrderState.Waiting) return false; string itemTag = deliverItem != null ? deliverItem.tag : ""; if (itemTag != order.Recipe.recipeId) return false; order.State = OrderState.Completed; activeOrders.Remove(order); GameManager.Instance.AddMoney(order.Recipe.price); GameEvents.NotifyOrderCountChanged(activeOrders.Count); return true; } private void FailOrder(Order order) { order.State = OrderState.Failed; activeOrders.Remove(order); GameEvents.NotifyOrderCountChanged(activeOrders.Count); // 在这里可以触发顾客离开、满意度下降等逻辑 Debug.Log($"订单 {order.OrderId} 超时失败"); } } }这里有一个关键设计:CompleteOrder通过deliverItem.tag与菜谱 ID 匹配。这意味着你在做菜时,生成的成品物体 tag 应该设置成对应菜谱的recipeId。这种约定简化了匹配逻辑,但要求你在资源管理上保持一致。
4.4 订单 UI 示例
订单 UI 需要显示当前订单、剩余时间、顾客名称。这里提供一个简单示例,在 Canvas 下创建一个OrderUIController:
// 文件路径:Assets/Scripts/UI/OrderUIController.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using FoodGame.Order; namespace FoodGame.UI { public class OrderUIController : MonoBehaviour { [SerializeField] private OrderManager orderManager; [SerializeField] private Text orderInfoText; [SerializeField] private Text orderCountText; private List<Order> displayedOrders = new List<Order>(); private void OnEnable() { // 简单起见,每帧刷新。实际项目可使用事件驱动优化 } private void Update() { if (orderManager == null || orderInfoText == null) return; var orders = orderManager.ActiveOrders; string info = ""; foreach (var order in orders) { info += $"顾客:{order.CustomerName}\n"; info += $"菜品:{order.Recipe.recipeName}\n"; info += $"剩余时间:{Mathf.CeilToInt(order.TimeLimit - order.ElapsedTime)}s\n"; info += "------------------\n"; } orderInfoText.text = info; if (orderCountText != null) { orderCountText.text = $"当前订单数:{orders.Count}"; } } } }Demo 阶段用Update刷新 UI 没问题,但真正项目建议改成“订单变化时刷新一次”,避免每帧执行字符串拼接。
5. 顾客流程与满意度机制
5.1 顾客状态机
顾客从生成到离开,会经历几个状态。用枚举定义状态,能让代码的可读性提高很多:
Entering:进入餐厅,走向座位或柜台。Waiting:等待下单/等待上菜。Eating:上菜后用餐。Leaving:用餐结束或等太久离开。
下面是一个简化版CustomerController:
// 文件路径:Assets/Scripts/Customer/CustomerController.cs using System.Collections; using UnityEngine; using FoodGame.Core; using FoodGame.Order; namespace FoodGame.Customer { public enum CustomerState { Entering, Waiting, Eating, Leaving } public class CustomerController : MonoBehaviour { [SerializeField] private string customerName = "顾客"; [SerializeField] private Transform targetPosition; [SerializeField] private float moveSpeed = 2f; [SerializeField] private float satisfaction = 1f; private CustomerState state = CustomerState.Entering; private OrderManager orderManager; private Order currentOrder; public string CustomerName => customerName; public Order CurrentOrder => currentOrder; public void Init(string name, Transform target, OrderManager manager) { customerName = name; targetPosition = target; orderManager = manager; } private void Update() { switch (state) { case CustomerState.Entering: MoveToTarget(); break; case CustomerState.Waiting: UpdateSatisfaction(); break; } } private void MoveToTarget() { if (targetPosition == null) { state = CustomerState.Waiting; CreateOrder(); return; } Vector3 direction = targetPosition.position - transform.position; direction.y = 0f; if (direction.magnitude > 0.1f) { transform.position += direction.normalized * moveSpeed * Time.deltaTime; transform.rotation = Quaternion.LookRotation(direction); } else { state = CustomerState.Waiting; CreateOrder(); } } private void CreateOrder() { if (orderManager == null) return; currentOrder = orderManager.CreateOrder(customerName); } private void UpdateSatisfaction() { if (currentOrder == null) return; float ratio = currentOrder.ElapsedTime / currentOrder.TimeLimit; satisfaction = Mathf.Clamp01(1f - ratio * 0.5f); GameEvents.NotifyCustomerSatisfactionChanged(customerName, satisfaction); } public void OnMealServed() { state = CustomerState.Eating; satisfaction = 1f; GameEvents.NotifyCustomerSatisfactionChanged(customerName, satisfaction); StartCoroutine(EatAndLeave()); } private IEnumerator EatAndLeave() { yield return new WaitForSeconds(3f); Leave(); } public void Leave() { state = CustomerState.Leaving; Destroy(gameObject, 1f); } } }这个脚本里的targetPosition可以是场景中的一个空物体,代表“顾客站立点”。真实的经营游戏还会有座位、排队、动画播放等逻辑,但当前版本已足够跑通主流程。
5.2 顾客生成器 CustomerSpawner
顾客生成器负责按一定间隔生成顾客,并给顾客指定目标点。
// 文件路径:Assets/Scripts/Customer/CustomerSpawner.cs using System.Collections; using UnityEngine; using FoodGame.Order; namespace FoodGame.Customer { public class CustomerSpawner : MonoBehaviour { [SerializeField] private GameObject customerPrefab; [SerializeField] private Transform spawnPoint; [SerializeField] private Transform customerTargetPoint; [SerializeField] private OrderManager orderManager; [SerializeField] private float spawnInterval = 8f; [SerializeField] private int maxCustomerCount = 3; private int currentCustomerCount = 0; private void Start() { StartCoroutine(SpawnLoop()); } private IEnumerator SpawnLoop() { while (true) { if (currentCustomerCount < maxCustomerCount) { SpawnCustomer(); } yield return new WaitForSeconds(spawnInterval); } } private void SpawnCustomer() { if (customerPrefab == null || spawnPoint == null || orderManager == null) return; GameObject go = Instantiate(customerPrefab, spawnPoint.position, spawnPoint.rotation); CustomerController customer = go.GetComponent<CustomerController>(); if (customer != null) { customer.Init($"顾客{currentCustomerCount + 1}", customerTargetPoint, orderManager); } currentCustomerCount++; } public void OnCustomerLeave() { currentCustomerCount--; } } }注意:currentCustomerCount是生成器内部维护的数量,真实项目建议在CustomerController的OnDestroy中回调给生成器,或者通过事件系统广播,避免计数不准。这里先保留为简化版本。
5.3 满意度对游戏的影响
在单机经营游戏里,满意度通常会影响评价星级和收入倍率。如果顾客等待时间过长,满意度下降,最后可以只给一半的钱甚至不给钱。如果你想实现“评价星级”,可以继续扩展。核心思路是:满意度是 0~1 的浮点数,根据满意度计算结算倍率。
6. 烹饪交互与上菜判定实现
6.1 锅具状态机
烹饪是美食经营游戏里“操作感”最强的部分。为了让玩法不单调,锅具至少要有四个状态:
Empty:空闲,可以放入食材。Cooking:烹饪中。Cooked:已完成,可以盛出。Overcooked:过火,继续放置会变焦。Burned:焦糊,只能倒掉。
我们用一个CookingPot脚本来实现:
// 文件路径:Assets/Scripts/Cooking/CookingPot.cs using System.Collections; using UnityEngine; using FoodGame.Core; using FoodGame.Data; namespace FoodGame.Cooking { public enum CookState { Empty, Cooking, Cooked, Overcooked, Burned } public class CookingPot : MonoBehaviour { [SerializeField] private RecipeData currentRecipe; [SerializeField] private CookState state = CookState.Empty; [SerializeField] private GameObject cookedFoodPrefab; public CookState State => state; public RecipeData CurrentRecipe => currentRecipe; public bool TryStartCooking(RecipeData recipe) { if (state != CookState.Empty) return false; currentRecipe = recipe; state = CookState.Cooking; GameEvents.NotifyCookingStateChanged(state.ToString()); StartCoroutine(CookingProcess()); return true; } private IEnumerator CookingProcess() { yield return new WaitForSeconds(currentRecipe.cookDuration); state = CookState.Cooked; GameEvents.NotifyCookingStateChanged(state.ToString()); yield return new WaitForSeconds(3f); if (state == CookState.Cooked) { state = CookState.Overcooked; GameEvents.NotifyCookingStateChanged(state.ToString()); } yield return new WaitForSeconds(3f); if (state == CookState.Overcooked) { state = CookState.Burned; GameEvents.NotifyCookingStateChanged(state.ToString()); } } public GameObject TakeFood() { if (state != CookState.Cooked) return null; state = CookState.Empty; currentRecipe = null; if (cookedFoodPrefab == null) return null; return Instantiate(cookedFoodPrefab, transform.position, Quaternion.identity); } public void ClearPot() { if (state != CookState.Burned) return; state = CookState.Empty; currentRecipe = null; GameEvents.NotifyCookingStateChanged(state.ToString()); } } }这里cookedFoodPrefab只是一个示例,真实项目中你应该根据currentRecipe动态生成对应菜品的实例,而不是所有菜都用一个预制体。一个简单的方案是给RecipeData增加一个cookedFoodPrefab字段。
6.2 玩家如何与锅具交互
玩家在锅具附近按交互键,如果背包/手上没有食材,就尝试开始烹饪;如果锅已经做好菜,就拿起菜品。交互检测可以是简单的触发器加按键判断:
// 文件路径:Assets/Scripts/Player/PlayerCookInteraction.cs using UnityEngine; using FoodGame.Cooking; namespace FoodGame.Player { public class PlayerCookInteraction : MonoBehaviour { [SerializeField] private KeyCode interactKey = KeyCode.E; [SerializeField] private Transform holdPoint; private CookingPot nearbyPot; private GameObject heldFood; private void OnTriggerEnter(Collider other) { CookingPot pot = other.GetComponent<CookingPot>(); if (pot != null) nearbyPot = pot; } private void OnTriggerExit(Collider other) { CookingPot pot = other.GetComponent<CookingPot>(); if (pot != null && nearbyPot == pot) nearbyPot = null; } private void Update() { if (Input.GetKeyDown(interactKey)) { InteractWithPot(); } } private void InteractWithPot() { if (nearbyPot == null) return; if (heldFood == null) { heldFood = nearbyPot.TakeFood(); if (heldFood != null) AttachToHoldPoint(heldFood); } else { // 如果手里有东西,不再尝试烹饪 return; } } private void AttachToHoldPoint(GameObject foodObject) { if (holdPoint == null) return; foodObject.transform.SetParent(holdPoint); foodObject.transform.localPosition = Vector3.zero; foodObject.transform.localRotation = Quaternion.identity; } } }这个脚本是“玩家侧”的交互入口。需要注意的是,OnTriggerEnter要求锅具带有Collider,并且至少一方带有Rigidbody才能正常触发。如果使用 CharacterController,也可以改用OnControllerColliderHit或者在Update里做射线检测。
6.3 上菜判定 ServingCounter
当玩家拿着做好的菜走到上菜柜台时,柜台需要判断:当前是否有等待中的订单,玩家手里的菜是否匹配。ServingCounter负责接收菜品,并调用OrderManager.CompleteOrder。
// 文件路径:Assets/Scripts/Serving/ServingCounter.cs using UnityEngine; using FoodGame.Player; using FoodGame.Order; namespace FoodGame.Serving { public class ServingCounter : MonoBehaviour { [SerializeField] private OrderManager orderManager; [SerializeField] private Transform counterPoint; private void OnTriggerEnter(Collider other) { PlayerCookInteraction player = other.GetComponent<PlayerCookInteraction>(); if (player == null) return; GameObject heldFood = GetHeldFood(player); if (heldFood == null) return; TryDeliverFood(heldFood); } private GameObject GetHeldFood(PlayerCookInteraction player) { // 通过 Player 上的字段访问,需要把 heldFood 的访问权限改为 public 或提供 getter // 这里为示例,建议在 PlayerCookInteraction 中增加 public GameObject HeldFood => heldFood; return null; } private void TryDeliverFood(GameObject foodObject) { // 轮询一个等待中的订单进行匹配 var orders = orderManager.ActiveOrders; foreach (var order in orders) { if (order.State != OrderState.Waiting) continue; if (order.Recipe.recipeId == foodObject.tag) { bool success = orderManager.CompleteOrder(order.OrderId, foodObject); if (success) { Destroy(foodObject); Debug.Log($"上菜成功:{order.Recipe.recipeName}"); return; } } } Debug.Log("没有匹配的订单,不能上菜"); } } }这里我故意留了一个小问题:PlayerCookInteraction的heldFood目前是 private 的,无法被ServingCounter直接访问。实际开发时,你应该在PlayerCookInteraction中补充一个公有属性:
public GameObject HeldFood => heldFood;同时要把ServingCounter里的GetHeldFood逻辑修正为直接访问玩家身上的属性。这种“留坑-补全”的过程,也是实际开发中常见的重构操作。
7. 任务系统与经营升级
7.1 任务数据定义
任务系统的目的,是给玩家短期目标。任务可以分成不同的类型,比如:
- 完成 N 个订单。
- 赚到 N 金钱。
- 烹饪 N 次。
- 达到 N 满意度的顾客数量。
// 文件路径:Assets/Scripts/Data/TaskData.cs using UnityEngine; namespace FoodGame.Data { public enum TaskType { CompleteOrderCount, EarnMoneyAmount, CookCount, ReachLevel } [CreateAssetMenu(fileName = "NewTask", menuName = "FoodGame/Task")] public class TaskData : ScriptableObject { public string taskId; public string taskName; [TextArea] public string description; public TaskType taskType; public int targetAmount; public int rewardMoney; public int rewardLevel; } }7.2 TaskManager 运行时进度
TaskManager负责监听游戏事件,累加任务进度,并在进度达标时发放奖励。
这里的关键点在于:任务系统不应该去“轮询”其他模块,而应该订阅事件。订单完成时,GameManager或OrderManager发出事件,TaskManager收到后检查所有进行中的任务。
// 文件路径:Assets/Scripts/Task/TaskManager.cs using System.Collections.Generic; using UnityEngine; using FoodGame.Core; using FoodGame.Data; namespace FoodGame.Task { public class TaskManager : MonoBehaviour { [SerializeField] private List<TaskData> activeTasks = new List<TaskData>(); private Dictionary<string, int> taskProgress = new Dictionary<string, int>(); private void OnEnable() { GameEvents.OnMoneyChanged += OnMoneyChanged; GameEvents.OnOrderCountChanged += OnOrderCountChanged; GameEvents.OnCookingStateChanged += OnCookingStateChanged; GameEvents.OnLevelUp += OnLevelUp; } private void OnDisable() { GameEvents.OnMoneyChanged -= OnMoneyChanged; GameEvents.OnOrderCountChanged -= OnOrderCountChanged; GameEvents.OnCookingStateChanged -= OnCookingStateChanged; GameEvents.OnLevelUp -= OnLevelUp; } private void Start() { foreach (var task in activeTasks) { taskProgress[task.taskId] = 0; } } private void OnMoneyChanged(int money) { AddProgress(TaskType.EarnMoneyAmount, money); } private void OnOrderCountChanged(int count) { AddProgress(TaskType.CompleteOrderCount, 1); } private void OnCookingStateChanged(string state) { if (state == "Cooked") { AddProgress(TaskType.CookCount, 1); } } private void OnLevelUp(int level) { AddProgress(TaskType.ReachLevel, level); } private void AddProgress(TaskType type, int amount) { foreach (var task in activeTasks) { if (task.taskType != type) continue; if (!taskProgress.ContainsKey(task.taskId)) taskProgress[task.taskId] = 0; taskProgress[task.taskId] += amount; GameEvents.NotifyTaskProgressChanged(task.taskId, taskProgress[task.taskId]); if (taskProgress[task.taskId] >= task.targetAmount) { CompleteTask(task); } } } private void CompleteTask(TaskData task) { if (GameManager.Instance == null) return; GameManager.Instance.AddMoney(task.rewardMoney); if (task.rewardLevel > 0) { GameManager.Instance.AddLevel(task.rewardLevel); } activeTasks.Remove(task); Debug.Log($"任务完成:{task.taskName}"); } } }这里有个需要注意的地方:OnMoneyChanged事件里每次金钱变化都会触发一次进度累加,如果EarnMoneyAmount任务的目标是赚到 100 元,那这个逻辑是合理的。但如果你在结算订单时同时调用了两次AddMoney,就会导致任务进度重复累加。所以实际项目中,建议只监听“订单完成”事件,传入订单价格,而不是监听每一次金钱变化。
8. 常见问题与排查思路
这一节整理开发 Part3 过程中最常遇到的几类问题,你可以按表格快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 顾客生成后站在原地不动 | targetPosition未赋值 | 检查生成器里的目标点是否拖入场景物体 |
| 订单面板一直为空 | orderManager引用为空 | 在 Inspector 中确认脚本引用已赋值 |
| 点击交互键没反应 | 锅具 Collider 未开启或缺少 Rigidbody | 给锅具添加 Collider,并确保进入触发器 |
| 做好的菜无法上菜 | heldFood的 tag 与菜谱 ID 不一致 | 检查菜品预制体 tag 是否设置为对应 recipeId |
| 任务进度不增加 | 事件未订阅或事件名拼写错误 | 在OnEnable中确认订阅,在 Unity 控制台加日志 |
| 顾客一直等待,不离开 | 超时订单没有回调顾客 | 在FailOrder中调用顾客的Leave逻辑 |
| 满意度始终为 1 | UpdateSatisfaction里的订单引用为空 | 确认顾客创建订单成功后再计算满意度 |
| UI 刷新太频繁 | 在 Update 中反复拼接字符串 | 改成事件驱动刷新,或使用对象池复用 Text |
如果你遇到了“顾客卡住不动”的问题,需要优先检查两件事:第一,顾客脚本是否被正确挂载;第二,targetPosition是否是一个有效物体的 Transform。很多新手会把targetPosition拖成预制体资源,而不是场景实例,导致运行时引用为空。
9. 工程最佳实践与扩展建议
9.1 事件驱动优于直接引用
Part3 的代码里已经大量使用了事件系统。这是经营类游戏工程化的第一步。如果你发现某个脚本需要同时调用三个其他脚本的方法,通常意味着可以用事件解耦。比如订单完成时,不需要让OrderManager去调用TaskManager的方法,而是让TaskManager自己订阅“金钱变化”或“订单完成”事件。
9.2 ScriptableObject 当配置表
菜谱、任务、店铺等级都可以做成ScriptableObject。这样做的好处有:
- 策划可以脱离代码配置数值。
- 不同菜谱可以有不同的图片、描述、价格。
- 后续做道具系统、成就系统时,可以复用同一套数据模式。
- 比较容易和 JSON 存档对接。
9.3 对象池的必要性
顾客、菜品、飘字提示都是高频创建和销毁的对象。频繁Instantiate和Destroy会导致 GC 压力。建议在 Part3 完成后,把顾客生成改为对象池模式。核心思路:预创建 N 个对象,隐藏不用,需要时激活并重置状态,不需要时回收而不是销毁。
这不是“优化洁癖”,而是经营游戏在移动端运行时的真实需求。顾客在你开店高峰期可能有七八个同时在场,菜品和飘字更多,没有池化很容易掉帧。
9.4 存档与安全写入
单机游戏一定要做存档。Part3 没有展开存档代码,但架构上已经支持:金钱、等级都集中在GameManager,任务进度在TaskManager。保存时只需要把这些数值序列化成 JSON,写入Application.persistentDataPath下的文件即可。
注意事项:
- 不要在游戏运行中被异常强杀时写存档,容易损坏文件。
- 建议采用“临时文件 + 主文件”的写入方式,写完后替换。
- 涉及删除旧存档或重置数据时,先在测试环境验证,正式项目还要做好存档备份。
- 移动端上,避免在主线程做大量磁盘 IO。
9.5 代码规范建议
- 限定命名空间:
FoodGame.Core、FoodGame.Data、FoodGame.Order。 - 公有字段用
[SerializeField]私有化,避免外部随意修改。 - 数据修改集中在管理类,不要允许每个脚本直接改金钱。
- 使用
#region按“字段、生命周期、公共方法、私有方法”分组,方便阅读。 - 每个脚本开头写清楚文件路径和职责,方便多人协作。
10. 总结与下一步学习路线
Part3 完成之后,你的项目已经从“玩家可以在场景里跑动”升级成了“有明确经营目标的可玩游戏原型”。你掌握了数据配置、事件通信、订单生命周期、顾客状态机、锅具状态机、上菜判定和任务驱动升级。
接下来可以继续打磨的方向有几个,难度从低到高排序:
- 在现有框架上增加更多菜谱、更多锅具和更复杂的食材步骤。
- 把满意度改为评价星级,影响单笔收入倍率。
- 增加店铺装饰购买系统,用金币购买桌椅和装修。
- 实现完整存档与读档功能。
- 把任务系统扩展成“成就墙”,增加长线目标。
- 加入音效、动画和特效,提升操作反馈。
如果 Part3 你已经顺利跑通,可以试着做一次“重构练习”:把场景里所有硬编码引用清理掉,全部改为事件或 Inspector 赋值。这个练习能帮你提前规避很多项目中期才爆发的架构问题。如果遇到卡点,可以先记录现象,再从事件订阅、引用赋值、状态枚举三个方面排查。