ตอนนี้คุณสร้างตัวละครที่เดินได้ ชนกับของอื่นได้ และตอบสนอง input ได้แล้ว บทนี้จะสอนวิธีให้ตัวละครพวกนั้นมี combat system (ระบบต่อสู้): วิธีสร้างความเสียหาย, รับความเสียหาย, ตาย, ร่ายสกิลอย่าง fireball หรือ heal, และใส่ effect อย่างพิษหรือบัฟความเร็ว นี่คือแกนหลักของเกมแอคชั่นหรือเกม gacha แทบทุกเกม — เกมของ HoYoverse อย่าง Genshin Impact กับ Honkai: Star Rail สร้างขึ้นจากชิ้นส่วนพวกนี้เป๊ะ ๆ แค่มีคอนเทนต์ทับซ้อนเยอะกว่าเท่านั้นเอง
เป้าหมายในบทนี้ไม่ใช่แค่ "ทำให้ตัวเลขลดลง" เป้าหมายจริง ๆ คือสร้างระบบให้ designer (คนที่ไม่ใช่โปรแกรมเมอร์) เพิ่มสกิลใหม่ได้เองด้วยการกรอกตัวเลขใน Unity Editor โดยไม่ต้องเขียนโค้ดเพิ่มเลยสักบรรทัด ไอเดียเดียวนี้ — data-driven design (ออกแบบให้ขับเคลื่อนด้วยข้อมูล) — คือสิ่งที่กำหนดแทบทุกการตัดสินใจในบทนี้
แยก "combat" ออกเป็นงานย่อย ๆ ที่เป็นอิสระจากกัน แต่ละงานควรเป็นโค้ดของตัวเองที่แทบไม่รู้จักงานอื่นเลย:
คำที่ใช้เรียกการแยกงานพวกนี้ออกจากกันคือ decoupling (การลดการผูกติดกันตรง ๆ ระหว่างชิ้นโค้ด เพื่อให้แต่ละชิ้นเปลี่ยนได้โดยไม่ทำพังชิ้นอื่น) ระบบ combat แบบ coupled (ผูกติดกัน) อาจมีเมธอด Fireball() เขียนตรง ๆ อยู่ในคลาส PlayerCharacter เช็ค if (target is EnemyGoblin) แล้วทำอะไรพิเศษเฉพาะศัตรูชนิดนั้น ส่วนแบบ decoupled (แยกกันเป็นอิสระ) จะมี Ability แบบทั่วไปที่ทำงานได้กับอะไรก็ตามที่มี component Health ถูกร่ายโดยอะไรก็ตามที่มี component AbilityRunner — จะเป็นผู้เล่น ศัตรู หรือบอสก็ไม่ต่าง บทนี้จะสร้างมันแบบ decoupled ตั้งแต่ต้น
จำภาพนี้ไว้ในหัว แต่ละหัวข้อข้างล่างจะสร้างกล่องหนึ่งกล่องในภาพนี้ พอถึงหัวข้อ 10 คุณจะเห็นทั้งหมดต่อเข้าด้วยกันเป็นโค้ดจริง
ทุกอย่างที่รับดาเมจได้ในเกม — ผู้เล่น ศัตรู ลังที่ทำลายได้ — ต้องมีพฤติกรรมชุดเล็ก ๆ ชุดเดียวกัน: เก็บ 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 ตายเล่นซ้ำสองรอบ)
IsDead ในเกมจริงตัวละครอาจโดนตีพร้อมกันหลายครั้งในเฟรมเดียว (การระเบิดที่ทับกับการฟันดาบ) ถ้าไม่มีการเช็คนี้ OnDied อาจยิงมากกว่าหนึ่งครั้ง ซึ่งมักหมายถึง "เสียงตายเล่นสองรอบ" หรือแย่กว่านั้นคือ "ของดรอปสองรอบ"ตอนนี้สคริปต์ไหนก็ฟัง event ได้โดยที่ Health ไม่ต้องรู้จักมันเลย:
void Start()
{
Health h = GetComponent<Health>();
h.OnDied += () => Debug.Log("Play death animation, disable AI, drop loot");
}
Health.TakeDamage จะทำงานก็ต่อเมื่อมีอะไรตัดสินใจแล้วว่า ใครโดนตี การตัดสินใจนั้นเรียกว่า hit detection และ Unity มีวิธีหลัก ๆ ให้ใช้สามแบบ แต่ละแบบเหมาะกับการโจมตีคนละแบบ
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 จะพูดถึง
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 (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");
}
การโจมตีไม่ได้ "เปิด" ตลอดเวลาที่มันเล่นอยู่ เกมต่อสู้จะแบ่งการโจมตีออกเป็นสามช่วง และไอเดียเดียวกันนี้ใช้ได้กับเกมแอคชั่นทุกแบบ:
นี่คือการโจมตีระยะประชิดที่สร้างด้วย 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 จะไม่เกิดอะไรขึ้นเลย
WaitForSeconds ใช้เรียนรู้ได้สบาย ๆ แต่เกมจริง ๆ แทบทุกเกมจะสั่ง Activate()/Deactivate() ผ่าน Animation Event (เครื่องหมายที่วางไว้บน timeline ของ animation โดยตรง ที่จะเรียกเมธอดเมื่อเล่นมาถึงจุดนั้น) วิธีนี้ทำให้ active window ตรงกับท่าฟันดาบจริงใน animation เสมอ ต่อให้ artist เปลี่ยน timing ของ animation ทีหลังก็ตามทีนี้มาถึงส่วนที่น่าสนใจ สมมติคุณ 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 ใหม่เลย
พูดให้เป็นรูปธรรม: แทนที่จะมีสคริปต์ PlayerCharacter ตัวเดียวที่เต็มไปด้วย if-statement คุณจะได้คลาส AbilityData ตัวเดียว (หัวข้อถัดไป) และ component AbilityRunner ตัวเดียว (หัวข้อ 7) ที่เล่น ability asset ตัวไหนก็ได้ ที่ designer ลากใส่ list มา สกิลใหม่ = asset ใหม่ กรอกผ่าน Inspector ไม่มีโค้ดใหม่แม้แต่บรรทัดเดียว
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 — ไม่ต้องเขียนโค้ดเลย:
สร้าง asset ตัวที่สอง Heal.asset โดยใช้ข้อตกลงดาเมจติดลบแยก หรือมี field healAmount ของตัวเอง แล้วคุณก็จะได้ ability สองตัวที่ใช้โค้ดชุดเดียวกันเป๊ะ ๆ นี่คือประเด็นทั้งหมด: คลาส คือโค้ด เขียนครั้งเดียวโดยโปรแกรมเมอร์; asset คือข้อมูล สร้างได้กี่ครั้งก็ได้ตามที่ designer ต้องการ
[Header("...")] แค่เพิ่มป้ายตัวหนาไว้เหนือ field ที่ตามมาใน Inspector — มันไม่ทำอะไรตอน runtime เพิ่มแทบไม่มีต้นทุนอะไรเลย แต่ทำให้ชีวิต designer ง่ายขึ้นมากเมื่อ ability asset โตขึ้นไปมีสิบห้า fieldasset 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)
AbilityData เอง แทนที่จะเก็บใน dictionary ของ runner ปกติแล้ว ScriptableObject asset เป็น instance เดียวที่ถูกแชร์กัน — ถ้า Goblin กับ Wizard ต่างก็อ้างถึง Fireball.asset ตัวเดียวกัน แล้วคุณเขียนเวลา cooldown ที่เหลือลงบน field ของ asset ตัวนั้น การร่าย Fireball ที่ Wizard ก็จะทำให้ Goblin เข้า cooldown ไปด้วย เพราะทั้งคู่กำลังอ่านและเขียน object ตัวเดียวกันเป๊ะ ๆ เก็บตัวเลข (cost, ความยาว cooldown, damage) ไว้บน asset; เก็บสถานะ (มานาที่เหลือ, เวลาที่เหลือ) ไว้บน instance ของ MonoBehaviour หนึ่งตัวต่อตัวละครหนึ่งตัวย้อนกลับไปดู 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 ทั้งหมดก็ยังทำงานได้เหมือนเดิม ไม่ต้องแก้อะไรเลย
Player, Goblin, Boss) ในโค้ด ability/combat ของคุณ ถ้าเจอ แปลว่าส่วนนั้นผูกติดกับตัวละครตัวเดียว แล้วจะขยายไปใช้กับตัวอื่นไม่ได้ interface (IDamageable) และ component แบบทั่วไป (AbilityRunner, StatusEffectController) ไม่ควรต้องมีชื่อพวกนั้นเลย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:
tick สามครั้ง ครั้งละ 4 ดาเมจ ตลอด 3 วินาที แล้ว effect ก็ลบตัวเองออก เพราะ damagePerTick กับ moveSpeedMultiplier เป็นแค่ตัวเลขบน asset โค้ด StatusEffectController ชุดเดียวกันเป๊ะ ๆ จัดการได้ทั้งพิษแบบ DoT, debuff ไฟไหม้ และ buff "Haste" ที่มี moveSpeedMultiplier = 1.5 กับ damagePerTick = 0 — ไม่ต้องมีคลาสใหม่เลย
for ที่วนถอยหลังใน Update (i = active.Count - 1; i >= 0; i--) เป็น pattern มาตรฐานของ C# เวลาลบ item ออกจาก list ระหว่างวนลูปอยู่ ถ้าวนไปข้างหน้าแล้วเรียก RemoveAt item ทุกตัวที่อยู่หลังจากนั้นจะเลื่อนตำแหน่งลงมาหนึ่งช่อง ทำให้คุณข้าม item ที่เลื่อนเข้ามาแทนที่ช่องที่เพิ่งลบไปโดยไม่รู้ตัวนี่คือทุกชิ้นส่วนจากบทนี้ในภาพเดียว ตามรอยการกดปุ่มครั้งเดียวตั้งแต่ input ไปจนถึงเป้าหมายตอบสนอง
ลองไล่ตามลูกศรแล้วดูว่าใครคุยกับใคร: AbilityRunner คุยกับเป้าหมายผ่าน IDamageable กับ StatusEffectController เท่านั้น — สัญญาเล็ก ๆ ทั่วไปสองตัว Health ไม่เคยคุยกับ AbilityRunner เลย มันแค่ยิง event ออกไปในอากาศแล้วให้ใครก็ตามที่สนใจมา subscribe เอง asset AbilityData กับ StatusEffectData ถูกอ่านหลายครั้งโดยตัวละครหลายตัว แต่ไม่เคยถูกเขียนตอน runtime เลย ทุกลูกศรในภาพนี้คือการเชื่อมต่อเล็ก ๆ ที่เจาะจงมาก — ไม่มีชิ้นไหนต้องรู้จักภาพทั้งหมดเพื่อทำงานของตัวเองได้
AbilityRunner, Health, หรือ StatusEffectController เลย ถ้าการเพิ่ม ability ใหม่บังคับให้คุณแก้ตัวใดตัวหนึ่งในสามนี้ แปลว่ามีอะไรบางอย่างที่ผูกติดกันโดยไม่ควรจะเป็น คุ้มค่าที่จะถอยออกมาแล้วถามว่า interface ตัวไหนที่ยังขาดอยู่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:
การกดครั้งที่สองอยู่ในหน้าต่างเวลาพอดี (0.3s <= 0.6s) มันเลยเลื่อนไปท่า 1 ส่วนการกดครั้งที่สามมาหลังครั้งที่สอง 1.2 วินาที — เกินหน้าต่างเวลาไปแล้ว — มันเลยรีเซ็ตกลับไปท่า 0 แทนที่จะไปต่อที่ท่า 2 สังเกตว่าแต่ละท่า combo ก็แค่ asset AbilityData คนละตัวที่ถูกร่ายผ่าน AbilityRunner ตัวเดียวกันจากหัวข้อ 7: ระบบ combo แค่ตัดสินใจว่าจะร่าย ability ตัวไหน ต่อไป ไม่ต้องมี logic เรื่องดาเมจหรือ cooldown ของตัวเองเลย
TakeDamage; อะไรก็ตามที่ implement มันคือเป้าหมาย combat ที่ถูกต้องAction) — วิธีให้ object ประกาศว่ามีอะไรเกิดขึ้นโดยไม่ต้องรู้ว่าใครกำลังฟังอยู่OverlapSphere/OverlapBox) — หาทุกอย่างที่อยู่ในทรงหนึ่งในช่วงเวลาเดียว เหมาะกับ AoE burstHealth จากหัวข้อ 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);
ไล่ 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; ทันทีและไม่พิมพ์อะไรออกมาเลย
Wizard มี currentMana = 60 และมี AbilityData ชื่อ IceBolt ที่ manaCost = 25 กับ cooldown = 3 wizard เรียก TryCast(iceBolt, target) ที่ t = 0, t = 1, และ t = 3.5 สำหรับแต่ละครั้ง บอกว่าสำเร็จไหม และบอก currentMana ทันทีหลังจากนั้น (สมมติว่า Update ลดค่า cooldown ถูกต้องระหว่างการเรียกแต่ละครั้ง)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
(ก) สำหรับการโจมตีแต่ละแบบ บอกวิธี hit detection ที่เหมาะที่สุดจากหัวข้อ 3 (hitbox collider, raycast, หรือ overlap query) พร้อมเหตุผลหนึ่งบรรทัด: กระสุนสไนเปอร์, การฟันดาบที่มี wind-up animation, ground-slam ที่ทำดาเมจทุกอย่างรอบตัวทันทีที่ลงพื้น
(ข) ในหัวข้อ 8 AbilityRunner.TryCast ไม่เคยพูดถึงคลาสตัวละครที่เจาะจงเลย จะเกิดอะไรพังถ้ามันเปลี่ยนให้พารามิเตอร์เป้าหมายต้องเป็นชนิด PlayerCharacter แทนที่จะเป็น GameObject ธรรมดาที่เช็คด้วย IDamageable?
(ก) กระสุนสไนเปอร์: 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 ตัวที่หนึ่งเลย