สร้างเกม FPS ออนไลน์ด้วย Unity: หลายผู้เล่นผ่านเครือข่าย ระบบเซิร์ฟเวอร์ตัดสิน และการทดสอบ

เกม FPS ออนไลน์คือเกมที่ผู้เล่นหลายคนอยู่ในแมตช์เดียวกันผ่านเครือข่าย บทความนี้ใช้ **Netcode for GameObjects** (NGO) ของ Unity ซึ่งเป็นระบบเครือข่ายอย่างเป็นทางการ และจะสร้างสิ่งต่อไปนี้ทีละขั้น ได้แก่ การเริ่มเป็นโฮสต์หรือไคลเอนต์ ตัวละครผู้เล่นที่ส่งข้อมูลถึงกัน ระบบยิงที่เซิร์ฟเวอร์เป็นผู้ตัดสิน การฟื้นคืนชีพ และการนับคะแนน

หลักการสำคัญที่สุดของเกมออนไลน์คือ **เซิร์ฟเวอร์เป็นผู้มีอำนาจตัดสิน (server authority)** ไคลเอนต์จะบอกว่าตัวเองกดยิง แต่ไม่มีสิทธิ์บอกว่าโดนหรือไม่ และเลือดเท่าไร บทความนี้ออกแบบตามหลักการนั้น

หมายเหตุสำคัญ: ตรรกะของกติกาแมตช์ (ดาเมจ ความตาย ฟื้นคืนชีพ การนับคะแนน) ถูกทดสอบจริงด้วยการคอมไพล์และรัน 8 ข้อ ผ่านทั้งหมด แต่ส่วนที่ทำงานกับเครือข่ายจริง (NGO) ไม่ได้ทดสอบในเครื่อง เพราะต้องใช้ Unity Editor และผู้เล่นอย่างน้อยสองคน ควรทดสอบตามขั้นตอนในบทความก่อนนำไปใช้

สิ่งที่ต้องเตรียม

ขั้นที่ 1: ติดตั้ง Netcode for GameObjects

1. เปิดโปรเจกต์ FPS แล้วไปที่ **Window → Package Manager**

2. เลือก **Unity Registry** ค้นหา **Netcode for GameObjects** แล้วกด **Install**

3. ติดตั้งแพ็กเกจ **Unity Transport** ตามที่ NGO แนะนำ (มักติดตั้งให้อัตโนมัติ)

4. ไปที่ **Edit → Project Settings → Player** แล้วตรวจว่า Active Input Handling ยังเป็น Both หรือ Input Manager (Old) ตามบทความ FPS

ขั้นที่ 2: สร้าง NetworkManager และตัวเริ่มเกม

ภาพแผนผังฉาก (ไม่ใช่ภาพหน้าจอจริง): 1 GameObject NetworkManager 2 GameObject NetworkBoot 3 ปุ่ม Start Host 4 ปุ่ม Start Client 5 ช่องที่อยู่เซิร์ฟเวอร์
ภาพแผนผังฉาก (ไม่ใช่ภาพหน้าจอจริง): 1 GameObject NetworkManager 2 GameObject NetworkBoot 3 ปุ่ม Start Host 4 ปุ่ม Start Client 5 ช่องที่อยู่เซิร์ฟเวอร์

1. คลิกขวาใน Hierarchy เลือก **Create Empty** ตั้งชื่อ `NetworkManager`

2. กด **Add Component** แล้วเพิ่ม **Network Manager** และ **Unity Transport**

3. สร้าง Empty อีกอันชื่อ `NetworkBoot` แล้วเพิ่มสคริปต์ `NetworkBoot` (ด้านล่าง)

4. กด **Play** คุณจะเห็นปุ่มสองปุ่มมุมซ้ายบนของหน้าจอ

สคริปต์ `NetworkBoot`:

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 ค่าที่อยู่ของการเชื่อมต่อตั้งผ่าน Inspector ของ Unity Transport ช่อง `address` ในสคริปต์นี้เป็นเพียงช่องกรอกให้ผู้ทดสอบ ต้องเอาค่านั้นไปตั้งที่ Unity Transport ด้วยเมื่อเล่นข้ามเครื่อง

ขั้นที่ 3: ทำ Player prefab

ภาพแผนผัง Inspector ของ Player prefab (ไม่ใช่ภาพหน้าจอจริง): 1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 ช่อง Camera Object
ภาพแผนผัง Inspector ของ Player prefab (ไม่ใช่ภาพหน้าจอจริง): 1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 ช่อง Camera Object

1. ใช้ GameObject `Player` จากโปรเจกต์ FPS (ที่มี Character Controller, FPSMovement, MouseLook, Weapon และกล้องลูก)

2. เพิ่ม **Network Object** (คอมโพเนนต์ที่บอก NGO ว่านี่คือวัตถุที่ซิงก์)

3. เพิ่ม **Network Transform** เพื่อซิงก์ตำแหน่งและการหมุนไปยังทุกเครื่อง

4. ลบคอมโพเนนต์ `Weapon` ออก เพราะการยิงจะย้ายไปอยู่ใน `PlayerNetwork`

5. เพิ่มสคริปต์ `PlayerNetwork` และ `OwnerSetup` (ด้านล่าง)

6. ลาก `Player/Camera/Muzzle` เข้าช่อง **Muzzle** และลากกล้องลูกเข้าช่อง **Camera Object** ของ `OwnerSetup`

7. ใน `OwnerSetup` ช่อง **Owner Only** ให้ใส่ `FPSMovement` และ `MouseLook` เพื่อให้เปิดเฉพาะผู้เล่นเจ้าของ

