بناء لعبة تصويب من منظور الشخص الأول (FPS) في Unity: التحكم بالنظر، والحركة، والإطلاق، والاختبار

لعبة التصويب من منظور الشخص الأول (FPS) تعرض العالم من عيني الشخصية. الأنظمة الأساسية فيها هي التحكم بالنظر بالفأرة، والحركة بمفاتيح WASD، والإطلاق بالأشعة، والأهداف التي لها صحة. تبني هذه المقالة كل نظام خطوة بخطوة في Unity، من مشروع جديد حتى كود ينجح في الاختبارات.

ملاحظة: يحتفظ الكود بالتعليقات التايلاندية من النسخة الأصلية. لم يُشغَّل في محرر Unity. تعمل الاختبارات على واجهة Unity وهمية كُتبت لهذه المقالة، وتؤكد منطق النص البرمجي فقط. ملف الواجهة الوهمية كاملًا موجود في النسخة التايلاندية من المقالة.

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

الخطوة 1: إنشاء مشروع ثلاثي الأبعاد وإعداد المدخلات

1. افتح Unity Hub واضغط **New project**، واختر قالب **Universal 3D** (URP) أو **3D** (Built-in)، وسمِّ المشروع `MyFPS`.

2. انتقل إلى **Edit → Project Settings → Player**، واجعل **Active Input Handling** هو **Both** أو **Input Manager (Old)**، ثم أعد تشغيل المحرر عند الطلب.

3. تحقق من وجود المحاور **Mouse X** و**Mouse Y** و**Horizontal** و**Vertical** وزر **Jump** في **Edit → Project Settings → Input Manager**. هي موجودة في القوالب القياسية.

الخطوة 2: إنشاء الأرضية والهدف

1. انقر بزر الفأرة الأيمن في Hierarchy واختر **3D Object → Plane**. سمّه `Ground` واجعل Scale هو `X = 5, Y = 1, Z = 5`.

2. انقر مرة أخرى واختر **3D Object → Cube**. سمّه `Target` وضعه على بُعد نحو 10 أمتار، مثلًا Position `Z = 10` و`Y = 1`.

3. أضف مكون **Target** (وسنكتبه في الخطوة 6) إلى المكعب. يحتوي المكعب على Box Collider، وهو لازم حتى يصطدم الشعاع به.

الخطوة 3: إنشاء اللاعب

1. انقر بزر الفأرة الأيمن في Hierarchy واختر **Create Empty**، وسمّه `Player` واجعل Position هو `Y = 1`.

2. أضف **Character Controller** إلى `Player`. يحرّك الشخصية دون الحاجة إلى Rigidbody.

3. انقر بزر الفأرة الأيمن على `Player` واختر **Camera** لتصبح ابنًا له. اجعل Position هو `Y = 0.6` لتكون الرؤية على مستوى العين.

4. احذف Main Camera الأصلية، لأن الكاميرا التابعة تحل محلها.

البنية هي `Player` ← `Camera`. وسيحمل `Player` لاحقًا المكونات `MouseLook` و`FPSMovement` و`Weapon`.

الخطوة 4: التحكم بالنظر بالفأرة

أنشئ نصًا برمجيًا باسم `MouseLook` والصق فيه الكود التالي.

using UnityEngine;

// หมุนกล้องตามเมาส์: ก้ม-เงยที่กล้อง (pitch) และหันซ้าย-ขวาที่ตัวละคร (yaw)
public class MouseLook : MonoBehaviour
{
    [SerializeField] private float sensitivity = 2f;   // ความไวเมาส์
    [SerializeField] private float minPitch = -80f;    // ก้มลงได้สุดกี่องศา
    [SerializeField] private float maxPitch = 80f;     // เงยขึ้นได้สุดกี่องศา
    [SerializeField] private Transform body;           // ตัวละครที่ต้องหมุนตามแนวนอน

    private float pitch;

    public float Pitch => pitch;

    private void Update()
    {
        float mouseX = Input.GetAxis("Mouse X") * sensitivity;
        float mouseY = Input.GetAxis("Mouse Y") * sensitivity;

        // เมาส์ขึ้นคือเงยหน้า จึงต้องลบค่า mouseY
        pitch = Mathf.Clamp(pitch - mouseY, minPitch, maxPitch);
        transform.localRotation = Quaternion.Euler(pitch, 0f, 0f);

        body.Rotate(0f, mouseX, 0f);
    }
}

