用 Unity 制作第一人称射击游戏(FPS):鼠标视角、移动、射击与测试

第一人称射击游戏(FPS)通过角色的眼睛看世界。核心系统包括:鼠标控制视角、WASD 移动、用射线开火,以及带血量的目标。本文将在 Unity 中从新建项目开始,逐步完成每个系统,直到代码通过测试。

注:代码保留了原始泰语版本中的注释。代码未在 Unity 编辑器中运行。测试是针对为本文编写的 Unity API 模拟库进行的,只验证脚本的逻辑。完整的模拟库文件见泰语版本。

需要准备的东西

第 1 步:创建 3D 项目并设置输入

1. 打开 Unity Hub,点击 **New project**,选择 **Universal 3D**(URP)或 **3D**(Built-in)模板,命名为 `MyFPS`。

2. 进入 **Edit → Project Settings → Player**,将 **Active Input Handling** 设为 **Both** 或 **Input Manager (Old)**,按提示重启编辑器。

3. 在 **Edit → Project Settings → Input Manager** 中确认存在 **Mouse X**、**Mouse Y**、**Horizontal**、**Vertical** 轴以及 **Jump** 按钮。标准模板中已包含。

第 2 步:创建地面和目标

1. 在 Hierarchy 中右键,选择 **3D Object → Plane**,命名为 `Ground`,将 Scale 设为 `X = 5, Y = 1, Z = 5`。

2. 再次右键,选择 **3D Object → Cube**,命名为 `Target`,放在约 10 米远处,例如 Position `Z = 10`、`Y = 1`。

3. 给立方体添加 **Target** 组件(在第 6 步编写)。立方体自带 Box Collider,射线需要它才能命中。

第 3 步:创建玩家

1. 在 Hierarchy 中右键,选择 **Create Empty**,命名为 `Player`,Position 设为 `Y = 1`。

2. 给 `Player` 添加 **Character Controller**。它无需 Rigidbody 即可移动角色。

3. 右键 `Player`,选择 **Camera**,使其成为子对象。将 Position 设为 `Y = 0.6`,使视角位于眼睛的高度。

4. 删除原来的 Main Camera,子摄像机将代替它。

结构为 `Player` → `Camera`。`Player` 之后还会挂载 `MouseLook`、`FPSMovement` 和 `Weapon`。

第 4 步:鼠标视角控制

创建名为 `MouseLook` 的脚本,粘贴以下代码。

using UnityEngine;

// หมุนกล้องตามเมาส์: ก้ม-เงยที่กล้อง (pitch) และหันซ้าย-ขวาที่ตัวละคร (yaw)
public class MouseLook : MonoBehaviour
{
    [SerializeField] private float sensitivity = 2f;   // ความไวเมาส์
    [SerializeField] private float minPitch = -80f;    // ก้มลงได้สุดกี่องศา
    [SerializeField] private float maxPitch = 80f;     // เงยขึ้นได้สุดกี่องศา
    [SerializeField] private Transform body;           // ตัวละครที่ต้องหมุนตามแนวนอน

    private float pitch;

    public float Pitch => pitch;

    private void Update()
    {
        float mouseX = Input.GetAxis("Mouse X") * sensitivity;
        float mouseY = Input.GetAxis("Mouse Y") * sensitivity;

        // เมาส์ขึ้นคือเงยหน้า จึงต้องลบค่า mouseY
        pitch = Mathf.Clamp(pitch - mouseY, minPitch, maxPitch);
        transform.localRotation = Quaternion.Euler(pitch, 0f, 0f);

        body.Rotate(0f, mouseX, 0f);
    }
}

要点:

在 Inspector 中,把 `Player` 拖到摄像机上 `MouseLook` 的 **Body** 栏。

第 5 步:WASD 移动与跳跃

创建名为 `FPSMovement` 的脚本,粘贴以下代码,并挂载到 `Player` 上。

using UnityEngine;

// เดินแบบ FPS ด้วย WASD โดยใช้ CharacterController (ไม่ใช้ Rigidbody)
[RequireComponent(typeof(CharacterController))]
public class FPSMovement : MonoBehaviour
{
    [SerializeField] private float speed = 6f;        // ความเร็วเดิน
    [SerializeField] private float jumpHeight = 1.5f; // ความสูงกระโดด (เมตร)
    [SerializeField] private float gravity = -20f;    // แรงโน้มถ่วง

    private CharacterController controller;
    private float verticalVelocity;

    public bool IsGrounded => controller.isGrounded;

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

    private void Update()
    {
        // แกน x คือซ้าย-ขวา และแกน z คือหน้า-หลัง ทั้งคู่อิงทิศทางที่ตัวละครหันอยู่
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        Vector3 move = transform.right * h + transform.forward * v;

        // จำกัดความยาวเวกเตอร์ไม่ให้เดินเฉียงเร็วกว่าเดินตรง
        if (move.magnitude > 1f) move = move.normalized;
        controller.Move(move * speed * Time.deltaTime);

        // แรงโน้มถ่วงและการกระโดด
        if (controller.isGrounded && verticalVelocity < 0f)
        {
            verticalVelocity = -2f;                       // กดให้ติดพื้นเล็กน้อย
        }
        if (Input.GetButtonDown("Jump") && controller.isGrounded)
        {
            verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
        }
        verticalVelocity += gravity * Time.deltaTime;
        controller.Move(new Vector3(0f, verticalVelocity * Time.deltaTime, 0f));
    }
}

