Hey!欢迎回来! 经过四天的基础学习 相信大家都对Unity的功能已经略有了解**,**那么 今天我们来做一个简单的射击小游戏啦!
一、构建思路布置场景
(一)功能构建思路
| 序号 | 功能 | 涉及知识点 |
| 一 | 第一人称角色移动 | Rigidbody、生命周期、Mouse X/Y |
| 二 | 点击鼠标发射子弹 | Instantiate、AddForce、欧拉角 |
| 三 | 靶子自动生成补全 | Random.Range |
| 四 | 碰撞检测与销毁 | OnCollisionEnter、CompareTag |
| 五 | 积分系统与结束判定 | TextMeshProUGUI、TryParse |
|---|
(二)场景布置
使用我们的平面和方形构建一个简单的靶场并且加入我们角色和UI

二、角色移动控制器 - PlayerP.cs
那么我们就先开始思考并编写我们的玩家移动控制器脚本!
(一)生命周期回顾
在写代码之前,先回顾一下 Unity 的生命周期执行顺序。
| 函数 | 执行时机 | 我们用来做什么 |
|---|---|---|
Awake() |
对象创建时,始终最先执行 | 获取组件 |
Start() |
第一次 Update 前执行一次 | 锁定鼠标 |
Update() |
每帧执行(约60次/秒) | 读取输入、计算视角 |
FixedUpdate() |
固定0.02秒执行一次 | 执行物理操作 |
生活类比 :想象做作业。Awake 是准备好文具,Start 是翻开作业本,Update 是一道题一道题做,FixedUpdate 是做完一道题检查一次(固定间隔)。
(二)为什么物理要写在 FixedUpdate 里?
| 写入位置 | 帧率60 | 帧率30 | 效果 |
|---|---|---|---|
Update() |
1秒执行60次 | 1秒执行30次 | 速度不稳定 ❌ |
FixedUpdate() |
1秒执行50次 | 1秒执行50次 | 速度恒定 ✅ |
重要原则 :Update 只负责读取输入 ,真正的物理操作交给 FixedUpdate!
(三)完整代码逐行解析
cs
using UnityEngine;
public class PlayerP : MonoBehaviour
{
// ────────────────────────────── ① 字段声明 ──────────────────────────────
[Header("移动速度")]
public float moveSpeed = 10f; // 每秒移动10米
[Header("跳跃高度")]
public float jumpForce = 10f; // 跳跃冲量
[Header("视角参数")]
public float rotateSpeed = 100f; // 旋转速度(度/秒)
public float minPitch = -45f; // 最小俯角(低头限制)
public float maxPitch = 45f; // 最大仰角(抬头限制)
// 私有变量:跨函数通信用
private bool jumpRequest; // 跳跃请求标记
private Vector3 inputDir; // 输入方向(Update写,FixedUpdate读)
private float pitch; // 上下看角度(绕X轴)
private float yaw; // 左右看角度(绕Y轴)
private Rigidbody rb; // 刚体引用
说明 :[Header] 特性让 Inspector 面板分组显示,private 变量不显示在面板上但可以在代码中跨函数使用。
cs
// ────────────────────────────── ② Awake - 获取组件 ──────────────────────────────
void Awake()
{
rb = GetComponent<Rigidbody>();
if (rb == null)
{
Debug.Log("没有刚体组件"); // 提前报错,防止运行时才发现
}
}
// ────────────────────────────── ③ Start - 初始化 ──────────────────────────────
void Start()
{
// 锁定鼠标到窗口中心并隐藏指针
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
说明 :Awake 中提前检查 Rigidbody 是否存在,比运行时报错更容易定位问题。
cs
// ────────────────────────────── ④ Update - 读取输入 ──────────────────────────────
void Update()
{
// ① 读取 WASD 输入(范围 -1 ~ 1)
float h = Input.GetAxis("Horizontal"); // A/D → -1/0/1
float v = Input.GetAxis("Vertical"); // W/S → -1/0/1
// ② 将本地方向转为世界方向(跟随角色朝向)
Vector3 localDir = new Vector3(h, 0, v).normalized;
inputDir = transform.TransformDirection(localDir);
说明 :Input.GetAxis 返回 -1~1 的连续值,比 GetKey 的 0/1 更平滑,适合角色移动。normalized 保证斜方向速度和直走一致。
cs
// ③ 鼠标视角计算 - 左右转(绕Y轴)
float mouseX = Input.GetAxis("Mouse X");
yaw += mouseX * rotateSpeed * Time.deltaTime; // 乘时间保证不同帧率下速度一致
// ④ 鼠标视角计算 - 上下看(绕X轴)
float mouseY = Input.GetAxis("Mouse Y");
pitch -= mouseY * rotateSpeed * Time.deltaTime;
pitch = Mathf.Clamp(pitch, minPitch, maxPitch); // 限制角度防止相机翻跟头
生活类比 :yaw 像你左右转头,pitch 像你点头/仰头。两者分开控制才能实现"边走边看"的效果。
cs
// ⑤ 应用旋转到物体本身(Y轴 = 左右转)
transform.rotation = Quaternion.Euler(0, yaw, 0);
// ⑥ 应用旋转到相机子物体(X轴 = 上下看)
Transform cam = Camera.main?.transform ?? transform.GetChild(0);
if (cam != null)
{
cam.localRotation = Quaternion.Euler(pitch, 0, 0);
}
说明 :?. 是空条件运算符,Camera.main 为 null 时不报错。?? 是空合并运算符,左边为 null 时用右边。两者配合实现相机兼容。
| 运算符 | 写法 | 含义 |
|---|---|---|
?. |
obj?.method() |
obj 不为 null 才调用方法 |
?? |
a ?? b |
a 不为 null 用 a,否则用 b |
cs
// ⑦ 跳跃请求(标记模式,不在 Update 里直接跳跃)
if (Input.GetKeyDown(KeyCode.Space))
{
jumpRequest = true; // 记录请求,FixedUpdate 统一处理
}
// ⑧ Esc 切换鼠标锁定状态
if (Input.GetKeyDown(KeyCode.Escape))
{
if (Cursor.lockState == CursorLockMode.Locked) // 当前锁定中
{
Cursor.lockState = CursorLockMode.None; // 释放锁定
Cursor.visible = true; // 显示鼠标
}
else // 当前自由状态
{
Cursor.lockState = CursorLockMode.Locked; // 重新锁定
Cursor.visible = false; // 隐藏鼠标
}
}
}
生活类比 :jumpRequest = true 就像你在清单上画个圈说"待会要跳",FixedUpdate 是执行清单的人。好处是不会漏掉跳跃请求,也不会跳多次。
cs
// ────────────────────────────── ⑤ FixedUpdate - 物理执行 ──────────────────────────────
void FixedUpdate()
{
// ① 物理驱动移动(比直接改 position 更稳定)
Vector3 nextPos = rb.position + inputDir * moveSpeed * Time.fixedDeltaTime;
rb.MovePosition(nextPos);
// ② 执行跳跃
if (jumpRequest)
{
// ForceMode.Impulse = 瞬间冲量,适合跳跃这种瞬时动作
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
jumpRequest = false; // 重置标记
}
}
}
| ForceMode | 效果 | 适合场景 |
|---|---|---|
Force |
持续施力,受质量影响 | 推车、火箭 |
Impulse |
瞬间冲量,受质量影响 | 跳跃、爆炸 ✅ |
Acceleration |
持续加速,不受质量影响 | 统一加速 |
VelocityChange |
直接改变速度,不受质量影响 | 直接设定速度 |
三、子弹发射系统 - Bullet.cs
玩家移动做完之后来思考我们的子弹发射应该如何制作,以及需要些什么!
(一)Instantiate 参数详解
| 参数 | 类型 | 说明 | 示例 |
|---|---|---|---|
| 第1个 | GameObject |
要克隆的预制体 | bulletPrefab |
| 第2个 | Vector3 |
生成位置 | transform.position |
| 第3个 | Quaternion |
生成旋转 | Quaternion.identity |
| 常用 Quaternion | 含义 |
|---|---|
Quaternion.identity |
无旋转(默认姿态) |
Quaternion.Euler(x, y, z) |
按欧拉角旋转 |
(二)欧拉角三轴含义
| 轴 | 旋转名称 | 效果 |
|---|---|---|
| X | 俯仰角 | 点头/仰头 |
| Y | 偏航角 | 左右转头 |
| Z | 翻滚角 | 侧翻 |
(三)完整代码解析
using UnityEngine;
public class Bullet : MonoBehaviour
{
[Header("子弹预制体")]
public GameObject bulletPrefab; // 要克隆的预制体
[Header("发射口")]
public Transform launchPoint; // 发射口Transform
[Header("作用力")]
public float force = 20f; // 发射力度
void Update()
{
// 按下鼠标左键发射
if (Input.GetMouseButtonDown(0))
{
Fire();
}
}
void Fire()
{
// ① 获取发射位置
Vector3 pos = launchPoint.position;
// ② 计算旋转
// 获取发射口的欧拉角,X轴固定为90度(预制体模型要求),Y/Z跟随发射口
Vector3 euler = launchPoint.eulerAngles;
euler.x = 90f;
// ③ 生成克隆体(必须用变量接收!)
GameObject clone = Instantiate(bulletPrefab, pos, Quaternion.Euler(euler));
// ④ 给克隆体施力(一定是 clone,不是 bulletPrefab!)
clone.GetComponent<Rigidbody>().AddForce(
launchPoint.forward * force, // 方向 × 力度
ForceMode.Impulse // 瞬间爆发力
);
}
}
常见错误 :忘记用变量接收 Instantiate 的返回值,导致给原始预制体施力而不是克隆体!
(四)Instantiate 返回值对比
| 写法 | 结果 |
|---|---|
Instantiate(prefab); |
生成但不控制 |
GameObject clone = Instantiate(prefab); |
生成并可控制克隆体 ✅ |
四、碰撞检测与积分系统 - BulletCollisoin.cs
子弹发射也写完了,那么我们当然需要来检测碰撞执行打击到物体后的效果啦,从而实现可以获取积分结束游戏
(一)OnCollisionEnter vs OnTriggerEnter
| 特性 | OnCollisionEnter | OnTriggerEnter |
|---|---|---|
| 触发条件 | 两物体都有 Collider | 至少一个是 Trigger |
| 物理碰撞 | 有(会弹开) ✅ | 无(可穿透) |
| 穿透效果 | 正常阻挡 | 可穿墙检测 |
| 适用场景 | 子弹打墙、拳击 | 拾取金币、进入区域 |
(二)脚本查找 API 对比
| 旧版(已弃用) | 新版 | 说明 |
|---|---|---|
FindObjectOfType<T>() |
Object.FindAnyObjectByType<T>() ✅ |
找一个 |
FindObjectsOfType<T>() |
Object.FindObjectsByType<T>() |
找全部 |
(三)数字解析方法对比
| 方法 | 文本"100" | 文本"分数10" | 文本"" |
|---|---|---|---|
int.Parse() |
100 ✅ | ❌ 抛异常 | ❌ 抛异常 |
int.TryParse() |
100 ✅ | false | false |
(四)完整代码解析
using UnityEngine;
public class BulletCollisoin : MonoBehaviour
{
[Header("子弹存活时间(秒)")]
public float lifetime = 5f;
private Target target; // 预制体无法拖拽,运行时查找
void Start()
{
// 运行时查找场景中的 Target 脚本
target = Object.FindAnyObjectByType<Target>();
// 兜底保险:即使没碰撞,5秒后也销毁
Destroy(gameObject, lifetime);
}
private void OnCollisionEnter(Collision collision)
{
// 只处理带有 NPC 标签的物体
if (collision.gameObject.CompareTag("NPC"))
{
// ① 销毁被击中的目标
Destroy(collision.gameObject);
// ② 更新分数
if (target != null && target.textM != null)
{
target.num--;
// 读取当前分数,空文本当作0处理
int score = 0;
if (!string.IsNullOrEmpty(target.textM.text))
{
int.TryParse(target.textM.text, out score);
}
score += 10;
target.textM.text = score.ToString();
// ③ 分数达到100时结束游戏
if (score >= 100)
{
Application.Quit();
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#endif
}
}
}
// ④ 无论打什么都销毁子弹
Destroy(gameObject);
}
}
五、靶子自动生成系统 - Target.cs
重要的功能全部实现后就是我们的随机生成靶子啦!这样才有趣味性
(一)Random.Range 版本区别
| 版本 | 调用方式 | 返回值范围 |
|---|---|---|
| 整型版 | Random.Range(1, 10) |
1 ~ 9(不含最大值) |
| 浮点版 | Random.Range(1f, 10f) |
1f ~ 10f(含最大值) |
(二)完整代码解析
using TMPro;
using UnityEngine;
public class Target : MonoBehaviour
{
[Header("预制体")]
public GameObject target; // 靶子预制体
[Header("生成范围")]
public float maxR = 10f; // 最大随机坐标
public float minR = -10f; // 最小随机坐标
[Header("当前靶子数")]
public int num; // 初始值设为0
[Header("分数显示")]
public TextMeshProUGUI textM; // UI分数文本 void Update()
{
// 靶子少于20个时自动生成补全
if (num <= 20)
{
SpawnEnemy();
num++;
}
}
public void SpawnEnemy()
{
// 随机生成位置(X和Z随机,Y固定在地面)
Vector3 pos = new Vector3(
Random.Range(minR, maxR), // X轴随机
0, // Y轴固定
Random.Range(minR, maxR) // Z轴随机
);
// 生成靶子,Y轴旋转180度(面对玩家)
Instantiate(target, pos, Quaternion.Euler(0f, 180f, 0f));
}
}
六、组件配置
(一)脚本挂载总览
| 脚本 | 挂载位置 | 职责 |
|---|---|---|
PlayerP.cs |
玩家物体 | 移动、跳跃、视角 |
Bullet.cs |
发射器物体 | 发射子弹 |
BulletCollisoin.cs |
子弹预制体 | 碰撞检测、计分 |
Target.cs |
场景空物体 | 生成靶子 |
(二)Recommended 组件配置
玩家物体 Rigidbody:
| 属性 | 推荐值 | 说明 |
|---|---|---|
| Mass | 1 | 质量 |
| Drag | 0 | 空气阻力 |
| Use Gravity | true | 受重力影响 |
| Collision Detection | Discrete | 移动缓慢够用 |
子弹预制体 Rigidbody:
| 属性 | 推荐值 | 说明 |
|---|---|---|
| Mass | 1 | 质量 |
| Drag | 0 | 空气阻力 |
| Use Gravity | false | 子弹不受重力 |
| Collision Detection | Continuous Dynamic | 防止穿墙 ✅ |
| Is Kinematic | false | 启用物理 |
七、运行成功展示:
可以看到我们成功的发射子弹击杀了目标并且加分20,但是由于完整展示无法上传,所以大家就自己动手吧!

**今天的教学就到这里!**接下来我将连续更新90天的Untiy教程从基础到一个网络部分,有兴趣的朋友们可以收藏关注,谢谢!如果有疑问,评论区见。