بناء لعبة ثنائية الأبعاد في Unity من الصفر: إضافة الشخصية وكتابة الكود والتشغيل والاختبار

يُعد Unity من أكثر محركات الألعاب استخدامًا، سواء للمبتدئين أو للفرق المحترفة. تصلح اللعبة ثنائية الأبعاد مكانًا جيدًا لتعلم المفاهيم الأساسية: المشاهد وكائنات GameObject والمكونات والفيزياء وقراءة مدخلات اللاعب، دون تعقيد كبير. تأخذك هذه المقالة خلال إنشاء مشروع جديد، ووضع الشخصية في المشهد، وكتابة نصوص برمجية تجعل الشخصية تمشي وتقفز، وجعل الكاميرا تتبعها، ثم اختبار أن الكود يعمل بشكل صحيح.

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

مخطط لنافذة Unity (وليس لقطة شاشة حقيقية): 1 زر Play في الأعلى، 2 علامة + في Hierarchy لإنشاء الكائنات، 3 زر Add Component في Inspector، 4 لوحة Project لإنشاء النصوص البرمجية
مخطط لنافذة Unity (وليس لقطة شاشة حقيقية): 1 زر Play في الأعلى، 2 علامة + في Hierarchy لإنشاء الكائنات، 3 زر Add Component في Inspector، 4 لوحة Project لإنشاء النصوص البرمجية

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

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

1. افتح Unity Hub واضغط **New project**، ثم اختر القالب **Universal 2D** (URP) أو **2D** (Built-in).

2. سمّ المشروع، مثل `MyPlatformer`، واضغط **Create project**.

3. انتظر حتى يفتح Unity، وستجد مشهدًا فارغًا اسمه `SampleScene`.

المشهد هو ساحة اللعب. كل ما فيه، مثل الشخصية والأرضية والكاميرا، هو **GameObject**، والمكونات المرفقة به هي التي تحدد سلوكه.

مخطط لمسار Edit → Project Settings → Player → Active Input Handling (وليس لقطة شاشة حقيقية): 1 قائمة Edit، 2 Project Settings، 3 تبويب Player، 4 قائمة Active Input Handling المنسدلة
مخطط لمسار Edit → Project Settings → Player → Active Input Handling (وليس لقطة شاشة حقيقية): 1 قائمة Edit، 2 Project Settings، 3 تبويب Player، 4 قائمة Active Input Handling المنسدلة

الخطوة 2: إعداد المدخلات

يستخدم النص البرمجي في هذه المقالة نظام المدخلات التقليدي (Input Manager). لتفعيله:

1. انتقل إلى **Edit → Project Settings → Player**.

2. ابحث عن **Active Input Handling** واختر **Both** أو **Input Manager (Old)**.

3. وافق على طلب Unity بإعادة تشغيل المحرر.

المحور **Horizontal** (يمين ويسار) والزر **Jump** (القفز، أي مفتاح المسافة) موجودان في الإعداد الافتراضي. الحركة يمينًا ويسارًا تتم بالمفاتيح A وD أو مفاتيح الأسهم.

الخطوة 3: إنشاء الأرضية

1. انقر بزر الفأرة الأيمن في Hierarchy واختر **2D Object → Sprites → Square**، وسمّه `Ground`.

2. في Inspector اجعل Scale هو `X = 20` و`Y = 1` لتكوين أرضية طويلة.

3. اجعل Position هو `X = 0` و`Y = -2`.

4. اضغط **Add Component** وأضف **Box Collider 2D**، ليتمكن اللاعب من الوقوف عليها.

5. أنشئ طبقة (Layer) باسم `Ground`: اضغط القائمة المنسدلة **Layer** في أعلى Inspector، واختر **Add Layer**، ثم ارجع إلى الكائن `Ground` واختر الطبقة `Ground`.

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

الخطوة 4: إضافة الشخصية

1. انقر بزر الفأرة الأيمن في Hierarchy واختر **2D Object → Sprites → Square**، وسمّه `Player`.

2. اجعل Position هو `X = 0` و`Y = 1` ليبدأ اللاعب فوق الأرضية.

3. إن كانت لديك صورة للشخصية، اسحب ملف PNG إلى حقل **Sprite** في Sprite Renderer.

4. أضف **Rigidbody 2D** واجعل **Body Type** هو Dynamic، وفعّل **Freeze Rotation Z** حتى لا تسقط الشخصية على جانبها.

