6.3 ระบบต่อสู้และ Ability

เฟส 6 · Gameplay Programming · เวลาเรียน: 40–70 h

สกิลแบบ data-driven, cooldown, ดาเมจ, buff และ debuff และการตรวจโดน — หัวใจของเกมแอ็กชันและกาชาอย่าง Genshin

ตอนนี้คุณสร้างตัวละครที่เดินได้ ชนกับของอื่นได้ และตอบสนอง input ได้แล้ว บทนี้จะสอนวิธีให้ตัวละครพวกนั้นมี combat system (ระบบต่อสู้): วิธีสร้างความเสียหาย, รับความเสียหาย, ตาย, ร่ายสกิลอย่าง fireball หรือ heal, และใส่ effect อย่างพิษหรือบัฟความเร็ว นี่คือแกนหลักของเกมแอคชั่นหรือเกม gacha แทบทุกเกม — เกมของ HoYoverse อย่าง Genshin Impact กับ Honkai: Star Rail สร้างขึ้นจากชิ้นส่วนพวกนี้เป๊ะ ๆ แค่มีคอนเทนต์ทับซ้อนเยอะกว่าเท่านั้นเอง

เป้าหมายในบทนี้ไม่ใช่แค่ "ทำให้ตัวเลขลดลง" เป้าหมายจริง ๆ คือสร้างระบบให้ designer (คนที่ไม่ใช่โปรแกรมเมอร์) เพิ่มสกิลใหม่ได้เองด้วยการกรอกตัวเลขใน Unity Editor โดยไม่ต้องเขียนโค้ดเพิ่มเลยสักบรรทัด ไอเดียเดียวนี้ — data-driven design (ออกแบบให้ขับเคลื่อนด้วยข้อมูล) — คือสิ่งที่กำหนดแทบทุกการตัดสินใจในบทนี้

1. combat system คืออะไร และทำไมต้อง decouple มัน

แยก "combat" ออกเป็นงานย่อย ๆ ที่เป็นอิสระจากกัน แต่ละงานควรเป็นโค้ดของตัวเองที่แทบไม่รู้จักงานอื่นเลย:

คำที่ใช้เรียกการแยกงานพวกนี้ออกจากกันคือ decoupling (การลดการผูกติดกันตรง ๆ ระหว่างชิ้นโค้ด เพื่อให้แต่ละชิ้นเปลี่ยนได้โดยไม่ทำพังชิ้นอื่น) ระบบ combat แบบ coupled (ผูกติดกัน) อาจมีเมธอด Fireball() เขียนตรง ๆ อยู่ในคลาส PlayerCharacter เช็ค if (target is EnemyGoblin) แล้วทำอะไรพิเศษเฉพาะศัตรูชนิดนั้น ส่วนแบบ decoupled (แยกกันเป็นอิสระ) จะมี Ability แบบทั่วไปที่ทำงานได้กับอะไรก็ตามที่มี component Health ถูกร่ายโดยอะไรก็ตามที่มี component AbilityRunner — จะเป็นผู้เล่น ศัตรู หรือบอสก็ไม่ต่าง บทนี้จะสร้างมันแบบ decoupled ตั้งแต่ต้น

INPUT / AI DECISION | v +-----------------+ reads data from +--------------------+ | AbilityRunner | -------------------> | Ability asset | | (one per | | (a ScriptableObj, | | character) | | just numbers) | +-----------------+ +--------------------+ | | applies effects to a TARGET v +-----------------+ +---------------------------+ | Health | <------------------> | StatusEffectController | | (on the target) | both live on the | (on the target) | +-----------------+ same character +---------------------------+

จำภาพนี้ไว้ในหัว แต่ละหัวข้อข้างล่างจะสร้างกล่องหนึ่งกล่องในภาพนี้ พอถึงหัวข้อ 10 คุณจะเห็นทั้งหมดต่อเข้าด้วยกันเป็นโค้ดจริง

2. Health: รับดาเมจ ตาย และบอกระบบอื่นให้รู้

ทุกอย่างที่รับดาเมจได้ในเกม — ผู้เล่น ศัตรู ลังที่ทำลายได้ — ต้องมีพฤติกรรมชุดเล็ก ๆ ชุดเดียวกัน: เก็บ HP ปัจจุบัน, ลดมันเมื่อโดนตี, clamp ไว้ที่ 0, และรู้ว่าตายเมื่อไร เราเริ่มจาก interface (สัญญาที่ระบุรายการเมธอดที่คลาสสัญญาว่าจะมี ไม่มีโค้ดข้างใน จากบทก่อนหน้า) เพื่อให้ อะไรก็ได้ เป็นเป้าหมายที่ถูกต้อง ไม่ใช่แค่คลาสใดคลาสหนึ่ง

public interface IDamageable
{
    void TakeDamage(float amount, GameObject source);
}

ทีนี้มาดู component Health ตัวจริง มันอยู่บน GameObject ไหนก็ได้ที่โดนทำร้ายได้ และมันยิง event (วิธีให้ object หนึ่งประกาศว่า "มีอะไรเกิดขึ้น" เพื่อให้ตัวอื่นตอบสนองได้ จากบทเรื่อง delegate/event) เพื่อให้ UI, animation, และ AI ตอบสนองได้โดยที่ Health ไม่ต้องรู้จักพวกมันเลย

using UnityEngine;
using System;

public class Health : MonoBehaviour, IDamageable
{
    [SerializeField] private float maxHp = 50f;
    private float currentHp;

    public bool IsDead { get; private set; }
    public float CurrentHp => currentHp;

    // other systems subscribe to these; Health never asks who is listening
    public event Action<float, float> OnDamaged;   // (amount, hpAfter)
    public event Action OnDied;

    void Awake()
    {
        currentHp = maxHp;
    }

