تحميل نماذج ثلاثية الأبعاد وبناء لعبة قتال في Unity

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

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

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

مخطط Inspector لملف FBX (وليس لقطة شاشة حقيقية): 1 الملف في Assets/Models 2 تبويب Rig 3 قائمة Animation Type 4 زر Apply
مخطط Inspector لملف FBX (وليس لقطة شاشة حقيقية): 1 الملف في Assets/Models 2 تبويب Rig 3 قائمة Animation Type 4 زر Apply

الخطوة 1: إنشاء مشروع واستيراد النموذج

1. في Unity Hub اضغط **New project**، واختر قالب **3D (URP)** أو **3D**، وسمّه `MyFighter`.

2. اسحب ملف FBX الخاص بالشخصية إلى `Assets/Models/`.

3. اختر ملف FBX، وفي Inspector ضمن **Rig** اجعل **Animation Type** هو **Humanoid** ثم اضغط **Apply**.

4. ضمن **Animation** تأكد من وجود المقاطع: Idle وPunch وKick وBlock وHit وDie.

ضبط Humanoid مهم، لأنه يسمح باستخدام رسوم متحركة من شخصيات أخرى على هذه الشخصية.

مخطط نافذة Animator (وليس لقطة شاشة حقيقية): 1 زر إضافة المعاملات 2 أسماء المعاملات وأنواعها 3 حالة Idle باللون البرتقالي وهي الافتراضية 4 الانتقالات إلى حالة كل حركة
مخطط نافذة Animator (وليس لقطة شاشة حقيقية): 1 زر إضافة المعاملات 2 أسماء المعاملات وأنواعها 3 حالة Idle باللون البرتقالي وهي الافتراضية 4 الانتقالات إلى حالة كل حركة

الخطوة 2: إنشاء Animator Controller

1. انقر بزر الفأرة الأيمن في `Assets` واختر **Create → Animation → Animator Controller**، وسمّه `FighterController`.

2. انقر مرتين لفتح نافذة Animator، واسحب مقطع Idle إلى الرسم البياني واجعله الحالة الافتراضية (برتقالي).

3. أنشئ معاملات من نوع **Trigger** بأسماء `Attack` و`Hit` و`Die`، ومعاملًا من نوع **Bool** باسم `Block`.

4. أنشئ حالة Punch تُدخل من Idle عند الشرط `Attack`، وأنشئ حالتي Hit وDie بالطريقة نفسها.

5. أنشئ حالة Block تُدخل عندما يكون `Block` صحيحًا وتخرج عندما يكون خاطئًا.

الخطوة 3: تحميل النموذج أثناء التشغيل من الكود

يفيد التحميل من الكود عندما تتيح اللعبة للاعب الاختيار بين عدة شخصيات دون وضعها جميعًا في المشهد مسبقًا. أبسط طريقة هي وضع البريفابات في `Assets/Resources/` وتحميلها بالاسم.

1. ضع بريفاب الشخصية، وقد ضُبط عليها Animator Controller، في `Assets/Resources/Characters/Fighter.prefab`.

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

using UnityEngine;

// โหลดโมเดลตัวละครตอนรันเกม จากโฟลเดอร์ Resources แล้วติดตั้งไว้ใต้จุดที่กำหนด
// ก่อนใช้งานให้วางไฟล์ Prefab ไว้ที่ Assets/Resources/Characters/<ชื่อ>.prefab
public class ModelLoader : MonoBehaviour
{
    [SerializeField] private string characterPath = "Characters/Fighter";  // ไม่ต้องใส่นามสกุลไฟล์
    [SerializeField] private Transform modelParent;                       // จุดที่จะวางโมเดล

    private GameObject model;
    private Animator animator;

    public GameObject Model => model;
    public Animator Animator => animator;

    private void Awake()
    {
        Load();
    }

    // คืนค่า true ถ้าโหลดสำเร็จและมี Animator ให้ควบคุม
    public bool Load()
    {
        GameObject prefab = Resources.Load<GameObject>(characterPath);
        if (prefab == null)
        {
            Debug.LogError("ไม่พบโมเดลที่ Resources/" + characterPath);
            return false;
        }

        model = Object.Instantiate(prefab, modelParent);
        animator = model.GetComponent<Animator>();
        if (animator == null)
        {
            Debug.LogError("โมเดลนี้ไม่มี Animator");
            return false;
        }
        return true;
    }
}

النقاط المهمة:

ملاحظة: يناسب `Resources` المشاريع الصغيرة والتعلم. أما المشاريع الكبيرة التي تحمّل ملفات كثيرة فينبغي أن تستخدم Addressables.