5. أضف **Box Collider 2D**.

يجعل Rigidbody 2D الكائن يسقط بفعل الجاذبية ويصطدم بالكائنات الأخرى. بدونه ستبقى الشخصية معلّقة في مكانها.

الخطوة 5: إنشاء نقطة فحص الأرضية

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

2. انقله إلى قدم الشخصية، مثلًا Position هو `X = 0` و`Y = -0.5`.

يستخدم النص البرمجي هذه النقطة مركزًا لدائرة صغيرة يفحص بها وجود أرضية أسفل الشخصية.

الخطوة 6: كتابة نص التحكم بالشخصية

انقر بزر الفأرة الأيمن في نافذة Project واختر **Create → C# Script**، وسمّه `PlayerController2D`، ثم افتحه والصق الكود التالي.

using UnityEngine;

// ควบคุมตัวละคร 2D: เดินซ้ายขวา กระโดด และหันหน้าตามทิศทาง
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(SpriteRenderer))]
public class PlayerController2D : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;      // ความเร็วเดิน
    [SerializeField] private float jumpForce = 12f;     // แรงกระโดด
    [SerializeField] private Transform groundCheck;     // จุดตรวจพื้น
    [SerializeField] private float groundRadius = 0.15f;
    [SerializeField] private LayerMask groundLayer;     // เลเยอร์ที่นับว่าเป็นพื้น

    private Rigidbody2D rb;
    private SpriteRenderer sprite;
    private float moveInput;
    private bool isGrounded;
    private bool jumpRequested;

    public bool IsGrounded => isGrounded;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        sprite = GetComponent<SpriteRenderer>();
    }

    // Update ทำงานทุกเฟรม ใช้อ่านปุ่มกด แต่ยังไม่กระโดดทันที
    // เพราะค่า isGrounded ถูกอัปเดตใน FixedUpdate ถ้ากระโดดใน Update อาจใช้สถานะพื้นเก่า
    private void Update()
    {
        moveInput = Input.GetAxisRaw("Horizontal");   // -1 ซ้าย, 0 ไม่กด, 1 ขวา

        if (Input.GetButtonDown("Jump"))
        {
            jumpRequested = true;                      // จำไว้ว่าผู้เล่นกดกระโดด
        }

        if (moveInput != 0)
        {
            sprite.flipX = moveInput < 0;             // หันหน้าไปทางที่เดิน
        }
    }

    // FixedUpdate ทำงานตามจังหวะฟิสิกส์ ตรวจพื้นก่อน แล้วค่อยตัดสินใจกระโดด
    private void FixedUpdate()
    {
        // ถ้ายังไม่ได้ผูก groundCheck ใน Inspector ให้ใช้ตำแหน่งของตัวละครแทน
        Vector2 checkPosition = groundCheck != null ? groundCheck.position : transform.position;
        isGrounded = Physics2D.OverlapCircle(checkPosition, groundRadius, groundLayer);

        float verticalSpeed = rb.velocity.y;
        if (jumpRequested && isGrounded)
        {
            verticalSpeed = jumpForce;
        }
        jumpRequested = false;                         // ใช้คำขอกระโดดแล้ว

        rb.velocity = new Vector2(moveInput * moveSpeed, verticalSpeed);
    }
}

نقاط مهمة في الكود

مخطط Inspector للاعب (وليس لقطة شاشة حقيقية): 1 نص PlayerController2D البرمجي، 2 لوحة Rigidbody 2D مع تفعيل Freeze Rotation Z، 3 حقل Ground Check، 4 حقل Ground Layer
مخطط Inspector للاعب (وليس لقطة شاشة حقيقية): 1 نص PlayerController2D البرمجي، 2 لوحة Rigidbody 2D مع تفعيل Freeze Rotation Z، 3 حقل Ground Check، 4 حقل Ground Layer

الخطوة 7: ربط النص البرمجي

1. اسحب نص `PlayerController2D` البرمجي وأفلته على `Player`.

2. في Inspector اسحب `GroundCheck` إلى حقل **Ground Check**.

3. اجعل **Ground Layer** هو `Ground`.

4. اجعل **Move Speed** هو 5 و**Jump Force** هو 12، أو اختر القيم التي تفضلها.

الخطوة 8: جعل الكاميرا تتبع الشخصية

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