الأفكار الأساسية:

في Inspector اسحب `Player` إلى حقل **Body** الخاص بـ `MouseLook` الموجود على الكاميرا.

الخطوة 5: الحركة بـ WASD والقفز

أنشئ نصًا برمجيًا باسم `FPSMovement` والصق فيه الكود التالي، ثم ألحقه بـ `Player`.

using UnityEngine;

// เดินแบบ FPS ด้วย WASD โดยใช้ CharacterController (ไม่ใช้ Rigidbody)
[RequireComponent(typeof(CharacterController))]
public class FPSMovement : MonoBehaviour
{
    [SerializeField] private float speed = 6f;        // ความเร็วเดิน
    [SerializeField] private float jumpHeight = 1.5f; // ความสูงกระโดด (เมตร)
    [SerializeField] private float gravity = -20f;    // แรงโน้มถ่วง

    private CharacterController controller;
    private float verticalVelocity;

    public bool IsGrounded => controller.isGrounded;

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

    private void Update()
    {
        // แกน x คือซ้าย-ขวา และแกน z คือหน้า-หลัง ทั้งคู่อิงทิศทางที่ตัวละครหันอยู่
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        Vector3 move = transform.right * h + transform.forward * v;

        // จำกัดความยาวเวกเตอร์ไม่ให้เดินเฉียงเร็วกว่าเดินตรง
        if (move.magnitude > 1f) move = move.normalized;
        controller.Move(move * speed * Time.deltaTime);

        // แรงโน้มถ่วงและการกระโดด
        if (controller.isGrounded && verticalVelocity < 0f)
        {
            verticalVelocity = -2f;                       // กดให้ติดพื้นเล็กน้อย
        }
        if (Input.GetButtonDown("Jump") && controller.isGrounded)
        {
            verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
        }
        verticalVelocity += gravity * Time.deltaTime;
        controller.Move(new Vector3(0f, verticalVelocity * Time.deltaTime, 0f));
    }
}

الأفكار الأساسية:

الخطوة 6: سلاح بالأشعة وهدف له صحة

يطلق السلاح الفوري (hitscan) شعاعًا إلى الأمام من فوهة السلاح. إذا اصطدم الشعاع بشيء يحمل مكون `Target`، تنقص صحة هذا الهدف.

أنشئ نصًا برمجيًا باسم `Weapon` وألحقه بـ `Player`.

using UnityEngine;

// ปืนแบบ hitscan: ยิงรังสีจากปากกระบอกปืน ถ้าโดนเป้าหมายก็ลดเลือด
public class Weapon : MonoBehaviour
{
    [SerializeField] private Transform muzzle;        // จุดปากกระบอกปืน
    [SerializeField] private float range = 100f;      // ระยะยิงสูงสุด
    [SerializeField] private int damage = 25;         // ดาเมจต่อนัด
    [SerializeField] private float fireInterval = 0.2f; // เวลาขั้นต่ำระหว่างนัด (วินาที)
    [SerializeField] private int maxAmmo = 10;        // กระสุนเต็มซอง

    private int ammo;
    private float nextFireTime;

    public int Ammo => ammo;

    private void Awake()
    {
        ammo = maxAmmo;
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            TryShoot();
        }
    }

    // คืน true ถ้ายิงจริง, false ถ้ากระสุนหมดหรือยังยิงไม่ได้
    public bool TryShoot()
    {
        if (ammo <= 0 || Time.time < nextFireTime) return false;

        ammo--;
        nextFireTime = Time.time + fireInterval;

        RaycastHit hit;
        if (Physics.Raycast(muzzle.position, muzzle.forward, out hit, range))
        {
            Target target = hit.collider.GetComponent<Target>();
            if (target != null)
            {
                target.TakeDamage(damage);
            }
        }
        return true;
    }
}

أنشئ كائنًا فارغًا باسم `Muzzle` كابن للكاميرا، وضعه أمام السلاح، ثم اسحبه إلى حقل **Muzzle** الخاص بـ `Weapon`.

أنشئ نصًا برمجيًا باسم `Target` وألحقه بالمكعب.

using UnityEngine;