    public void TakeDamage(float amount, GameObject source)
    {
        if (IsDead) return;              // dead things don't take more damage

        currentHp = Mathf.Max(0f, currentHp - amount);
        OnDamaged?.Invoke(amount, currentHp);
        Debug.Log(gameObject.name + " took " + amount + " dmg, hp = " + currentHp);

        if (currentHp <= 0f)
        {
            IsDead = true;
            OnDied?.Invoke();
            Debug.Log(gameObject.name + " died");
        }
    }
}

ลองไล่ดูว่าเกิดอะไรขึ้นเมื่อ GameObject ชื่อ Goblin ที่มี maxHp = 50 โดนตี 2 ครั้ง:

// somewhere else, e.g. a weapon script
Health goblinHealth = goblin.GetComponent<Health>();
goblinHealth.TakeDamage(20f, null);
goblinHealth.TakeDamage(40f, null);

Console output:

Goblin took 20 dmg, hp = 30
Goblin took 40 dmg, hp = 0
Goblin died

สังเกตว่า hp หยุดที่ 0 ไม่ใช่ -10 — Mathf.Max(0f, ...) เป็นตัว clamp มันไว้ สังเกตด้วยว่ามีการเช็ค if (IsDead) return; อยู่ด้านบน ถ้าไม่มีบรรทัดนี้ การโดนตีครั้งที่ 3 หลังตายไปแล้วจะยิง OnDamaged และอาจจะยิง OnDied ซ้ำอีกรอบ ซึ่งจะทำให้ตัวที่ฟัง event อยู่งง (เช่น animation ตายเล่นซ้ำสองรอบ)

Common mistake ลืมเช็ค IsDead ในเกมจริงตัวละครอาจโดนตีพร้อมกันหลายครั้งในเฟรมเดียว (การระเบิดที่ทับกับการฟันดาบ) ถ้าไม่มีการเช็คนี้ OnDied อาจยิงมากกว่าหนึ่งครั้ง ซึ่งมักหมายถึง "เสียงตายเล่นสองรอบ" หรือแย่กว่านั้นคือ "ของดรอปสองรอบ"

ตอนนี้สคริปต์ไหนก็ฟัง event ได้โดยที่ Health ไม่ต้องรู้จักมันเลย:

void Start()
{
    Health h = GetComponent<Health>();
    h.OnDied += () => Debug.Log("Play death animation, disable AI, drop loot");
}

3. Hit detection: hitbox, raycast, และ overlap query

Health.TakeDamage จะทำงานก็ต่อเมื่อมีอะไรตัดสินใจแล้วว่า ใครโดนตี การตัดสินใจนั้นเรียกว่า hit detection และ Unity มีวิธีหลัก ๆ ให้ใช้สามแบบ แต่ละแบบเหมาะกับการโจมตีคนละแบบ

HITBOX (trigger collider) RAYCAST OVERLAP QUERY shape on a weapon/bone, a thin line, fired once, check a whole shape turned on only during the reports the FIRST thing at one instant -- active frames it touches everyone inside right now [sword]===(box) P ........> X ( . . . ) the box moves with (start) (first hit) ( . X . ) <-- boom! the swing, catches ( . . . ) anything that enters it

Hitbox collider — เหมาะกับการฟันระยะประชิด, weapon trail

hitbox คือ Collider ที่ตั้งเป็น trigger (collider ที่ตรวจจับการซ้อนทับได้แต่ไม่ผลักของจริง ๆ) ติดอยู่กับอาวุธหรือ bone แล้วเปิดมันแค่ตอนที่การโจมตีควรจะโดนได้ Unity จะเรียก OnTriggerEnter ให้อัตโนมัติทุกครั้งที่มีอะไรเข้ามาในนั้น

using UnityEngine;

[RequireComponent(typeof(Collider))]
public class HitboxController : MonoBehaviour
{
    public float damage = 10f;
    private Collider hitboxCollider;

    void Awake()
    {
        hitboxCollider = GetComponent<Collider>();
        hitboxCollider.isTrigger = true;
        hitboxCollider.enabled = false;      // off until the attack says so
    }

    public void Activate()   { hitboxCollider.enabled = true;  }
    public void Deactivate() { hitboxCollider.enabled = false; }

    void OnTriggerEnter(Collider other)
    {
        IDamageable target = other.GetComponent<IDamageable>();
        if (target != null)
        {
            target.TakeDamage(damage, gameObject);
        }
    }
}

ส่วนสำคัญคือ Activate() / Deactivate() — collider จะมีผลในการตรวจจับการโดนแค่ในช่วงสั้น ๆ เท่านั้น ช่วงนั้นคือสิ่งที่หัวข้อ 4 จะพูดถึง

Common mistake เปิด hitbox collider ทิ้งไว้ตลอดเวลา ถ้ามันเปิดค้างทั้ง animation การฟันครั้งเดียวอาจโดนศัตรูตัวเดียวกันห้าครั้งในห้า physics step แทนที่จะเป็นครั้งเดียว ต้องปิดมันนอกช่วง active window เสมอ และถ้า box อาจเข้าซ้ำได้ ควรพิจารณาเก็บสถานะ "โดนไปแล้วในสวิงนี้" แยกตามเป้าหมายด้วย

Raycast — เหมาะกับ hitscan และการเช็คแบบแม่นยำ

raycast คือการยิงเส้นตรงที่มองไม่เห็นออกไปจากจุดหนึ่งในทิศทางหนึ่ง แล้วรายงานสิ่งแรกที่มันโดน ใช้แค่การเรียกครั้งเดียว ได้ผลทันที และไม่ต้องมี collider ติดอยู่กับอาวุธ เหมาะกับปืน, ลูกธนูที่เร็วเกินกว่าจะจำลองการเคลื่อนที่จริง หรือ "มีเส้นทางที่โล่งไปถึงเป้าหมายไหม"

public void FireHitscan(Vector3 origin, Vector3 direction, float range, float damage)
{
    RaycastHit hit;
    if (Physics.Raycast(origin, direction, out hit, range))
    {
        IDamageable target = hit.collider.GetComponent<IDamageable>();
        if (target != null)
        {
            target.TakeDamage(damage, gameObject);
        }
        Debug.Log("Raycast hit " + hit.collider.name + " at distance " + hit.distance);
    }
    else
    {
        Debug.Log("Raycast hit nothing");
    }
}

