在我们日常的教育类游戏(DGBL)开发中,经常会遇到极其庞大的同类知识点对象。比如烹饪玩法里的几十种食材、化学实验里的 118 种元素、或者是语言学习里的海量词根词缀。

如果在 Unity 中为每一种 “面条”、“番茄” 或 “氧元素” 单独制作一个 Prefab(预制件)并编写独立的控制脚本,工程将迅速膨胀。后续教研同学想要修改属性时,也会因为满屏的代码而无从下手。

为了解决这个痛点,我们在近期的项目中引入了数据驱动设计结合 ** 有限状态机(FSM)** 的架构。通过将 “数据” 与 “表现容器” 彻底剥离,实现了一套对团队协作极其友好的高复用性系统。

# 1. 核心架构拆解:享元模式下的数据与容器分离

在传统的面向对象编程中,我们习惯于把属性和方法写在同一个脚本里挂载给物体。但在这里,我们借鉴了享元模式(Flyweight Pattern)的思想:将成百上千个物体中不变的逻辑外壳提取为通用容器,将变化的数据抽离为独立配置。

# 1.1 定义静态数据模板:ScriptableObject

首先,我们摒弃了在 MonoBehaviour 里直接写死属性的做法,利用 Unity 的 ScriptableObject 定义了一个纯数据类 FOODSO

这相当于一个 “模具”,它不参与场景运行,只负责存储各类属性定义:

FOODSO.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[CreateAssetMenu(fileName = "NewFoodData", menuName = "CookingGame/FoodData")]
public class FOODSO : ScriptableObject
{
public string foodName; // 实体名称
public Sprite icon; // UI/场景显示贴图

[Header("逻辑属性")]
public bool canBeCooked; // 交互鉴权:是否能放进设备处理
public float processTime; // 处理耗时

[Header("表现配置")]
public string processText = "Processing..."; // 处理时的文本提示
public string finishedText = "Ready!"; // 完成时的文本提示
}

设计红利: 所有的知识点数据都变成了 Project 文件夹里的轻量级 .asset 资源文件。团队里的同学只需要在后台右键创建对应的数据体并 “填表”、配图即可。完全零代码,实现了程序与策划工作流的彻底解耦。

# 1.2 构建通用视觉容器

我们不需要制作几十个预制件,整个项目中维护的实体 Prefab 永远只有 1 个
它是一个极其干净的空壳,仅包含基础的排版组件和负责显示的 Image 子节点。对应的控制脚本只暴露一个简单的注入接口:

ItemVisual.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using UnityEngine;
using UnityEngine.UI;

public class ItemVisual : MonoBehaviour
{
[Header("视觉层引用")]
public Image iconImage;
private FOODSO _data; // 存储当前物体的核心数据

// 暴露给外部调用的“初始化”接口
public void Initialize(FOODSO data)
{
_data = data;
if (iconImage != null && _data != null)
{
iconImage.sprite = _data.icon; // 注入数据,瞬间“易容”
}
}

public FOODSO GetData() => _data;
}

# 2. 行为解耦:基于状态模式的无差别交互台

解决了 “物体是什么” 的问题后,我们需要处理交互逻辑(比如把食材扔进锅里,或者把元素扔进反应炉)。这部分的难点在于防止玩家的异常操作(比如菜已经煮熟了还在往里加料,导致逻辑崩溃)。

针对这个问题,我们利用 ** 有限状态机(FSM)** 设计了统一的交互组件。交互台本身对具体放入的是什么一无所知,它只认 FOODSO 这种数据结构,并在三个严谨的状态间流转: Idle (空闲)、 Processing (处理中)、 Finished (已完成)。

# 状态机的封闭性与安全性验证

DeviceController.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
26
27
28
29
30
31
32
33
34
35
36
using UnityEngine;

public enum DeviceStatus { Idle, Processing, Finished }

public class DeviceController : MonoBehaviour
{
public DeviceStatus currentStatus = DeviceStatus.Idle;
private FOODSO currentData;
private float timer;

// 交互触发点:状态阻拦与数据读取
public void StartProcessing(FOODSO data)
{
// 经典的状态鉴权:如果不是空闲状态,或该物品设定为不可处理,直接拒绝
if (currentStatus != DeviceStatus.Idle || !data.canBeCooked) return;

currentData = data;
currentStatus = DeviceStatus.Processing; // 切换至处理状态,上锁

// 此处可执行 UI 表现同步(读取传入数据)
timer = 0f;
}

void Update()
{
if (currentStatus == DeviceStatus.Processing)
{
timer += Time.deltaTime;
if (timer >= currentData.processTime)
{
currentStatus = DeviceStatus.Finished;
// 执行产出逻辑
}
}
}
}

Update() 的生命周期中,倒计时逻辑被严格包裹在 Processing 状态下。一旦时间达到判定阈值,内部触发完成逻辑切断计时,并将状态推至 Finished ,等待玩家取走成品后重置状态,重新回归 Idle

# 3. 跨项目复用场景

这套 “通用容器 + 数据体 + 状态机交互台” 的系统非常纯粹,可以迅速平移至以下场景:

  1. 化学 / 物理实验沙盒: * FOODSO 变为 ElementSO (包含化学式、外观、反应耗时)。

    • 交互台变为 Reactor_Prefab (反应炉)。
    • 玩家将不同元素拖入反应炉,状态机鉴权后开始读取配方倒计时,最终生成新的化合物实体。
  2. 语言文字建构玩法: * FOODSO 变为 WordSO (包含词根、词缀文本及发音音频)。

    • 交互台变为 GrammarSlot_Prefab (语法判定槽)。
    • 判定槽通过状态机拦截非法的词缀组合,并在组合成功后播放对应的正向反馈和发音。

采用这种架构,不仅降低了运行时的渲染和内存压力,更重要的是它为团队协作铺平了道路 —— 底层逻辑的舞台搭好后,后续关卡内容的扩充仅仅是填表而已。

更新于