สร้างเกม FPS ออนไลน์ด้วย Unity: หลายผู้เล่นผ่านเครือข่าย ระบบเซิร์ฟเวอร์ตัดสิน และการทดสอบ
เกม FPS ออนไลน์คือเกมที่ผู้เล่นหลายคนอยู่ในแมตช์เดียวกันผ่านเครือข่าย บทความนี้ใช้ **Netcode for GameObjects** (NGO) ของ Unity ซึ่งเป็นระบบเครือข่ายอย่างเป็นทางการ และจะสร้างสิ่งต่อไปนี้ทีละขั้น ได้แก่ การเริ่มเป็นโฮสต์หรือไคลเอนต์ ตัวละครผู้เล่นที่ส่งข้อมูลถึงกัน ระบบยิงที่เซิร์ฟเวอร์เป็นผู้ตัดสิน การฟื้นคืนชีพ และการนับคะแนน
หลักการสำคัญที่สุดของเกมออนไลน์คือ **เซิร์ฟเวอร์เป็นผู้มีอำนาจตัดสิน (server authority)** ไคลเอนต์จะบอกว่าตัวเองกดยิง แต่ไม่มีสิทธิ์บอกว่าโดนหรือไม่ และเลือดเท่าไร บทความนี้ออกแบบตามหลักการนั้น
หมายเหตุสำคัญ: ตรรกะของกติกาแมตช์ (ดาเมจ ความตาย ฟื้นคืนชีพ การนับคะแนน) ถูกทดสอบจริงด้วยการคอมไพล์และรัน 8 ข้อ ผ่านทั้งหมด แต่ส่วนที่ทำงานกับเครือข่ายจริง (NGO) ไม่ได้ทดสอบในเครื่อง เพราะต้องใช้ Unity Editor และผู้เล่นอย่างน้อยสองคน ควรทดสอบตามขั้นตอนในบทความก่อนนำไปใช้
สิ่งที่ต้องเตรียม
- Unity Hub และ Unity Editor รุ่น LTS ล่าสุด
- โปรเจกต์ FPS ที่ทำตามบทความ **สร้างเกม FPS ด้วย Unity** แล้ว (มีสคริปต์ `MouseLook`, `FPSMovement`, `Weapon`, `Target`)
- อินเทอร์เน็ตหรือเครือข่าย LAN เดียวกันสำหรับเล่นข้ามเครื่อง
ขั้นที่ 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. คลิกขวาใน 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
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: สคริปต์ผู้เล่นออนไลน์
นี่คือหัวใจของระบบ มีสามส่วนสำคัญ
- **ฝั่งผู้เล่น** รับการกดปุ่มยิงแล้วส่งคำขอไปเซิร์ฟเวอร์ผ่าน `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;
}
}
}
จุดที่ควรเข้าใจ
- ชื่อเมธอด `FireServerRpc` ต้องลงท้ายด้วย `ServerRpc` เพราะ NGO ใช้ชื่อนี้ระบุว่าเป็นคำขอไปยังเซิร์ฟเวอร์
- `NetworkVariableWritePermission.Server` ทำให้ไคลเอนต์เขียนเลือดหรือคะแนนไม่ได้ แม้จะพยายามแก้จากโค้ดก็ตาม
- การเช็คว่า "คนตายยิงไม่ได้" ทำที่เซิร์ฟเวอร์ ไม่ใช่ที่ไคลเอนต์ เพราะไคลเอนต์อาจถูกแก้โค้ดได้
- เมื่อดาเมจทำให้เลือดเป็นศูนย์ เซิร์ฟเวอร์ให้คะแนนผู้ยิงทันที
- `Mathf.Max` ไม่ใช้กับ int เหมือนที่เคยพบในบทความอื่น จึงใช้การเช็คเอง
ขั้นที่ 6: ให้เฉพาะเจ้าของควบคุมและเห็นกล้อง
ผู้เล่นทุกคนจะมี 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: ภาพการไหลของข้อมูล
ลำดับการทำงานเมื่อผู้เล่น 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. ตรวจว่าหลังตายแล้วฟื้นคืนชีพหลังสามวินาที
ปัญหาที่พบบ่อย
- **ผู้เล่นอีกคนไม่ปรากฏ:** ตรวจว่า Player prefab ถูกลากเข้าช่อง Player Prefab ของ NetworkManager แล้ว และมี Network Object
- **ควบคุมตัวละครของคนอื่นได้:** ตรวจว่า FPSMovement และ MouseLook อยู่ในช่อง Owner Only ของ OwnerSetup
- **ยิงแล้วไม่โดน:** ตรวจว่าช่อง Hit Mask รวมเลเยอร์ของผู้เล่น และ Muzzle ชี้ไปข้างหน้า
- **Console ขึ้นว่าชื่อ RPC ไม่ถูกต้อง:** ตรวจว่าชื่อเมธอดลงท้ายด้วย `ServerRpc`
- **เล่นข้ามเครื่องไม่ได้:** ตรวจว่าที่อยู่ของ Unity Transport ตรงกับ IP ของโฮสต์ และไฟร์วอลล์ของโฮสต์อนุญาตพอร์ตที่ใช้
- **เลือดไม่ซิงก์:** ตรวจว่าเขียน `Health.Value` จากเซิร์ฟเวอร์เท่านั้น ถ้าเขียนจากไคลเอนต์จะเกิดข้อผิดพลาด
ข้อจำกัดของบทความนี้
- ระบบยิงใช้รังสีเดียวและไม่มีการทำนายฝั่งไคลเอนต์ (client-side prediction) ทำให้ผู้เล่นที่เน็ตช้าจะรู้สึกว่าการยิงตอบสนองช้า เกมจริงมักเพิ่มการชดเชยความหน่วง (lag compensation)
- การซิงก์ตำแหน่งใช้ Network Transform เฉย ๆ ไม่มีการทำนายการเคลื่อนที่
- ไม่มีระบบห้อง ล็อบบี้ หรือการจับคู่ผู้เล่น
- ไม่ได้ตรวจสอบการโกง (anti-cheat) อย่างเต็มรูปแบบ
สำหรับเกมที่ต้องใช้งานจริงกับผู้เล่นจำนวนมาก ควรศึกษาเรื่องเหล่านี้เพิ่มเติม
สรุป
เกม FPS ออนไลน์ใช้หลักการหลัก คือเซิร์ฟเวอร์ตัดสิน ไคลเอนต์ส่งคำขอ และค่าที่ทุกคนเห็นเขียนได้จากเซิร์ฟเวอร์เท่านั้น กติกาถูกแยกเป็นคลาสที่ทดสอบได้ ส่วนเครือข่ายใช้ NGO ตามขั้นตอนข้างต้น ผลการทดสอบกติกาผ่านทั้งหมด ส่วนเครือข่ายจริงต้องทดสอบเองใน Unity Editor