โหลดโมเดล 3D และสร้างเกมต่อสู้ด้วย Unity: ตัวละครโจมตี ป้องกัน และคู่ต่อสู้ AI

เกมต่อสู้ (fighting game) ต้องมีตัวละครที่ขยับได้สมจริง เช่น ท่าชก ท่าเตะ ท่าป้องกัน และท่าตาย ตัวละครเหล่านี้มาจากโมเดล 3D ที่มีกระดูกและแอนิเมชันอยู่แล้ว บทความนี้จะพาคุณทำสองอย่างคู่กันไป คือการโหลดโมเดลเข้าเกมตอนรันด้วยโค้ด และการเขียนระบบต่อสู้ที่ใช้โมเดลนั้น ได้แก่ การโจมตี การป้องกัน เลือด และคู่ต่อสู้ที่ควบคุมด้วย AI พื้นฐาน

หมายเหตุ: โค้ดในบทความนี้ใช้คอมเมนต์ภาษาไทย และไม่ได้รันใน Unity Editor จริง การทดสอบทำผ่านตัวจำลอง Unity API ที่เขียนขึ้นเอง ซึ่งยืนยันเฉพาะ logic ของสคริปต์ ส่วนโมเดลและแอนิเมชันต้องตรวจในเอดิเตอร์

สิ่งที่ต้องเตรียม

ภาพแผนผัง 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** window แล้วลากคลิป Idle ไปไว้ในกราฟ ตั้งเป็นสถานะเริ่มต้น (สีส้ม)

3. สร้างพารามิเตอร์ชนิด **Trigger** ชื่อ `Attack`, `Hit`, `Die` และ **Bool** ชื่อ `Block`

4. สร้างสถานะ Punch ที่เชื่อมจาก Idle ด้วยเงื่อนไข Trigger `Attack` และสถานะ Hit, Die ในทำนองเดียวกัน

5. สร้างสถานะ Block ที่เชื่อมด้วยเงื่อนไข Bool `Block` เป็น true และกลับเมื่อเป็น false

ขั้นที่ 3: โหลดโมเดลตอนรันเกมด้วยโค้ด

การโหลดโมเดลด้วยโค้ดมีประโยชน์เมื่อต้องการเลือกตัวละครได้หลายตัวโดยไม่ต้องวางทุกตัวในฉากไว้ล่วงหน้า วิธีที่ง่ายที่สุดคือเก็บ Prefab ไว้ในโฟลเดอร์ `Assets/Resources/` แล้วเรียกด้วยชื่อในโค้ด

1. ลาก Prefab ของตัวละครที่ตั้ง 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: คู่ต่อสู้ AI

`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 ของ Player (ไม่ใช่ภาพหน้าจอจริง): 1 Character Path 2 ช่อง Max Health 3 Self 4 Opponent
ภาพแผนผัง Inspector ของ Player (ไม่ใช่ภาพหน้าจอจริง): 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. สร้าง GameObject `Enemy` ทำเหมือนกัน แล้วเพิ่ม `FighterAI` ตั้ง Target เป็น `Player`

5. สร้าง GameObject `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: ทดสอบอัตโนมัติ

ทดสอบด้วยตัวจำลอง Unity API เหมือนบทความ FPS ไฟล์จำลองและไฟล์ทดสอบมีดังนี้

ไฟล์จำลอง Unity API (FightStubs.cs)

using System;
using System.Collections.Generic;
using System.Reflection;

public struct Vector3
{
    public float x, y, z;
    public Vector3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }

    public float magnitude => (float)Math.Sqrt(x * x + y * y + z * z);
    public Vector3 normalized { get { float m = magnitude; return m > 0 ? new Vector3(x / m, y / m, z / m) : this; } }

    public static float Distance(Vector3 a, Vector3 b) => (a - b).magnitude;
    public static Vector3 operator +(Vector3 a, Vector3 b) => new Vector3(a.x + b.x, a.y + b.y, a.z + b.z);
    public static Vector3 operator -(Vector3 a, Vector3 b) => new Vector3(a.x - b.x, a.y - b.y, a.z - b.z);
    public static Vector3 operator *(Vector3 a, float s) => new Vector3(a.x * s, a.y * s, a.z * s);
    public static Vector3 operator *(float s, Vector3 a) => a * s;
}

