ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

Unity6美食经营游戏开发:订单、顾客与任务系统架构全解析 做美食经营类项目时很多开发者会陷入一个共同的困境单个功能怎么看都简单比如“生成一个顾客”“炒一份菜”“弹一个订单面板”但要把它们串成一套完整的经营循环代码就开始失控了——脚本之间互相引用、状态到处修改、加一个新功能就要动老代码。如果你正卡在这个阶段那么本篇文章应该能帮你把思路重新理清。本文是 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 Actionint OnOrderCountChanged; // 金钱变化 public static Actionint OnMoneyChanged; // 顾客满意度变化 public static Actionstring, float OnCustomerSatisfactionChanged; // 烹饪状态变化 public static Actionstring OnCookingStateChanged; // 任务进度变化 public static Actionstring, int OnTaskProgressChanged; // 店铺等级变化 public static Actionint 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 Liststring requiredIngredients new Liststring(); 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 ListRecipeData allRecipes new ListRecipeData(); public ListRecipeData AllRecipes allRecipes; public RecipeData GetRecipeById(string id) { foreach (var recipe in allRecipes) { if (recipe.recipeId id) return recipe; } return null; } public ListRecipeData GetUnlockedRecipes(int playerLevel) { var result new ListRecipeData(); 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 ListOrder activeOrders new ListOrder(); private int nextOrderId 1; public IReadOnlyListOrder 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 ListOrder displayedOrders new ListOrder(); 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.GetComponentCustomerController(); 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.GetComponentCookingPot(); if (pot ! null) nearbyPot pot; } private void OnTriggerExit(Collider other) { CookingPot pot other.GetComponentCookingPot(); 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.GetComponentPlayerCookInteraction(); 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 ListTaskData activeTasks new ListTaskData(); private Dictionarystring, int taskProgress new Dictionarystring, 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 对象池的必要性顾客、菜品、飘字提示都是高频创建和销毁的对象。频繁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 赋值。这个练习能帮你提前规避很多项目中期才爆发的架构问题。如果遇到卡点可以先记录现象再从事件订阅、引用赋值、状态枚举三个方面排查。
返回列表