บทเรื่อง Gameplay Architecture (6.7) ใช้ pattern ไปแล้วหลายตัว — Component, Observer, Command, State, Object Pool — เพื่อแก้ปัญหาเดียว คือกันไม่ให้สคริปต์ของเกมที่โตขึ้นเรื่อย ๆ กลายเป็น spaghetti บทนี้ไม่ใช่ภาคต่อของปัญหานั้น แต่เป็นเรื่องของตัว pattern เอง ในฐานะเครื่องมือที่ programmer เกมมืออาชีพพูดชื่อออกมาตรง ๆ ตอน code review ตอนอ่าน engine source ที่คุณจะอ่านไปอีกหลายปี และตอนสัมภาษณ์งาน คุณรู้รูปคร่าว ๆ ของ pattern ห้าตัวนี้จาก 6.7 อยู่แล้ว ในบทนี้คุณจะเห็นรูปแบบทางการที่ทุก pattern ถูกสร้างขึ้นมาจาก จุดคมที่ 6.7 ไม่มีที่ว่างพอจะพูดถึง pattern อีกสองตัวที่บทนั้นไม่เคยพูดถึงเลย (Flyweight กับ Dirty Flag) และ — ส่วนที่ tutorial ส่วนใหญ่มักข้าม — วิธีที่ pattern กลายเป็นความผิดพลาดตอนคุณหยิบมันมาใช้ก่อนที่จะต้องการจริง ๆ
design pattern คือทางแก้ปัญหาที่มีชื่อและใช้ซ้ำได้ สำหรับปัญหาที่โผล่ซ้ำ ๆ ในโปรแกรมต่างกัน เขียนโดยคนต่างกันที่ไม่เคยคุยกันมาก่อน มันไม่ใช่ library ที่ import มาใช้ และไม่ใช่ feature ของภาษา — มันคือรูปทรงที่คุณจดจำได้และสร้างขึ้นใหม่ด้วยมือทุกครั้ง เพราะโค้ดจริงต่างกันแต่โครงสร้างซ้ำเดิม pattern ถูก "ค้นพบ" ไม่ใช่ "ประดิษฐ์ขึ้น" — มีคนสังเกตว่ารูปทรงเดิมโผล่มาซ้ำ ๆ แล้วตั้งชื่อให้มัน เพื่อให้ programmer คนอื่นพูดว่า "ใช้ Command ตรงนี้" แทนที่จะอธิบายไอเดียทั้งหมดใหม่ตั้งแต่ต้น
นี่คือการค้นพบแบบนั้นในเวอร์ชันย่อ programmer สองคนในทีมเดียวกัน ที่ไม่เคยอ่านโค้ดของกันและกันเลย ต่างคนต่างแก้ปัญหา "ลองใหม่ถ้าพลาด" ด้วยวิธีเดียวกัน:
// SaveSystem.cs -- written by one programmer
int attempts = 0;
while (attempts < 3)
{
if (TrySaveToDisk()) break;
attempts++;
}
// NetworkClient.cs -- written by a different programmer, months
// later, who never saw SaveSystem.cs
int tries = 0;
while (tries < 3)
{
if (TrySendRequest()) break;
tries++;
}
โค้ดทั้งสองก้อนเป็นรูปทรงเดียวกัน แค่ใส่ชื่อตัวแปรต่างกัน: ลอง แล้วถ้าพลาดก็ลองใหม่ จนถึงจำนวนครั้งที่กำหนด พอรูปทรงแบบนี้โผล่บ่อยพอทั่วทั้งวงการ มันจะได้คำอธิบายสี่ส่วน และทุก pattern ในบทนี้ก็ตามรูปแบบสี่ส่วนเดียวกันนี้:
attempts == 3 ทีหลัง ความล้มเหลวจริง ๆ ที่ถาวรก็แอบซ่อนอยู่หลังการลองใหม่เงียบ ๆ สามครั้งได้)ส่วนสุดท้าย — consequences — คือส่วนที่มือใหม่มักข้าม และเป็นเหตุผลที่บทนี้มีอยู่ทั้งบท ทุก pattern ข้างล่างนี้มีต้นทุนจริง ก่อนจะเรียนตัวไหนเลย คุณต้องเห็นก่อนว่าเกิดอะไรขึ้นเมื่อเอา pattern ไปใช้กับปัญหาที่ยังไม่เกิดขึ้นจริง
สวิตช์ไฟดวงเดียว ควบคุมด้วยปุ่มเดียว อ่านค่าด้วยสคริปต์เดียว ไม่ต้องการ pattern ใด ๆ เลย:
// Over-patterned: one light, wrapped in a Command, a Factory,
// and an interface, for a feature nobody asked for.
public interface ILightCommand { void Execute(); }
public class ToggleLightCommand : ILightCommand
{
private readonly Light light;
public ToggleLightCommand(Light light) { this.light = light; }
public void Execute() => light.enabled = !light.enabled;
}
public class LightCommandFactory
{
public static ILightCommand CreateToggle(Light light) => new ToggleLightCommand(light);
}
// Used like this:
ILightCommand cmd = LightCommandFactory.CreateToggle(myLight);
cmd.Execute();
// ...versus just:
myLight.enabled = !myLight.enabled;
โค้ดทั้งสองแบบทำสิ่งเดียวกันเป๊ะตอนรัน แบบแรกเสียไฟล์เพิ่มสามไฟล์ และต้องกระโดดผ่าน interface อีกชั้น แลกกับ feature — undo, rebinding, replay — ที่ไม่มีใครขอ และไฟดวงนี้แทบไม่มีทางต้องการเลย นี่คือ over-patterning (บางทีเรียกว่า pattern-itis): การใส่โครงสร้างของ pattern เข้าไปก่อนที่ปัญหาของมันจะเกิดขึ้นจริง มันเป็นปัญหาจริงพอ ๆ กับ spaghetti code ที่พูดถึงในตอนต้นของ 6.7 เลย — แค่มันพังไปคนละทิศทาง spaghetti code อ่านยากเพราะทุกอย่างแตะกันหมด ส่วนโค้ดที่ over-pattern อ่านยากเพราะบรรทัดพฤติกรรมจริง ๆ บรรทัดเดียวถูกซ่อนอยู่หลังเลเยอร์ที่ไม่มีใครต้องการ จนเพื่อนร่วมทีมต้องเปิดไฟล์สี่ไฟล์เพื่อหาบรรทัดที่แค่เปิด-ปิดไฟ
เกม real-time ทุกเกม ลึก ๆ แล้วรันลูปเดียวกันหมด: อ่าน input, update โลก, render เฟรม, วนซ้ำ หลายสิบครั้งต่อวินาที Unity ซ่อนลูปนี้ไว้และเรียก Update() ให้ทุก MonoBehaviour อัตโนมัติ แต่ทันทีที่คุณสร้างระบบที่ไม่ใช่ MonoBehaviour — simulation แบบ headless, AI planner ที่รันเบื้องหลัง, scripting engine ของตัวเอง — คุณต้องสร้างลูปนั้นเอง และวิธีที่คุณสร้างมันก็เป็น pattern ที่ควรมีชื่อเรียกเช่นกัน: Update Method pattern รูปทรงของมันเรียบง่าย: object ทุกตัวที่ต้องทำงานทุกเฟรมจะเปิด method ออกมา (มักเรียกว่า Tick หรือ Update) แล้วมีลูปกลางหนึ่งลูปคอยเรียก method นั้นบน object ที่ลงทะเบียนไว้ทุกตัว ครั้งละหนึ่งเฟรม
public interface IUpdatable
{
void Tick(float deltaTime);
}
public class GameLoop : MonoBehaviour
{
private readonly List<IUpdatable> updatables = new List<IUpdatable>();
public void Register(IUpdatable obj) => updatables.Add(obj);
public void Unregister(IUpdatable obj) => updatables.Remove(obj);
void Update()
{
float dt = Time.deltaTime;
foreach (IUpdatable obj in updatables)
{
obj.Tick(dt);
}
}
}
โค้ดนี้ดูไม่มีพิษภัยจนกว่า object ตัวใดตัวหนึ่งจะลบตัวเองออกจาก list ระหว่าง Tick ของตัวมันเอง — ซึ่งเป็นสิ่งที่ enemy ทำพอดีตอนมันตายกลางเฟรม:
public class Enemy : IUpdatable
{
private GameLoop loop;
private int hp = 10;
public void Tick(float deltaTime)
{
hp -= 1;
if (hp <= 0)
{
loop.Unregister(this); // removes itself from the list being iterated right now
}
}
}
ผลที่ควรได้: เกม crash ด้วย System.InvalidOperationException: Collection was modified; enumeration operation may not execute. ลูป foreach เก็บ cursor ภายในไว้ชี้ใน list และ C# ไม่ยอมให้คุณเปลี่ยนรูปทรงของ list ระหว่างที่ cursor นั้นยังเดินอยู่
void Update()
{
float dt = Time.deltaTime;
for (int i = updatables.Count - 1; i >= 0; i--)
{
updatables[i].Tick(dt);
}
}
ลูป MonoBehaviour.Update ของ Unity เองแก้ปัญหานี้ให้คุณไปแล้วภายใน ซึ่งเป็นเหตุผลที่การเรียก Destroy() กลางเฟรมไม่ทำให้ engine crash บั๊กข้างบนคือสิ่งที่ควรคาดไว้ทันทีที่ คุณ สร้าง list ของ "สิ่งที่ต้อง tick ทุกเฟรม" ขึ้นมาเอง — ซึ่งเกิดขึ้นบ่อยมากทันทีที่คุณเริ่มเขียนระบบที่ไม่ใช่ MonoBehaviour
deltaTime ของเฟรมนั้น ๆ ได้อย่างปลอดภัย การแบ่ง FixedUpdate/Update ของ Unity คือความต่างแบบนี้เป๊ะ ๆ กลไกเต็ม ๆ เป็นของบทฟิสิกส์ แต่การจำได้ว่ามันคือ Update Method pattern ตัวเดียวกันที่รันคนละอัตรากันนั้นมีประโยชน์ต่อไปแน่นอนตอนไหนควรพึ่งมันตรง ๆ: แทบไม่มีเลยใน Unity — คุณได้มันมาฟรีผ่าน MonoBehaviour อยู่แล้ว หยิบ list แบบ IUpdatable ที่สร้างเองมาใช้ตอนที่ต้องการควบคุมลำดับ update อย่างชัดเจน, simulation แบบ headless ที่ไม่มี MonoBehaviour เลย, หรือ server tick ที่รันด้วยอัตราคงที่ ตอนไหนไม่ควร: ถ้า Update() ธรรมดาทำสิ่งที่คุณต้องการอยู่แล้ว การสร้าง registration list ของตัวเองก็แค่เพิ่มลูปที่สองที่ต้องคอยซิงก์กับของ Unity
คุณใช้ Component pattern อยู่แล้วทุกครั้งที่แนบสคริปต์เข้ากับ GameObject: composition (สร้างพฤติกรรมจากชิ้นเล็ก ๆ ที่เป็นอิสระ แนบเข้าออกได้) แทน inheritance (ขยาย class hierarchy ลึก ๆ ชั้นเดียว) สิ่งที่ 6.7 ไม่ได้แสดงคือ วิธี ที่มันทำงานจริง ๆ ข้างใต้ — ความรู้ที่คุณต้องใช้ทันทีที่แตะ engine ที่ไม่ใช่ Unity หรือตอนที่ interviewer ขอให้คุณสร้างมันขึ้นมาเองตั้งแต่ต้น ถ้าลอกกลไกของ Unity ออกไป component system ก็ไม่มีอะไรมากไปกว่า dictionary ที่ key ด้วย type:
using System;
using System.Collections.Generic;
public class Entity
{
private readonly Dictionary<Type, object> components = new Dictionary<Type, object>();
public void AddComponent<T>(T component)
{
components[typeof(T)] = component;
}
public T GetComponent<T>() where T : class
{
components.TryGetValue(typeof(T), out object found);
return found as T; // null if this entity never got one
}
}
public class PositionComponent { public float X, Y; }
public class HealthComponent { public int Hp = 100; }
Entity boss = new Entity();
boss.AddComponent(new PositionComponent { X = 0, Y = 0 });
boss.AddComponent(new HealthComponent { Hp = 500 });
HealthComponent hc = boss.GetComponent<HealthComponent>();
Console.WriteLine("Boss HP: " + hc.Hp);
ผลลัพธ์ที่ควรได้: Boss HP: 500 ไม่มีอะไรตรงนี้เป็นของ Unity โดยเฉพาะเลย — ไม่มี MonoBehaviour ไม่มี Instantiate มีแค่ class ธรรมดาที่ถือ dictionary ไว้
นี่คือ (ด้วยการ optimize เยอะกว่านี้มาก — memory pool ที่ต่อเนื่องกันแทน dictionary ธรรมดา, integer type ID แทน reflection) สิ่งที่ GameObject ของ Unity เองทำอยู่ข้างในโดยประมาณ และเป็นสิ่งที่คุณต้องสร้างเองมือด้วยใน custom C++ engine การจำรูปทรง "dictionary คีย์ด้วย type" นี้ได้ คือสิ่งที่เปลี่ยน "ฉันใช้ component" ให้กลายเป็น "ฉันเข้าใจ component"
ปัญหาเกมจริงที่มันแก้: enemy ที่ต้องผสม บินได้ + มีโล่ + ระเบิดตอนตาย โดยไม่ต้องมี class แยกสำหรับทุกชุดผสม — แค่แนบ Flight, Shield, และ ExplodeOnDeath เข้าไป และไม่มีตัวไหนต้องรู้เลยว่าอีกตัวมีอยู่ ตอนไหนไม่ควรใช้: ค่าที่ไม่มีพฤติกรรมเป็นของตัวเอง (Vector3 ธรรมดา, config struct) ไม่จำเป็นต้องเป็น component — การทำให้มันเป็นเสีย dictionary lookup กับการ allocate เพิ่ม สำหรับสิ่งที่ field ธรรมดาจัดการให้ฟรีอยู่แล้ว และ object ที่จะมีพฤติกรรมชุดเดียวคงที่ตลอดไป ไม่เคยผสมหรือสลับเลย ก็ไม่ได้ประโยชน์อะไรจากความ composable ที่ไม่เคยใช้
Observer pattern ให้ object หนึ่งตัว (publisher) ประกาศว่ามีอะไรเกิดขึ้น โดยไม่ต้องรู้หรือสนใจว่าใครกำลังฟังอยู่ ตัวอย่างคลาสสิกในเกมของมันคือ health bar: Health ควรจะเสีย hit point ได้โดยไม่ต้อง import, reference, หรือแม้แต่รู้ด้วยซ้ำว่ามี UI อยู่
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
// (current, max) -- publisher, no listener references stored here
public event Action<int, int> OnHealthChanged;
private int current = 100;
private int max = 100;
public void TakeDamage(int amount)
{
current = Mathf.Max(0, current - amount);
OnHealthChanged?.Invoke(current, max);
}
}
using UnityEngine;
using UnityEngine.UI;
public class HealthBarUI : MonoBehaviour
{
public Health target;
public Slider slider;
void OnEnable() { target.OnHealthChanged += Refresh; }
void OnDisable() { target.OnHealthChanged -= Refresh; }
void Refresh(int current, int max)
{
slider.value = (float)current / max;
}
}
ผลที่ควรได้: ตอน TakeDamage(30) รัน Health ไม่รู้อะไรเลยเกี่ยวกับ slider, canvas, หรือ UI ใด ๆ — มันแค่ยิง event ออกมา HealthBarUI เป็นฝ่ายไปหา Health แล้ว subscribe เอง Health ไม่เคยไปหา HealthBarUI เลย
event ของ C# คือ multicast delegate (ตัวแปรเดียวที่ข้างในเก็บ list ของ method ไว้ เรียกทุกตัวตามลำดับที่ subscribe เมื่อคุณเรียกมัน) list ข้างในนี้สำคัญมากตอนที่ subscriber ตัวใดตัวหนึ่งโยน exception ออกมา:
void RiskyAchievementCheck(int current, int max)
{
int ratio = 100 / current; // crashes with DivideByZeroException when current == 0
}
ถ้า AchievementTracker.Check subscribe ก่อน HealthBarUI.Refresh ในไดอะแกรมข้างบน แล้ว health ของผู้เล่นตกถึงศูนย์พอดี exception ที่โยนออกมาจากใน Check จะพุ่งออกไปตรง ๆ จาก OnHealthChanged?.Invoke(...) — และ HealthBarUI.Refresh ที่ควรจะรันต่อจากมันเลย จะไม่รันเลยแม้แต่ครั้งเดียว subscriber ที่ไม่ระวังตัวเดียวทำให้ subscriber ทุกตัวที่มาหลังจากมันพังไปเงียบ ๆ และ Health — class ที่ไม่ได้ทำอะไรผิดเลย — กลายเป็นจุดที่ crash ดูเหมือนเกิดขึ้น
// Defensive version: each subscriber runs in isolation.
public void RaiseHealthChanged(int current, int max)
{
if (OnHealthChanged == null) return;
foreach (Delegate d in OnHealthChanged.GetInvocationList())
{
try
{
((Action<int, int>)d).Invoke(current, max);
}
catch (Exception e)
{
Debug.LogException(e); // logged, but the OTHER subscribers still run
}
}
}
UnityEvent ให้ใช้ (กำหนดและจัดลำดับได้ตรง ๆ ใน Inspector ไม่ต้องเขียนโค้ด) มันช้ากว่า event ของ C# และกำหนด type ได้ไม่แน่นเท่า แต่ทำให้ designer ต่อ reaction ได้เองโดยไม่ต้องเปิดสคริปต์ ใช้ event ของ C# ระหว่างระบบที่ programmer ดูแลกันเอง แล้วใช้ UnityEvent สำหรับจุดเชื่อมต่อไม่กี่จุดที่คุณตั้งใจให้ designer ต่อจาก Editor เองOnEnable/Start แต่ลืม unsubscribe ใน OnDisable/OnDestroy event ยังคงถือ reference ไปยัง listener ที่ถูกทำลายไปแล้วอยู่ ซึ่งจะโยน exception ตอนถูกเรียก หรือไม่ก็แอบกัน garbage collector ไม่ให้ปล่อยมันทิ้งไปเงียบ ๆ ทุก += ต้องมี -= คู่กันเสมอตอนไหนควรใช้: การเปลี่ยนแปลงหนึ่งอย่างต้องกระเพื่อมไปหลายระบบที่ไม่เกี่ยวข้องกัน หรือคุณยังไม่รู้ทุกอย่างที่จะต้องมาตอบสนองทีหลัง (plugin ของ modder, feature ในอนาคต) ตอนไหนไม่ควร: dependency ที่ตายตัวและมีอยู่เสมอตัวเดียว (component ที่ต้องมี Rigidbody ของตัวเองเสมอ) ใช้ reference ตรง ๆ ง่ายกว่าและตามรอยง่ายกว่าการทำเป็น event ที่มี subscriber แค่ตัวเดียว
การเขียน input แบบตายตัวดูโอเคตอนแรก — if (Input.GetKeyDown(KeyCode.Space)) Jump(); — จนกว่าคุณจะต้องการ key remapping, undo stack, ระบบ replay, หรือ AI ที่ต้องสั่ง action เดียวกันเป๊ะกับที่ผู้เล่นสั่งได้ Command pattern แก้ทั้งสี่อย่างนี้พร้อมกันด้วยการห่อทุก action ไว้เป็น object ที่มี method Execute() แทนที่จะเรียก method เป้าหมายตรง ๆ จากโค้ดที่อ่าน input
public interface ICommand
{
void Execute(Actor actor);
}
public class MoveCommand : ICommand
{
public Vector3 Direction;
public void Execute(Actor actor) => actor.Move(Direction);
}
public class AttackCommand : ICommand
{
public void Execute(Actor actor) => actor.Attack();
}
เพราะทั้งคนเล่นและ AI แค่ต้องสร้าง ICommand แล้วส่งให้ Execute() ทั้งสองฝั่งจึงใช้โค้ดปลายทางชุดเดียวกันได้เป๊ะ ๆ:
// Player: input maps to a command object.
ICommand playerCmd = Input.GetKeyDown(KeyCode.Space) ? new AttackCommand() : null;
if (playerCmd != null) playerCmd.Execute(playerActor);
// AI: a decision system (state machine, behavior tree -- see the
// Decision-Making chapter) picks a command through the SAME interface.
ICommand aiCmd = enemyBrain.ChooseCommand();
aiCmd.Execute(enemyActor);
เพราะทั้งสองเส้นทางไหลผ่าน ICommand.Execute ตัวเดียวกันเป๊ะ ๆ bot สามารถ "เล่น" เกมได้จริง ๆ ผ่าน API เดียวกับที่คนเล่นใช้ — ไม่ต้องมีฟังก์ชันเคลื่อนไหวพิเศษสำหรับ AI ที่อาจทำงานต่างจากของผู้เล่นแบบแอบแฝง นี่ยังเป็นวิธีที่ tutorial ที่ scripted ไว้สามารถขับตัวละครผู้เล่นด้วย command ที่บันทึกไว้ และเป็นวิธีที่คุณป้อน command log ของผู้เล่นที่บันทึกไว้ล่วงหน้าให้ AI test suite เพื่อเช็คปฏิกิริยาของมันได้
using System.Collections.Generic;
using UnityEngine;
public class InputManager : MonoBehaviour
{
public Actor player;
private Dictionary<KeyCode, ICommand> bindings;
private Stack<ICommand> history = new Stack<ICommand>();
void Awake()
{
bindings = new Dictionary<KeyCode, ICommand>
{
{ KeyCode.W, new MoveCommand { Direction = Vector3.forward } },
{ KeyCode.Space, new AttackCommand() },
};
}
void Update()
{
foreach (var binding in bindings)
{
if (Input.GetKeyDown(binding.Key))
{
binding.Value.Execute(player);
history.Push(binding.Value);
}
}
if (Input.GetKeyDown(KeyCode.Z) && history.Count > 0)
{
// Undo needs each command to remember how to reverse itself --
// omitted here for brevity, shown in full in 6.7's Command section.
history.Pop();
}
}
}
ถ้า command ทุกตัวที่แมตช์หนึ่งสร้างขึ้น ถูกต่อท้ายเข้า list แทนที่จะแค่ push เข้า undo stack list นั้นคือตัว replay: เล่น command เดียวกันย้อนกลับตามลำดับเดิม กับ game state ใหม่เอี่ยม แล้ว — ตราบใดที่ simulation เองมี determinism — คุณก็จำลองแมตช์ทั้งหมดขึ้นมาใหม่ได้
Transform mover ใน MoveCommand) แทนที่จะเก็บ ID ที่คงที่ reference นั้นใช้ได้แค่ในเซสชันที่มันถูกสร้างขึ้นมาเท่านั้น — พอเล่น command log ย้อนกลับวันพรุ่งนี้ กับ object ที่โหลดขึ้นมาใหม่ คุณต้องใช้การ lookup ด้วย ID ไม่ใช่ pointer เก่า determinism ของ replay เต็มรูปแบบเป็นเรื่องใหญ่กว่า pattern เดียว (ยังต้องการ fixed timestep และไม่พึ่งการดำเนินการ floating-point ที่ลำดับไม่แน่นอนด้วย) แต่การเก็บ command ด้วย ID แทนที่จะเป็น reference คือส่วนที่เป็นของ Command pattern เองตอนไหนควรใช้: key remapping, undo/redo, replay/rewind, หรือ AI ที่ควรใช้โค้ดร่วมกับ input ของผู้เล่น ตอนไหนไม่ควร: prototype เล็ก ๆ ที่ปุ่มควบคุมตายตัว ไม่มีแผน remap ไม่มี undo หรือ replay — การห่อทุก action ไว้เป็น class เพิ่มพิธีการสำหรับ feature ที่คุณอาจไม่เคยสร้างเลย
ปืนกลที่เรียก Instantiate() ทุกครั้งที่ยิงกระสุน แล้ว Destroy() ตอนมันชนอะไรสักอย่าง สร้างขยะไหลไม่หยุดให้ garbage collector (GC) ของ C# ต้องมาเก็บกวาด — และการเก็บกวาดของ GC สามารถหยุดเกมทั้งเกมไว้หนึ่งเฟรมหรือมากกว่านั้น เรียกว่า stop-the-world pause Object Pool pattern หลีกเลี่ยงเรื่องนี้ด้วยการ allocate object เป็นชุดคงที่ครั้งเดียว แล้วนำกลับมาใช้ซ้ำแทนการทำลายแล้วสร้างใหม่
public interface IPoolable
{
void OnGet(); // called when taken out of the pool
void OnRelease(); // called when put back
}
public class Pool<T> where T : class, IPoolable, new()
{
private readonly Stack<T> free = new Stack<T>();
public T Get()
{
T item = free.Count > 0 ? free.Pop() : new T();
item.OnGet();
return item;
}
public void Release(T item)
{
item.OnRelease();
free.Push(item);
}
}
public class Bullet : IPoolable
{
public bool Active { get; private set; }
public void OnGet() { Active = true; }
public void OnRelease() { Active = false; }
}
Pool<Bullet> bulletPool = new Pool<Bullet>();
Bullet b = bulletPool.Get(); // reused if one is free, otherwise allocated
// ... bullet flies, hits something ...
bulletPool.Release(b); // returned to the pool, not destroyed
เวอร์ชันนี้ไม่ผูกกับ engine ไหนเป็นพิเศษ — Pool<T> ใช้ได้กับ class ที่ implement IPoolable ตัวไหนก็ได้ ไม่ใช่แค่ GameObject ซึ่งเป็นรูปทรงที่ลึกกว่าและใช้ซ้ำได้มากกว่า อยู่เบื้องหลังเวอร์ชัน Instantiate/SetActive แบบเฉพาะ Unity ที่ 6.7 พาเดินผ่านไปแล้ว
Get() แต่ไม่เคยเรียก Release() เลย — กระสุนที่ชนกำแพงแล้วลืมปล่อยตัวเอง เป็นต้น pool จะโตขึ้นเรื่อย ๆ ทุกครั้งที่มันหมดแล้วต้อง allocate ตัวใหม่ ซึ่งเป็นรูปทรงเดียวกันเป๊ะกับ memory leak แค่อยู่คนละชั้น ถ้าขนาด pool ของคุณไต่ขึ้นเรื่อย ๆ ตลอดเซสชันการเล่นที่ยาว ให้ตรวจทุกเส้นทางที่เรียก Get() และยืนยันว่าแต่ละเส้นทางมี Release() คู่กันGC.Alloc สำหรับโค้ด spawn กระสุนควรอ่านได้ใกล้ศูนย์หรือเป็นศูนย์ เมื่อ pooling ถูกใช้แล้ว ตัวเลขที่ไม่เป็นศูนย์และไต่ขึ้นเรื่อย ๆ ในคอลัมน์นั้น ที่ผูกกับการ spawn/destroy มักเป็นเบาะแสแรกว่ามีอะไรบางอย่างต้องการ poolingปัญหาเกมจริงที่มันแก้: กระสุน, particle, ตัวเลข damage popup, ไอเทมที่หยิบได้ — อะไรก็ตามที่ spawn และ destroy หลายครั้งต่อวินาที ตอนไหนไม่ควรใช้: object ที่สร้างครั้งเดียวต่อด่าน (บอส, ประตูล็อก) ไม่ได้อะไรจากการ pooling เลย มีแต่จะเพิ่มงานดูแล Get()/Release() คู่หนึ่งที่รันแค่ครั้งเดียวตลอดกาล
6.7 แสดงการ implement แบบ class-per-state ให้ enemy AI ไปแล้ว section นี้ไม่ได้จะสร้างสิ่งนั้นซ้ำ แต่เป็นเรื่องการตัดสินใจที่คุณต้องเจอก่อนจะเขียน state machine ตัวไหนเลย: จะเลือกใช้รูปแบบ implementation ไหนในสองแบบนี้ และจุดพลาดเรื่อง memory ที่เฉพาะกับเวอร์ชัน class-per-state ที่ 6.7 ไม่มีที่ว่างพอจะพูดถึง การตัดสินใจว่ามี state อะไรบ้างและเปลี่ยนตอนไหน — ตัวการตัดสินใจของ AI จริง ๆ — เป็นของบท Decision-Making (finite state machine, behavior tree, GOAP, utility AI) section นี้ครอบคลุมแค่วิธีสร้างกลไกที่ถือ state เอง
เมื่อ state ไม่มีข้อมูลเป็นของตัวเอง และ machine ทั้งตัวเล็ก enum ธรรมดาคู่กับ switch statement มักเป็นสิ่งเดียวที่คุณต้องการ:
public enum WeaponState { Idle, Firing, Reloading }
public class Weapon : MonoBehaviour
{
private WeaponState state = WeaponState.Idle;
private float timer;
void Update()
{
switch (state)
{
case WeaponState.Idle:
if (Input.GetButtonDown("Fire1")) { state = WeaponState.Firing; timer = 0f; }
break;
case WeaponState.Firing:
timer += Time.deltaTime;
if (timer >= 0.1f) { state = WeaponState.Reloading; timer = 0f; }
break;
case WeaponState.Reloading:
timer += Time.deltaTime;
if (timer >= 1.5f) state = WeaponState.Idle;
break;
}
}
}
ผลที่ควรได้: กดยิงจะเปลี่ยน state จาก Idle ไป Firing แล้วเปลี่ยนไป Reloading เองอัตโนมัติ แล้วกลับไป Idle หลังตัวจับเวลาครบ — ไม่มีการ allocate เกิดขึ้นเลยที่ไหนในไฟล์นี้
เวอร์ชัน class-per-state (มี class IWeaponState หนึ่งตัวต่อหนึ่ง state แต่ละตัวมี Enter/Tick ของตัวเอง) อ่านง่ายกว่าเมื่อ machine มีหลาย state หรือ state มี private field เป็นของตัวเอง เหมือนที่ 6.7 แสดงให้ enemy AI แต่ลองดูใกล้ ๆ ว่า transition มักถูกเขียนยังไง:
public void ChangeState(IWeaponState newState)
{
currentState = newState;
currentState.Enter(this);
}
// Called on every transition:
weapon.ChangeState(new ReloadingState()); // allocates a new object -- every time
สำหรับปืนกระบอกเดียว การ allocate นี้ไม่มีความหมายอะไรเลย แต่สำหรับเกมยิงบุกโจมตีที่มี enemy 200 ตัว แต่ละตัวรัน class-per-state AI ของตัวเอง เปลี่ยน state หลายครั้งต่อวินาที นี่คือโรคเดียวกับที่ Section 6 เพิ่งแก้ให้กระสุนไปหมาด ๆ — แค่คราวนี้เป็น state object ที่สร้างขยะแทนที่จะเป็นกระสุน ทางแก้สะท้อนไอเดียของ Object Pool ตรง ๆ: ถ้า state ไม่มีข้อมูลที่เปลี่ยนได้เฉพาะตัวเลย instance ตัวเดียวก็ใช้แทนได้ทุก actor ทำให้การเปลี่ยน state ไม่ต้อง allocate เลยสักครั้ง
public class ReloadingState : IWeaponState
{
// Safe to share ONLY because this class has no instance fields
// that would need to differ between two different weapons.
public static readonly ReloadingState Instance = new ReloadingState();
private ReloadingState() { }
public void Enter(Weapon w) { w.PlayAnimation("Reload"); }
public void Tick(Weapon w) { /* ... */ }
}
// elsewhere, on every transition:
weapon.ChangeState(ReloadingState.Instance); // zero allocation
timer เช่นนั้น) ปืนสองกระบอกที่เปลี่ยนเข้าไปยัง ReloadingState.Instance ตัวเดียวกันที่ถูกแชร์ไว้ ในเวลาต่างกัน จะเขียนทับ timer ของกันและกัน เพราะมี object แค่ตัวเดียวรองรับทั้งคู่ ให้แชร์ state instance เฉพาะตอนที่มันเป็น stateless จริง ๆ เท่านั้น — ถ้ามันต้องการ field ก็ต้องคง new object ใหม่ต่อการ transition ไว้ หรือไม่ก็ย้าย field นั้นไปไว้ที่ตัว actor เองแทนที่จะไว้ที่ stateปัญหาเกมจริงที่มันแก้: กองของ boolean ที่พึ่งพากันไปมา (isChasing, isAttacking, isReloading...) ที่ไม่มีใครบอกได้ว่าชุดผสมไหนถูกต้อง ถูกแทนที่ด้วย state เดียวที่มีชื่อชัดเจน ณ เวลาหนึ่ง ตอนไหนไม่ควรใช้: สอง state ที่ควบคุมด้วย toggle ตัวเดียวที่ชัดเจนอยู่แล้ว (isPaused) ไม่ต้องการทั้งสองเวอร์ชันข้างบนเลย — bool ตัวเดียวชัดเจนกว่า machine ที่มีแค่สอง state
บางระบบต้องการ instance ที่แชร์กันตัวเดียวเข้าถึงได้จากทุกที่จริง ๆ — AudioManager, GameManager ระดับบนสุด Singleton pattern ให้ class หนึ่งมี instance ตัวเดียวที่เข้าถึงได้จากทุกที่:
using UnityEngine;
public class ScoreManager : MonoBehaviour
{
public static ScoreManager Instance { get; private set; }
public int Score { get; private set; }
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
public void AddScore(int amount) => Score += amount;
}
// Called from anywhere, forty different times across the codebase:
ScoreManager.Instance.AddScore(10);
ทุก tutorial หยิบสิ่งนี้มาใช้เพราะมันสะดวกจริง ๆ: ไม่ต้อง setup ไม่ต้อง wiring ScoreManager.Instance ใช้ได้จากสคริปต์ไหนก็ได้ทันทีที่ compile ผ่าน ความสะดวกนั้นแหละคือสิ่งที่ทำให้มันเจ็บทีหลัง
เกมข้างบนออกมาสำเร็จ พร้อมคะแนนที่แชร์กันตัวเดียว หกเดือนต่อมา โปรดิวเซอร์ขอให้ทำ local two-player split-screen ที่ผู้เล่นแต่ละคนต้องมีคะแนนของตัวเองแยกกัน
การแก้เรื่องนี้แปลว่าต้องแตะ call site ทั้งสี่สิบจุดนั้น เพราะตัว type ScoreManager เอง ไม่ใช่การใช้งานจุดใดจุดหนึ่ง เป็นตัวที่สมมติไว้ว่ามีได้แค่ตัวเดียวเท่านั้น ทางแก้คือดีไซน์แบบที่คุณควรเขียนไว้ตั้งแต่วันแรก ถ้า split-screen เป็น requirement ที่รู้ล่วงหน้าอยู่แล้ว: tracker ธรรมดาที่ไม่ใช่ static ที่ผู้เล่นแต่ละคนมีสำเนาของตัวเอง
public class ScoreTracker // no static Instance -- just a plain object
{
public int Score { get; private set; }
public void AddScore(int amount) => Score += amount;
}
public class PlayerController : MonoBehaviour
{
public ScoreTracker scoreTracker = new ScoreTracker(); // this player's own copy
}
นอกจากความเจ็บปวดจุดนี้ singleton ยังทำให้เกิดปัญหาเดียวกันสามอย่างในทุกโปรเจกต์ที่โตพอ: hidden dependency (ไม่มีอะไรในลายเซ็นของ class PlayerController บอกเลยว่ามันต้องการ ScoreManager — คุณจะรู้ก็ต่อเมื่ออ่านทุกบรรทัด), initialization-order bug (เรียก Instance ก่อนที่ Awake() ของมันเองจะรัน ก็ได้ null มา), และโค้ดที่เทสไม่ได้ (คุณสลับ ScoreManager ปลอมเข้าไปสำหรับ unit test ไม่ได้ เมื่อโค้ดเอื้อมไปหา global ตัวตายตัวแทนตรง ๆ)
Service Locator แทนที่ static Instance field แยกกันหลายตัว ด้วย registry กลางตัวเดียวที่คอยแจก reference เมื่อมีการร้องขอ:
using System;
using System.Collections.Generic;
public static class ServiceLocator
{
private static readonly Dictionary<Type, object> services = new Dictionary<Type, object>();
public static void Register<T>(T service) => services[typeof(T)] = service;
public static T Get<T>() => (T)services[typeof(T)];
}
// Setup, once, at game start:
ServiceLocator.Register<IAudioService>(new AudioManager());
// Anywhere in the game:
ServiceLocator.Get<IAudioService>().Play(explosionClip);
เพราะ Register รับ interface test จึง register IAudioService ปลอมที่ไม่ทำอะไรเลยแทนตัวจริงได้ — ตรงนี้แก้ปัญหาโค้ดเทสไม่ได้ แต่มันยังไม่แก้อีกสองปัญหาเลย: ServiceLocator.Get<IAudioService>() ยังเป็น hidden dependency อยู่ (ไม่มีอะไรในลายเซ็นของ class บอกว่ามันเรียกสิ่งนี้) และมันยังเป็น global mutable state ที่อะไรก็ตามในโปรแกรมพึ่งพาได้เงียบ ๆ
Dependency Injection (DI) เลือกเส้นทางที่ตรงกว่า: แทนที่ class จะเอื้อมไปหาสิ่งที่ต้องการเอง อะไรก็ตามที่สร้าง class นั้นขึ้นมา จะส่ง dependency เข้าไปให้อย่างชัดเจน มักผ่าน constructor, method Init, หรือ field ที่กำหนดผ่าน Inspector
public class Explosion : MonoBehaviour
{
private IAudioService audio;
// The dependency is now part of THIS CLASS'S OWN public shape --
// you cannot construct a working Explosion without deciding
// where its audio comes from. It cannot be hidden anymore.
public void Init(IAudioService audioService)
{
audio = audioService;
}
void OnHit()
{
audio.Play(explosionClip);
}
}
// Setup:
Explosion e = Instantiate(explosionPrefab).GetComponent<Explosion>();
e.Init(realAudioService); // or a fake IAudioService, inside a test
ข้อแลกเปลี่ยนเห็นชัดตรงนั้นเลย: DI ชัดเจนและเทสได้ง่ายมาก แต่ object ทุกตัวที่ต้องการ service ต้องถูก wire ด้วยมือที่ไหนสักที่ ซึ่งเป็นโค้ดเพิ่มจริง ๆ สตูดิโอใหญ่ ๆ มักเอา DI framework มาใช้ (Zenject/Extenject เป็นตัวที่นิยมสำหรับ Unity) เพื่อทำ wiring นั้นให้อัตโนมัติในสเกลใหญ่ — ควรรู้ไว้ว่ามันมีอยู่ ไม่จำเป็นต้องหยิบมาใช้ในโปรเจกต์เล็ก ที่เวอร์ชัน Init() แบบมือข้างบนก็เพียงพอแล้ว
ตอนไหนควรใช้ Singleton: ระบบระดับทั้งเกมที่มีแค่ตัวเดียวจริง ๆ (game manager ระดับบนสุด, audio device wrapper) ใช้อย่างประหยัด ในโปรเจกต์เล็กที่ split-screen หรือ unit test ไม่ได้อยู่ในแผน ตอนไหนไม่ควร: เป็นคำตอบเริ่มต้นสำหรับ "สคริปต์สองตัวจะคุยกันยังไง" — ใช้ reference ตรง ๆ ผ่าน Inspector หรือ event (Section 4) ก่อน ค่อยใช้ Service Locator หรือ DI เมื่อคุณต้องการระบบที่สลับได้หรือเทสได้จริง ๆ
ลองนึกภาพโลกแบบ tile ที่มี tile instance 200,000 ตัว — หญ้า, น้ำ, หิน, ลาวา tile type แต่ละแบบต้องการ field ชุดเดียวกัน: texture, movement cost, เดินผ่านได้ไหม การเก็บข้อมูลทั้งหมดนี้แยกกันบน tile instance ทั้ง 200,000 ตัว จะซ้ำค่าห้าค่าเดียวกันเป๊ะ ๆ ในทุก tile หญ้า ทุก tile น้ำ ไปเรื่อย ๆ Flyweight pattern แก้เรื่องนี้ด้วยการแยกข้อมูลของ object ออกเป็นสองแบบ: intrinsic state (เหมือนกันทุก instance ของ type นั้น จึงแชร์ได้อย่างปลอดภัย) กับ extrinsic state (เฉพาะตัวของแต่ละ instance จึงต้องแยกเก็บไว้ต่างหาก)
using System.Collections.Generic;
using UnityEngine;
// Intrinsic state: identical for every "Grass" tile in the world,
// every "Water" tile, and so on -- so store it exactly once per type.
public class TileType
{
public readonly string Name;
public readonly Texture2D Texture;
public readonly float MovementCost;
public readonly bool IsWalkable;
public TileType(string name, Texture2D texture, float cost, bool walkable)
{
Name = name; Texture = texture; MovementCost = cost; IsWalkable = walkable;
}
}
// Factory guarantees only ONE TileType object exists per distinct type.
public static class TileTypeFactory
{
private static readonly Dictionary<string, TileType> cache = new Dictionary<string, TileType>();
public static TileType Get(string name, Texture2D texture, float cost, bool walkable)
{
if (!cache.TryGetValue(name, out TileType type))
{
type = new TileType(name, texture, cost, walkable);
cache[name] = type;
}
return type;
}
}
// Extrinsic state: unique per tile instance -- a small struct, not a class.
public struct Tile
{
public TileType Type; // shared reference, not a copy of the fields
public int GridX, GridY;
}
ผลที่ควรได้: แทนที่จะเป็น 200,000 สำเนาของห้า field เกมจะถือ TileType object ทั้งหมดสักสามสิบตัว (ไม่กี่กิโลไบต์) บวกกับ Tile struct เล็ก ๆ 200,000 ตัว แต่ละตัวถือแค่ reference กับ integer สองตัว
tile.Type.MovementCost = 0.5f เพื่อให้ tile หญ้าโคลนตัวใดตัวหนึ่งช้าลง เพราะ tile หญ้าทุกตัวชี้ไปที่ TileType object ตัวเดียวกัน บรรทัดนั้นจะทำให้ tile หญ้าทุกตัวในโลกทั้งใบช้าลงเงียบ ๆ ไม่ใช่แค่ตัวที่คุณตั้งใจ อะไรก็ตามที่ต้องเปลี่ยนแปลงเฉพาะตัวจริง ๆ ควรอยู่ใน extrinsic struct (เพิ่ม field เข้าไปใน Tile เอง) ไม่ใช่ไปแก้ที่ flyweight ที่แชร์กันอยู่ปัญหาเกมจริงที่มันแก้: instance เป็นพันตัวที่ข้อมูลส่วนใหญ่เหมือนกัน — tile type, particle definition, item definition ที่ถูกอ้างถึงโดยสำเนาที่หล่นอยู่ในโลกหลายชุด, foliage instance ตอนไหนไม่ควรใช้: instance แค่ไม่กี่สิบตัว ไม่ประหยัดอะไรมีนัยสำคัญจากการแชร์เลย แถม factory lookup กับ indirection ที่เพิ่มเข้ามาก็แค่เพิ่มความซับซ้อนสำหรับ memory ที่คุณคงไม่มีทางสังเกตเห็นความต่าง
การคำนวณบางอย่างมีต้นทุนสูงและถูกอ่านตลอดเวลา แต่ข้อมูลเบื้องหลังมันแทบไม่เปลี่ยนเลย inventory UI ที่สร้าง layout ไอคอนทั้งหมดใหม่ทุกเฟรมเดี่ยว ๆ กำลังทำแบบนี้พอดี — layout จริง ๆ แล้วต้องเปลี่ยนแค่ในเฟรมที่หายากที่มีอะไรถูกหยิบหรือทิ้งไป
// BAD: rebuilds the whole layout 60 times a second, even on the
// 599 out of 600 frames where the inventory did not change at all.
public class InventoryUI : MonoBehaviour
{
public Inventory inventory;
void Update()
{
RebuildLayout(); // expensive: repositions/instantiates every icon
}
void RebuildLayout() { /* ... expensive work ... */ }
}
Dirty Flag pattern แก้เรื่องนี้ด้วย bool ตัวเดียว: คำนวณใหม่ก็ต่อเมื่อมีอะไรมาร์กผลลัพธ์ที่ cache ไว้ว่าล้าสมัยแล้วจริง ๆ แล้วเคลียร์ flag หลังคำนวณใหม่เสร็จ มันเข้าคู่กับ Observer pattern จาก Section 4 ได้พอดี — event ที่เปลี่ยนข้อมูลนั่นแหละคือสิ่งที่ควรตั้ง flag
public class InventoryUI : MonoBehaviour
{
public Inventory inventory; // assume it exposes: public event Action OnChanged;
private bool isDirty = true; // start dirty so it builds once on the first frame
void OnEnable() { inventory.OnChanged += MarkDirty; }
void OnDisable() { inventory.OnChanged -= MarkDirty; }
void MarkDirty() => isDirty = true;
void Update()
{
if (!isDirty) return; // nothing changed since the last build -- skip everything
RebuildLayout();
isDirty = false;
}
void RebuildLayout() { /* ... expensive work ... */ }
}
ผลที่ควรได้: ตลอด 600 เฟรม (10 วินาทีที่ 60 fps) ที่มีการหยิบไอเทมพอดี 3 ครั้ง RebuildLayout จะรันแค่ 3 ครั้งแทนที่จะเป็น 600 ครั้ง — ลดการเรียก method ที่ต้นทุนสูงลง 200 เท่า โดยผลลัพธ์บนหน้าจอเหมือนเดิมทุกเฟรมกับเวอร์ชันไร้เดียงสา
ไอเดียแบบนี้เป๊ะ ๆ โผล่ในชื่อต่างกันไปทั่ว game engine: flag transform.hasChanged ของ Unity เอง, "dirty rectangle" ใน 2D renderer เก่า ๆ ที่ redraw แค่บริเวณจอที่เปลี่ยนไป และ world matrix ที่ cache ไว้แล้วคำนวณใหม่แบบ lazy ใน scene graph
ปัญหาเกมจริงที่มันแก้: การคำนวณต้นทุนสูงที่ถูกอ่านบ่อยกว่าข้อมูลเบื้องหลังมันเปลี่ยนมาก ๆ — UI layout, world transform ที่ cache ไว้, minimap, grid สำหรับ pathfinding ที่ต้อง rebuild แค่ตอน level geometry เปลี่ยน ตอนไหนไม่ควรใช้: ถ้าข้อมูลเบื้องหลังเปลี่ยนแทบทุกเฟรมอยู่แล้ว (ตำแหน่งของ object ที่กำลังเคลื่อนที่) flag จะแค่เพิ่มงานดูแลรอบ ๆ การคำนวณที่คุณจะทำทุกเฟรมอยู่แล้ว — คำนวณตรง ๆ ไปเลยดีกว่า
ทุก pattern ในบทนี้เป็นทางแก้สำหรับความเจ็บปวดที่ชี้ชื่อได้ นิสัยแบบมืออาชีพไม่ใช่การจำ pattern ทั้งสิบตัวแล้วหยิบมาใช้ตั้งแต่วันแรก แต่คือการเขียนเวอร์ชันตรง ๆ ง่าย ๆ ก่อน แล้วค่อยหยิบ pattern ตัวใดตัวหนึ่งมาใช้เมื่อความเจ็บปวดเฉพาะของมันโผล่ขึ้นมาจริง ๆ ในโปรเจกต์ของคุณ นี่คือความเจ็บปวดนั้น จับคู่กับ pattern ที่ตอบมัน:
GC.Collect ผูกกับ spawn/destroy ที่ฉันเรียกตลอด" — Object Pool (Section 6)สังเกตรูปแบบของทุกบรรทัด: มันชี้ชื่ออาการที่คุณสังเกตเห็นได้จริงในโปรเจกต์ของคุณตอนนี้เลย — trace จาก profiler, บรรทัดโค้ดที่ซ้ำ, feature request ที่เจาะจง — ไม่ใช่การเดาว่าอะไรอาจมีประโยชน์สักวัน นั่นคือบททดสอบทั้งหมด YAGNI ("You Aren't Gonna Need It") คือเวอร์ชันสั้นของกฎนี้: เขียน field, การเรียกตรง ๆ, if ตัวเดียว ก่อนเสมอ pattern คือสิ่งที่คุณ refactor เข้าไปหา เมื่อเวอร์ชันตรง ๆ เริ่มเจ็บจริงเห็นได้ชัด ไม่ใช่สิ่งที่คุณเริ่มด้วยเพราะมันอาจต้องใช้สักวัน ต้นทุนของการข้าม pattern ที่คุณยังไม่ต้องการคือศูนย์เป๊ะ ๆ ต้นทุนของการเพิ่ม pattern เร็วเกินไปคือของจริง: ไฟล์เพิ่ม, indirection เพิ่ม, และเพื่อนร่วมทีมมือใหม่ที่ตอนนี้ต้องเปิดสี่ไฟล์เพื่อหาบรรทัดพฤติกรรมบรรทัดเดียวที่สำคัญจริง ๆ
Tick/Update) ออกมา แล้วมีลูปกลางตัวเดียวเรียกมันบน object ที่ลงทะเบียนไว้ทุกตัวในทุกเฟรมdeltaTime ของเฟรมนั้น ๆevent ของ C#: ตัวแปรเดียวที่ข้างในถือ list ของ method ที่ subscribe ไว้ตามลำดับ เรียกทุกตัวเมื่อ event ทำงานExecute() ทำให้ remap, queue, undo, replay, และให้ AI ใช้ action เดียวกันซ้ำได้Get() แต่ไม่เคยถูกคืนด้วย Release() ทำให้ pool โตขึ้นเรื่อย ๆInstance field แยกกันหลายตัวInit, หรือ field ที่ตั้งผ่าน Inspector) แทนที่จะให้ object เอื้อมไปหามันเองScoreManager ให้เลิกใช้ static Instance และเขียน Coin ใหม่ให้ให้เครดิตกับผู้เล่นที่แตะมันจริง ๆ
public class ScoreManager : MonoBehaviour
{
public static ScoreManager Instance { get; private set; }
public int Score { get; private set; }
void Awake() { Instance = this; }
public void AddScore(int amount) => Score += amount;
}
public class Coin : MonoBehaviour
{
void OnTriggerEnter(Collider other)
{
ScoreManager.Instance.AddScore(10);
Destroy(gameObject);
}
}
public class ScoreTracker // plain object -- no static Instance anywhere
{
public int Score { get; private set; }
public void AddScore(int amount) => Score += amount;
}
public class PlayerController : MonoBehaviour
{
public ScoreTracker scoreTracker = new ScoreTracker(); // this player's own copy
}
public class Coin : MonoBehaviour
{
void OnTriggerEnter(Collider other)
{
// Coin no longer assumes there is exactly one score in the game --
// it credits whichever player's controller actually touched it.
PlayerController player = other.GetComponent<PlayerController>();
if (player != null)
{
player.scoreTracker.AddScore(10);
}
Destroy(gameObject);
}
}
PlayerController สองตัวในฉาก split-screen ตอนนี้ต่างมี ScoreTracker ของตัวเองแยกกัน Coin อ่านค่าจากผู้เล่นที่มันชนด้วยจริง ๆ แทนที่จะเป็น instance กลางตัวเดียว นี่คือ refactor แบบเดียวกับที่ singleton ควรจะช่วยคุณให้ไม่ต้องทำ แต่กลับถูกบังคับให้ทำอยู่ดี ทำทีเดียวพร้อมกันทั้งหมด ในทุก call site — ความเจ็บปวดที่อธิบายไว้ใน Section 8.1
ItemType ที่ถือข้อมูลที่แชร์กัน (intrinsic) ไว้ factory ที่แจก instance ที่แชร์กันตัวเดียวต่อชื่อไอเทมหนึ่งชื่อ และ DroppedItem ที่เบาลงเหลือแค่ reference กับตำแหน่งของตัวเอง
public class DroppedItem : MonoBehaviour
{
public string itemName;
public Sprite icon;
public int value;
public string description;
}
using System.Collections.Generic;
using UnityEngine;
public class ItemType // intrinsic state -- identical for every potion, every sword, etc.
{
public readonly string Name;
public readonly Sprite Icon;
public readonly int Value;
public readonly string Description;
public ItemType(string name, Sprite icon, int value, string description)
{
Name = name; Icon = icon; Value = value; Description = description;
}
}
public static class ItemTypeFactory
{
private static readonly Dictionary<string, ItemType> cache = new Dictionary<string, ItemType>();
public static ItemType Get(string name, Sprite icon, int value, string description)
{
if (!cache.TryGetValue(name, out ItemType type))
{
type = new ItemType(name, icon, value, description);
cache[name] = type;
}
return type;
}
}
public class DroppedItem : MonoBehaviour
{
public ItemType type; // shared reference -- extrinsic wrapper
public Vector3 dropPosition; // unique per instance
}
Health potion ที่หล่นอยู่หนึ่งร้อยชิ้นในโลกตอนนี้แชร์ ItemType "Health Potion" object ตัวเดียวกันเป๊ะ ที่สร้างผ่าน ItemTypeFactory DroppedItem แต่ละตัวเก็บแค่ reference ไปยัง object ที่แชร์กันนั้น บวกกับตำแหน่งของตัวเอง แทนที่จะ duplicate icon, value, และ description แยกกันหนึ่งร้อยครั้ง
Update ทั้ง ๆ ที่ตำแหน่ง enemy ที่มันแสดงเปลี่ยนแค่ตอน enemy spawn หรือตายเท่านั้น สมมติว่า EnemySpawner เปิด event ของ C# ออกมา public event Action OnEnemyCountChanged ที่ยิงตอนนั้นพอดี เขียน Minimap ใหม่ให้ redraw แค่ตอนที่จำเป็นเท่านั้น
public class Minimap : MonoBehaviour
{
void Update()
{
RedrawIcons(); // expensive: repositions every icon on the map texture
}
void RedrawIcons() { /* ... expensive work ... */ }
}
public class Minimap : MonoBehaviour
{
public EnemySpawner spawner;
private bool isDirty = true; // start dirty so it draws once on the first frame
void OnEnable() { spawner.OnEnemyCountChanged += MarkDirty; }
void OnDisable() { spawner.OnEnemyCountChanged -= MarkDirty; }
void MarkDirty() => isDirty = true;
void Update()
{
if (!isDirty) return;
RedrawIcons();
isDirty = false;
}
void RedrawIcons() { /* ... expensive work ... */ }
}
RedrawIcons ตอนนี้รันแค่ในเฟรมที่ต่อจาก OnEnemyCountChanged ยิงพอดี แทนที่จะรันหกสิบครั้งต่อวินาทีไม่ว่าอะไรจะเปลี่ยนหรือไม่ นี่รวมสอง pattern จากบทนี้เข้าด้วยกันตรง ๆ: Observer (event ที่พลิก flag) ส่งสัญญาณที่ Dirty Flag (flag ที่กันงานต้นทุนสูงไว้) ต้องการพอดีเพื่อรู้ว่าควรรันตอนไหน