الخطوة 4: الشخصية المقاتلة

يحتفظ `Fighter` بالصحة، ويتلقى الضرر، ويصد، ويهاجم، ويشغّل Animator ليعرض الحركة المناسبة.

using UnityEngine;

// ตัวละครนักสู้: มีเลือด โจมตี ป้องกัน และตาย ผ่าน Animator เพื่อให้เล่นท่าทางตามเหตุการณ์
public class Fighter : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    [SerializeField] private int attackDamage = 10;
    [SerializeField] private float attackRange = 1.5f;     // ระยะที่โจมตีถึง (เมตร)
    [SerializeField] private float attackCooldown = 0.6f;  // เวลาพักหลังโจมตีแต่ละครั้ง (วินาที)
    [SerializeField] private float blockMultiplier = 0.5f; // ป้องกันแล้วรับดาเมจเหลือกี่เปอร์เซ็นต์

    private int health;
    private bool isBlocking;
    private float nextAttackTime;
    private Animator animator;

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

    public void Init(Animator animatorSource)
    {
        animator = animatorSource;
        health = maxHealth;
    }

    // โจมตีคู่ต่อสู้ คืน true ถ้าการโจมตีเกิดขึ้น (ไม่ได้อยู่ในช่วงพัก ไม่ได้ตาย ไม่ได้กำลังป้องกัน)
    public bool Attack(Fighter target)
    {
        if (IsDead || isBlocking || Time.time < nextAttackTime) return false;

        nextAttackTime = Time.time + attackCooldown;
        SetTrigger("Attack");

        if (DistanceTo(target) <= attackRange)
        {
            target.TakeHit(attackDamage);
        }
        return true;
    }

    public void SetBlocking(bool value)
    {
        if (IsDead) return;
        isBlocking = value;
        animator?.SetBool("Block", value);
    }

    public void TakeHit(int damage)
    {
        if (IsDead) return;

        int finalDamage = isBlocking ? (int)(damage * blockMultiplier) : damage;
        health -= finalDamage;
        if (health < 0) health = 0;

        if (IsDead)
        {
            SetTrigger("Die");
        }
        else
        {
            SetTrigger("Hit");
        }
    }

    public float DistanceTo(Fighter other)
    {
        return Vector3.Distance(transform.position, other.transform.position);
    }

    private void SetTrigger(string name)
    {
        animator?.SetTrigger(name);
    }
}

النقاط المهمة:

الخطوة 5: الخصم الذي يتحكم به الذكاء الاصطناعي

يتقدم `FighterAI` نحو اللاعب، ويهاجم عندما يصل إلى المدى.

using UnityEngine;

// สมองของคู่ต่อสู้: เดินเข้าหาผู้เล่น และโจมตีเมื่ออยู่ในระยะ
[RequireComponent(typeof(Fighter))]
public class FighterAI : MonoBehaviour
{
    [SerializeField] private Fighter target;         // ผู้เล่น
    [SerializeField] private float walkSpeed = 2f;   // ความเร็วเดินเข้าหา (เมตรต่อวินาที)

    private Fighter self;

    private void Awake()
    {
        self = GetComponent<Fighter>();
    }

    private void Update()
    {
        if (self.IsDead || target == null || target.IsDead) return;

        if (self.DistanceTo(target) > 1.5f)
        {
            // ยังไกลเกินไป ให้เดินเข้าหาเป้าหมาย
            Vector3 direction = (target.transform.position - transform.position).normalized;
            transform.position = transform.position + direction * walkSpeed * Time.deltaTime;
        }
        else
        {
            // อยู่ในระยะแล้ว ลองโจมตี (ถ้าพักอยู่จะไม่โจมตี)
            self.Attack(target);
        }
    }
}

النقاط المهمة:

الخطوة 6: تحديد الفائز

يتحقق `RoundManager` من الطرف الذي سقط، ويعلن الفائز.

using UnityEngine;

// ตัดสินผลแมตช์: ฝ่ายที่เหลือเลือดหรือยืนอยู่หลังอีกฝ่ายตายคือผู้ชนะ
public class RoundManager : MonoBehaviour
{
    [SerializeField] private Fighter player;
    [SerializeField] private Fighter enemy;

    // คืน "Player" หรือ "Enemy" เมื่อมีคนตาย, คืน null ถ้าการต่อสู้ยังไม่จบ
    public string GetWinner()
    {
        if (enemy.IsDead && !player.IsDead) return "Player";
        if (player.IsDead && !enemy.IsDead) return "Enemy";
        if (player.IsDead && enemy.IsDead) return "Draw";
        return null;
    }
}
مخطط Inspector للاعب (وليس لقطة شاشة حقيقية): 1 Character Path 2 Max Health 3 Self 4 Opponent
مخطط Inspector للاعب (وليس لقطة شاشة حقيقية): 1 Character Path 2 Max Health 3 Self 4 Opponent

