UnityでオンラインFPSを作る:マルチプレイ、サーバー権限、テスト

オンラインFPSは、ネットワークを通じて複数のプレイヤーを同じ試合に入れるゲームです。この記事ではUnityの公式ネットワークパッケージ **Netcode for GameObjects**(NGO)を使い、ホストやクライアントとしての起動、マシン間で同期するプレイヤーオブジェクト、サーバーが判定する射撃、リスポーン、キルスコアの基本を作ります。

オンラインゲームで最も重要なルールは **サーバー権限(server authority)** です。クライアントは「撃った」と伝えることはできますが、命中したかどうか、体力がいくつかを決めることはできません。以下のコードはこのルールに従っています。

重要:試合のルール(ダメージ、死亡、リスポーン、スコア)はこの環境でコンパイルして実行し、8項目すべて合格しました。ネットワーク部分(NGO)はUnity Editorと少なくとも2人のプレイヤーが必要なため、ここでは実行していません。手順に従って自分で確認してから使ってください。

注:コードは元のタイ語版のコメントを残しています。

必要なもの

ステップ1:Netcode for GameObjects を入れる

1. FPSプロジェクトを開き、**Window → Package Manager** を選びます。

2. **Unity Registry** を選び、**Netcode for GameObjects** を検索して **Install** を押します。

3. NGOの推奨どおり **Unity Transport** も入れます。通常は自動で入ります。

4. **Edit → Project Settings → Player** を開き、Active Input Handling が Both または Input Manager (Old) のままか確認します。

ステップ2:NetworkManager と開始メニューを作る

シーンの概略図(実際のスクリーンショットではありません):1 NetworkManager 2 NetworkBoot 3 Start Host ボタン 4 Start Client ボタン 5 サーバーアドレスの欄
シーンの概略図(実際のスクリーンショットではありません):1 NetworkManager 2 NetworkBoot 3 Start Host ボタン 4 Start Client ボタン 5 サーバーアドレスの欄

1. Hierarchy を右クリックし **Create Empty** を選び、`NetworkManager` と名付けます。

2. **Add Component** で **Network Manager** と **Unity Transport** を追加します。

3. もう一つ空のオブジェクト `NetworkBoot` を作り、下の `NetworkBoot` スクリプトを追加します。

4. **Play** を押すと、左上に2つのボタンが表示されます。

using Unity.Netcode;
using UnityEngine;

// ปุ่มเริ่มเกม: เลือกเป็นโฮสต์ (เซิร์ฟเวอร์ + ผู้เล่น) หรือไคลเอนต์ (เข้าร่วม)
// วางบน GameObject ชื่อ NetworkBoot ในฉาก และให้มี NetworkManager อยู่ในฉากด้วย
public class NetworkBoot : MonoBehaviour
{
    [SerializeField] private string address = "127.0.0.1";  // ที่อยู่ของโฮสต์ (ใส่ IP ของเครื่องโฮสต์เมื่อเล่นข้ามเครื่อง)

    private void OnGUI()
    {
        if (NetworkManager.Singleton.IsClient || NetworkManager.Singleton.IsServer) return;

        GUILayout.BeginArea(new Rect(10, 10, 220, 120));
        if (GUILayout.Button("Start Host (server + player)")) NetworkManager.Singleton.StartHost();
        if (GUILayout.Button("Start Client (join)")) NetworkManager.Singleton.StartClient();
        address = GUILayout.TextField(address);
        GUILayout.EndArea();
    }
}

この入力欄は試す人のための文字入力欄です。別のマシンで遊ぶときは、実際のアドレスを **Unity Transport** コンポーネントで設定します。

ステップ3:Player プレハブを作る

PlayerプレハブのInspectorの概略図(実際のスクリーンショットではありません):1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 Camera Object 欄
PlayerプレハブのInspectorの概略図(実際のスクリーンショットではありません):1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 Camera Object 欄

1. FPSプロジェクトの `Player`(Character Controller、FPSMovement、MouseLook、子のカメラとMuzzle)から始めます。

2. **Network Object** を追加します。これでNGOが同期対象だと認識します。

3. **Network Transform** を追加し、位置と回転をすべてのマシンに同期させます。

4. `Weapon` コンポーネントは削除します。射撃は `PlayerNetwork` に移ります。

5. `PlayerNetwork` と `OwnerSetup`(下記)を追加します。

6. `Player/Camera/Muzzle` を `PlayerNetwork` の **Muzzle** 欄へ、子のカメラを `OwnerSetup` の **Camera Object** 欄へドラッグします。

7. `OwnerSetup` の **Owner Only** に `FPSMovement` と `MouseLook` を入れ、持ち主だけが操作できるようにします。

8. Hierarchy の `Player` を `Assets/Prefabs/` にドラッグしてプレハブにし、シーンから削除します。

9. `NetworkManager` を選び、このプレハブを **Player Prefab** 欄にドラッグします。

ステップ4:試合のルールを純粋な C# で書く

ルールは `UnityEngine` を使わない普通のクラスに置くので、エディタを開かずにテストできます。サーバーはこのロジックを使います。