public static class Mathf
{
    public static float Clamp(float v, float min, float max) => v < min ? min : (v > max ? max : v);
}

public class Transform
{
    public Vector3 position;
}

public class Animator
{
    public readonly List<string> Triggers = new List<string>();
    public readonly Dictionary<string, bool> Bools = new Dictionary<string, bool>();

    public void SetTrigger(string name) { Triggers.Add(name); }
    public void SetBool(string name, bool value) { Bools[name] = value; }
}

public class GameObject
{
    public readonly Dictionary<Type, object> Components = new Dictionary<Type, object>();
    public Transform transform = new Transform();
    public bool activeSelf = true;

    public T GetComponent<T>() where T : class
    {
        object value;
        return Components.TryGetValue(typeof(T), out value) ? (T)value : null;
    }

    public void SetActive(bool value) { activeSelf = value; }
}

public static class Object
{
    // จำลอง Object.Instantiate: คืนค่าเดิม (ไม่คัดลอกจริง) เพื่อให้ทดสอบง่าย
    public static T Instantiate<T>(T original, Transform parent) where T : class => original;
}

public static class Resources
{
    public static readonly Dictionary<string, GameObject> Prefabs = new Dictionary<string, GameObject>();

    public static T Load<T>(string path) where T : class
    {
        GameObject go;
        return Prefabs.TryGetValue(path, out go) ? (T)(object)go : null;
    }
}

public static class Debug
{
    public static readonly List<string> Errors = new List<string>();
    public static void LogError(string message) { Errors.Add(message); }
}

public static class Time
{
    public static float time;
    public static float deltaTime;
}

public class SerializeFieldAttribute : Attribute { }

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class RequireComponentAttribute : Attribute
{
    public RequireComponentAttribute(Type type) { }
}

public class MonoBehaviour
{
    public readonly Dictionary<Type, object> Components = new Dictionary<Type, object>();
    public Transform transform = new Transform();
    public GameObject gameObject = new GameObject();

    public T GetComponent<T>() where T : class
    {
        object value;
        return Components.TryGetValue(typeof(T), out value) ? (T)value : null;
    }

