بناء لعبة FPS أونلاين في Unity: تعدد اللاعبين، وسلطة الخادم، والاختبار
تجمع لعبة FPS الأونلاين عدة لاعبين في المباراة نفسها عبر الشبكة. تستخدم هذه المقالة **Netcode for GameObjects** (NGO)، وهي حزمة الشبكات الرسمية في Unity، لبناء الأساسيات: البدء كمضيف أو عميل، وكائنات لاعبين تتزامن بين الأجهزة، وإطلاق نار يحكم فيه الخادم، وإعادة الظهور، وحساب القتلى.
القاعدة الأهم في الألعاب الأونلاين هي **سلطة الخادم (server authority)**. يستطيع العميل أن يقول إنه أطلق، لكنه لا يقرر إن كانت الطلقة أصابت أو كم صحة اللاعب. يتبع الكود التالي هذه القاعدة.
مهم: قواعد المباراة (الضرر والموت وإعادة الظهور والنقاط) تمت ترجمتها وتشغيلها في هذه البيئة: 8 اختبارات نجحت كلها. أما الجزء الشبكي (NGO) فلم يُشغَّل هنا، لأنه يحتاج إلى محرر Unity ولاعبين اثنين على الأقل. اتبع الخطوات واختبره بنفسك قبل الاعتماد عليه.
ملاحظة: يحتفظ الكود بالتعليقات التايلاندية من النسخة الأصلية.
ما الذي تحتاجه
- Unity Hub ومحرر Unity بأحدث إصدار LTS
- مشروع FPS من مقالة **بناء لعبة تصويب من منظور الشخص الأول في Unity** (يحتوي على `MouseLook` و`FPSMovement` و`Weapon` و`Target`)
- إنترنت أو شبكة محلية مشتركة للعب عبر الأجهزة
الخطوة 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. أنشئ كائنًا فارغًا آخر باسم `NetworkBoot` وأضف إليه سكربت `NetworkBoot` أدناه.
4. اضغط **Play** فيظهر زران في الزاوية العلوية اليسرى.
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: إنشاء بريفاب اللاعب
1. ابدأ من كائن `Player` في مشروع FPS (ويحتوي على Character Controller وFPSMovement وMouseLook وكاميرا تابعة مع Muzzle).
2. أضف **Network Object**، وهذا يخبر NGO أن الكائن يُزامَن.
3. أضف **Network Transform** لمزامنة الموقع والدوران على كل الأجهزة.
4. احذف مكوّن `Weapon`، فالإطلاق ينتقل إلى `PlayerNetwork`.
5. أضف سكربتي `PlayerNetwork` و`OwnerSetup` (أدناه).
6. اسحب `Player/Camera/Muzzle` إلى حقل **Muzzle** في `PlayerNetwork`، واسحب الكاميرا التابعة إلى حقل **Camera Object** في `OwnerSetup`.
7. في `OwnerSetup` أضف `FPSMovement` و`MouseLook` إلى **Owner Only** حتى يتحكم بهما صاحب الشخصية فقط.
8. اسحب `Player` من Hierarchy إلى `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` العملاء من تغيير الصحة أو النقاط، حتى لو عدّلوا الكود بأنفسهم.
- التحقق من أن "الميت لا يطلق" يتم على الخادم وليس على العميل، لأن العميل قد يُعدَّل.
- عندما يصل الضرر بالصحة إلى صفر، يمنح الخادم القتل لمن أطلق فورًا.
- يتجنب الكود استخدام `Mathf.Max` مع int، وهو الخطأ نفسه الذي ظهر في مقالات أخرى من هذه السلسلة.
الخطوة 6: التحكم للمالك فقط والكاميرا
لكل لاعب نسخة من بريفاب اللاعب. بدون هذه الخطوة يمكن لأحد اللاعبين أن يتحكم بشخصية غيره ويرى كاميرات زائدة. يتولى `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 إلى صفر، يمنح الخادم 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)
لم يُشغَّل هذا الجزء هنا. افعله بنفسك:
1. اضغط **Play** في المحرر ثم **Start Host**. ستكون اللاعب الأول.
2. افتح نافذة Unity ثانية للمشروع نفسه، أو استخدم **ParrelSync** (أداة شائعة لتشغيل عدة نسخ من المحرر)، واضغط **Start Client**.
3. تحقق من أن كل لاعب يرى الآخر، وأن كلًا منهما يحرك شخصيته فقط.
4. أطلق على الآخر أربع مرات، وتحقق من انخفاض الصحة على الشاشتين وحصول المطلِق على قتلة.
5. تحقق من أن اللاعب يعود بعد ثلاث ثوانٍ من موته.
مشكلات شائعة
- **اللاعب الآخر لا يظهر:** تحقق من أن بريفاب اللاعب موجود في حقل Player Prefab الخاص بـ NetworkManager، وأن له Network Object.
- **تتحكم بشخصية لاعب آخر:** تحقق من وجود FPSMovement وMouseLook في قائمة Owner Only الخاصة بـ OwnerSetup.
- **الطلقات تخطئ:** تحقق من أن Hit Mask يشمل اللاعبين والجدران، وأن Muzzle موجَّه إلى الأمام.
- **تظهر رسالة بأن اسم RPC غير صالح:** تحقق من أن اسم الدالة ينتهي بـ `ServerRpc`.
- **لا يمكن الانضمام عبر الأجهزة:** تحقق من أن عنوان Unity Transport يطابق IP المضيف، وأن جدار الحماية يسمح بالمنفذ المستخدم.
- **الصحة لا تتزامن:** تحقق من أن الخادم وحده يكتب في `Health.Value`.
حدود هذه المقالة
- الإطلاق عبارة عن تتبع شعاع واحد دون تنبؤ من جهة العميل (client-side prediction). اللاعبون ذوو التأخير العالي سيشعرون بتأخر الاستجابة. تضيف الألعاب الحقيقية تعويض التأخير (lag compensation).
- مزامنة الحركة تعتمد على Network Transform وحده، دون تنبؤ بالحركة.
- لا توجد ردهة أو نظام غرف أو مطابقة لاعبين.
- مكافحة الغش ليست مكتملة.
ادرس هذه الموضوعات قبل إصدار لعبة لعدد كبير من اللاعبين.
الخلاصة
تعتمد ألعاب FPS الأونلاين على سلطة الخادم: يرسل العملاء الطلبات، ويقرر الخادم، والقيم التي يراها الجميع يكتبها الخادم وحده. توضع القواعد في صنف عادي يمكن اختباره، وتستخدم الشبكة NGO كما شرحنا أعلاه. اجتازت اختبارات القواعد هنا، أما اللعب الشبكي فيجب اختباره في محرر Unity.