教程参考及素材来源:油管-Code Monkey
项目简介
《Kitchen Chaos》是一款轻量版的 Overcooked 风格游戏。它是一个完整的项目,非常适合用来学习架构、模块化和事件系统。这个版本是单人厨房管理游戏,玩家扮演厨师,在限定时间内制作顾客点单的菜品。游戏节奏快、任务多,需要玩家在混乱的厨房中合理分工。
学习目标
这个课程是油管上评分最高的免费课程,有一些没有编程经验的人甚至跟随课程之后在一年时间内上线自己首款独立游戏并赚了几十到几百万美金。这个课程里,Code Monkey同样遵循他一贯的代码设计理念,只写干净的、商业级的游戏代码。因为这个项目是中等规模的程序,所以包括的可复用的系统和架构模式十分丰富。这些我都可以复用在以后自己的游戏中。
另外,本小站的笔记与其说是分享型的,不如说是私人定制型的,所以一些本人已经熟悉的好的设计,不会记录在笔记中;之前已经记录过的觉得好的点也不会重复记录。
系统包括:
- 玩家系统
- 交互系统
- 食材系统
- 厨房工作台系统
- 订单系统
- UI 系统
架构模式包括:
- 事件系统(C# Events)
- 接口驱动的交互(IInteractable)
- ScriptableObject 配置数据
- 状态机(Cooking State / Cutting State)
此外,由于代码整体拥有良好的可扩展性,也适合我以后做工程化扩展,可以轻松加入:
- 更多菜品
- 更多工作台
- 多人联机(未来你做网络版时非常有用)
- 难度曲线
- 厨房布局随机生成
核心玩法结构
接收订单(Orders)
- 顾客会随机生成订单(如汉堡)
- 玩家需要根据订单要求准备对应的食材组合
- 订单有时间限制,超时会扣分或失去奖励
处理食材(Ingredients)
- 食材需要经过不同的处理方式:
- 切菜(Chopping)
- 烹饪(Cooking / Frying)
- 组合(Assembling)
- 每种处理方式都有对应的工作台(Counter)
厨房操作(Kitchen Stations)
课程中会实现多个交互点,这些系统都通过事件系统 + ScriptableObjects实现解耦,是课程的亮点,需要重点学习
- 切菜台:按住键切到进度条完成
- 炉灶/平底锅:加热食材,有烧焦风险
- 垃圾桶:丢弃错误食材
- 盘子台:获取盘子
- 上菜窗口:提交完成的菜品
玩家控制(Player Movement & Interaction)
课程使用**接口(Interfaces)**统一交互逻辑,让所有可交互物体共享同一套规则。
得分系统(Scoring)
- 完成订单 → 加分
- 超时或错误 → 扣分
- 连续完成订单 → Combo 奖励
开发步骤
-
项目设置
-
Post Processing
-
角色的控制与动画
-
Input System(输入部分代码重构)
-
碰撞检测
-
柜台
- 空柜台
- 交互(Raycast + LayerMask, events)
- 选中柜台的视觉效果
-
厨房物品
- 在空柜台上的生成位置
- Scriptable Objects
- 厨房物体的父对象
-
玩家与厨房物品的交互
-
不同类型的柜台(prefab variant)
- 空柜台(重新做成预制体变量)
- container counter
- cutting counter
- 切菜的实现(整颗生成对应的片片)
- 切菜进度及UI
- 切菜进度条始终正对玩家
- Trash Counter
思路及关键步骤
输入和逻辑分离
这样的好处在此项目中,最显而易见的是在后期input重构时,逻辑部分可以在不同平台下复用。
总结这种策略的好处:
- 提高代码可读性与可维护性:将输入处理(如按键检测)与游戏逻辑(如角色移动、状态更新)分开,使代码结构更清晰,便于理解和修改
- 增强模块化与复用性:输入处理和核心逻辑解耦后,逻辑部分可在不同平台或输入方式(如手柄、触屏)下复用,只需更换输入模块
- 便于测试与调试:逻辑部分不依赖具体输入,可独立进行单元测试;调试时也更容易定位问题是出在输入还是逻辑
- 支持更灵活的架构设计:为后续引入ECS、状态机或命令模式等高级架构打下基础,提升项目扩展性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
private void Update()
{
Vector2 inputVector = new Vector2(0, 0);
if (Input.GetKey(KeyCode.W))
{
inputVector.y = 1;
}
if (Input.GetKey(KeyCode.S))
{
inputVector.y = -1;
}
if (Input.GetKey(KeyCode.A))
{
inputVector.x = -1;
}
if (Input.GetKey(KeyCode.D))
{
inputVector.x = 1;
}
inputVector = inputVector.normalized; // 为了让对角线的速度也是1
Vector3 moveDirection = new Vector3(inputVector.x, 0, inputVector.y);
transform.position += moveDirection * moveSpeed * Time.deltaTime;
}
|
使用 Slerp 处理角色的转向
Lerp 和Slerp 都可用于插值计算,两者的关键区别如下:
| 特性 |
Lerp |
Slerp |
| 插值路径 |
直线(欧氏空间) |
球面大圆弧(旋转空间) |
| 常用于 |
位置、颜色、缩放等 |
旋转(Quaternion) |
| 速度均匀性 |
线性均匀 |
角速度均匀(更自然的旋转) |
| 性能 |
更快 |
稍慢(涉及三角函数计算) |
角色的动画
处理动画的注意点
点击“录制”后放置关键帧才有效。另外,如果要在不动物体的情况下设置关键帧,需要改变一下物体的位置再还原,否则无法添加关键帧。
动画的名称是区分大小写的,而且动画的部分必须使用字符串名称,无法绕开,所以要十分小心拼写、大小写等。
动画中的 visual 与 logic 分离原则
根据 visual 和 logic 分离原则,将 animator 添加到 PlayerVisual子物体上。根据松耦合的原则,不要将动画的逻辑直接放到 Player.cs 中,而是创建 PlayerAnimator.cs,挂在PlayerVisual子物体上。
拓展:CInemachine工作原理
基于“虚拟相机(Virtual Camera) + 主相机(Main Camera)”的架构:
主相机(Main Camera)
虚拟相机(Virtual Camera)
- 虚拟相机是 Cinemachine 的核心组件,代表一种“镜头意图”(如跟随玩家、聚焦目标、过场运镜等)
- 每个虚拟相机可以设置:
- Follow:自动跟随某个目标(如玩家角色)
- Look At:朝向某个焦点(如敌人或交互对象)
- Body / Aim 设置:控制相机的位置偏移(如轨道、环绕、自由跟随)和朝向逻辑(如平滑对准)
- Priority:决定多个虚拟相机之间的切换优先级
CinemachineBrain
- 附加在主相机上,负责:
- 根据虚拟相机的 Priority 选择当前激活的虚拟相机
- 在多个虚拟相机之间进行平滑过渡(blend)
- 将选中虚拟相机计算出的最终位置/旋转应用到主相机
高级功能支持
- Noise(抖动):模拟手持摄像机效果
- Impulse(冲击反馈):配合
CinemachineImpulseSource 实现爆炸、跳跃等镜头震动
- Timeline 集成:与 Unity 的 Timeline 工具无缝协作,制作电影化过场动画
所以,添加了Cinemachine后,无需/不能控制主摄像机,控制Cinamachine就可以了。本项目中,Cinemachine的初始参数设置成和原来的主摄像机一样就可以了。
输入部分的重构
为了让input和玩家的代码逻辑解耦,创建一个空的游戏物体,在其上挂载单独的脚本 GameInput.cs。这里遵循的是“单一职责原则”,一个类/函数只做一件事情。好代码原则是:管理和最小化项目的复杂度。
同时保留新旧两套输入系统:Edit => Project Settings => Player => Configuration => Active Input Handling => Both
新建一个 Input Actions,在这里我们将管理 Player 所有的输入。
WASD
创建Action(取名为 Move)=> Action Type(Value) => 删掉默认的 Binding => 新建选择 Up/Down/Left/Right Composite
生成C# class
点击之前创建的 PlayerInputActions ,勾选 Generate C# class
重构后的输入代码
使用 Input System 后重构的代码只有短短几句。用 InputSystem来控制的玩家移动都大同小异,这个脚本可以作为“积木”使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public class GameInput : MonoBehaviour
{
private PlayerInputActions playerInputActions;
private void Awake()
{
playerInputActions = new PlayerInputActions();
playerInputActions.Enable();
}
public Vector2 GetInputVectorNormalized()
{
Vector2 inputVector = playerInputActions.Player.Move.ReadValue<Vector2>();
inputVector = inputVector.normalized; // 为了让对角线的速度也是1
return inputVector;
}
}
|
此外,代码中的 normalized 也可以交由 Input System中的 Processors 来做,真的很周到了。
3D的碰撞检测
在3D中一般使用Cast和Raycast来处理碰撞检测,它们的含义和使用场景有一些区别。
Raycast(射线检测)
从某一点沿指定方向发射一条无限细、无体积的直线(射线),检测它是否与场景中的碰撞体(Collider)相交。常用于瞄准、点击检测、视线判断等。
特点:
- 只检测“线”是否碰到物体
- 性能开销小
- 无法检测空心区域或复杂形状内部
Cast(投射 / 形状检测)
发射一个有体积的几何形状(如球体、胶囊体、盒子等),沿方向移动一段距离,检测是否与碰撞体重叠。常见的类型有 SphereCast、
CapsuleCast、BoxCast 等。用于角色移动预测和更真实的碰撞预判(比如角色跳跃前检测前方是否有障碍)。
特点:
- 考虑物体的体积和形状,比
Raycast更贴近实际碰撞
- 性能开销略高于
Raycast,但更精确
关于碰撞时如果同时按下两个键,玩家移动的处理
主要的思路是将速度分解,先看x轴方向能不能走,能走就走x方向的;然后再看z轴方向能不能走,能走就走z方向的。需要注意的时,由于在分解单个轴方向时矢量会变短(斜线方向矢量更大的相反情况),所以单个轴方向上的矢量仍然需要归一化,以保证分解后的速度是一样大小的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
Vector3 moveDirection = new Vector3(inputVector.x, 0, inputVector.y);
float playerHeight = 2f;
float playerRadius = 0.7f;
float moveDistance = moveSpeed * Time.deltaTime;
// 碰撞检测 Cast
bool canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirection, moveDistance);
if(!canMove)
{
// 不能移动的情况下,分解速度到每个方向上
// 只有在x轴方向能移动
Vector3 moveDirectionX = new Vector3(moveDirection.x, 0, 0);
canMove = !Physics.CapsuleCast(transform.position,transform.position +Vector3.up*playerHeight,playerRadius, moveDirectionX, moveDistance);
if (canMove)
{
moveDirection = moveDirectionX.normalized;
}
else
{
// 只能在z轴移动
Vector3 moveDirectionZ = new Vector3(0,0,moveDirection.z);
canMove = !Physics.CapsuleCast(transform.position, transform.position+Vector3.up*playerHeight,playerRadius, moveDirectionZ, moveDistance);
if(canMove)
{
moveDirection = moveDirectionZ.normalized;
}
else
{
// 一点都不能移动
}
}
}
if(canMove)
transform.position += moveDirection * moveDistance;
|
与柜台的交互
使用Raycast来检测玩家是否碰到了柜台。这里需要处理的细节是,记录上一次和玩家和柜台交互的方向,这样可以解决玩家必须一直运动才显示和柜台交互的问题。另外,使用LayerMask来确保玩家和柜台交互。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
private void HandleInteraction()
{
Vector2 inputVector = gameInput.GetInputVectorNormalized();
Vector3 moveDirection = new Vector3(inputVector.x, 0, inputVector.y);
if (moveDirection != Vector3.zero)
{
lastInteractDirection = moveDirection; // 在运动,就更新lastInteractDirection
}
float interactDistance = 2f;
if (Physics.Raycast(transform.position, lastInteractDirection, out RaycastHit raycastHit, interactDistance, counterLayerMask))
{
if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter))
{
clearCounter.Interact();
}
}
}
|
进一步重构:
使用事件来监听player与counter的交互,从而达到解耦的目的。
在 GameInput.cs 中我们使用委托为Interact Action添加一个调用的函数Interact_performed()。performed是 InputAction 提供的事件之一。Input System 对每个 action 提供三个常见阶段的回调:started(开始),performed(触发/完成),canceled(取消)。使用 performed 表示当输入动作被视为“完成”时(例如按下按钮,或按键触发的交互条件满足时)调用你的回调。
具体调用的方式参照我们在设置Input Action资产时所命名的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
public class GameInput : MonoBehaviour
{
private PlayerInputActions playerInputActions;
public event EventHandler OnInteractAction;
private void Awake()
{
playerInputActions = new PlayerInputActions();
playerInputActions.Enable();
// 监听player 与 counter的交互
playerInputActions.Player.Interact.performed += Interact_performed;
}
private void Interact_performed(InputAction.CallbackContext context)
{
OnInteractAction?.Invoke(this, EventArgs.Empty);
}
private void OnDestroy()
{
playerInputActions?.Disable();
playerInputActions.Player.Interact.performed -= Interact_performed;
}
|
随后在 Player.cs 中根据消息做出相应的反应:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
private void Start()
{
gameInput.OnInteractAction += GameInput_OnInteraction;
}
private void OnDestroy()
{
gameInput.OnInteractAction -= GameInput_OnInteraction;
}
private void GameInput_OnInteraction(object sender, EventArgs e)
{
HandleInteraction();
}
|
柜台选中时的视觉效果
我们复制 ClearCouter 下的子物体 ClearCounter_Visual,并将其命名为 Selected,为其设置对应的材质。一个需要注意的点是:
由于两个大小完全一样的物体的网格重叠时可能会形成闪烁的效果,为了避免这种情况,需要将选中效果的柜台稍微放大一些,例如本项目中将其扩大了 1%。
另外,需要事先将 选中效果 隐藏,只需要使 选中柜台中单纯负责渲染的子物体失活即可。
在处理选中的视觉效果,有两种思路可以选择:
- 让当前 selectedCounter 发送通知,在处理视觉表现的脚本中订阅该事件并且做出相应的视觉效果的变化。也就是在
ClearCounter.cs 中发送事件,在 SeletedCounterVisual.cs 中订阅该事件
- 让当前操作的角色发送通知,在处理视觉表现的脚本中订阅该事件并做出视觉效果的变化
两种方法有各自的优缺点:
- 第一种方法
- 好处:SeletedCounterVisual.cs 只订阅当前选中柜台的事件,如果我们需要选中时添加其他效果也可以很方便地添加
- 坏处:控制视觉效果的代码又需要出现在专门处理逻辑的
ClearCounter.cs的脚本中,造成耦合
- 第二种方法
- 好处:更方便。控制视觉效果的代码不会出现在处理逻辑的脚本中,解耦
- 坏处:可能存在性能问题。因为所有的柜台都需要订阅由角色发送的事件
鉴于此项目规模较小,并不会带来性能瓶颈,所以此处选择了第二种方法。
因为此项目是单人版,所以我们索性将玩家设置为单例。
踩的坑
在局部优化的时候,我在 SelectedCounterVisual.cs 中,将来自 player 的事件注册到了 Enable 中:
1
2
3
4
5
6
7
8
9
10
11
|
//============ SelectedCounterVisual.cs ============
private void OnEnabe()
{
Player.Instance.OnSelectedCounterChange += Player_OnSelectedCounterChange;
Hide();
}
private void OnDisable()
{
Player.Instance.OnSelectedCounterChange -= Player_OnSelectedCounterChange;
}
|
这引起了空引用报错。
因为在 Unity 的生命周期顺序里,OnEnable() 可能会在很多情况下很早触发(比如物体/组件一开始就是 Enabled、或者 SetActive(true) 时),它可能早于 Player 的 Awake() / Start() 执行。
虽然我的 Player.Instance = this; 是在 Awake() 里做的,那理论上应该很早,但仍可能因为 SelectedCounterVisual 启用时, Player.Instance还没赋值,导致空引用异常。所以更安全的做法是将注册事件的部分移到 Start 中:
1
2
3
4
5
6
7
8
9
10
|
private void Start()
{
Player.Instance.OnSelectedCounterChange += Player_OnSelectedCounterChange;
Hide();
}
private void OnDestroy()
{
Player.Instance.OnSelectedCounterChange -= Player_OnSelectedCounterChange;
}
|
有别的脚本参与的注册事件最好放在 Start 中(类似于 GetComponent的处理一样),不要放在 Enable 中,避免空引用问题(其他脚本还没准备好就开始注册和该脚本有关的事件)
使用Scriptable Object来管理厨房物品
这是我第一次在项目中系统使用 ScriptableObject 来管理资源,所以将这部分的内容详细总结如下:
ScriptableObject
是 Unity 中 的一种轻量级、数据驱动的资源类,它不依附于场景中的 GameObject,而是作为 Project 窗口中的独立资产(.asset 文件) 存在。
好处
- 解耦数据与逻辑
- 将配置数据(如角色属性、关卡参数、技能效果)从 MonoBehaviour 脚本中剥离,避免硬编码
- 修改数值无需改动代码,便于策划或美术协作
- 跨场景复用
- 一个 ScriptableObject 资产可在多个场景、Prefab 或脚本中引用,保证数据一致性(如全局游戏设置)
- 编辑器友好
- 在 Inspector 中直观编辑,支持自定义绘制(PropertyDrawer)、Undo/Redo、多选批量修改
- 节省内存
- 数据集中存储,避免每个实例重复持有相同配置(相比在 MonoBehaviour 中复制粘贴)
- 版本控制友好
- .asset 文件为纯文本(YAML 格式),便于 Git 等工具 diff 和合并
典型使用场景
| 场景 |
示例 |
| 游戏配置 |
玩家初始生命值、敌人掉落表、武器伤害参数 |
| 关卡/任务数据 |
关卡目标、任务奖励、对话树节点 |
| 状态机/行为树 |
AI 行为配置、动画状态参数 |
| 本地化文本 |
多语言词条表(Key-Value 对) |
| 经济系统 |
商店商品价格、货币兑换规则 |
| 原型快速迭代 |
设计师直接调整数值,无需程序员介入 |
注意事项
- 不可用于运行时动态生成的数据(如玩家存档)→ 应使用 JSON / PlayerPrefs / Save System
- 不包含 MonoBehaviour 生命周期方法(如
Update)→ 仅用于静态数据容器
- 引用其他对象(如 Prefab、Texture)时需注意资源依赖和打包
创建 KitchenObjectSO 脚本
新建专门的脚本,继承自 ScriptableObject,然后把需要的属性设为字段。为了规范,还是采用私有字段,公开Get使其可以被外部访问。
1
2
3
4
5
6
7
8
9
10
11
|
[CreateAssetMenu()]
public class KitchenObjectSO : ScriptableObject
{
[SerializeField] GameObject prefab;
[SerializeField] Sprite sprite;
[SerializeField] string objectName;
public GameObject Prefab => prefab;
public Sprite Sprite => sprite;
public string ObjectName => objectName;
}
|
通过鼠标右键创建了资产后,就可以在面板上进行相应设置了:
创建 KitchenObject 脚本
为了能够识别不同的厨房物品,创建 KitchenObject .cs,绑定相应的可编程对象资产,这样玩家和柜台互动时就能识别不同的物体了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
//=========== KitchenObject .cs ===========
public class KitchenObject : MonoBehaviour
{
[SerializeField] private KitchenObjectSO kitchenObjectSO;
public KitchenObjectSO GetKitchenObjectSO()
{
return kitchenObjectSO;
}
}
//============ ClearCounter.cs =============
public class ClearCounter : MonoBehaviour
{
[SerializeField] private KitchenObjectSO kitchenObjectSO;
[SerializeField] private Transform counterTopPointTransform;
public void Interact()
{
print("clear counter is interacting!");
Instantiate(kitchenObjectSO.Prefab, counterTopPointTransform.position, Quaternion.identity);
print(kitchenObjectSO.Prefab.transform.GetComponent<KitchenObject>().GetKitchenObjectSO().ObjectName);
}
}
|
厨房物体的父对象
游戏的设计是:玩家可以拿起物体,放在不同的柜子上,这就需要通过改变这个厨房物体的父物体来实现对厨房对象做各种不同的操作。
我们从两个方面来实现,它们是一一对应的:
- 空柜台需要知道自己上面是否有厨房物品
- 厨房物品需要知道自己在哪个柜台上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
//=========== ClearCounter.cs ===========
public class ClearCounter : MonoBehaviour
{
[SerializeField] private KitchenObjectSO kitchenObjectSO;
[SerializeField] private Transform counterTopPointTransform;
private KitchenObject kitchenObject;
public void Interact()
{
if (kitchenObject == null)
{
GameObject kitchenObjectGameObject = Instantiate(kitchenObjectSO.Prefab, counterTopPointTransform);
kitchenObjectGameObject.transform.localPosition = Vector3.zero;
kitchenObject = kitchenObjectGameObject.GetComponent<KitchenObject>();
kitchenObject.SetClearCounter(this);
}
else
{
print(kitchenObject.GetClearCounter());
}
}
}
//============= KitchenObject.cs ==============
public class KitchenObject : MonoBehaviour
{
[SerializeField] private KitchenObjectSO kitchenObjectSO;
private ClearCounter clearCounter;
public KitchenObjectSO GetKitchenObjectSO()
{
return kitchenObjectSO;
}
public void SetClearCounter(ClearCounter clearCounter)
{
this.clearCounter = clearCounter;
}
public ClearCounter GetClearCounter()
{
return this.clearCounter;
}
}
|
将厨房物品移动到另一个柜台上去,也就是说,此时厨房物品的父对象改变了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
//=========== ClearCounter.cs ===========
// 因为厨房物品是放在 counterTopPointTransform 上的
// 所以用一个函数返回
public Transform GetKitchenObjectFollowTransform()
{
return counterTopPointTransform;
}
//============= KitchenObject.cs ==============
public void SetClearCounter(ClearCounter clearCounter)
{
this.clearCounter = clearCounter;
// 设置厨房物品的父对象
transform.parent = clearCounter.GetKitchenObjectFollowTransform();
transform.localPosition = Vector3.zero;
}
|
通过这样的操作,厨房物品的transform虽然已经可以到另一个柜台了,但是厨房物品的数据信息仍然留在原来的柜台上。所以也需要对其进行处理。这里有两种方法:
此项目中采用了第二种方法。厨房物品告诉老的父级“它已经不在那里了”,再告诉新的父级“它现在在那里”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
//=========== ClearCounter.cs ===========
// 增加一些helper函数
// 厨房物品的 getter 和 setter,用以通知父级它的更新
public void SetKitchenObject(KitchenObject kitchenObject)
{
this.kitchenObject = kitchenObject;
}
public KitchenObject GetKitchenObject()
{
return kitchenObject;
}
public void ClearKitchenObject()
{
kitchenObject = null;
}
public bool HasKitchenObject()
{
return kitchenObject != null;
}
//============= KitchenObject.cs ==============
// 有厨房物品本身通知它的两个父级自己状态的变化
public void SetClearCounter(ClearCounter clearCounter)
{
// 通知老的柜台:这个厨房物品已经不在了
// 老柜台:this.clearCounter
// 新柜台:参数列表中的 clearCounter(没有this)
if(this.clearCounter != null)
{
// 将老柜台上的厨房物品清空
this.clearCounter.ClearKitchenObject();
}
// 设置新柜台
this.clearCounter = clearCounter;
// 确保这真的是一个新的空柜台,上面没有任何厨房物品
if (clearCounter.HasKitchenObject())
{
Debug.LogError("Counter already has a kitchen object");
}
// 在新柜台上设置厨房物品
clearCounter.SetKitchenObject(this);
// 设置厨房物品的父对象
// Update visual
transform.parent = clearCounter.GetKitchenObjectFollowTransform();
transform.localPosition = Vector3.zero;
}
|
避坑:
这里的新老柜台因为名字都是 kitchenObjectParent ,所以非常容易搞错。
我第一次就搞乱了新老柜台,导致后面玩家无法在空柜台上正常放下物品。
所以为了保险起见,虽然很多情况下 this 都是可以省略的,但是为了清晰,以后还是需要加上。
在此过程中,Code Monkey进行了测试,复制另一个空柜台,看:
- 厨房物品有没有从视觉上从老柜台移动新柜台上
- 老柜台和新柜台中,厨房物品的数据有没有更新正确
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
//========= testing ==========
[SerializeField] ClearCounter clearCounter2;
[SerializeField] bool testing;
private void Update()
{
if (testing && Input.GetKeyDown(KeyCode.T))
{
if (kitchenObject != null)
{
kitchenObject.SetClearCounter(clearCounter2);
}
}
}
|
在此项目中,Code Monkey展示了大量这样的测试过程,用打印信息,或者在播放状态下把面板调成 Debug 状态来看私有变量或者其他数据的变化情况。这是一种高效的开发方式,因为 Unity 中的断点调试不太容易,所以用这两者结合,在开始先将逻辑跑通,避免将bug堆积到后期。
玩家与厨房物品的交互
由于厨房物品既需要与空柜台交互,又需要与玩家交互,两者的逻辑某些方法是重合的——可以将空柜台和玩家都看成是厨房物品的父对象,所以设计一个接口 IKitchenObjectParent ,这样,厨房物品能同时实现针对柜台和玩家的方法,又不至于需要继承(在逻辑上也说不通)它们。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
public interface IKitchenObjectParent
{
public Transform GetKitchenObjectFollowTransform();
public void SetKitchenObject(KitchenObject kitchenObject);
public KitchenObject GetKitchenObject();
public void ClearKitchenObject();
public bool HasKitchenObject();
}
|
这样,只需将原来在 ClearCounter.cs里换个名字,在 Player.cs 里也做类似实现即可,非常方便高效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public Transform GetKitchenObjectFollowTransform()
{
return counterTopPointTransform;
}
public void SetKitchenObject(KitchenObject kitchenObject)
{
this.kitchenObject = kitchenObject;
}
public KitchenObject GetKitchenObject()
{
return kitchenObject;
}
public void ClearKitchenObject()
{
kitchenObject = null;
}
public bool HasKitchenObject()
{
return kitchenObject != null;
}
|
不同类型的柜台
教程采用了prefab variant的方式来实现。具体的做法是先从复制一个空柜台,将它设置为 _BaseCounter(名字带下划线的好处是它可以自动排在文件夹的最前面)。_BaseCounter 只需保留最基本的东西,其他的根据不同柜台的需要添加:CounterTopPoint 子物体和 BoxCollider。于是,包括前面做的 ClearCounter,都可以通过 prefab variant来实现。
使用 Prefab Variant 实现高效复用与差异化管理
好处
-
继承 + 差异覆盖(Inheritance with Overrides)
- Variant 自动继承父 Prefab 的所有属性(组件、参数、子对象结构等)
- 仅需修改有差异的部分(如材质、数值、是否启用某个组件),其余保持同步
-
批量更新,一处修改,多处生效
-
若基础逻辑或公共结构变更(如给所有敌人加一个新脚本),只需修改原始 Prefab,所有 Variant 自动继承更新(除非该属性已被覆盖)
-
清晰的差异化标识
-
在 Inspector 中,被修改的属性会显示为 粗体 + 覆盖标记(Override),一目了然哪些地方做了定制
-
减少重复资产,节省维护成本
-
避免复制多个几乎相同的 Prefab 导致“配置漂移”(例如:10 个敌人只有血量不同,却要维护 10 份完整 Prefab)
-
支持嵌套与层级化设计
适用场景
除了本项目中柜台的案例外,再举几个适用的场景加深印象:
| 场景 |
示例 |
| 角色/敌人变种 |
基础士兵 → 精英士兵(更高血量、不同武器) |
| 道具系统 |
基础药水 → 治疗药水 / 魔力药水(仅图标和效果值不同) |
| UI 元素 |
按钮模板 → 确认按钮 / 取消按钮(仅颜色和文本不同) |
| 关卡物件 |
基础灯柱 → 损坏灯柱 / 闪烁灯柱(仅动画状态或材质不同) |
注意事项
- 避免过度嵌套:Variant 层级过深会增加理解成本
- 慎改结构:若在 Variant 中增删子 GameObject,可能影响后续从父 Prefab 继承的更新
- 适合“小差异”:若两个预制体差异过大(>50% 内容不同),应考虑拆分为独立 Prefab
不同类型柜台的关系设计
由于玩家和每种类型的柜台交互是大同小异的:按下按键,在柜台上生成物品,转移物品的父级对象(柜台/玩家),所以原来玩家和空柜台的交互代码 if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter)) 需要改为玩家和它们上一级的交互。这里可以用接口或者父类实现。由于各个柜台都大同小异,并且它们确实属于同一个大类,所以用一个柜台的父类来与玩家交互,其他各种类型的柜台都继承自这个父类,不管从逻辑上还是从功能上都能说得通,所以教程使用了这种做法。
拓展
virtual 和 abstract
两者都可用于支持继承和多态,但它们在实现要求、使用场景和类约束上有所区别:
| 特性 |
virtual(虚方法) |
abstract(抽象方法) |
| 是否必须有实现 |
必须提供默认实现 |
不能有实现(只有声明) |
| 子类是否必须重写 |
可选(用 override 覆盖) |
必须重写(否则编译报错) |
| 所在类是否可实例化 |
所在类可以是普通类(非抽象),能直接 new |
所在类必须是 abstract 类,不能直接实例化 |
| 设计目的 |
提供可选扩展点(有默认行为) |
定义强制契约(子类必须实现特定功能) |
本项目中两种方法都可以,Code Monkey采用了 virtual 的策略。需要注意的是,此时在其子类 ClearCounter.cs 中同样的方法会有警告,这代表子类中的这个具有相同签名的方法会覆盖父类中的方法,需要加上override 代表我们重写了父类中的方法而非覆盖。
重构
由于两个柜台子类继承了父类 BaseCounter 又同时实现了接口 IKithchenObjectParent,所以索性让 BaseCounter 实现接口,这样就无需每增加一个新类型的柜台就重写一遍实现接口的代码了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
//=============== BaseCounter.cs ================
public class BaseCounter : MonoBehaviour, IKitchenObjectParent
{
[SerializeField] private Transform counterTopPointTransform;
private KitchenObject kitchenObject;
public virtual void Interact(Player player)
{
Debug.LogError("BaseCounter.Interact();");
}
public Transform GetKitchenObjectFollowTransform()
{
return counterTopPointTransform;
}
public void SetKitchenObject(KitchenObject kitchenObject)
{
this.kitchenObject = kitchenObject;
}
public KitchenObject GetKitchenObject()
{
return kitchenObject;
}
public void ClearKitchenObject()
{
kitchenObject = null;
}
public bool HasKitchenObject()
{
return kitchenObject != null;
}
}
|
切菜柜台的实现
这个柜台主要需要解决3件事情:
- 整颗菜需要变成对应的片片
- 有些菜是可以切的(卷心菜、芝士、西红柿),有些是不能的(面包、肉饼)
- 已经是片片的菜不能再次被切
Cutting Recipe
照例使用 Scriptable Object 来实现。我们通过同一种菜不同形态的一一对应的组合来实现切菜的功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
//============ CuttingRecipeSO.cs ============
[CreateAssetMenu]
public class CuttingRecipeSO : ScriptableObject
{
[SerializeField] private KitchenObjectSO input;
[SerializeField] private KitchenObjectSO output;
public KitchenObjectSO Input => input;
public KitchenObjectSO Output => output;
}
//============= CuttingCounter.cs ==============
public override void InteractAlternate(Player player)
{
if (this.HasKitchenObject())
{
// 切菜柜台上有东西,切它
// 1)销毁整个的;2)生成切好的
KitchenObjectSO outputKitchenObjectSO = GetOutputFromInput(GetKitchenObject().GetKitchenObjectSO()); // 必须放在Destroy之前
this.GetKitchenObject().DestroySelf();
KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this);
}
}
// 从输入的 cutting recipe SO 中找到对应的输出的
private KitchenObjectSO GetOutputFromInput(KitchenObjectSO inputKitchenObjectSO)
{
foreach (CuttingRecipeSO cuttingRecipeSO in cutKichenObjectSOArray)
{
if (cuttingRecipeSO.Input == inputKitchenObjectSO)
return cuttingRecipeSO.Output;
}
return null;
}
|
对于可以被切的菜的限定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
//========= CuttingCounter.cs ==========
//======== 只有可以被切的菜才能放到切菜台上 ========
public override void Interact(Player player)
{
// 玩家捡起/放下厨房物品
if (!HasKitchenObject()) // 如果柜台上没有厨房物品
{
// 那么可以放下
if (player.HasKitchenObject()) // 玩家手里有厨房物品
{
// 只有在这个东西都可以被切的情况下才能放到切菜台上
if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
{
player.GetKitchenObject().SetKitchenObjectParent(this);
}
else // 玩家手里没有物品
{
}
}
}
else // 如果柜台上有厨房物品
{
if (player.HasKitchenObject()) // 玩家手上有东西
{
}
else // 玩家手上没东西,把柜台上的厨房物品转到玩家手上
{
GetKitchenObject().SetKitchenObjectParent(player);
}
}
}
//=========== 只有可以被切的菜才能被执行切菜的动作 ============
public override void InteractAlternate(Player player)
{
if (this.HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO())) // 玩家手上有东西,并且它可以被切的情况下才能执行切菜动作
{
// 切菜柜台上有东西,切它
// 1)销毁整个的;2)生成切好的
KitchenObjectSO outputKitchenObjectSO = GetOutputFromInput(GetKitchenObject().GetKitchenObjectSO()); // 必须放在Destroy之前
this.GetKitchenObject().DestroySelf();
KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this);
}
}
|
切菜的进度
因为每种菜的大小不同,所以将 cuttingProgressMax 这个参数放到 CuttingRecipeSO.cs 中。
美化切菜进度条的小技巧:
给进度条的 background 的添加 shadow 或者 outline
当然,切菜进度条的显示也是按照Code Monkey一贯的风格,使用 EventHandler 的方式来处理的。
切菜进度条正对摄像机显示
关于摄像机的更新一般都放在 LateUpdate 中,等待所有的 Update 完成后再进行,以确保没有奇怪的运动发生。
1
2
3
4
5
6
7
|
public class LookAtCamera : MonoBehaviour
{
private void LateUpdate()
{
transform.LookAt(Camera.main.transform);
}
}
|
如果简单这样处理,虽然看似达到了效果,但是仔细一看切菜条是反过来的。原因是进度条UI是切菜柜的子物体,会跟着它旋转,我们从柜子背面看进度条就会呈现从右向左的状态。所以索性写一个完善的脚本来应对各种LookAt的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
public class LookAtCamera : MonoBehaviour
{
private enum Mode
{
LookAt,
LookAtInverted,
CameraForward, // 永远正对摄像机的方向
CameraForwardInverted // 永远顺着摄像机的方向
}
[SerializeField] private Mode mode;
private void LateUpdate()
{
switch(mode)
{
case Mode.LookAt:
transform.LookAt(Camera.main.transform);
break;
case Mode.LookAtInverted:
Vector3 dir = -(Camera.main.transform.position - transform.position);
transform.LookAt(dir + transform.position);
break;
case Mode.CameraForward:
transform.forward = Camera.main.transform.forward;
break;
case Mode.CameraForwardInverted:
transform.forward = -(Camera.main.transform.forward);
break;
}
}
}
|
由于笔记太长,所以分为两个部分,下半部分请见 这里