// กติกาของแมตช์ เขียนเป็น C# ธรรมดา (ไม่ใช้ UnityEngine) เพื่อให้ทดสอบได้โดยไม่ต้องเปิด Unity
// เซิร์ฟเวอร์เป็นผู้ตัดสินทุกอย่างด้วยกติกานี้ ไคลเอนต์ไม่มีสิทธิ์เปลี่ยนเลือดหรือคะแนนเอง
public class MatchRules
{
    public const int MaxHealth = 100;
    public const int DamagePerShot = 25;
    public const int KillsToWin = 10;
    public const float RespawnSeconds = 3f;

    public int Health { get; private set; }
    public int Kills { get; private set; }
    public int Deaths { get; private set; }
    public float RespawnTimer { get; private set; }

    public bool IsDead => Health <= 0;

    public MatchRules()
    {
        Health = MaxHealth;
    }

    // คืน true ถ้าการยิงนี้ทำให้ผู้ถูกยิงตาย
    public bool ApplyDamage(int amount)
    {
        if (IsDead) return false;
        Health -= amount;
        if (Health <= 0)
        {
            Health = 0;
            Deaths++;
            RespawnTimer = RespawnSeconds;
            return true;
        }
        return false;
    }

    // เรียกทุกเฟรม (หรือทุกรอบของเซิร์ฟเวอร์) คืน true เมื่อครบเวลาและฟื้นคืนชีพแล้ว
    public bool Tick(float deltaSeconds)
    {
        if (!IsDead) return false;
        RespawnTimer -= deltaSeconds;
        if (RespawnTimer > 0f) return false;

        Health = MaxHealth;
        RespawnTimer = 0f;
        return true;
    }

    public void AddKill()
    {
        Kills++;
    }

    public bool HasWon()
    {
        return Kills >= KillsToWin;
    }
}

ステップ5:オンラインのプレイヤースクリプト

三つの部分からなります。

using Unity.Netcode;
using UnityEngine;

// ผู้เล่นออนไลน์: ผู้เล่นเจ้าของเครื่องส่งคำสั่งยิง แต่เซิร์ฟเวอร์เป็นคนตรวจการยิง คิดดาเมจ และนับคะแนน
// ต้องติดอยู่บน Player prefab พร้อม NetworkObject และ NetworkTransform
public class PlayerNetwork : NetworkBehaviour
{
    [SerializeField] private Transform muzzle;                 // ปากกระบอกปืน (อยู่ใต้กล้องของผู้เล่น)
    [SerializeField] private float range = 100f;
    [SerializeField] private float fireInterval = 0.2f;
    [SerializeField] private LayerMask hitMask;                // เลเยอร์ที่ยิงโดน (ผู้เล่นและกำแพง)
    [SerializeField] private Transform[] spawnPoints;          // จุดเกิดที่เซิร์ฟเวอร์เลือกตอนฟื้นคืนชีพ

    // เลือดและคะแนน: ทุกเครื่องอ่านได้ แต่มีเพียงเซิร์ฟเวอร์ที่เขียนค่าได้
    public NetworkVariable<int> Health = new NetworkVariable<int>(
        MatchRules.MaxHealth, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
    public NetworkVariable<int> Kills = new NetworkVariable<int>(
        0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);

    private float nextFireTime;
    private float respawnTimer;

    public bool IsAlive => Health.Value > 0;

    private void Update()
    {
        // ฝั่งผู้เล่น: รับปุ่มยิง แล้วส่งคำขอไปยังเซิร์ฟเวอร์
        if (IsOwner && Input.GetMouseButtonDown(0) && Time.time >= nextFireTime && IsAlive)
        {
            nextFireTime = Time.time + fireInterval;
            FireServerRpc(muzzle.position, muzzle.forward);
        }

        // ฝั่งเซิร์ฟเวอร์: นับเวลาฟื้นคืนชีพ
        if (IsServer && Health.Value <= 0)
        {
            respawnTimer -= Time.deltaTime;
            if (respawnTimer <= 0f) Respawn();
        }
    }

    // ทำงานบนเซิร์ฟเวอร์เท่านั้น เมื่อผู้เล่นส่งคำขอยิง
    [ServerRpc]
    private void FireServerRpc(Vector3 origin, Vector3 direction)
    {
        if (!IsAlive) return;                                  // คนตายยิงไม่ได้

        if (Physics.Raycast(origin, direction, out RaycastHit hit, range, hitMask))
        {
            PlayerNetwork target = hit.collider.GetComponentInParent<PlayerNetwork>();
            if (target != null && target != this)
            {
                target.TakeDamageOnServer(MatchRules.DamagePerShot, OwnerClientId);
            }
        }
    }

    // เรียกได้เฉพาะบนเซิร์ฟเวอร์ คิดดาเมจและให้คะแนนผู้ยิงเมื่อฆ่าสำเร็จ
    public void TakeDamageOnServer(int amount, ulong shooterClientId)
    {
        if (!IsServer || !IsAlive) return;

        int remaining = Health.Value - amount;
        Health.Value = remaining < 0 ? 0 : remaining;   // ใช้การเช็คเอง เพราะ Mathf.Max รับ float
        if (Health.Value > 0) return;

        respawnTimer = MatchRules.RespawnSeconds;
        var shooter = NetworkManager.Singleton.ConnectedClients[shooterClientId].PlayerObject;
        if (shooter != null)
        {
            shooter.GetComponent<PlayerNetwork>().Kills.Value++;
        }
    }

    private void Respawn()
    {
        Health.Value = MatchRules.MaxHealth;
        if (spawnPoints != null && spawnPoints.Length > 0)
        {
            transform.position = spawnPoints[Random.Range(0, spawnPoints.Length)].position;
        }
    }
}

ポイント

ステップ6:持ち主だけが操作し、カメラを持つ

概略図(実際のスクリーンショットではありません):ステップ3の Owner Only と Camera Object の欄
概略図(実際のスクリーンショットではありません):ステップ3の Owner Only と Camera Object の欄

全員が Player プレハブのコピーを持っています。この手順がないと、ある人が別の人のキャラクターを操作でき、余分なカメラも表示されてしまいます。`OwnerSetup` がこれを処理します。

using Unity.Netcode;
using UnityEngine;

// เปิดการควบคุมและกล้องเฉพาะผู้เล่นเจ้าของตัวละครนั้น ผู้เล่นคนอื่นที่เห็นตัวละครนี้จะไม่มีกล้องและไม่รับปุ่ม
public class OwnerSetup : NetworkBehaviour
{
    [SerializeField] private Behaviour[] ownerOnly;   // สคริปต์ที่ต้องเปิดเฉพาะเจ้าของ เช่น FPSMovement และ MouseLook
    [SerializeField] private GameObject cameraObject; // กล้องลูกของผู้เล่น

