news 2026/9/9 18:55:23

Unity6美食经营游戏开发:订单、顾客与任务系统架构全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity6美食经营游戏开发:订单、顾客与任务系统架构全解析

做美食经营类项目时,很多开发者会陷入一个共同的困境:单个功能怎么看都简单,比如“生成一个顾客”“炒一份菜”“弹一个订单面板”,但要把它们串成一套完整的经营循环,代码就开始失控了——脚本之间互相引用、状态到处修改、加一个新功能就要动老代码。如果你正卡在这个阶段,那么本篇文章应该能帮你把思路重新理清。

本文是 Unity6 美食经营类游戏完整开发教程的第三部分,重点解决“游戏骨架”问题。前两部分我们已经完成了项目初始化、场景搭建、玩家基础移动和部分交互逻辑,Part3 将围绕美食经营的核心闭环展开:顾客如何进店、如何下单、玩家如何接单做菜、如何上菜收钱、满意度如何影响评价、任务系统怎么驱动玩家持续游玩,以及店铺等级如何解锁新内容。

本部分面向已经掌握了 Unity 基础操作、C# 基本语法和 UGUI 常用组件的开发者。学完之后,你会得到一套完整的单机版经营玩法框架,可以继续扩展成剧情关卡、成就系统、食谱商店等内容。所有脚本都会给出完整代码和挂载说明,方便直接对照复现。

1. 背景与核心概念

1.1 美食经营类游戏到底在做什么

美食经营类游戏,英文通常叫 Food Management / Restaurant Simulation,玩家的核心行为是“接单 → 备餐 → 上菜 → 收钱”,同时要兼顾顾客的心情、店铺的升级和菜品的解锁。看起来简单,但一套合格的经营循环至少要回答以下问题:

  • 顾客从哪里来,去哪里,等待多久会离开。
  • 订单如何生成,如何判断玩家做对了菜。
  • 烹饪过程中如何表现“火候”“完成度”“焦糊”。
  • 上菜后如何结算收益,如何影响顾客满意度。
  • 任务系统如何把零散行为变成阶段目标。
  • 店铺升级如何解锁新菜谱、新顾客和新玩法。

单机版和网络版的区别在于,单机版不需要考虑服务器同步、多玩家并发,但反而更容易暴露“代码结构不良”的问题。因为没有服务器兜底,所有游戏状态都由客户端管理,一旦 Manager 满天飞、状态随意赋值,后续维护成本会迅速上升。

1.2 核心技术模块拆分

从工程角度,我会把整套系统拆成六个模块:

模块职责对应脚本示例
数据配置层定义菜谱、任务、店铺等级数据RecipeDataTaskData
订单系统生成订单、校验订单、结算订单OrderManagerOrder
顾客系统生成顾客、顾客状态机、满意度CustomerControllerCustomerSpawner
烹饪交互锅具状态机、食材阶段、产出成品CookingPot
上菜判定检测交付物、匹配订单、加钱ServingCounter
任务与升级任务进度、奖励、解锁新内容TaskManagerGameManager

这套拆法的好处是:每个模块可以通过事件通信,而不是直接互相 new 对象。后面加功能时,不需要改动整条调用链。

1.3 为什么 Part3 要重点讲“数据驱动”

很多初学者做游戏,习惯把数值写死在脚本里,比如直接在CustomerController里写money += 100,在CookingPot里写cookTime = 5f。这种做法在 Demo 阶段没问题,但一旦涉及“多个菜谱”“多种任务”“不同店铺等级”,就会变成一场灾难。

所以 Part3 会引入ScriptableObject作为数据配置载体。它本质上是 Unity 提供的一种数据容器,可以把菜谱、任务配置做成资源文件,策划不需要打开代码就能调整数值。这不只是“规范”,更是经营类游戏后续扩展的基础。

2. 环境准备与项目结构

2.1 版本说明

本文使用的是 Unity6 系列版本。Unity6 在渲染、UI 工具和脚本 API 上相比旧版本有一些变化,但经营类游戏用到的核心 API,比如MonoBehaviourCoroutineScriptableObjectUGUI事件接口,在Unity 2021 LTSUnity 6之间都保持兼容。因此如果你本地安装的是Unity 2022 LTSUnity 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是生成器内部维护的数量,真实项目建议在CustomerControllerOnDestroy中回调给生成器,或者通过事件系统广播,避免计数不准。这里先保留为简化版本。

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("没有匹配的订单,不能上菜"); } } }

这里我故意留了一个小问题:PlayerCookInteractionheldFood目前是 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负责监听游戏事件,累加任务进度,并在进度达标时发放奖励。

这里的关键点在于:任务系统不应该去“轮询”其他模块,而应该订阅事件。订单完成时,GameManagerOrderManager发出事件,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逻辑
满意度始终为 1UpdateSatisfaction里的订单引用为空确认顾客创建订单成功后再计算满意度
UI 刷新太频繁在 Update 中反复拼接字符串改成事件驱动刷新,或使用对象池复用 Text

如果你遇到了“顾客卡住不动”的问题,需要优先检查两件事:第一,顾客脚本是否被正确挂载;第二,targetPosition是否是一个有效物体的 Transform。很多新手会把targetPosition拖成预制体资源,而不是场景实例,导致运行时引用为空。

9. 工程最佳实践与扩展建议

9.1 事件驱动优于直接引用