Overlap query — เหมาะกับ AoE burst, ground slam

overlap query (Physics.OverlapSphere, OverlapBox) ถามว่า "มีอะไรอยู่ในทรงนี้ ตอนนี้เลย?" แล้วคืนค่า collider ทุกตัวที่เจอในการเรียกครั้งเดียว ไม่มีการสวิง ไม่มีระยะเวลาเดินทาง — มันเช็คแค่ช่วงเวลาเดียว ซึ่งตรงกับสิ่งที่การระเบิดหรือ ground-slam ต้องการพอดี

public void GroundSlam(Vector3 center, float radius, float damage)
{
    Collider[] hits = Physics.OverlapSphere(center, radius);
    foreach (Collider col in hits)
    {
        IDamageable target = col.GetComponent<IDamageable>();
        if (target != null)
        {
            target.TakeDamage(damage, gameObject);
        }
    }
    Debug.Log("Ground slam hit " + hits.Length + " colliders");
}
Tip เลือกตามรูปทรงของการโจมตี ไม่ใช่ตามความเคยชิน: กระสุนวิ่งตรงคือ raycast, การฟันดาบคือ hitbox collider ที่เปิดในช่วงเวลาหนึ่ง, การระเบิดจบในเสี้ยววินาทีคือ overlap query สกิลจริง ๆ หลายตัวใช้สองแบบผสมกัน — dash ด้วยดาบอาจใช้ overlap query ที่จุดหมายปลายทาง และ raycast เพื่อเช็คว่าเส้นทาง dash ไม่โดนกำแพงบัง

4. จับเวลาการโดนตี: active frame และ windup/recovery

การโจมตีไม่ได้ "เปิด" ตลอดเวลาที่มันเล่นอยู่ เกมต่อสู้จะแบ่งการโจมตีออกเป็นสามช่วง และไอเดียเดียวกันนี้ใช้ได้กับเกมแอคชั่นทุกแบบ:

