
做手机游戏项目尤其是那种还要配一整套设计文档和论文说明的最忌讳的就是把精力全放在“写”上结果程序跑不起来。这次“蘑菇大联盟”这个项目我的原则是先有能玩的Demo再回头补文档。它定位是一款轻量级的塔防加收集养成类手机游戏玩家操控一群蘑菇单位在不同关卡里布阵抵御一波波昆虫入侵赚取金币和经验去解锁新蘑菇、升级已有蘑菇。整个玩法单手就能操作单局控制在三分钟以内适合通勤、排队这种碎片时间。这篇文章就把我从立项、玩法设计、技术选型到实际开发过程中踩过坑的地方按能落地复现的方式完整记录一遍尤其适合打算做手机游戏设计课题、或者第一次完整走完一款Unity手游的同学参考。1. 项目定位与整体设计思路1.1 为什么选“塔防养成”而不是更复杂的品类立项时我认真对比过几个方向横版格斗、卡牌RPG、模拟经营、塔防。最后选了塔防加养成核心原因是开发成本可控同时玩法本身有足够的策略深度。塔防的关卡结构天然适合手机端的碎片化体验一局两三分钟玩家随时可以停下不需要复杂的存档恢复机制。而养成系统负责给玩家一个长期目标否则塔防这种“反复打同一关”的模式很容易腻。养成不是让玩家无脑堆数值而是通过新蘑菇种类提供新的策略组合比如高攻速的近战菇、远程减速的孢子菇、范围爆炸的爆破菇不同组合面对不同敌人有完全不同的解法。成本方面也很关键。塔防场景大多是单屏或固定卷轴美术资源量比开放世界小一个量级。程序端也不需要复杂的物理系统大部分战斗逻辑可以用简单的距离判断和坐标移动完成。这个项目从零开始到完整可玩版本两个人大概花了两个月其中一个多月集中在玩法打磨和数据平衡上这个节奏对于一个学生团队或者第一次做独立手游的开发者来说是比较现实的。1.2 Unity 技术栈的选型与具体理由技术栈用的是 Unity 2021.3 LTS 加 C#渲染管线选的通用渲染管线的2D渲染器UI 用 UGUI资源加载先走 Resources存档用 JSON 加本地文件存储。选Unity而不是其他引擎主要看重三点。第一是社区和资源生态。塔防游戏在Unity里几乎每个模块都能找到现成参考不管是波次生成、寻路算法还是对象池踩坑时搜一下就能找到同行的解决方案。第二是跨平台打包成熟同一套代码出Android和iOS包只需要处理少量平台差异整个流程我实测下来从Android包切到iOS包没有遇到架构层面的问题。第三是引擎内置的Profiler和帧调试器足够好用定位卡顿和内存问题时不需要额外接第三方工具。这里我明确没有用ECS和DOTS这套新架构。原因很简单项目规模不大单位数量最多同时在场也就几十个用传统MonoBehaviour加协程完全扛得住。DOTS能带来性能优势但学习成本和调试成本都更高对一个小体量2D塔防来说是过度设计。注意技术选型的关键不是“谁最强”而是“谁在限定的人力、时间内最稳妥”。做毕设或独立项目时稳定交付远比技术炫技重要。1.3 工程目录与模块划分项目结构我按功能而不是按类型划分所有跟战斗相关的放一起所有跟UI相关的放一起避免出现一堆脚本堆在Assets根目录的情况。实际目录长这样Assets/ │ ├── Scripts/ │ ├── Battle/ // 战斗逻辑单位、敌人、子弹、波次 │ ├── UI/ // 界面脚本主菜单、关卡选择、战斗HUD │ ├── Data/ // 数据模型与存档组件 │ ├── Managers/ // 全局管理器GameManager、AudioManager等 │ └── Utils/ // 扩展方法、数学工具、通用组件 │ ├── Resources/ │ ├── Prefabs/ // 所有预制体 │ └── Configs/ // JSON配置关卡表、单位属性表 │ ├── Scenes/ │ ├── BootScene // 启动场景加载管理器 │ ├── MainMenu // 主菜单 │ └── BattleScene // 战斗关卡场景 │ └── Art/ ├── Sprites/ // 图集与贴图 └── Animations/ // 动画资源管理器用单例模式全局只有一个GameManager管游戏状态一个AudioManager管音效一个存档管理器管数据读写。但这里我给自己定了个规矩战斗内部的单位之间不允许直接互相调用而是通过事件或者一个战斗控制器来中转。比如一个蘑菇单位攻击了敌人它只负责生成子弹和通知战斗控制器不直接去改敌人的血量。这么做的好处是后面加新单位类型时不需要翻遍所有旧单位的代码去改依赖关系。2. 核心玩法系统的完整实现2.1 蘑菇单位的属性设计与数值平衡所有蘑菇单位都基于一个基础数据类字段包括名称、生命值、攻击力、攻击间隔、攻击范围、技能类型、技能冷却、解锁所需金币。设计上没有搞太复杂的属性就这几项因为数值系统越简单平衡越容易调。先看几个典型的单位配置单位定位生命攻击力攻击间隔(秒)攻击范围技能蘑菇哨兵单体近战120180.81.2格无孢子射手远程单体70120.64.5格20%概率附加减速爆破菇范围输出90302.22.8格对2格内敌人造成溅射治愈菇辅助10051.53格每秒回复周围单位3点生命数值设计的核心是保证每个单位在某个场景有明确的高光时刻。孢子射手单体输出高但血量差爆破菇清群效率高但攻速慢治愈菇负责长线作战。玩家必须在有限的布阵格子里组合出针对性阵容这才有策略感。攻击间隔要换算成秒伤来评估。比如孢子射手攻击力12间隔0.6秒秒伤是20。爆破菇攻击力30间隔2.2秒秒伤约13.6看似低很多但如果一次溅射打到3个敌人等效秒伤就到40了。每次调整数值我都会把这些换算结果写在一张表里避免出现某个单位“看起来输出高但实际溢价严重”的问题。2.2 战斗循环与攻击逻辑的实现战斗循环可以描述为游戏开始时敌人按波次从出生点沿路径走向终点蘑菇单位在布置后进入待机状态当有敌人进入攻击范围时开始索敌、计时、发射子弹或技能敌人生命归零后消散玩家获得金币漏掉的敌人到达终点则扣除关卡生命值。单位索敌我强烈建议不要每帧去遍历所有敌人。一个单位每帧都在做范围内找目标这种事情当单位数量和敌人数都上来以后性能会变得很难看。我的做法是给每个单位一个独立的协程每0.15秒做一次目标扫描找到目标后就锁定直到目标死亡或离开范围再重新扫描。public class AttackComponent : MonoBehaviour { public float attackRange 3f; public float attackInterval 0.8f; public int damage 12; private Enemy currentTarget; private float scanCooldown 0f; void Update() { scanCooldown - Time.deltaTime; if (scanCooldown 0f) { scanCooldown 0.15f; TryAcquireTarget(); } if (currentTarget ! null) { TryAttack(); } } void TryAcquireTarget() { if (currentTarget ! null currentTarget.IsAlive() Vector2.Distance(transform.position, currentTarget.transform.position) attackRange) { return; } currentTarget BattleManager.Instance.FindNearestEnemy(transform.position, attackRange); } void TryAttack() { if (Time.time - lastAttackTime attackInterval) { lastAttackTime Time.time; BulletPool.Instance.Spawn(transform.position, currentTarget.transform); } } }这里有两个关键点。一个是锁敌后不轻易换目标避免单位反复横跳导致攻击一直打不出去。另一个是子弹生成一定要走对象池后面有专门的优化章节详谈。2.3 波次生成与关卡节奏设计波次是塔防的心脏。波次数据结构我设计成三个字段怪物类型、数量、出生间隔。一个关卡由若干波组合而成波与波之间给玩家几秒钟的空窗期去补塔或调整阵型。[Serializable] public class WaveGroup { public string enemyType; public int count; public float spawnInterval; public float delayBeforeWave; }波次生成器是一个协程逐个读取配置表按间隔生成敌人。完全不修改玩家BattleScene里的场景所有关卡配置都放在JSON文件里这样测试新关卡时不需要重新打包热更新配置也方便很多。public class WaveSpawner : MonoBehaviour { public ListWaveGroup waveGroups new(); IEnumerator Start() { foreach (var group in waveGroups) { yield return new WaitForSeconds(group.delayBeforeWave); for (int i 0; i group.count; i) { var enemy EnemyPool.Instance.Spawn(group.enemyType, spawnPoint.position); enemy.MoveAlongPath(pathPoints); yield return new WaitForSeconds(group.spawnInterval); } yield return new WaitUntil(() BattleManager.Instance.CurrentEnemyCount 0); } BattleManager.Instance.WinLevel(); } }关卡节奏上我的原则是“前五关教学中段解锁全部机制后段开始压数值”。第一关只有普通爬虫一种敌人让玩家学会放置和攻击。第三关加入远程敌人逼玩家考虑布阵位置。第六关加入Boss检验前面学到的阵容搭配。难度曲线不是线性增长的而是像台阶一样每解锁一个新机制就抬高一截然后用两三关让玩家适应。2.4 养成系统与资源循环养成系统的核心是资源如何闭环。战斗胜利给金币金币用来解锁新单位或者给单位升级。单位升级直接提升基础属性比如每级加10%生命和8%攻击。这里我刻意没有做装备、宝石这种额外系统因为养成链一旦变长数值平衡的工作量会成倍增加对于一个塔防Demo来说没有必要。玩家的核心目标链是“通关→拿奖励→解锁新蘑菇→尝试新阵容→挑战更高难度”。金币发放也要控制节奏如果玩家能在一两关内买到所有单位养成系统就形同虚设。我的做法是让前10关总产出刚好够解锁前4个单位第11到20关产出足够解锁剩余单位并把主力升到5级左右后面关卡才开始真正考验阵容理解。养成界面的状态控制我用了一个简单的UI状态机每个面板有Enter和Exit方法避免多个面板互相覆盖导致点击穿透。经验教训来自早期版本主菜单的“开始游戏”按钮和战斗界面的“暂停”按钮用的是同一个全局输入处理结果出现过好几次点击暂停结果跳到了菜单的Bug。3. 移动端实现中的关键细节与优化3.1 对象池是这个游戏最关键的性能优化对象池在塔防里的地位我可以说它决定了这个游戏在低端Android机上能不能跑得动。战斗过程中子弹不断生成、命中后销毁敌人不断被击退、死亡后回收。如果全部用Instantiate和Destroy每一帧都会产生大量内存碎片和GC压力很快就会出现掉帧和卡顿。对象池的实现思路很简单池子里预先存放一批实例需要时取一个活跃对象用完不仅隐藏起来而是放回池子里待用。public class BulletPool : MonoBehaviour { public static BulletPool Instance; public GameObject bulletPrefab; public int preloadCount 30; private QueueGameObject pool new(); void Awake() { Instance this; for (int i 0; i preloadCount; i) { var obj Instantiate(bulletPrefab); obj.SetActive(false); pool.Enqueue(obj); } } public GameObject Spawn(Vector3 position, Transform target) { GameObject bullet; if (pool.Count 0) { bullet pool.Dequeue(); } else { bullet Instantiate(bulletPrefab); } bullet.transform.position position; bullet.SetActive(true); bullet.GetComponentBullet().Init(target); return bullet; } public void Recycle(GameObject bullet) { bullet.SetActive(false); pool.Enqueue(bullet); } }预热数量我这里取了30实测下来一场20波的战斗同时活跃的子弹不会超过15颗30的预热余量足够。对象池除了子弹敌人单位也用了同样的逻辑。敌人在场景外生成死亡后不是立即销毁而是等动画播放完再回收回池子这样既保留了战斗反馈又控制了不必要的实例化开销。注意如果对象池里的对象需要重置很多状态记得在Recycle时把速度、血量、动画全部归位。我踩过的坑是敌人回收后没重置动画状态下一局战斗出现敌人“自带死亡倒地动画”的诡异现象。3.2 分辨率和异形屏适配手机游戏绕不开屏幕适配。我的做法是用UGUI的CanvasScalerUI模式选择“缩放适屏宽高”参考分辨率设成1920×1080。这样做的好处是UI会尽量保持设计稿比例不同宽高比下不会被拉伸变形。但异形屏还需要额外处理。很多手机顶部有刘海或挖孔如果UI在默认区域显示按钮可能被遮挡。Unity里可以用Screen.safeArea获取安全区域RectTransform uiRoot GetComponentRectTransform(); Rect safeArea Screen.safeArea; Vector2 anchorMin safeArea.position; Vector2 anchorMax safeArea.position safeArea.size; anchorMin.x / Screen.width; anchorMin.y / Screen.height; anchorMax.x / Screen.width; anchorMax.y / Screen.height; uiRoot.anchorMin anchorMin; uiRoot.anchorMax anchorMax;这个逻辑写在UI根节点上四个锚点会根据当前设备的安全区自动收拢。我测试过的机型里iPhone的刘海屏和Android的各种挖孔屏都能正常显示不会再出现顶部按钮点不到的问题。3.3 数据持久化与存档设计存档设计我采用的是“JSON文件存储加Base64混淆”的方案虽然加密强度不高但能防止玩家用文本编辑器随便改出全满级存档。JSON结构里有一个重要的字段存档版本号。{ version: 3, playerLevel: 12, gold: 3450, unlockedUnits: [mushroom_sentinel, spore_shooter], unitLevels: { mushroom_sentinel: 5, spore_shooter: 3 }, levelProgress: 16 }版本号配合数据迁移函数使用。每次游戏更新时如果旧存档的字段结构发生了变化我就在Load存档后检查版本号走对应的迁移逻辑补默认值。没有这一步的话一旦上线后改了字段名或者删了旧字段老玩家打开游戏直接读档崩溃。以下是存档加载时的版本兼容示例public void Load() { string json File.ReadAllText(savePath); SaveData data JsonUtility.FromJsonSaveData(json); if (data.version 2) { data.unlockedUnits ?? new Liststring() { mushroom_sentinel }; data.version 2; } if (data.version 3) { data.levelProgress 0; data.version 3; } SaveInternal(data); }写存档的频率也要控制好。每一场战斗结束自动存一次玩家如果中途退出下次进入时本关重开但已获得的单位等级和金币保留。不要每帧写文件移动端闪存写入次数有限频繁IO既伤硬件又拖慢游戏。3.4 UI框架与界面管理UI这块我用的是UGUI自带的事件系统所有按钮通过OnClick绑定逻辑。界面层级我分三层最底层场景内容中间层战斗HUD最上层弹窗与设置。不同层级用不同的Canvas排序避免弹窗被战斗HUD挡住。界面切换我用一个简单的UIManager维护一个界面栈。打开新的全屏界面时旧界面会被压栈隐藏关掉新界面时自动恢复旧的。这种栈式管理比直接SetActive更可靠不会出现两个全屏界面同时显示、互相抢点击的情况。实际开发中比较耽搁时间的反而不是界面布局而是输入冲突。当战斗界面和暂停弹窗同时存在时点击暂停按钮会穿透到下面的HUD。解决方案是弹窗打开时给底层Canvas加一个不可见的全屏遮罩拦截所有射线点击。这个方法简单有效我只在遮罩层加了RawImage组件没有让它消耗额外性能。4. 开发中的常见问题与排查记录4.1 低配手机发热掉帧Profiler抓出了真凶项目第一次在真机上跑用的是台两年前的千元机一进入战斗界面就明显掉帧玩三分钟背面发热到烫手。接上Unity Profiler看数据发现CPU耗时最高的不是渲染也不是物理而是一堆零散的GC Alloc。逐帧排查下来主要有三个问题。第一是每帧都调用了GameObject.Find或者FindObjectOfType这个方法在Unity里是出了名的慢操作不该出现在任何Update里。解决方案是在初始化阶段把引用缓存好后续所有逻辑直接用缓存引用。第二是大量字符串拼接。调试时Debug.Log频繁拼接单位状态字符串确实只会在开发期执行但我在发布包里没关掉这些日志导致正式运行也在执行。解决办法是用条件编译宏把Debug.Log全部包裹起来发布版本完全不会编译进去。第三是LINQ的滥用。代码里用Where和OrderBy遍历敌人列表每次调用都会分配一个新的迭代器对象。游戏里的敌人列表50个对象时感觉不到但一密集起来GC压力立刻显现。我把所有热点路径上的LINQ全部改成了普通for循环GC Alloc瞬间降了一个数量级。优化完后再用Profiler测帧时间从22毫秒降到了8毫秒左右发热问题也基本消失。这个经历给我的教训是性能问题一定要先量化再修盲猜往往是白费力气。4.2 存档版本升级导致全线崩溃这是上线后最惨烈的一次事故。某个版本加了一个新单位我在初始化单位解锁列表时忘了处理旧存档结果老玩家加载存档后代码去读取的单位ID在配置表里不存在直接空引用报错。当时我花了一个下午把错误场景复现出来最后补了一套兜底方案Unity里没有全局的异常捕获真机上线一旦出问题要等用户反馈所以正确防守是在存档加载后做一次完整性校验。现在我的做法是加载完JSON后遍历所有单位ID去配置表里查一遍查不到的就剔除并给玩家补个提示。核心逻辑是存档永远是不可靠的来源任何字段都可能缺失或非法加载时要做防护处理而不是假设存档一定是对的。4.3 触摸输入误触发和遮挡问题塔防的布阵操作是点击场景上的格子放置蘑菇单位。问题出现在iOS设备上玩家手指放上去准备拖拽查看地图结果松开时被当成了点击多放了一个蘑菇。排查下来是点击和拖拽的判断阈值设得太短了。我用的是Unity自带的Touch类当手指移动超过15像素就取消点击判定。另外还有一个问题是布阵点和暂停按钮重叠暂停按钮在屏幕右上角地图边界也正好走到那里经常出现想暂停结果放了个蘑菇的情况。解决方法是把布阵格点和暂停按钮的可点击区域做了分离按钮点击层使用独立的Canvas布阵检测则在另一个事件层并且冻结布阵层在暂停弹窗显示时的输入。这个处理思路所有涉及多层UI交互的游戏都可以借鉴。4.4 真机权限和平台差异处理Android和iOS真机测试时遇到过一些平台相关的问题。Android端早期版本在Android 11上读取存档失败是因为我没有申请应用专属存储目录的权限直接写了绝对路径。后来统一改为Unity提供的Application.persistentDataPath下的路径问题解决。iOS端则是音频初始化顺序的问题。项目在启动场景里立刻播放背景音乐但iOS上音频系统还没有就绪导致前两秒没有声音。处理方式是检测到iOS平台时延迟0.5秒再初始化音频管理器。这一类问题在编辑器环境完全发现不了只能靠真机频繁测试来暴露。5. 项目复盘与后续可扩展方向5.1 开发周期和人力分配经验整个项目从零开始到可对外试玩的版本两个人投入大约六周时间。其中程序约占三周美术资源约占两周数据配置和测试约占一周。最容易被低估的是数值配置工作我原本计划两天搞定结果连续调了一个多星期才把前20关的难度曲线调到比较舒服的状态。对于时间紧张的同学我强烈建议在第一周就做一次“垂直切片”也就是打通一关完整流程放单位、敌人进攻、技能释放、结算界面。哪怕是丑到不行的白模只要流程通了后面所有优化都是在这个基础上不断丰富。不要上来就去抠美术细节先把玩法是否好玩验证了再投入资源做正式美术这是做游戏最省时间的路线。5.2 后续可扩展的设计方向头部和剪裁都做完了这个游戏已经可以玩但如果要继续推内测或者商业版本有几个方向可以扩展。联机排行可以做一个每日挑战关卡玩家用固定的低等级阵容挑战高难波次按回合数打分上传排行榜。这个功能是拉长生命周期最快速的手段而且单局设计一次所有玩家都能复用。云存档功能值得考虑一下。现在的存档只在本地设备换了手机就得重来对玩家来说是很劝退的体验。云存档可以用第三方后端服务以存档字符串为单位做同步玩家切换设备时拉取最新版本覆盖本地存档。要注意的是服务端的版本冲突处理策略比如以时间戳为准还是以关卡进度为准。内购和广告的商业化方案也不是不能加但接入任何SDK之前都要仔细阅读平台约束条款尤其是未成年用户保护和隐私政策这些模块的合规成本往往被忽略。建议等项目玩法被验证后再考虑商业化先攒核心口碑不要让广告位影响首局体验。最后说点真实的个人体会。这个项目做完我对“设计文档”这件事的理解改变了很多纯纸面上的功能清单意义不大真正的设计是被实机测试反馈逼出来的。比如最初设计的红蘑菇自爆技能在数值上看起来很平衡但实际跑起来因为自爆会误伤附近的友方单位结果玩家根本不用——后来改成自爆只伤害敌方角色才被接受。与其纠结文档写得多华丽不如早点把可玩版本放出来让真实数据告诉你问题在哪里。这就是“蘑菇大联盟”给我上的最宝贵一课。