ARTICLE DETAIL

资讯详情

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

Unity射击游戏核心Demo:子弹打靶加分全流程实战

Unity射击游戏核心Demo:子弹打靶加分全流程实战 经常有新手问我想学做射击游戏第一课应该做什么我的建议永远是先做一个最简单的打靶Demo鼠标一点子弹飞出去打中目标屏幕上的分数跳一下。这个看似不起眼的“子弹射击物体加分”小循环其实是游戏开发里相当硬核的一课——输入处理、物理碰撞、对象生命周期、UI刷新全都被压缩在一段几十行代码里。这篇文章我不会给你讲花哨的框架而是实打实地带你把这条链路线路捋一遍。你不需要有项目经验只要会一点Unity基础操作照着下文一步步做就能得到一个可以玩的射击加分Demo。更重要的是我会把你以后一定踩得上的坑提前标出来包括子弹穿透、分数不刷新、对象池误回收这些典型问题。1. 项目拆解一个“子弹射击物体加分”到底包含多少事1.1 最终效果与需求定义先把这个项目做到什么程度说清楚避免边写边跑偏。我们要做的是一款固定视角或第一人称视角的打靶场景场景里有若干立方体靶子。玩家点击鼠标左键从枪口位置发射一颗带物理效果的子弹。子弹碰到靶子后靶子消失屏幕右上角的分数增加。子弹如果打空了飞出一段距离后也会自动消失。可以把它拆成几条用户可见的行为也就是所谓需求玩家处于一个简单的3D场景中可以看到靶子并瞄准。点击鼠标左键从枪口向准星方向发射一颗子弹。子弹在飞行过程中受物理碰撞控制看起来有真实速度。子弹击中靶子靶子消失加分播放反馈。子弹没有击中任何物体飞出场景或超过生命周期后自动回收。UI分数实时更新。你可能会觉得这些描述太基础了但真正落地的时候每一条都会对应一系列代码和场景配置。初学者最容易犯的错误就是一上来就写射击逻辑结果发现子弹没反应、加分没显示、UI不刷新最后不知道从哪排查。1.2 拆成哪些可独立验证的模块我习惯把整个功能拆成五块每一块都可以独立测试。发射模块负责监听鼠标输入生成子弹并设置子弹的初始方向和速度。没有这一块后面的所有东西都触发不了。子弹模块负责飞行、生命周期控制、碰撞后的回收。这块要防止子弹无限飞行也要保证碰撞后不要残留物理体。目标模块负责接收碰撞事件把“命中”翻译成具体的游戏行为比如加分、销毁或者播放音效。计分UI模块管理分数数据并驱动UI文本更新。性能优化模块用对象池管理子弹的创建和销毁避免运行时反复Instantiate/Destroy造成卡顿。这五块是层层依赖的UI只认分数管理器分数管理器只认目标模块目标模块只认子弹模块子弹模块由发射模块生成。如果你能清晰地把这些依赖关系说出来哪怕代码写得丑一点后面也容易维护和扩展。1.3 为什么这个小循环值得认真做很多人不理解这么一个小项目有什么好做的我常说几乎所有射击游戏的“核心战斗循环”都是同一个套路玩家输入产生伤害源伤害源碰到目标目标反馈结果界面刷新数据。你可以把子弹换成射线、魔法飞弹、近战挥砍把靶子换成敌人、木箱、掉落物把加分换成扣血、经验、金币——上层逻辑完全一样。你把这个最简单的加分循环吃透了后面接什么系统都能顺。比如做一个带血条的敌人本质就是在“命中后加分”的基础上加一层“血量判断”做一个移动靶本质是在目标模块里增加“位置随时间变化”的逻辑。所以这篇项目写得很简单但它是一个极其重要的起点。2. 场景搭建与核心技术选型2.1 引擎选择与项目参数我以Unity 2021.3 LTS为例3D模板内置渲染管线。为什么不用URP或HDRP因为在这个阶段我们不做美术表现URP反而多一层混淆。当然你用Unity 6也没问题逻辑基本都是通用的。创建项目的步骤就不细写了只是提醒一件事项目名最好别叫默认的“New Project”哪怕叫ShootTargetDemo也行。项目名本身不会影响代码但养成“见名知义”的习惯对以后玩大型项目很重要。打开场景后第一件事是把默认的平行光和摄像机都用上再补一个地板。创建Plane作为地板Scale设为(5,1,10)左右或者干脆用默认大小只要能挡子弹就行。创建几个Cube作为靶子间隔摆放例如X轴方向从-2到2Position的Y设0.5到1左右避免与地板重叠。Main Camera放在(0, 1.8, -5)Rotation设(0,0,0)然后让它看向靶子区域。创建一个空物体叫Muzzle放在摄像机前方偏右一点比如(0.4, 1.4, -4)当作枪口。这里有一个关键认知枪口不是一个模型而是一个世界空间的点。你的子弹从这个点的位置生成可以理解为“发射起点”。2.2 场景摆放细节别小看场景摆放它直接影响你后面调试的体验。靶子太远子弹飞行半天你几乎看不出碰撞是否有效太近子弹一出生就穿到靶子背后产生穿模误判。我常用的尺度是靶子离摄像机大约8到12米。子弹初始速度在20到40米/秒之间这样一颗子弹从枪口飞到靶子明显需要零点几秒你既能看清飞行路径又不会等待太久。靶子之间的间隔至少1.5米方便你区分哪个被击中了。如果你用的Cube默认Scale是(1,1,1)建议把它压扁一点变成(0.8, 0.8, 0.8)这样视觉上像个小靶子也不会因为碰撞体太大影响手感。另外地板的材质建议设成暗色墙体改成浅色因为后面调试粒子特效和子弹轨迹时背景颜色太杂会干扰你判断。2.3 子弹方案对比物理子弹 vs 射线检测做射击加分第一个要决定的技术方案就是“子弹到底是什么”。你可以在Unity里用两种完全不同的实现物理子弹创建一个带有Rigidbody的小球给它一个初速度让它靠物理引擎负责碰撞。这种方式能表现出真实的子弹轨迹、弹道下坠甚至能撞飞物体。代价是高速运动时可能穿透薄物体以及物理模拟需要额外性能。射线检测从枪口处发射一条射线如果是瞬间命中也就是即时命中场景里不产生子弹实体。这种方式响应最快适合射击手感要求高的游戏但缺少“子弹在飞”的视觉过程。在我的Demo里我会先用物理子弹因为它的实现更能让你理解碰撞、刚体、对象池这些知识点。等你把物理子弹跑通了想改成射线检测基本上只是把“生成子弹”换成“射线检测”得分逻辑完全不变。等于是先学复杂的再降级到简单的。3. 核心逻辑实现从发射到加分的完整链路3.1 Shooter脚本鼠标点击发射子弹代码从发射模块开始。新建一个脚本Shooter.cs挂到摄像机或Muzzle所在的空物体上都可以。using UnityEngine; public class Shooter : MonoBehaviour { public Camera cam; public Transform muzzle; public Bullet bulletPrefab; public float bulletSpeed 30f; void Update() { if (!Input.GetButtonDown(Fire1)) return; // 用屏幕中心作为准星避免鼠标点击偏移影响手感 var ray cam.ScreenPointToRay(new Vector3(Screen.width * 0.5f, Screen.height * 0.5f, 0f)); Vector3 direction; // 用一条辅助射线检测前方有没有目标只是用来算出子弹飞行方向 if (Physics.Raycast(ray, out RaycastHit hit, 200f)) { direction (hit.point - muzzle.position).normalized; } else { direction muzzle.forward; } var bullet Instantiate(bulletPrefab, muzzle.position, Quaternion.LookRotation(direction)); bullet.Fire(direction, bulletSpeed); } }这段代码有一个容易被忽略的细节Input.GetButtonDown(Fire1)会在玩家按住左键的每一帧返回一次true如果你想要按住连续射击可以用Input.GetButton()加冷却时间。如果你做成点射GetButtonDown就够用了。方向计算这里我第一次用的是“屏幕点击点”后来发现手感不如“屏幕中心准星”。因为玩家点击鼠标时鼠标通常不会正好落在准星上如果用Click位置子弹会沿鼠标指向方向飞而不是沿屏幕中心方向这和大多数FPS游戏习惯不符。3.2 Bullet脚本飞出去并学会回收自己给子弹预制体创建一个Bullet.cs挂在带Rigidbody和Collider的物体上。using UnityEngine; public class Bullet : MonoBehaviour { private Rigidbody rb; private bool isReady; private float lifeTime 2f; void Awake() { rb GetComponentRigidbody(); } public void Fire(Vector3 direction, float speed) { transform.rotation Quaternion.LookRotation(direction); rb.velocity direction * speed; isReady true; lifeTime 2f; } public void Release() { if (!isReady) return; isReady false; rb.velocity Vector3.zero; rb.angularVelocity Vector3.zero; gameObject.SetActive(false); } void Update() { if (!isReady) return; lifeTime - Time.deltaTime; if (lifeTime 0f) { if (BulletPool.Instance ! null) BulletPool.Instance.Return(this); else Destroy(gameObject); } } }我特意加了一句isReady标记作用是防止子弹在还没被Fire的时候就触发Release。想象一个场景子弹碰巧掉落在地板上触发了碰撞但它的速度还没有被赋值你却把它回收了那玩家真正发射时子弹就消失或者找不到。这个标记是处理对象状态的小习惯类似“空闲标记”在所有可复用物体上都建议加。另外子弹的物理体不要勾选Is Kinematic不要直接用transform.Translate移动。物理引擎里有一种常见误区觉得手动改Transform能更精确。但高速移动时Transform移动和物理碰撞不同步会产生穿透。用rb.velocity让物理引擎接管反而稳妥。3.3 Target脚本命中后加分并销毁目标模块是核心中的核心。新建Target.cs挂到Cube预制体上。using UnityEngine; public class Target : MonoBehaviour { [SerializeField] private int scoreValue 10; void OnCollisionEnter(Collision collision) { if (!collision.collider.TryGetComponentBullet(out var bullet)) return; // 先把子弹回收再处理加分保证碰撞只触发一次 if (BulletPool.Instance ! null) BulletPool.Instance.Return(bullet); else bullet.Release(); // 加分 ScoreManager.Instance.AddScore(scoreValue); // 目标消失这里用SetActive而不是Destroy方便以后做对象池目标 gameObject.SetActive(false); } }这里要注意TryGetComponent的写法。很多老代码还在用collision.collider.GetComponentBullet() ! null但GetComponent在没有组件时会有额外开销和潜在的NullReferenceException。TryGetComponent是Unity 2019之后推荐的写法不仅能判断有没有组件还能一次性把组件引用带出来。还有个细节我先回收子弹再调用AddScore。这样在同一物理帧里子弹先被禁用不会在下一帧继续产生新的碰撞回调分数管理器再更新分数UI刷新也不会被打断。顺序错乱可能导致一个Target在一帧内被计数两次。gameObject.SetActive(false)是一个适合对象池和关卡重置的处理方式。如果只有一个场景、不需要重置用Destroy更直观但为了方便扩展我还是建议用SetActive这样配合对象池后目标可以轻松复用。3.4 ScoreManager和UI把分数显示出来分数数据不能散落在每个Target里。如果每个Target都去改UI文本那UI组件会被“到处引用”以后想重置分数或者加飘字动画就很麻烦。正确的做法是建一个ScoreManager。using System; using UnityEngine; public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } public int Score { get; private set; } public event Actionint OnScoreChanged; void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; } public void AddScore(int amount) { if (amount 0) return; Score amount; OnScoreChanged?.Invoke(Score); } public void ResetScore() { Score 0; OnScoreChanged?.Invoke(Score); } }这里用事件而不是直接把UI引用写进ScoreManager是为了解耦。ScoreManager只负责“分数变了通知大家”谁想听谁就订阅。以后你加连击系统、最高分记录、飘字动画都不需要回头改ScoreManager。对应地创建一个ScoreUI脚本挂在Canvas下的Text组件上。using UnityEngine; using UnityEngine.UI; public class ScoreUI : MonoBehaviour { [SerializeField] private Text scoreText; void OnEnable() { if (ScoreManager.Instance ! null) { ScoreManager.Instance.OnScoreChanged UpdateScore; UpdateScore(ScoreManager.Instance.Score); } } void OnDisable() { if (ScoreManager.Instance ! null) { ScoreManager.Instance.OnScoreChanged - UpdateScore; } } void UpdateScore(int score) { scoreText.text $分数{score}; } }建议你在设计器里创建一个CanvasScale Mode设置为Screen Space - Overlay再挂一个Text然后把这个脚本挂到Text上把scoreText拖进Inspector。如果你的项目安装过TextMeshPro推荐把Text换成TMP_Text另外再用一句if (scoreText ! null)防止场景刚开始时UI组件还没激活导致控制台报错。3.5 BulletPool用对象池替代频繁销毁当你的Demo只有几颗子弹时Instantiate和Destroy没什么问题。但一个普通射击游戏每局可能射出上百发子弹每次Instantiate都伴随GC每次Destroy都会导致物理引擎重新整理场景帧数会出现肉眼可见的“卡顿”。所以趁现在就把对象池写进去。新建一个BulletPool脚本using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public static BulletPool Instance; [SerializeField] private Bullet bulletPrefab; [SerializeField] private int prewarmCount 20; [SerializeField] private Transform poolParent; private readonly QueueBullet pool new QueueBullet(); void Awake() { Instance this; if (poolParent null) poolParent transform; for (int i 0; i prewarmCount; i) { var bullet Instantiate(bulletPrefab, poolParent); // 先激活保证Awake执行再隐藏进池 bullet.gameObject.SetActive(false); pool.Enqueue(bullet); } } public Bullet Spawn(Vector3 position, Quaternion rotation) { Bullet bullet; if (pool.Count 0) { bullet pool.Dequeue(); } else { bullet Instantiate(bulletPrefab, poolParent); } bullet.transform.position position; bullet.transform.rotation rotation; bullet.gameObject.SetActive(true); return bullet; } public void Return(Bullet bullet) { bullet.Release(); pool.Enqueue(bullet); } }注意这里prewarmCount表示预热数量等于在场景加载时就准备好20颗子弹对象。之后玩家发射时如果池里有对象就直接拿出一个并激活如果没有了再额外Instantiate。这是一个很经典的“池化”思路。然后给发射脚本改成从池里获取子弹。// Shooter中替换Instantiate部分 var bullet BulletPool.Instance.Spawn( muzzle.position, Quaternion.LookRotation(direction) ); bullet.Fire(direction, bulletSpeed);同时目标脚本里已经写了BulletPool.Instance.Return(bullet)确保碰撞后子弹不是被直接Destroy而是归还对象池。这个阶段你可以观察一下Inspector随着你连续射击场景里不会出现几十个闲置子弹空物体所有子弹都会被收纳到同一个父节点下这就是一个很干净的池化管理。4. 让手感更真实参数调节与反馈设计4.1 刚体参数和碰撞配置的注意点很多新手做完基础版后会发现子弹飞得太快每次都“嗖”一下穿过靶子靶子一点反应都没有。这几乎都是因为Rigidbody的Collision Detection没有设置成Continuous Dynamic。Inspector里选中子弹预制体找到Rigidbody组件把Collision Detection改为Continuous Dynamic。Unity物理引擎默认是Discrete模式对高速物体不够敏感容易发生“隧穿”——也就是物体在上一帧还在碰撞体左侧下一帧已经跑到右侧碰撞被跳过。Continuous Dynamic模式会更多地进行连续扫描但性能开销也更大。对于单机Demo这点开销不值一提。另一个参数是Mass。子弹质量建议设为1不要设成0.01因为质量太小会让碰撞体的冲击力表现不真实。如果你只是想让子弹不推飞目标也可以把Target的Rigidbody去掉让Cube保持静态碰撞体。静态碰撞体不会因为碰撞而移动只会被“挡住”这正好符合靶子被打碎消失的表现。还有很多新手分不清Collider上的Is Trigger。当前方案用的是普通碰撞所以不要勾选Is Trigger。如果勾选了你的OnCollisionEnter根本不会执行必须换用OnTriggerEnter。这个坑我踩过太多次写在这里提醒大家。4.2 命中反馈三件套音效、粒子和UI飘字只加分不反馈玩家根本感受不到“打中了”。不管你的项目是学习还是作品集都值得补上三个基础反馈。第一是音效。在Target的OnCollisionEnter里加一句if (hitAudioClip ! null) { AudioSource.PlayClipAtPoint(hitAudioClip, transform.position, 0.8f); }PlayClipAtPoint是最省事的临时音效方法不需要额外挂AudioSource适合Demo。如果你想追求更好的性能建议用对象池化的AudioSource播放器。第二是粒子。给Target挂一个ParticleSystem预制体或者在碰撞点实例化一个粒子特效。粒子效果哪怕只是一个极小的爆裂火花也可以让命中一瞬间有“反馈感”。第三是UI飘字。这是最容易被忽略的加分反馈在命中点上方浮起一个“10”的Text然后慢慢上升并淡出。实现方法是动态创建一个Text组件用协程控制它的alpha和position。这一段代码比较长但效果非常直白可以让整个Demo的“击中-加分”体验瞬间高级起来。我建议先做前两个因为它们的实现成本几乎为零。等主体逻辑稳定后再加飘字动画。4.3 对象池化后的性能优化细节对象池不是写完就完事的你得注意几个细节否则会踩坑。第一子弹收回时必须先清空速度。如果子弹带着速度被SetActive(false)虽然它在场景中看不见了但物理状态几乎还是活跃的。下次再激活时它可能还保留着上一次的velocity一出生就不知飞到哪里。第二物体被SetActive(false)后会触发OnDisable。如果你在OnDisable里注册或注销事件务必注意执行顺序。我的建议是所有和池化相关的回调都不要在OnDisable里做而是放在Release()方法里因为Release是回归池子的唯一入口逻辑一目了然。第三池子里的子弹不要无限增长。如果玩家的射击频率很高而池子只预创建了20颗那么当子弹用完时它还是会继续Instantiate。实际游戏中建议设置一个长度上限超过上限后可以强制重用最久未使用的那个子弹或者暂停发射并提示弹药不足。对于DemoprewarmCount设为30或50就够了。5. 常见问题与排查技巧实录5.1 子弹穿透目标或直接穿过这是新手问得最多的问题。通常有两种原因。一是速度太快且Collision Detection没开Continuous Dynamic。解决方案已经在上文说过了去子弹预制体把Collision Detection改一下。二是子弹生成位置已经在目标碰撞体内部。如果你把子弹发出点放在了一个Cube内部那子弹会在下一帧直接脱离物理引擎根本不会报告“从内向外穿过”的碰撞。检查一下Muzzle的位置别放在地板里也别和靶子重叠。还可以打开Debug界面在Game视图里打开Gizmos看每一帧子弹的碰撞体边界是否和Target重叠。方法是选中子弹看Scene视图里是否显示蓝色边框。5.2 打中目标但分数不增加分三处排查看看Target上是否挂了Target脚本并检查OnCollisionEnter里面是否用对了TryGetComponentBullet。如果碰撞到的是Target的某个子物体而不是根物体脚本可能要挂在子物体上或者用GetComponentInParentBullet()。检查ScoreManager的Instance是否为空。如果场景里没有挂ScoreManager肯定报错。可以把ScoreUI的if去掉让它直接引用快速定位问题。检查UI文本是否订阅了OnScoreChanged。最常见的问题是OnEnable在ScoreManager Awake之前执行此时Instance还是null结果没订阅成功。解决办法是在Start里订阅或者让ScoreManager在Awake时初始化后主动调用一次UI刷新。5.3 分数重复增加或目标消失后又加了一次分在Target的碰撞回调里你需要用isReady类的标记先判断是否已经处理过这次命中。虽然物理碰撞一般不会在同一个FixedUpdate里对同一对碰撞体重复触发但如果Target有多个Collider或者子弹和Target碰撞后又立刻和地板碰撞就可能出现多次回调。更稳妥的做法是给Target加一个bool isHit之类的标记void OnCollisionEnter(Collision collision) { if (!isHit collision.collider.TryGetComponentBullet(out var bullet)) { isHit true; // 处理逻辑 } }当Target被SetActive(false)后下一次SetActive(true)时需要在OnEnable里把isHit重置为false。这一步是对象池最容易忘的。5.4 对象池里的子弹被再次使用时还残留旧状态如果你发现池化后的子弹第二次发射时飞向了完全随机的位置多半是上一次没清空速度。检查Release方法是否把rb.velocity和rb.angularVelocity都设成了零。另外一个容易被忽略的变量是lifeTime它必须也要重置。我在Bullet脚本里每次Fire时把lifeTime重置为2秒就是为了避免第二次使用继承上一次剩余生命周期导致子弹飞出去0.3秒就消失。你可以把池化类比成图书馆还书书还回去之前如果不把书签清理干净下一位读者就会看到上一任留下的折页体验会很差。对象池的“清理书签”就是清空速度、重置计时器、重置命中标记。6. 扩展玩法把加分循环变成完整游戏6.1 移动靶、生成波次与难度加分循环跑通后第一个自然的扩展是让目标动起来。你可以给Target加一个MoveTarget脚本public class MoveTarget : MonoBehaviour { public Vector3 moveDirection Vector3.right; public float speed 2f; void Update() { transform.Translate(moveDirection * speed * Time.deltaTime); } }再配合边界反弹就能得到一个来回移动的靶子。这样子弹的提前量和预判就变得重要起来游戏体验马上不一样。第二个扩展是生成波次。可以做一个SpawnManager每隔几秒在当前可用位置随机生成一个Target并且根据玩家命中的累计分数提高靶子的移动速度。这里的重点是在生成新目标之前检查上一次目标是否因为SetActive(false)而隐藏避免不断往场景里堆物体。6.2 连击、暴击与命中准确率如果你想做一个更像样的射击游戏可以在ScoreManager里加入连击倍率。比如连续命中时每次加分从1倍递增到1.5倍、2倍打空一发则重置连击。实现方式就是记录一个comboCount在Target的OnCollisionEnter里根据组合情况计算实际加分值。命中准确率也很适合作为额外UI。统计总发射次数与命中次数然后在UI上显示一个百分比。这个数据看起来简单但它会给玩家一种“被评价”的感觉是游戏反馈很重要的一部分。6.3 数据持久化最高分存档最后把最高分保存到本地这样Demo就有了“可反复挑战”的目标。Unity里最简单的持久化方式是PlayerPrefs。void OnApplicationQuit() { int bestScore PlayerPrefs.GetInt(BestScore, 0); if (ScoreManager.Instance.Score bestScore) { PlayerPrefs.SetInt(BestScore, ScoreManager.Instance.Score); PlayerPrefs.Save(); } }如果你想要更正式的数据结构比如存储玩家的最高连击、命中率、已解锁武器那可以考虑用JSON文件或SQLite。但这是后话至少在你的Demo里加一个最高分存档整个项目看起来就完整得多。我只分享一个我长期使用的小技巧在做这个加分循环时不要去追求“一版写到完美”。先把最丑的、最直观的物理子弹版本跑通再一步步替换成对象池、加反馈、加扩展。因为游戏开发里最怕的不是代码丑而是你还没跑通就想把功能一次性全部堆进去最后所有模块耦合在一起哪里都调不通。这个最小闭环跑通之后你的自信会完全不同后面加什么都只是给这棵已经活了的小树苗添枝叶。
返回列表