☰
Unity 5.0射击小游戏:从项目拆解到对象池优化
2026/10/17 9:33:14 网站建设 项目流程

简介:一份基于Unity3D开发的初级射击游戏完整项目,面向刚接触Unity引擎的初学者,帮助理解3D游戏开发的核心流程。压缩包共28个文件,包含exe可执行程序、DLL动态库、config配置、assets资源及场景数据等,整体约33.73MB,可直接运行体验并查看项目结构。项目中涉及计数系统、粒子特效、音频管理、键盘输入处理、场景构建与光照材质等多个关键知识点。通过C#脚本实现射击数与消灭数统计,利用Input类响应WASD、QE、ZC、HN等按键操作,粒子系统模拟子弹轨迹与火花效果,音频组件控制射击音效播放。场景中的地形、敌人与道具通过Transform组件调整位置与交互,展示了Unity常用开发技巧。目前已有1584人学习浏览,资源附带了主程序与Data目录,便于初学者对照学习脚本逻辑与资源组织方式,是一份适合动手实践的入门级Unity射击游戏案例。

1. 项目定位:Unity 5.0 射击小游戏能学到什么

解压这个 unity射击游戏.zip 的时候,我原本以为能跑通就算完事,真正打开场景才发现反直觉的点:这个看似简单的 unity 小游戏,其实是理解 Unity 5.0 全家桶的捷径。项目规模控制在一个场景、五个脚本、七个预制体以内,玩法是典型的 unity3D 射击小游戏——控制角色移动、端起枪瞄准、敌人一波一波冲向你,打中加分别被打到掉命。它适合两类人:一类是想从零到一跑通一个 Unity 小游戏的初学者;另一类是手里有 4.x/5.x 老工程、想快速对照经典架构看上一个人怎么写代码的开发者。整个工程没有多余资源,读完直接能跑,跑完直接能改。

2. 核心模块拆解:从鼠标点击到分数弹出的完整链路

这个小游戏的玩法闭环可以压缩成一条链路:玩家输入 → 角色移动与瞄准 → 扣扳机 → 射线命中判定 → 敌人销毁与加分 → 敌人接近玩家扣命 → UI 刷新。下面按脚本逐个拆,顺序刚好就是这条链路走一遍的顺序。

2.1 玩家控制脚本:移动、视角与射击入口

玩家控制是所有射击玩法里最容易被写乱的部分。这个项目用的是 Unity 5.0 时代最标准的组合:Input.GetAxis读键盘、transform.Translate移动、transform.Rotate转视角。先看完整脚本:

using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; // 前后左右移动速度 public float mouseSensitivity = 2f; // 鼠标灵敏度 public Transform gunPivot; // 枪口挂点,用于枪口上下瞄准 void Update() { // 1. 读取 WASD 或方向键输入 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); // 2. 把输入方向转到相机朝向上,保证按 W 永远朝屏幕里走 Vector3 dir = new Vector3(h, 0, v); dir = Camera.main.transform.TransformDirection(dir); dir.y = 0; transform.Translate(dir.normalized * moveSpeed * Time.deltaTime); // 3. 鼠标左右移动控制角色水平旋转 float mouseX = Input.GetAxis("Mouse X") * mouseSensitivity; transform.Rotate(0, mouseX, 0); // 4. 鼠标上下移动控制枪口俯仰 float mouseY = Input.GetAxis("Mouse Y") * mouseSensitivity; gunPivot.localRotation = Quaternion.Euler(-mouseY, 0, 0); } }

逻辑说明:移动和视角放在Update()而不是FixedUpdate(),因为输入每帧读一次,写进物理循环反而会丢手感。TransformDirection是把键盘方向转换到相机坐标系,这一步不做的话,你按 W 角色永远沿世界坐标走,视角转 180 度后操作就完全反了。Translate传的是dir.normalized,防止斜向移动时速度变成 1.414 倍。

参数说明:moveSpeed在 3~8 之间体验差异明显,调到 10 以上小场景就很难瞄;mouseSensitivity是相乘系数,Unity 5.0 里Mouse X轴的范围大约是 -1~1,乘 2 是一个不晕的默认值。gunPivot需要在 Inspector 手动指定,这一步漏了就是第 4 章说的空引用坑。