Part3 的代码里已经大量使用了事件系统。这是经营类游戏工程化的第一步。如果你发现某个脚本需要同时调用三个其他脚本的方法,通常意味着可以用事件解耦。比如订单完成时,不需要让OrderManager去调用TaskManager的方法,而是让TaskManager自己订阅“金钱变化”或“订单完成”事件。

9.2 ScriptableObject 当配置表

菜谱、任务、店铺等级都可以做成ScriptableObject。这样做的好处有:

  • 策划可以脱离代码配置数值。
  • 不同菜谱可以有不同的图片、描述、价格。
  • 后续做道具系统、成就系统时,可以复用同一套数据模式。
  • 比较容易和 JSON 存档对接。

9.3 对象池的必要性

顾客、菜品、飘字提示都是高频创建和销毁的对象。频繁InstantiateDestroy会导致 GC 压力。建议在 Part3 完成后,把顾客生成改为对象池模式。核心思路:预创建 N 个对象,隐藏不用,需要时激活并重置状态,不需要时回收而不是销毁。

这不是“优化洁癖”,而是经营游戏在移动端运行时的真实需求。顾客在你开店高峰期可能有七八个同时在场,菜品和飘字更多,没有池化很容易掉帧。

9.4 存档与安全写入

单机游戏一定要做存档。Part3 没有展开存档代码,但架构上已经支持:金钱、等级都集中在GameManager,任务进度在TaskManager。保存时只需要把这些数值序列化成 JSON,写入Application.persistentDataPath下的文件即可。

注意事项:

  • 不要在游戏运行中被异常强杀时写存档,容易损坏文件。
  • 建议采用“临时文件 + 主文件”的写入方式,写完后替换。
  • 涉及删除旧存档或重置数据时,先在测试环境验证,正式项目还要做好存档备份。
  • 移动端上,避免在主线程做大量磁盘 IO。

9.5 代码规范建议

  • 限定命名空间:FoodGame.CoreFoodGame.DataFoodGame.Order
  • 公有字段用[SerializeField]私有化,避免外部随意修改。
  • 数据修改集中在管理类,不要允许每个脚本直接改金钱。
  • 使用#region按“字段、生命周期、公共方法、私有方法”分组,方便阅读。
  • 每个脚本开头写清楚文件路径和职责,方便多人协作。

10. 总结与下一步学习路线

Part3 完成之后,你的项目已经从“玩家可以在场景里跑动”升级成了“有明确经营目标的可玩游戏原型”。你掌握了数据配置、事件通信、订单生命周期、顾客状态机、锅具状态机、上菜判定和任务驱动升级。

接下来可以继续打磨的方向有几个,难度从低到高排序:

  • 在现有框架上增加更多菜谱、更多锅具和更复杂的食材步骤。
  • 把满意度改为评价星级,影响单笔收入倍率。
  • 增加店铺装饰购买系统,用金币购买桌椅和装修。
  • 实现完整存档与读档功能。
  • 把任务系统扩展成“成就墙”,增加长线目标。
  • 加入音效、动画和特效,提升操作反馈。

如果 Part3 你已经顺利跑通,可以试着做一次“重构练习”:把场景里所有硬编码引用清理掉,全部改为事件或 Inspector 赋值。这个练习能帮你提前规避很多项目中期才爆发的架构问题。如果遇到卡点,可以先记录现象,再从事件订阅、引用赋值、状态枚举三个方面排查。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 18:54:18

HarmonyOS开发工程师转型指南:技能拆解、实操路线与面试考点全解析

看到HarmonyOS开发工程师这个岗位的讨论热度持续走高&#xff0c;不少朋友私信问我“到底值不值得转”“面试都考什么”。我在移动端这块摸爬滚打了十多年&#xff0c;这两年也完整经历了从传统App开发转向鸿蒙生态的全过程&#xff0c;踩过不少坑&#xff0c;也拿过几个还不错…

作者头像 李华
网站建设 2026/9/9 18:52:06

基于ESP32的RGBWY五通道灯带双模无线控制方案

先说个实际场景。晚上窝在沙发里看电影&#xff0c;想把灯带调到那种暗一点、带琥珀色的暖光&#xff0c;遥控器不知道扔哪儿了&#xff0c;手机App启动又慢&#xff0c;最后只能走去墙边把开关拍一下。这种体验大家多少都遇到过。我这次做的RGBWY双模无线控制方案&#xff0c;…

作者头像 李华
网站建设 2026/9/9 18:48:41

永磁同步电机NVH仿真:从电磁力到声辐射的全链路解析

最近在帮朋友处理一台永磁同步电机的啸叫问题&#xff0c;电机一上电就发出听起来很尖的电磁噪声&#xff0c;现场用声学相机一扫&#xff0c;噪声源直接指向机壳侧面。为了定位问题&#xff0c;我们把电磁—谐响应—噪声这条多物理场仿真链路完整走了一遍&#xff0c;最终锁定…

作者头像 李华
网站建设 2026/9/9 18:48:03

基于J-Link SDK的Qt烧录上位机开发:从J-Flash到自研工具的实践

简介&#xff1a;一份基于Qt开发的J-Link上位机烧录工具源码&#xff0c;面向STM32/GD32嵌入式开发者&#xff0c;旨在提供可编译运行的烧录与调试一体化方案。项目通过调用J-Link官方API接口&#xff0c;实现了固件读取、擦除、写入及校验等完整烧录流程&#xff0c;并支持SWD…

作者头像 李华