time -> 0.00s 0.15s 0.30s 0.55s |------------|------------------|------------| WINDUP ACTIVE FRAMES RECOVERY (no hit) (hitbox is ON, (no hit, can connect) can't act yet)

นี่คือการโจมตีระยะประชิดที่สร้างด้วย coroutine (เมธอดที่พักและกลับมาทำงานต่อข้ามเฟรมได้ด้วย yield ซึ่งคุณเคยใช้มาแล้วสำหรับลำดับเหตุการณ์ที่มีเวลา) มันเปิด hitbox จากหัวข้อ 3 แค่ในช่วง active window เท่านั้น

using System.Collections;
using UnityEngine;

public class MeleeAttacker : MonoBehaviour
{
    public HitboxController weaponHitbox;
    public float windupTime = 0.15f;
    public float activeTime = 0.15f;
    public float recoveryTime = 0.25f;
    private bool isAttacking = false;

    public void Attack()
    {
        if (isAttacking) return;          // ignore input mid-swing
        StartCoroutine(AttackSequence());
    }

    private IEnumerator AttackSequence()
    {
        isAttacking = true;

        Debug.Log("Windup start");
        yield return new WaitForSeconds(windupTime);

        Debug.Log("Active frames start");
        weaponHitbox.Activate();
        yield return new WaitForSeconds(activeTime);
        weaponHitbox.Deactivate();
        Debug.Log("Active frames end");

        yield return new WaitForSeconds(recoveryTime);
        Debug.Log("Recovery end, can act again");

        isAttacking = false;
    }
}

ลองไล่ดูการเรียก Attack() ด้วยตัวเลขข้างบน (windup 0.15 วินาที, active 0.15 วินาที, recovery 0.25 วินาที):

Windup start                       (t = 0.00s)
Active frames start                (t = 0.15s)
Active frames end                  (t = 0.30s)
Recovery end, can act again        (t = 0.55s)

อะไรก็ตามที่เข้ามาใน hitbox ระหว่าง t = 0.15s ถึง t = 0.30s จะโดนตี ก่อนหรือหลังช่วงนั้น hitbox ปิดอยู่ — เดินเข้าไปหาตัวละครตอน windup หรือ recovery จะไม่เกิดอะไรขึ้นเลย

Tip WaitForSeconds ใช้เรียนรู้ได้สบาย ๆ แต่เกมจริง ๆ แทบทุกเกมจะสั่ง Activate()/Deactivate() ผ่าน Animation Event (เครื่องหมายที่วางไว้บน timeline ของ animation โดยตรง ที่จะเรียกเมธอดเมื่อเล่นมาถึงจุดนั้น) วิธีนี้ทำให้ active window ตรงกับท่าฟันดาบจริงใน animation เสมอ ต่อให้ artist เปลี่ยน timing ของ animation ทีหลังก็ตาม

5. ทำไม ability ควรเป็น data ไม่ใช่ code

ทีนี้มาถึงส่วนที่น่าสนใจ สมมติคุณ hard-code fireball แบบนี้:

public class PlayerCharacter : MonoBehaviour
{
    public void CastFireball(GameObject target)
    {
        if (mana < 30f) return;
        mana -= 30f;
        target.GetComponent<IDamageable>()?.TakeDamage(25f, gameObject);
        // ...cooldown logic, effects, all written by hand, right here...
    }
}

วิธีนี้ใช้ได้กับ ability เดียว แต่ถ้าเพิ่มอีกสิบตัว PlayerCharacter จะมีเมธอดเพิ่มขึ้นทีละตัว ก็อปมาวางแล้วแก้ตัวเลขนิดหน่อย อยากให้ designer ลอง "Fireball ทำดาเมจ 40 แทนที่จะเป็น 25 และใช้มานา 45" ไหม? เขาต้องมาหาคุณ คุณต้องแก้โค้ด compile ใหม่ แล้วส่งกลับไปให้ ในสตูดิโอจริง ๆ นี่คือเวลาที่เสียไปเป็นชั่วโมง ทุกวัน ทุกครั้งที่ปรับ tuning

ทางแก้คือ ScriptableObject (คลาสของ Unity ที่เก็บข้อมูลและอยู่เป็น asset file ในโปรเจกต์ — ไม่ได้ติดอยู่กับ GameObject ในซีน แค่วางอยู่ใน Project window เหมือน texture หรือ prefab) สคริปต์ Ability ตัวเดียวที่เขียนครั้งเดียว สามารถสร้าง ability asset ได้ไม่จำกัด — Fireball, Heal, Dash Strike — แต่ละอันแค่มีชุดตัวเลขต่างกันที่กรอกผ่าน Inspector ไม่ต้องเขียนโค้ดใหม่สำหรับ ability ใหม่เลย

MonoBehaviour ScriptableObject ---------------- ----------------- lives ON a GameObject in a scene lives as an ASSET FILE in the project one copy per object in the scene one asset can be shared/read by many objects good for: behavior that runs good for: data that many things need to every frame (Update, physics) read (an ability's numbers, an item's stats)

พูดให้เป็นรูปธรรม: แทนที่จะมีสคริปต์ PlayerCharacter ตัวเดียวที่เต็มไปด้วย if-statement คุณจะได้คลาส AbilityData ตัวเดียว (หัวข้อถัดไป) และ component AbilityRunner ตัวเดียว (หัวข้อ 7) ที่เล่น ability asset ตัวไหนก็ได้ ที่ designer ลากใส่ list มา สกิลใหม่ = asset ใหม่ กรอกผ่าน Inspector ไม่มีโค้ดใหม่แม้แต่บรรทัดเดียว

6. Ability asset: cost, cooldown, damage, effects

attribute [CreateAssetMenu] เพิ่มเมนูคลิกขวาใน Unity Editor ("Create > Combat > Ability") เพื่อให้ designer สร้าง ability asset ใหม่ได้โดยไม่ต้องแตะสคริปต์เลย

using UnityEngine;

[CreateAssetMenu(menuName = "Combat/Ability", fileName = "NewAbility")]
public class AbilityData : ScriptableObject
{
    [Header("Identity")]
    public string abilityName = "New Ability";

    [Header("Cost and timing")]
    public float manaCost = 10f;
    public float cooldown = 2f;

    [Header("Effect")]
    public float damage = 15f;
    public StatusEffectData[] appliedEffects;   // buffs/debuffs/DoT this ability applies (section 9)
}

(StatusEffectData เป็น ScriptableObject เล็ก ๆ อีกตัว ซึ่งจะพูดถึงเต็ม ๆ ในหัวข้อ 9 — ตอนนี้แค่รู้ไว้ว่ามันเป็น data asset ที่บรรยาย buff, debuff หรือ damage-over-time effect หนึ่งตัว เหมือนกับที่ AbilityData บรรยาย ability หนึ่งตัว)

พอสคริปต์นี้มีอยู่แล้ว designer แค่คลิกขวาใน Project window เลือก Create > Combat > Ability แล้วกรอกค่าใน Inspector — ไม่ต้องเขียนโค้ดเลย:

Project window: Assets/Abilities/Fireball.asset Inspector: +--------------------------------------+ | Ability Name Fireball | | Mana Cost 30 | | Cooldown 4 | | Damage 25 | | Applied Effects [ Burn.asset ] | +--------------------------------------+

สร้าง asset ตัวที่สอง Heal.asset โดยใช้ข้อตกลงดาเมจติดลบแยก หรือมี field healAmount ของตัวเอง แล้วคุณก็จะได้ ability สองตัวที่ใช้โค้ดชุดเดียวกันเป๊ะ ๆ นี่คือประเด็นทั้งหมด: คลาส คือโค้ด เขียนครั้งเดียวโดยโปรแกรมเมอร์; asset คือข้อมูล สร้างได้กี่ครั้งก็ได้ตามที่ designer ต้องการ

Tip [Header("...")] แค่เพิ่มป้ายตัวหนาไว้เหนือ field ที่ตามมาใน Inspector — มันไม่ทำอะไรตอน runtime เพิ่มแทบไม่มีต้นทุนอะไรเลย แต่ทำให้ชีวิต designer ง่ายขึ้นมากเมื่อ ability asset โตขึ้นไปมีสิบห้า field

7. Ability Runner: ใช้ resource และเริ่ม cooldown

asset AbilityData เป็นแค่ตัวเลข — มันไม่รู้ว่าใครกำลังร่ายมัน มีมานาเหลือเท่าไร หรืออะไร cooldown อยู่ สถานะที่มีชีวิตแบบนั้น ที่เป็นของแต่ละตัวละคร ควรอยู่บน MonoBehaviour ที่ติดอยู่กับผู้ร่าย: Ability Runner (บางทีก็เรียกว่า "caster" หรือ "executor" ของ ability)

using System.Collections.Generic;
using UnityEngine;

public class AbilityRunner : MonoBehaviour
{
    public float currentMana = 100f;
    public float maxMana = 100f;

    // per-instance cooldown state: which ability, how much time is left
    private Dictionary<AbilityData, float> cooldowns = new Dictionary<AbilityData, float>();

    void Update()
    {
        if (cooldowns.Count == 0) return;

        // snapshot the keys first -- you cannot modify a Dictionary
        // while a foreach is walking over it
        List<AbilityData> keys = new List<AbilityData>(cooldowns.Keys);
        foreach (AbilityData key in keys)
        {
            float remaining = cooldowns[key] - Time.deltaTime;
            if (remaining <= 0f) cooldowns.Remove(key);
            else cooldowns[key] = remaining;
        }
    }

    public bool CanCast(AbilityData ability)
    {
        if (cooldowns.ContainsKey(ability)) return false;
        if (currentMana < ability.manaCost) return false;
        return true;
    }

    public bool TryCast(AbilityData ability, GameObject target)
    {
        if (!CanCast(ability))
        {
            Debug.Log("Cannot cast " + ability.abilityName + " (on cooldown or not enough mana)");
            return false;
        }

        currentMana -= ability.manaCost;
        cooldowns[ability] = ability.cooldown;

        target.GetComponent<IDamageable>()?.TakeDamage(ability.damage, gameObject);

        StatusEffectController statuses = target.GetComponent<StatusEffectController>();
        if (statuses != null && ability.appliedEffects != null)
        {
            foreach (StatusEffectData effect in ability.appliedEffects)
                statuses.ApplyEffect(effect);
        }

        Debug.Log(gameObject.name + " cast " + ability.abilityName + " on " + target.name
                   + " (mana left: " + currentMana + ")");
        return true;
    }
}

ลองไล่ดู: Wizard มีมานา 50 และมี asset Fireball ที่ manaCost = 30, cooldown = 4, damage = 25 ร่ายใส่ Goblin hp 50 ตอน t = 0 แล้วลองร่ายอีกครั้งตอน t = 1:

// t = 0
runner.TryCast(fireball, goblin);   // succeeds

// t = 1  (only 1 second has passed, cooldown is 4)
runner.TryCast(fireball, goblin);   // fails
Goblin took 25 dmg, hp = 25
Wizard cast Fireball on Goblin (mana left: 20)
Cannot cast Fireball (on cooldown or not enough mana)
Common mistake เก็บ "เวลาที่เหลือของ cooldown" เป็น field อยู่บน asset AbilityData เอง แทนที่จะเก็บใน dictionary ของ runner ปกติแล้ว ScriptableObject asset เป็น instance เดียวที่ถูกแชร์กัน — ถ้า Goblin กับ Wizard ต่างก็อ้างถึง Fireball.asset ตัวเดียวกัน แล้วคุณเขียนเวลา cooldown ที่เหลือลงบน field ของ asset ตัวนั้น การร่าย Fireball ที่ Wizard ก็จะทำให้ Goblin เข้า cooldown ไปด้วย เพราะทั้งคู่กำลังอ่านและเขียน object ตัวเดียวกันเป๊ะ ๆ เก็บตัวเลข (cost, ความยาว cooldown, damage) ไว้บน asset; เก็บสถานะ (มานาที่เหลือ, เวลาที่เหลือ) ไว้บน instance ของ MonoBehaviour หนึ่งตัวต่อตัวละครหนึ่งตัว

8. ทำให้ ability decouple จากตัวละคร

ย้อนกลับไปดู TryCast: มันไม่เคยเขียนคำว่า Player, EnemyGoblin หรือคลาสตัวละครที่เจาะจงเลยสักครั้ง มันแค่ถาม target.GetComponent<IDamageable>() กับ target.GetComponent<StatusEffectController>() นี่คือไอเดียเดียวกับตัวอย่าง barrel กับ goblin จากบท interface นำมาใช้กับ combat: ถ้ามันมี IDamageable มันคือเป้าหมายที่ถูกต้อง — ระบบ ability ไม่สนว่ามันคืออะไรจริง ๆ

// three completely different classes, none related by inheritance,
// all valid Fireball targets because all implement IDamageable
public class Health : MonoBehaviour, IDamageable { /* section 2 */ }

public class DestructibleCrate : MonoBehaviour, IDamageable
{
    public void TakeDamage(float amount, GameObject source)
    {
        Debug.Log("Crate smashed open!");
        Destroy(gameObject);
    }
}

public class ShieldGenerator : MonoBehaviour, IDamageable
{
    public float shieldHp = 200f;
    public void TakeDamage(float amount, GameObject source)
    {
        shieldHp -= amount;
        Debug.Log("Shield generator hp: " + shieldHp);
    }
}

บรรทัด AbilityRunner.TryCast(fireball, someTarget) เดียวกันนี้ทำงานได้ไม่ว่า someTarget จะเป็นผู้เล่น, goblin, ลัง หรือ shield generator ของบอส โดยไม่ต้องแก้ AbilityRunner เลยสักนิด นั่นคือสิ่งที่ "decoupled" ให้คุณได้จริง ๆ

อีกครึ่งหนึ่งของการ decouple คือ: ใครเป็นคนเรียก TryCast AbilityRunner ไม่รู้และไม่สนด้วย ตัวละครที่ผู้เล่นควบคุมเรียกมันจากสคริปต์ input; ศัตรูเรียกมันจากสคริปต์ AI ทั้งสองแบบแค่วางอยู่บน component AbilityRunner ตัวเดียวกัน

// player side
public class PlayerInput : MonoBehaviour
{
    public AbilityRunner runner;
    public AbilityData fireball;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Q))
        {
            GameObject target = FindClosestEnemy();
            runner.TryCast(fireball, target);
        }
    }
}

