Unity射击小游戏工程拆解:对象池与射线检测实战指南

发布时间:2026/10/6 21:07:28
Unity射击小游戏工程拆解:对象池与射线检测实战指南 简介这是一份基于Unity3D开发的初级射击游戏工程包适合刚接触Unity的开发者学习小游戏的完整制作流程。包内以C#脚本驱动射击与消灭计数通过Unity粒子系统呈现子弹火花和烟雾结合音频管理触发打击音效并利用Input类处理WASD、QE、ZC等按键操作覆盖了游戏逻辑、视觉反馈、场景管理及光照材质设定等关键知识点。压缩包共28个文件体积33.73MB内含可直接运行的exe程序以及DLL库、Config配置、Asset资源等工程数据解压后既能直接试玩也便于在编辑器中对照学习。已有1584人学习下载属于面向初学者的实操型Unity学习资料能够帮助快速建立游戏开发的结构化认知。1. 这套 unity 射击小游戏工程为什么值得你照着敲一遍很多人学 Unity 最大的障碍不是 C# 语法而是手里没有一套能完整跑通的工程。这套 unity 射击小游戏正是这样的存在基于 Unity 5.0 开发的完整射击 Demo从玩家移动、敌人 AI、射线命中判定到 UI 计分、对象池回收把一个小型射击游戏最核心的模块全部串起来了。适合三类人准备做课程设计但找不到切入点的学生、学 Unity 两三个月还在原地打转的自学者以及想快速了解完整游戏循环的从业者。这篇笔记不会把源码贴一遍就完事而是把每个脚本为什么这样写、参数怎么调、常见的坑在哪逐个拆开讲。最后你会得到一套能直接改、直接扩展的工程骨架而不是一堆看不懂的代码文件。2. 工程结构与场景搭建先理清 Assets 目录和 Input 映射再动手改代码2.1 拿到工程包后先看 Assets 目录结构很多新手拿到这类资源第一件事是点开场景按 Play看到画面能跑就觉得自己学会了。我拆这套工程时的习惯是先不开场景把 Assets 目录从上到下过一遍。因为 Unity 项目的引用关系是靠 GUID 和 FileID 维持的你不了解目录结构改一个脚本名都可能导致场景里所有 Prefab 的引用断掉UI 消失、材质变紫那会儿才叫真的手足无措。这套工程的目录分层很常规但也正是初学者最容易忽略的地方目录存放内容为什么要单独分出来Scripts全部 C# 脚本脚本拆分后便于维护换机器不会丢脚本引用Scenes主场景和空场景模板空场景用作测试不污染正式场景Prefabs玩家、敌人、子弹特效等预制体Prefab 是 Unity 复用的核心场景里不直接摆裸物体SpritesUI 图标、背景图、枪械贴图资源集中管理避免挂到场景后找不到原图Audio枪声、命中音效音频单独放方便统一替换和裁剪ScriptableObjects武器参数配置如果资源里有数据与逻辑分离调平衡不用改代码说句实在话这六个目录里对后续开发影响最大的是 Prefabs 的分层。我见过不少项目把所有物体直接摆在 Hierarchy 里场景一多根本不知道哪个是谁想加一个功能还得进场景里翻半天。如果你打算长期用这套工程做扩展建议遵守目录的边界场景里只放引用 Prefab 的实例不放裸体对象。2.2 场景基本配置这几项不确认角色根本动不了打开主场景后先确认 Hierarchy 里这几个关键对象是否存在Player带 Rigidbody 和 CapsuleCollider、EnemySpawnPoint空物体用于标记敌人出生位置、CanvasUGUI 的 UI 根节点、Main Camera。这套工程的 Player 是一个胶囊体上面挂了刚体Rigidbody和碰撞器刚体不是用来模拟物理的而是为了用 MovePosition 做稳定位移这一点后面章节会细讲。然后是 Project Settings 里的输入映射。射击游戏离不开 Input.GetAxis 和 Input.GetButton而这两者的读取方式是在 Edit Project Settings Input 里配置的。很多人在自己机器上把 Fire1 设成了鼠标左键换台机器打开项目后轴映射变成了其他键角色能跑但射不出子弹排查的时候第一反应往往是想脚本哪里写错了实际上问题出在 Input 配置。我一般会这样检查 Input 配置打开 Edit Project Settings Input确认名为 “Horizontal” 的轴存在且正按键为 right/d负按键为 left/a。确认 “Vertical” 轴的正按键为 up/w负按键为 down/s。确认名为 “Fire1” 的轴存在鼠标左键绑定在 altPositiveButton 或 positiveButton 上。把 Sensitivity 设置到 1 以上、3 以下。数值太大会导致移动顿挫太小则角色反应迟钝。这套工程默认是键盘 WASD 移动、鼠标左键射击的方案对于 5.0 时代的老工程来说是标准配置。如果你在 Unity 2018 以上版本打开Input 轴的配置界面稍有不同但名称保持一致不要改轴名因为脚本里写死的是 Input.GetAxis(Horizontal) 这类字符串。轴名改了脚本就找不到输入。顺带一提场景里光源建议保持平行光 环境光足够亮不要为了省性能把方向光删掉。很多射击游戏场景整体偏暗玩家找不着敌人那不是代码问题是灯没打好。3. 玩家控制与射击手感MovePosition 移动和射线命中判定组合出来的效果3.1 别直接改 transform刚体位移要用 MovePosition这套工程的玩家控制脚本核心是刚体位移不是 transform.position 累加。这两者手感差异非常大尤其在高速移动和碰撞密集的场景里直接修改 transform 会导致刚体抖动、穿墙、身体挤进障碍物后弹不出来。原因在于 transform.position 绕过了物理引擎而刚体的位置校验被跳过了。下面是这套工程移动部分的典型写法using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class PlayerController : MonoBehaviour { public float moveSpeed 6f; // 移动速度单位米/秒 private Rigidbody rb; private void Awake() { rb GetComponentRigidbody(); } private void Update() { // 读取垂直和水平输入返回 -1 到 1 之间的平滑值 float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); // 把输入向量转换到世界空间方向与摄像机面向一致 Vector3 dir new Vector3(h, 0, v); if (dir.magnitude 0.1f) { rb.MovePosition(transform.position dir.normalized * moveSpeed * Time.deltaTime); } } }这段代码的逻辑是每帧读取输入把方向向量单位化后乘以速度与帧间隔得出这一帧的位移量再交给刚体的 MovePosition 执行。这里有几个关键点Input.GetAxis 返回的是平滑后的连续值键盘按下的瞬间从 0 平滑过渡到 1所以移动不是生硬的开关式而是有加速感。必须做 dir.normalized否则斜向移动时同时按 W 和 D速度会比直线快约 1.414 倍这是新手最容易忽略的细节。MovePosition 只移动不旋转如果你的角色需要面向移动方向还要额外做旋转插值这里先用固定朝向省掉属于 Demo 级别的取舍。参数上moveSpeed 设 6 是比较合适的手感阈值。低于 3 会觉得拖泥带水高于 10 在中小场景里常常一头撞进障碍物。这个参数不用玄学猜建议从 6 起步在 Game 视图里反复跑两圈再微调。3.2 射线命中判定为什么推荐用 Raycast 而不是子弹 Prefab这部分是整套工程里最值得反复体会的模块。很多初学者做射击游戏时第一反应是把子弹做成 Prefab然后 Instantiate AddForce让子弹飞出去撞到目标后销毁。这套工程选择的方案是射线检测从枪口位置发出一条不可见的射线在一瞬间内判定是否击中敌人。两种方案的取舍很清晰方案优点缺点适用场景子弹 Prefab 物理飞行真实感强能看到子弹飞行的轨迹高频生成销毁开销大高速子弹容易穿透碰撞体低速真实系武器射线命中判定性能开销极低命中判定即时且稳定无法表现子弹飞行时间视觉上感觉不到延迟街机风、竞技向、手游射击的核心代码集中在 PlayerShoot 脚本里public class PlayerShoot : MonoBehaviour { public float fireCooldown 0.12f; // 射击间隔秒约等于 500 RPM 射速 public float range 100f; // 射线射程 public int damage 1; // 单发伤害 public LayerMask targetLayer; // 只检测敌人层不检测地面和墙 private float nextFireTime; private void Update() { // GetButton 在按住时持续返回 true配合冷却时间实现全自动射击 if (Input.GetButton(Fire1) Time.time nextFireTime) { Shoot(); nextFireTime Time.time fireCooldown; } } private void Shoot() { // 从摄像机中心生成射线保证准星位置不等于弹着点 Ray ray Camera.main.ScreenPointToRay( new Vector3(Screen.width / 2f, Screen.height / 2f, 0f) ); if (Physics.Raycast(ray, out RaycastHit hit, range, targetLayer)) { Enemy target hit.collider.GetComponentInParentEnemy(); if (target ! null) { target.TakeDamage(damage); } } } }这段代码有一个值得记住的设计射线从摄像机中心发出而不是从枪口模型发出。原因是第一人称射击视角下玩家的准星位于屏幕中央枪口模型在屏幕下方偏右如果从枪口发出射线子弹实际落点会低于准星位置手感很怪。这种设计在商业游戏里也算常见是性价比最高的做法。GetButton(Fire1) 是按住就持续返回 true配合 fireCooldown 实现按住左键连续射击。Fire1 的轴配置在上一章已经确认过如果换台机器发现无法连发先回去检查轴映射。关于 LayerMask务必在编辑器中把敌人所在的层设为 Enemy并把 PlayerShoot 里 targetLayer 设为只包含 Enemy 层。否则射线会打在地面、墙体和玩家自己身上。常见翻车场景是射线打中了 Collider 但 GetComponent 取不到 Enemy原因就是把 Collider 放在了子物体上用 GetComponentInParent 可以容错这也是我强烈推荐的做法。参数调节上fireCooldown 设在 0.08~0.15 秒之间属于正常范围低于 0.08 秒人耳听到的枪声会和射击频率脱节。damage 为 1 时敌人血量若是 3则三发击毙节奏应该调到玩家能在战斗中明显感受到压力且不至于很快打光。4. 敌人 AI 与对象池从生成、追击到回收的完整循环4.1 Enemy 脚本移动、血量、被击毙与销毁敌人的行为在这套工程里走的是极简路线朝着玩家位置匀速移动碰撞后扣玩家血量自己血量归零时销毁自己。极简不代表粗糙相反几乎所有商业项目里冲锋型近战敌人都能从这个模板出发扩展。public class Enemy : MonoBehaviour { public float hp 3f; // 敌人生命值 public float moveSpeed 2f; // 追击速度 public int scoreValue 10; // 击毙后给的分数 private Transform player; // 缓存玩家位置引用 private void Start() { // 按标签查找玩家避免每帧 GetComponent GameObject playerObj GameObject.FindGameObjectWithTag(Player); if (playerObj ! null) player playerObj.transform; } private void Update() { if (player null) return; // MoveTowards 返回从当前位置向目标移动指定距离后的位置 Vector3 targetPos Vector3.MoveTowards( transform.position, player.position, moveSpeed * Time.deltaTime ); transform.position targetPos; } public void TakeDamage(float damage) { hp - damage; if (hp 0) { Die(); } } private void Die() { // 通知游戏管理器加分同时触发对象池回收 if (ScoreManager.Instance ! null) { ScoreManager.Instance.AddScore(scoreValue); } gameObject.SetActive(false); } }注意 Die 里用了 SetActive(false) 而不是 Destroy。这是对象池模式的地基敌人没有被销毁只是被隐藏。如果不考虑对象池只想最快跑通用 Destroy(gameObject) 也可以但那样做意味着每次生成敌人都会有 Instantiate 的新开销敌人数量一旦突破 30 个每帧的 CPU 消耗会明显上升。Start 方法里的玩家引用缓存同样重要。每帧 Transform 找玩家会调用 FindGameObjectWithTag高频执行时会产生不必要的 CPU 开销敌人数量越多越明显。对 Demo 而言缓存一次就够了。4.2 对象池为什么 Instantiate / Destroy 频繁调用是性能毒药传统方式里每次敌人被击毙就 Destroy然后每隔几秒用一个 Spawner 脚本 Instantiate 新敌人。这在极少量敌人时没问题但射击游戏里敌人往往同时在场几十个反复创建销毁会让 GC 频繁触发表现为游戏每隔几秒卡顿一次。对象池的做法是初始化时预先创建一批敌人并全部隐藏需要时从池里取一个 SetActive(true)击毙时不是销毁而是 SetActive(false) 还回池里。public class EnemyPool : MonoBehaviour { public GameObject enemyPrefab; // 敌人预制体 public int poolSize 20; // 池容量 private QueueGameObject pool new QueueGameObject(); private void Awake() { // 预热对象池一次性创建 20 个敌人并隐藏 for (int i 0; i poolSize; i) { GameObject enemy Instantiate(enemyPrefab, transform); enemy.SetActive(false); pool.Enqueue(enemy); } } public GameObject GetEnemy(Vector3 spawnPos) { GameObject enemy pool.Count 0 ? pool.Dequeue() : Instantiate(enemyPrefab, transform); enemy.transform.position spawnPos; enemy.SetActive(true); // 重置血量到初始值防止复用后残留 enemy.GetComponentEnemy().ResetEnemy(); return enemy; } public void ReturnEnemy(GameObject enemy) { enemy.SetActive(false); pool.Enqueue(enemy); } }这套代码的关键不在于多复杂而在于把“创建”和“销毁”替换成了“取出”和“归还”。Res 这行的逻辑体现在取用时重置位置和血量还回时不清理也无所谓因为下次取出时会覆盖。如果你把这份资源里的敌人数量调大比如 Spawner 同时生成 50 个敌人并全部激活再开着 Profiler 观察对象池版本和 Instantiate/Destroy 版本的内存分配差别会非常直观。以前我在做手机端射击 Demo 时踩过的坑就是图省事全部 Instantiate结果性能面板里的 GC Alloc 每帧跳动好几 KB游戏在真机上掉帧严重。换对象池后这个问题基本消失。参数上poolSize 建议设为“同屏敌人数量上限 × 1.5”。这套工程没有严格的波次系统20 个已经够用。如果你自己加波次把峰值敌人数量估算出来再乘 1.5 即可。5. UI 计分、反馈与五个高频坑从紫红材质到打包拉伸5.1 UGUI 计分与击杀反馈Canvas 缩放模式是手感的一部分计分显示在这套工程里由 ScoreManager 单例管理UI 上的 Text 通过代码更新。UGUI 的 Canvas 配置值得一提很多工程在手机或者窗口拉伸后 UI 位置错乱往往不是代码问题而是 Canvas 的缩放模式没设对。这套工程是在 1280×720 分辨率下调整的打包换到其他分辨率时需要把 Canvas 的 UI Scale Mode 设置为 Scale With Screen Size并把 Reference Resolution 设为 1280×720。这样 UI 会按比例缩放不会跑到屏幕外面去。ScoreManager 的加分方法public class ScoreManager : MonoBehaviour { public static ScoreManager Instance; // 全局唯一访问点 public Text scoreText; // 在 Inspector 中拖入 UI Text private int score; private void Awake() { Instance this; } public void AddScore(int value) { score value; if (scoreText ! null) { scoreText.text 分数 score.ToString(); } } }单例模式在小型游戏里是快速实现全局访问的方式但注意 Instance 在场景切换时会被销毁如果你在多个场景间跳转需要 DontDestroyOnLoad 或者在 GameManager 里做持久化处理。这套工程默认只有一个场景不需要这层考虑但扩展多场景时建议补上。5.2 五个高频坑现象、原因、解决办法这部分是这套工程里我最想单独拿出来说的地方也是我翻车次数最多的五个问题。坑一材质变成紫红色现象打开工程后敌人、场景、UI 图标全是紫红色或粉红色看起来像恐怖游戏。原因Unity 5.0 时代的内置着色器路径在升级后变了材质引用的 Shader 找不到或者显卡驱动不支持某些旧版内置 Shader。解决选中紫红材质在 Inspector 的 Shader 下拉框里改选 Standard如果是高版本引擎或 Legacy Shaders/Diffuse如果保持老版本。如果是脚本生成的材质确认 Shader.Find 用的字符串路径没有拼错。这个坑几乎人手一份不要太恐慌。坑二Prefab 引用丢失场景里全是 None现象打开场景后Hierarchy 里一堆对象显示 Missing Script 或 Missing Prefab点击后 Inspector 一片红。原因工程里的 .meta 文件缺失Unity 为每个资源重新生成了新的 GUID导致旧引用全部失效。解决解压工程包后不要手动删除 .meta 文件也不要自己改名脚本文件。改名操作在 Unity 编辑器里做Unity 会自动更新引用如果真丢引用且没有备份只能重新拖拽赋值。坑三角色移动时剧烈抖动现象跑起来像喝了酒尤其撞到墙的时候画面震得厉害。原因脚本里直接写 transform.position … 和刚体同时控制位置物理引擎每帧做碰撞检测时发现位置已经被脚本改走于是强行纠正回来抖动就这样产生了。解决把位移统一交给刚体处理实测 MovePosition 或者 AddForce 都行但不要混用 transform 和 Rigidbody。使用这套方案后把 Rigidbody 的 Interpolate 设为 Interpolate画面会更平滑。坑四射线打不到敌人现象准星对准敌人开枪敌人血量纹丝不动控制台也没报错。原因没有给敌人所在的物体设置 Layer或者射线检测的 LayerMask 里没有勾选 Enemy 层。解决在 Inspector 里把敌人预制体的 Layer 设为 Enemy然后在 PlayerShoot 的 targetLayer 里勾选 Enemy。查看敌人身上是否挂了 ColliderCollider 在子物体上的可以改用 GetComponentInParent 获取 Enemy。坑五老工程用新版 Unity 打开后一堆 API 过时警告现象Console 面板一片黄脚本运行报错特别是 GetComponent、Input 相关 API。原因Unity 5.0 到 2018/2022 之间有破坏性变更部分 API 被标记过时或被替换。解决把这套工程放在 Unity 2018 及以下版本打开最省事如果非要用新版把报错位置逐个替换Input.GetMouseButtonDown(0) 换成 Input.GetButtonDown(Fire1)Camera.main 在这里还能用但未来的 URP 工程里记得缓存引用。这些坑的共同点是它们都不是逻辑错误而是工程配置和环境差异导致的连锁问题。换句话说你逐行检查代码可能永远查不出来因为问题根本不在代码里。6. 进阶屏幕震动、摄像机跟随与一键暂停的实现细节这套工程基础功能已经完整但离“有手感”还差一点点。我给读者的建议是先重写一遍移动和射击再给相机加两件小事命中反馈和跟随平滑。很多教程告诉你 Camera.main 放哪就不用管了实战中直接跟随会导致镜头僵硬甚至穿墙。先说跟随平滑。把摄像机作为玩家的子物体是偷懒的做法旋转和穿墙问题会接踵而至。更建议用 LateUpdate 里的 Vector3.Lerppublic class CameraFollow : MonoBehaviour { public Transform target; public float smoothTime 0.15f; private Vector3 offset; private void Start() { offset transform.position - target.position; } private void LateUpdate() { Vector3 targetPos target.position offset; transform.position Vector3.Lerp(transform.position, targetPos, smoothTime); } }然后是屏幕震动。每次命中都可以触发一段抖动让玩家明显感知到自己打中了东西private IEnumerator Shake(float duration, float magnitude) { Vector3 originalPos transform.localPosition; float elapsed 0f; while (elapsed duration) { float x Random.Range(-1f, 1f) * magnitude; float y Random.Range(-1f, 1f) * magnitude; transform.localPosition originalPos new Vector3(x, y, 0); elapsed Time.deltaTime; yield return null; } transform.localPosition originalPos; }调用 Shake(0.1f, 0.2f) 就能在命中瞬间打出 0.1 秒的轻微镜头位移幅度 0.2 已经很清楚但不会让玩家头晕。最后是最容易遗漏的一键暂停。实战里 Escape 暂停是刚需但很多人只做 Time.timeScale 0没做 UI 遮挡导致玩家还能听到音频甚至能看到对象池里的敌人在动。建议的做法是先把时间归零再打开一个包含按钮的 Pause 面板把画布上的 EventSystem 保留这样点击继续按钮时按钮的 OnClick 才能正常响应。经历过一次完整工程的拆解之后我现在遇到任何新项目都会强制自己按这个顺序走一遍先看目录结构再检查 Input 配置然后看脚本里有没有直接改 transform 的地方最后才打开场景按 Play。这几个动作每次只需要五分钟却能把大多数“脏活”提前挡在门外。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询