using UnityEngine;

// กล้องติดตามตัวละครแบบนุ่มนวล และไม่ให้ตกเกินขอบล่างของฉาก
public class CameraFollow2D : MonoBehaviour
{
    [SerializeField] private Transform target;          // ตัวละครที่จะติดตาม
    [SerializeField] private float smoothTime = 0.2f;   // ยิ่งน้อยยิ่งตามทันเร็ว
    [SerializeField] private float minY = -2f;          // ขอบล่างของกล้อง

    private Vector3 velocity;

    private void LateUpdate()
    {
        if (target == null) return;

        Vector3 desired = new Vector3(target.position.x, Mathf.Max(target.position.y, minY), transform.position.z);
        transform.position = Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime);
    }
}

تعمل `LateUpdate` بعد جميع استدعاءات Update، وهذا مناسب لكاميرا يجب أن تتبع شخصية تحركت للتو في الإطار نفسه. وتجعل `Vector3.SmoothDamp` الحركة سلسة دون اهتزاز.

اختر **Main Camera** وأضف `CameraFollow2D`، ثم اسحب `Player` إلى حقل **Target**، واجعل موضع Z للكاميرا هو `-10` ليكون كل ما في المشهد أمام الكاميرا.

الخطوة 9: تشغيل اللعبة

اضغط **Play** في أعلى المحرر، وجرّب ما يلي:

اضغط Play مرة أخرى للإيقاف. القيم التي تغيّرها في Inspector أثناء التشغيل تعود إلى أصلها عند الإيقاف، لذلك اضبط القيم أثناء الإيقاف، ثم احفظ المشروع.

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

يكفي الاختبار اليدوي للعبة صغيرة، لكن مع زيادة تعقيد الكود تكشف الاختبارات الآلية الأخطاء أسرع بكثير. يملك Unity إطار اختبار رسميًا اسمه Unity Test Framework، ويستحق التعلم. تستخدم هذه المقالة طريقة أبسط، وهي محاكاة حلقة إطارات Unity بلغة C# عادية، فتختبر منطق النص البرمجي دون فتح المحرر.

يستدعي الاختبار التالي Update وFixedUpdate بالترتيب، ويضبط المدخلات وحالة الأرضية، ويحرك الكائن حسب سرعته، ثم يتحقق من النتائج.

using System;

public static class PlayerTests
{
    private const float Dt = 1f / 50f;   // الاستدعاء الواحد لـ FixedUpdate يساوي 1/50 ثانية
    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); }
    }

    private static PlayerController2D CreatePlayer(out Rigidbody2D rb, out SpriteRenderer sprite)
    {
        var player = new PlayerController2D();
        rb = new Rigidbody2D();
        sprite = new SpriteRenderer();
        player.Components[typeof(Rigidbody2D)] = rb;
        player.Components[typeof(SpriteRenderer)] = sprite;
        player.Call("Awake");
        return player;
    }

    private static void Step(PlayerController2D player, Rigidbody2D rb)
    {
        player.Call("Update");
        player.Call("FixedUpdate");
        player.transform.position = new Vector3(
            player.transform.position.x + rb.velocity.x * Dt,
            player.transform.position.y + rb.velocity.y * Dt, 0);
    }

    public static int Main()
    {
        // 1. الضغط على اليمين والمشي نحو اليمين
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 1f;
            for (int i = 0; i < 50; i++) Step(player, rb);
            Check("walk right: x velocity equals moveSpeed (5)", Math.Abs(rb.velocity.x - 5f) < 0.001f);
            Check("walk right: x position moves about 5 units per second", Math.Abs(player.transform.position.x - 5f) < 0.2f);
            Check("walk right: faces right (flipX = false)", sprite.flipX == false);
        }

        // 2. الضغط على اليسار والتوجه نحو اليسار
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = -1f;
            Step(player, rb);
            Check("walk left: x velocity is negative", rb.velocity.x < 0);
            Check("walk left: faces left (flipX = true)", sprite.flipX == true);
        }

        // 3. عدم الضغط على أي شيء يعني أن الشخصية تبقى ساكنة
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 0f;
            Step(player, rb);
            Check("no input: x velocity is 0", rb.velocity.x == 0f);
        }

        // 4. القفز من على الأرض
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 0f;
            Step(player, rb);                    // دع FixedUpdate يفحص الأرضية أولًا
            Input.JumpPressedThisFrame = true;
            Step(player, rb);
            Input.JumpPressedThisFrame = false;
            Check("jump on ground: y velocity equals jumpForce (12)", Math.Abs(rb.velocity.y - 12f) < 0.001f);
        }

        // 5. الضغط على القفز في الهواء يجب ألا يسبب قفزة ثانية
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 0f;
            Step(player, rb);
            Physics2D.GroundBelowPlayer = false; // الشخصية غادرت الأرض
            rb.velocity = new Vector2(0, -3f);   // تسقط إلى الأسفل
            Input.JumpPressedThisFrame = true;
            Step(player, rb);
            Input.JumpPressedThisFrame = false;
            Check("jump in air: y velocity unchanged (still falling)", Math.Abs(rb.velocity.y + 3f) < 0.001f);
        }

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

