
简介体素射击游戏《Voxel Shooter》是一款基于 Unity 引擎的肉鸽幸存者玩法完整源码面向想要学习或移植同类射击小游戏的 Unity 开发者与游戏设计爱好者支持 Unity 2021.1.17f1 及以上版本可直接在此基础上扩展玩法或分析项目结构。核心玩法围绕操控炮塔击碎体素敌人、收集黄金并逐步升级武器展开单局约 30 分钟无内置广告和内购方便二次开发时自行接入商业化内容项目同时包含较完整的安卓侧配置涉及第三方统计与归因依赖库适合研究移动端打包和 SDK 接入流程。压缩包共 1862 个文件、49.02MB涵盖 137 个 C# 脚本、64 个预制体、166 个材质、13 个着色器、27 个模型以及大量贴图、预设与安卓依赖库文件目录类型齐全可对照分析角色生成、炮塔升级、多平台构建等核心逻辑。已有 249 人学习/下载适合具备一定 Unity 操作基础、希望快速掌握体素射击或肉鸽幸存者玩法搭建过程的中级开发者。1. 体素射击肉鸽幸存者本质是数据驱动和对象池的游戏体素射击加肉鸽幸存者听起来是两种流派的拼贴做起来是一套很收敛的工程问题体素场景怎么在运行时保持帧率射击手感在方块体积下怎么立住幸存者那套“随机捡经验、升级三选一”的循环怎么和波次压力咬合。很多 C# 项目源码读起来难不是代码量失控而是配置与行为混在一个脚本里。读这类源码我一般先不看单个武器实现而是先看数据层怎么组织。成熟的做法是用 ScriptableObject 收武器数值用对象池管子弹和敌人再把体素生成、射击、掉落之间的依赖方向理顺。这里直接从 C# 源码角度拆开这三块地基从配置、核心玩法到难度曲线每一步都有能复制进 Unity 项目的实现。2. 用 C# 源码拆解体素射击的三层结构配置、对象池与模块边界源码读起来舒服还是难受分水岭往往不在单个函数写得多漂亮而在层级是否清楚。Voxel Shooter 这类游戏最怕的是一个 PlayerController 里既管移动、又管发射、还管敌人列表升级面板也要找它。肉鸽幸存者的变量比普通射击游戏多很多所以第一件事是把数据、逻辑和表现拆开。这个拆法不是先搭一堆文件夹而是先确定引用方向。2.1 ScriptableObject 管理武器属性与成长曲线把数值直接写进 MonoBehaviour 字段里调平衡时要翻场景、找实例是源码维护中最耗时间的部分。常见做法是把武器参数放进 ScriptableObject让预制体和场景只引用配置资产。这样同一个 WeaponData 可以被多个武器槽、多个敌人复用改一张配置表所有引用它的对象下一帧就拿到新数值。using UnityEngine; [CreateAssetMenu(fileName WeaponData, menuName VoxelShooter/WeaponData)] public class WeaponData : ScriptableObject { public string displayName; public int damage 10; public float fireInterval 0.2f; public float bulletSpeed 40f; public int projectileCount 1; public float spreadAngle 2f; public GameObject projectilePrefab; [Header(等级成长曲线)] public AnimationCurve damagePerLevel; public AnimationCurve fireIntervalPerLevel; public int GetDamage(int level) { return Mathf.RoundToInt(damagePerLevel.Evaluate(level)); } public float GetFireInterval(int level) { return fireIntervalPerLevel.Evaluate(level); } }代码里有一个容易漏的设计点成长曲线用 AnimationCurve而不是硬编码“每级加 5%”。原因是曲线可以在 Inspector 里可视化收益递减还是后期膨胀一眼能看到不用为改一个数值重新编译。fireInterval用间隔而不是射速是因为射击判定里写Time.time _lastFireTime fireInterval更直接。若用射速每帧还要做 1/fireRate 的换算源码阅读成本变高。GetDamage用Mathf.RoundToInt避免伤害数值在小数位飘也方便 UI 直接拼字符串。2.2 对象池体素射击场景内同屏单位多的底线Voxel Shooter 的肉鸽幸存者模式子弹和敌人数量很容易同时超过几百个。如果每个子弹都 InstantiateGC 分配会让帧率呈锯齿状下跌。Unity 2023 之后官方也提供ObjectPoolT但很多源码项目仍手写简单池目的是把回收时机握在自己手里比如子弹飞出边界后立刻回收到隐藏节点而不是等协程或生命周期回调。using System.Collections.Generic; using UnityEngine; public class SimplePoolT where T : Component { private readonly T _prefab; private readonly Transform _parent; private readonly QueueT _pool new QueueT(); public SimplePool(T prefab, Transform parent, int preloadCount) { _prefab prefab; _parent parent; for (int i 0; i preloadCount; i) { T item Create(); item.gameObject.SetActive(false); _pool.Enqueue(item); } } private T Create() { T item Object.Instantiate(_prefab, _parent); item.gameObject.SetActive(false); return item; } public T Spawn(Vector3 position, Quaternion rotation) { T item _pool.Count 0 ? _pool.Dequeue() : Create(); item.transform.SetPositionAndRotation(position, rotation); item.gameObject.SetActive(true); return item; } public void Despawn(T item) { item.gameObject.SetActive(false); _pool.Enqueue(item); } }关键参数是preloadCount。我一般按波次峰值的六成来设比如一次最多 200 个活动单位预载 120 个。剩下 80 个在峰值瞬间由Create()补齐既能避免过度占用内存又能把瞬时实例化控制在可接受范围。使用池时外部只应调用Spawn和Despawn不要暴露内部列表否则外部拿到引用后会在池外销毁对象回收逻辑就乱了。2.3 模块依赖方向把源码边界画出来模块边界不靠文件夹靠依赖方向。下面是常见划分模块职责允许依赖常见错误体素生成地形网格、碰撞体、方块数据Biome/Noise 配置在生成代码里读玩家输入射击系统输入、武器切换、子弹发射WeaponData、子弹池子弹直接修改伤害数值敌人系统波次、寻路、受击掉落对象池、敌人配置在敌人脚本里生成升级 UI肉鸽升级三选一、被动叠加、关卡进度WeaponData、PassiveData用场景对象作为参数传递依赖方向的意思是肉鸽升级系统可以读取 WeaponData但 WeaponData 不应该反过来引用升级 UI。在真实的 Voxel Shooter 项目里我一般在敌人受击回调里发出事件由专门的掉落系统监听再决定生成经验球还是其他物品。每个脚本同一时段只处理一件事波次很密、同屏单位多时单帧损耗才能定位到具体模块。3. 体素地形、射击手感与敌人波次的落地代码有了数据配置和对象池核心玩法按“场景生成 → 玩家射击 → 敌人波次”推进。注意顺序先让体素地形能跑再调射击最后接敌人波次。反过来做每次测试敌人时地形还没生成完手感判断会被严重干扰。3.1 分块体素生成用噪声高度图出方块地形Unity 里做体素地块不必一开始就上完整的 Marching Cubes。肉鸽幸存者游戏的地图通常是一张或几张大平地用高度图生成地表已经足够。严格来说体素数据还要维护一个三维数组记录“实心方块还是空气”但很多标着 Voxel Shooter 的源码项目只把方块当美术风格。先跑通最小网格再考虑挖坑和建筑生成。using UnityEngine; public class VoxelChunk : MonoBehaviour { public int size 16; public float noiseScale 0.08f; public float heightScale 6f; [ContextMenu(Rebuild)] public void Rebuild() { Mesh mesh new Mesh(); var vertices new System.Collections.Generic.ListVector3(); var triangles new System.Collections.Generic.Listint(); var normals new System.Collections.Generic.ListVector3(); for (int x 0; x size; x) { for (int z 0; z size; z) { float wx transform.position.x x; float wz transform.position.z z; float noise Mathf.PerlinNoise(wx * noiseScale, wz * noiseScale); float height noise * heightScale; int vertexOffset vertices.Count; vertices.Add(new Vector3(x, height, z)); vertices.Add(new Vector3(x 1, height, z)); vertices.Add(new Vector3(x 1, height, z 1)); vertices.Add(new Vector3(x, height, z 1)); triangles.Add(vertexOffset 2); triangles.Add(vertexOffset 1); triangles.Add(vertexOffset 0); triangles.Add(vertexOffset 3); triangles.Add(vertexOffset 2); triangles.Add(vertexOffset 0); for (int i 0; i 4; i) { normals.Add(Vector3.up); } } } mesh.SetVertices(vertices); mesh.SetTriangles(triangles, 0); mesh.SetNormals(normals); GetComponentMeshFilter().sharedMesh mesh; } }这段代码用 PerlinNoise 采样二维高度场生成一个块的地表。size控制块内格子数量noiseScale越小地形起伏越平缓heightScale直接决定高度落差。三角形顺序要逆时针否则法线朝向下面光照会整体发黑这也是体素网格最常见的阴影问题来源。用MeshFilter.sharedMesh而不是mesh可以在 Editor 里多次点击 Rebuild避免每次都残留一份网格内存。真正的体素项目还要处理相邻 chunk 的接缝做法是让每个 chunk 的位置对齐到 size 的整数倍并按世界坐标采样噪声而不是按局部坐标。3.2 射击手感参数命中反馈和子弹回收射击手感不完全靠动画更多取决于发射频率、子弹速度和命中延迟。体素角色碰撞体通常会偏大我一般把敌方碰撞体缩小到方块体积的 80%让玩家感觉子弹打在方块表面而不是打在无形的大盒子上。命中反馈可以用短促的方块高亮或音效源码里没必要为这一个小效果引入复杂粒子系统。public class WeaponHandler : MonoBehaviour { public WeaponData weapon; public Transform fireOrigin; private SimplePoolBullet _bulletPool; private float _lastFireTime; public void Setup(SimplePoolBullet bulletPool) { _bulletPool bulletPool; } void Update() { if (Input.GetMouseButton(0)) { TryFire(); } } private void TryFire() { if (Time.time _lastFireTime weapon.GetFireInterval(0)) { return; } _lastFireTime Time.time; int count Mathf.Max(1, weapon.projectileCount); for (int i 0; i count; i) { float angle (i - (count - 1) * 0.5f) * weapon.spreadAngle; Vector3 direction Quaternion.Euler(0f, angle, 0f) * fireOrigin.forward; Bullet bullet _bulletPool.Spawn(fireOrigin.position, Quaternion.LookRotation(direction)); bullet.Setup(weapon.damage, weapon.bulletSpeed, _bulletPool); } } }spreadAngle的算法让多枚子弹围绕准星对称展开当projectileCount是 3 时偏移角分别是负散射角、0、正散射角。fireInterval用帧时间判断而不是协程因为按住左键时每一帧都要检查触发条件协程反而要维护额外状态。这里的GetFireInterval(0)是原型阶段的写法接入升级后可以传入当前等级。子弹生命周期交给 Bullet 自己管理public class Bullet : MonoBehaviour { private float _speed; private int _damage; private float _lifeStartTime; private SimplePoolBullet _pool; public void Setup(int damage, float speed, SimplePoolBullet pool) { _damage damage; _speed speed; _pool pool; _lifeStartTime Time.time; } void Update() { transform.position transform.forward * (_speed * Time.deltaTime); if (Time.time - _lifeStartTime 2f) { _pool?.Despawn(this); } } void OnTriggerEnter(Collider other) { if (other.TryGetComponentEnemyHealth(out var health)) { health.TakeDamage(_damage); } _pool?.Despawn(this); } }子弹生存时间设为 2 秒避免飞出场景后永远不回收。用TryGetComponent而不是tag判断是同屏单位多时性能最稳的选择。如果发现子弹穿过敌人没命中先在 Project Settings 里把子弹和敌人放到同一碰撞层否则OnTriggerEnter不会触发。3.3 敌人波次生成与难度倍率幸存者模式的敌人波次不能是“每次生成固定数量”就结束它要持续压着玩家走。源码里至少要有难度倍率和波次间隔运行时动态调整敌人血量和生成数量。using System.Collections; using UnityEngine; public class EnemyWaveManager : MonoBehaviour { public GameObject enemyPrefab; public int baseCount 8; public float spawnInterval 0.6f; public float waveInterval 15f; public float countGrowth 1.15f; public float healthGrowth 1.1f; private int _waveIndex; private int _spawnedCount; private void Start() { StartCoroutine(WaveLoop()); } IEnumerator WaveLoop() { while (true) { yield return new WaitForSeconds(waveInterval); _waveIndex; int count Mathf.FloorToInt(baseCount * Mathf.Pow(countGrowth, _waveIndex - 1)); _spawnedCount 0; while (_spawnedCount count) { SpawnEnemy(); _spawnedCount; yield return new WaitForSeconds(spawnInterval); } } } private void SpawnEnemy() { Vector3 pos GetSpawnPosition(); EnemyHealth enemy Instantiate(enemyPrefab, pos, Quaternion.identity).GetComponentEnemyHealth(); enemy.SetMaxHealth(Mathf.RoundToInt(100 * Mathf.Pow(healthGrowth, _waveIndex - 1))); } private Vector3 GetSpawnPosition() { float angle Random.Range(0f, 360f); float radius 15f; Vector3 center PlayerController.Instance.transform.position; return center Quaternion.Euler(0f, angle, 0f) * Vector3.forward * radius; } }这个波次表的作用不是限时而是保证每波都制造压力峰值。countGrowth 1.15让数量每 15 秒增加 15%healthGrowth 1.1让血量增长略慢于数量增长玩家中期还能靠走位弥补输出不足。原型阶段用PlayerController.Instance取玩家位置是可以接受的但项目做大后建议在Start里注入玩家 Transform测试代码就不依赖场景上的单例。4. 肉鸽幸存者机制升级三选一与被动叠加很多类幸存者游戏看起来不同底层循环是一样的生存、捡经验、升级、三选一、继续。如果“三选一”被写成固定在场景里的三个按钮那每次升级都要打开场景编辑器改 UI。正确做法是把升级选项看作纯数据玩家选择后再由系统应用到武器和被动。4.1 升级选项随机三选一的生成逻辑升级选项是运行时生成的数据集合不能靠场景设计师手工拼接。先给活跃武器加上等级字段再做随机生成器public class ActiveWeapon { public WeaponData data; public int level; } public class UpgradeChoice { public string title; public string description; public float weight 1f; public System.Action apply; } public static class UpgradeRoller { public static ListUpgradeChoice Roll(ListActiveWeapon ownedWeapons, int resultCount) { var choices new ListUpgradeChoice(); foreach (var w in ownedWeapons) { int current w.level; int next current 1; choices.Add(new UpgradeChoice { title w.data.displayName, description $伤害 {w.data.GetDamage(current)} → {w.data.GetDamage(next)}, apply () w.level next }); } return choices .OrderByDescending(c Random.value * c.weight) .Take(Mathf.Min(resultCount, choices.Count)) .ToList(); } }把应用结果塞进 lambda是源码里很常见的写法。UI 层只需要调用apply不关心改的是武器数值还是被动层数。这里有几个注意点第一生成前要把重复武器合并否则一把霰弹枪会连续出现三个升级项。第二weight字段控制出现概率伤害提升的权重可以高于新武器避免玩家前期一直拿不到输出。第三OrderByDescending在升级瞬间会产生少量 GC平时没问题但不要把这个方法放到每帧调用的地方。4.2 被动技能叠加和数值边界被动技能用 switch 分支写后期会非常难收场。更好的方式是把被动效果做成 ScriptableObject并定义统一的叠加规则。叠加规则直接影响平衡最常见的陷阱是“攻速缩短”用累加层数高了变成负数被动类型叠加规则参数示例伤害加成累乘1.1 的等级次方攻速缩短累减有下限原冷却乘以 0.94 的等级次方移速提升累加封顶最多 3 层弹道数增加离散增量1、3、5、8叠乘和叠加在后期差距巨大。伤害加成累乘10 层后膨胀到约 2.6 倍玩家能明显感到输出质变冷却缩短如果每层减 6%最终会被压缩到固定的下限。源码里至少要把所有数值在应用之前拉回可玩范围public static class StatCaps { public static float ClampPositive(float v, float min 0.05f) { return Mathf.Max(min, v); } public static int ClampUpgradeLevel(int level, int maxLevel) { return Mathf.Clamp(level, 0, maxLevel); } }我用0.05f作为冷却下限意味着攻速最快提升 20 倍再向上就失去操作意义。实际项目中每加一种被动都要回到这个静态类里补充一条上限约束这是肉鸽数值稳定最省钱的做法。4.3 掉落物、经验球与进度循环敌人死亡后的经验球是升级来源也是一次收集循环。不要让每个敌人直接朝玩家飞经验球而是让掉落物进入全局收集器这样后续加磁铁道具、加经验倍率都比较方便。public class ExperienceGem : MonoBehaviour { public int value 1; private SimplePoolExperienceGem _pool; public void Setup(SimplePoolExperienceGem pool) { _pool pool; transform.localScale Vector3.one * 0.6f; } void Update() { Transform player PlayerController.Instance.transform; float step 12f * Time.deltaTime; transform.position Vector3.MoveTowards(transform.position, player.position, step); if (Vector3.Distance(transform.position, player.position) 0.5f) { PlayerController.Instance.AddExperience(value); _pool?.Despawn(this); } } }经验球的追尾不要用 Physics直接用MoveTowards最便宜。如果体素地图上障碍很多再用 NavMesh 或简单抛物线否则大量球的碰撞查询会拖慢主线程。升级曲线可以设定为第 n 级需要50 * 1.12^(n-1)经验这个数值在 20 级后玩家成长速度放缓需要靠武器升级而不是硬堆等级这种节奏比线性增长更有肉鸽味道。5. Unity 游戏优化用 Profiler 验证 Voxel Shooter 的稳定性源码能跑不等于能稳定 60 帧。体素射击最常遇到的卡顿集中在两块GC 分配和网格重建。每组代码改动之后我按一套固定流程验证开 3 分钟波次压力场景让敌人数量跑到峰值同时开 Unity Profiler 的 CPU 模块盯住以下三个值。观察点Profiler 位置可接受值改善动作子弹 InstantiateCPU 使用率GC Alloc 长期为 0确认所有子弹走对象池体素 Mesh 重建主线程单帧不超过 2ms分帧生成或只在改动时重建全屏碰撞检测Physics每次 FixedUpdate 不超过 5ms碰撞层分开子弹只打敌人和环境在 Profiler 里看 GC Alloc 时不要只看平均值要展开主线程每个方法的 GC Alloc 列。如果某个方法长期大于 0就顺着堆栈找到是Instantiate、Debug.Log还是 LINQ。比如第 4 章的UpgradeRoller用了OrderByDescending升级瞬间会有少量垃圾这可以接受但如果同样的模式出现在每帧逻辑里就要改成静态数组手写排序。体素最典型的坑是启动时一次性重建所有 chunk。场景里有 25 块每块几百个顶点主线程会在开场卡掉好几帧。做法是把Rebuild放进一个逐帧执行的任务队列每帧最多生成两到三块地形被破坏时只重建改动的那一块。值得注意的是网格合并成一个大 Mesh 能减少 Draw Call但会让碰撞检测更慢幸存者游戏里我更愿意保持分块让 Physics 只处理玩家附近的几个块。最后讲一个验证技巧检查网格法线有没有反掉不需要反复 build 项目。把 Game 窗口的材质临时换成 Unlit/Color再切到 Wireframe 模式观察。所有面向相机的片元应该显示统一颜色如果出现暗面就是三角形绕序反了。这个判断比在 Play 模式里打空枪检查子弹穿墙快得多也是我接手任何 Voxel Shooter 项目时第一个会做的检查。本文还有配套的精品资源点击获取