
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 ListEntityData entities new ListEntityData(); } [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 SerializableDictionarystring, string properties; } // 一个简单的可序列化字典实现Unity原生不支持序列化Dictionary [System.Serializable] public class SerializableDictionaryTKey, TValue { public ListTKey keys new ListTKey(); public ListTValue values new ListTValue(); // ... 需要实现添加、查找等方法 }注意这里我们引入了一个prefabId字段。这看似与“不使用预制体”矛盾实则不然。这里的prefabId是一个逻辑标识符而不是直接的Prefab引用。它告诉系统“当需要创建这个实体时请去找标识为‘Prefabs/Enemies/Mushroom’的资源”。这个资源可以通过Resources.Load、Addressables或AssetBundle系统动态加载。这实现了数据与资源引用的解耦。2.2 设计关卡管理器LevelManager数据模型有了我们需要一个大脑来协调整个关卡的加载、创建和销毁。这就是LevelManager一个通常以单例模式存在的核心管理器。它的核心职责包括加载数据从指定路径如Application.streamingAssetsPath/Levels/level_01.json读取LevelData。资源映射维护一个Dictionarystring, 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 Dictionarystring, GameObject _prefabCache new Dictionarystring, GameObject(); private LevelData _currentLevelData; private ListLevelEntity _spawnedEntities new ListLevelEntity(); 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.FromJsonLevelData(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.AddComponentLevelEntity(); 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 FindObjectsOfTypeLevelEntityEditorHelper(); 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.LoadGameObject(prefabId)预加载所有可能用到的Prefab到缓存字典中。缺点是Resources文件夹不易管理且所有资源会打包在一个AssetBundle中。AddressablesUnity官方推荐适用于中大型项目这是更现代、更强大的方案。为每个Prefab设置一个Addressable Address这个Address就可以作为prefabId。运行时使用Addressables.LoadAssetAsyncGameObject(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) { // 计算需要加载的新区块和需要卸载的旧区块 HashSetVector3Int chunksToLoad CalculateChunksToLoad(currentChunkCoord); HashSetVector3Int 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]。对于Vector3JsonUtility可以原生支持但为了更好的兼容性有时会将其拆分为三个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编辑器的固有工作流中跳脱出来真正以数据和代码为核心去构建你的游戏世界。附带的源文件提供了一个最基础的实现框架你可以以此为起点根据自己项目的具体需求逐步添砖加瓦构建出最适合你的那个强大而优雅的关卡系统。