1. 项目概述:为什么我们需要绕开场景和预制体?
在Unity开发的日常中,我们早已习惯了将游戏世界构建在一个个.unity场景文件中,将可复用的对象做成预制体(Prefab)。这就像盖房子,场景是设计好的户型图,预制体是标准化的门窗和家具,拖拽、实例化、运行,一气呵成。这套工作流直观、高效,是Unity引擎的基石。但是,你有没有遇到过这样的困境:一个超大型的开放世界关卡,场景文件加载缓慢,内存占用巨大;或者是一个需要动态生成、高度可配置的Roguelike地牢,每次进入都是全新的布局,用传统的场景和预制体来“保存”关卡信息,显得笨重且不灵活。
这正是“不使用场景和预制体保存关卡信息”这个命题的价值所在。它探讨的是一种更底层、更数据驱动的关卡构建方式。简单来说,我们不再依赖Unity编辑器里那个可视化的、包含所有GameObject引用和Transform数据的场景文件,也不依赖预制体资源作为模板。取而代之的,是我们自己定义的一套数据结构,用来描述关卡中每一个元素的位置、类型、状态等信息。游戏运行时,程序读取这份“蓝图”数据,然后在内存中动态地创建出整个游戏世界。
这样做的好处是显而易见的。首先是灵活性:关卡数据可以来自任何地方——一个本地的JSON/XML文件、一个远程服务器、甚至是一串算法生成的种子。其次是性能:对于超大地图,我们可以实现流式加载,只实例化玩家视野范围内的物体,极大减轻内存和CPU的瞬时压力。再者是版本控制友好:纯文本或二进制数据文件比庞大的场景文件更容易进行diff和合并。最后,它也为动态内容(如玩家自定义关卡、程序化生成内容)打开了大门。
当然,这并不意味着要抛弃场景和预制体。它们依然是编辑和预览的高效工具。本方案的核心思想是“编辑时用场景,运行时用数据”。我们可以在编辑器中利用场景和预制体进行快速布局和调试,但最终发布时,将布局信息“烘焙”成纯粹的数据。下面,我将结合一个完整的可运行示例(文末附源文件),拆解如何实现这套系统。
2. 核心架构设计:数据与逻辑的分离
要实现脱离场景和预制体的关卡系统,首要任务是设计一个清晰的数据模型。这个模型需要能完整描述一个关卡,同时又要足够轻量,便于序列化(保存到文件)和反序列化(从文件加载)。
2.1 定义关卡数据模型
我们的关卡数据模型需要包含哪些信息?以一个简单的2D平台游戏关卡为例,我们至少需要知道:
- 关卡标识:ID、名称、版本。
- 玩家出生点:位置和旋转。
- 所有关卡实体:比如平台、敌人、金币、陷阱等。每个实体都需要有:
- 类型标识:这是一个“金币”还是一个“蘑菇怪”?
- 位置和旋转:它在世界坐标系中的Transform信息。
- 唯一ID:用于运行时查找和状态管理(如这个金币是否已被收集)。
- 自定义属性:比如敌人的血量、金币的价值、平台是否移动等。
在C#中,我们可以用可序列化的类来定义这些结构。这里的关键是使用[System.Serializable]特性,这样这些类才能被Unity的JsonUtility或第三方库如Newtonsoft.Json序列化。
[System.Serializable] public class LevelData { public string levelId; public string levelName; public Vector3 playerSpawnPoint; public List<EntityData> entities = new List<EntityData>(); } [System.Serializable] public class EntityData { public string entityId; // 唯一标识,如 “enemy_001” public string prefabId; // 关联的预制体标识符,如 “Prefabs/Enemies/Mushroom” public Vector3 position; public Vector3 rotation; public Vector3 scale; // 扩展属性:可以是一个字符串字典,存储任意自定义数据 public SerializableDictionary<string, string> properties; } // 一个简单的可序列化字典实现(Unity原生不支持序列化Dictionary) [System.Serializable] public class SerializableDictionary<TKey, TValue> { public List<TKey> keys = new List<TKey>(); public List<TValue> values = new List<TValue>(); // ... 需要实现添加、查找等方法 }注意:这里我们引入了一个
prefabId字段。这看似与“不使用预制体”矛盾,实则不然。这里的prefabId是一个逻辑标识符,而不是直接的Prefab引用。它告诉系统:“当需要创建这个实体时,请去找标识为‘Prefabs/Enemies/Mushroom’的资源”。这个资源可以通过Resources.Load、Addressables或AssetBundle系统动态加载。这实现了数据与资源引用的解耦。
2.2 设计关卡管理器(LevelManager)
数据模型有了,我们需要一个大脑来协调整个关卡的加载、创建和销毁。这就是LevelManager,一个通常以单例模式存在的核心管理器。
它的核心职责包括:
- 加载数据:从指定路径(如
Application.streamingAssetsPath/Levels/level_01.json)读取LevelData。 - 资源映射:维护一个
Dictionary<string, GameObject>,将prefabId映射到实际的Prefab对象。这个映射可以在启动时初始化,也可以按需异步加载。 - 实例化关卡:遍历
LevelData.entities,根据prefabId找到对应的Prefab,然后在指定的position和rotation实例化它。 - 实体管理:为每个实例化的GameObject附加一个
LevelEntity脚本。这个脚本持有其对应的EntityData,并负责在游戏过程中更新数据(如位置变化、状态改变),以及在销毁时可能需要的清理工作。 - 状态保存:在游戏过程中,如果关卡状态发生变化(如金币被收集),
LevelManager需要能收集所有LevelEntity的当前状态,并序列化回LevelData,实现游戏进度的保存。
public class LevelManager : MonoBehaviour { public static LevelManager Instance { get; private set; } private Dictionary<string, GameObject> _prefabCache = new Dictionary<string, GameObject>(); private LevelData _currentLevelData; private List<LevelEntity> _spawnedEntities = new List<LevelEntity>(); void Awake() { if (Instance != null && Instance != this) Destroy(this); else Instance = this; InitializePrefabCache(); // 预加载或建立资源索引 } public void LoadLevel(string levelFilePath) { // 1. 清空当前关卡 ClearCurrentLevel(); // 2. 从文件读取LevelData string json = File.ReadAllText(levelFilePath); _currentLevelData = JsonUtility.FromJson<LevelData>(json); // 3. 实例化玩家 InstantiatePlayer(_currentLevelData.playerSpawnPoint); // 4. 实例化所有实体 foreach (var entityData in _currentLevelData.entities) { SpawnEntity(entityData); } } private void SpawnEntity(EntityData data) { if (!_prefabCache.TryGetValue(data.prefabId, out GameObject prefab)) { Debug.LogError($"Prefab not found for ID: {data.prefabId}"); return; } GameObject instance = Instantiate(prefab, data.position, Quaternion.Euler(data.rotation)); instance.transform.localScale = data.scale; var levelEntity = instance.AddComponent<LevelEntity>(); levelEntity.Initialize(data); _spawnedEntities.Add(levelEntity); } public void SaveLevel(string saveFilePath) { // 收集所有实体的当前数据 foreach (var entity in _spawnedEntities) { entity.UpdateEntityData(); // 让每个实体更新自己的数据(如位置) } // 将_currentLevelData序列化为JSON并保存 string json = JsonUtility.ToJson(_currentLevelData, true); File.WriteAllText(saveFilePath, json); } private void ClearCurrentLevel() { foreach (var entity in _spawnedEntities) { if (entity != null) Destroy(entity.gameObject); } _spawnedEntities.Clear(); } }2.3 实体脚本(LevelEntity)的角色
LevelEntity是挂在每个动态生成的关卡物体上的组件。它是连接游戏对象(GameObject)与原始数据(EntityData)的桥梁。
public class LevelEntity : MonoBehaviour { public EntityData Data { get; private set; } public void Initialize(EntityData data) { this.Data = data; this.Data.entityId = data.entityId; // 确保ID被设置 // 可以根据data.properties初始化自身状态 if (data.properties.TryGetValue("health", out string healthStr)) { // 初始化血量组件 } } public void UpdateEntityData() { // 将当前Transform状态写回Data Data.position = transform.position; Data.rotation = transform.eulerAngles; Data.scale = transform.localScale; // 更新其他需要保存的属性到Data.properties } void OnDestroy() { // 必要时从LevelManager的列表中移除自己 LevelManager.Instance?.OnEntityDestroyed(this); } }3. 实操流程:从编辑器布局到数据驱动运行
有了核心架构,我们来看看如何将其融入实际工作流。整个过程可以分为“编辑阶段”和“运行时阶段”。
3.1 编辑阶段:利用场景作为“可视化编辑器”
我们并不需要在真空中设计关卡。相反,我们可以最大化利用Unity编辑器的便利性。
- 在场景中搭建:像往常一样,在Unity场景中放置Prefab来设计关卡。你可以尽情使用场景视图、网格对齐、Probuilder等所有工具。
- 创建“关卡烘焙器”编辑器工具:这是一个关键步骤。我们需要编写一个Editor脚本,它遍历当前场景中所有带有特定标记(如一个
LevelDesignTag组件)的GameObject。 - 收集数据:对于每个标记的物体,工具读取其Prefab信息(需要一种方式将场景中的Prefab实例映射到我们定义的
prefabId,例如通过一个PrefabMapping的ScriptableObject),以及它的Transform信息,生成一个EntityData。 - 生成LevelData文件:工具收集玩家出生点(可以是一个特殊的空物体),并将所有生成的
EntityData整合成一个LevelData对象,最后使用JsonUtility.ToJson将其序列化为一个JSON文件,保存到项目的StreamingAssets或Resources文件夹下。
// 示例:一个简单的编辑器烘焙工具 #if UNITY_EDITOR using UnityEditor; using UnityEngine; public class LevelBaker : EditorWindow { [MenuItem("Tools/Bake Current Scene to Level Data")] static void BakeLevel() { LevelData levelData = new LevelData(); levelData.levelId = "level_" + SceneManager.GetActiveScene().name; levelData.levelName = SceneManager.GetActiveScene().name; // 假设玩家出生点是一个名为“PlayerSpawn”的GameObject GameObject spawnPoint = GameObject.Find("PlayerSpawn"); if (spawnPoint != null) levelData.playerSpawnPoint = spawnPoint.transform.position; // 查找所有带“LevelEntity”组件的物体(编辑时临时添加用于标记) var allEntities = FindObjectsOfType<LevelEntityEditorHelper>(); foreach (var helper in allEntities) { EntityData data = new EntityData(); data.prefabId = helper.prefabId; // 编辑时手动指定或通过工具映射 data.position = helper.transform.position; data.rotation = helper.transform.eulerAngles; data.scale = helper.transform.localScale; data.entityId = System.Guid.NewGuid().ToString(); // 生成唯一ID levelData.entities.Add(data); } string json = JsonUtility.ToJson(levelData, true); string path = Path.Combine(Application.streamingAssetsPath, "Levels", levelData.levelId + ".json"); File.WriteAllText(path, json); AssetDatabase.Refresh(); Debug.Log($"Level baked to: {path}"); } } #endif实操心得:在编辑阶段,可以为设计物体临时添加一个
LevelEntityEditorHelper组件,上面有一个prefabId的字符串字段,方便设计师手动填写或通过下拉菜单选择。烘焙完成后,这个组件在运行时不会被使用。这保证了编辑的灵活性和运行时的纯净。
3.2 运行时阶段:加载与实例化
游戏运行时,流程就变得非常清晰:
- 初始化:游戏启动,
LevelManager初始化,加载Prefab资源映射表。 - 加载关卡:玩家选择关卡后,调用
LevelManager.Instance.LoadLevel(jsonFilePath)。 - 数据解析:管理器读取JSON文件,反序列化成
LevelData对象。 - 世界生成:管理器根据
LevelData,依次实例化玩家和所有实体。此时,场景中可能一开始几乎是空的,所有物体都是动态生成的。 - 游戏进行:玩家与动态生成的实体交互。
- 进度保存:在检查点或退出时,调用
LevelManager.Instance.SaveLevel(saveFilePath),将当前所有实体的状态写回一个新的JSON文件。
3.3 资源管理策略:PrefabId如何关联真实Prefab?
这是实现“不使用预制体保存”但最终又需要预制体的关键。我们有几个主流选择:
Resources.Load(适用于小型项目):将Prefab放在
Resources文件夹下,prefabId就是相对于Resources文件夹的路径(不含扩展名)。例如prefabId = "Prefabs/Enemy/Mushroom"。LevelManager在初始化时可以用Resources.Load<GameObject>(prefabId)预加载所有可能用到的Prefab到缓存字典中。缺点是Resources文件夹不易管理,且所有资源会打包在一个AssetBundle中。Addressables(Unity官方推荐,适用于中大型项目):这是更现代、更强大的方案。为每个Prefab设置一个Addressable Address,这个Address就可以作为
prefabId。运行时使用Addressables.LoadAssetAsync<GameObject>(prefabId)来异步加载。Addressables提供了完善的依赖管理、内存管理和远程加载能力。自定义AssetBundle(适用于高度定制化的管线):手动或通过脚本将Prefab打包成AssetBundle,并维护一个AssetBundle名与资源路径的映射表。
prefabId可能是一个复合键,如"enemy_bundle/mushroom"。
在我们的示例项目中,为了简洁和可运行,会采用Resources.Load的方式。但在实际生产环境中,我强烈建议使用Addressables。
4. 高级技巧与深度优化
基础系统搭建完成后,我们可以从性能、工作流和扩展性方面进行深度优化。
4.1 性能优化:流式加载与对象池
对于大型关卡,一次性实例化所有实体是不可取的。我们需要流式加载(Streaming)。
- 空间划分:将关卡划分为多个区域(Chunk或Cell),每个区域关联一部分
EntityData。在LevelData中,实体列表可以按区域分组。 - 动态加载/卸载:根据玩家位置,
LevelManager动态加载玩家所在区域及相邻区域的实体,并卸载远离玩家的区域。这需要与EntityData的设计结合,例如为每个实体增加一个chunkId字段。 - 结合对象池:对于频繁创建和销毁的实体(如子弹、特效、可再生物品),使用对象池(Object Pooling)。
LevelManager在实例化时,先向对象池请求,池中没有才真正实例化。实体“销毁”时,实际上是回收到池中。这能有效减少GC(垃圾回收)压力。
// 简化的流式加载逻辑 public void UpdateStreaming(Vector3 playerPosition) { Vector3Int currentChunkCoord = GetChunkCoordinate(playerPosition); if (currentChunkCoord != _lastChunkCoord) { // 计算需要加载的新区块和需要卸载的旧区块 HashSet<Vector3Int> chunksToLoad = CalculateChunksToLoad(currentChunkCoord); HashSet<Vector3Int> chunksToUnload = CalculateChunksToUnload(_lastChunkCoord); foreach (var coord in chunksToUnload) UnloadChunk(coord); foreach (var coord in chunksToLoad) LoadChunk(coord); _lastChunkCoord = currentChunkCoord; } } private void LoadChunk(Vector3Int coord) { // 从LevelData中找到属于这个coord的所有EntityData var entitiesInChunk = _currentLevelData.GetEntitiesInChunk(coord); foreach (var data in entitiesInChunk) { SpawnEntity(data); // SpawnEntity内部应整合对象池逻辑 } }4.2 编辑器工作流强化
为了让关卡设计师更舒服,我们需要打造强大的编辑器工具。
- 可视化编辑与实时烘焙:可以创建一个自定义的
LevelEditorWindow,允许设计师在场景视图中直接放置“实体笔刷”,选择prefabId,然后点击放置。所有操作实时更新到一个内部的LevelData结构中,并提供一键保存到文件的功能。甚至可以做到“运行时编辑”,在Play模式下调整物体位置,停止播放后自动将变化写回数据文件。 - 数据验证:在烘焙前,工具应自动检查数据有效性,例如:是否有实体使用了无效的
prefabId?实体ID是否重复?玩家出生点是否设置? - 版本迁移:当
LevelData或EntityData的数据结构发生变更时(如新增一个字段),需要提供旧版本数据文件升级到新版本的迁移脚本。
4.3 扩展性设计:支持复杂属性和逻辑
基础的EntityData.properties字典可以存储字符串键值对,但如何存储复杂类型(如颜色、数组、甚至对其他实体的引用)?
- 自定义序列化:可以定义自己的序列化格式。例如,将
properties字典的值类型定义为string,但约定其内容是一段JSON。对于“颜色”属性,存储为"{\"r\":1.0,\"g\":0.0,\"b\":0.0,\"a\":1.0}"。在实体初始化时,再解析这段JSON。 - 使用ScriptableObject定义实体类型:为每一种实体类型(如“移动平台”、“开关门”、“对话NPC”)创建一个
EntityTypeSO的ScriptableObject。这个SO可以定义该类型的所有可能属性(强类型)。在EntityData中,我们存储一个entityTypeId来引用对应的SO,以及一个序列化的属性值列表。这样在编辑器和运行时都能获得良好的类型支持。 - 逻辑组件附着:
LevelEntity在初始化时,可以根据EntityData中的类型信息,动态地为GameObject添加相应的逻辑脚本组件。例如,如果properties中包含"patrolPath",则自动添加一个PatrolBehaviour脚本,并用路径数据初始化它。
5. 常见问题、排查技巧与避坑指南
在实际实现这套系统的过程中,你会遇到不少坑。以下是我总结的一些典型问题和解决方案。
5.1 数据序列化与反序列化问题
- 问题:使用
JsonUtility序列化包含Vector3、Quaternion或自定义类的List时,数据丢失或格式错误。 - 排查:首先检查所有需要序列化的类是否都标记了
[System.Serializable]。对于Vector3,JsonUtility可以原生支持,但为了更好的兼容性,有时会将其拆分为三个float字段(x, y, z)来定义。 - 解决:
- 使用包装类:如果
JsonUtility不能满足需求(如序列化字典、多态类型),强烈推荐使用Newtonsoft.Json (Json.NET)。通过Unity的Package Manager安装即可,功能强大,对C#类型支持极好。 - 自定义序列化:实现
ISerializationCallbackReceiver接口,在OnBeforeSerialize和OnAfterDeserialize中手动处理复杂数据的转换。 - 二进制序列化:如果对文件大小和加载速度有极致要求,可以考虑使用
BinaryFormatter(已过时,不推荐)或MemoryPack、MessagePack等高性能二进制序列化库。但会牺牲可读性。
- 使用包装类:如果
5.2 资源加载与引用丢失
- 问题:运行时根据
prefabId加载Prefab时返回null。 - 排查步骤:
- 检查
prefabId字符串是否完全正确,包括大小写和路径分隔符(通常用“/”)。 - 如果使用Resources,确认Prefab是否放在名为
Resources的文件夹下,且路径是相对于Resources文件夹的。 - 如果使用Addressables,检查Addressable Groups的构建是否正确,Address是否匹配。
- 在编辑器模式下,可以在
LevelManager的InitializePrefabCache方法中加入Debug.Log,打印出所有成功加载的Prefab名,进行核对。
- 检查
- 解决:建立一套资源校验工具。在烘焙关卡数据时,工具自动验证场景中每个实体引用的Prefab是否存在于目标加载路径(Resources或Addressables)中,并在验证失败时给出明确错误提示。
5.3 运行时实体管理与状态同步
- 问题:动态创建的实体在游戏过程中被销毁(如敌人被击败),但在保存关卡时,数据中仍然包含它,导致下次加载时又出现。
- 解决:在
LevelEntity中维护一个bool isActive或bool isDestroyed状态。在UpdateEntityData方法中,如果实体已被逻辑销毁,则将其从LevelData.entities列表中移除,或者在数据中标记为“已销毁”。加载时,跳过标记为“已销毁”的实体。 - 问题:实体之间的引用(如一个开关控制一扇门)在序列化后丢失。
- 解决:不要直接存储对另一个
GameObject或LevelEntity的引用。而是存储目标实体的entityId(字符串)。在运行时,LevelManager需要提供一个根据entityId查找LevelEntity的方法。实体初始化后,通过这个方法来解析引用关系。
public class SwitchEntity : LevelEntity { public string targetDoorEntityId; // 存储目标门的ID private DoorEntity _targetDoor; public override void Initialize(EntityData data) { base.Initialize(data); // 初始化时解析引用 if (data.properties.TryGetValue("targetDoorId", out string doorId)) { targetDoorEntityId = doorId; } } void Start() { // 在Start或稍后的阶段,通过管理器解析引用 _targetDoor = LevelManager.Instance.GetEntityById(targetDoorEntityId) as DoorEntity; if (_targetDoor == null) Debug.LogError($"Switch {Data.entityId} cannot find door {targetDoorEntityId}"); } }5.4 版本控制与协作冲突
- 问题:JSON数据文件在版本控制(如Git)中,多人同时修改同一关卡,合并时容易产生冲突,且冲突不易解决。
- 解决:
- 细分数据文件:不要将整个关卡的所有数据放在一个巨大的JSON文件里。可以按区域(Chunk)或按类型(背景装饰、交互物体、敌人)拆分成多个小文件。这样冲突的范围会缩小。
- 使用结构化数据格式:考虑使用更易于diff和merge的格式,如YAML。或者,将数据存储在SQLite等轻量级数据库中,通过Schema来管理。
- 自定义合并工具:对于核心的关卡数据文件,可以编写一个简单的合并工具,优先以某个设计师的版本为主,或者提供可视化的冲突解决界面。
5.5 内存与性能 profiling
- 问题:流式加载频繁实例化/销毁物体,导致内存碎片和GC压力。
- 排查:使用Unity Profiler,重点关注:
- GC Alloc:查看每帧的托管内存分配。对象池是减少分配的关键。
- Instantiate Calls:查看实例化调用的频率和耗时。
- Memory > Simple:查看Texture、Mesh、Material等资源是否被正确加载和卸载,避免内存泄漏。
- 解决:
- 对象池全覆盖:对所有通过数据动态生成的、可能频繁出现和消失的实体实现对象池。
- 异步加载:使用Addressables的异步加载接口,避免主线程卡顿。对于Resources,可以使用
ResourceRequest的异步版本。 - 预加载:预测玩家移动方向,提前异步加载下一个区域所需的Prefab资源到内存中,但先不实例化。当玩家进入时,实例化速度会非常快。
这套“不使用场景和预制体保存关卡信息”的方案,本质上是在构建一个属于你自己的、高度定制化的关卡数据驱动系统。它初期需要一定的开发投入,但带来的灵活性、性能提升和对于特定类型项目(如开放世界、程序化生成游戏)的适配能力是巨大的。它让你从Unity编辑器的固有工作流中跳脱出来,真正以数据和代码为核心去构建你的游戏世界。附带的源文件提供了一个最基础的实现框架,你可以以此为起点,根据自己项目的具体需求,逐步添砖加瓦,构建出最适合你的那个强大而优雅的关卡系统。