الخطوة 7: تجميع المشهد والتحكم باللاعب

1. أضف الأرضية والإضاءة إلى المشهد.

2. أنشئ كائن GameObject باسم `Player`، وأضف `ModelLoader` واجعل `characterPath` هو `Characters/Fighter`، وأضف `Fighter`.

3. استدعِ `Init` الخاص بـ `Fighter` باستخدام Animator من `ModelLoader`، لتبدأ الصحة من 100. عادةً يتم ذلك في نص برمجي صغير للمشهد.

4. أنشئ كائن `Enemy` بالطريقة نفسها، ثم أضف `FighterAI` واجعل `Target` هو `Player`.

5. أنشئ كائن `RoundManager` واربط المقاتلين الاثنين به.

يستخدم هذا المتحكم البسيط النقر الأيسر للهجوم، والضغط المطول على الزر الأيمن للصد.

using UnityEngine;

// ผู้เล่นควบคุมด้วยเมาส์: คลิกซ้ายโจมตี คลิกขวาค้างเพื่อป้องกัน
public class PlayerInput : MonoBehaviour
{
    [SerializeField] private Fighter self;
    [SerializeField] private Fighter opponent;

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            self.Attack(opponent);
        }
        self.SetBlocking(Input.GetMouseButton(1));
    }
}

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

تستخدم الاختبارات الأسلوب نفسه المستخدم في مقالة FPS. ملف الواجهة الوهمية (`FightStubs.cs`) وملف الاختبار المكون من 24 حالة (`FightTests.cs`) موجودان في النسخة التايلاندية. تغطي الاختبارات الهجوم داخل المدى وخارجه، وفترة الانتظار، والصد، والموت، واللاعب الميت الذي لا يستطيع الهجوم، واقتراب الخصم الذكي وهجومه، وتحميل النموذج مع ملف مفقود ومع غياب Animator، ومنطق تحديد الفائز.

للتجميع والتشغيل: انسخ الواجهة الوهمية وملف الاختبار والنصوص الأربعة (`ModelLoader.cs` و`Fighter.cs` و`FighterAI.cs` و`RoundManager.cs`) إلى مجلد `build`، واحذف سطر `using UnityEngine;` من النسخ فقط، ثم جمّع بمترجم C# حديث (مثلًا `csc -langversion:latest -out:FightTests.exe *.cs`) وشغّل `FightTests.exe`.

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

PASS  attack in range: attack starts
PASS  attack in range: defender loses 10 health (100 to 90)
PASS  attack in range: defender plays Hit animation
PASS  attack: attacker plays Attack animation
PASS  attack out of range: no damage (still 100)
PASS  cooldown: first attack succeeds
PASS  cooldown: second attack at 0.3 s is refused
PASS  cooldown: only one hit landed (90)
PASS  cooldown: attack after 0.7 s is allowed
PASS  block: damage halved (100 to 95)
PASS  death: fighter is dead at 0 health
PASS  death: Die animation is played
PASS  dead fighter cannot attack
PASS  damage to a dead fighter is ignored
PASS  AI approaches the player when far
PASS  AI attacks when in range
PASS  model loader: loads the prefab from Resources
PASS  model loader: finds the Animator on the model
PASS  model loader: missing file returns false
PASS  model loader: missing file logs an error
PASS  model loader: prefab without Animator returns false
PASS  round: no winner while both are alive
PASS  round: player wins when enemy is dead
PASS  round: enemy wins when player is dead

Passed 24 / Failed 0

هذه النتائج جاءت من الواجهة الوهمية وليس من Unity. ما زال شكل النموذج وتوقيت الحركات واكتشاف الإصابة الحقيقي يحتاج إلى فحص في المحرر.

خطأ منعته الاختبارات: كانت النسخة الأولى من `Fighter` تستخدم `Mathf.Max(0, health - finalDamage)`. لا يُترجم هذا الكود، لأن `Mathf.Max` تقبل أرقامًا عشرية بينما `health` من نوع int. الإصلاح هو الطرح المباشر مع تثبيت القيمة عند الصفر.

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

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

مشكلات شائعة

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

بعد أن يعمل القتال الأساسي، يمكنك إضافة سلاسل الضربات (الحركات المتتابعة)، ونظام القفز والمراوغة، وعدة شخصيات قابلة للاختيار، وتأثيرات صوتية وجسيمات عند الإصابة.

الخلاصة

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