    public override void OnNetworkSpawn()
    {
        foreach (Behaviour script in ownerOnly)
        {
            script.enabled = IsOwner;
        }
        if (cameraObject != null)
        {
            cameraObject.SetActive(IsOwner);
        }
    }
}

ステップ7:データの流れ

フロー図(実際のスクリーンショットではありません):1 クライアントAが射撃要求を送る 2 サーバーが命中を判定しダメージを適用 3 全クライアントが新しい体力を受け取る
フロー図(実際のスクリーンショットではありません):1 クライアントAが射撃要求を送る 2 サーバーが命中を判定しダメージを適用 3 全クライアントが新しい体力を受け取る

プレイヤーAが撃ってプレイヤーBに命中した場合:

1. Aがクリックすると、Aのクライアントは銃口の位置と向きを添えて `FireServerRpc` を呼びます。

2. サーバーが受け取り、Aが生きているか確認して、その位置からレイキャストします。

3. レイがBに当たれば、サーバーは `TakeDamageOnServer` を呼びBの体力を減らします。

4. Bの体力は `NetworkVariable` なので、NGOが全マシンに同期します。全員が同じ数字を見ます。

5. Bの体力が0になれば、サーバーはAにキルを与え、Bのリスポーンタイマーを開始します。

ステップ8:テスト

試合のルール(この環境で実行)

テストファイル `MatchRulesTests.cs`(タイ語版にあります)は、Unityを使わずにダメージ、死亡、リスポーンの時間、勝利条件を確認します。`MatchRules.cs` と `MatchRulesTests.cs` を新しいC#コンパイラでまとめてコンパイルし(例:`csc -langversion:latest -out:MatchRulesTests.exe *.cs`)、出力されたプログラムを実行します。

PASS  one shot: health drops from 100 to 75
PASS  four shots: player dies on the fourth
PASS  four shots: death is counted
PASS  damage while dead: no extra death counted
PASS  respawn: still dead after 1 second
PASS  respawn: back to full health after 3 seconds
PASS  win: not yet at 9 kills
PASS  win: wins at 10 kills

Passed 8 / Failed 0

ネットワーク対戦(Unity Editorで行う)

これはここでは実行していません。次の手順で確認してください。

1. エディタで **Play** を押し、**Start Host** をクリックします。これで1人目のプレイヤーになります。

2. 同じプロジェクトでもう一つUnityのウィンドウを開くか、エディタを複数起動するツールの **ParrelSync** を使い、**Start Client** をクリックします。

3. 互いのキャラクターが見えていて、それぞれ自分のキャラクターだけが動くことを確認します。

4. 相手を4回撃ち、両方の画面で体力が減り、撃った人にキルが付くことを確認します。

5. 死んでから3秒後にリスポーンすることを確認します。

よくある問題

この記事の限界

多くのプレイヤー向けに公開する前に、これらのテーマを学んでください。

まとめ

オンラインFPSはサーバー権限に依存します。クライアントは要求を送り、サーバーが判定し、全員が見る値はサーバーだけが書き込みます。ルールはテストできる普通のクラスに置き、ネットワークは上記のとおり NGO を使います。ルールのテストはここで合格しました。ネットワーク対戦は Unity Editor で確認が必要です。