
简介这是一份基于Unity引擎的2D肉鸽幸存者游戏完整项目源码以《Brotato》土豆幸存者为原型面向具备一定C#与Unity基础的独立开发者、游戏专业学生及想研究竞技场射击玩法的进阶学习者。项目采用自上而下的竞技场射击机制玩家操控土豆角色最多同时装备6种武器对抗成群外星人并通过特性与物品组合构建独特流派适合作为肉鸽lite玩法、武器系统与生存循环设计的实战参考。压缩包共约2000个文件整体122.87MB其中242个cs脚本承载核心逻辑153个png与4个psd构成美术资源120个wav与10个ogg提供音效另有63个unity场景、45个shader及大量asset、prefab、mat等配置资源结构完整。目前已有303人学习下载可帮助读者快速理解项目目录组织、脚本架构与资源引用方式并在此基础上二次开发或扩展关卡。1. 从 Brotato 拆解 2D 肉鸽幸存者的最小可玩闭环Brotato 这个标题背后其实是一类被反复验证过的玩法结构2D 俯视角、自动开火、波次刷怪、局内成长、局外解锁。很多做 Unity 的工程师第一次看到它会觉得「这不就是个弹幕生存吗」但真正动手复现时卡住的地方往往不是射击而是刷怪节奏、经验曲线和武器堆叠这三件事怎么串成一个闭环。土豆幸存者这类项目的源码价值不在于它用了多高深的架构而在于它把「一局 20 波、每波 20 到 60 秒、玩家只控制移动」这套约束落成了可调参数。如果你正在找 Unity 2D 肉鸽幸存者的落地路径或者手里有一个类似项目但跑不起来、数值崩了、性能掉了这篇笔记按「先跑通最小闭环再补刷怪和成长最后处理性能和避坑」的顺序讲。适合有 C# 基础、能看懂 Unity 生命周期函数、但没完整做过一局肉鸽循环的从业者。2. 最小可玩闭环角色移动、自动索敌、波次计时2.1 为什么先做移动和索敌而不是先做武器系统肉鸽幸存者的核心体验是「走位即操作」。玩家不按攻击键所有输出由武器自动完成所以第一优先级是让角色动起来、让最近的敌人被找到、让武器按冷却开火。常见做法是把玩家移动写成Rigidbody2D.velocity直接赋值而不是AddForce因为幸存者类游戏要的是精确、无惯性、可预测的位移。索敌用Physics2D.OverlapCircleNonAlloc而不是OverlapCircleAll前者不产生 GC后者每帧分配数组波次怪一多就会触发 GC 卡顿。武器冷却用Time.time累加不要用InvokeRepeating因为波次结束要能统一暂停和重置。// PlayerController.cs using UnityEngine; public class PlayerController : MonoBehaviour { [SerializeField] private float moveSpeed 5f; private Rigidbody2D rb; private Vector2 input; void Awake() rb GetComponentRigidbody2D(); void Update() { // 只读输入不在这里做物理 input new Vector2(Input.GetAxisRaw(Horizontal), Input.GetAxisRaw(Vertical)).normalized; } void FixedUpdate() { // 直接赋值速度保证无惯性、可预测 rb.velocity input * moveSpeed; } }参数说明moveSpeed建议从 5 起步Brotato 类游戏玩家移速通常略快于普通怪但慢于冲刺怪。normalized保证斜向不加速。FixedUpdate里赋值速度是 2D 物理的常规做法如果你用Update赋值低帧率下会出现位移抖动。2.2 自动索敌与武器冷却的最小实现索敌逻辑要解决两个问题找谁、多久找一次。每帧找最近敌人没必要怪多时开销明显。我一般用 0.1 秒的索敌间隔配合一个环形缓冲区记录候选目标。武器开火用冷却计时器冷却到了才执行射击射击方向取「玩家到目标」的归一化向量。// AutoWeapon.cs using UnityEngine; public class AutoWeapon : MonoBehaviour { [SerializeField] private float fireCooldown 0.5f; [SerializeField] private float range 6f; [SerializeField] private GameObject bulletPrefab; [SerializeField] private LayerMask enemyLayer; private float timer; private readonly Collider2D[] hits new Collider2D[32]; void Update() { timer - Time.deltaTime; if (timer 0f) return; Transform target FindNearestEnemy(); if (target null) return; Fire(target); timer fireCooldown; } Transform FindNearestEnemy() { int count Physics2D.OverlapCircleNonAlloc(transform.position, range, hits, enemyLayer); Transform nearest null; float minDist float.MaxValue; for (int i 0; i count; i) { float d (hits[i].transform.position - transform.position).sqrMagnitude; if (d minDist) { minDist d; nearest hits[i].transform; } } return nearest; } void Fire(Transform target) { Vector2 dir (target.position - transform.position).normalized; var bullet Instantiate(bulletPrefab, transform.position, Quaternion.identity); bullet.GetComponentBullet().Init(dir); } }参数说明fireCooldown是武器攻速的倒数0.5 秒对应每秒 2 发Brotato 里初始手枪大约就是这个量级。range是索敌半径建议 5 到 7太大玩家没有走位压力太小武器经常空转。hits数组长度 32 是经验值超过 32 个敌人进入范围时只取前 32 个实际游戏中很少触发但你要知道这个上限存在。2.3 波次计时器与刷怪节奏波次系统的最小状态机只有三个状态准备、进行、结束。准备阶段给玩家 3 到 5 秒捡东西进行阶段按时间曲线刷怪结束阶段清场并结算。刷怪节奏不要用固定间隔用「随时间递减的间隔」更接近 Brotato 的手感前 10 秒每 1.2 秒刷一波之后每 0.8 秒最后 10 秒每 0.5 秒。// WaveManager.cs using UnityEngine; using System.Collections; public class WaveManager : MonoBehaviour { [SerializeField] private float waveDuration 20f; [SerializeField] private GameObject[] enemyPrefabs; [SerializeField] private Transform[] spawnPoints; public IEnumerator RunWave(int waveIndex) { float elapsed 0f; while (elapsed waveDuration) { float t elapsed / waveDuration; float interval Mathf.Lerp(1.2f, 0.5f, t); // 越到后面刷得越快 SpawnOne(waveIndex); yield return new WaitForSeconds(interval); elapsed interval; } } void SpawnOne(int waveIndex) { var prefab enemyPrefabs[Random.Range(0, enemyPrefabs.Length)]; var point spawnPoints[Random.Range(0, spawnPoints.Length)]; Instantiate(prefab, point.position, Quaternion.identity); } }参数说明waveDuration是单波时长Brotato 后期波次约 20 到 30 秒。Mathf.Lerp(1.2f, 0.5f, t)控制刷怪密度曲线你可以把它改成 AnimationCurve 在 Inspector 里调。spawnPoints建议放在屏幕外一圈避免怪在玩家脸上凭空出现。3. 局内成长经验、升级、武器堆叠怎么不崩3.1 经验曲线与升级三选一的参数设计肉鸽的成长感来自「每次升级都有选择」。经验曲线常见两种线性exp level * 100和指数exp 100 * 1.15^level。Brotato 类游戏用指数更合适因为后期怪多、经验获取快线性曲线会让玩家在 5 分钟内满级失去成长节奏。升级时弹出三选一选项从武器、属性、被动里抽权重按稀有度分配。// LevelSystem.cs using UnityEngine; public class LevelSystem : MonoBehaviour { [SerializeField] private int level 1; [SerializeField] private float baseExp 100f; [SerializeField] private float growth 1.15f; private float currentExp; public float ExpToNext baseExp * Mathf.Pow(growth, level - 1); public void AddExp(float amount) { currentExp amount; while (currentExp ExpToNext) { currentExp - ExpToNext; level; TriggerLevelUp(); } } void TriggerLevelUp() { // 这里接三选一 UI实际项目里用事件解耦 Debug.Log($Level up to {level}, next needs {ExpToNext}); } }参数说明baseExp100 是 1 级升 2 级所需经验growth1.15 表示每级需求乘 1.15。如果你想让前期更快把 growth 降到 1.1想让后期更慢升到 1.2。注意while循环处理一次加大量经验连升多级的情况不要用if。3.2 武器堆叠的数值边界与去重逻辑武器堆叠是 Brotato 最容易被做崩的地方。玩家可以拿 6 把武器每把独立冷却、独立索敌如果每把都每帧索敌6 把就是 6 次OverlapCircleNonAlloc怪多时开销翻倍。常见优化是让所有武器共享一次索敌结果用一个TargetProvider单例每 0.1 秒更新最近敌人列表武器只读结果。另外武器去重不是禁止同名而是限制同类武器数量上限比如手枪最多 3 把超过后不再出现在三选一里。// TargetProvider.cs using UnityEngine; public class TargetProvider : MonoBehaviour { public static TargetProvider Instance; [SerializeField] private float refreshInterval 0.1f; [SerializeField] private float range 8f; [SerializeField] private LayerMask enemyLayer; public Transform Nearest { get; private set; } private float timer; private readonly Collider2D[] hits new Collider2D[64]; void Awake() Instance this; void Update() { timer - Time.deltaTime; if (timer 0f) return; timer refreshInterval; int count Physics2D.OverlapCircleNonAlloc(transform.position, range, hits, enemyLayer); float minDist float.MaxValue; Nearest null; for (int i 0; i count; i) { float d (hits[i].transform.position - transform.position).sqrMagnitude; if (d minDist) { minDist d; Nearest hits[i].transform; } } } }参数说明refreshInterval0.1 秒是手感和性能的平衡点再低收益不明显再高武器会「反应迟钝」。range取所有武器里最大的索敌半径武器自己再判断目标是否在射程内。hits长度 64 给后期怪多留余量。3.3 属性叠加的乘区与取整规则伤害计算最怕乘区混乱。我一般分三个乘区基础伤害、百分比加成、暴击。基础伤害来自武器百分比加成来自升级选项暴击单独判定。取整放在最后一步中间用 float避免多次取整导致数值偏差。攻速同理冷却时间 基础冷却 / (1 攻速加成)不要写成 基础冷却 * (1 - 攻速加成)后者在攻速接近 100% 时会归零甚至负数。// DamageCalculator.cs public static class DamageCalculator { public static int Compute(float baseDamage, float percentBonus, float critChance, float critMultiplier) { float dmg baseDamage * (1f percentBonus); bool isCrit UnityEngine.Random.value critChance; if (isCrit) dmg * critMultiplier; return UnityEngine.Mathf.Max(1, UnityEngine.Mathf.RoundToInt(dmg)); } public static float ComputeCooldown(float baseCooldown, float attackSpeedBonus) { // 攻速加成用除法保证不会归零 return baseCooldown / (1f attackSpeedBonus); } }参数说明percentBonus是累加后的百分比比如两个 10% 就是 0.2。critMultiplier常见 1.5 到 2.0。ComputeCooldown里attackSpeedBonus为 1 时冷却减半为 3 时冷却变四分之一永远不会到 0。4. 性能与刷怪对象池、物理层、粒子特效内存4.1 对象池替换 Instantiate 的改造步骤波次刷怪和子弹发射如果一直Instantiate/DestroyGC 会在后期集中爆发。改造对象池分三步第一步把子弹和怪物的创建改成从池里取第二步回收时SetActive(false)而不是Destroy第三步池的容量按「单波最大同屏数 × 1.5」预估。子弹池 200 到 500怪物池 100 到 300视你的刷怪密度定。// SimplePool.cs using UnityEngine; using System.Collections.Generic; public class SimplePool : MonoBehaviour { [SerializeField] private GameObject prefab; [SerializeField] private int initialSize 100; private readonly QueueGameObject pool new QueueGameObject(); void Awake() { for (int i 0; i initialSize; i) { var go Instantiate(prefab, transform); go.SetActive(false); pool.Enqueue(go); } } public GameObject Get(Vector3 pos, Quaternion rot) { GameObject go pool.Count 0 ? pool.Dequeue() : Instantiate(prefab, transform); go.transform.SetPositionAndRotation(pos, rot); go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }参数说明initialSize是预热数量太小会在运行时扩容太大浪费内存。Release里不要Destroy也不要重置父物体保持池的层级干净。注意池对象上的脚本要在OnEnable里重置状态比如子弹速度、怪物血量否则会带着上一轮的数据复活。4.2 物理层矩阵与碰撞检测的取舍2D 肉鸽的物理开销主要来自碰撞对。用 Layer 矩阵把「玩家 vs 敌人」「子弹 vs 敌人」「敌人 vs 敌人」分开敌人之间通常不需要碰撞关掉后物理开销能降一半。子弹用 Trigger 而不是 Collision因为子弹不需要物理反弹。敌人受击用OnTriggerEnter2D但要注意 Trigger 在高速下会穿透子弹速度超过 20 时建议用射线检测代替。// Bullet.cs using UnityEngine; public class Bullet : MonoBehaviour { [SerializeField] private float speed 12f; [SerializeField] private float lifeTime 2f; private Vector2 dir; private float life; public void Init(Vector2 direction) { dir direction; life lifeTime; } void Update() { transform.position (Vector3)(dir * speed * Time.deltaTime); life - Time.deltaTime; if (life 0f) SimplePoolRef.Release(gameObject); } void OnTriggerEnter2D(Collider2D other) { if (!other.CompareTag(Enemy)) return; // 造成伤害后回收 SimplePoolRef.Release(gameObject); } }参数说明speed12 是常见子弹速度超过 20 要考虑射线检测。lifeTime2 秒防止子弹飞出屏幕不回收。OnTriggerEnter2D里先判 Tag 再处理避免和场景装饰物碰撞。4.3 粒子特效内存泄露的排查方法粒子特效内存泄露在 Unity 里很隐蔽常见原因是ParticleSystem播放完没有Destroy或回池或者ParticleSystem的main.loop被误开。排查步骤第一步在 Profiler 里看ParticleSystem数量是否随时间增长第二步检查所有Play()调用是否配对Stop()和回收第三步用ParticleSystem.main.stopAction设为Callback或Destroy让播放完自动处理。// ParticleAutoRelease.cs using UnityEngine; [RequireComponent(typeof(ParticleSystem))] public class ParticleAutoRelease : MonoBehaviour { private ParticleSystem ps; void Awake() { ps GetComponentParticleSystem(); var main ps.main; main.stopAction ParticleSystemStopAction.Callback; } void OnParticleSystemStopped() { // 播放结束回池或销毁 gameObject.SetActive(false); } }参数说明stopAction设为Callback后粒子播放完会调用OnParticleSystemStopped。如果你用对象池这里改成Release如果不用池这里Destroy。注意OnParticleSystemStopped只在stopAction为Callback或Destroy时触发默认None不会调用。5. 避坑与排查源码跑不起来、数值崩、性能掉5.1 现象导入源码后报错「The type or namespace name ‘XXX’ could not be found」原因通常是源码依赖了某个包或 Unity 版本特性比如InputSystem、TextMeshPro、2D Tilemap Extras而你的项目没装。解决先看报错里缺的命名空间去 Package Manager 装对应包如果是UnityEngine.UI缺失检查是否装了com.unity.ugui。另一个常见原因是源码用了Unity 6的 API而你用的是2022 LTS这种情况要么升级 Unity要么把新 API 替换成旧写法。5.2 现象游戏跑起来后玩家不动或者移动方向反了原因一般是输入轴没配置或者Input.GetAxisRaw的轴名和项目设置不一致。解决打开Edit Project Settings Input Manager确认Horizontal和Vertical存在如果源码用的是InputSystem检查PlayerInput组件是否绑定。方向反了通常是Rigidbody2D的gravityScale没设 0或者constraints没冻结旋转导致角色翻滚。5.3 现象波次越到后面越卡Profiler 里 GC 频繁原因通常是每帧Instantiate/Destroy、OverlapCircleAll分配数组、字符串拼接。解决按第 4 章的对象池改造索敌换NonAllocUI 文本更新用StringBuilder或缓存字符串。另外检查是否有FindObjectsOfType在Update里调用这个 API 在 2021 后虽然优化过但每帧调用仍然昂贵。5.4 现象升级三选一里出现重复武器或者选项为空原因通常是随机抽取没有去重或者候选池在某个稀有度下为空。解决抽取时用一个HashSet记录已选抽到重复就重抽重抽超过 10 次就降级到低稀有度池。候选池为空时给一个保底选项比如「5% 移速」不要让 UI 弹空窗。5.5 现象粒子特效播放后场景里残留大量空物体原因就是 4.3 说的ParticleSystem播放完没有回收。解决给所有粒子预制体挂ParticleAutoRelease或者统一在ParticleSystem的main里设stopAction。另外检查是否有代码在Update里反复Play()导致粒子一直重启、永远不结束。6. 进阶技巧用 ScriptableObject 把数值和逻辑拆开做到后面你会发现数值调整比写逻辑更耗时。我一般把武器、敌人、升级选项都做成ScriptableObject策划在 Inspector 里改程序不动代码。这样做的另一个好处是版本管理清晰数值改动不会和逻辑改动混在一个提交里。下面是一个武器配置的最小示例。// WeaponData.cs using UnityEngine; [CreateAssetMenu(fileName WeaponData, menuName Game/Weapon)] public class WeaponData : ScriptableObject { public string weaponName; public float baseDamage 10f; public float cooldown 0.5f; public float range 6f; public GameObject bulletPrefab; public int maxStack 3; }参数说明maxStack控制同类武器上限配合 3.2 的去重逻辑用。CreateAssetMenu让你在Assets Create Game Weapon里直接建配置。注意ScriptableObject在运行时修改会持久化到编辑器如果你要在运行时改数值先Instantiate一份副本。验证方法改完配置后在 Play 模式下打开Profiler看GC Alloc是否稳定在每帧 0 到几百字节如果还是几 KB回去检查索敌和 UI。另一个验证是连续跑 5 波看帧率是否稳定在 60如果掉到 40 以下优先查对象池和粒子。我自己的习惯是每加一个新系统先在空场景里跑 100 个怪压测确认 GC 和帧率没问题再接入主流程。这个习惯帮我省了很多后期返工。希望帮到你。本文还有配套的精品资源点击获取