要点:

第 6 步:射线武器与带血量的目标

瞬时命中(hitscan)武器从枪口向前发射一条射线。如果射线击中带有 `Target` 组件的对象,该目标就会掉血。

创建名为 `Weapon` 的脚本,挂载到 `Player` 上。

using UnityEngine;

// ปืนแบบ hitscan: ยิงรังสีจากปากกระบอกปืน ถ้าโดนเป้าหมายก็ลดเลือด
public class Weapon : MonoBehaviour
{
    [SerializeField] private Transform muzzle;        // จุดปากกระบอกปืน
    [SerializeField] private float range = 100f;      // ระยะยิงสูงสุด
    [SerializeField] private int damage = 25;         // ดาเมจต่อนัด
    [SerializeField] private float fireInterval = 0.2f; // เวลาขั้นต่ำระหว่างนัด (วินาที)
    [SerializeField] private int maxAmmo = 10;        // กระสุนเต็มซอง

    private int ammo;
    private float nextFireTime;

    public int Ammo => ammo;

    private void Awake()
    {
        ammo = maxAmmo;
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            TryShoot();
        }
    }

    // คืน true ถ้ายิงจริง, false ถ้ากระสุนหมดหรือยังยิงไม่ได้
    public bool TryShoot()
    {
        if (ammo <= 0 || Time.time < nextFireTime) return false;

        ammo--;
        nextFireTime = Time.time + fireInterval;

        RaycastHit hit;
        if (Physics.Raycast(muzzle.position, muzzle.forward, out hit, range))
        {
            Target target = hit.collider.GetComponent<Target>();
            if (target != null)
            {
                target.TakeDamage(damage);
            }
        }
        return true;
    }
}

在摄像机下创建一个名为 `Muzzle` 的空对象,放在枪口前方,然后把它拖到 `Weapon` 的 **Muzzle** 栏。

创建名为 `Target` 的脚本,挂载到立方体上。

using UnityEngine;

// เป้าหมายที่ถูกยิงได้: มีเลือด เมื่อเลือดหมดจะตาย
public class Target : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    private int health;

    public int Health => health;
    public bool IsDead => health <= 0;

    private void Awake()
    {
        health = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        if (IsDead) return;

        health -= amount;
        if (health < 0) health = 0;
        if (IsDead)
        {
            Die();
        }
    }

    private void Die()
    {
        // ในเกมจริงอาจใช้ Destroy(gameObject) หรือเล่นแอนิเมชันตาย
        gameObject.SetActive(false);
    }
}

关于代码的说明:`Target` 的第一个版本写的是 `health = Mathf.Max(0, health - amount);`。这段代码无法通过编译,因为 `Mathf.Max` 返回 float,而 `health` 是 int。编译器在运行测试之前就发现了这个错误。上面的版本直接做减法,并将结果限制在 0 以上。

第 7 步:自动化测试

测试框架使用 Unity API 模拟库(`UnityStubs.cs`,完整内容见泰语版本)以及测试文件 `FPSTests.cs`。测试内容包括鼠标视角、移动、跳跃、射击、射击间隔、弹药上限和目标死亡。

编译与运行:

1. 创建 `build` 文件夹,把 `UnityStubs.cs`、`FPSTests.cs` 和四个脚本复制进去。

2. 只在副本中删除 `using UnityEngine;` 这一行。Unity 项目中的真实脚本应保留该行。

3. 使用较新的 C# 编译器编译(例如 `csc -langversion:latest -out:FPSTests.exe *.cs`),然后运行 `FPSTests.exe`。

测试结果

PASS  mouse up: pitch becomes -2 (looking up)
PASS  mouse up for long: pitch stops at minPitch (-80)
PASS  mouse right: body turns 2 degrees
PASS  walk forward 1 second: moves about 6 units
PASS  diagonal walk: speed capped at about 6 (not 8.5)
PASS  jump from ground: rises above the ground in the first frame
PASS  shoot: fires when ammo is available
PASS  shoot hit: target health drops by damage (100 to 75)
PASS  shoot: ammo decreases from 10 to 9
PASS  fire interval: second shot within 0.2 s is blocked
PASS  fire interval: ammo is not spent on the blocked shot
PASS  4 hits of 25: target is dead
PASS  4 hits of 25: target object is deactivated
PASS  empty magazine: after 10 shots the 11th is refused

Passed 14 / Failed 0

这些结果来自模拟库,而不是 Unity 本身。物理碰撞、动画和真实的摄像机表现,仍需在编辑器中按 Play 进行检查。

游戏画面模拟图(不是实际渲染):中央的目标、准星、手持的枪,以及显示弹药和目标血量的 HUD
游戏画面模拟图(不是实际渲染):中央的目标、准星、手持的枪,以及显示弹药和目标血量的 HUD
根据实际测试输出渲染的结果:通过 14 项,失败 0 项(在模拟库而非 Unity 编辑器中运行)
根据实际测试输出渲染的结果:通过 14 项,失败 0 项(在模拟库而非 Unity 编辑器中运行)

第 8 步:在编辑器中测试

按 **Play** 后检查:

常见问题

总结

FPS 主要包括四个系统:摄像机视角、移动、射线射击,以及带血量的目标。每个系统都是可以单独测试的小脚本。测试在运行游戏之前就发现了编译错误,节省了后续的调试时间。下一步可以添加会移动的敌人、屏幕上的弹药计数和换弹功能。