    // เรียกเมธอดวงจรชีวิตตามชื่อ เหมือนที่ Unity เรียกให้ในแต่ละเฟรม
    public void Call(string methodName)
    {
        var method = GetType().GetMethod(methodName,
            BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
        method.Invoke(this, null);
    }

    // ตั้งค่าฟิลด์ที่เป็น private (เช่น [SerializeField]) เหมือนที่ตั้งใน Inspector
    public void SetField(string name, object value)
    {
        GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
            .SetValue(this, value);
    }
}

ไฟล์ทดสอบ (FightTests.cs)

ไฟล์ทดสอบมี 24 ข้อ ครอบคลุมการโจมตีในระยะและนอกระยะ คูลดาวน์ การป้องกัน การตาย การที่ศพโจมตีไม่ได้ AI เดินเข้าหาและโจมตี การโหลดโมเดลที่มีและไม่มีไฟล์หรือ Animator และการตัดสินผู้ชนะ

using System;

public static class FightTests
{
    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); }
    }

    // สร้างนักสู้ที่ตำแหน่ง x พร้อม Animator ของตัวเอง
    private static Fighter CreateFighter(float x, out Animator animator)
    {
        var fighter = new Fighter();
        animator = new Animator();
        fighter.Init(animator);
        fighter.transform.position = new Vector3(x, 0, 0);
        return fighter;
    }

    private static void Reset()
    {
        Time.time = 0f;
        Time.deltaTime = 0.1f;
        Debug.Errors.Clear();
        Resources.Prefabs.Clear();
    }

    public static int Main()
    {
        Console.OutputEncoding = System.Text.Encoding.UTF8;

        // ---------- Fighter ----------
        {
            Reset();
            Animator aAnim, bAnim;
            var a = CreateFighter(0, out aAnim);
            var b = CreateFighter(1, out bAnim);
            Check("attack in range: attack starts", a.Attack(b));
            Check("attack in range: defender loses 10 health (100 to 90)", b.Health == 90);
            Check("attack in range: defender plays Hit animation", bAnim.Triggers.Contains("Hit"));
            Check("attack: attacker plays Attack animation", aAnim.Triggers.Contains("Attack"));
        }
        {
            Reset();
            Animator aAnim, bAnim;
            var a = CreateFighter(0, out aAnim);
            var b = CreateFighter(5, out bAnim);
            a.Attack(b);
            Check("attack out of range: no damage (still 100)", b.Health == 100);
        }
        {
            Reset();
            Animator aAnim, bAnim;
            var a = CreateFighter(0, out aAnim);
            var b = CreateFighter(1, out bAnim);
            Check("cooldown: first attack succeeds", a.Attack(b));
            Time.time = 0.3f;                              // ยังอยู่ในช่วงพัก 0.6 วินาที
            Check("cooldown: second attack at 0.3 s is refused", a.Attack(b) == false);
            Check("cooldown: only one hit landed (90)", b.Health == 90);
            Time.time = 0.7f;                              // พ้นช่วงพักแล้ว
            Check("cooldown: attack after 0.7 s is allowed", a.Attack(b));
        }
        {
            Reset();
            Animator aAnim, bAnim;
            var a = CreateFighter(0, out aAnim);
            var b = CreateFighter(1, out bAnim);
            b.SetBlocking(true);
            a.Attack(b);
            Check("block: damage halved (100 to 95)", b.Health == 95);
        }
        {
            Reset();
            Animator aAnim, bAnim;
            var a = CreateFighter(0, out aAnim);
            var b = CreateFighter(1, out bAnim);
            b.TakeHit(100);
            Check("death: fighter is dead at 0 health", b.IsDead && b.Health == 0);
            Check("death: Die animation is played", bAnim.Triggers.Contains("Die"));
        }
        {
            Reset();
            Animator aAnim, bAnim;
            var a = CreateFighter(0, out aAnim);
            var b = CreateFighter(1, out bAnim);
            b.TakeHit(100);
            Check("dead fighter cannot attack", b.Attack(a) == false);
            a.TakeHit(999);
            Check("damage to a dead fighter is ignored", a.Health == 0);
        }

        // ---------- FighterAI ----------
        {
            Reset();
            Animator playerAnim, aiAnim;
            var player = CreateFighter(0, out playerAnim);
            var enemy = CreateFighter(10, out aiAnim);
            var ai = new FighterAI();
            ai.Components[typeof(Fighter)] = enemy;
            ai.SetField("target", player);
            ai.SetField("walkSpeed", 2f);
            enemy.transform = ai.transform;                // ทั้งสองอยู่บน GameObject เดียวกัน
            ai.transform.position = new Vector3(10, 0, 0); // ตั้งตำแหน่งเริ่มต้นหลังผูก transform แล้ว
            ai.Call("Awake");
            Time.deltaTime = 0.5f;                         // เดินได้ 2 * 0.5 = 1 เมตรต่อหนึ่งเฟรม
            ai.Call("Update");
            ai.Call("Update");
            Check("AI approaches the player when far", Math.Abs(ai.transform.position.x - 8f) < 0.001f);
        }
        {
            Reset();
            Animator playerAnim, aiAnim;
            var player = CreateFighter(0, out playerAnim);
            var enemy = CreateFighter(1.2f, out aiAnim);
            var ai = new FighterAI();
            ai.Components[typeof(Fighter)] = enemy;
            ai.SetField("target", player);
            enemy.transform = ai.transform;
            ai.Call("Awake");
            ai.Call("Update");
            Check("AI attacks when in range", player.Health == 90);
        }

        // ---------- ModelLoader ----------
        {
            Reset();
            var prefab = new GameObject();
            prefab.Components[typeof(Animator)] = new Animator();
            Resources.Prefabs["Characters/Fighter"] = prefab;
            var loader = new ModelLoader();
            loader.SetField("characterPath", "Characters/Fighter");
            bool ok = loader.Load();
            Check("model loader: loads the prefab from Resources", ok && loader.Model != null);
            Check("model loader: finds the Animator on the model", loader.Animator != null);
        }
        {
            Reset();
            var loader = new ModelLoader();
            loader.SetField("characterPath", "Characters/Missing");
            bool ok = loader.Load();
            Check("model loader: missing file returns false", ok == false);
            Check("model loader: missing file logs an error", Debug.Errors.Count == 1);
        }
        {
            Reset();
            var prefab = new GameObject();                 // ไม่มี Animator
            Resources.Prefabs["Characters/NoAnim"] = prefab;
            var loader = new ModelLoader();
            loader.SetField("characterPath", "Characters/NoAnim");
            Check("model loader: prefab without Animator returns false", loader.Load() == false);
        }

        // ---------- RoundManager ----------
        {
            Reset();
            Animator pAnim, eAnim;
            var player = CreateFighter(0, out pAnim);
            var enemy = CreateFighter(1, out eAnim);
            var round = new RoundManager();
            round.SetField("player", player);
            round.SetField("enemy", enemy);
            Check("round: no winner while both are alive", round.GetWinner() == null);
            enemy.TakeHit(100);
            Check("round: player wins when enemy is dead", round.GetWinner() == "Player");
        }
        {
            Reset();
            Animator pAnim, eAnim;
            var player = CreateFighter(0, out pAnim);
            var enemy = CreateFighter(1, out eAnim);
            var round = new RoundManager();
            round.SetField("player", player);
            round.SetField("enemy", enemy);
            player.TakeHit(100);
            Check("round: enemy wins when player is dead", round.GetWinner() == "Enemy");
        }

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

วิธีคอมไพล์และรัน

1. สร้างโฟลเดอร์ `build` คัดลอก `FightStubs.cs`, `FightTests.cs` และสคริปต์ทั้งสี่ (`ModelLoader.cs`, `Fighter.cs`, `FighterAI.cs`, `RoundManager.cs`)

2. ในสำเนาเท่านั้น ลบบรรทัด `using UnityEngine;`

3. คอมไพล์ด้วยคอมไพเลอร์ 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

ข้อควรระวังเรื่องการทดสอบ: ผลนี้มาจากตัวจำลอง การเปลี่ยนท่าทาง ระยะจริงของมือและเท้า และการชนจริงต้องทดสอบด้วยการกด Play ในเอดิเตอร์

ข้อผิดพลาดที่เทสต์ช่วยจับได้ระหว่างเขียน: เวอร์ชันแรกของ `Fighter` ใช้ `Mathf.Max(0, health - finalDamage)` ซึ่งไม่คอมไพล์เพราะ `Mathf.Max` รับ float ส่วน `health` เป็น int จึงเปลี่ยนเป็นการลบตรง ๆ และกันค่าติดลบ

ขั้นที่ 9: ทดสอบในเอดิเตอร์

กด **Play** แล้วตรวจ

ปัญหาที่พบบ่อย

ขั้นต่อไป

เมื่อการต่อสู้พื้นฐานทำงานแล้ว คุณอาจเพิ่มคอมโบ (ลำดับท่าต่อเนื่อง) ระบบกระโดดและหลบ ตัวละครหลายตัวที่เลือกได้ และเอฟเฟกต์เสียงกับอนุภาคตอนโดนตี

สรุป

การโหลดโมเดลด้วยโค้ดทำให้เลือกตัวละครได้ยืดหยุ่น และ Animator ทำให้ท่าทางแยกจากตรรกะการต่อสู้ ระบบต่อสู้แบ่งเป็นสี่ส่วนชัดเจน ได้แก่ ตัวละคร AI ผู้ตัดสิน และการโหลดโมเดล แต่ละส่วนทดสอบแยกได้ การทดสอบจับข้อผิดพลาดการคอมไพล์ได้ก่อนเข้าเอดิเตอร์