ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity 2D俯视角射击游戏源码实战解析:角色控制、敌人AI与血条UI

Unity 2D俯视角射击游戏源码实战解析:角色控制、敌人AI与血条UI 简介Metal Black OPS 是一款面向Unity开发者与游戏学习者的完整2D横版射击游戏源码项目适用于C#编程进阶、移动端游戏开发实践及商业化游戏架构研究。资源基于Unity 2019.2.9f1及以上版本构建涵盖5大世界、24个关卡、25敌人与5个Boss设计集成AdMob与Unity Ads广告系统、Facebook登录、每日任务/成就/技能升级等成熟运营模块支持iOS与Android双平台离线运行。压缩包为ZIP格式大小718.62MB包含场景文件、脚本逻辑C#、动画控制器、UI预制体、音效资源及配置数据等典型Unity工程结构文件目录组织清晰便于模块化学习与二次开发。已有303人下载学习可直接导入Unity编辑器运行调试快速掌握2D射击游戏的核心机制实现——如武器系统升级逻辑、敌人AI状态机、卷轴关卡管理及内购与广告接入流程。 Metal Black OPS 这套 Unity 2D 俯视角射击游戏源码项目我拿到手的第一反应不是点 Play而是先打开文件夹看目录结构。因为一个射击游戏源码能不能用来学习关键不在于画面多炫而在于战斗闭环是否完整——玩家能不能移动、瞄准、开火敌人会不会追你、打你、掉血、死亡血条和 UI 会不会跟着状态实时刷新关卡能不能一关一关往下推。这套源码把这条链路跑通了所以它才值得拆开来讲。这阵子unity2d 血条源码项目这类搜索词热度一直不低说明很多人手里都攥着类似的项目源码但卡在了打开能跑看不太懂不知道怎么改这一步。这篇文章我就以 Metal Black OPS 为例把它拆成角色控制、敌人生成、血条 UI、状态管理、扩展改造几个模块逐一讲清楚源码里每条关键逻辑在干什么、为什么要这么写以及你在自己项目里能怎么复用。1. 先从项目骨架看起这套源码的玩法定位与目录结构1.1 玩法定位Metal Black OPS 到底是什么样的游戏从名字就能猜到OPS 是 Operations 的缩写整体气质偏军事行动那一挂配色和 UI 上通常带着金属质感、战术小队、黑色行动的味道。实际跑起来之后它就是一个非常典型的俯视角 2D 射击游戏玩家控制角色在场景里移动鼠标控制瞄准方向左键开火敌人会从地图边缘或者固定刷怪点一波一波涌出来击杀后掉落补给或者进入下一波。这个玩法的核心价值不是它美术多精致也不是操作多复杂而是它把完整战斗循环做出来了。什么叫完整战斗循环就是玩家操作角色——角色开火——子弹命中——敌人扣血——敌人血条更新——敌人死亡——生成下一波——奖励反馈到 UI——玩家血量归零——游戏结束。这一整条链里面任何一环断了游戏都玩不起来。很多新手自己写 Demo往往只做到能开火、敌人能死后面血条、波次、游戏结束全都没有这就是典型的单点 Demo 而不是完整项目。Metal Black OPS 这类源码项目最大的学习价值就在于这一个完整的闭环。适合什么人看我觉得有两类人非常合适。一类是刚学完 Unity 基础、想完整做一个游戏却不知道从哪下手的初学者另一类是手里有别的源码项目但看不懂、想通过一个完整案例学会拆解他人代码的人。如果你已经能自己写 2048 或者 Flappy Bird 那种小游戏再来看这套源码的上手成本会非常合适。1.2 拆开 Assets 目录每个文件夹对应一套系统拿到源码先别急着开场景花十分钟把 Assets 目录过一遍基本就能摸清作者的架构习惯。按我见过的 Unity 2D 射击项目惯例这套源码大概率会分成下面几个文件夹Scripts/Player角色移动、玩家生命值、武器输入、受击处理Scripts/Enemy敌人 AI 状态、敌人属性、波次生成器Scripts/Weapon枪械数据、子弹预制体、弹夹/弹药管理Scripts/UI玩家的血条、敌人头顶血条、准星、伤害飘字Scripts/SystemGameManager、事件中心、音频管理Prefabs敌人、子弹、掉落物、血条、UI 组件等预制体Scenes主场景、菜单场景这种按系统拆文件夹的习惯非常值得学习。很多初学者写脚本的习惯是全部扔在 Assets 根目录下面等脚本数量超过二十个的时候找文件比写代码还费时间。而像 Metal Black OPS 这种项目因为挂载对象的预制体特别多如果脚本不放好问题排查起来会极其痛苦。你在读这套源码的时候建议先画一张简单的功能清单移动属于 PlayerAI 属于 Enemy血量条属于 UI波次属于 System。之后每看到一个功能就往清单上对应位置填代码这样梳理完整套项目的运行逻辑也就在你脑子里成型了。1.3 版本与依赖的坑为什么先看 ProjectSettings 而不是直接开场景打开一个 Unity 项目最容易踩的坑不是代码报错而是版本不匹配。我在本地跑 Metal Black OPS 时就遇到过一打开所有材质都变成紫粉色、场景里的 UI 全部错乱的情况。这种问题大概率不是代码 bug而是项目的渲染管线、输入系统或者包版本与你本机的 Unity 版本不一致造成的。具体说三个最常出问题的地方。第一Unity 版本号。在 ProjectSettings/ProjectVersion.txt 里能看到项目当初用的 Unity 版本最好用相同的大版本打开比如项目用 2021.3 保存你至少用 2021.3 以上小版本跨得太多序列化数据可能会出问题。第二输入系统。如果你用的 Unity 版本默认开了新版 Input System Package但项目里写的是旧的 Input.GetAxisRaw那脚本会直接报错因为新输入系统默认禁用了旧 API。遇到这个情况需要在 Player Settings 里把 Active Input Handling 改成 Both代码才能正常跑。第三渲染管线。如果项目用的是 URP 管线而你的场景里 Sprite 的材质写死了内置管线的 Sprite-Default那图片可能直接渲染不出来或者颜色发暗。这种情况下把材质换成 URP 下的 Sprite-Lit-Default 就能解决。这类问题在商业模板和开源项目里太常见了。所以我的建议是任何源码项目拿到手第一件事永远是看版本配置而不是双击场景。否则你可能花一整晚排查一个根本不存在的逻辑 bug最后发现只是管线和批次不兼容。2. 角色控制器与射击手感不是单纯移动加开火2.1 移动与瞄准的输入处理逻辑俯视角射击游戏的操作逻辑跟横版射击有一个根本区别移动方向和瞄准方向是分离的。移动用 WASD 控制角色在 X、Y 平面里走动瞄准用鼠标控制角色上身朝向或者枪口朝向跟随鼠标位置旋转。这个分离逻辑是整个角色控制器的地基。移动部分最基础的写法是直接用 Rigidbody2D 的 linearVelocity 赋值。注意Unity 6 以后新版 API 已经把 velocity 改名为 linearVelocity旧版本用 velocity如果你在自己的项目里发现代码提示找不到 linearVelocity检查一下 Unity 版本就行。Vector2 moveDir new Vector2(Input.GetAxisRaw(Horizontal), Input.GetAxisRaw(Vertical)).normalized; rb.linearVelocity moveDir * moveSpeed;这里有三个细节值得展开。第一为什么要 normalized如果不归一化斜方向移动时 X 和 Y 同时输入速度会变成原来的根号 2 倍角色斜着走比横着走快一截这是个很细微但实际影响手感的 bug。第二为什么用 GetAxisRaw 而不是 GetAxisGetAxisRaw 只返回 -1、0、1没有平滑过渡适合键盘输入角色响应更快不会出现松手后角色还在滑行的滞后感。第三为什么不直接改 transform.position因为后续要检测碰撞、实现击退效果用 Rigidbody2D 物理系统能统一处理如果你直接改位置碰到墙会穿模被击退也无法实现。瞄准部分核心是 ScreenToWorldPoint。鼠标坐标是屏幕坐标而角色在世界里所以要先把鼠标位置转换成世界坐标再计算从角色指向鼠标的向量也就是瞄准方向。Vector3 mousePos Camera.main.ScreenToWorldPoint(Input.mousePosition); mousePos.z 0; Vector2 aimDir (mousePos - transform.position).normalized;这段代码里有一个性能小坑很多源码项目图省事直接写 Camera.main但 Camera.main 底层是 FindGameObjectWithTag每帧调用会产生额外开销。如果在战斗激烈、敌人成批生成时这个开销会被放大。更好的做法是把 Camera 缓存在变量里Awake 时赋值一次。这个小改动不会改变任何逻辑但对性能是有实打实帮助的。另外如果鼠标位置在角色背后枪口会向后指这很正常但如果你做的是角色始终面向鼠标方向的设计还需要额外的翻转判断。典型的做法是判断 aimDir.x 的正负决定角色 Sprite 是否翻转或者是否旋转到对应角度。2.2 子弹不能 Instantiate 了事对象池才是正解射击游戏里最影响性能的操作就是频繁生成和销毁子弹。如果每开一枪都 Instantiate 一颗子弹等屏幕上同时存在二三十颗子弹时帧率就会出现肉眼可见的下降。原因在于 Instantiate 和 Destroy 涉及内存分配、序列化、生命周期回调是一个比较重的操作不适合在战斗中高频使用。而对象池的思路很朴素提前创建一批子弹开火的时候从池子里取一颗激活它命中或飞出射程后不是销毁而是隐藏起来放回池子下次再取出来用。对象池的核心数据结构就是一个队列Queue我用这种方式实现过很多次public class BulletPool : MonoBehaviour { [SerializeField] private GameObject bulletPrefab; [SerializeField] private int initialSize 20; private QueueGameObject pool new QueueGameObject(); private void Awake() { for (int i 0; i initialSize; i) { var obj Instantiate(bulletPrefab); obj.SetActive(false); pool.Enqueue(obj); } } public GameObject Get() { if (pool.Count 0) { var obj pool.Dequeue(); obj.SetActive(true); return obj; } var newObj Instantiate(bulletPrefab); newObj.SetActive(true); return newObj; } public void Return(GameObject obj) { obj.SetActive(false); pool.Enqueue(obj); } }这里有一个容易被忽略的细节当子弹数量超过池子初始容量时Get 方法会 Instantiate 一个新的但这个新对象不会在 Return 时被销毁而是被正常回收。所以池子是会自动扩容的。扩容是好事但如果扩容发生得太频繁说明初始容量设置得太小应该根据你游戏实际战斗中同时存在的子弹数量来定。我一般会把初始容量设置成单把武器极限射速下的子弹数再乘 1.5左右这样既不会频繁扩容也不会一次性预创建太多浪费内存。还有一个很关键的回收时机问题。子弹飞出屏幕之外或者命中敌人时都要及时回收。飞出屏幕的判断可以用 OnBecameInvisible但这依赖 Collider更稳的做法是在子弹脚本里记录存活时间超时后主动回池。注意回池的时候要取消子弹身上的协程和 Invoke否则一个已经被淡出屏幕的子弹仍然在跑协程数量一多同样会卡。2.3 手感三件套后坐力、弹道偏移、命中反馈很多新手觉得射击手感是玄学其实手感是可以被拆解、被实现的。Metal Black OPS 这类俯视角射击手感主要由三件套构成后坐力、弹道偏移、命中反馈。这三个如果只做其中一个手感会非常单薄合在一起才会有开枪有反应、中弹有反馈的完整体验。后坐力在 2D 射击里并不是真的推动角色后退——俯视角下角色被枪击退会很奇怪那是击退效果不是后坐力。更常见的做法是给枪口一个短暂的随机偏移让连续射击时准星轻微上跳或抖动松开枪后慢慢恢复。实现上可以维护一个 float 类型的 recoil 值每次开火叠加每帧乘以一个衰减系数最后叠加到瞄准方向或枪口旋转角度上private float recoil; public void Fire() { recoil 0.05f; // 开火逻辑... } private void Update() { recoil Mathf.Lerp(recoil, 0f, Time.deltaTime * 8f); gunTransform.rotation Quaternion.Euler(0, 0, baseAngle Random.Range(-recoil, recoil)); }弹道偏移则是给子弹的飞行方向加一个随机扰动。很多游戏为了让武器显得有差异化会故意给冲锋枪类武器加较大的散布角给狙击枪加很小的散布角。实现上开枪时在原来的瞄准方向上加一个随机角度然后通过 Quaternion 的旋转矩阵或者 Rotate 方法转一下Vector2 dir (aimPos - firePoint.position).normalized; float spread Random.Range(-spreadAngle, spreadAngle); Vector2 bulletDir Quaternion.Euler(0, 0, spread) * dir;命中反馈包含的东西更多敌人受伤时 Sprite 闪白、血条掉血、伤害数字飘出、命中音效、屏幕震动。闪白最简单的实现是给敌人的 SpriteRenderer 挂一个 HitFlash 脚本逻辑是受伤时把 Material 颜色调成白色或者切换成一个纯白 Sprite然后延迟几帧恢复原色。屏幕震动则可以给摄像机挂一个脚本记录原始位置震动时用 Perlin 噪声或者随机数偏移。这些反馈单独看都不复杂但缺了任何一样玩家打中敌人的爽感都会打折扣。这也是这套源码最能体现游戏感的地方。3. 敌人生成与行为逻辑从会动的靶子到像样的对手3.1 敌人状态机别在 Update 里堆 if敌人 AI 是源码项目里最容易写乱的地方。很多新手会把所有逻辑都堆在 Update 里结果代码看起来像这样如果距离大于多少就走向玩家如果距离小于多少就攻击如果血量低于多少就逃跑。逻辑一多各种 if 套 if后面想加一个新行为根本无处下手。Metal Black OPS 这类项目里敌人的行为逻辑大概率会用状态机FSM来组织核心状态就几个空闲Idle、巡逻Patrol、追击Chase、攻击Attack。状态机的好处是每个状态下只处理自己该做的事状态之间的切换由明确的条件触发而不是让 Update 里所有判断同步执行。一种很经典的实现方式就是 switchpublic enum EnemyState { Idle, Patrol, Chase, Attack } private EnemyState currentState; private void Update() { switch (currentState) { case EnemyState.Idle: // 检测玩家进入视野范围后切换为追击 if (PlayerInRange(chaseRange)) ChangeState(EnemyState.Chase); break; case EnemyState.Patrol: Patrol(); if (PlayerInRange(chaseRange)) ChangeState(EnemyState.Chase); break; case EnemyState.Chase: Chase(); if (PlayerInRange(attackRange)) ChangeState(EnemyState.Attack); break; case EnemyState.Attack: Attack(); if (!PlayerInRange(attackRange)) ChangeState(EnemyState.Chase); break; } }这段代码的核心是 ChangeState 方法它负责在切换状态时执行一些初始化工作比如离开 Chase 状态时停止移动进入 Attack 状态时播放攻击动画。很多源码项目在状态切换时不注意做退出动作导致敌人攻击完不会停下追击、巡逻完不会改变方向行为看起来非常僵硬。读这套源码的时候我建议你先在纸上把敌人的状态图画出来然后在每个状态对应的代码段上做标记最后再看状态之间的切换条件。这样一遍下来一个敌人 AI 的整个生命周期就清楚了。如果你以后想做得更进阶一点可以把状态机做成一个通用的 FSM 基类把每个状态拆成独立的类这样新增一个狂暴状态只需要新写一个类完全不用改原有代码。3.2 2D 寻路与遮挡排序俯视角游戏躲不开的两座山2D 俯视角游戏里敌人 AI 要面对两个很现实的难题一个是找路一个是遮挡排序。找路方案有好几档。最省事也最常见的是直线冲向玩家前提是场景足够开阔没有太多阻挡物。稍微复杂一点的是用 Unity 自带的 NavMesh2D先给场景里的障碍物加上碰撞体再烘焙 NavMesh敌人挂一个 NavMeshAgent2D就能自动绕着障碍物走。但 NavMesh2D 在一些窄通道或动态生成的场景里表现不太稳定有时候会卡在墙角需要额外做避障处理。Metal Black OPS 这种偏街机风格的射击游戏多数敌人可能就是直线追踪加碰撞体推开不太追求寻路的最优性。遮挡排序是一个很多人第一次做俯视角 2D 时完全想不到的问题。想象一下一棵大树挡在角色面前如果树的 SpriteRender 渲染顺序永远优先那角色站在树后面会被大树盖住看起来就像角色走进了树丛里非常出戏。正确的做法是让角色和场景物体之间根据 Y 坐标决定谁盖住谁——Y 坐标越小的越靠屏幕顶端的应该被越靠屏幕下方的角色覆盖。在 Unity 2D 里这个可以用 Sprite Sorting Order 手动控制也可以动态设置。动态排序的经典做法是在角色的 SpriteRenderer 上写一个脚本每帧把自己和周围场景物体的 Y 坐标对比然后更新 SortingOrder。Cinemachine 相机和 URP 里也可以用 SortingGroup 组件统一管理。这部分源码项目经常会忽略因为跑 Demo 的时候场景简单看不出问题。但你一旦把地图做复杂不做遮挡排序整个画面就是一场灾难。3.3 波次刷怪的简单实现列表驱动加距离检查波次刷怪是这类塔防/射击游戏里非常核心的玩法引擎。它的实现思路其实很统一每一波配置一个敌人列表或者数量规则当前波的所有敌人被清空后进入下一波。最简单的波次管理可以写成协程private IEnumerator SpawnWave(int waveIndex) { int count 3 waveIndex * 2; for (int i 0; i count; i) { SpawnEnemy(spawnPoints[Random.Range(0, spawnPoints.Length)]); yield return new WaitForSeconds(0.5f); } }这个写法放在原型阶段完全够用。但如果你把它改成正式版本有几个坑要踩。第一协程不要放在敌人身上要放在场景里独立的 WaveManager 上否则敌人死亡时协程可能被中断。第二刷怪点要和玩家保持距离。如果刷怪点离玩家太近玩家站在附近时下一波敌人直接刷新在脸上体验非常差。解决方式是在刷怪之前检测刷怪点到玩家位置的距离小于安全距离就换一个刷怪点。第三也是最容易遗漏的波次之间的状态判定。敌人存活数量怎么统计最简单的是用一个 List 保存存活的敌人每次敌人死亡时把自己从列表里移除列表为空时触发下一波。但实际游戏里还要处理敌人被销毁了却忘了从列表移除导致永远进不了下一波的问题。稳妥的办法是每帧查询场上所有激活状态的敌人数量或者用事件回调在敌人死亡时通知 WaveManager。读这套源码时波次系统是最容易理解的模块因为它和游戏循环深度绑定跟着它就能顺藤摸瓜找到敌人、掉落、UI、GameOver 的所有入口。我强烈建议你把波次刷怪逻辑当成读源码的线索从 SpawnWave 这个入口开始往各个方向追代码引用比从角色控制器开始读要清晰得多。4. 血条与 UI 反馈源码里最值得抄的模块4.1 敌我血条的两种方案WorldSpace 与 ScreenSpace 的取舍血条是unity2d 血条这个关键词下大家最关心的问题也确实值得单独拿出来讲。2D 游戏里血条到底怎么挂主流的方案就两种各有适用场景。方案 AWorldSpace Canvas挂在角色/敌人预制体下面当子物体。Canvas 的 Render Mode 选 World Space血条在 3D 世界里占据一个位置随着角色移动而移动。它的优点很明显不用每帧手动换算坐标血条天然跟随角色而且多个角色各自拥有独立血条互不干扰。缺点也明显血条的层级遮挡排序和多角色同时显示时的管理会麻烦一些而且它必须始终朝向相机否则你转到侧面血条就看不见了。方案 BScreenSpace Canvas。血条放在 UI 层由脚本每帧把角色的世界坐标转换成屏幕坐标再设置血条的位置。优点是所有血条都在 UI 层层级完全可控做排序方便而且和分辨率适配更好。缺点是在敌人很多的时候每帧对每个敌人都做一次 WorldToScreenPoint性能开销不小。我把两种方案放在一起对比一下对比项WorldSpace CanvasScreenSpace Canvas实现难度低挂在预制体下即可中需要每帧坐标转换层级控制依赖 SortingOrder 和 SortGroupUI 层级天然可控性能较好无每帧坐标转换敌人多时开销较高适配缩放需要手动调整世界尺寸自动适配屏幕适用场景战斗激烈、敌人数量多的关卡UI 密集、需要精确排列的菜单Metal Black OPS 这种敌人数量多、战斗频繁的场景用 WorldSpace Canvas 更合理因为每个敌人身上附带一个独立血条不需要额外集中管理。如果你自己做一个 BOSS 战BOSS 血条要固定在屏幕下方那用 ScreenSpace 更合适。两种方案没有绝对的好坏只有适不适合当前场景。4.2 血条怎么跟人走从预制体层级到坐标转换如果你选 WorldSpace那血条预制体应该是这样的结构敌人的 GameObject 下面挂一个 CanvasRender Mode 为 World SpaceCanvas 下面挂一个 Image背景再挂一个 Image填充。Canvas 的缩放要手动调整因为 World Space Canvas 默认尺寸单位是 Unity 单位不是像素如果你直接用默认 RectTransform 尺寸血条可能会巨大无比。我习惯把 Canvas 的 RectTransform 尺寸设为 1x1然后通过调整 Scale 来控制血条在世界里的显示大小。为了让血条朝向相机需要给 Canvas 挂一个 BillBoard 脚本private void LateUpdate() { if (Camera.main ! null) { transform.rotation Camera.main.transform.rotation; } }注意这个脚本要在 LateUpdate 里执行而不是 Update。原因很简单Update 里角色的移动和相机的移动都还没完成如果在 Update 里做朝向修正可能会和下一帧的相机位置产生几帧的延迟感LateUpdate 在所有 Update 都执行完之后再跑能保证血条朝向用的是最新帧的相机旋转视觉上不会有滞后。如果你用 ScreenSpace 方案核心代码就是一行坐标转换Vector3 screenPos Camera.main.WorldToScreenPoint(enemy.headPoint.position); healthBar.rectTransform.position screenPos new Vector3(0f, 30f, 0f);这里的 headPoint 是敌人预制体上专门放置的一个空物体用来标记血条应该显示的位置比如敌人的头顶或者胸口上方。用一个独立的挂点来控制血条位置比直接拿角色的 Transform 的 position 要灵活得多。你可以调整挂点的高度决定血条是显示在头顶还是腰上完全不用改代码。4.3 血条本体怎么画填充方式与颜色渐变血条本体按我的经验最少需要两个 Image一个当背景一个当填充。背景通常是深色半透明填充用红色或者绿色上面可以再叠加边框。核心的填充逻辑有两种做法。第一种是设置 Image 的 Image.type Image.Type.Filled然后控制 fillAmount。这种方法非常简单血条会以从左到右、从下到上、环形等方式被填充或剥离。2D 血条用 Filled 非常方便因为它天然支持从左往右缩水的效果public void SetHealth(float current, float max) { fill.fillAmount current / max; }第二种是直接控制填充 Image 的 RectTransform 宽度。这种做法的优势是血条边缘的纹理可以保持不拉伸适合做纯像素风格或需要精确控制边缘样式的血条。实现上就是把 width 设为当前血量比例乘以最大宽度。两种方案实际使用差别不大我建议新手直接用 Filled简单不容易出错。等你需要做复杂血条比如分段血条、护盾血条时再研究宽度控制。颜色渐变也是血条里非常常见的一个细节满血时绿色半血时黄色低血时红色。实现上可以在 SetHealth 里根据 current / max 的比例动态改颜色private void UpdateColor(float healthPercent) { if (healthPercent 0.5f) fill.color Color.Lerp(Color.yellow, Color.green, (healthPercent - 0.5f) * 2f); else fill.color Color.Lerp(Color.red, Color.yellow, healthPercent * 2f); }这个渐变效果能让玩家不用看具体数字只扫一眼血条颜色就知道当前血量状况是游戏 UI 设计中很有效的即时反馈手段。4.4 平滑掉血与伤害飘字让人一眼看出掉了多少血现代一点的游戏血条不会直接啪一下掉到底而是会有一种延迟掉血的效果受击的瞬间血条先掉到一个中间位置通常是一个白色的薄层然后红色/绿色再缓慢追下来。这样做的好处是玩家能直观地看到这次攻击扣了多少血而不是只能看到一个最终结果。实现这个效果需要两个填充层。一个叫 immediateFill受影响瞬间立刻更新一个叫 delayedFill用 Lerp 慢慢追向目标值。受击时immediateFill 直接设置为当前血量delayedFill 用一个协程或者 Update 里的 Lerp 慢慢逼近。这个效果的慢要控制好太快看不出延迟太慢会挡住下一波攻击的反馈。我习惯用 0.3 到 0.5 秒左右完成追赶。伤害飘字是另一个非常实用的 UI 反馈。实现思路不复杂实例化一个 Text或者 TextMeshPro 的 TextMesh让它从角色受伤位置向上飘并逐渐透明大概持续 0.8 秒后销毁或回池。里面有两个细节值得注意。第一飘字的方向要加一个随机偏移否则所有伤害数字会重叠在一起。用 Random.insideUnitCircle 生成一个偏移量再设置给 Text 的起始位置。第二飘字的动画如果用协程实现注意在 Text 回池的时候要停止协程不然会泄漏。伤害飘字同样推荐用对象池管理因为高射速武器打中敌人时飘字生成频率极高Instantiate 和 Destroy 会拖慢帧率。5. 状态管理与关卡流程光有战斗还不够5.1 全局状态一个 GameManager 管住所有重要数据战斗系统做完之后你会发现整个游戏还存在一些跨系统的数据比如游戏当前处于什么阶段菜单、游玩、暂停、GameOver、玩家当前血量、当前波数、累计分数。这些数据如果分散在各个系统里各管各的状态判断会变得很混乱。所以 Metal Black OPS 这类项目里几乎一定会有一个 GameManager 作为单例来统管全局状态。单例的经典写法是public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public enum GameState { Menu, Playing, Paused, GameOver } public GameState CurrentState { get; private set; } private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } }Awake 里的判断是单例的标配如果 Instance 已经存在说明场景里已经有了一个 GameManager新生成的把自己销毁避免因为场景切换而生成重复对象。DontDestroyOnLoad 是保证 GameManager 在场景切换时不被销毁。GameManager 通常还负责分发一些全局事件比如进入游戏结束状态时通知 UI 弹结算画面、通知敌人停止攻击、通知音频切换背景音乐。这些通过简单的事件回调就能实现不需要复杂框架。5.2 事件解耦什么时候该用事件什么时候不该用源码项目里一个常见的坏味道是到处引用。玩家掉血了玩家脚本直接去调用 UIManager 的血条脚本再去调用 AudioManager 播放音效再去调用 CameraController 做震动。这些调用之间的耦合会随着功能增加越来越紧最后改一个血量显示逻辑可能要连带改十几个脚本。更好的办法是引入事件机制。定义事件事件的发起者只负责广播不关心谁在听事件的订阅者只负责响应不关心谁触发的。以玩家血量为例子public static event System.Actionint, int OnPlayerHealthChanged; // current, max // 发起者玩家脚本 private void TakeDamage(int amount) { currentHealth - amount; OnPlayerHealthChanged?.Invoke(currentHealth, maxHealth); } // 订阅者UI 血条脚本 private void OnEnable() { Player.OnPlayerHealthChanged UpdateHealthBar; } private void OnDisable() { Player.OnPlayerHealthChanged - UpdateHealthBar; }事件解耦的好处是UI 脚本不需要知道伤害是谁打的只需要知道玩家血量发生了变化这个事实然后去更新自己的显示。但是事件也不是越多越好。如果某个事件只在一个地方触发、一个地方监听那引入事件反而增加了阅读负担。我的习惯是如果功能之间是1 对 1的强关系直接调用反而更清晰如果是1 对 多或者无法确定未来会有多少监听者才用事件。比如玩家死亡可能有 UI、音频、关卡、成就系统都要响应这种场景事件就是正确的选择。5.3 游戏结束与重开流程最容易出 Bug 的环节重开流程做不好是源码项目里最常见的问题。你打完一局点击重新开始结果发现上一局的敌人还在场上跑血条还挂在头顶波次倒计时还在走——这些都是状态没有重置干净导致的。正确的重开流程应该是这样的先把场上的所有敌人全部回收回池或销毁清空所有子弹重置玩家位置、血量、弹药重置波次计数器最后再把游戏状态从 GameOver 改回 Playing。其中最容易遗漏的是协程的清理。如果你用协程做波次倒计时游戏结束时协程并没有被停止它还在后台继续跑等你点重开时它可能已经跑到第 N 波了。解决方式是在 GameManager 里维护一个协程引用游戏结束时停止它或者在 WaveManager 里加一个 StopAllCoroutines 的处理。还有血条的问题。上一局敌人的血条如果挂在 Canvas 上且没有被回收即使敌人被销毁了血条也可能残留在 UI 层。很多项目在这方面马马虎虎都是进行到重开这一步时才暴露问题。所以如果你在读这套源码建议专门测试一次战斗进入尾声快速点击重新开始这个场景看有没有资源残留。这个测试往往能帮你发现自己代码里隐藏的状态管理 bug。6. 改造成自己的游戏这套源码的扩展路线6.1 不是所有代码都要留着先换美术再验证逻辑拿到 Metal Black OPS 这类源码最大的诱惑是直接拿它的素材和玩法改个名字当自己的作品发布。我不建议这么做风险高而且对你自己的能力提升没有任何帮助。我更推荐的做法是把它当脚手架逐个模块替换成自己的东西。第一步先替换玩家和敌人的美术素材看看游戏逻辑在你自己的美术资源下能不能正常工作。如果替换之后出现位移、大小、层级等问题说明原来的代码里可能硬编码了一些和资源尺寸相关的数值需要通过这次替换把这些问题暴露出来并修正。替换美术是一个特别好的验证理解的方式。你没有改任何逻辑代码只替换了素材如果游戏还能正常玩说明你对逻辑的理解是对的如果出现异常说明你对某个系统的理解还不到位顺藤摸瓜去查就能把盲区补上。这一步做完你再去改玩法逻辑、加新功能就会顺畅得多。6.2 性能优化优先级从 DrawCall 到对象池跑通之后很多人会关心怎么让游戏更流畅。我建议按优先级来做优化而不是凭感觉乱改一通。第一优先级是 DrawCall。2D 游戏里 DrawCall 过高的主要原因是 UI 元素和 Sprite 使用了太多不同的图集。解决办法是给美术资源打图集Sprite Atlas让同一批次使用的图片尽量来自同一张图集减少 GPU 的批次切换。第二优先级是对象池。这前面已经写过玩家子弹、敌弹、伤害飘字、敌人本体都应该用对象池管理。第三优先级是粒子系统。粒子的数量对移动端影响很大你可以检查一下场景里同时激活的粒子系统数量每个粒子的寿命和最大数量。第四是物理组件。Rigidbody2D 的数量越多物理引擎的负担越大。如果你的游戏没有用到复杂碰撞可以考虑用更轻量的自定义碰撞检测。我把这些优化项放在一起方便你对照自己项目排查优化项优先级怎么做DrawCall高打图集、合并材质、控制 UI 重绘对象池高子弹、敌人、飘字、音效统一池化粒子系统中限制粒子数量、缩短粒子寿命物理组件中减少 Rigidbody2D 数量、简化碰撞体每帧查找低缓存 Camera.main、避免 FindObjectOfType6.3 读源码的正确顺序我的个人习惯最后聊聊怎么高效读这类源码项目。我见过很多人拿着源码第一件事就是翻代码从第一个脚本看到最后一个看完忘光等于没看。我自己的习惯是四步走。第一步跑起来。把项目跑起来正常玩一遍观察一遍完整流程开场、战斗、击杀、波次、受伤、死亡、重开。这一遍的目的是建立功能清单。第二步从 GameManager 读状态流转。找到 GameManager看它管理了哪些状态和数据把状态切换的时机记录下来比如什么时候从 Playing 切换到 GameOver。第三步挑一个完整闭环追代码。我推荐从玩家开火这个事件入手玩家脚本怎么触发的开火子弹怎么生成的子弹命中后调用谁敌人怎么扣血血条 UI 怎么监听到并更新最后一环扣一环把整条链路走通。这个闭环追完你对整个项目的理解能超过百分之八十的人。第四步改需求验证理解。比如把玩家的移动速度提高一倍看看会不会影响敌人的 AI 判断把敌人的血量翻倍看看关卡节奏会不会变化。改需求不是目的通过改需求把代码之间的逻辑关系理清楚才是目的。最后再补充一点我自己的体会Metal Black OPS 这套源码给我最大的启发不是某一种悬空的技术而是完整战斗循环这件事的价值。一个人能不能独立做出游戏往往不取决于他会不会写某个功能而取决于他能不能把几十个小功能串成一个循环让玩家在操作时感觉不到系统之间的断裂。血条、子弹、敌人、波次、UI单独拎出来每一个都不难难的是把它们组合在一起时既能保证性能又能保证反馈的即时性。这也是我觉得这类完整源码项目比零散教程更值得研究的根本原因。你如果手里也有一套类似的项目源码先别急着重写试着按我上面说的顺序走一遍把它的生命周期读明白把每一个系统摘出来改造成你自己的东西。时间花在这种拆解上比照着视频抄一百遍代码都值。本文还有配套的精品资源点击获取
返回列表