// เป้าหมายที่ถูกยิงได้: มีเลือด เมื่อเลือดหมดจะตาย
public class Target : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    private int health;

    public int Health => health;
    public bool IsDead => health <= 0;

    private void Awake()
    {
        health = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        if (IsDead) return;

        health -= amount;
        if (health < 0) health = 0;
        if (IsDead)
        {
            Die();
        }
    }

    private void Die()
    {
        // ในเกมจริงอาจใช้ Destroy(gameObject) หรือเล่นแอนิเมชันตาย
        gameObject.SetActive(false);
    }
}

ملاحظة على الكود: كانت النسخة الأولى من `Target` تكتب `health = Mathf.Max(0, health - amount);`. هذا الكود لا يُترجم، لأن `Mathf.Max` تعيد قيمة من نوع float بينما `health` من نوع int. لاحظ المترجم الخطأ قبل أي اختبار تشغيل. النسخة أعلاه تطرح القيمة مباشرة وتمنع النزول دون الصفر.

الخطوة 7: الاختبارات الآلية

يستخدم إطار الاختبار واجهة Unity وهمية (`UnityStubs.cs`، وهي كاملة في النسخة التايلاندية من المقالة) وملف الاختبار `FPSTests.cs`. يتحقق الاختبار من النظر بالفأرة، والحركة، والقفز، والإطلاق، وفاصل الإطلاق، وحد الذخيرة، وموت الهدف.

التجميع والتشغيل:

1. أنشئ مجلدًا باسم `build` وانسخ إليه `UnityStubs.cs` و`FPSTests.cs` والنصوص البرمجية الأربعة.

2. في النسخ فقط احذف السطر `using UnityEngine;`. أبقه في النصوص الحقيقية داخل مشروع Unity.

3. جمّع الملفات بمترجم C# حديث (مثلًا `csc -langversion:latest -out:FPSTests.exe *.cs`) ثم شغّل `FPSTests.exe`.

نتائج الاختبار

PASS  mouse up: pitch becomes -2 (looking up)
PASS  mouse up for long: pitch stops at minPitch (-80)
PASS  mouse right: body turns 2 degrees
PASS  walk forward 1 second: moves about 6 units
PASS  diagonal walk: speed capped at about 6 (not 8.5)
PASS  jump from ground: rises above the ground in the first frame
PASS  shoot: fires when ammo is available
PASS  shoot hit: target health drops by damage (100 to 75)
PASS  shoot: ammo decreases from 10 to 9
PASS  fire interval: second shot within 0.2 s is blocked
PASS  fire interval: ammo is not spent on the blocked shot
PASS  4 hits of 25: target is dead
PASS  4 hits of 25: target object is deactivated
PASS  empty magazine: after 10 shots the 11th is refused

Passed 14 / Failed 0

هذه النتائج جاءت من الواجهة الوهمية وليس من Unity. الفيزياء والتصادمات والرسوم المتحركة وسلوك الكاميرا الحقيقي ما زالت تحتاج إلى التحقق بالضغط على Play في المحرر.

محاكاة لواجهة اللعبة (وليست عرضًا حقيقيًا): الهدف في المنتصف والصليب والسلاح في اليد وشاشة HUD تعرض الذخيرة وصحة الهدف
محاكاة لواجهة اللعبة (وليست عرضًا حقيقيًا): الهدف في المنتصف والصليب والسلاح في اليد وشاشة HUD تعرض الذخيرة وصحة الهدف
عرض لنتيجة الاختبار الفعلية: نجح 14، وفشل 0 (تشغيل على الواجهة الوهمية وليس محرر Unity)
عرض لنتيجة الاختبار الفعلية: نجح 14، وفشل 0 (تشغيل على الواجهة الوهمية وليس محرر Unity)

الخطوة 8: الاختبار داخل المحرر

اضغط **Play** وتحقق من:

مشكلات شائعة

الخلاصة

يتكون FPS من أربعة أنظمة رئيسية: النظر بالكاميرا، والحركة، والإطلاق بالأشعة، والأهداف التي لها صحة. كل نظام نص برمجي صغير يمكن اختباره على حدة. كشف الاختبار خطأ تجميع قبل أي اختبار تشغيل، مما يوفر وقتًا لاحقًا. الخطوات التالية تشمل أعداءً يتحركون، وعدّاد ذخيرة على الشاشة، وإعادة التعبئة.