// enemy side -- same AbilityRunner, different decision-maker
public class SimpleEnemyAI : MonoBehaviour
{
    public AbilityRunner runner;
    public AbilityData fireball;
    public GameObject player;

    void Update()
    {
        if (runner.CanCast(fireball) && DistanceToPlayer() < 10f)
        {
            runner.TryCast(fireball, player);
        }
    }
}

AbilityRunner ไม่มี if (isPlayer) อยู่ที่ไหนเลยข้างในนั้น สคริปต์ input กับสคริปต์ AI เป็นสองอย่างเดียวที่รู้ว่าใครเป็นคนตัดสินใจว่าจะร่ายเมื่อไร สลับผู้เล่นเป็นบอสที่ควบคุมด้วย AI ระบบ ability ทั้งหมดก็ยังทำงานได้เหมือนเดิม ไม่ต้องแก้อะไรเลย

Tip วิธีเช็คง่าย ๆ ว่า decouple ดีหรือยัง: ลองค้นหาชื่อคลาสตัวละครที่เจาะจง (Player, Goblin, Boss) ในโค้ด ability/combat ของคุณ ถ้าเจอ แปลว่าส่วนนั้นผูกติดกับตัวละครตัวเดียว แล้วจะขยายไปใช้กับตัวอื่นไม่ได้ interface (IDamageable) และ component แบบทั่วไป (AbilityRunner, StatusEffectController) ไม่ควรต้องมีชื่อพวกนั้นเลย

