UnityでFPS(一人称シューター)を作る:マウス視点、移動、射撃、テスト

一人称シューター(FPS)は、キャラクターの目を通して世界を見るゲームです。基本となる要素は、マウスによる視点操作、WASDによる移動、レイキャストによる射撃、そして体力を持つ的です。この記事では、新しいプロジェクトから、テストに合格するコードまでを一つずつ作ります。

注:コードは元のタイ語版のコメントを残しています。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 で、カメラに付いた `MouseLook` の **Body** 欄に `Player` をドラッグします。

ステップ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:レイキャストの銃と体力を持つ的

ヒットスキャン方式の銃は、銃口から前方へレイを飛ばします。レイが `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` と4つのスクリプトをコピーします。

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 Editorではなくスタブで実行)
実際のテスト出力を描画したもの:14件成功、0件失敗(Unity Editorではなくスタブで実行)

ステップ8:エディタでテストする

**Play** を押して確認します。

よくある問題

まとめ

FPSには主に4つの仕組みがあります。視点の操作、移動、レイキャストによる射撃、体力を持つ的です。それぞれが小さなスクリプトで、個別にテストできます。テストによって、再生テストの前にコンパイルエラーを見つけられました。次のステップとしては、動く敵、画面の弾数表示、リロードなどが考えられます。