City Builder 的数据驱动之道:ScriptableObject 与网格管理
上一篇完成了摄像机系统,这篇来解决放置系统的数据层——如何管理建筑数据、如何设计网格、以及最重要的:为什么 ScriptableObject 是你应该最先学会的 Unity 架构工具。
一、放置一个建筑需要哪些数据
回放下需求:玩家在网格上选择一块空地,点击放置一座建筑。拆解下来需要两类数据:
设计时数据(策划配置的,游戏中不变):
| 数据 | 例子 |
|---|---|
| 建筑名称 | ”居民楼” |
| 建筑 ID | 唯一标识 |
| 建筑类型 | 建筑 / 道路 |
| 尺寸 | 2×2 格、3×1 格 |
| 预制体 | 哪个 GameObject 模板 |
运行时数据(游戏中动态变化的):
| 数据 | 例子 |
|---|---|
| 哪些格子被占了 | {(0,0),(0,1),(1,0),(1,1)} → 一座 2×2 的建筑 |
| 建筑朝向 | 0° / 90° / 180° / 270° |
| 放置的是哪种建筑 | 通过 ID 引用设计时数据 |
二、设计时数据:ScriptableObject 为什么是正确答案
反面教材:硬编码
如果不用 ScriptableObject,你可能会这样写:
public class BuildingPlacer : MonoBehaviour{ public GameObject housePrefab; public GameObject shopPrefab; public GameObject factoryPrefab;
public void PlaceBuilding(string type, Vector3 position) { if (type == "house") Instantiate(housePrefab, position, Quaternion.identity); else if (type == "shop") Instantiate(shopPrefab, position, Quaternion.identity); // ... 每加一种建筑,多一个 if 分支 }}问题显而易见:
- 加新建筑 = 改代码。策划想加一个”学校”建筑,必须找程序员
- 数据散落各处。Prefab 在 Inspector 里拖,类型名是硬编码字符串,没有统一入口
- 无法复用。另一个场景要用同一套建筑数据?重新拖一遍 Prefab
ScriptableObject 方案
ScriptableObject 是 Unity 提供的一种特殊资源——它像类一样定义字段,但数据存在 .asset 文件中,独立于场景和 Prefab。
// 第一步:定义建筑的类型数据public enum ObjectType{ Road, Building}
[System.Serializable]public class ObjectData{ public string Name; public int ID; public ObjectType Type; public Vector2Int Size; // 建筑占多少格,如 2×2 public GameObject Prefab; // 实例化用的预制体}
// 第二步:用 ScriptableObject 做"数据库"[CreateAssetMenu(menuName = "CityBuilder/Object Database")]public class ObjectDatabaseSO : ScriptableObject{ public List<ObjectData> objectDataList;}在 Project 窗口右键 Create → CityBuilder → Object Database,得到一个 .asset 文件。打开后可以直接在 Inspector 里增删建筑条目:填名称、ID、类型、尺寸、拖 Prefab。策划可以自己操作,不碰代码。
ScriptableObject vs JSON vs CSV
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| ScriptableObject | Inspector 可视化编辑,支持拖 Prefab/贴图,内存中只存一份 | 非程序员不能直接编辑(需要 Unity Editor) | 大部分游戏配置 |
| JSON | 纯文本,版本控制友好,任何编辑器都能改 | 不能拖 Prefab,需要写解析代码 | 运行时热更新、服务端下发 |
| CSV | Excel 可编辑,策划友好 | 同上,且嵌套结构难表达 | 大量纯数值表格 |
City Builder 的建筑数据包含 GameObject Prefab 字段——这个字段必须拖拽赋值,CSV 和 JSON 都做不到。所以 ScriptableObject 是唯一选择。
为什么 ScriptableObject 省内存? 假如场景中有 50 栋居民楼,每栋楼共享同一个 ObjectData。如果数据直接写在 Prefab 上,50 个实例就有 50 份拷贝;而 ScriptableObject 不管被引用多少次,内存中只有一份。
三、运行时数据:网格系统的设计
为什么是 Dictionary<Vector3Int, PlacementData> 而不是二维数组
直觉方案是用二维数组 GridCell[,]:
GridCell[,] grid = new GridCell[width, height];grid[0, 0].isOccupied = true;这在固定大小的地图上完全可行。但 City Builder 可能扩展为”无限地图”(类似 Factorio),二维数组需要预先分配内存,且动态扩容代价高。
Dictionary 天然适合稀疏数据——只有被放置了建筑的格子才占内存,空地不占。查询 O(1),与数组一致。
public class GridData{ // Key = 网格坐标,Value = 放置在该格子的对象信息 public Dictionary<Vector3Int, PlacementData> placementObjects = new();
public bool CanPlaceObjectAt(Vector3Int gridPos, Vector2Int objectSize) { // 计算建筑将占用的所有格子 List<Vector3Int> occupiedCells = CalculateOccupiedPositions(gridPos, objectSize);
foreach (var cell in occupiedCells) { if (placementObjects.ContainsKey(cell)) return false; // 有任意一个格子已被占用 } return true; }
public void AddObjectAt( Vector3Int gridPos, Vector2Int objectSize, int ID, int placedObjectIndex, Quaternion rotation) { List<Vector3Int> occupiedCells = CalculateOccupiedPositions(gridPos, objectSize); PlacementData data = new(occupiedCells, ID, placedObjectIndex, rotation);
foreach (var cell in occupiedCells) placementObjects[cell] = data; }
private List<Vector3Int> CalculateOccupiedPositions( Vector3Int origin, Vector2Int size) { List<Vector3Int> cells = new(); for (int x = 0; x < size.x; x++) for (int y = 0; y < size.y; y++) cells.Add(origin + new Vector3Int(x, 0, y)); return cells; }}PlacementData:为什么每个格子都存完整引用,而不是只存 ID
public class PlacementData{ public List<Vector3Int> OccupiedPositions; // 这个建筑占了多少格 public int ID; // 对应 ObjectData 的 ID public int PlacedObjectIndex; // 实例在场景中的索引 public Quaternion Rotation; // 放置时的朝向}如果你这样做:
// ❌ 每个格子只存 ID,再去 ObjectDatabaseSO 查Dictionary<Vector3Int, int> grid; // grid[pos] = 建筑 ID问题是:当需要删除建筑时,你不知道这个建筑占了多少格。你需要反向遍历整个字典找相同 ID 的格子——O(n) 操作。
而 PlacementData 中存储了 OccupiedPositions,删除时遍历这个列表就行,是 O(㎡)(㎡ 等于建筑面积,通常在 2×2 到 5×5 之间,常数级)。
为什么 OccupiedPositions 不直接算,要预存?
你可能会想:既然已知建筑尺寸,删除时再算一遍 CalculateOccupiedPositions 不就行了?
问题是删除时你只有格子坐标,不知道建筑的原点(origin)。2×2 建筑占 {(0,0),(1,0),(0,1),(1,1)},你点击 (1,1) 想删除,但建筑的原点可能在 (0,0)。如果没有记录原点,你无法”反推”出整个建筑的占用范围。预存 OccupiedPositions 就解决了这个问题——随便点哪个格子,都能定位到完整的占用列表。
四、双数据源的联动
ObjectDatabaseSO(ScriptableObject)和 GridData(Dictionary)如何配合:
// 要放置一个 ID=5 的建筑,在格子 (3, 0, 4) 上
// 1. 从 ScriptableObject 查设计数据ObjectData objectData = objectDatabase.objectDataList.Find(o => o.ID == 5);
// 2. 检查网格可用性if (!gridData.CanPlaceObjectAt(new Vector3Int(3, 0, 4), objectData.Size)) return; // 有重叠,不能放
// 3. 在场景中实例化GameObject instance = Instantiate(objectData.Prefab, worldPosition, rotation);int index = objectPlacer.Register(instance); // 记录索引
// 4. 更新网格数据(逻辑层)gridData.AddObjectAt(new Vector3Int(3, 0, 4), objectData.Size, 5, index, rotation);这里有两点值得注意:
- ID 只存
int,不存ObjectData引用——避免 ScriptableObject 被多处直接依赖。ID 是一个轻量的”外键”,想获取建筑名或 Prefab 时再去ObjectDatabaseSO查 - “场景实例化”和”逻辑网格更新”是两个独立步骤——
ObjectPlacer负责 GameObject 的生命周期,GridData负责纯数据管理。哪怕以后想支持”纯数据模式”(比如 AI 模拟城市发展,不需要渲染),GridData的代码一行都不用改
五、完整类图
注意 PlacementData 和 ObjectData 之间的关系——不是直接引用,而是”通过 ID 引用”。实线带空心三角是组合(composition),虚线带空心三角是依赖(dependency)。
六、总结
| 模块 | 技术方案 | 为什么这样设计 |
|---|---|---|
| 建筑数据 | ObjectDatabaseSO + ScriptableObject | 支持 Prefab 拖拽、内存共享、非程序员可编辑 |
| 网格存储 | Dictionary<Vector3Int, PlacementData> | 适合稀疏数据/无限地图,O(1) 查询 |
| 占位信息 | PlacementData 预存 OccupiedPositions | 删除建筑时不需要反向搜索,也不需要知道原点 |
| 数据与实例分离 | ID 引用 + 独立 ObjectPlacer | 场景实例化与逻辑数据解耦,支持纯数据模拟 |
核心收获
-
ScriptableObject 是游戏配置的第一选择。只要数据需要在 Inspector 里编辑(拖 Prefab/贴图/调数值),就优先考虑 ScriptableObject。JSON 只在需要热更新或跨平台时才用。
-
Dictionary>二维数组(在大多数放置类游戏中)。稀疏网格场景下,Dictionary 不需要预分配,查询速度相同。唯一的代价是内存碎片稍多,但除非网格密度超过 50%,否则感知不到差异。 -
预存储 > 实时计算(当计算需要额外信息时)。就像
OccupiedPositions预存一样——如果你删建筑时需要知道它的原点,而原点无法从当前信息推导,就别犹豫,直接存下来。 -
DDC 的核心理念不是 ScriptableObject,而是”数据与逻辑分离”。用 ScriptableObject 是实现手段之一。真正的价值在于——当你以后想换一种网格数据结构(比如从 Dictionary 换成稀疏八叉树),建筑数据层完全不受影响。
下一篇:《策略模式实战:City Builder 的可扩展放置系统》——如何用策略模式让建筑和道路共享同一套放置框架,以及怎么通过
PlacementContext避免接口膨胀。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