9. Status effect: buff, debuff, และ damage over time

status effect คือการเปลี่ยนแปลงชั่วคราวที่ใส่ให้ตัวละคร: damage over time (DoT) อย่างพิษหรือไฟไหม้, debuff ที่ทำให้อ่อนแอลง (ชะลอความเร็ว, ลดดาเมจ), หรือ buff ที่ทำให้แข็งแกร่งขึ้น (เร่งความเร็ว, เพิ่มดาเมจ) เหมือนกับ ability เราอยากให้พวกนี้เป็น data ไม่ใช่คลาสแยกต่างหากสำหรับแต่ละ effect

using UnityEngine;

[CreateAssetMenu(menuName = "Combat/StatusEffect", fileName = "NewStatusEffect")]
public class StatusEffectData : ScriptableObject
{
    public string effectName = "Poison";
    public float duration = 5f;
    public float tickInterval = 1f;        // how often it deals damage
    public float damagePerTick = 4f;       // 0 for a pure buff/debuff with no DoT
    public float moveSpeedMultiplier = 1f; // 1 = no change, 0.5 = half speed
}

การใส่ effect ต้องมีสถานะ runtime ต่อ effect ที่กำลัง active อยู่ (เหลือเวลาเท่าไร, tick ถัดไปจะเกิดเมื่อไร) — สถานะนั้นอยู่ในคลาส C# ธรรมดา ไม่ใช่ ScriptableObject ด้วยเหตุผลเดียวกับที่ cooldown อยู่บน runner ไม่ใช่บน AbilityData

using System.Collections.Generic;
using UnityEngine;

public class ActiveEffect
{
    public StatusEffectData data;
    public float timeRemaining;
    public float tickTimer;
}

public class StatusEffectController : MonoBehaviour
{
    private List<ActiveEffect> active = new List<ActiveEffect>();
    private IDamageable damageable;

    void Awake()
    {
        damageable = GetComponent<IDamageable>();
    }

    public void ApplyEffect(StatusEffectData data)
    {
        active.Add(new ActiveEffect { data = data, timeRemaining = data.duration, tickTimer = data.tickInterval });
        Debug.Log(gameObject.name + " gained " + data.effectName);
    }

    // a simple modifier system: multiply every active effect's speed modifier together
    public float GetMoveSpeedMultiplier()
    {
        float multiplier = 1f;
        foreach (ActiveEffect effect in active)
            multiplier *= effect.data.moveSpeedMultiplier;
        return multiplier;
    }

    void Update()
    {
        // walk backwards so RemoveAt does not skip the next item
        for (int i = active.Count - 1; i >= 0; i--)
        {
            ActiveEffect effect = active[i];
            effect.timeRemaining -= Time.deltaTime;
            effect.tickTimer -= Time.deltaTime;

            if (effect.tickTimer <= 0f && effect.data.damagePerTick > 0f)
            {
                effect.tickTimer = effect.data.tickInterval;
                damageable?.TakeDamage(effect.data.damagePerTick, gameObject);
            }

            if (effect.timeRemaining <= 0f)
            {
                Debug.Log(gameObject.name + " lost " + effect.data.effectName);
                active.RemoveAt(i);
            }
        }
    }
}

ลองไล่ดู effect Burn (duration = 3, tickInterval = 1, damagePerTick = 4) ที่ใส่ให้ Goblin hp 50 ตอน t = 0:

t = 0.0s ApplyEffect Goblin gained Burn t = 1.0s tick Goblin took 4 dmg, hp = 46 t = 2.0s tick Goblin took 4 dmg, hp = 42 t = 3.0s tick + expires Goblin took 4 dmg, hp = 38 Goblin lost Burn

tick สามครั้ง ครั้งละ 4 ดาเมจ ตลอด 3 วินาที แล้ว effect ก็ลบตัวเองออก เพราะ damagePerTick กับ moveSpeedMultiplier เป็นแค่ตัวเลขบน asset โค้ด StatusEffectController ชุดเดียวกันเป๊ะ ๆ จัดการได้ทั้งพิษแบบ DoT, debuff ไฟไหม้ และ buff "Haste" ที่มี moveSpeedMultiplier = 1.5 กับ damagePerTick = 0 — ไม่ต้องมีคลาสใหม่เลย

Tip ลูป for ที่วนถอยหลังใน Update (i = active.Count - 1; i >= 0; i--) เป็น pattern มาตรฐานของ C# เวลาลบ item ออกจาก list ระหว่างวนลูปอยู่ ถ้าวนไปข้างหน้าแล้วเรียก RemoveAt item ทุกตัวที่อยู่หลังจากนั้นจะเลื่อนตำแหน่งลงมาหนึ่งช่อง ทำให้คุณข้าม item ที่เลื่อนเข้ามาแทนที่ช่องที่เพิ่งลบไปโดยไม่รู้ตัว

10. เอามารวมกัน: data flow เต็มระบบ

นี่คือทุกชิ้นส่วนจากบทนี้ในภาพเดียว ตามรอยการกดปุ่มครั้งเดียวตั้งแต่ input ไปจนถึงเป้าหมายตอบสนอง

