โหลดโมเดล 3D และสร้างเกมต่อสู้ด้วย Unity: ตัวละครโจมตี ป้องกัน และคู่ต่อสู้ AI
เกมต่อสู้ (fighting game) ต้องมีตัวละครที่ขยับได้สมจริง เช่น ท่าชก ท่าเตะ ท่าป้องกัน และท่าตาย ตัวละครเหล่านี้มาจากโมเดล 3D ที่มีกระดูกและแอนิเมชันอยู่แล้ว บทความนี้จะพาคุณทำสองอย่างคู่กันไป คือการโหลดโมเดลเข้าเกมตอนรันด้วยโค้ด และการเขียนระบบต่อสู้ที่ใช้โมเดลนั้น ได้แก่ การโจมตี การป้องกัน เลือด และคู่ต่อสู้ที่ควบคุมด้วย AI พื้นฐาน
หมายเหตุ: โค้ดในบทความนี้ใช้คอมเมนต์ภาษาไทย และไม่ได้รันใน Unity Editor จริง การทดสอบทำผ่านตัวจำลอง Unity API ที่เขียนขึ้นเอง ซึ่งยืนยันเฉพาะ logic ของสคริปต์ ส่วนโมเดลและแอนิเมชันต้องตรวจในเอดิเตอร์
สิ่งที่ต้องเตรียม
- Unity Hub และ Unity Editor รุ่น LTS ล่าสุด
- โมเดลตัวละคร 3D ที่มี Humanoid rig และแอนิเมชัน ในรูปแบบ FBX (ถ้าไม่มี ใช้โมเดลตัวอย่างจาก Asset Store หรือ Mixamo ซึ่งมีให้ดาวน์โหลดฟรี ตรวจสอบเงื่อนไขการใช้งานของแต่ละแหล่งก่อน)
- ความรู้พื้นฐานจากบทความ 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** 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.Load<GameObject>` หาไฟล์จากโฟลเดอร์ `Resources` โดยไม่ต้องใส่นามสกุล
- `Object.Instantiate` สร้างสำเนาของ Prefab ขึ้นมาในฉาก ทำให้เรียกใช้หลายครั้งได้
- ตรวจ `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`
- ทุกการเปลี่ยนสถานะส่งผ่าน Trigger ของ Animator ตัวเกมไม่ต้องรู้ว่าแอนิเมชันจะเล่นอย่างไร แยกหน้าที่ชัดเจน
- การตัดสินว่าโดนหรือไม่ใช้ระยะห่างแบบง่าย เพียงพอสำหรับเกมเรียนรู้ เกมจริงมักใช้ Hitbox (เช่น `Physics.OverlapBox`) ที่ตรงกับท่าทาง
ขั้นที่ 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);
}
}
}
หลักการ
- AI แบบนี้เป็นสถานะเดียว คือ "เข้าหา" หรือ "โจมตี" เหมาะสำหรับเริ่มต้น เกมจริงมักใช้ State Machine หรือ Behaviour Tree เพิ่มการป้องกัน การหลบ และการเลือกท่า
- ระยะหยุดเดิน (1.5) ควรตรงกับ `attackRange` ของ `Fighter` เพื่อให้ AI โจมตีได้เมื่อเข้าถึงแล้ว
ขั้นที่ 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. สร้าง 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 และเล่นท่า Idle
- คลิกซ้ายแล้วท่า Punch เล่น และคู่ต่อสู้เลือดลด
- คลิกขวาค้างแล้วท่า Block เล่น และรับดาเมจน้อยลง
- คู่ต่อสู้เดินเข้าหาเมื่ออยู่ไกล และโจมตีเมื่อใกล้
- เมื่อเลือดหมด ท่า Die เล่น และ Console แสดงผู้ชนะ (ถ้าเพิ่มข้อความไว้)
ปัญหาที่พบบ่อย
- **โมเดลไม่ขึ้น:** ตรวจว่า Prefab อยู่ใน `Assets/Resources/Characters/` และชื่อใน `characterPath` ตรงทุกตัวอักษร
- **Console ขึ้นว่าไม่มี Animator:** ตรวจว่า Prefab มีคอมโพเนนต์ Animator และได้ใส่ Animator Controller แล้ว
- **ท่าไม่เปลี่ยน:** ตรวจว่าชื่อ Trigger ใน Animator Controller สะกดตรงกับชื่อในโค้ด (`Attack`, `Hit`, `Die`) ทุกตัวอักษร
- **โมเดลลอยหรือหมุนผิด:** ตรวจ Rig เป็น Humanoid และ Root Motion ของคลิปแอนิเมชัน
- **AI ไม่เดิน:** ตรวจว่าช่อง Target ของ FighterAI มีผู้เล่น และ Fighter ของ AI มี Animator
ขั้นต่อไป
เมื่อการต่อสู้พื้นฐานทำงานแล้ว คุณอาจเพิ่มคอมโบ (ลำดับท่าต่อเนื่อง) ระบบกระโดดและหลบ ตัวละครหลายตัวที่เลือกได้ และเอฟเฟกต์เสียงกับอนุภาคตอนโดนตี
สรุป
การโหลดโมเดลด้วยโค้ดทำให้เลือกตัวละครได้ยืดหยุ่น และ Animator ทำให้ท่าทางแยกจากตรรกะการต่อสู้ ระบบต่อสู้แบ่งเป็นสี่ส่วนชัดเจน ได้แก่ ตัวละคร AI ผู้ตัดสิน และการโหลดโมเดล แต่ละส่วนทดสอบแยกได้ การทดสอบจับข้อผิดพลาดการคอมไพล์ได้ก่อนเข้าเอดิเตอร์