บทนี้พูดถึงวิธีจัดระเบียบ gameplay code ที่เราเขียนเป็นอยู่แล้ว ให้เกมที่มีโค้ดหลายพันบรรทัดยังอ่านรู้เรื่องได้แม้ผ่านไปเป็นปี บทก่อนหน้าสอนแนวคิด GameObject + component ใน Unity ไปแล้ว บทนี้จะต่อยอดจากตรงนั้น เราจะเรียนรู้ 6 pattern ที่เจอบ่อยแทบทุกเกมจริง ว่าแต่ละอันควรใช้ตอนไหน และที่สำคัญไม่แพ้กัน คือตอนไหนที่ไม่ควรใช้
ทุกเกมเริ่มจากอะไรเล็ก ๆ สคริปต์หนึ่งขยับตัวละคร อีกอันสร้าง enemy อีกอันแสดงคะแนน แล้วเกมก็โตขึ้นเรื่อย ๆ สคริปต์ enemy ต้องบอกสคริปต์ UI เมื่อผู้เล่นตาย สคริปต์ UI ต้องบอกสคริปต์เสียงให้เล่นเสียงเอฟเฟกต์ สคริปต์เสียงต้องเช็คว่าเกม pause อยู่ไหม ซึ่งข้อมูลนั้นอยู่ในอีกสคริปต์หนึ่ง ผ่านไปหกเดือน แทบทุกสคริปต์เรียกเข้าไปหาแทบทุกสคริปต์อื่นโดยตรง แก้โค้ดบรรทัดเดียวก็ทำฟีเจอร์ที่ไม่เกี่ยวข้องกันสามอย่างพังได้
ความยุ่งเหยิงแบบนี้มีชื่อเรียกว่า spaghetti code (โค้ดที่ flow การทำงานพันกันจนตามรอยส่วนหนึ่งไม่ได้เลยถ้าไม่ดึงอีกสิบส่วนตามมาด้วย เหมือนชามสปาเก็ตตี้) มันไม่ได้เกิดจากโปรแกรมเมอร์ห่วย แต่เกิดจากการไม่มีแผนว่าส่วนต่าง ๆ ของเกมจะคุยกันได้อย่างไรบ้าง
Gameplay architecture (วิธีจัดระเบียบและเชื่อมต่อโค้ดของเกม) คือคำตอบ มันไม่ใช่เรื่องเขียนโค้ดให้ฉลาด แต่เป็นเรื่องควบคุม coupling (ความผูกติดกันของโค้ดชิ้นหนึ่งกับรายละเอียดของอีกชิ้นหนึ่ง) coupling ต่ำแปลว่าเราแก้หรือลบสคริปต์หนึ่งได้โดยไม่ทำสคริปต์อื่นพังตามไปด้วย ที่เหลือของบทนี้คือกล่องเครื่องมือของรูปแบบที่คนใช้กันมานาน — design pattern (วิธีแก้ปัญหาที่นำกลับมาใช้ซ้ำได้ มีชื่อเรียก สำหรับปัญหาที่เจอซ้ำ ๆ ในซอฟต์แวร์) — ที่ช่วยคุม coupling ให้ต่ำเมื่อเกมโตขึ้น เราจะเรียนด้วยว่าตอนไหนที่ pattern เป็นเครื่องมือผิด เพราะการใช้ pattern ในจุดที่ไม่จำเป็นก็สร้าง spaghetti แบบของมันเองได้เหมือนกัน
บทก่อนหน้าแนะนำ building block หลักของ Unity ไปแล้ว: GameObject คือ container เปล่า ๆ แล้วเราให้พฤติกรรมกับมันโดยแนบ component (สคริปต์เล็ก ๆ ที่โฟกัสงานเดียว เช่น Transform, Rigidbody, หรือสคริปต์ Health ของเราเอง) นี่คือ composition (สร้างสิ่งต่าง ๆ ด้วยการรวมชิ้นส่วนเล็ก ๆ ที่เป็นอิสระต่อกัน) แทนที่จะเป็น inheritance (สร้างสิ่งต่าง ๆ ด้วยการ extend จาก parent class แล้วรับ field กับ method ของมันมา)
ทำไมเรื่องนี้ถึงสำคัญกับ architecture เพราะ inheritance tree จะโตไปในทิศทางที่ผิดเมื่อเกมใหญ่ขึ้น ลองนึกภาพโมเดล enemy แบบนี้
ทุกครั้งที่มี combination ของฟีเจอร์ใหม่ (บิน + มีโล่ + ระเบิดตอนตาย) เราถูกบังคับให้ต้อง copy โค้ดซ้ำ หรือเพิ่ม layer อีกชั้นเข้าไปใน tree เกมจริงต้องการ combination ไม่ใช่ hierarchy ที่ตายตัว composition แก้ปัญหานี้ได้: แทนที่จะมี class สำหรับทุก combination เราสร้าง component เล็ก ๆ — Flight, Shield, ExplodeOnDeath — แล้วแนบอันไหนก็ได้ที่ enemy ตัวนั้นต้องการ
// One enemy: flies and explodes, no shield
GameObject boss = new GameObject("Boss");
boss.AddComponent<Health>();
boss.AddComponent<Flight>();
boss.AddComponent<ExplodeOnDeath>();
// A different enemy: walks and has a shield, does not explode
GameObject grunt = new GameObject("Grunt");
grunt.AddComponent<Health>();
grunt.AddComponent<GroundMovement>();
grunt.AddComponent<Shield>();
ไม่มี base class ตัวไหนต้องเดาล่วงหน้าเลยว่า combination ในอนาคตจะเป็นแบบไหน pattern ในบทนี้ต่อยอดจากแนวคิดนี้ทั้งหมด: เมื่อพฤติกรรมถูกแยกอยู่ใน component เล็ก ๆ แล้ว เรายังต้องมีกฎว่า component เหล่านั้นจะรู้เรื่องกันได้อย่างไร โดยไม่ hard-wire reference ตรง ๆ ไปทั่วทุกที่ นั่นคือสิ่งที่ที่เหลือของบทนี้จะครอบคลุม
เกม real-time ทุกเกมไม่ว่าจะใช้ engine ไหน วิ่งด้วย loop พื้นฐานแบบเดียวกันอยู่ข้างใต้ทุกอย่าง: อ่าน input, อัปเดตโลกเกม, วาดเฟรม, วนซ้ำ ประมาณ 60 ครั้งต่อวินาที นี่คือ game loop Unity ซ่อนมันไว้จากเรา แต่มันยังอยู่ตรงนั้น — core ของ engine รันอะไรที่แนวคิดคล้าย ๆ แบบนี้ทุกเฟรม
// This is NOT Unity code. It is what a game engine's core loop
// looks like underneath, written as plain pseudocode-C#.
while (gameIsRunning)
{
ProcessInput(); // read keyboard, mouse, gamepad
Update(deltaTime); // move things, run AI, check rules
Render(); // draw the current frame
}
deltaTime (เวลาเป็นวินาทีนับตั้งแต่เฟรมที่แล้ว) ถูกส่งเข้ามาเพื่อให้การเคลื่อนที่ยัง smooth แม้ frame rate จะเปลี่ยน — เราใช้แนวคิดนี้ไปแล้วตอนขยับผู้เล่นด้วย transform.position += speed * Time.deltaTime
Unity เรียก loop นี้ให้เราเอง และให้ทุก MonoBehaviour มีโอกาสรันโค้ดครั้งหนึ่งต่อเฟรมผ่าน method Update
using UnityEngine;
public class Spinner : MonoBehaviour
{
public float degreesPerSecond = 90f;
// Unity's game loop calls this once per frame automatically.
void Update()
{
transform.Rotate(0f, degreesPerSecond * Time.deltaTime, 0f);
}
}
ผลลัพธ์ที่คาดหวัง: object หมุนได้ smooth ด้วยความเร็วจริงเท่ากันไม่ว่าเกมจะรันที่ 30 fps หรือ 144 fps เพราะแต่ละครั้งที่เรียกจะคูณด้วย deltaTime ของเฟรมนั้น
Unity มี loop method อยู่หลายตัว เรียกตามลำดับนี้ทุกเฟรม
Rigidbody force, velocity) ไปไว้ใน FixedUpdate ส่วนที่เหลือ (อ่าน input, timer, trigger animation) ไปไว้ใน Update ส่วนโค้ด camera-follow เอาไว้ใน LateUpdate เพื่อให้กล้องขยับหลังจากผู้เล่นขยับไปแล้วในเฟรมนั้นเสมอตอนไหนควรใช้: ใน Unity เอง engine ให้ loop นี้มาแล้ว เราแทบไม่ต้องสร้างเอง แต่การเข้าใจมันช่วยอธิบายว่าทำไมลำดับ FixedUpdate/Update/LateUpdate ถึงสำคัญ และเราต้องใช้แนวคิดนี้ตรง ๆ ถ้าจะเขียนเครื่องมือที่ไม่ใช่ Unity, simulation บน background thread, หรือระบบเทิร์นที่มี "tick" ของตัวเอง
เราใช้ Component pattern อยู่แล้วทุกครั้งที่แนบสคริปต์เข้ากับ GameObject ในฐานะเครื่องมือด้าน architecture กฎของมันเรียบง่ายมาก: ให้แต่ละ component ทำงานเดียว component Health ทำหน้าที่แค่เก็บ hit point กับจัดการความตาย component PlayerMovement ทำหน้าที่แค่อ่าน input แล้วขยับ transform component WeaponFire ทำหน้าที่แค่สร้างกระสุน ไม่มีตัวไหนควรไปจัดการ UI, save, หรือเสียงตรง ๆ ด้วย
component มักต้องเข้าถึง component อื่นบน object เดียวกัน วิธีตรงที่สุดคือ GetComponent
using UnityEngine;
public class WeaponFire : MonoBehaviour
{
private Health health;
void Awake()
{
// Cache the reference once...
health = GetComponent<Health>();
}
void Update()
{
// ...instead of calling GetComponent every frame.
if (health.IsAlive && Input.GetButtonDown("Fire1"))
{
Fire();
}
}
void Fire() { /* spawn a bullet, shown in section 8 */ }
}
GetComponent<T>() ข้างใน Update() ทุกเฟรม มันคือการค้นหาผ่าน component ของ GameObject และช้าโดยไม่จำเป็นถ้าถูกเรียกซ้ำ 60 ครั้งต่อวินาที ให้ cache ผลลัพธ์ไว้ครั้งเดียวใน Awake() หรือ Start() แล้ว reuse ตัวแปรนั้นGetComponent ใช้ได้ดีเมื่อ component หนึ่งต้องการ component พี่น้องเฉพาะเจาะจงบน object เดียวกัน แต่มันใช้ได้ไม่ดีเป็นวิธีให้ระบบที่อยู่ห่างกัน (component Health กับ UIManager บน GameObject คนละตัว) คุยกัน — reference ตรง ๆ แบบนั้นคือ coupling แบบเดียวกับใน Section 1 เลย pattern ถัดไปคือ Observer ซึ่งเป็นวิธีที่ component หนึ่งบอกผู้ฟังที่อยู่ไกลและไม่เกี่ยวข้องกันโดยตรง โดยไม่ต้องรู้ด้วยซ้ำว่าผู้ฟังพวกนั้นคือใคร
Observer pattern ให้ object หนึ่ง (เรียกว่า publisher หรือ subject) ประกาศว่ามีอะไรเกิดขึ้น โดยไม่ต้องรู้หรือสนใจว่ามี object อื่นตัวไหนกำลังฟังอยู่บ้าง ผู้ฟัง (เรียกว่า observer หรือ subscriber) ลงทะเบียนความสนใจไว้ล่วงหน้า แล้วจะถูกแจ้งเตือนอัตโนมัติ ใน C# เครื่องมือมาตรฐานสำหรับสิ่งนี้คือ event
ลองนึกถึงปัญหาจาก Section 1: เมื่อผู้เล่นตาย UI ต้องแสดงหน้าจอ game-over ระบบเสียงต้องเล่นเสียง ระบบคะแนนต้องบันทึก high score ถ้าไม่มี event Health จะต้องมี reference ตรงไปทั้งสามระบบเลย
// BAD: Health is now coupled to three unrelated systems.
public class Health : MonoBehaviour
{
public UIManager ui;
public SoundManager sound;
public ScoreManager score;
public void Die()
{
ui.ShowGameOverScreen();
sound.PlayDeathSound();
score.SaveHighScore();
// Add a fourth system later? Edit this class again.
}
}
ถ้าใช้ event Health แค่ประกาศว่า "ฉันตายแล้ว" โดยไม่รู้เลยว่าใครกำลังฟังอยู่
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
// An event other scripts can subscribe to. No listener references here.
public event Action OnDeath;
private int hp = 100;
public void TakeDamage(int amount)
{
hp -= amount;
if (hp <= 0)
{
OnDeath?.Invoke(); // "?" means: only invoke if someone subscribed
}
}
}
using UnityEngine;
public class UIManager : MonoBehaviour
{
public Health playerHealth;
void OnEnable()
{
playerHealth.OnDeath += ShowGameOverScreen; // subscribe
}
void OnDisable()
{
playerHealth.OnDeath -= ShowGameOverScreen; // unsubscribe
}
void ShowGameOverScreen()
{
Debug.Log("Game Over screen shown");
}
}
ผลลัพธ์ที่คาดหวัง เมื่อ hp ของผู้เล่นแตะ 0: console จะพิมพ์ Game Over screen shown และถ้า SoundManager กับ ScoreManager ก็ subscribe แบบเดียวกัน method ของพวกมันก็จะรันด้วย — Health ไม่เคยพูดถึงชื่อ class ของทั้งสามตัวเลย
OnEnable/Start แต่ลืม unsubscribe ใน OnDisable/OnDestroy event ยังคง hold reference ไปยัง listener ที่ถูกทำลายไปแล้ว ซึ่งจะ throw exception เวลาถูก invoke หรือแอบ leak memory เงียบ ๆ (garbage collector ไม่สามารถ free object ที่ยังมีอะไรบางอย่างอ้างอิงอยู่ได้) ต้องจับคู่ += กับ -= ที่ตรงกันเสมอตอนไหนควรใช้: ทุกครั้งที่การเปลี่ยนแปลงหนึ่งอย่างควรกระเพื่อมไปถึงหลายระบบที่ไม่เกี่ยวข้องกัน หรือตอนที่เรายังไม่รู้ครบว่าอะไรจะต้องมา react บ้าง (plugin ของ modder, ฟีเจอร์ในอนาคต) ตอนไหนไม่ควรใช้: สำหรับ dependency เดียวที่อยู่ตรงนั้นตลอด (component ที่ต้องการ Rigidbody ของตัวเองเสมอ) reference ตรง ๆ หรือ GetComponent ง่ายกว่าและตามรอยได้ง่ายกว่า event
การ hardcode input แบบนี้ดูเหมือนจะโอเคตอนแรก
void Update()
{
if (Input.GetKeyDown(KeyCode.Space)) Jump();
if (Input.GetKeyDown(KeyCode.E)) Interact();
}
มันจะพังทันทีที่เราต้องการ key remapping (ให้ผู้เล่นเลือกปุ่มของตัวเอง) ระบบ replay ฟีเจอร์ undo (พบบ่อยในเกมปริศนาและ level editor) หรือ AI ที่ต้อง queue action แบบเดียวกับที่ผู้เล่นทำได้ วิธีแก้คือ Command pattern: ห่อ action แต่ละอันไว้ใน object ที่มี method Execute() แทนที่จะเรียก method ปลายทางตรง ๆ
public interface ICommand
{
void Execute();
void Undo();
}
public class MoveCommand : ICommand
{
private readonly Transform mover;
private readonly Vector3 direction;
private Vector3 previousPosition;
public MoveCommand(Transform mover, Vector3 direction)
{
this.mover = mover;
this.direction = direction;
}
public void Execute()
{
previousPosition = mover.position;
mover.position += direction;
}
public void Undo()
{
mover.position = previousPosition;
}
}
ตอนนี้ key binding เป็นแค่ตาราง lookup จากปุ่มไปหา command และการ remap แปลว่าแก้ตาราง ไม่ใช่แก้โค้ดที่อ่าน input
using System.Collections.Generic;
using UnityEngine;
public class InputManager : MonoBehaviour
{
public Transform player;
private Dictionary<KeyCode, ICommand> bindings;
private Stack<ICommand> history = new Stack<ICommand>();
void Awake()
{
bindings = new Dictionary<KeyCode, ICommand>
{
{ KeyCode.W, new MoveCommand(player, Vector3.forward) },
{ KeyCode.S, new MoveCommand(player, Vector3.back) },
};
}
void Update()
{
foreach (var binding in bindings)
{
if (Input.GetKeyDown(binding.Key))
{
binding.Value.Execute();
history.Push(binding.Value);
}
}
if (Input.GetKeyDown(KeyCode.Z) && history.Count > 0)
{
history.Pop().Undo(); // Ctrl+Z style undo
}
}
}
ผลลัพธ์ที่คาดหวัง: กด W จะขยับผู้เล่นไปข้างหน้าหนึ่งก้าวและ push command นั้นเข้า history กด Z จะ pop command ล่าสุดแล้วเรียก Undo() ของมัน ทำให้ผู้เล่นกลับไปที่ previousPosition การ remap W ไปปุ่มอื่น แค่เปลี่ยน key ใน dictionary ตัวเดียว ไม่ต้องแตะ logic ใน Update เลย
ตอนไหนควรใช้: key remapping, undo/redo (editor, เกมปริศนา), ระบบ replay/rewind, หรือ queue action ให้ AI ตอนไหนไม่ควรใช้: prototype เล็ก ๆ ที่ปุ่มตายตัวไม่มีวัน remap — การห่อทุก action ไว้ใน class เพิ่ม ceremony ที่อาจไม่ได้ใช้เลย
AI ของ enemy มักเริ่มต้นเป็นกองของ boolean
// BAD: "boolean soup" -- gets worse with every new behavior added.
if (!isChasing && !isAttacking && playerInSight) isChasing = true;
if (isChasing && distanceToPlayer < 2f) { isChasing = false; isAttacking = true; }
if (isAttacking && !playerInSight) { isAttacking = false; isChasing = false; }
// ...six more flags later, nobody can tell which combinations are even valid.
State machine (ระบบที่อยู่ในสถานะเดียวที่มีชื่อชัดเจนเสมอ พร้อมกฎชัดเจนว่าจะย้ายไปอีกสถานะได้อย่างไร) ทำให้ logic แบบเดียวกันชัดเจนขึ้น แต่ละ state เป็นโค้ดชิ้นเล็ก ๆ ของตัวเอง มี entry action, update, และกฎว่าจะออกจาก state ได้อย่างไร
public interface IEnemyState
{
void Enter(EnemyAI enemy);
void Tick(EnemyAI enemy);
}
public class IdleState : IEnemyState
{
public void Enter(EnemyAI enemy) { enemy.PlayAnimation("Idle"); }
public void Tick(EnemyAI enemy)
{
if (enemy.CanSeePlayer())
{
enemy.ChangeState(new ChaseState());
}
}
}
public class ChaseState : IEnemyState
{
public void Enter(EnemyAI enemy) { enemy.PlayAnimation("Run"); }
public void Tick(EnemyAI enemy)
{
enemy.MoveTowardPlayer();
if (enemy.DistanceToPlayer() < 2f)
{
enemy.ChangeState(new AttackState());
}
else if (!enemy.CanSeePlayer())
{
enemy.ChangeState(new IdleState());
}
}
}
public class AttackState : IEnemyState
{
public void Enter(EnemyAI enemy) { enemy.PlayAnimation("Attack"); }
public void Tick(EnemyAI enemy)
{
if (enemy.DistanceToPlayer() >= 2f)
{
enemy.ChangeState(new ChaseState());
}
}
}
using UnityEngine;
public class EnemyAI : MonoBehaviour
{
private IEnemyState currentState;
void Start()
{
ChangeState(new IdleState());
}
void Update()
{
currentState.Tick(this);
}
public void ChangeState(IEnemyState newState)
{
currentState = newState;
currentState.Enter(this);
}
public bool CanSeePlayer() { /* raycast check */ return false; }
public float DistanceToPlayer() { /* distance check */ return 999f; }
public void MoveTowardPlayer() { /* move logic */ }
public void PlayAnimation(string name) { Debug.Log("Playing: " + name); }
}
ผลลัพธ์ที่คาดหวัง: เมื่อ enemy เริ่มทำงาน console จะพิมพ์ Playing: Idle พอ CanSeePlayer() คืนค่า true มันจะพิมพ์ Playing: Run และ enemy เริ่มขยับ วิธีนี้ตามรอยและขยายง่ายกว่ากองของ boolean ที่โตขึ้นเรื่อย ๆ มาก — เพิ่ม state ใหม่แปลว่าเขียน class ใหม่ ไม่ใช่ไปแก้ทุก if ที่มีอยู่แล้ว
ตอนไหนควรใช้: entity ไหนก็ตามที่มีโหมดพฤติกรรมชัดเจนไม่กี่แบบ: AI ของ enemy, โหมดการเคลื่อนที่ของผู้เล่น (เดินบนพื้น/ว่ายน้ำ/ปีน), หน้าจอเมนู, ไฟจราจร, phase ของเกม (เมนู/กำลังเล่น/pause/game-over) ตอนไหนไม่ควรใช้: สอง state ที่ควบคุมด้วย toggle ง่าย ๆ ตัวเดียว (isPaused) ไม่ต้องมี class hierarchy — bool ตัวเดียวชัดเจนกว่า
บทก่อนหน้าพูดถึง garbage collector ของ C# (GC — ระบบ runtime ที่ free memory ของ object ที่ไม่มีอะไรอ้างอิงถึงแล้วโดยอัตโนมัติ) และ stop-the-world pause ของมัน (ช่วงเวลาที่ GC หยุดโปรแกรมของเราเพื่อเก็บกวาด ซึ่งอาจแสดงออกมาเป็นอาการเฟรมกระตุก) ปืนกลที่เรียก Instantiate() ทุกนัดและ Destroy() ตอนโดนอะไรบางอย่าง สร้าง garbage ให้ GC ต้องเก็บอยู่ตลอดเวลา — นี่คือ pattern เป๊ะ ๆ ที่ทำให้เกิด pause แบบนั้นในเกมที่จังหวะเร็ว
// BAD: creates and destroys a new object every shot.
public class BadGun : MonoBehaviour
{
public GameObject bulletPrefab;
public void Fire()
{
GameObject bullet = Instantiate(bulletPrefab, transform.position, transform.rotation);
Destroy(bullet, 2f); // destroyed after 2 seconds -- more garbage later
}
}
Object Pool pattern แก้ปัญหานี้ด้วยการจอง object เป็นชุดตายตัวไว้ล่วงหน้าครั้งเดียว แล้ว reuse มัน: แทนที่จะทำลายกระสุน เรา deactivate มันแล้วเอากลับเข้า pool แทนที่จะสร้างอันใหม่ เราหยิบอันที่ inactive อยู่ออกมาจาก pool
using System.Collections.Generic;
using UnityEngine;
public class ObjectPool : MonoBehaviour
{
public GameObject prefab;
public int startSize = 20;
private readonly Queue<GameObject> available = new Queue<GameObject>();
void Awake()
{
for (int i = 0; i < startSize; i++)
{
GameObject obj = Instantiate(prefab);
obj.SetActive(false);
available.Enqueue(obj);
}
}
public GameObject Get(Vector3 position, Quaternion rotation)
{
GameObject obj = available.Count > 0
? available.Dequeue()
: Instantiate(prefab); // pool ran dry: grow instead of failing
obj.transform.SetPositionAndRotation(position, rotation);
obj.SetActive(true);
return obj;
}
public void Release(GameObject obj)
{
obj.SetActive(false);
available.Enqueue(obj);
}
}
public class Gun : MonoBehaviour
{
public ObjectPool bulletPool;
public void Fire()
{
GameObject bullet = bulletPool.Get(transform.position, transform.rotation);
// The bullet script itself calls bulletPool.Release(gameObject)
// when it hits something or times out, instead of Destroy().
}
}
ผลลัพธ์ที่คาดหวัง: หลังจากกระสุน 20 นัดแรกถูกจองไว้ล่วงหน้าใน Awake() การยิงจะไม่เรียก Instantiate อีกเลยระหว่างเล่นเกมปกติ — กระสุนถูก recycle ไม่มี garbage ใหม่แปลว่าไม่มี GC pause เพิ่มจากกระสุน ซึ่งสำคัญที่สุดในเกมยิงแบบ bullet-hell, เอฟเฟกต์ particle เยอะ ๆ หรือเกมไหนก็ตามที่ spawn object หลายสิบชิ้นต่อวินาที
UnityEngine.Pool.ObjectPool<T> built-in ที่จัดการ bookkeeping แบบข้างบนให้เราแล้วด้วยตอนไหนควรใช้: object ที่ถูก spawn และทำลายบ่อย ๆ โดยเฉพาะใน loop ที่ sensitive เรื่อง performance ตอนไหนไม่ควรใช้: object ที่ถูกสร้างครั้งเดียวต่อ level (บอส, ประตู) ไม่ได้อะไรจากการ pool เลย มีแต่เพิ่ม bookkeeping
บางระบบต้องการ instance เดียวจริง ๆ ที่ทุกอย่างเข้าถึงได้ เช่น AudioManager, GameManager ที่เก็บคะแนน, SaveSystem Singleton pattern ให้ class หนึ่งมี instance เดียวที่แชร์กันและเข้าถึงได้จากทุกที่
using UnityEngine;
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject); // enforce only one instance ever exists
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
public void PlaySound(AudioClip clip)
{
GetComponent<AudioSource>().PlayOneShot(clip);
}
}
// Anywhere in the game:
AudioManager.Instance.PlaySound(explosionClip);
Service Locator เป็นญาติใกล้ชิดกัน: แทนที่แต่ละระบบจะมี static Instance ของตัวเอง จะมี registry กลางตัวเดียวคอยแจก reference ให้กับระบบไหนก็ตามที่มาขอ (ประมาณ Locator.Get<IAudioService>()) ทั้งสองแก้ปัญหาเดียวกัน — เข้าถึงระบบที่แชร์กันจากที่ไหนก็ได้ — และมีอันตรายแบบเดียวกันด้วย
PlayerController พึ่งพา AudioManager, ScoreManager, และ GameManager แบบซ่อนอยู่ โดยไม่มีอะไรใน signature ของ class บอกเลย เราจะรู้ได้ก็ต่อเมื่ออ่านทุกบรรทัดAudioManager.Instance ก่อนที่ Awake() ของมันจะรัน เราจะได้ null referenceAudioManager ปลอมเข้ามาสำหรับ unit test ได้ไม่ง่าย เมื่อโค้ดเอื้อมไปหา global ที่ตายตัวตอนไหนควรใช้: ระบบระดับทั้งเกมที่มี instance เดียวจริง ๆ จำนวนน้อย (เสียง, save system, game manager ระดับบนสุด) ใช้แบบประหยัด ๆ ตอนไหนไม่ควรใช้: เป็นตัวแทนทั่วไปแทนการส่ง reference หรือใช้ event — ถ้า gameplay component สองตัวต้องคุยกัน ให้เลือก reference ตรง ๆ (ตั้งค่าใน Inspector) หรือ event (Section 5) ก่อนที่จะคว้า singleton
ทุก pattern ในบทนี้แก้ปัญหาจริงได้ — แต่ก็ต่อเมื่อปัญหานั้นเกิดขึ้นจริงแล้วเท่านั้น การคว้า pattern มาใช้ก่อนที่จะต้องการมันจริง ๆ เรียกว่า over-engineering บางทีก็ล้อกันว่า pattern-itis (เพิ่ม abstraction เพื่อ abstraction เอง) สัญญาณว่าเราเป็นแบบนี้ ได้แก่
ICommand ที่รองรับ undo ให้กับ prototype งาน game jam ที่มีปุ่มตายตัวแค่สามปุ่มซึ่งไม่มีวัน remapIEnemyState ให้กับ enemy ที่มีพฤติกรรมเดียวตลอดแต่ละอันนี้เพิ่ม indirection (ชั้นเพิ่มเติมของ "โค้ดจริงอยู่ที่อื่น") โดยไม่ได้อะไรกลับมาเลย indirection มีต้นทุน: ไฟล์เยอะขึ้น จุดที่ต้องมองหาเวลาตามรอยบั๊กเยอะขึ้น concept ที่คนใหม่ในทีมต้องเรียนรู้ก่อนจะแก้โค้ดบรรทัดเดียวได้เยอะขึ้น
if ตัวเดียว ค่อยคว้า pattern มาใช้ตอนที่ความเจ็บปวดที่มันแก้ได้เกิดขึ้นจริง เช่น เรากำลังจะเขียน coupling ที่พันกันแบบ Section 1 ซ้ำเป็นครั้งที่สาม หรือฟีเจอร์จริง (remapping, undo, pooling สำหรับ GC spike ที่วัดค่าได้จริง) อยู่ใน task list แล้ววิธีทดสอบที่มีประโยชน์ก่อนเพิ่ม pattern: เราชี้ปัญหาที่มันแก้ได้เจาะจง ในเกมจริงของเรา ตอนนี้ ได้ไหม "เผื่อมีประโยชน์ทีหลัง" ไม่ใช่คำตอบนั้น
pattern ทุกอันจนถึงตอนนี้ยังเป็นแบบ object-oriented (จัดระเบียบโค้ดรอบ ๆ object ที่รวมข้อมูลกับพฤติกรรมไว้ด้วยกัน เช่น component Health) สไตล์นี้ชัดเจนดี แต่อาจช้าเมื่อ scale ใหญ่ขึ้น เพราะสิ่งที่เราเรียนไปแล้วในบท C: พฤติกรรมของ cache (CPU เก็บ memory ที่เพิ่งใช้ล่าสุดไว้ใกล้ ๆ เพื่อเข้าถึงได้เร็ว การอ่าน memory ที่กระจัดกระจายช้ากว่าการอ่าน memory ที่เรียงติดกันมาก)
ลองนึกภาพ enemy 10,000 ตัว แต่ละตัวเป็น GameObject ของตัวเองที่มี Transform, Health, และ component AIState กระจายแยกกันอยู่ทั่ว heap การอัปเดต "ขยับ enemy ทุกตัว" กระโดดไปทั่ว memory — เกิด cache miss (CPU ต้องการข้อมูลที่ไม่ได้อยู่ใกล้ ๆ เลยต้องรอ) เกือบทุก object
Data-oriented design (DOD — จัดระเบียบโค้ดรอบ ๆ วิธีที่ข้อมูลถูกจัดวางและประมวลผลเป็นชุดใหญ่ แทนที่จะจัดรอบ ๆ object) พลิกวิธีคิด: แทนที่จะมี object แยกกัน 10,000 ตัว เราเก็บ array ของตำแหน่งที่แน่นเป็นก้อนเดียว array ของค่า health หนึ่งอัน array ของ AI state หนึ่งอัน แล้ววนลูปตรง ๆ ผ่านแต่ละ array
// Object-oriented: one array of full objects (fields scattered together)
public class EnemyData
{
public Vector3 position;
public int hp;
}
EnemyData[] enemies = new EnemyData[10000];
// Data-oriented: separate, tightly packed arrays, one per field
Vector3[] positions = new Vector3[10000];
int[] hitPoints = new int[10000];
// Moving every enemy now touches only the positions array --
// the CPU stays inside one tight block of memory instead of
// jumping to a different EnemyData object 10000 times.
for (int i = 0; i < positions.Length; i++)
{
positions[i] += Vector3.forward * Time.deltaTime;
}
DOTS/ECS ของ Unity (Data-Oriented Technology Stack / Entity Component System — ชุดเครื่องมือของ Unity ที่สร้างมาเพื่อ layout แบบนี้โดยเฉพาะ) คือเวอร์ชัน production ของแนวคิดนี้: แทนที่จะมี MonoBehaviour ต่อ enemy หนึ่งตัว เราได้ "entity" ที่จริง ๆ แล้วเป็นแค่ ID ตัวหนึ่ง โดยข้อมูลของมันกระจายอยู่ใน array ที่แน่นเป็นก้อน ("component" ในความหมายของ ECS ไม่ใช่สิ่งเดียวกับ component สคริปต์ของ Unity) ที่ system ต่าง ๆ วนลูปประมวลผลเป็นชุดใหญ่
ตอนไหนควรใช้: จำนวน object ที่คล้ายกันเยอะจริง ๆ โดยที่ผล profiler ที่วัดได้จริงบอกว่าเวอร์ชัน object-oriented ช้าเกินไป (กระสุนหลายพันนัด, particle, unit ในเกม RTS) ตอนไหนไม่ควรใช้: แทบทุกกรณีอื่น เกม GameObject/Component ปกติที่มี object active อยู่แค่หลักสิบหรือหลักร้อยจะไม่รู้สึกถึงความต่างเลย และโค้ด DOD เขียนและอ่านยากกว่า เรียนไว้เป็นมุมมองสำหรับตอนที่ scale กลายเป็นคอขวดจริง ๆ ไม่ใช่ default
ส่วนเล็ก ๆ ของเกมยิงจริง ๆ ใช้หลาย pattern พร้อมกัน แต่ละอันแก้ปัญหาเฉพาะเจาะจงของมัน
สังเกตว่าแต่ละ pattern ทำอะไรตรงนี้ และแค่ตรงนี้เท่านั้น: Command ทำให้ "ปุ่มไหน" แยกออกจาก "action อะไร" Object Pool ทำให้ "spawn กระสุน" แยกออกจาก "จอง memory" Observer ทำให้ "มีอะไรตายไป" แยกออกจาก "ใครที่สนใจบ้าง" State ทำให้ "enemy กำลังทำอะไรอยู่" แยกออกจากกองของ boolean ไม่มีตัวไหนเลยที่คุยกับ global singleton ยกเว้นบางที ScoreManager ที่จงใจทำให้เล็กมาก นี่คือเป้าหมายของ gameplay architecture: แต่ละชิ้นทำงานเดียว และการเชื่อมต่อระหว่างชิ้นต่าง ๆ หลวมที่สุดเท่าที่จะเป็นได้ ในขณะที่ยังทำงานได้อยู่
Execute() (และอาจมี Undo()) เพื่อให้ action นั้น remap, queue, หรือย้อนกลับได้Coin ที่เก็บได้ตรงไปยัง UIManager และ SoundManager โดยตรง เขียน Coin ใหม่ให้ raise C# event แทน แล้วเขียน listener ของระบบ UI กับระบบเสียงที่ subscribe เข้ามา
public class Coin : MonoBehaviour
{
public UIManager ui;
public SoundManager sound;
void OnTriggerEnter(Collider other)
{
ui.AddScore(10);
sound.PlayCoinSound();
Destroy(gameObject);
}
}
using System;
using UnityEngine;
public class Coin : MonoBehaviour
{
public static event Action<int> OnCoinCollected;
void OnTriggerEnter(Collider other)
{
OnCoinCollected?.Invoke(10);
Destroy(gameObject);
}
}
public class UIManager : MonoBehaviour
{
void OnEnable() { Coin.OnCoinCollected += AddScore; }
void OnDisable() { Coin.OnCoinCollected -= AddScore; }
void AddScore(int amount) { Debug.Log("Score +" + amount); }
}
public class SoundManager : MonoBehaviour
{
void OnEnable() { Coin.OnCoinCollected += PlayCoinSound; }
void OnDisable() { Coin.OnCoinCollected -= PlayCoinSound; }
void PlayCoinSound(int amount) { Debug.Log("Coin sound played"); }
}
Coin ไม่มี reference ไปหา manager ตัวไหนเลยแล้ว ทั้งสอง manager subscribe เข้ามาเองใน OnEnable และ unsubscribe ใน OnDisable ทำให้ manager ที่ถูกทำลายไปแล้วไม่มีวันถูกเรียกโดยผิดพลาด การเพิ่ม listener ตัวที่สามทีหลัง แปลว่าเขียน subscriber ตัวที่สาม ไม่ใช่ไปแก้ Coin
Closed, Opening, และ Open กฎ: กด "E" ตอน Closed จะย้ายไป Opening; หลังจากอยู่ใน Opening ครบ 1 วินาที จะย้ายไป Open; กด "E" ตอน Open จะย้ายกลับไป Closed ทันที เขียน state interface และ state class ทั้งสามตัว (ไม่ต้องเขียน MonoBehaviour wrapper ให้ครบ แค่ state กับร่างคร่าว ๆ ว่า Tick อ่านเวลาที่ผ่านไปอย่างไร)
public interface IDoorState
{
void Enter(Door door);
void Tick(Door door, float deltaTime);
}
public class ClosedState : IDoorState
{
public void Enter(Door door) { door.PlayAnimation("Closed"); }
public void Tick(Door door, float deltaTime)
{
if (door.InteractPressed())
{
door.ChangeState(new OpeningState());
}
}
}
public class OpeningState : IDoorState
{
private float timer;
public void Enter(Door door)
{
timer = 0f;
door.PlayAnimation("Opening");
}
public void Tick(Door door, float deltaTime)
{
timer += deltaTime;
if (timer >= 1f)
{
door.ChangeState(new OpenState());
}
}
}
public class OpenState : IDoorState
{
public void Enter(Door door) { door.PlayAnimation("Open"); }
public void Tick(Door door, float deltaTime)
{
if (door.InteractPressed())
{
door.ChangeState(new ClosedState());
}
}
}
แต่ละ state รู้แค่ entry action ของตัวเองและกฎการออกของตัวเองเท่านั้น OpeningState เก็บ field timer ของตัวเอง ซึ่งเริ่มใหม่เสมอทุกครั้งเพราะมี state object ใหม่ถูกสร้างขึ้นทุกครั้งที่ transition เกิดขึ้น class Door (ไม่ได้แสดงแบบเต็ม) ต้องการแค่ field currentState, method ChangeState ที่เรียก Enter, และ Update ที่เรียก Tick — โครงสร้างเดียวกับตัวอย่าง EnemyAI ใน Section 7
public interface ICommand { void Execute(); void Undo(); }
public class JumpCommand : ICommand
{
private readonly Rigidbody rb;
private Vector3 previousVelocity;
public JumpCommand(Rigidbody rb) { this.rb = rb; }
public void Execute()
{
previousVelocity = rb.linearVelocity;
rb.linearVelocity += Vector3.up * 5f;
}
public void Undo() { rb.linearVelocity = previousVelocity; }
}
public class InputManager : MonoBehaviour
{
public Rigidbody player;
private ICommand jumpCommand;
void Awake() { jumpCommand = new JumpCommand(player); }
void Update()
{
if (Input.GetKeyDown(KeyCode.Space)) jumpCommand.Execute();
}
}
นี่คือ pattern-itis: project นี้มีปุ่มตายตัวปุ่มเดียวที่ไม่มีวัน remap และไม่มีอะไรเรียก Undo() เลย — การกระโดดจะ undo แบบมีเหตุผลก็ทำไม่ได้อยู่แล้ว interface ICommand, class, และ constructor เพิ่ม indirection มาสามชั้นสำหรับฟีเจอร์ที่จริง ๆ แล้วเป็นแค่บรรทัดเดียว สำหรับ jam สองวัน เวอร์ชันตรง ๆ เขียนง่ายกว่า อ่านง่ายกว่า และ debug ง่ายกว่า
public class PlayerJump : MonoBehaviour
{
public Rigidbody player;
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
player.linearVelocity += Vector3.up * 5f;
}
}
}
ถ้าเกมโตขึ้นและมีความต้องการจริง ๆ ทีหลัง — key remapping กลายเป็นฟีเจอร์ที่ต้อง ship หรือมีระบบ replay — นั่นคือจังหวะที่ควรเริ่มใช้ ICommand ไม่ใช่ก่อนหน้านั้น