1. Player presses "Q" (or AI decides to act) | v 2. PlayerInput / EnemyAI calls AbilityRunner.TryCast(fireballAsset, target) | v 3. AbilityRunner reads fireballAsset.manaCost / .cooldown / .damage / .appliedEffects (the asset is READ-ONLY data -- nothing here writes back onto it) | v 4. CanCast? checks the runner's OWN cooldowns dict and OWN currentMana | (false -> stop here) v 5. spends currentMana, sets cooldowns[fireballAsset] = cooldown (this live state lives on the RUNNER, never on the asset) | v 6. target.GetComponent<IDamageable>().TakeDamage(damage, caster) | | v v 7. Health lowers currentHp, for each effect in appliedEffects: fires OnDamaged / OnDied target.GetComponent<StatusEffectController>() | .ApplyEffect(effect) v | 8. UI / Animator / AI listen v to OnDamaged, OnDied 9. effect ticks damage over time on its (they never talk to own timer, expires on its own, AbilityRunner directly) possibly calling Health.TakeDamage again

ลองไล่ตามลูกศรแล้วดูว่าใครคุยกับใคร: AbilityRunner คุยกับเป้าหมายผ่าน IDamageable กับ StatusEffectController เท่านั้น — สัญญาเล็ก ๆ ทั่วไปสองตัว Health ไม่เคยคุยกับ AbilityRunner เลย มันแค่ยิง event ออกไปในอากาศแล้วให้ใครก็ตามที่สนใจมา subscribe เอง asset AbilityData กับ StatusEffectData ถูกอ่านหลายครั้งโดยตัวละครหลายตัว แต่ไม่เคยถูกเขียนตอน runtime เลย ทุกลูกศรในภาพนี้คือการเชื่อมต่อเล็ก ๆ ที่เจาะจงมาก — ไม่มีชิ้นไหนต้องรู้จักภาพทั้งหมดเพื่อทำงานของตัวเองได้

Tip เมื่อคุณเพิ่ม ability ตัวที่สิบทีหลัง คุณไม่ควรต้องแตะ AbilityRunner, Health, หรือ StatusEffectController เลย ถ้าการเพิ่ม ability ใหม่บังคับให้คุณแก้ตัวใดตัวหนึ่งในสามนี้ แปลว่ามีอะไรบางอย่างที่ผูกติดกันโดยไม่ควรจะเป็น คุ้มค่าที่จะถอยออกมาแล้วถามว่า interface ตัวไหนที่ยังขาดอยู่

11. Combo: ร้อย ability ต่อกันในหน้าต่างเวลาสั้น ๆ

combo แปลว่า: ถ้าผู้เล่นกดโจมตีอีกครั้ง เร็วพอ หลังจากครั้งก่อน ให้ต่อไปยังท่าถัดไปแทนที่จะเริ่มใหม่ กลไกทั้งหมดคือ timestamp กับหน้าต่างเวลาสั้น ๆ

using UnityEngine;

public class ComboController : MonoBehaviour
{
    public float comboWindow = 0.6f;   // seconds allowed between hits to keep the combo alive
    public AbilityData[] comboSteps;   // e.g. [ Slash1, Slash2, Slash3 ]

    private int comboIndex = 0;
    private float lastAttackTime = -999f;
    public AbilityRunner runner;

    public void OnAttackInput(GameObject target)
    {
        float sinceLastAttack = Time.time - lastAttackTime;

        if (sinceLastAttack > comboWindow)
        {
            comboIndex = 0;                     // too slow: combo reset to the start
        }

        AbilityData step = comboSteps[comboIndex];
        runner.TryCast(step, target);
        Debug.Log("Combo step " + comboIndex + ": " + step.abilityName);

        comboIndex = (comboIndex + 1) % comboSteps.Length;
        lastAttackTime = Time.time;
    }
}

ลองไล่ดูการกดโจมตีสามครั้งที่ t = 0.0s, t = 0.3s, และ t = 1.5s โดย comboWindow = 0.6s และมีสามท่า combo:

t = 0.0s gap from last: n/a -> step 0 (Slash1), next index = 1 t = 0.3s gap = 0.3s (<= 0.6s) -> step 1 (Slash2), next index = 2 t = 1.5s gap = 1.2s (> 0.6s) -> RESET, step 0 (Slash1) again

การกดครั้งที่สองอยู่ในหน้าต่างเวลาพอดี (0.3s <= 0.6s) มันเลยเลื่อนไปท่า 1 ส่วนการกดครั้งที่สามมาหลังครั้งที่สอง 1.2 วินาที — เกินหน้าต่างเวลาไปแล้ว — มันเลยรีเซ็ตกลับไปท่า 0 แทนที่จะไปต่อที่ท่า 2 สังเกตว่าแต่ละท่า combo ก็แค่ asset AbilityData คนละตัวที่ถูกร่ายผ่าน AbilityRunner ตัวเดียวกันจากหัวข้อ 7: ระบบ combo แค่ตัดสินใจว่าจะร่าย ability ตัวไหน ต่อไป ไม่ต้องมี logic เรื่องดาเมจหรือ cooldown ของตัวเองเลย

Tip นี่คือระบบ combo แบบง่ายเพื่อโชว์ไอเดียหลัก (ตัวจับเวลาบวก index) combo ระดับเกมต่อสู้จริง ๆ ยังต้องจัดการเรื่องการยกเลิก recovery ของท่าปัจจุบันก่อนเวลา, input buffering (การรับ input ครั้งถัดไปไว้ล่วงหน้าก่อนที่จะอนุญาตให้กดได้จริง), และแขนง combo ที่ต่างกัน สิ่งเหล่านี้คือการต่อยอดขึ้นไปจากพื้นฐาน timestamp-กับ-หน้าต่างเวลาแบบเดียวกันนี้

12. Glossary

13. Exercises

Exercise 1 ใช้คลาส Health จากหัวข้อ 2 (maxHp = 40) ทายผล console output ที่แน่นอนจากลำดับการเรียกนี้ รวมถึงบอกด้วยว่ามันตายไหมและตายเมื่อไร
Health h = enemy.GetComponent<Health>();
h.TakeDamage(15f, null);
h.TakeDamage(10f, null);
h.TakeDamage(20f, null);
h.TakeDamage(5f, null);
Show answer

ไล่ hp: 40 -> 25 -> 15 -> 0 (clamped, dies) -> การเรียกครั้งที่ 4 ถูกข้ามเพราะ IsDead เป็น true แล้ว

