تحميل نماذج ثلاثية الأبعاد وبناء لعبة قتال في Unity
تحتاج ألعاب القتال إلى شخصيات تتحرك بشكل مقنع: اللكمات والركلات والصد ومشهد السقوط. تأتي هذه الشخصيات من نماذج ثلاثية الأبعاد تحتوي على هيكل عظمي ورسوم متحركة. تغطي هذه المقالة أمرين معًا: تحميل النموذج إلى اللعبة أثناء التشغيل من خلال الكود، وبناء نظام القتال الذي يستخدمه، ويشمل الهجوم والصد والصحة وخصمًا يتحكم به الذكاء الاصطناعي.
ملاحظة: يحتفظ الكود بالتعليقات التايلاندية من النسخة الأصلية. لم يُشغَّل في محرر Unity. تعمل الاختبارات على واجهة Unity وهمية كُتبت لهذه المقالة، وتؤكد منطق النص البرمجي فقط. تحقق من النموذج والرسوم المتحركة في المحرر. ملف الواجهة الوهمية كاملًا موجود في النسخة التايلاندية.
ما الذي تحتاجه
- Unity Hub ومحرر Unity بأحدث إصدار LTS
- نموذج شخصية ثلاثي الأبعاد بهيكل Humanoid ورسوم متحركة، بصيغة FBX. توجد خيارات مجانية، لكن تحقق من ترخيص كل مصدر قبل الاستخدام
- أساسيات مقالة FPS (النصوص البرمجية، وInspector، والتجميع وتشغيل الاختبارات)
الخطوة 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 مهم، لأنه يسمح باستخدام رسوم متحركة من شخصيات أخرى على هذه الشخصية.
الخطوة 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.Load<GameObject>` عن الملف في مجلد `Resources` دون الحاجة إلى الامتداد.
- ينشئ `Object.Instantiate` نسخة من البريفاب في المشهد، فيمكن إنشاؤها عدة مرات.
- يفحص السكربت القيمة `null` عند البحث عن الملف وعند البحث عن Animator، فتظهر الأخطاء في Console فورًا بدلًا من أن تتعطل لاحقًا.
ملاحظة: يناسب `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);
}
}
النقاط المهمة:
- يمنع زمن الانتظار (`attackCooldown`) اللاعب من الضغط المتكرر للهجوم بلا حدود.
- أثناء الصد لا يمكن الهجوم، ويتلقى الشخص ضررًا مخفَّضًا عبر `blockMultiplier`.
- تمر كل تغييرات الحالة عبر محفزات Animator، فلا يحتاج منطق اللعبة إلى معرفة طريقة تشغيل الحركة.
- يُحسم الإصابة بالمسافة البسيطة، وهذا كافٍ لمشروع تعليمي. أما الألعاب الحقيقية فتستخدم عادة صناديق إصابة مثل `Physics.OverlapBox` تتطابق مع الحركة.
الخطوة 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);
}
}
}
النقاط المهمة:
- لهذا الذكاء الاصطناعي حالتان فقط: الاقتراب والهجوم. وهو بداية جيدة. أما الألعاب الحقيقية فتستخدم عادة آلة حالات أو شجرة سلوك تضيف الدفاع والمراوغة واختيار الحركات.
- ينبغي أن تتطابق مسافة التوقف (1.5) مع `attackRange` في `Fighter`، ليتمكن الخصم من الهجوم بمجرد وصوله.
الخطوة 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;
}
}
الخطوة 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 وتشغّل Idle.
- النقر الأيسر يشغّل Punch، وتنخفض صحة الخصم.
- الضغط المطول على الزر الأيمن يشغّل Block، ويقل الضرر.
- يقترب الخصم منك من بعيد، ويهاجم عندما يصبح قريبًا.
- عندما تصل الصحة إلى الصفر يُشغَّل Die.
مشكلات شائعة
- **لا يظهر النموذج:** تأكد من أن البريفاب في `Assets/Resources/Characters/` وأن الاسم في `characterPath` يطابق تمامًا.
- **تظهر رسالة في Console بعدم وجود Animator:** تأكد من أن البريفاب يحتوي على مكون Animator وأن المتحكم معيَّن.
- **الحركات لا تتغير:** تأكد من أن أسماء المحفزات في Animator Controller تطابق الكود تمامًا (`Attack` و`Hit` و`Die`).
- **النموذج يطفو أو يدور بشكل خاطئ:** تأكد من أن الهيكل Humanoid، وإعداد Root Motion للمقطع.
- **الخصم لا يتحرك:** تأكد من أن حقل Target في الخصم يحتوي على اللاعب، وأن `Fighter` الخاص بالخصم لديه Animator.
الخطوات التالية
بعد أن يعمل القتال الأساسي، يمكنك إضافة سلاسل الضربات (الحركات المتتابعة)، ونظام القفز والمراوغة، وعدة شخصيات قابلة للاختيار، وتأثيرات صوتية وجسيمات عند الإصابة.
الخلاصة
يتيح تحميل النماذج من الكود اختيار الشخصيات بمرونة، ويفصل Animator الحركة عن منطق القتال. ينقسم نظام القتال إلى أربعة أجزاء واضحة: المقاتل، والذكاء الاصطناعي، والحكم، ومحمّل النموذج. يمكن اختبار كل جزء على حدة، وتكشف الاختبارات أخطاء الترجمة قبل فتح المحرر.