يوفر ملف المحاكاة `UnityStubs.cs` (انظر النسخة التايلاندية) الأنواع `Vector2` و`Rigidbody2D` و`Input` و`Physics2D` وغيرها، فيعمل الاختبار خارج Unity. لا تحاكي هذه المحاكاة الفيزياء الحقيقية أو العرض، بل تفحص الحسابات وترتيب تنفيذ النص البرمجي، وهذا ما يحتاجه الاختبار. قم بتجميع الملفات الأربعة معًا باستخدام مترجم C# حديث، ثم شغّل الملف التنفيذي الناتج. في النصوص البرمجية المستخدمة مع Unity أبقِ سطر `using UnityEngine;`، واحذفه فقط من النسخ المستخدمة في اختبار المحاكاة.

نتيجة تشغيل الاختبارات

عند التجميع باستخدام مترجم Roslyn والتشغيل، كانت النتيجة:

PASS  walk right: x velocity equals moveSpeed (5)
PASS  walk right: x position moves about 5 units per second
PASS  walk right: faces right (flipX = false)
PASS  walk left: x velocity is negative
PASS  walk left: faces left (flipX = true)
PASS  no input: x velocity is 0
PASS  jump on ground: y velocity equals jumpForce (12)
PASS  jump in air: y velocity unchanged (still falling)

Passed 8 / Failed 0

خلل كشفه الاختبار

كانت النسخة الأولى من وحدة التحكم تقفز مباشرة داخل Update. فشل اختبار "jump in air": كانت Update تقرأ `isGrounded` من الإطار السابق، وكانت ما تزال true، فقفزت الشخصية مرة أخرى وهي في الهواء فعلًا. في اللعبة الحقيقية يحدث هذا عندما تخرج الشخصية من حافة منصة. وكان الإصلاح بحفظ طلب القفز في المتغير `jumpRequested` وجعل FixedUpdate يفحص الأرضية ويقرر القفز في الخطوة نفسها. وهذا هو الكود الموجود في الخطوة 6.

لهذا تكتسب الاختبارات الآلية أهميتها، فمثل هذا الخطأ يصعب كشفه بالعين، أما اختبار واضح يغطي الحالة الحدية فيكشفه فورًا.

الخطوة 11: بناء اللعبة كملف قابل للتشغيل

1. افتح **File → Build Profiles** (وتسمى **Build Settings** في الإصدارات القديمة).

2. اختر المنصة، مثل Windows أو WebGL، ثم اضغط **Build**.

3. اختر مجلدًا للحفظ. سينشئ Unity ملف `.exe` أو نسخة ويب يمكن للآخرين لعبها.

مشكلات شائعة وحلولها

الخطوات التالية

بعد أن تمشي الشخصية وتقفز، يمكنك إضافة عملات باستخدام Trigger Collider 2D و`OnTriggerEnter2D`، وأعداء يتحركون ذهابًا وإيابًا، ونظام للصحة والموت، وخلفية باستخدام Tilemap. كل ذلك يعتمد على الأفكار نفسها التي في هذه المقالة.

الخلاصة

يبدأ بناء لعبة ثنائية الأبعاد في Unity بفهم أن كل شيء كائن GameObject مكوّن من مكونات. يقرأ نص التحكم المدخلات في Update، ويتولى الفيزياء في FixedUpdate، وتكشف الاختبارات الآلية أخطاء يصعب رؤيتها بالعين. اتبع الخطوات واحدة تلو الأخرى، واضبط القيم في Inspector، واجعل اللعبة من تصميمك.