بناء لعبة FPS أونلاين في Unity: تعدد اللاعبين، وسلطة الخادم، والاختبار

تجمع لعبة FPS الأونلاين عدة لاعبين في المباراة نفسها عبر الشبكة. تستخدم هذه المقالة **Netcode for GameObjects** (NGO)، وهي حزمة الشبكات الرسمية في Unity، لبناء الأساسيات: البدء كمضيف أو عميل، وكائنات لاعبين تتزامن بين الأجهزة، وإطلاق نار يحكم فيه الخادم، وإعادة الظهور، وحساب القتلى.

القاعدة الأهم في الألعاب الأونلاين هي **سلطة الخادم (server authority)**. يستطيع العميل أن يقول إنه أطلق، لكنه لا يقرر إن كانت الطلقة أصابت أو كم صحة اللاعب. يتبع الكود التالي هذه القاعدة.

مهم: قواعد المباراة (الضرر والموت وإعادة الظهور والنقاط) تمت ترجمتها وتشغيلها في هذه البيئة: 8 اختبارات نجحت كلها. أما الجزء الشبكي (NGO) فلم يُشغَّل هنا، لأنه يحتاج إلى محرر Unity ولاعبين اثنين على الأقل. اتبع الخطوات واختبره بنفسك قبل الاعتماد عليه.

ملاحظة: يحتفظ الكود بالتعليقات التايلاندية من النسخة الأصلية.

ما الذي تحتاجه

الخطوة 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 كائن 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** فيظهر زران في الزاوية العلوية اليسرى.

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: إنشاء بريفاب اللاعب

مخطط Inspector لبريفاب اللاعب (وليس لقطة شاشة حقيقية): 1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 حقل Camera Object
مخطط Inspector لبريفاب اللاعب (وليس لقطة شاشة حقيقية): 1 Network Object 2 Network Transform 3 Player Network 4 Owner Setup 5 حقل Camera Object

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: سكربت اللاعب الأونلاين

يتكون من ثلاثة أجزاء:

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

لكل لاعب نسخة من بريفاب اللاعب. بدون هذه الخطوة يمكن لأحد اللاعبين أن يتحكم بشخصية غيره ويرى كاميرات زائدة. يتولى `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 إلى صفر، يمنح الخادم 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. تحقق من أن اللاعب يعود بعد ثلاث ثوانٍ من موته.

مشكلات شائعة

حدود هذه المقالة

ادرس هذه الموضوعات قبل إصدار لعبة لعدد كبير من اللاعبين.

الخلاصة

تعتمد ألعاب FPS الأونلاين على سلطة الخادم: يرسل العملاء الطلبات، ويقرر الخادم، والقيم التي يراها الجميع يكتبها الخادم وحده. توضع القواعد في صنف عادي يمكن اختباره، وتستخدم الشبكة NGO كما شرحنا أعلاه. اجتازت اختبارات القواعد هنا، أما اللعب الشبكي فيجب اختباره في محرر Unity.