Unity射击游戏子弹命中加分系统设计与实战解析

发布时间:2026/10/8 11:58:37
Unity射击游戏子弹命中加分系统设计与实战解析 子弹射击物体加分听起来就是个不起眼的小功能但真把它当回事去仔细设计你会发现这其实是游戏开发里一套完整的玩法闭环微缩版发射端要解决子弹从哪出来、怎么飞命中阶段要解决怎么判定打中了、打中了谁加分阶段要解决加多少、加给谁、界面怎么反馈最后还要考虑被打中的物体应该消失、弹出特效还是原地复用。一串处理下来牵扯的是对象池、碰撞检测、事件分发和 UI 刷新这些平时写业务代码根本不会碰的东西。就算你不是第一次做游戏这个课题也值得静下心重做一次。这篇文章就是我站在实际做这个功能的视角把前后趟过的坑、理清的方案、能直接抄的代码都整理出来。适合正在做 2D 射击、打靶玩法的同学也适合刚学 Unity 想找一个小而完整的练手目标的人。你会看到的不只是写个碰撞然后加分数这种三行代码而是这背后一套能扩展到任何射击玩法的设计思路。1. 为什么说子弹打物体加分是个微缩实战1.1 一个加分功能牵扯几条技术线很多新手第一次接触子弹射击物体加分脑子里冒出来的方案是给子弹加个碰撞检测碰到目标后调用一个加分数的方法再让目标消失。逻辑确实没错但这只是功能能跑的最小路径。真要把这个功能做得能在真实项目里站稳你得同时考虑四条技术线。发射端不能每次开火都 new 一个子弹出来然后等它落地 Destroy因为你很快就会发现频繁的实例化和销毁会把 GC 压得很难看。飞行中的子弹速度达到一定程度时默认物理检测会漏检目标会被直接穿过去。命中判定是给子弹挂 OnCollisionEnter 还是 OnTriggerEnter决定了你后面做穿透、做反弹、做爆炸溅射时是顺手还是难受。加分之后 UI 怎么刷新是每帧轮询分数还是由事件驱动触发更新。这些点单独拎出来都不算难但组合到一起它们会逼着你把一个小小的加分功能当作一个完整系统来思考。我为什么强调这是一个微缩实战因为你在做正式项目时射击命中加分这个链路往往还会叠加更多需求不同部位不同伤害、暴击翻倍、连击计数、命中后飘字、特效回收、音效播放、关卡目标累计……如果你在最开始的那版实现里没有留出扩展空间后面每一个新需求都会变成一段硬塞的代码。1.2 我最初是怎么搞砸的我第一次实现这个功能时偷懒走了最简单的路子弹是一颗普通的 GameObject飞行靠 Update 里移动位置碰撞用 OnTriggerEnter检测到目标之后直接调 ScoreManager 里的静态方法加分然后 Destroy 目标和子弹。当时只测了 能加分、能消失觉得没问题就交差了。结果项目联调的时候问题集中爆发了。子弹速度一调快目标偶尔被穿过连续开火十几秒之后游戏明显掉帧因为子弹和破坏碎片全在 Instantiate/Destroy更离谱的是同一个目标有时候会被计算两次分数因为我给目标挂了两个碰撞体Trigger 回调触发了两次。那时候我才意识到这个不起眼的小功能完全没有我想象的那么简单。从此以后我把这类功能的实现顺序固定为先做方案选型再写代码。后面的内容基本就是我那套方案的完整复盘。2. 动手前先定方案子弹、目标、得分2.1 子弹怎么飞自驱移动还是物理施力子弹的运动方式本质上是两种。一种是自驱移动子弹不依赖物理系统自己在 Update 或者 FixedUpdate 里朝着某个方向移动适合表现简单、高速、直来直去的子弹比如弹幕游戏里的子弹出膛用 transform.Translate 或者给 Rigidbody 的 velocity 赋值稳定可控不受重力干扰。另一种是把初速度交给物理引擎让刚体自己飞适合需要受重力、反弹、然后弹跳的投射物比如飞刀、手雷、抛物线弹道。对于打物体加分这个最基础的场景我推荐用自驱移动配合触发器检测。原因为自驱移动的子弹速度是你显式控制的不会因为物理材质或场景中的其他刚体碰撞而产生不可控的偏移。你需要关心的一个关键参数是速度。Unity 刚体在速度特别高的时候如果用的是默认的 Discrete 碰撞检测模式子弹会在某一帧里直接跳到目标背后因为这种方式只在固定间隔做一次离散检测不会考虑这一帧内物体从位置 A 移动到位置 B 的整条路径。我在实际项目里遇到过 30 个单位每秒的子弹就会开始偶尔漏检如果目标碰撞体又比较小漏检概率会急剧上升。所以做高速子弹时有两条路一是把 Rigidbody2D 的 Collision Detection 改成 Continuous让物理引擎做连续碰撞检测二是放弃依靠物理回调直接用 Physics2D.Raycast 沿着子弹上的运动方向做射线扫描。前者实现简单后者在高帧率、多子弹、复杂场景下性能更可控。我个人做打靶类玩法时更常用射线检测因为无论子弹速度多快只要射线发射的那一帧目标在路径上就能命中。2.2 碰撞检测用触发器还是碰撞体Unity 里有两套物理触碰回调OnCollisionEnter 和 OnTriggerEnter。它们的核心区别一句话就能讲清楚OnCollisionEnter 是在两个刚体发生物理接触后触发物理引擎会计算碰撞响应把它们推开OnTriggerEnter 只告诉你两个物体相交了并不影响两者的运动轨迹。对射击物体加分这种需求我建议用触发器而不是碰撞体。因为大多数情况下你希望子弹穿过目标后继续飞行或者子弹击中目标后只让目标消失子弹本身不受阻挡。如果用 Collision 模式还得额外处理物理材质、碰撞反弹、刚体间的受力影响这些对加分类玩法来说完全是干扰项。反过来你需要子弹被目标弹开、卡进木箱或者把目标撞飞时Collision 模式才是合适的。我在做一个小游戏时最初把子弹设置成 Collision 模式结果子弹每次打到靶子上都会被弹开靶子也被撞得乱飞后来改成 Trigger 才得到了想要的效果。还有一个非常容易忽略的点碰撞层Layer和碰撞矩阵。不要图省事让所有物体都在 Default 层互相检测否则子弹会打到背景、打到玩家、打到场景里的无关物体。我会单独建两个层一个叫 Bullet一个叫 Target然后在 Edit Project Settings Physics 2D 的 Layer Collision Matrix 里只勾选 Bullet 和 Target 之间的碰撞。这既能避免误检也能减少物理引擎的无效计算一举两得。2.3 得分事件怎么发最直接的做法是目标被打中时拿着 ScoreManager 的引用直接调用 AddScore。这样写三行代码就完事但场景一复杂你会发现计分 UI 的引用散落在各个目标里改一次 UI 路径要满世界找而且 ScoreManager 万一没加载空引用崩溃得莫名其妙。更好的做法是让目标在命中时不关心谁要加分只发出一个计分事件谁监听谁负责。放在 Unity 里最常见的实现是 C# event 或者 Action放在 Godot 里就是 signal。事件模式的好处是目标物件和计分系统彻底解耦。目标物体可以只专注于我是靶子、我的分值是多少、我被打中后要我自己的死亡动画而计分系统只专注于收到一条消息、更新分数数值、刷新 UI。以后加成就、加排行榜、加数据统计都是在事件监听端新增一个订阅者的事目标物体的代码完全不需要改动。这是我在踩过满世界找 ScoreManager的坑之后最坚定的一个设计选择。3. Unity 下的完整实现能直接抄的那套方案3.1 子弹预制体与对象池搭建先做子弹预制体。我习惯在一个空的 Bullet Prefab 上挂 SpriteRenderer子弹的视觉、Rigidbody2D重力缩放设为 0、BoxCollider2D勾选 Is Trigger、以及一个 Bullet 脚本。因为用的是 TriggerRigidbody2D 只需要用来参与触发器检测不需要让它真的被物理引擎推着走。对象池的代码我直接贴出来这个是比较规整的版本using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public static BulletPool Instance; [Header(配置)] public GameObject bulletPrefab; public int initSize 30; private readonly QueueGameObject availableBullets new QueueGameObject(); private void Awake() { Instance this; InitializePool(); } private void InitializePool() { for (int i 0; i initSize; i) { GameObject bullet CreateBullet(); bullet.SetActive(false); availableBullets.Enqueue(bullet); } } private GameObject CreateBullet() { GameObject bullet Instantiate(bulletPrefab); bullet.transform.SetParent(transform); return bullet; } public GameObject GetBullet() { if (availableBullets.Count 0) { GameObject bullet availableBullets.Dequeue(); bullet.SetActive(true); return bullet; } // 池子不够时动态补一个避免极端情况导致子弹无法生成 return CreateBullet(); } public void ReleaseBullet(GameObject bullet) { if (!bullet.activeSelf) return; bullet.SetActive(false); bullet.transform.SetParent(transform); availableBullets.Enqueue(bullet); } }注意这个实现里我刻意避开了很多新手会犯的一个错从池里取出子弹后不要立刻把它放回队尾否则同一个子弹可能又被取走一次。正确做法是借出和归还两个动作彻底分开归还时入队借出时出队队列只在 Reserve 阶段被填充。子弹在飞行过程中如果需要回收就在命中逻辑里调用 ReleaseBullet。3.2 子弹脚本与命中接口子弹脚本的核心动作很清晰被激活后持续向前移动检测到目标后调用目标身上的接口。为了避免重复触发我还会在命中后主动关闭碰撞器然后交给对象池回收。using UnityEngine; public class Bullet : MonoBehaviour { [Header(飞行参数)] public float moveSpeed 30f; public int damage 1; private Rigidbody2D rb; private Collider2D col; private void Awake() { rb GetComponentRigidbody2D(); col GetComponentCollider2D(); } private void OnEnable() { // 每次从池子里取出来时重置状态避免上一次的残留数据 rb.velocity Vector2.zero; col.enabled true; } private void Update() { // 速度足够高时,用射线检测代替触发器,防止穿模 Vector2 origin transform.position; Vector2 direction transform.up; RaycastHit2D hit Physics2D.Raycast(origin, direction, moveSpeed * Time.deltaTime, 1 LayerMask.NameToLayer(Target)); if (hit.collider ! null) { OnHitTarget(hit.collider); return; } // 没检测到目标时再移动 transform.Translate(direction * moveSpeed * Time.deltaTime, Space.World); } private void OnHitTarget(Collider2D target) { var targetComp target.GetComponentITarget(); if (targetComp ! null) { targetComp.OnHit(damage); } col.enabled false; BulletPool.Instance.ReleaseBullet(gameObject); } }这里有个细节值得专门说为什么用 transform.up 作为子弹方向因为这样子弹预制体的 Sprite 默认朝上摆放的话旋转子弹物体的角度就能直接控制发射方向做旋转射击时非常方便。3.3 目标物体的可被打接口目标物体为什么要用一个接口 ITarget因为你在后续扩展中会有很多可以被子弹打的东西靶子、敌人、木箱、花瓶、甚至一堵可以打碎的墙。它们被打中之后的处理逻辑各不相同但都对外提供一个统一的 OnHit 入口这样子弹脚本就完全不需要关心自己打中的到底是什么东西。public interface ITarget { void OnHit(int damage); }接着做目标物体的实现。这里我把加分数和播放特效拆开处理兼顾了对象池复用和状态重置using UnityEngine; public class Target : MonoBehaviour, ITarget { [Header(分值配置)] public int scoreValue 10; private bool isHit; // 假设这个 Target 也走对象池或者只是普通物体 private void OnEnable() { isHit false; } public void OnHit(int damage) { if (isHit) return; isHit true; // 通知计分系统加分 GameEvents.RaiseScoreAdded(scoreValue); // 播放命中特效、音效等表现,这里用协程做个延迟再回收 // 伪代码: EffectManager.Instance.PlayHitFX(transform.position); gameObject.SetActive(false); } }isHit 这个状态锁必须加原因我说过如果目标身上有多个碰撞体或者你用射线检测叠加了碰撞回调同一个目标可能在同一帧里被同一颗子弹命中两次。没有这个锁分数会翻倍。我记得一个真实项目里就因为目标背后挂了个小碰撞体没注意每次命中都加了两倍分排查了一个多小时才想起来看回调次数。3.4 计分系统与 UI 刷新计分系统用静态事件来接收加分消息再用 UI 层订阅刷新。之所以不直接用静态方法调 UI是为了让计分核心逻辑完全不依赖任何具体 UI 组件。using System; using UnityEngine; public static class GameEvents { public static event Actionint OnScoreAdded; public static void RaiseScoreAdded(int amount) { OnScoreAdded?.Invoke(amount); } }using UnityEngine; using UnityEngine.UI; public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } [Header(UI 引用)] public Text scoreText; // 用 TextMeshProUGUI 也可以 private int totalScore; private void Awake() { Instance this; } private void OnEnable() { GameEvents.OnScoreAdded AddScore; } private void OnDisable() { GameEvents.OnScoreAdded - AddScore; } private void AddScore(int amount) { totalScore amount; if (scoreText ! null) { scoreText.text totalScore.ToString(); } } }这里的 UI 刷新方式值得再强调一句不要在 Update 里每帧把 totalScore 重新赋值给 Text因为这会对字符串和 UI 网格产生无谓的开销。正确的做法是只有分数变化的那一帧去更新文本。在桌面平台上这差别不明显但在手机上高频刷新 Text 是会造成可感知的发热和掉帧的。3.5 发射器与整体流程串联最后写一个简单的发射器脚本把子弹从枪口位置打出去再绑定到玩家角色或者场景中的某个物体上。using UnityEngine; public class Shooter : MonoBehaviour { [Header(发射配置)] public Transform muzzlePoint; public float fireInterval 0.15f; private float nextFireTime; private void Update() { if (Input.GetMouseButton(0) Time.time nextFireTime) { Fire(); nextFireTime Time.time fireInterval; } } private void Fire() { GameObject bullet BulletPool.Instance.GetBullet(); bullet.transform.position muzzlePoint.position; bullet.transform.rotation muzzlePoint.rotation; } }整个流程跑一遍玩家按鼠标左键Shooter 从池子里取一颗子弹放到枪口位置子弹激活后按自身方向做 Raycast 检测检测到目标就调用 ITarget.OnHit目标发出 GameEvents.OnScoreAdded 事件ScoreManager 累加分数并刷新 UI目标关闭自身子弹回到池子里等待下次使用。加上粒子特效和音效之后玩家就能看到完整的射击-命中-加分-消失闭环。4. 我踩过的问题与排查实录4.1 高频问题的排查表真实项目里遇到的坑远比教程里的复杂。我把最常见的几个问题整理成一个速查表每个都是我实际排查过的方向现象最可能的原因解决办法子弹偶尔穿透目标速度过快离散碰撞检测漏检用 Continuous 检测或改为 Raycast 逐帧扫描一个目标被加两次分多个碰撞体触发多次回调加 isHit 状态锁保证单次命中只处理一次分数刷新了但 UI 没变化引用的 Text 没绑定或绑定的是缓存副本检查 Inspector 引用避免运行时动态创建新的 Text 覆盖原引用开火一段时间后卡顿频繁 Instantiate/Destroy 造成 GC 压力改用对象池包括子弹和特效子弹把背景、玩家一起打了没有配置碰撞层矩阵为 Bullet/Target 单独建层只勾选互相碰撞目标消失后子弹还在飞命中后没有及时回收子弹命中后关闭碰撞器立即归还对象池4.2 最隐蔽的问题生命周期与事件泄漏这个问题的隐蔽程度排在我遇到过的所有 Bug 前列。如果你的战斗场景是通过加载场景循环切换的ScoreManager 的 OnEnable 里订阅了 GameEvents.OnScoreAdded但在旧场景卸载时没有取消订阅就会发生事件泄漏新场景的 ScoreManager 和旧场景的 ScoreManager 同时收到了加分事件UI 被刷了两次分数也加了两遍。我排查这类问题时的第一反应是看目标是不是被打了两次结果发现目标只 OnHit 了一次。后来在 ScoreManager 的 OnDisable 里打了日志才发现旧实例根本没有被正确清理。解决方式就是在 OnDisable 里加上反订阅保证订阅生命周期严格对称:private void OnDisable() { GameEvents.OnScoreAdded - AddScore; }还有一个和生命周期绑定的坑是子弹从池子里取出后如果忘了重置速度、伤害、碰撞体状态那么上一次命中后关闭的碰撞器会保持关闭导致子弹飞出屏幕仍然不触发任何东西。所以我在 Bullet.OnEnable 里重置所有状态。记住一句话对象池的对象必须保证取出来时和全新的一样否则就是拿一次踩一次坑。5. 从能用到好玩加分的进阶玩法5.1 加分反馈的节奏感一个加分数值的反馈链路远不只是数字从 0 变成 10。真正让玩家觉得打中了很爽的往往是数字变化之外的东西命中瞬间的屏幕震动、目标碎裂的粒子、简短的音效、以及一个在命中位置飘起来的 10 文字。这里面有一个很容易被忽视的规律加分数值进入计分面板这个动作和玩家看到的反馈是有时间差的。如果反馈太干只有计分面板数字变大玩家会完全无感如果反馈太慢飘字延迟出现手感又会拖沓。我会把反馈拆成两层瞬时反馈和延迟反馈。瞬时反馈包括音效、粒子、屏幕震动这些必须在命中发生的同一帧或下一帧内给出延迟反馈包括数字飘字从命中位置缓缓上升到计分面板附近这个过程大约持续 0.5 到 1 秒。两者叠加起来玩家才会对得分这件事产生情绪。说实话很多商业游戏里加分两个字看着简单背后全是这些反馈节奏的设计。5.2 衍生设计连击、爆头、飘字在这套基础结构上扩展并不难。比如加一个简单的连击系统在时间窗口内连续命中可以积累倍率打出的分数会越来越高。代码可以很轻量using UnityEngine; public class ComboSystem : MonoBehaviour { public float comboWindow 2f; private int comboCount; private float lastHitTime; public int CalculateScore(int baseScore) { if (Time.time - lastHitTime comboWindow) { comboCount; } else { comboCount 1; } lastHitTime Time.time; int multiplier Mathf.Clamp(comboCount, 1, 5); return baseScore * multiplier; } }然后在 Target.OnHit 里调用 ComboSystem.CalculateScore(scoreValue)把返回值传给 GameEvents.RaiseScoreAdded。这一套做下来玩家会为了保持连击而主动调整射击节奏玩法的策略性就出来了。爆头这类部位差异也很容易挂上去给不同碰撞体挂不同分值的 Target 或子组件例如头部碰撞体 20 分、身体碰撞体 5 分再配合同一套 ITarget 接口只需要在子物体的脚本里实现相同的 OnHit 即可。飘字系统同样可以做成一个 Pool 来管理命中位置 得分数值传入脚本飘字完自动回收。5.3 换引擎时这套思路怎么迁移这套射击命中加分的链路不局限于 Unity。我做过的 Godot 项目里几乎是一比一对应地迁移过去子弹用 Area2D RayCast2D信号代替 C# event目标物体挂一个可以被调用的方法。Godot 的 GDScript 写法会更短func _on_bullet_area_entered(area): if area.is_in_group(target): area.score_manager.add_score(value) area.queue_free()核心思想没有变发射端、检测端、目标端、计分端四个模块彼此通过规范化的接口通信而不是互相硬搓。你在 Unity 里想明白的每一层逻辑映射到其他引擎时都能复用。这说明你花时间把这个小功能当系统做一遍是非常划算的投入。我个人在实际操作中的体会是一个子弹射击物体加分的功能真正决定玩家体验的不是分数本身而是那条反馈链路。先把最小的循环跑通再把反馈补足最后才考虑连击和飘字这些锦上添花的东西。顺序反过来的话效果会很糟糕。还有一个小技巧调试阶段在碰撞回调和加分事件里多打几行日志把命中对象、分值、当前总分全部打印出来前期觉得啰嗦但排查问题的时候真能救命。这个内容后续其实还可以继续延伸比如给子弹加上穿透多个目标、给目标加上血量条、给不同的武器配不同的子弹预制体这些都是在同一套骨架上加肌肉的活了。而你现在手里这个加分系统就是那副骨架。