1812 字
5 分钟
City Builder 的数据驱动之道:ScriptableObject 与网格管理
2026-06-26

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 分支
}
}

问题显而易见:

  1. 加新建筑 = 改代码。策划想加一个”学校”建筑,必须找程序员
  2. 数据散落各处。Prefab 在 Inspector 里拖,类型名是硬编码字符串,没有统一入口
  3. 无法复用。另一个场景要用同一套建筑数据?重新拖一遍 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#

方案优势劣势适用场景
ScriptableObjectInspector 可视化编辑,支持拖 Prefab/贴图,内存中只存一份非程序员不能直接编辑(需要 Unity Editor)大部分游戏配置
JSON纯文本,版本控制友好,任何编辑器都能改不能拖 Prefab,需要写解析代码运行时热更新、服务端下发
CSVExcel 可编辑,策划友好同上,且嵌套结构难表达大量纯数值表格

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);

这里有两点值得注意:

  1. ID 只存 int,不存 ObjectData 引用——避免 ScriptableObject 被多处直接依赖。ID 是一个轻量的”外键”,想获取建筑名或 Prefab 时再去 ObjectDatabaseSO
  2. “场景实例化”和”逻辑网格更新”是两个独立步骤——ObjectPlacer 负责 GameObject 的生命周期,GridData 负责纯数据管理。哪怕以后想支持”纯数据模式”(比如 AI 模拟城市发展,不需要渲染),GridData 的代码一行都不用改

五、完整类图#

注意 PlacementDataObjectData 之间的关系——不是直接引用,而是”通过 ID 引用”。实线带空心三角是组合(composition),虚线带空心三角是依赖(dependency)。


六、总结#

模块技术方案为什么这样设计
建筑数据ObjectDatabaseSO + ScriptableObject支持 Prefab 拖拽、内存共享、非程序员可编辑
网格存储Dictionary<Vector3Int, PlacementData>适合稀疏数据/无限地图,O(1) 查询
占位信息PlacementData 预存 OccupiedPositions删除建筑时不需要反向搜索,也不需要知道原点
数据与实例分离ID 引用 + 独立 ObjectPlacer场景实例化与逻辑数据解耦,支持纯数据模拟

核心收获#

  1. ScriptableObject 是游戏配置的第一选择。只要数据需要在 Inspector 里编辑(拖 Prefab/贴图/调数值),就优先考虑 ScriptableObject。JSON 只在需要热更新或跨平台时才用。

  2. Dictionary > 二维数组(在大多数放置类游戏中)。稀疏网格场景下,Dictionary 不需要预分配,查询速度相同。唯一的代价是内存碎片稍多,但除非网格密度超过 50%,否则感知不到差异。

  3. 预存储 > 实时计算(当计算需要额外信息时)。就像 OccupiedPositions 预存一样——如果你删建筑时需要知道它的原点,而原点无法从当前信息推导,就别犹豫,直接存下来。

  4. DDC 的核心理念不是 ScriptableObject,而是”数据与逻辑分离”。用 ScriptableObject 是实现手段之一。真正的价值在于——当你以后想换一种网格数据结构(比如从 Dictionary 换成稀疏八叉树),建筑数据层完全不受影响。


下一篇:《策略模式实战:City Builder 的可扩展放置系统》——如何用策略模式让建筑和道路共享同一套放置框架,以及怎么通过 PlacementContext 避免接口膨胀。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

City Builder 的数据驱动之道:ScriptableObject 与网格管理
https://www.losrhythm.world/posts/citybuilder/02-scriptableobject-grid-system/
作者
LosRhythm
发布于
2026-06-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录