8. ลาก `Player` จาก Hierarchy ลงในโฟลเดอร์ `Assets/Prefabs/` เพื่อสร้างเป็น prefab แล้วลบออกจากฉาก

9. เลือก `NetworkManager` แล้วลาก prefab นี้เข้าช่อง **Player Prefab** ของ Network Manager

ขั้นที่ 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: ให้เฉพาะเจ้าของควบคุมและเห็นกล้อง

ภาพแผนผัง (ไม่ใช่ภาพหน้าจอจริง) ดูช่อง Owner Only และ Camera Object ในรูปของขั้นที่ 3
ภาพแผนผัง (ไม่ใช่ภาพหน้าจอจริง) ดูช่อง Owner Only และ Camera Object ในรูปของขั้นที่ 3

ผู้เล่นทุกคนจะมี Player prefab อยู่ในฉาก ถ้าไม่จัดการ ผู้เล่นคนหนึ่งจะควบคุมตัวละครของอีกคนได้ และจะมีกล้องซ้อนกัน สคริปต์ `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` จึงถูกซิงก์ไปทุกเครื่องโดยอัตโนมัติ ทุกคนเห็นตัวเลขเดียวกัน

5. ถ้าเลือดของ B เป็นศูนย์ เซิร์ฟเวอร์เพิ่มคะแนนให้ A และเริ่มนับเวลาฟื้นคืนชีพ

ขั้นที่ 8: ทดสอบ

การทดสอบกติกาแมตช์ (ทดสอบจริงในเครื่อง)

กติกาถูกทดสอบด้วยคลาสทดสอบ `MatchRulesTests.cs` ด้านล่าง ไม่ต้องใช้ Unity

using System;

public static class MatchRulesTests
{
    private static int passed = 0, failed = 0;

    private static void Check(string name, bool condition)
    {
        if (condition) { passed++; Console.WriteLine("PASS  " + name); }
        else { failed++; Console.WriteLine("FAIL  " + name); }
    }

    public static int Main()
    {
        Console.OutputEncoding = System.Text.Encoding.UTF8;

        {
            var rules = new MatchRules();
            rules.ApplyDamage(MatchRules.DamagePerShot);
            Check("one shot: health drops from 100 to 75", rules.Health == 75);
        }
        {
            var rules = new MatchRules();
            bool died = false;
            for (int i = 0; i < 4; i++) died = rules.ApplyDamage(MatchRules.DamagePerShot);
            Check("four shots: player dies on the fourth", died && rules.IsDead);
            Check("four shots: death is counted", rules.Deaths == 1);
        }
        {
            var rules = new MatchRules();
            for (int i = 0; i < 4; i++) rules.ApplyDamage(MatchRules.DamagePerShot);
            rules.ApplyDamage(MatchRules.DamagePerShot);
            Check("damage while dead: no extra death counted", rules.Deaths == 1);
        }
        {
            var rules = new MatchRules();
            for (int i = 0; i < 4; i++) rules.ApplyDamage(MatchRules.DamagePerShot);
            rules.Tick(1f);
            Check("respawn: still dead after 1 second", rules.IsDead);
            bool respawned = rules.Tick(2.5f);
            Check("respawn: back to full health after 3 seconds", respawned && rules.Health == MatchRules.MaxHealth);
        }
        {
            var rules = new MatchRules();
            for (int i = 0; i < MatchRules.KillsToWin - 1; i++) rules.AddKill();
            Check("win: not yet at 9 kills", rules.HasWon() == false);
            rules.AddKill();
            Check("win: wins at 10 kills", rules.HasWon());
        }

        Console.WriteLine();
        Console.WriteLine("Passed " + passed + " / Failed " + failed);
        return failed == 0 ? 0 : 1;
    }
}

วิธีคอมไพล์และรัน: คัดลอก `MatchRules.cs` และ `MatchRulesTests.cs` ไปไว้ในโฟลเดอร์เดียวกัน แล้วคอมไพล์ด้วยคอมไพเลอร์ C# รุ่นใหม่ (เช่น `csc -langversion:latest -out:MatchRulesTests.exe *.cs`) จากนั้นรัน `MatchRulesTests.exe`

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** จะได้ผู้เล่นหนึ่งคน

2. เปิดอีกหน้าต่างของ Unity ด้วยโปรเจกต์เดียวกัน (หรือใช้ **ParrelSync** ซึ่งเป็นเครื่องมือยอดนิยมสำหรับเปิดหลายตัวอย่างของเอดิเตอร์) แล้วกด **Start Client**

3. ตรวจว่าผู้เล่นทั้งสองเห็นตัวของกันและกันและเคลื่อนที่ได้เฉพาะตัวเอง

4. ยิงผู้เล่นอีกคนสี่ครั้ง ตรวจว่าเลือดลดบนทั้งสองหน้าจอและผู้ยิงได้คะแนน

5. ตรวจว่าหลังตายแล้วฟื้นคืนชีพหลังสามวินาที

ปัญหาที่พบบ่อย

ข้อจำกัดของบทความนี้

สำหรับเกมที่ต้องใช้งานจริงกับผู้เล่นจำนวนมาก ควรศึกษาเรื่องเหล่านี้เพิ่มเติม

สรุป

เกม FPS ออนไลน์ใช้หลักการหลัก คือเซิร์ฟเวอร์ตัดสิน ไคลเอนต์ส่งคำขอ และค่าที่ทุกคนเห็นเขียนได้จากเซิร์ฟเวอร์เท่านั้น กติกาถูกแยกเป็นคลาสที่ทดสอบได้ ส่วนเครือข่ายใช้ NGO ตามขั้นตอนข้างต้น ผลการทดสอบกติกาผ่านทั้งหมด ส่วนเครือข่ายจริงต้องทดสอบเองใน Unity Editor