击杀后随机生成建筑:Unity事件驱动与对象池实战
开头在做游戏功能开发时有一种需求看起来很带感做起来却容易翻车玩家击杀敌人之后在死亡位置附近随机生成一座建筑。这个建筑可能是掩体、可能是奖励房间、也可能是会阻挡敌人路线的障碍物。项目代号暂且叫 Zova标题很简短但背后要解决的问题一点都不少——击杀事件怎么可靠地传给生成系统建筑长在哪里才算“合法”生成太多会不会拖垮性能更麻烦的是建筑一旦生成AI寻路、物理碰撞、战斗索敌全部都要跟着变。这篇文章会把“击杀后随机生成建筑”从设计到落地完整拆一遍。你会看到它解决了什么样的玩法痛点以及实现时真正容易踩坑的地方在哪里。我会用 Unity C# 作为示例环境把事件监听、加权随机、合法位置检测、对象池管理这套链路全部写成可复制的代码。如果你用的是 Unreal、Godot 或者其他自研引擎只要架构思路清晰迁移也不难。先说判断这个玩法的本质是把战斗反馈从“数值掉落”升级成“场景演化”。它改变的不是玩家得到什么而是玩家下一秒面对什么。因此实现的核心不是“在随机位置摆一个预制体”而是把“随机”控制在一个既刺激又可控的范围内。1. 这篇文章真正要解决的问题很多人听到“随机生成建筑”第一反应是写一个Instantiate()然后塞个随机坐标。如果只是做一个单机 Demo这样做确实能跑。但一旦放到真实项目里问题立刻暴露建筑生成在悬崖中间、墙体内部、玩家脚下玩家体验非常糟糕。建筑之间互相重叠模型穿插看起来像地图加载出错。敌人寻路时不会主动绕开新建筑直接把墙体当空气。战斗过程中频繁生成新物体GC 压力升高低端手机掉帧明显。随机结果不可控有时一场战斗生成 20 个建筑地图变成迷宫有时一个都不生成机制形同虚设。这些问题说明随机生成不是“一个函数”能解决的而是一套需要设计约束的子系统。它至少包含四个部分事件来源、生成规则、合法性校验、生命周期管理。那这个机制到底解决了什么往深一层看它把“击杀”这个瞬间从单一结果变成了多分支结果。传统玩法里击杀敌人 获得经验 掉落装备加入建筑生成后击杀敌人 改变地形 改变后续战斗路线 提供新的掩体或威胁。玩家的策略空间变大了每一场战斗的重复可玩性也提升了。对关卡设计来说这意味着不需要手动摆放所有掩体某些地图段落可以交给运行时动态生成。什么样的读者最适合读这篇如果你正在做 Roguelike、塔防、沙盒生存、或者任何带“战斗 地图变化”规则的玩法这套机制可以给你一个可以直接落地的骨架。如果是纯前端或业务后端开发者同样可以借这篇文章理解“事件驱动”和“确定性随机”在游戏系统设计中的通用思路。2. 核心概念与实现原理要讲清楚这套机制先分清几个容易混淆的词随机生成、程序化生成、动态场景演化。概念含义典型场景随机生成在规则约束下随机摆放物体开箱掉落时随机爆出装备模型程序化生成用算法批量创建地图或关卡地牢房间自动拼接、地形生成动态场景演化战局进行中由事件触发场景改变击杀敌人后生成建筑、墙体倒塌“击杀后随机生成建筑”属于第三种。它的特点是触发源是战斗事件生成结果会反过来影响后续战斗。所以实现时不能只考虑“怎么生成”还要考虑“生成后怎么融入运行中的场景”。核心链路拆开是这样的敌人死亡时由战斗系统发出一个统一的事件。生成系统监听该事件拿到敌人死亡位置。在死亡位置附近收集候选生成点。对候选点做合法性校验地面、碰撞、范围、高度。按权重随机选择建筑类型。实例化建筑并通知寻路系统刷新障碍数据。建筑进入生命周期管理后续可被清理、回收或存档。这里的关键设计是“事件驱动”。战斗系统不需要知道建筑生成系统的存在它只负责广播“敌人死亡”这个消息。生成系统单独监听、单独处理。两个系统解耦之后后续你想在击杀后触发爆炸、触发 buff 掉落、触发剧情对话都只需要新增一个监听者不需要改战斗系统的逻辑。另一个关键点是“确定性随机”。字面上看随机是随机的但工程上往往需要对随机过程可控。最常用的方式是设置随机种子seed。同样的种子不管在哪台设备上运行生成的建筑序列完全一致。这在调试、录制回放、同步对局里非常重要。如果你做过联机游戏就会明白两边客户端如果随机种子不一致画面迟早分叉。关于建筑本身建议抽象成可配置的“建筑模板”。每个模板定义预制体、权重、占位半径、是否阻挡寻路、生命值、消失条件。这样生成器只认模板不关心具体建筑长什么样。以后加新建筑美术出一份预制体策划在配置表里加一行数据就行程序完全不需要改动。3. 环境准备与前置条件下面的示例基于 Unity 2021.3 LTS 或更高版本使用 C# 编写。不依赖任何第三方插件用内置的物理系统和 NavMesh 就能跑通。如果你用的是其他版本大概率也能直接编译因为涉及到的 API 都是底层且稳定的。建议准备一个干净的测试场景包含一块地面Plane 或者其他静态碰撞体。少量敌人预制体带死亡逻辑。一个空物体挂载生成器脚本。一个调试用的 UI 文本用来显示当前建筑数量。版本方面不需要刻意追求最新。Unity 2021 LTS 足够稳定而且和大部分教程的 API 文档一致。如果你在 Unity 6 或者 2022 上操作脚本同样适用。代码层面我建议使用以下目录结构Assets/ Scripts/ Events/ GameEvents.cs Buildings/ BuildingSpawner.cs BuildingConfig.cs BuildingPlacementValidator.cs Enemy/ EnemyHealth.cs Art/ Buildings/ Enemies/事件脚本单独放一个文件夹因为它会被战斗系统和生成器同时引用。建筑脚本放一个文件夹方便后续扩充建筑类型。这样划分没有强制约束但长期维护时会舒服很多。还有一个前置条件容易被忽略如果战斗和生成系统在不同场景之间切换事件静态委托会残留引用。建议在场景切换时或者对象销毁时主动清理静态事件。下面会专门说明这个坑。4. 核心流程拆解整个流程可以拆成六步每一步都对应一个明确的职责。这里先理清逻辑再到第 5 节写完整代码。4.1 定义击杀事件这一步要做的是让敌人死亡时对外广播消息携带一个位置信息和敌人引用。注意这里不需要让敌人知道谁在监听它只需要发出消息。为什么需要敌人引用因为生成建筑可能需要读取敌人类型、掉落归属、所属阵营等信息。比如只有精英怪死亡时才生成大型建筑普通怪只生成小掩体。如果事件只传一个位置后续扩展会受限。当然如果你的项目里不需要区分只传位置也够用但多传一个引用不增加多少成本。4.2 确定生成点和生成范围敌人死亡的瞬间生成器需要围绕死亡位置打点。最朴素的方式是在水平面上随机取一个方向乘以一个半径值得到候选点。但这样做有两个问题一是可能选到墙里二是可能选到斜坡上。更稳妥的方式是先随机生成一个水平偏移量然后用射线检测把候选点落在地面上再做重叠检测。射线检测能解决高度问题重叠检测能解决碰撞问题。如果候选点校验失败就重新随机最多重试 N 次如果 N 次都失败就放弃这次生成。这一步是“随机可控”的关键节点。你的随机不是无限制撒点而是有边界、有失败机制的系统行为。4.3 加权随机选择建筑类型所有可生成的建筑都有一个权重。权重高的建筑更容易被选中权重低的是稀有生成。实现方式很简单把所有权重累加得到一个总权重然后在 0 到总权重之间取一个随机数依次减去每个建筑的权重减到负数时选中该建筑。这样做的好处是策划可以轻松调节概率。精英怪掉落稀有建筑的权重设为 1普通掩体设为 10那普通掩体出现的概率就是稀有建筑的 10 倍。权重完全可以放到配置表里不需要改代码。4.4 合法性校验合法性校验是整条链路里“技术含量”最高的一步也是大多数人忽略了的一步。至少要检查三件事候选点下面的地面是否存在射线检测。候选点周围是否已经有建筑或其他障碍物重叠检测。候选点是否在配置好的生成范围内。如果你做的是大型建筑还应该检查净空高度。比如生成一座塔楼上方有山体遮挡模型会穿模。这时候需要用BoxCast或Physics.OverlapBox做一次空间检测。4.5 生成建筑并通知寻路系统建筑实例化后静态场景还没结束。如果你的游戏使用了 NavMesh 寻路必须把新建筑所在的区域标记为不可行走。这一步在 Unity 里有两种做法一种是动态更新 NavMesh运行时切开对应区域另一种是给建筑添加NavMeshModifier组件标记为 obstacle让 NavMeshAgent 自动绕开。对大多数项目来说NavMeshModifier加上NavMeshObstacle是性价比最高的方案不需要运行时重新烘焙性能开销也小。4.6 生命周期与回收管理建筑不会永远存在。有些建筑只是临时掩体过一段时间就消失有些建筑被摧毁后需要回收。这里最推荐的做法是对象池而不是频繁Instantiate/Destroy。原因很简单战斗场景里生成和销毁频率高频繁实例化会导致 GC 压力和物理系统抖动。对象池的管理逻辑是生成时从池里取对象标记为激活建筑销毁时回收到池里标记为隐藏。后面代码示例会给你一个简化版的对象池实现。5. 完整示例与代码实现下面给出可以直接复制的完整代码。为节省篇幅我把枚举和辅助类直接放在同一个脚本里但你可以按项目规范拆分文件。5.1 事件脚本// 文件路径Assets/Scripts/Events/GameEvents.cs using UnityEngine; namespace ZovaDemo { public static class GameEvents { // 敌人死亡事件参数为敌人引用和死亡位置 public delegate void OnEnemyKilledHandler(GameObject enemy, Vector3 deathPos); public static event OnEnemyKilledHandler OnEnemyKilled; // 由敌人死亡逻辑调用 public static void EnemyKilled(GameObject enemy, Vector3 deathPos) { OnEnemyKilled?.Invoke(enemy, deathPos); } // 场景切换时清理事件防止残留引用 public static void Clear() { OnEnemyKilled null; } } }这个脚本的要点在于静态事件谁都可以订阅但也必须有人负责清理。很多项目报错“MissingReferenceException”就是因为事件在场景切换后还引用着一个已经被销毁的对象。建议在场景管理的 OnSceneLoaded 回调里执行一次GameEvents.Clear()。5.2 敌人死亡逻辑// 文件路径Assets/Scripts/Enemy/EnemyHealth.cs using UnityEngine; namespace ZovaDemo { public class EnemyHealth : MonoBehaviour { public float maxHealth 100f; private float currentHealth; void Start() { currentHealth maxHealth; } public void TakeDamage(float damage) { currentHealth - damage; if (currentHealth 0f) { Die(); } } void Die() { // 广播死亡事件位置取当前对象位置 GameEvents.EnemyKilled(gameObject, transform.position); // 这里可以继续播放死亡动画、销毁敌人等逻辑 // 注意不要在这里直接销毁敌人否则事件接收者拿到的引用可能失效 // 推荐做法先停用行为、播放动画由动画事件或延迟调用销毁 gameObject.SetActive(false); } } }真正容易出错的地方是Die()的实现顺序。如果你先Destroy(enemy)再发事件监听者收到引用后去读取enemy.name都会抛异常。稳妥的顺序是先停用敌人行为再广播事件最后延迟销毁或回收到对象池。5.3 建筑配置// 文件路径Assets/Scripts/Buildings/BuildingConfig.cs using UnityEngine; namespace ZovaDemo { [CreateAssetMenu(fileName BuildingConfig, menuName Zova/BuildingConfig)] public class BuildingConfig : ScriptableObject { public GameObject prefab; public float weight 1f; public float occupyRadius 2f; public bool blockNavigation true; public float minSpawnDistance 3f; public float maxSpawnDistance 10f; } }配一个 ScriptableObject 的好处是策划可以直接在编辑器里创建新的建筑配置资产不需要让程序改代码。权重、半径、距离全部可视化编辑团队协作效率高很多。这里把生成距离也放到配置里因为不同建筑对距离的需求不一样——小掩体可以贴脸生成大型塔楼则必须离击杀点一段距离否则玩家会被自己的奖励建筑砸到头。5.4 生成器主逻辑// 文件路径Assets/Scripts/Buildings/BuildingSpawner.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; namespace ZovaDemo { public class BuildingSpawner : MonoBehaviour { [Header(建筑配置列表)] public ListBuildingConfig buildConfigs new ListBuildingConfig(); [Header(生成约束)] public LayerMask groundLayer; public LayerMask obstacleLayer; public int maxRetryCount 5; public int maxActiveBuildings 20; private readonly ListGameObject activeBuildings new ListGameObject(); private readonly StackGameObject buildingPool new StackGameObject(); private int totalSpawnedCount 0; void OnEnable() { GameEvents.OnEnemyKilled HandleEnemyKilled; } void OnDisable() { GameEvents.OnEnemyKilled - HandleEnemyKilled; } private void HandleEnemyKilled(GameObject enemy, Vector3 deathPos) { if (activeBuildings.Count maxActiveBuildings) { return; // 地图上建筑数量已达上限 } // 从配置中加权随机选一种建筑 BuildingConfig config PickRandomConfig(); if (config null) { return; } // 在死亡位置附近找一个合法放置点 if (!TryFindValidPosition(deathPos, config, out Vector3 spawnPos)) { return; } // 生成建筑 GameObject building GetFromPool(config.prefab); building.transform.position spawnPos; building.transform.rotation Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); building.SetActive(true); if (config.blockNavigation) { // 让寻路系统把建筑视为障碍 NavMeshObstacle obstacle building.GetComponentNavMeshObstacle(); if (obstacle null) { obstacle building.AddComponentNavMeshObstacle(); } obstacle.carving true; } activeBuildings.Add(building); totalSpawnedCount; } private BuildingConfig PickRandomConfig() { if (buildConfigs.Count 0) { return null; } float totalWeight 0f; foreach (var config in buildConfigs) { totalWeight config.weight; } float randomValue Random.Range(0f, totalWeight); foreach (var config in buildConfigs) { randomValue - config.weight; if (randomValue 0f) { return config; } } return buildConfigs[buildConfigs.Count - 1]; } private bool TryFindValidPosition(Vector3 center, BuildingConfig config, out Vector3 result) { result Vector3.zero; for (int i 0; i maxRetryCount; i) { float distance Random.Range(config.minSpawnDistance, config.maxSpawnDistance); float angle Random.Range(0f, 360f) * Mathf.Deg2Rad; Vector3 offset new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * distance; Vector3 candidate center offset; // 用射线检测将候选点落到地面上 if (!Physics.Raycast(candidate Vector3.up * 10f, Vector3.down, out RaycastHit hit, 50f, groundLayer)) { continue; } Vector3 groundPos hit.point; // 检测是否与其他建筑或障碍物重叠 Collider[] colliders Physics.OverlapSphere(groundPos, config.occupyRadius, obstacleLayer); if (colliders.Length 0) { continue; } // 检测是否离玩家/击杀点太近避免建筑直接压住玩家 if (Vector3.Distance(groundPos, center) config.minSpawnDistance * 0.5f) { continue; } result groundPos; return true; } return false; } private GameObject GetFromPool(GameObject prefab) { if (buildingPool.Count 0) { return buildingPool.Pop(); } return Instantiate(prefab); } public void RecycleBuilding(GameObject building) { building.SetActive(false); activeBuildings.Remove(building); buildingPool.Push(building); } public int GetActiveCount() { return activeBuildings.Count; } public int GetTotalSpawnedCount() { return totalSpawnedCount; } } }这段代码的核心逻辑都集中在HandleEnemyKilled和TryFindValidPosition里。简单解释几个设计取舍maxActiveBuildings是地图建筑数量上限。没有这个限制一局战斗可能生成上百个建筑性能迟早崩。这个值建议放到配置表里按场景大小调节。NavMeshObstacle.carving设置为 true可以让运行时新增的建筑动态切割寻路网格。这样 AI 不需要重新寻路就会自动绕开建筑。如果你的项目没用 NavMesh这一步就换成你自己导航系统里的“动态碰撞”接口。RecycleBuilding方法是给外部调用的。比如建筑血量归零、或者计时器到期都可以调用它把建筑回收到池子里。比较容易被忽略的是HandleEnemyKilled里的顺序先判断数量上限、再选类型、再找位置、最后实例化。尽量把“失败的、需要重试的”逻辑放在实例化之前减少对象池分配和场景节点变动。生成器是高频触发的系统尽可能避免在失败路径上产生垃圾。5.5 调试辅助脚本// 文件路径Assets/Scripts/Buildings/BuildingDebug.cs using UnityEngine; using UnityEngine.UI; namespace ZovaDemo { public class BuildingDebug : MonoBehaviour { public BuildingSpawner spawner; public Text debugText; void Update() { if (spawner ! null debugText ! null) { debugText.text $Active: {spawner.GetActiveCount()} Total: {spawner.GetTotalSpawnedCount()}; } } } }这个脚本不参与核心逻辑但调试时非常有用。跑起来之后你需要在场景里杀死一些敌人然后盯着屏幕左上角看建筑数量变化。没有可视化反馈你很难判断生成器到底有没有被触发。6. 运行结果与效果验证把上面脚本挂好后测试流程建议这样走场景里放几个敌人记得挂上EnemyHealth。添加一个BuildingSpawner对象在buildConfigs列表里至少填一个建筑配置。写一个简单攻击脚本对敌人调用TakeDamage或者直接在 Inspector 里手动调用一次。写一个测试用的小脚本// 文件路径Assets/Scripts/Testing/TestKill.cs using UnityEngine; namespace ZovaDemo { public class TestKill : MonoBehaviour { void Update() { if (Input.GetKeyDown(KeyCode.K)) { EnemyHealth[] enemies FindObjectsOfTypeEnemyHealth(); if (enemies.Length 0) { enemies[0].TakeDamage(9999f); } } } } }运行后按 K 键杀死敌人。预期效果敌人被击杀后在死亡位置附近出现一个随机建筑。建筑位置在地面上方且没有和其他建筑重叠。BuildingDebug面板上的 Active 数量增加 1。如果加了 NavMeshAgent 在场景里巡逻你会发现它会绕开新生成的建筑。如果建筑没有生成按下面顺序排查是否挂载了BuildingSpawner且列表里有配置敌人的Die()方法是否真的执行了groundLayer是否包含地面所在的 LayeroverlapSphere是否把地面本身也算进碰撞了如果是需要把地面排除在obstacleLayer之外。第五点是最容易踩的坑如果你的地面是 Mesh Collider而且被加进了obstacleLayer那么OverlapSphere永远检测到碰撞生成器永远不会成功生成。检查 Layer 分配时要保证地面层只属于groundLayer建筑、墙体、道具才属于obstacleLayer。7. 常见问题与排查思路问题现象可能原因排查方式解决方案建筑完全不生成事件没被广播或生成器没订阅在 HandleEnemyKilled 入口加 Debug.Log检查 OnEnable/OnDisable 中事件订阅和退订建筑总是生成失败地面被同时放在 groundLayer 和 obstacleLayer检查 LayerMask 配置分离地面层与障碍层建筑卡在墙里射线检测只检查了竖直方向没有检查水平方向增加 OverlapBox 检测在竖直检测后增加水平空间检测建筑叠在一起OccupyRadius 太小或者没有挂 Collider显示所有建筑的 Gizmos 半径调大 OccupyRadius确保建筑预制体带 ColliderAI 穿过新建筑没有给建筑加 NavMeshObstacle运行中检查建筑组件列表生成时添加 NavMeshObstacle 并设置 carving true场景切换后报空引用静态事件残留查看报错堆栈和挂载对象在场景切换时调用 GameEvents.Clear()生成多了掉帧Instantiate 频繁物理碰撞开销大Profiler 查看 GC Alloc使用对象池并限制同时存在的建筑数量随机结果不像随机Random.Range 在短时间内提供相似值观察种子值为生成器设置随机种子必要时手动指定种子关于场景切换的静态事件残留值得单独再提一句。OnDisable里虽然做了退订但如果你在场景切换前没有销毁挂载BuildingSpawner的那个对象订阅关系会保留到下一次场景。如果新旧场景都加载了带有OnEnable的对象会出现重复订阅。最彻底的办法是场景管理器统一调用GameEvents.Clear()然后把所有事件源清零重建。8. 最佳实践与工程建议8.1 用配置表管理权重和建筑属性不要把建筑的中型、稀有度、占地面积写死在代码里。策划调整数值的频率远超程序改代码的频率。用 ScriptableObject、JSON 或者 Excel 导出的配置表都行关键是程序只读取配置。配置项至少包含prefab 引用。生成权重。最小/最大生成距离。占位半径。是否阻挡寻路。是否可被攻击。持续时间或消失条件。8.2 固定随机种子提升可调试性游戏开发里完全随机的 Bug 是最难复现的。建议在生成器上暴露一个seed参数。调试时指定种子值跑一段固定流程就能稳定复现同一个场景。联机对局更需要统一种子。示例Random.InitState(seed);注意Random.InitState影响的是全局随机状态。如果整个游戏还有其他地方用Random.Range统一种子会影响它们。所以在生成器内部更推荐自己写一个简单的随机类而不是直接修改全局随机。这里展示一个最小实现// 轻量线性同余随机类仅用于生成器内部 public class SpawnRandom { private uint state; public SpawnRandom(uint seed) { state seed 0 ? 123456789u : seed; } public float Range(float min, float max) { state state * 1664525u 1013904223u; float t (state 8) / 16777216f; return min t * (max - min); } }这样生成的随机序列与其他系统互不干扰。8.3 给建筑加保护期建筑生成瞬间如果玩家正好站在生成位置附近很可能被模型卡住或者被碰撞体“弹飞”。建议在生成后前 0.5 秒内把建筑的碰撞体设为触发状态Trigger等保护期结束再恢复为实心碰撞体。这个细节虽然小但对玩家手感影响很大。8.4 关注生成建筑的清理时机建筑是动态场景的一部分需要明确的销毁规则。常见规则有三种存活时间到期自动回收。被玩家或敌人摧毁播放碎裂动画后回收。离开玩家视野一定距离后延迟回收。无论哪种规则最终都调用对象池的回收方法避免 GC 峰值。8.5 预留“事件日志”生成器是战斗系统的一部分它的触发次数、成功次数、失败原因都需要记录。这里的“日志”不一定是写入磁盘也可以是在编辑器里显示统计数字。当你需要调平衡时这些数据非常有用某个建筑生成概率过高、某个区域失败率太大一看数据就清楚。9. 总结与后续学习方向击杀后随机生成建筑本质上是一套“战斗事件驱动场景演化”的机制。它最吸引人的地方不在于“随机”两个字而在于把玩家每一次击杀都变成一次环境决策新生成的建筑是掩体还是障碍它会让敌人改变路线还是给你开出一条新的进攻路径把这些体验落地成稳定代码靠的是事件解耦、加权随机、合法性校验、寻路刷新、对象池管理这几层设计。文章里的代码演示了从事件发起到建筑回收的完整链路你在自己的项目里可以直接复用这套结构。接下来值得深入的方向有三个一是确定性随机与联机同步。如果游戏需要多人同局必须把生成器做成服务器权威所有客户端只负责表现或者所有客户端统一种子并同步生成结果。二是动态 NavMesh 与大量建筑的性能平衡。建筑数量多了之后NavMeshObstacle.carving的性能不一定吃得住可以考虑分批烘焙、区域划分或者改用寻路层。三是扩展“击杀生成”的玩法外延。比如击杀精英怪时生成稀有建筑生成建筑会改变战斗奖励、解锁新区域、触发进攻波次。基础设施已经打好了在上面加玩法规则只是配置和数据表的事。开发建议很简单先在小场景里把流程跑通再逐步增加建筑类型和规则。感觉上是游戏机制设计写起来全是工程细节。把事件、校验、回收这三件事做好剩下的交给策划表就可以了。建议收藏备用等你真正开始做“动态场景演化”玩法时这份代码能帮你少走不少弯路。