简介:这是一份Unity体素风格的肉鸽幸存者射击游戏完整源码工程,使用C#编写,支持Unity 2021.1.17f1及以上版本。资源面向想要学习游戏循环、炮塔升级、敌人波次与金币经济系统的Unity开发者,也适合作为独立游戏项目的二次开发基底。 包体共1862个文件、约49MB,包含137个C#脚本、64个Prefab预制体、166个材质、150张PNG贴图,以及FBX模型、Shader着色器、动画控制器等资源;同时集成Facebook、Google Play、Appsflyer等安卓SDK的AAR/JAR文件,便于理解移动端接入配置。各类型文件分工明确,目录结构清晰,方便按模块查找学习。 已有249人学习下载。该工程提供30分钟的独特玩法流程,无广告、无应用内购,可直接构建多平台版本;官方提示UX/UI存在少量问题,恰好可作为优化练习切入点。通过阅读与运行源码,能掌握体素角色碎裂特效、炮塔升级逻辑和幸存者模式关卡设计,是一份实践性很强的游戏开发参考。
1. 体素射击肉鸽幸存者:为什么 Unity 里做 Voxel Shooter 值得把源码抠一遍
看到“Voxel Shooter 体素射击游戏 Unity 肉鸽幸存者游戏项目源码 C#”这个标题,我的第一反应不是“又一个打方块的 demo”,而是“这套源码把肉鸽玩法和体素射击拧到了什么程度”。体素射击在 Unity 里做个小场景并不难,难的是肉鸽幸存者最吃性能的部分——同屏几百敌人、无限叠加的数值、局内成长,全都会把体素引擎的短板放大出来。之前同事把敌人堆到 300 个,帧率掉到 30 以下,定位半天发现根本不是渲染问题,而是敌人死亡时的 C# 堆分配没有清理干净,GC 一触发就顿卡。所以这个标题对从业者真正的价值,不是“又有一个体素射击项目可以抄”,而是看它如何用 C# 把对象管理、射线命中、数据配置这三件事搭成可扩展的骨架。下面按我自己做 Voxel Shooter 幸存者的顺序拆:先立场景和射击,再写刷怪和成长,最后收在数值和优化上,有 Unity 基础的开发者照着走能少踩不少坑。
2. Voxel Shooter 的体素场景与射击手感:从方块生成到命中判定先立三件事
做体素射击最容易犯的错,是一上来就铺物理引擎的 Box Collider 阵列。一个 64 乘 64 的地块,方块碰撞体数量轻松过千,物理引擎光是同步刚体状态就能吃掉不少帧。更稳的路线是场景走自定义网格合并,碰撞只保留极少数的碰撞体,射击命中用射线和体素数据结构单独做检测。按这个顺序搭,能避免把物理引擎拖垮之后才发现要重构。
2.1 体素数据结构:三维数组还是字典,取决于要不要保留破坏痕迹
体素数据最常见两种存法:三维数组Voxel[,,]和Dictionary<Vector3Int, byte>。三维数组内存连续,遍历快,但一改尺寸就要整块重建,适合地形完全静态的建造游戏。肉鸽幸存者里体素要承担两个职责:作为背景场景,以及作为玩家可以破坏的掩体。破坏产生的坑洞是局部修改,三维数组每次写一个方块都要重算整块网格,浪费太多。
我一般用Dictionary<Vector3Int, byte>加 Chunk 分组,每个 Chunk 负责 16 乘 16 乘 16 的范围。改一个方块只重建那一个 Chunk 的 Mesh,并且因为字典只存“非空气块”,空区域不占内存。代价是遍历稀疏区域时 CPU 缓存不友好,但这个项目体量完全够用。
using System.Collections.Generic; using UnityEngine; public class VoxelChunk : MonoBehaviour { public const int ChunkSize = 16; private readonly Dictionary<Vector3Int, byte> _blocks = new Dictionary<Vector3Int, byte>(); public void SetBlock(Vector3Int localPos, byte blockId) { if (blockId == 0) _blocks.Remove(localPos); else _blocks[localPos] = blockId; RebuildMesh(); } public byte GetBlock(Vector3Int localPos) { return _blocks.TryGetValue(localPos, out var id) ? id : (byte)0; } }SetBlock里传blockId == 0表示删除方块,非零表示写入,每次修改后立刻重建当前 Chunk 的网格。注意传进来的是局部坐标localPos,外部调用时要先用transform.InverseTransformPoint把世界坐标转成局部坐标,否则很容易出现相邻 Chunk 改错位置的 Bug。方块 ID 用byte而不是int,表面看只是省内存,实际对缓存友好,后面做网格合并时也可以直接映射到 UV 数组。如果你一秒钟要破坏 10 个以上的方块,重建网格本身也会成为瓶颈,常见做法是每帧最多重建两个 Chunk,或者把破坏请求放进待处理队列,下一帧统一重建。
2.2 射击检测:敌人走 Unity Physics,体素走 DDA 射线步进
射击命中要区分目标。敌人是动态物体,用一个Physics.Raycast打LayerMask是最省事的;体素是静态网格,用物理射线打 Mesh 虽然也能拿到命中点,但要确定“打掉了哪个方块”还得再做一次坐标反推。更稳的是对体素单独走 DDA(Digital Differential Analyzer)射线步进,从枪口出发,按格子依次推进,碰到非空方块就确定命中位置。
using System; using UnityEngine; public static class VoxelRaycast { public static bool Cast(Vector3 origin, Vector3 direction, float maxDist, Func<Vector3Int, byte> getBlock, out Vector3Int hitBlock, out Vector3Int normal) { hitBlock = Vector3Int.zero; normal = Vector3Int.zero; int x = Mathf.FloorToInt(origin.x); int y = Mathf.FloorToInt(origin.y); int z = Mathf.FloorToInt(origin.z); int stepX = direction.x > 0f ? 1 : (direction.x < 0f ? -1 : 0); int stepY = direction.y > 0f ? 1 : (direction.y < 0f ? -1 : 0); int stepZ = direction.z > 0f ? 1 : (direction.z < 0f ? -1 : 0); float deltaX = direction.x != 0f ? Mathf.Abs(1f / direction.x) : float.MaxValue; float deltaY = direction.y != 0f ? Mathf.Abs(1f / direction.y) : float.MaxValue; float deltaZ = direction.z != 0f ? Mathf.Abs(1f / direction.z) : float.MaxValue; float tMaxX = direction.x != 0f ? deltaX * (direction.x > 0f ? (x + 1 - origin.x) : (origin.x - x)) : float.MaxValue; float tMaxY = direction.y != 0f ? deltaY * (direction.y > 0f ? (y + 1 - origin.y) : (origin.y - y)) : float.MaxValue; float tMaxZ = direction.z != 0f ? deltaZ * (direction.z > 0f ? (z + 1 - origin.z) : (origin.z - z)) : float.MaxValue; float t = 0f; int lastAxis = 0; while (t <= maxDist) { byte block = getBlock(new Vector3Int(x, y, z)); if (block != 0) { hitBlock = new Vector3Int(x, y, z); if (lastAxis == 0) normal = new Vector3Int(-stepX, 0, 0); else if (lastAxis == 1) normal = new Vector3Int(0, -stepY, 0); else normal = new Vector3Int(0, 0, -stepZ); return true; } if (tMaxX < tMaxY && tMaxX < tMaxZ) { x += stepX; t = tMaxX; tMaxX += deltaX; lastAxis = 0; } else if (tMaxY < tMaxZ) { y += stepY; t = tMaxY; tMaxY += deltaY; lastAxis = 1; } else { z += stepZ; t = tMaxZ; tMaxZ += deltaZ; lastAxis = 2; } } return false; } }Cast从射线原点所在的格子开始,每次选择tMax最小的轴前进一格,直到碰到非 0 方块或超过maxDist。getBlock是回调,统一返回方块 ID,0 表示空气;在实际调用时把 VoxelChunk 的GetBlock转成局部坐标包一层即可,如果坐标超出范围就返回 1 当作保守的墙,避免射线穿透边界。注意direction需要先归一化,否则maxDist和t的单位不一致;方向分量为 0 的轴,tMax保持float.MaxValue,永远不会被选中,也就不会出现除零问题。返回值里的normal是射线穿入命中方块的面外法线,可以直接用来做破坏特效的朝向。
2.3 手感三要素:后坐力、命中反馈和准星扩散别交给美术全凭感觉
体素射击的手感不等于“枪有后坐力动画”。幸存者类游戏里玩家几乎不间断开火,手感由三个参数决定:射击间隔、单发后坐力、准星恢复速度。下面是我常用的一组初始值,按这个基准调,能让自动射击的第 5 发和后面 100 发有差异,又不至于一开始就飘到没法玩。
| 参数 | 默认值 | 调整方向 | 影响 |
|---|---|---|---|
| 射击间隔 | 0.12s | 缩短手感爽快,但太短会变成高频抖动 | 决定 DPS 上限 |
| 后坐力抬升 | 1.2 度/发 | 抬升太快会压不住准星 | 抵消横向漂移 |
| 准星恢复 | 6 度/s | 恢复慢导致长时间扫射精度崩溃 | 扶持点射玩法 |
| 命中顿帧 | 0.05s | 命中目标时短暂冻结动作 | 强化打击感 |
这些数值建议埋进PlayerShooter的序列化字段,再用 Animation Curve 把“连射惩罚”做成随时间加大的曲线,调手感时改曲线而不改代码。体素被破坏时的方块碎裂粒子不要每颗都走Instantiate,用ParticleSystem的Play和Stop切换,或者直接做一个粒子池,否则射击频率一高,粒子创建也会变成 GC 大户。
3. 幸存者局内循环:波次刷怪、经验掉落与武器升级的 C# 骨架
体素射击只是表达层,肉鸽幸存者的骨架是局内循环:时间推进、刷怪、杀怪掉经验、获得经验升级、升级三选一、怪物变强。这个循环全塞进 Update 也能跑,但做到后期会越来越难扩展。下面三层是我觉得最值得照着源码改的:对象池、事件驱动、升级弹窗的状态控制。
3.1 刷怪与对象池:300 个敌人掉帧,先查 ObjectPool 的回收逻辑
幸存者游戏敌人极多,每帧都在生成和销毁,对象池已经不是优化项,而是必须项。真正写好的池不是“字典加队列”就够,还要解决两个常见坑:OnEnable里没重置状态,以及池扩容时一次性创建过多对象。按 300 个敌人同时在场计算,初始容量设 64,每帧Get时发现不足再批量补 8 个,比一次性Instantiate100 个预留对象更稳。
using System.Collections.Generic; using UnityEngine; public class ObjectPool<T> where T : Component { private readonly Stack<T> _pool = new Stack<T>(); private readonly T _prefab; private readonly Transform _parent; private readonly int _growCount; public ObjectPool(T prefab, Transform parent, int prewarm, int growCount) { _prefab = prefab; _parent = parent; _growCount = growCount; for (int i = 0; i < prewarm; i++) { var obj = Object.Instantiate(_prefab, _parent); obj.gameObject.SetActive(false); _pool.Push(obj); } } public T Get() { if (_pool.Count == 0) { for (int i = 0; i < _growCount; i++) { var obj = Object.Instantiate(_prefab, _parent); obj.gameObject.SetActive(false); _pool.Push(obj); } } var item = _pool.Pop(); item.gameObject.SetActive(true); return item; } public void Return(T item) { item.gameObject.SetActive(false); _pool.Push(item); } }这个池的Return只做SetActive(false),没有把组件状态归零,所以敌人脚本要在OnEnable里重置血量、速度和 AI 状态。很多人图省事在Get之后手动 Reset,漏一个字段就是 NullReference,改在OnEnable里至少能保证每一条路径都覆盖到。Stack比Queue更合适,因为最近归还的对象大概率还在热缓存里,弹栈比出队快。刷怪表也建议用数据驱动,不要直接在 Spawner 里写时间硬编码,下面这张表是常见的波次节奏,具体数值可以在 Inspector 里序列化。
| 敌人类型 | 基础权重 | 解锁时间(分钟) | 特点 |
|---|---|---|---|
| 近战跑者 | 100 | 0 | 血量低,移速快 |
| 远程射手 | 60 | 2 | 发射体素弹道 |
| 精英坦克 | 30 | 5 | 高血量,死亡时范围爆炸 |
3.2 经验掉落与升级:用 C# 事件驱动代替每帧轮询
幸存者里玩家杀怪掉宝石、宝石飘向玩家、玩家吸收够经验值升级。如果宝石和玩家都各自在 Update 里检测距离,数量多了就是双重 O(n)。一个常用的做法是让宝石脚本带一个OnReachedPlayer事件,玩家作为收集者,把“谁被谁吸收”的逻辑倒过来。用事件驱动而不是轮询,能减少每帧的判断次数,也把收集逻辑收敛到一个系统里。
using System; using UnityEngine; public class PlayerLevel : MonoBehaviour { public int Level { get; private set; } = 1; public int Exp { get; private set; } public event Action<int> OnLevelUp; public void AddExp(int amount) { Exp += amount; while (Exp >= GetRequiredExp(Level)) { Exp -= GetRequiredExp(Level); Level++; OnLevelUp?.Invoke(Level); } } private int GetRequiredExp(int level) => 10 + (level - 1) * 5; }AddExp在经验溢出当级上限时用while循环处理连续升级,避免一次吃进大量经验只升一级的 Bug。升级弹窗、音效、属性加成都通过订阅OnLevelUp挂接,而不是让PlayerLevel在 Update 里每帧轮询。GetRequiredExp当前是硬编码公式,实际项目建议放到LevelCurve配置里,后面第 4 节会讲怎么用 ScriptableObject 管理这类数值。
3.3 升级三选一:暂停时不要阻塞 Update,用状态机控制弹窗和倒计时
肉鸽幸存者的升级界面通常会暂停游戏,但暂停本身不简单:弹窗出现后敌人不能动,弹窗里武器参数的预览又要能实时刷新。直接用Time.timeScale = 0能暂停大部分逻辑,但如果弹窗用OnGUI或者 UI 组件各自写 Update,暂停后会出现计时错乱。更稳的做法是把游戏状态切到LevelUpSelect,刷怪逻辑、敌人 AI 和子弹的 Update 都先判断状态再执行。
public enum GameState { Playing, LevelUpSelect, WavePause }读状态而不是依赖Time.timeScale判断,是因为很多移动端 SDK 或广告回调会把Time.timeScale冲掉。这里有一个 C# 的坑:如果方法签名为void UpdateGame(in GameState state),要注意GameState枚举对应的结构体成员是否标注了readonly。C# 对in传值默认会做防御性复制,如果结构体不是只读的,每次调用都会多出拷贝开销,性能反而比按值传更差。这个细节在肉鸽项目里被放大的原因,是 Update 里到处都是状态判断。
升级弹窗的三选一要和战斗逻辑解耦。我一般把每个升级选项做成UpgradeOption数据类,包含名称、描述和效果回调,玩家点击后执行回调并恢复状态,而不是在 Button 的 OnClick 上直接改数值。按钮只负责“选中的是哪个索引”,真正的效果由UpgradeSystem.Apply统一处理,这样多角色、多周目都能复用同一套弹窗。另外,升级弹窗里三个选项的按钮点击区域要留足,Unity 的 Button 默认只认Image的 alphaHitTest 区域,视觉图标往往是 70 乘 70 的图,手指却远大于这个范围。常见做法是把按钮的Image矩形扩大到 1.15 倍,视觉内容放子物体并关闭子物体的 Raycast Target,默认 alpha 阈值保持 0,这样扩大出的透明部分也能正常点击,不会出现手机上点不到选项的问题。
| 升级选项类型 | 增长方式 | 示例 |
|---|---|---|
| 固定值 | 直接加 | +5 伤害 |
| 百分比 | 乘区 | +15% 攻速 |
| 新武器 | 启用新配置 | 解锁霰弹枪 |
4. 肉鸽成长与数值平衡:用 ScriptableObject 做武器、增益和敌人配置
到这里,游戏已经能跑起来:体素场景能射击,敌人会刷,经验升级能弹三选一。但离能玩还差一步:数值系统。如果把武器伤害、射速、弹道速度都写在 MonoBehaviour 的字段里,每加一把武器就要新增一个类,存档也难处理。肉鸽游戏的核心是“不同内容组合产生不同玩法”,必须数据驱动。
4.1 武器配置:把射速、弹道、伤害成长拆进 ScriptableObject
ScriptableObject 在 Unity 里做配置是常规做法,好处是资源可以跨预制体共享,一份数据被多个武器预制体引用,改数值不用进场景。下面是一个武器配置的骨架,覆盖幸存者最常用到的属性:射速、弹速、基础伤害、穿透数和弹道类型。弹道类型用枚举标识,物理子弹、追踪弹、霰弹都由同一个发射器根据枚举分发。
using UnityEngine; [CreateAssetMenu(menuName = "VoxelShooter/WeaponConfig")] public class WeaponConfig : ScriptableObject { public string weaponName; public float fireInterval = 0.12f; public float projectileSpeed = 20f; public int baseDamage = 5; public int pierceCount = 0; public BulletType bulletType; public enum BulletType { Straight, Homing, Shotgun } }字段里的bulletType是后续扩展的核心。如果你的游戏要做“每次升级给武器加穿透”这种肉鸽词条,不要在代码里写if (config.bulletType == BulletType.Homing)然后硬改行为,而是让ProjectileBehavior基类派生出HomingProjectile、ShotgunProjectile,在运行时按武器配置切换或组合。好处是数值配置和逻辑行为各自演化,不会出现几百行 if 分支的发射器脚本。武器配置的字段说明也要在 Inspector 里写清楚,否则过一个星期再回来看,没人知道pierceCount为 0 时是只能打一个还是穿透无限。
| 字段 | 作用 | 建议范围 |
|---|---|---|
| fireInterval | 开火间隔 | 0.05 到 0.5 |
| projectileSpeed | 弹道速度 | 10 到 60 |
| pierceCount | 穿透敌人数量 | 0 到 8 |
4.2 增益叠加顺序:固定数值和百分比加成的计算顺序决定平衡边界
肉鸽游戏里最常出 Bug 的地方不是玩法,而是数值叠加顺序。例如基础伤害 10,拿两个“+5 固定伤害”,再拿一个“+20% 伤害”,如果固定伤害先加再乘百分比,结果是(10 + 5 + 5) * 1.2 = 24;如果百分比先生效再加固伤,结果是(10 * 1.2) + 5 + 5 = 22。差别只有 2 点,但叠到几十层后,玩家强度曲线会完全失控。
我一般给数值系统分两层:先加固定值,再乘百分比。固定值代表武器本身的升级,百分比代表角色等级或全局增益。这样新玩家单拿百分比收益较低,鼓励多拿固定值,成长曲线更平滑。实际落地时写一个DamageCalc静态类,把公式收敛在一个入口,后续加暴击、易伤之类的乘区也往这里塞。
using UnityEngine; public static class DamageCalc { public static int ApplyDamage(int baseValue, int flatBonus, float percentBonus, float difficultyScale) { float value = (baseValue + flatBonus) * (1f + percentBonus); return Mathf.RoundToInt(value * difficultyScale); } }| 计算顺序 | 公式 | 单层效果(基础 10、固定 5、百分比 20%) |
|---|---|---|
| 先固定后百分比 | (base + flat) * (1 + percent) | 18 |
| 先百分比后固定 | base * (1 + percent) + flat | 17 |
difficultyScale是敌人的难度系数,局内时间越往后越大,但它要作为最后一个乘区,不能混进玩家成长公式。如果difficultyScale放进percentBonus里,玩家后期点出大量防御时,敌人的伤害加成会被防御属性二次放大,数值崩得更快。
4.3 敌人配置与生成权重:按局内时间区间的加权随机表
幸存者的敌人不是完全随机刷,而是有一张时间波次表:第 0 分钟刷近战怪,第 2 分钟开始掺远程怪,第 5 分钟出现精英。实现时最常见的设计是把刷怪表做成权重列表,每过一段时间重新计算权重,而不是把时间硬编码在 Spawner 的 if else 里。下面这个SpawnTable用权重累计的方式随机选敌人类型,比Random.Range(0, enemies.Length)更可控。
using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class SpawnEntry { public EnemyConfig enemyType; public int weight; public float unlockTime; } public class SpawnTable { public List<SpawnEntry> entries; private int _totalWeight; public EnemyConfig PickEnemy(float elapsedMinutes) { _totalWeight = 0; foreach (var e in entries) if (e.unlockTime <= elapsedMinutes) _totalWeight += e.weight; int roll = UnityEngine.Random.Range(0, _totalWeight); foreach (var e in entries) { if (e.unlockTime > elapsedMinutes) continue; roll -= e.weight; if (roll < 0) return e.enemyType; } return entries[entries.Count - 1].enemyType; } }加权随机的核心是“先累计可见敌人的总权重,再在 0 到总权重之间取随机数,倒序扣除”。好处是权重可以运行时调整,比如某种敌人多到失控时把权重减半,下一波就会明显稀释。unlockTime也可以由策划直接改表,不用碰代码。不过这里每次PickEnemy都会重建_totalWeight,如果每帧要刷好几只怪,建议把时间区间缓存成字典,在unlockTime变化时才重新计算,上面为降低阅读门槛没有做缓存。把这个类和第 3 节的对象池接在一起,刷怪逻辑就完整了。武器掉落也可以复用这个加权表,改个字段名又是一套系统。
5. 打包调试与进阶技巧:从 Profiler 定位卡顿到体素内存治理
5.1 卡顿别猜渲染:用 Profiler 的 GC Alloc 做 Unity 游戏优化第一刀
Voxel Shooter 真正拖后腿的往往是 C# 的堆分配。在 Profiler 的 CPU Usage 模块里打开 Hierarchy 视图,按GC Alloc排序,如果看到子弹每帧new Vector3,或者敌人脚本每帧List<T>.Add导致扩容,那就是优先处理对象。具体操作路径是 Window > Analysis > Profiler,切到 CPU Usage,把 View 切为 Hierarchy。这里有一个容易忽略的选项:真机调试前要先把 Development Build 关掉,否则编辑器模拟出的分配量和真机完全不同。经验数值是:单帧 GC Alloc 超过 2KB,就需要警惕;如果超过 16KB,在低端安卓机上基本会卡。
5.2 体素网格合并与 Unity 阴影问题:把 Draw Call 压到 20 以内
如果不对体素做网格合并,一个 16 乘 16 乘 16 的 Chunk 可能有几百分被当作独立面渲染,Draw Call 直接爆炸。我一般用Mesh.CombineMeshes把同材质的可见面合并成一个 Mesh,然后重新计算法线,地形碰撞用简化的 MeshCollider。阴影方面最常见的坑有三个:法线方向反了、合并后的 Mesh 没有重新算包围盒、投射阴影时每个小方块各自投影。解决方式是把合并后的大物体统一设置MeshRenderer的阴影属性,投射阴影只开在大块地形上,接受阴影也只对玩家和敌人开。如果场景里出现“阴影漏光”或“阴影闪烁”,先查法线朝向和法线重算逻辑,再把投影模式切到双面,能解决大部分情况。合并后的 Mesh 如果要在运行时做顶点位移或碰撞体同步,必须开启 Read/Write Enabled,否则运行时访问顶点数据会直接报错。
5.3 最后值得抄的不是体素算法,是两个习惯
这个项目从标题看是“源码”,但最值得抄的不是 DDA,也不是VoxelChunk。体素算法每个项目都要根据地形规格重写,抄过来大概率不适配。真正值得保留的是两个习惯:把波次和武器做成数据表,以及所有生成物全部走对象池。如果你以后把这个项目写进简历,面试官大概率会先问敌人数量到了多少会掉帧,然后问伤害计算的叠加顺序是怎么定的。这两个问题能答清楚,就不算白看这套源码。至于体素破坏的具体实现,每个项目的地形规格和玩法重点都不同,换个项目八成要重做一遍。把VoxelRaycast和ObjectPool这两个文件单独拎出来,保持依赖干净,下一次做射击游戏时它们还能直接派上用场。
本文还有配套的精品资源,点击获取