2.2 射击判定:用射线而不是子弹物理,为什么

很多新手以为射击必须生成一颗子弹飞出去,其实教学型小游戏最省事、最稳的做法是一条射线。这个项目就是用Physics.Raycast从屏幕中心打出去,谁挡住射线谁就中枪:

using UnityEngine; public class FireScript : MonoBehaviour { public Camera cam; // 主相机,射线从相机中心打出 public float fireRange = 100f; // 射线最大距离 public AudioSource fireAudio; // 开枪音效 public ParticleSystem muzzleFlash; // 枪口火花 void Update() { if (Input.GetMouseButtonDown(0)) { Fire(); } } void Fire() { if (fireAudio != null) fireAudio.Play(); if (muzzleFlash != null) muzzleFlash.Play(); Ray ray = cam.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2, 0)); RaycastHit hit; if (Physics.Raycast(ray, out hit, fireRange)) { Debug.Log("击中:" + hit.collider.name); if (hit.collider.CompareTag("Enemy")) { hit.collider.SendMessage("Die", SendMessageOptions.DontRequireReceiver); } } } }

逻辑说明:GetMouseButtonDown(0)是左键按下的一帧触发,不是持续触发,所以连点才会连射。ScreenPointToRay从屏幕正中心发出一条射线,这刚好对应画面中央的准星。命中后优先用CompareTag("Enemy")判断身份,而不是hit.collider.name——名字在改版时会变,Tag 是稳定约定。SendMessage是 Unity 5.0 时代的常见做法,它直接调用敌人身上的Die()方法,好处是 FireScript 不需要提前拿到 EnemyController 的引用,耦合降到最低。

参数说明:fireRange = 100f在小场景里绰绰有余,但如果敌人停在 150 米外,射线会直接落空——这是第 4 章的经典坑。muzzleFlash和fireAudio都做了空判断,因为枪口没挂粒子系统时不该让脚本崩掉,很多项目就是少了这个判空被玩家喷。

2.3 敌人AI与生成器:让靶子自己走到枪口前

敌人脚本是这个小游戏里「看起来有 AI 味」的部分,但实现拆开只有两个动作:转面向玩家,然后往前走。这里用了一个在 5.0 里特别顺手的小技巧:

using UnityEngine; public class EnemyController : MonoBehaviour { public float speed = 2f; // 移动速度 public Transform player; // 朝向目标,指向玩家 void Update() { if (player == null) return; Vector3 target = player.position; target.y = transform.position.y; // 只转水平面,不让敌人抬头低头 transform.LookAt(target); transform.Translate(Vector3.forward * speed * Time.deltaTime); } void Die() { if (GameManager.Instance != null) GameManager.Instance.AddScore(10); Destroy(gameObject); } }

逻辑说明:LookAt让敌人正脸对准玩家,但直接把player.position传进去,敌人会在斜坡附近仰头或低头,看起来像脖子断掉。把target.y固定成敌人自己的高度,就只剩水平旋转,这是做地面敌人的固定习惯。Translate(Vector3.forward * speed)是沿着物体自身前方移动,配合LookAt正好形成「追着玩家走」的效果。

参数说明:speed = 2f偏慢偏稳,方便新手瞄准;想提高难度就把三五个敌人的速度在 1.5~3.5 之间随机浮动,让阵型错开。Die()里加分会直接写进GameManager,这样敌人不需要知道 UI 怎么刷。

生成器SpawnManager用协程控制间隔,比在Update里攒倒计时变量干净得多:

using UnityEngine; public class SpawnManager : MonoBehaviour { public GameObject enemyPrefab; public Transform[] spawnPoints; // 生成点数组 public float spawnInterval = 1.5f; // 每次生成间隔秒数 void Start() { StartCoroutine(SpawnLoop()); } System.Collections.IEnumerator SpawnLoop() { while (true) { SpawnOne(); yield return new WaitForSeconds(spawnInterval); } } void SpawnOne() { Transform point = spawnPoints[Random.Range(0, spawnPoints.Length)]; Instantiate(enemyPrefab, point.position, point.rotation); } }

逻辑说明:while (true)配WaitForSeconds是 Unity 协程的经典定时循环写法,脚本销毁时协程自动终止。Random.Range(0, spawnPoints.Length)从数组中随便取一个生成点,用length而不是写死 3 或 5,这样以后在 Inspector 里删掉一个生成点,代码不用动。

参数说明:spawnInterval = 1.5f是默认节奏,配合 2f 的敌人速度,新手大概能撑一分钟。想改成「越玩越难」,把间隔改成随分数递减即可,例如Mathf.Max(0.4f, 1.5f - score * 0.01f)。

2.4 游戏管理器:状态、分数与碰撞扣命的黏合层

GameManager在这个项目里承担了三个职责:全局唯一的实例入口、分数与生命值状态、UI 绘制。Unity 5.0 的典型做法是单例配合OnGUI,连 Canvas 都不用搭:

using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance; public int score; public int health = 5; void Awake() { if (Instance == null) Instance = this; } public void AddScore(int point) { score += point; } public void LoseLife() { health--; if (health <= 0) { health = 0; Debug.Log("Game Over:生命耗尽"); // 扩展点:这里切场景或弹结束面板 } } void OnGUI() { GUI.Label(new Rect(10, 10, 200, 30), "分数:" + score); GUI.Label(new Rect(10, 50, 200, 30), "生命:" + health); } }

逻辑说明:Awake里赋静态实例,其他脚本通过GameManager.Instance直接调用,这是 5.0 项目里最常见的单例写法,简单粗暴但足够用。OnGUI每帧会被调用多次,纯 UI 开销小,适合教学项目;等你想加按钮、图片、动画时,再按第 5 章换成 uGUI 的 Canvas。

最后一环是敌人撞到玩家的扣命回调,挂在 EnemyController 上:

void OnCollisionEnter(Collision collision) { if (collision.collider.CompareTag("Player")) { if (GameManager.Instance != null) GameManager.Instance.LoseLife(); Destroy(gameObject); } }

这段代码说明了一个设计取舍:敌人死亡逻辑不写在玩家身上,而是敌人自己处理「撞到玩家就消失并扣命」,职责边界非常清晰。整个链路到这里闭合——枪响、命中、加分、敌人近身、扣命、UI 刷新,每一环的调用关系都能在代码里直接追到。

3. 目录结构与预制体:Assets 资源组织的正确打开方式

3.1 目录结构:每个文件夹的职责边界

解压后 Assets 目录是 Unity 5.0 项目最标准的教学式布局:

Assets/ ├─ Scenes/ // 游戏场景,入口文件 ├─ Scripts/ // 全部 C# 脚本,按功能单文件存放 ├─ Prefabs/ // 敌人、枪口特效等预制体 ├─ Materials/ // 地面、墙体材质 └─ Audio/ // 射击音效

Scenes下只有一个主场景,命名通常是Game或Main,双击打开就是可运行状态。Scripts下五个脚本正好对应第 2 章的五个类,不建多层子目录是因为脚本之间通过 Inspector 引用,不需要按命名空间解耦。Prefabs是这套工程里最值得看的地方,一个预制体对应一个可复用对象,运行时的所有东西都从这里Instantiate出来。

这个结构对二次开发的意义是:你改玩法,优先动Scripts;加美术资源,丢进Materials和Prefabs;改打包入口,只碰Scenes。目录分得清,搜索和排查问题时能省下大量时间。

3.2 预制体参数表:子弹、敌人、枪口特效的挂载清单

预制体是场景和脚本之间的桥梁,表格里每一项都对应 Inspector 里真正拖进去的配置。以下面这套预制体配置为例,是 Unity 5.0 小游戏最常见的一组预设:

预制体挂载脚本关键组件预设参数
Player.prefabPlayerControllerCharacterController / TransformmoveSpeed=5,mouseSensitivity=2
Weapon.prefabFireScriptAudioSource + ParticleSystemfireRange=100
MuzzleFlash.prefab无脚本ParticleSystem + LightPlay On Awake 关闭
Enemy.prefabEnemyControllerCapsuleCollider + Rigidbodyspeed=2,Tag=Enemy
GameManager.prefabGameManager无(纯逻辑)health=5

参数说明:Enemy.prefab的Rigidbody必须存在,否则OnCollisionEnter永远不触发;Tag=Enemy是写死在代码里的约定,改 Tag 名就要同步改CompareTag字符串。MuzzleFlash.prefab的Play On Awake必须关掉,否则一实例化就自己播,FireScript再调Play()时会看到闪烁错乱。

预览体内嵌套预览体是 5.0 时代容易忽略的点:Weapon 预制体里其实挂了 MuzzleFlash 的子预制体,特效位置自动对齐枪口,省去了手动对齐的麻烦。这类嵌套关系在 Hierarchy 里点开就能看到,跑通后建议再回来看一遍。

3.3 场景搭建:从空场景复现项目的完整流程

照着预制体参数表,你可以把一个空场景复现到这个项目的初始状态。顺序如下:

第一步:新建场景,创建 Plane 作为地面并赋一个浅色材质,避免默认灰色调过暗。第二步:创建空物体命名 Player,挂 PlayerController;再创建子物体 Camera,设置 Tag 为 MainCamera,把相机 localPosition 调成 (0, 1.6, -2) 模拟第一人称视角高度。第三步:在 Player 下再建一个子物体 Weapon,挂 FireScript,指定相机引用、MuzzleFlash 预制体、音效音频源。第四步:创建空物体命名 SpawnManager,挂 SpawnManager 脚本,把 Enemy 预制体拖进enemyPrefab槽。第五步:创建 4~6 个空物体作为生成点,分布在场景四周,拖进spawnPoints数组。第六步:运行,按 WASD 移动,鼠标瞄准,左键开枪。

注意:Camera 的 Tag 必须是 MainCamera,Camera.main在脚本里才能找到它;如果场景里没有带这个 Tag 的相机,Camera.main返回 null,PlayerController 第一行移动就会报空引用。

复现完成后,再把 GameManager 预制体拖进场景,分数和生命 UI 就会出现在左上角。这套搭建流程同样适用于你手里任意一个 5.0 老工程——先建 Player、再挂武器、最后补管理器和生成器,基本不会漏东西。

4. 运行避坑与常见问题排查:四条血泪记录和一套检查流程

以下四条是这个系列项目里见过最多的问题,每一条都按「现象 → 原因 → 解决」给排查路径,照着走能省下大把时间。

4.1 坑一:打开场景漆黑一片,模型糊成一团黑

现象:运行场景后地面和墙体全是黑的,敌人在黑背景里若隐若现。原因:Unity 5.0 新建场景默认没有方向光,模型材质如果用的 Standard Shader,在无光环境下会显示为暗色甚至全黑。解决:在 Hierarchy 里创建 Directional Light,把角度调到朝地面倾斜 45 度左右;同时把地面材质 Shader 改为 Legacy Shaders / Diffuse,这个材质在低版本渲染管线里表现更稳定。

另一个相关问题是模型自身法线方向不对导致半边脸黑,这种情况灯光调再亮也没用。解决:导入模型时检查 Import Settings 里的 Normals,或者把材质改为双面渲染的 Shader。检查顺序是先看灯光是否存在,再看材质 Shader 类型,最后才是模型本身。

4.2 坑二:子弹穿墙、射线打不中目标

现象:开枪时子弹模型穿过了墙壁,或者射线对着敌人打了半天,分数一点没涨。原因分两种:如果是射线射击,常见原因是fireRange太短,敌人站在 100 以外射线提前落空;或者是墙和敌人没有 Collider,射线直接穿过了 MeshRenderer 但没有碰撞体接收。如果是子弹物理飞行,则是因为 Rigidbody 默认的碰撞检测模式是 Discrete,高速子弹很容易在两帧之间直接穿过薄墙。

解决:射线射击先检查fireRange数值,再确认敌人身上有 CapsuleCollider 或 BoxCollider,墙不需要碰撞检测的话可以只挂一个不用的 Collider 再关闭isTrigger。子弹物理的话,把 Rigidbody 的 Collision Detection 改成 Continuous,薄墙也能稳定挡弹。排查时看 FireScript 的 Debug.Log 是否打印了朝向名字,没打印基本就是距离或 Collider 的问题。

4.3 坑三:敌人不转身、碰撞不扣命、脚本报空引用

现象:敌人站在原地发呆不追玩家;撞到玩家后生命值不减;Console 里冒出一串NullReferenceException。原因:敌人不转身通常是player引用没拖到 Inspector,EnemyController.Update里if (player == null) return直接跳过了整个移动逻辑。碰撞不扣命是 Tag 没设置,OnCollisionEnter里的CompareTag("Player")永远返回 false。空引用则多发生在 FireScript 的cam或 GameManager 的Instance还没初始化就被调用。

解决:先看 Console 的红色报错,双击跳转到具体代码行;再查 Inspector 里每个public引用槽是否都拖进了对应物体。Tag 检查注意大小写,"Player"和"player"在CompareTag里是两个完全不同的值。GameManager 的Instance在Awake里赋值,而其他脚本的Start里调用它,顺序上不会越界;如果出现空引用,优先检查场景里有没有真的放 GameManager 物体,而不是纯脚本挂在别处。

4.4 排查思路:从 Console 日志到场景检查的固定顺序

踩坑多以后,我总结了一套固定检查顺序,从最能定位问题的环节开始。第一步看 Console:有没有报错日志,有就双击跳代码行,NullReference是引用缺失,MissingReference是对象被销毁后还在使用,两者处理方式完全不同。第二步看场景层级:报错脚本挂在哪棵树上,预制体是否实例化成功,SpawnManager 有没有真的出现在 Hierarchy 里。第三步看 Inspector:所有public引用槽是否为 None,Tag 是否匹配。第四步看运行状态:在运行时选中角色和敌人,观察 Inspector 里的数值有没有变化,CompareTag不生效时数值永远不对。

这套顺序解决了我从前 80% 的 Unity 5.0 工程问题。它强调先看错误信息,再看数据,最后才怀疑代码逻辑——大多数情况下代码逻辑是对的,只是引用没拖好、Tag 没设对、场景里缺东西,这类坑靠「读代码」是读不出来的,必须靠看运行态数据。

5. 二次开发:从打靶 Demo 到完整小游戏的三个改造

跑通之后,真正有乐趣的部分是把它改成你自己的游戏。按性价比排序,最值得做的三个改造是对象池、换 uGUI、扩展 GameManager 成状态机。

第一个改造是解决第 4 章提到的 GC 尖峰。现在 SpawnManager 每 1.5 秒Instantiate一个敌人,敌人死亡时再Destroy,高频创建销毁会让 Profiler 的 GC Alloc 曲线像锯齿一样难看。对象池的核心逻辑只有两段方法:

public class Pool : MonoBehaviour { public GameObject prefab; private System.Collections.Generic.Queue<GameObject> cache = new System.Collections.Generic.Queue<GameObject>(); public GameObject Get() { if (cache.Count > 0) { GameObject obj = cache.Dequeue(); obj.SetActive(true); return obj; } return Instantiate(prefab); } public void Back(GameObject obj) { obj.SetActive(false); cache.Enqueue(obj); } }

逻辑说明:Get()先从队列里取一个已经存在但隐藏的对象,队列空了才新建;Back()把敌人放回队列而不是销毁,代价只是几行代码,运行三分钟后 GC 曲线会平很多。改造时把 SpawnManager 里的Instantiate换成pool.Get(),把 EnemyControllerDie()里的Destroy换成pool.Back(gameObject)即可,注意Back前要把敌人身上的速度复位,否则第二次激活它会带着上一轮的转向角度跑。

第二个改造是 UI 层换 Canvas。OnGUI教学够用,但每帧构建 UI 的开销在真机上放大会明显发热。Unity 5.0 已经支持 uGUI,做法是新建 Canvas 和 Text 组件,让 GameManager 在Update里更新 Text 的text字段,代码量和 OnGUI 相当,但渲染效率和排版灵活性都上一个档次。

第三个改造是把 GameManager 从纯数据容器扩展成有限状态机,加GameState枚举(Ready、Playing、GameOver),在LoseLife里切换到 GameOver 状态,再配合一个 Button 组做重开与退出。验证方法很简单:把生命从 5 改成 1,撞一次敌人看是否进入结束状态;再改spawnInterval为 0.3 加高刷怪密度,确认对象池在高负载下不再掉帧。

从那以后我每次拿到新工程,第一件事都是先把高频创建销毁的地方强制走一遍对象池,再谈玩法扩展,这个顺序帮我避开了无数后期优化噩梦。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询