Enemy took 15 dmg, hp = 25
Enemy took 10 dmg, hp = 15
Enemy took 20 dmg, hp = 0
Enemy died

การเรียกครั้งที่สามจะทำให้ hp เป็น 15 - 20 = -5 แต่ Mathf.Max(0f, ...) clamp มันไว้ที่ 0 และเพราะ currentHp <= 0f เป็นจริงแล้ว OnDied เลยยิงออกมา การเรียกครั้งที่สี่ TakeDamage(5f, null) ไปโดนเช็ค if (IsDead) return; ทันทีและไม่พิมพ์อะไรออกมาเลย

Exercise 2 Wizard มี currentMana = 60 และมี AbilityData ชื่อ IceBolt ที่ manaCost = 25 กับ cooldown = 3 wizard เรียก TryCast(iceBolt, target) ที่ t = 0, t = 1, และ t = 3.5 สำหรับแต่ละครั้ง บอกว่าสำเร็จไหม และบอก currentMana ทันทีหลังจากนั้น (สมมติว่า Update ลดค่า cooldown ถูกต้องระหว่างการเรียกแต่ละครั้ง)
Show answer

t = 0: CanCast เป็นจริง (ยังไม่มี cooldown และมานา 60 >= 25) สำเร็จ มานาเหลือ 60 - 25 = 35 ตั้ง cooldown เป็น 3 ทำให้ IceBolt ถูกล็อกจนถึงประมาณ t = 3

t = 1: ผ่านไปแค่ 1 วินาที cooldown เหลืออยู่ประมาณ 2 วินาที CanCast เป็นเท็จ ล้มเหลว มานายังคงเป็น 35 (ไม่มีการใช้มานาเมื่อร่ายไม่สำเร็จ)

t = 3.5: ผ่านไป 3.5 วินาทีนับจากการร่ายที่ t = 0 ซึ่งเกิน cooldown 3 วินาทีไปแล้ว entry ของ cooldown จึงลดลงเหลือ 0 และถูกลบออกไปแล้ว มานา 35 >= 25 ดังนั้น CanCast เป็นจริง สำเร็จ มานาเหลือ 35 - 25 = 10

Exercise 3 คำถามสั้น ๆ สองข้อ

(ก) สำหรับการโจมตีแต่ละแบบ บอกวิธี hit detection ที่เหมาะที่สุดจากหัวข้อ 3 (hitbox collider, raycast, หรือ overlap query) พร้อมเหตุผลหนึ่งบรรทัด: กระสุนสไนเปอร์, การฟันดาบที่มี wind-up animation, ground-slam ที่ทำดาเมจทุกอย่างรอบตัวทันทีที่ลงพื้น

(ข) ในหัวข้อ 8 AbilityRunner.TryCast ไม่เคยพูดถึงคลาสตัวละครที่เจาะจงเลย จะเกิดอะไรพังถ้ามันเปลี่ยนให้พารามิเตอร์เป้าหมายต้องเป็นชนิด PlayerCharacter แทนที่จะเป็น GameObject ธรรมดาที่เช็คด้วย IDamageable?

Show answer

(ก) กระสุนสไนเปอร์: raycast — มันคือเส้นตรงที่ได้ผลทันทีจากปืนไปถึงสิ่งแรกที่มันโดน ไม่ต้องจำลองระยะเวลาเดินทาง การฟันดาบที่มี wind-up: hitbox collider — ใบดาบต้องการทรงที่เคลื่อนตามท่าฟัน และมีผลแค่ตอน active frames เท่านั้น ground slam: overlap query — มันเช็คทุกอย่างในรัศมีหนึ่งในช่วงเวลาเดียว ไม่มีทรงไหนเคลื่อนที่ผ่านพื้นที่ตามเวลาเลย

(ข) ถ้า TryCast บังคับให้เป้าหมายต้องเป็น PlayerCharacter ability ตัวเดียวกันนี้จะร่ายใส่ศัตรู, ลังที่ทำลายได้, หรือ shield generator ของบอสไม่ได้อีกต่อไป — ทุกอย่างพวกนี้จะ compile ไม่ผ่านหรือพังตอน runtime เพราะไม่มีอันไหนเป็น PlayerCharacter เลย คุณจะต้องมี TryCast ตัวที่สองที่เกือบซ้ำกันสำหรับเป้าหมายชนิดอื่นทุกแบบ การเช็คหา IDamageable แทนหมายความว่าคลาส ไหนก็ได้ ที่สัญญาว่าจะ implement TakeDamage จะกลายเป็นเป้าหมายที่ถูกต้องโดยอัตโนมัติ โดยไม่ต้องแก้ AbilityRunner เลย — นั่นคือประโยชน์ทั้งหมดของการเขียนโค้ดอิง interface แทนที่จะอิงคลาสที่เจาะจง

นั่นคือระบบ combat และ ability แบบเต็ม: คู่ Health/IDamageable ที่ decouple กันสำหรับการรับดาเมจ, เครื่องมือ hit detection สามแบบที่เลือกตามรูปทรงของการโจมตี, การจับเวลา active-frame ให้โดนแค่ในช่วงที่ถูกต้อง, asset AbilityData ที่ให้ designer เพิ่มสกิลได้โดยไม่ต้องเขียนโค้ด, AbilityRunner ที่ถือสถานะ cooldown/resource ที่มีชีวิตอยู่, StatusEffectController สำหรับ buff/debuff/DoT ที่สร้างจากไอเดีย data-driven แบบเดียวกัน และชั้น combo เล็ก ๆ ด้านบนที่แค่เลือกว่าจะร่าย ability asset ตัวไหนต่อไป ทุกชิ้นคุยกับชิ้นอื่นผ่าน interface หรือ event เท่านั้น — นั่นคือสิ่งที่ทำให้เพิ่ม ability ตัวที่ห้าสิบได้โดยไม่ต้องแตะ ability ตัวที่หนึ่งเลย

← กลับไปหน้ารวมบท