UnityでオンラインFPSを作る:マルチプレイ、サーバー権限、テスト
オンラインFPSは、ネットワークを通じて複数のプレイヤーを同じ試合に入れるゲームです。この記事ではUnityの公式ネットワークパッケージ **Netcode for GameObjects**(NGO)を使い、ホストやクライアントとしての起動、マシン間で同期するプレイヤーオブジェクト、サーバーが判定する射撃、リスポーン、キルスコアの基本を作ります。
オンラインゲームで最も重要なルールは **サーバー権限(server authority)** です。クライアントは「撃った」と伝えることはできますが、命中したかどうか、体力がいくつかを決めることはできません。以下のコードはこのルールに従っています。
重要:試合のルール(ダメージ、死亡、リスポーン、スコア)はこの環境でコンパイルして実行し、8項目すべて合格しました。ネットワーク部分(NGO)はUnity Editorと少なくとも2人のプレイヤーが必要なため、ここでは実行していません。手順に従って自分で確認してから使ってください。
注:コードは元のタイ語版のコメントを残しています。
必要なもの
- Unity Hub と最新のLTS版Unityエディタ
- 「UnityでFPSを作る」の記事で作ったプロジェクト(`MouseLook`、`FPSMovement`、`Weapon`、`Target` を含む)
- 別のマシンで遊ぶ場合はインターネットか同じLAN
ステップ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. 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 プレハブを作る
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:オンラインのプレイヤースクリプト
三つの部分からなります。
- **持ち主側** は射撃ボタンを読み取り、`ServerRpc` でサーバーに要求を送ります。
- **サーバー側** はレイキャストを行い、ダメージを適用します。
- **全員が見る値** は `NetworkVariable` で、サーバーだけが書き込めます。
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;
}
}
}
ポイント
- メソッド名は必ず `ServerRpc` で終わります。NGOはこの接尾辞でサーバーへの要求だと判断します。
- `NetworkVariableWritePermission.Server` により、クライアントはコードを書き換えても体力やスコアを変更できません。
- 「死んでいる人は撃てない」チェックはクライアントではなくサーバーで行います。クライアントは改造される可能性があるからです。
- ダメージで体力が0になると、サーバーは直ちに撃った人にキルを与えます。
- このシリーズの他の記事でも扱った、int に対する `Mathf.Max` の誤りを避けています。
ステップ6:持ち主だけが操作し、カメラを持つ
全員が 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:データの流れ
プレイヤー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秒後にリスポーンすることを確認します。
よくある問題
- **もう一人のプレイヤーが見えない:** Player プレハブが NetworkManager の Player Prefab 欄にあり、Network Object があるか確認します。
- **別のプレイヤーのキャラクターを操作できてしまう:** FPSMovement と MouseLook が OwnerSetup の Owner Only に入っているか確認します。
- **弾が当たらない:** Hit Mask にプレイヤーと壁が含まれ、Muzzle が前を向いているか確認します。
- **コンソールにRPCの名前が無効と出る:** メソッド名が `ServerRpc` で終わっているか確認します。
- **別のマシンに参加できない:** Unity Transport のアドレスがホストのIPと一致し、ホストのファイアウォールが使用中のポートを許可しているか確認します。
- **体力が同期しない:** `Health.Value` を書き込んでいるのがサーバーだけか確認します。
この記事の限界
- 射撃は単一のレイキャストで、クライアント側の予測(client-side prediction)はありません。通信遅延が大きいプレイヤーは反応の遅れを感じます。実際のゲームではラグ補正(lag compensation)を加えます。
- 移動の同期は Network Transform だけで、移動の予測はありません。
- ロビー、ルームシステム、マッチメイキングはありません。
- チート対策は完全ではありません。
多くのプレイヤー向けに公開する前に、これらのテーマを学んでください。
まとめ
オンラインFPSはサーバー権限に依存します。クライアントは要求を送り、サーバーが判定し、全員が見る値はサーバーだけが書き込みます。ルールはテストできる普通のクラスに置き、ネットワークは上記のとおり NGO を使います。ルールのテストはここで合格しました。ネットワーク対戦は Unity Editor で確認が必要です。