12.2 HUD, เมนู และ Inventory UI

เฟส 12 · UI / UX Programming · เวลาเรียน: 25–45 h

หน้าจอที่เกมกาชาและ RPG อยู่ด้วย — HUD, เมนูซับซ้อน, grid และ inventory UI ที่ยังเร็วแม้มีไอเทมเป็นร้อย

บทที่แล้ว (UI Systems) พูดถึงเรื่องพื้นฐานไปแล้ว คือ Canvas, RectTransform, anchor และการต่อ OnClick ของ Button เข้ากับ method แต่พื้นฐานพวกนั้นไม่ใช่ส่วนที่ยากของ UI ในเกมจริง ส่วนที่ยากคือการทำให้ชิ้นส่วนที่ขยับได้หลายสิบชิ้น — health bar, กองเมนูที่ซ้อนกัน, inventory ที่มีไอเทมเป็นร้อยชิ้น — ซิงค์กับข้อมูลที่เปลี่ยนตลอดเวลา โดยไม่ให้โค้ด UI กลายเป็นโค้ดอัปเดตมือที่กระจัดกระจายไปทั่ว บทนี้จะพูดถึง UI system สามอย่างที่แทบทุกเกมต้องมี คือ HUD (heads-up display — ส่วนแสดงผลที่อยู่บนจอตลอดเวลา แสดง health, mana, ammo และอื่นๆ), menu system ที่กดเข้าไปแล้วย้อนกลับออกมาได้ และหน้าจอ inventory ที่เก็บของได้ตั้งแต่สิบชิ้นไปจนถึงหลายร้อยชิ้น ทั้งสามอย่างนี้แก้ด้วยไอเดียเดียวกัน คือ UI ควรเป็น reflection (ภาพสะท้อน) ของข้อมูล อัปเดตเมื่อข้อมูลเปลี่ยน ไม่ใช่สร้างใหม่ทั้งหมดทุกเฟรม

1. ไอเดียเดียวที่ต้องรู้: UI สะท้อนข้อมูล

ลองนึกภาพวิธีที่ขี้เกียจที่สุดในการทำให้ health bar ถูกต้องอยู่เสมอ คือเช็ค health ของผู้เล่นทุกเฟรม แล้ววาด bar ใหม่


// WORKS, but is the wrong habit to build.
void Update()
{
    healthBarImage.fillAmount = (float)player.CurrentHealth / player.MaxHealth;
}

ถ้ามี bar เดียว เรื่องนี้ไม่สำคัญเลยจริงๆ — CPU สมัยนี้ทำการหารและ assign แบบนี้ฟรีๆ 60 ครั้งต่อวินาทีสบายๆ ปัญหาจะเริ่มโผล่มาตอนที่นิสัยนี้ลามไปทั่ว เกมที่มี health bar, mana bar, ammo counter, minimap, quest tracker และ inventory grid ถ้าแต่ละอันทำ polling (เช็คซ้ำๆ) แหล่งข้อมูลของตัวเองทุกเฟรม รวมกันแล้วก็เยอะ แย่กว่านั้นคือ polling จะแย่ลงเรื่อยๆ ทันทีที่ข้อมูลไม่ใช่แค่ตัวเลขเดียว เช่น inventory grid ที่อ่านและวาดใหม่ทั้ง 40 slot ทุกเฟรม เผื่อว่ามีอันไหนเปลี่ยน ก็เสียแรงไปกับ 39 slot ที่ไม่ได้เปลี่ยนเลย

วิธีแก้ไม่ใช่การ optimize อะไรฉลาดๆ แต่เป็นนิสัยที่ต่างออกไป คือ ข้อมูลประกาศออกมาเองเมื่อมันเปลี่ยน แล้ว UI ก็แค่ฟัง นี่คือ Observer pattern (คำศัพท์จากบทก่อนหน้า: publisher ประกาศว่ามีอะไรเกิดขึ้น แล้ว subscriber กี่ตัวก็ได้ก็ตอบสนอง โดย publisher ไม่ต้องรู้ด้วยซ้ำว่าใครฟังอยู่บ้าง) ที่เอามาใช้กับ UI โดยเฉพาะ ในบทนี้ "ข้อมูล" คือของอย่าง health ปัจจุบันของผู้เล่น, เนื้อหาใน inventory slot หนึ่งช่อง หรือว่าเมนูหน้าไหนอยู่บนสุด ส่วน "UI" คือของอย่าง fill bar, grid ของไอคอนไอเทม หรือ panel ที่มองเห็นอยู่ กฎของทั้งบทนี้คือ

เคล็ดลับ UI element ควรอัปเดตเฉพาะตอนที่สิ่งที่มันแสดงเปลี่ยนจริงๆ เท่านั้น โดยถูก trigger ด้วย event — ไม่ใช่เช็คทุกเฟรมว่า "มีอะไรเปลี่ยนไหม" เผื่อไว้ก่อน
Polling (what to avoid): Update() -- every frame, forever --> "did health change?" --> redraw Event-driven (what this chapter teaches): TakeDamage() -- only when it happens --> OnHealthChanged event --> redraw The event-driven version does zero work on the 59 frames out of 60 where nothing changed.

ที่เหลือของบทนี้จะสร้าง HUD, menu system และ inventory UI โดยใช้ไอเดียเดียวกันนี้ทั้งหมด บวกกับอีกหนึ่งไอเดียสำหรับตอนที่ข้อมูลมีเยอะมาก คือ ไม่ต้องสร้าง UI object สำหรับข้อมูลทุกชิ้น สร้างเฉพาะชิ้นที่มองเห็นอยู่ตอนนั้นพอ ไอเดียที่สองนี้เรียกว่า UI virtualization จะพูดถึงใน Section 9

2. พื้นฐาน HUD: Health Bar ที่ขับเคลื่อนด้วย Event

เริ่มจากข้อมูลก่อน component PlayerStats เป็นเจ้าของตัวเลข health จริงๆ และจะ raise event ทุกครั้งที่มันเปลี่ยน — โดยที่มันไม่รู้และไม่สนใจด้วยซ้ำว่ามี bar, ตัวเลข หรือ effect หน้าจอกระพริบตัวไหนกำลังฟังอยู่บ้าง


using UnityEngine;

public class PlayerStats : MonoBehaviour
{
    // (current, max) -- both values travel together so a listener
    // never has to ask "wait, max compared to what?"
    public event System.Action<int, int> OnHealthChanged;

    [SerializeField] private int maxHealth = 100;
    private int currentHealth;

    public int CurrentHealth => currentHealth;
    public int MaxHealth => maxHealth;

    void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        currentHealth = Mathf.Clamp(currentHealth - amount, 0, maxHealth);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }

    public void Heal(int amount)
    {
        currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }
}

ทีนี้มาที่ตัว bar เอง component Image ของ Unity มี Image Type ชื่อ Filled (ใน Inspector) แทนที่จะวาด sprite ทั้งรูป มันจะวาดแค่บางส่วนตามตัวเลข 0 ถึง 1 ที่เรียกว่า fillAmount ตั้ง Image Type เป็น Filled และ Fill Method เป็น Horizontal ใน Inspector แล้วค่อยควบคุมตัวเลขนี้จากโค้ด


using UnityEngine;
using UnityEngine.UI;

public class HealthBarUI : MonoBehaviour
{
    [SerializeField] private PlayerStats stats;
    [SerializeField] private Image fillImage; // Image Type = Filled

    void OnEnable()
    {
        stats.OnHealthChanged += UpdateBar;

        // Sync immediately -- otherwise the bar shows empty (its default
        // Inspector value) until the very first TakeDamage() call.
        UpdateBar(stats.CurrentHealth, stats.MaxHealth);
    }

    void OnDisable()
    {
        stats.OnHealthChanged -= UpdateBar;
    }

    void UpdateBar(int current, int max)
    {
        fillImage.fillAmount = (float)current / max;
    }
}

ผลลัพธ์ที่คาดไว้: เรียก stats.TakeDamage(30) กับผู้เล่นที่มี hp เต็ม 100 ข้างใน TakeDamage, currentHealth จะกลายเป็น 70 แล้ว OnHealthChanged?.Invoke(70, 100) จะถูกยิงออกมา HealthBarUI.UpdateBar รันหนึ่งครั้ง ตั้งค่า fillImage.fillAmount = 0.7f แล้ว bar ก็แสดงผลเต็ม 70% ไม่มีโค้ดอื่นทำงานเลย — ไม่มีการเช็คทิ้งเปล่าๆ 59 ครั้งในเฟรมที่ไม่มีอะไรเกิดขึ้น มีแค่การอัปเดตหนึ่งครั้งในเฟรมเดียวที่มีอะไรเกิดขึ้นจริง

PlayerStats.TakeDamage(30) | v currentHealth = 70 (data changes first) | v OnHealthChanged?.Invoke(70, 100) (data announces the change) | v HealthBarUI.UpdateBar(70, 100) (UI reacts, sets fillAmount = 0.7) PlayerStats never mentions HealthBarUI by name. It would fire the exact same event with zero listeners subscribed, and nothing would break.
ข้อผิดพลาดที่พบบ่อย subscribe ใน OnEnable แต่ลืมเรียก sync ทันทีหลังจากนั้น bar จะแสดงค่าอะไรก็ตามที่มันมีตอนอยู่ใน editor (มักจะเป็น 0 หรือ 1) จนกว่าจะมีการเปลี่ยน health จริงครั้งถัดไป ซึ่งอาจจะกินเวลาไปหลายวินาทีตั้งแต่เข้าด่านมา — นานพอที่ผู้เล่นจะเห็น bar ผิดหรือว่างเปล่าทั้งๆ ที่ตัวละครมี health เต็ม

3. Stat Bar Component ที่ใช้ซ้ำได้ (Health และ Mana)

mana bar ต้องการพฤติกรรมเหมือน health bar เป๊ะๆ คือ ให้ current กับ max มา แล้วแสดงเป็นสัดส่วนที่เติมเต็ม ถ้าเขียน ManaBarUI script อีกตัวที่แทบเหมือนกันทุกอย่าง ก็จะเป็นการก็อปโค้ด logic ซ้ำ แทนที่จะทำแบบนั้น ให้ทำ script ของ bar ให้ทั่วไป (generic) เกี่ยวกับสิ่งที่มันแสดง แล้วให้อย่างอื่นเป็นคนต่อมันเข้ากับ data source ที่ถูกต้อง


using UnityEngine;
using UnityEngine.UI;

// Knows nothing about health or mana specifically -- just
// "show this fraction filled." Works with Unity's Slider too.
public class StatBarUI : MonoBehaviour
{
    [SerializeField] private Slider slider; // has built-in min/max/value

    public void Bind(int current, int max)
    {
        slider.minValue = 0;
        slider.maxValue = max;
        slider.value = current;
    }
}

HudController เล็กๆ ตัวหนึ่งเป็นเจ้าของการต่อสาย (wiring) มันรู้ว่า bar นี้แสดง health และ bar นั้นแสดง mana แต่ตัว bar เองไม่รู้เรื่องนี้เกี่ยวกับตัวมันเองเลย


using UnityEngine;

public class HudController : MonoBehaviour
{
    [SerializeField] private PlayerStats stats;
    [SerializeField] private ManaSystem mana;      // same shape as PlayerStats
    [SerializeField] private StatBarUI healthBar;
    [SerializeField] private StatBarUI manaBar;

    void OnEnable()
    {
        stats.OnHealthChanged += healthBar.Bind;
        mana.OnManaChanged    += manaBar.Bind;

        healthBar.Bind(stats.CurrentHealth, stats.MaxHealth);
        manaBar.Bind(mana.CurrentMana, mana.MaxMana);
    }

    void OnDisable()
    {
        stats.OnHealthChanged -= healthBar.Bind;
        mana.OnManaChanged    -= manaBar.Bind;
    }
}

ผลลัพธ์ที่คาดไว้: การเพิ่ม bar ที่สาม (stamina, มิเตอร์ชาร์จ ultimate, health bar ของบอส) ไม่จำเป็นต้องเขียน bar script ใหม่เลย แค่ลาก StatBarUI อีกตัวลงบน Canvas แล้วเพิ่มโค้ดสองบรรทัดใน HudController การแสดงผล (bar ที่เติมเต็ม) แยกขาดจาก data source (health, mana หรืออะไรก็ตามที่มีรูปแบบเป็น "ตัวเลข current จาก max") อย่างสมบูรณ์

ควรใช้เมื่อไหร่: ทุกครั้งที่ UI element ตั้งแต่สองตัวขึ้นไปจะต้อง copy-paste กันโดยมีแค่ data source ที่ต่างกัน ไม่ควรใช้เมื่อไหร่: HUD element แบบเฉพาะทางที่มี display logic ไม่เหมือนใครจริงๆ (เข็มทิศ, popup ตัวเลขความเสียหาย) จะไม่ได้ประโยชน์อะไรจากการยัดมันให้เข้ารูป "bar" ที่ใช้ร่วมกัน

4. Menu Stack: มองหน้าจอเป็น State Machine แบบ Push/Pop

หน้าจอต่างๆ ในเกมแทบไม่เคยเรียงเป็นเส้นตรง จาก gameplay เปิด pause menu ได้, จาก pause menu เปิด settings ได้, จาก settings ก็ย้อนกลับไป pause menu ได้ แล้วจากตรงนั้นก็ย้อนกลับไป gameplay ได้อีก — และการกดปุ่ม "Back" ปุ่มเดียว (หรือ Escape หรือปุ่ม B ของ gamepad) ควรทำสิ่งที่ถูกต้องในจุดนั้นเสมอ ไม่ว่าตอนนี้จะลึกไปกี่ชั้นแล้วก็ตาม

data structure ที่เหมาะกับเรื่องนี้คือ stack (คำศัพท์จากบท data structures ก่อนหน้า: list แบบ last-in-first-out — เพิ่มหรือลบได้เฉพาะที่ด้านบนสุดเท่านั้น) การเปิดหน้าจอใหม่คือการ push มันเข้า stack ส่วนปุ่ม "Back" จะ pop ตัวบนสุดของ stack ออกเสมอ เผยให้เห็นสิ่งที่อยู่ข้างใต้ วิธีนี้ทำให้ระบบเมนูทั้งหมดกลายเป็น UI state machine (ระบบที่อยู่ใน visible configuration เดียวเป๊ะๆ ตลอดเวลา แล้วเปลี่ยนไปมาระหว่าง configuration ด้วยกฎที่ชัดเจน) โดยที่ "state ปัจจุบัน" ก็คือ "อะไรก็ตามที่อยู่บนสุดของ stack" นั่นเอง

Stack (drawn top to bottom, top = active + visible): Push(SettingsMenu) Push(PauseMenu) start -------------------- -------------------- ---------------- [ SettingsMenu ] [ PauseMenu ] [ HUD (base) ] [ PauseMenu ] [ HUD (base) ] [ ] [ HUD (base) ] [ ] [ ] Pop() always removes the top and reveals what's underneath it -- the same Pop() works whether you are 1 screen deep or 5.

การเปลี่ยนระหว่างหน้าจอแต่ละแบบสามารถวาดเป็น state diagram ได้ ในสไตล์เดียวกับที่ใช้กับ enemy AI ในบทก่อนหน้า

HUD --(Esc pressed)--------> PauseMenu [push] PauseMenu --(Resume button)------> HUD [pop] PauseMenu --(Settings button)----> SettingsMenu [push] SettingsMenu --(Back button)--------> PauseMenu [pop] PauseMenu --(Quit button)--------> ConfirmQuit [push] ConfirmQuit --(Yes)----------------> (Application.Quit) ConfirmQuit --(No / Esc)-----------> PauseMenu [pop]

สังเกตว่า "Back" ไม่ใช่ action ที่ต่างกันในแต่ละหน้าจอ — มันคือการเรียก Pop() ตัวเดียวกันเสมอ นี่คือประเด็นทั้งหมดของการโมเดลเมนูเป็น stack แทนที่จะเป็นใยของหน้าจอที่แต่ละอันต้องจำเองด้วยมือว่า "ใครเป็นคนเปิดฉันขึ้นมา"

5. Implement Menu Stack: Pause Menu และปุ่ม Back

หน้าจอทุกอันจะ implement base class เล็กๆ ตัวหนึ่ง เพื่อให้ stack manager ปฏิบัติกับทุกหน้าจอแบบเดียวกันได้


using UnityEngine;

public class UIScreen : MonoBehaviour
{
    // Called when this screen becomes the top of the stack.
    public virtual void OnPush() { gameObject.SetActive(true); }

    // Called when this screen is popped off the stack.
    public virtual void OnPop() { gameObject.SetActive(false); }

    // Called when another screen is pushed ON TOP of this one.
    public virtual void OnCovered() { }

    // Called when the screen above this one is popped, making
    // this one the top again.
    public virtual void OnRevealed() { }
}

using System.Collections.Generic;
using UnityEngine;

public class UIStackManager : MonoBehaviour
{
    private readonly Stack<UIScreen> stack = new Stack<UIScreen>();

    public void Push(UIScreen screen)
    {
        if (stack.Count > 0)
        {
            stack.Peek().OnCovered();
        }
        stack.Push(screen);
        screen.OnPush();
    }

    public void Pop()
    {
        if (stack.Count == 0) return;

        UIScreen top = stack.Pop();
        top.OnPop();

        if (stack.Count > 0)
        {
            stack.Peek().OnRevealed();
        }
    }

    void Update()
    {
        // One shared "Back" input for the whole game, instead of
        // every screen writing its own Escape-key handling.
        if (Input.GetKeyDown(KeyCode.Escape) || Input.GetButtonDown("Cancel"))
        {
            Pop();
        }
    }
}

หน้าจอ pause menu ใช้ base class นี้เพื่อ freeze และ unfreeze gameplay


using UnityEngine;

public class PauseMenu : UIScreen
{
    [SerializeField] private UIStackManager uiStack;
    [SerializeField] private UIScreen settingsScreen;

    public override void OnPush()
    {
        base.OnPush();
        Time.timeScale = 0f; // freeze gameplay while the menu is open
    }

    public override void OnPop()
    {
        base.OnPop();
        Time.timeScale = 1f; // resume gameplay
    }

    // Wired to the Resume button's OnClick in the Inspector.
    public void OnResumeButton()
    {
        uiStack.Pop();
    }

    // Wired to the Settings button's OnClick in the Inspector.
    public void OnSettingsButton()
    {
        uiStack.Push(settingsScreen);
    }
}

ผลลัพธ์ที่คาดไว้ ไล่ทีละขั้น: ผู้เล่นกด Esc ระหว่างเล่นเกม UIStackManager.Update() เรียก Pop() แต่ stack ไม่มีเมนูอยู่เลย เลยไม่มีอะไรเกิดขึ้น (เกมนี้ควรจะเรียก Push(pauseMenu) จาก gameplay script ตอนกด Esc แทน — การจัดการ Esc ของ stack manager เองมีไว้สำหรับย้อนกลับ ไม่ใช่เปิดเมนูแรกสุด) สมมติว่า gameplay code เรียก uiStack.Push(pauseMenu) ไปแล้ว: PauseMenu.OnPush() จะรัน ตั้ง Time.timeScale = 0f และแสดง panel ผู้เล่นกด Settings: OnSettingsButton() เรียก Push(settingsScreen) ซึ่งจะเรียก pauseMenu.OnCovered() ก่อน (pause menu อาจจะหรี่ตัวเองตรงนี้ได้) แล้วค่อยแสดง settings ผู้เล่นกด Esc: Pop() เอา settings ออก เรียก OnPop() ของมัน แล้วเรียก pauseMenu.OnRevealed() — pause menu กลับมาแสดงอีกครั้ง เหมือนจุดที่ผู้เล่นทิ้งไว้เป๊ะๆ กด Esc อีกครั้ง: Pop() เอา pause menu ออก Time.timeScale กลับเป็น 1 gameplay ก็เล่นต่อได้

เคล็ดลับ เพราะ Pop() เป็นการเรียกแบบเดียวกันทุกที่ ปุ่ม "Back" ของ OnClick ใน Inspector สามารถชี้ไปที่ method UIStackManager.Pop ตัวเดียวกันเป๊ะๆ ในทุก screen prefab ได้เลย ไม่ต้องเขียน method "ย้อนกลับ" ใหม่ทุกหน้าจอ

6. ข้อมูล Inventory: Source of Truth

ก่อนจะวาด inventory slot สักช่อง ให้สร้างข้อมูลที่มันจะแสดงก่อน เรื่องนี้สำคัญกว่าที่ฟังดู เพราะถ้า UI script เก็บเองว่า "ไอเทมอะไรอยู่ตรงไหน" ข้อมูลก็จะอยู่ได้แค่ตราบเท่าที่ UI GameObject ยังอยู่ แล้วการ save/load หรือเช็คว่า "ผู้เล่นมี health potion ไหม" จาก gameplay code ก็จะยุ่งยากขึ้นมาก ให้เก็บข้อมูลเป็น C# ธรรมดา — ไม่ต้องใช้ MonoBehaviour สำหรับตัวข้อมูลเอง


using UnityEngine;

// A ScriptableObject is a data asset that lives in the project,
// not on a scene GameObject -- one asset per item type, shared
// by every stack of that item anywhere in the game.
[CreateAssetMenu(menuName = "Inventory/Item")]
public class ItemData : ScriptableObject
{
    public string itemId;
    public string displayName;
    [TextArea] public string description;
    public Sprite icon;
    public int maxStack = 99;
}

using System;

// One slot: which item (null = empty) and how many.
[Serializable]
public class InventorySlotData
{
    public ItemData item;
    public int count;
}

using System;
using UnityEngine;

// Plain C# class -- not a MonoBehaviour. It does not need a
// GameObject to exist, which makes it far easier to save, load,
// and unit-test independently of any UI.
public class Inventory
{
    public event Action<int> OnSlotChanged; // which slot index changed

    private readonly InventorySlotData[] slots;

    public Inventory(int capacity)
    {
        slots = new InventorySlotData[capacity];
        for (int i = 0; i < capacity; i++)
        {
            slots[i] = new InventorySlotData();
        }
    }

    public int Capacity => slots.Length;
    public InventorySlotData GetSlot(int index) => slots[index];

    public bool AddItem(ItemData item, int amount)
    {
        // 1. Try to stack onto an existing slot of the same item.
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == item && slots[i].count < item.maxStack)
            {
                int room = item.maxStack - slots[i].count;
                int add = Mathf.Min(room, amount);
                slots[i].count += add;
                amount -= add;
                OnSlotChanged?.Invoke(i);
                if (amount == 0) return true;
            }
        }

        // 2. Drop whatever is left into the first empty slot.
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == null)
            {
                slots[i].item = item;
                slots[i].count = amount;
                OnSlotChanged?.Invoke(i);
                return true;
            }
        }

        return false; // bag is full
    }

    public void MoveSlot(int from, int to)
    {
        InventorySlotData temp = slots[from];
        slots[from] = slots[to];
        slots[to] = temp;

        OnSlotChanged?.Invoke(from);
        OnSlotChanged?.Invoke(to);
    }
}

ผลลัพธ์ที่คาดไว้: เรียก inventory.AddItem(healthPotion, 5) กับ inventory ที่ว่างเปล่า จะข้าม loop การ stack ไป (ยังไม่มี stack ของ health potion อยู่เลย) แล้วหา slot ว่างช่องแรก (index 0) เจอ ตั้ง slots[0].item = healthPotion, slots[0].count = 5 แล้วยิง OnSlotChanged(0) ถ้าเรียกอีกครั้งด้วย potion อีก 3 ชิ้น จะเจอ stack เดิมที่ index 0 แล้วบวกเพิ่มไปอีก 3 (ตอนนี้เป็น 8) แล้วยิง OnSlotChanged(0) อีกครั้ง — ไม่ใช้ slot ใหม่เลย เพราะมันถูก stack รวมกัน

สังเกตรูปแบบ: Inventory raise OnSlotChanged(int index) แบบเดียวกับที่ PlayerStats raise OnHealthChanged(int, int) ใน Section 2 ไอเดียเหมือนกันเป๊ะๆ — ข้อมูลเปลี่ยน แล้วข้อมูลก็ประกาศออกมา — แค่เปลี่ยนจากค่าเป็น index แทน เพราะตรงนี้มีข้อมูลแบบ "slot" อิสระหลายชิ้น ไม่ใช่ตัวเลข health ตัวเดียว

7. Inventory Grid UI: Bind Slot View เข้ากับข้อมูล

slot view คือ script เล็กๆ บน UI prefab ที่รู้วิธีแสดง InventorySlotData แค่หนึ่งช่อง — ไม่รู้อะไรมากไปกว่านั้น มันไม่รู้เรื่อง inventory ส่วนที่เหลือเลย และไม่เคยเอื้อมไปขอข้อมูลเองด้วย ข้อมูลจะถูกส่งมาให้มันต่างหาก


using UnityEngine;
using UnityEngine.UI;

public class InventorySlotView : MonoBehaviour
{
    [SerializeField] private Image icon;
    [SerializeField] private Text stackCountText; // TMP_Text in a real project

    public void Bind(InventorySlotData data)
    {
        if (data.item == null)
        {
            icon.enabled = false;
            stackCountText.text = "";
            return;
        }

        icon.enabled = true;
        icon.sprite = data.item.icon;
        // Hide the "1" on single items -- only stacks need a count shown.
        stackCountText.text = data.count > 1 ? data.count.ToString() : "";
    }
}

สำหรับกระเป๋าขนาดเล็ก (สมมติ 30-40 slot — backpack ทั่วไปของผู้เล่น ไม่ใช่ endgame stash) grid สามารถสร้าง view หนึ่งอันต่อหนึ่ง slot แค่ครั้งเดียว แล้วค่อย re-bind เฉพาะ slot ที่เปลี่ยนก็พอ


using UnityEngine;

public class InventoryGridUI : MonoBehaviour
{
    [SerializeField] private Inventory inventory;       // data, Section 6
    [SerializeField] private InventorySlotView slotPrefab;
    [SerializeField] private Transform gridParent;       // has a Grid Layout Group

    private InventorySlotView[] views;

    void Start()
    {
        // Build the view objects ONCE. This runs a handful of times,
        // not every frame and not every time an item is picked up.
        views = new InventorySlotView[inventory.Capacity];
        for (int i = 0; i < inventory.Capacity; i++)
        {
            views[i] = Instantiate(slotPrefab, gridParent);
            views[i].Bind(inventory.GetSlot(i));
        }
    }

    void OnEnable()  { inventory.OnSlotChanged += RefreshSlot; }
    void OnDisable() { inventory.OnSlotChanged -= RefreshSlot; }

    void RefreshSlot(int index)
    {
        views[index].Bind(inventory.GetSlot(index)); // re-bind ONLY this slot
    }
}

ผลลัพธ์ที่คาดไว้: ผู้เล่นเก็บ health potion 5 ชิ้น ตกลงไปที่ slot 3 Inventory.AddItem ยิง OnSlotChanged(3) InventoryGridUI.RefreshSlot(3) รัน เรียก views[3].Bind(...) — icon กับ count ของช่องนั้นอัปเดต slot view อีก 29 หรือ 39 อันที่เหลือไม่ทำอะไรเลย ไม่มีการเรียก Bind ไม่มีการคำนวณ layout ใหม่ เพราะไม่มีอะไรเปลี่ยนเลยสักช่อง นี่คือกฎจาก Section 1 ที่เอามาใช้กับ grid แทนที่จะเป็น bar เดียว: UI 40 ชิ้นที่เป็นอิสระต่อกัน แต่ละอันอัปเดตตอนที่ — และเฉพาะตอนที่ — ข้อมูลของมันเองเปลี่ยนเท่านั้น

8. Drag-and-Drop และ Hover Tooltip

Drag-and-Drop ระหว่าง Slot

UI event system ของ Unity มี drag interface ให้ implement บน component ได้ คือ IBeginDragHandler, IDragHandler, IEndDragHandler และ IDropHandler slot view สามารถ implement ทั้งสี่ตัวนี้ เพื่อรองรับการลากไอเทมไปวางบน slot อื่นเพื่อสลับกันได้


using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class InventorySlotView : MonoBehaviour,
    IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
{
    [SerializeField] private Image icon;
    [SerializeField] private Text stackCountText;
    [SerializeField] private Image dragGhost; // one shared Image, top-level "Drag Layer" canvas

    public int SlotIndex { get; set; }
    private Inventory inventory;

    public void Init(Inventory inv, int index)
    {
        inventory = inv;
        SlotIndex = index;
    }

    public void Bind(InventorySlotData data) { /* same as Section 7 */ }

    public void OnBeginDrag(PointerEventData eventData)
    {
        InventorySlotData data = inventory.GetSlot(SlotIndex);
        if (data.item == null) return; // nothing to drag from an empty slot

        dragGhost.sprite = data.item.icon;
        dragGhost.enabled = true;
    }

    public void OnDrag(PointerEventData eventData)
    {
        dragGhost.transform.position = eventData.position; // follows the cursor
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        dragGhost.enabled = false;
    }

    public void OnDrop(PointerEventData eventData)
    {
        InventorySlotView draggedFrom =
            eventData.pointerDrag.GetComponent<InventorySlotView>();

        if (draggedFrom == null || draggedFrom == this) return;

        // Change the DATA. The view updates itself automatically,
        // because InventoryGridUI is already listening to OnSlotChanged.
        inventory.MoveSlot(draggedFrom.SlotIndex, SlotIndex);
    }
}

ผลลัพธ์ที่คาดไว้ ไล่ทีละขั้น: ผู้เล่นกดเมาส์ค้างที่ slot 2 (มีดาบ) แล้วลากไปทาง slot 5 (ว่าง) OnBeginDrag ยิงที่ slot 2 แสดง drag ghost icon OnDrag ขยับ ghost ใหม่ทุกเฟรมที่เมาส์เคลื่อนที่ ปล่อยเมาส์เหนือ slot 5: event system ของ Unity เรียก OnDrop ที่ slot 5 โดยส่ง eventData.pointerDrag มาให้ (GameObject ที่กำลังถูกลาก คือ slot 2) slot 5 เรียก inventory.MoveSlot(2, 5) Inventory.MoveSlot สลับข้อมูลข้างใต้ แล้วยิง OnSlotChanged(2) ตามด้วย OnSlotChanged(5) InventoryGridUI.RefreshSlot จะ re-bind ทั้งสองช่อง — slot 2 ตอนนี้แสดงว่าง slot 5 ตอนนี้แสดงดาบ ไม่มีโค้ดใน OnDrop แตะ Image หรือ Text โดยตรงเลยสักบรรทัด

ข้อผิดพลาดที่พบบ่อย สลับภาพโดยตรงข้างใน OnDrop (ก็อป sprite ของ slot หนึ่งไปใส่อีก slot) แทนที่จะเปลี่ยนข้อมูลแล้วปล่อยให้ event pipeline ที่มีอยู่แล้ววาดใหม่ให้เอง ทางลัดนี้ดูเหมือนจะทำงานได้ แต่ตอนนี้ข้อมูล (สิ่งที่เกมเซฟจริงๆ และสิ่งที่ gameplay code เช็ค) กับหน้าจอไม่ตรงกันแล้ว — event OnSlotChanged ครั้งถัดไปที่ไม่เกี่ยวข้องกัน หรือการ save/load จะเผยให้เห็นว่าการสลับนี้ไม่ได้เกิดขึ้นจริงข้างใต้เลย

Tooltip ตอน Hover

Tooltip ใช้ interface เพิ่มอีกสองตัว คือ IPointerEnterHandler และ IPointerExitHandler และ — ที่สำคัญ — ใช้ tooltip object ที่แชร์กันตัวเดียว แทนที่จะมี tooltip หนึ่งตัวต่อหนึ่ง slot ผู้เล่น hover ได้ทีละ slot เดียวเท่านั้น เพราะฉะนั้นบนจอต้องการ tooltip แค่ตัวเดียวก็พอ


using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// The same InventorySlotView class from earlier in Section 8, with
// two more interfaces and two more methods added.
public class InventorySlotView : MonoBehaviour,
    IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler,
    IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField] private Image icon;
    [SerializeField] private Text stackCountText;
    [SerializeField] private Image dragGhost;
    [SerializeField] private TooltipUI tooltip; // one shared instance, not per slot

    public int SlotIndex { get; set; }
    private Inventory inventory;

    // ...Init, Bind, OnBeginDrag, OnDrag, OnEndDrag, OnDrop stay exactly
    // as shown earlier in this section. Only the two methods below are new.

    public void OnPointerEnter(PointerEventData eventData)
    {
        InventorySlotData data = inventory.GetSlot(SlotIndex);
        if (data.item == null) return;

        tooltip.Show(data.item.displayName, data.item.description, transform.position);
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        tooltip.Hide();
    }
}

using UnityEngine;
using UnityEngine.UI;

public class TooltipUI : MonoBehaviour
{
    [SerializeField] private GameObject panel;
    [SerializeField] private Text nameText;
    [SerializeField] private Text descriptionText;

    public void Show(string itemName, string description, Vector3 anchorPosition)
    {
        panel.SetActive(true);
        nameText.text = itemName;
        descriptionText.text = description;
        transform.position = anchorPosition + new Vector3(20f, 20f, 0f);
    }

    public void Hide()
    {
        panel.SetActive(false);
    }
}

ผลลัพธ์ที่คาดไว้: cursor เข้าไปในสี่เหลี่ยมของ slot 5 OnPointerEnter ยิงหนึ่งครั้ง เรียก tooltip.Show(...) — panel ที่แชร์กันตัวเดียวนั้นแสดงขึ้นมาใกล้ cursor พร้อมชื่อและคำอธิบายไอเทมของ slot 5 พอย้ายไป hover slot 6 จะยิง OnPointerExit ของ slot 5 (ซ่อน panel) แล้วตามด้วย OnPointerEnter ของ slot 6 (แสดงอีกครั้งด้วยข้อความใหม่) ไม่มีตอนไหนเลยที่มี tooltip panel 40 อันพร้อมกัน — panel ตัวเดียวถูกใช้ซ้ำและขยับตำแหน่งไปเรื่อยๆ ซึ่งเป็นตัวอย่างเล็กๆ ของไอเดีย pooling ที่ section ถัดไปจะเอาไปใช้กับทั้ง grid

9. UI Virtualization: ทำไมกระเป๋า 500 ไอเทมไม่ต้อง spawn 500 object

InventoryGridUI จาก Section 7 สร้าง GameObject ของ InventorySlotView หนึ่งอันต่อหนึ่ง inventory slot สำหรับ 30-40 slot แบบนี้ไม่มีปัญหาอะไรเลย แต่จะพังทันทีถ้าเป็น endgame stash, คลังวัตถุดิบ crafting หรือรายการใน auction house ที่มีเป็นร้อยเป็นพันรายการ: GameObject เป็นร้อยตัว แต่ละตัวมี Image กับ Text แต่ละตัวเป็นลูกที่ Grid Layout Group ต้องวัดขนาดและจัดตำแหน่งให้ — ส่วนใหญ่เลื่อนพ้นจอไปแล้วมองไม่เห็นด้วยซ้ำในตอนนั้นๆ ต้นทุนของ Instantiate, memory และการ rebuild layout ล้วนขยายตามจำนวนข้อมูล ทั้งที่มันควรจะขยายตามจำนวนสิ่งที่แสดงบนจอต่างหาก

UI virtualization (บางทีก็เรียกว่า "virtualized list" หรือ "recycled list") คือทางแก้: สร้าง view object แค่พอคลุม viewport ที่มองเห็น บวก buffer เล็กๆ แล้วใช้ซ้ำตอนที่ผู้เล่นเลื่อนหน้าจอ — ขยับมันแล้ว re-bind มันเข้ากับ data index ใหม่ แทนที่จะสร้างและทำลาย GameObject นี่คือไอเดียเดียวกันเป๊ะๆ กับ Object Pool pattern จากบทก่อนหน้า (pre-allocate ครั้งเดียว แจกออกไปแล้วรับกลับมา ไม่มี Instantiate/Destroy ใน hot path เลย) — เอามาใช้กับแถวของ UI โดยเพิ่มอีกอย่างหนึ่งเข้ามา: ทุกครั้งที่ pooled view ตัวหนึ่งถูกส่ง data index ใหม่มาให้ มันต้องถูก re-bind เสมอ เพราะไม่เหมือนกระสุนที่ pool ไว้ แถวที่ใช้ซ้ำต้องแสดงเนื้อหาที่ต่างไปโดยสิ้นเชิงทุกครั้ง

Data: 500 InventorySlotData objects in an array. All 500 are real, all 500 live in memory -- that part is cheap, plain data. Screen shows about 8 rows at a time. Only 8-10 InventorySlotView GameObjects are ever created, for the entire 500-item bag: scrollOffset = 0 scrollOffset = 5 rows down +---------------------+ +---------------------+ | View0 <- data[0] | | View0 <- data[5] | | View1 <- data[1] | | View1 <- data[6] | | View2 <- data[2] | | View2 <- data[7] | | ... (8 views) | | ... (8 views) | | View7 <- data[7] | | View7 <- data[12] | +---------------------+ +---------------------+ Scrolling repositions and re-binds the SAME 8-10 views. It never calls Instantiate or Destroy after the initial pool is built.

using System.Collections.Generic;
using UnityEngine;

public class PooledInventoryScrollView : MonoBehaviour
{
    [SerializeField] private Inventory inventory;      // can hold 500+ slots
    [SerializeField] private InventorySlotView slotPrefab;
    [SerializeField] private RectTransform viewport;   // the visible window
    [SerializeField] private RectTransform content;    // scrolls inside the viewport
    [SerializeField] private float rowHeight = 80f;

    private readonly List<InventorySlotView> pool = new List<InventorySlotView>();
    private int firstVisibleIndex = -1;

    void Start()
    {
        // Only enough views to cover the viewport, plus 2 spare rows
        // as a buffer so nothing pops in late while scrolling fast.
        int visibleRows = Mathf.CeilToInt(viewport.rect.height / rowHeight) + 2;
        int poolSize = Mathf.Min(visibleRows, inventory.Capacity);

        for (int i = 0; i < poolSize; i++)
        {
            pool.Add(Instantiate(slotPrefab, content));
        }

        // Content height matches the FULL data count, so the scrollbar
        // behaves exactly as if all 500 rows really existed as objects.
        content.sizeDelta = new Vector2(content.sizeDelta.x, inventory.Capacity * rowHeight);

        RefreshVisible();
    }

    // Hook this to the ScrollRect's OnValueChanged in the Inspector.
    public void OnScroll(Vector2 unusedScrollPosition)
    {
        RefreshVisible();
    }

    void RefreshVisible()
    {
        int newFirst = Mathf.Max(0, Mathf.FloorToInt(content.anchoredPosition.y / rowHeight));
        if (newFirst == firstVisibleIndex) return; // scrolled less than one row -- nothing to rebind
        firstVisibleIndex = newFirst;

        for (int i = 0; i < pool.Count; i++)
        {
            int dataIndex = firstVisibleIndex + i;
            if (dataIndex >= inventory.Capacity)
            {
                pool[i].gameObject.SetActive(false);
                continue;
            }

            pool[i].gameObject.SetActive(true);
            pool[i].GetComponent<RectTransform>().anchoredPosition =
                new Vector2(0f, -dataIndex * rowHeight);
            pool[i].Bind(inventory.GetSlot(dataIndex)); // re-bind to the new data index
        }
    }
}

ผลลัพธ์ที่คาดไว้: ด้วย inventory 500 slot, Instantiate จะรันประมาณ 10 ครั้งรวมทั้งหมด ใน Start() — ไม่ใช่ 500 ครั้ง และจะไม่รันอีกเลยตอนเลื่อนหน้าจอ การเลื่อนหน้าจอแค่ขยับและ re-bind view 10 อันเดิมนั้นเท่านั้น ไม่ว่ากระเป๋าจะมี 50 ชิ้นหรือ 5,000 ชิ้น ต้นทุนต่อเฟรมของการเลื่อนและต้นทุน setup ของ pool ครั้งเดียวจะเท่าเดิม เพราะทั้งสองอย่างขึ้นอยู่กับว่ามีกี่แถวพอดีกับจอ ไม่ใช่ขึ้นกับว่ามีข้อมูลอยู่เท่าไหร่

เคล็ดลับ นี่ไม่ใช่เทคนิคเฉพาะของ "inventory" — ScrollRect UI ของ Unity เองก็ไม่ได้ virtualize ให้อัตโนมัติ (ScrollRect ธรรมดาที่มี Grid Layout Group ก็ยังสร้างลูกหนึ่งตัวต่อหนึ่งข้อมูลอยู่ดี) list ไหนก็ตามที่มีโอกาสโตเกินหนึ่งจอจริงๆ — chat log, leaderboard, ตัวเลือก crafting recipe, รายชื่อเพื่อน — ก็ได้ประโยชน์จากวิธี pool-and-rebind แบบเดียวกันนี้

10. ทำให้ทุกอย่าง Sync กัน: ใช้ Event ไม่ใช่ Rebuild ทุกเฟรม

ทุกระบบในบทนี้ที่ผ่านมา — health bar, menu stack, inventory grid, pooled scroll view — เดินตามวินัยเดียวกันหมด ควรพูดแยกออกมาให้ชัดๆ อีกทีตอนที่เห็นทุกชิ้นมาอยู่รวมกันแล้ว

เทียบกับ anti-pattern ที่มันมาแทนที่ ซึ่งดูไม่มีพิษภัยตอนทำ prototype แต่จะแพงขึ้นเร็วมาก


// ANTI-PATTERN: rebuilds all 40 slots every single frame, whether
// or not anything changed. Costs Instantiate/Destroy 60 times a
// second for a screen that visually looks completely static.
void Update()
{
    foreach (Transform child in gridParent)
    {
        Destroy(child.gameObject);
    }
    for (int i = 0; i < inventory.Capacity; i++)
    {
        InventorySlotView view = Instantiate(slotPrefab, gridParent);
        view.Bind(inventory.GetSlot(i));
    }
}
ข้อผิดพลาดที่พบบ่อย "แค่เรียก RefreshAll() ใน Update() ก็ง่ายกว่า" เป็นทางลัดที่ยั่วใจตอนทำ prototype มันทำงานได้จริง แต่ทั้งสี่ pattern ในบทนี้มีไว้เพื่อหลีกเลี่ยงเรื่องนี้โดยเฉพาะ: การทำลายและสร้าง GameObject ใหม่ทุกเฟรมสร้าง garbage ให้ collector ต้องมาเก็บกวาด บังคับให้ layout system คำนวณตำแหน่งใหม่ตลอดเวลา แล้วทำแบบนั้นไม่ว่าจะมีอะไรเปลี่ยนจริงๆ หรือไม่ก็ตาม เวอร์ชัน event-driven ไม่ได้เขียนยากไปกว่ากันเลยจริงๆ แค่ต้องตัดสินใจครั้งเดียวว่า event อะไรจะยิงตอนไหน

11. Gamepad และ Keyboard Navigation กับ Focus

ทุกอย่างที่ผ่านมาสมมติว่ามี mouse เครื่องคอนโซลกับเครื่องพกพาแบบ Steam Deck ไม่มี cursor — แทนที่จะเป็นแบบนั้น EventSystem ของ Unity จะติดตาม GameObject ที่ "ถูกเลือกอยู่ตอนนี้" ทีละตัว แล้วการขยับ D-pad หรือ analog stick จะย้ายการเลือกนั้นไปมาระหว่าง UI element ที่มี component Selectable (Button, Slider และอื่นๆ ที่คล้ายกันล้วน inherit มาจากมัน)

มีสองเรื่องที่ต้องจัดการเองชัดๆ ซึ่งถ้าใช้ mouse จะได้มาฟรีๆ อย่างแรก: ตอนที่หน้าจอใหม่ถูก push ต้องมีบางอย่างถูกเลือกไว้ ไม่งั้น D-pad กับปุ่ม Submit จะไม่ทำอะไรเลย เพราะยังไม่มีอะไรถูก highlight ไว้


using UnityEngine;
using UnityEngine.EventSystems;

public class PauseMenu : UIScreen
{
    [SerializeField] private GameObject firstSelected; // e.g. the Resume button

    public override void OnPush()
    {
        base.OnPush();
        Time.timeScale = 0f;

        // Without this, a gamepad/keyboard player sees the pause menu
        // but nothing is highlighted, and no button will respond.
        EventSystem.current.SetSelectedGameObject(firstSelected);
    }
}

อย่างที่สอง: Selectable แต่ละตัวมีค่า Navigation ใน Inspector ปกติจะปล่อยไว้เป็น Automatic (Unity เดา neighbor ทิศ Up/Down/Left/Right เองจากตำแหน่งบนจอ) Automatic ใช้ได้ดีกับ list ปุ่มแนวตั้งธรรมดา แต่มักจะเดาผิดกับ layout ที่ไม่สม่ำเสมอ เช่น inventory grid หรือเมนูที่มีปุ่มขนาดต่างกัน ให้เปลี่ยนเป็น Explicit แล้ว assign neighbor แต่ละทิศเองด้วยมือ สำหรับอะไรก็ตามที่ไม่ใช่ list เส้นตรงเดียว

ข้อผิดพลาดที่พบบ่อย การรวม gamepad focus เข้ากับ pooled scroll view จาก Section 9 โดยไม่ระวังเพิ่มเติม GameObject ของ InventorySlotView ที่ถูก pool ไว้จะถูกใช้ซ้ำกับข้อมูลที่ต่างกันไปเรื่อยๆ ตอนผู้เล่นเลื่อนหน้าจอ — ตัวตนของมันในฐานะ GameObject ยังคงเดิม แต่มันแทน inventory slot ไหนจะเปลี่ยนไปตลอด ถ้า track "ไอเทมที่ถูกเลือก" ด้วยการจำ reference ของ GameObject ไว้ การเลื่อนหน้าจออาจจะสลับสิ่งที่ GameObject นั้นแทนอยู่แบบเงียบๆ โดยที่ผู้เล่นไม่รู้ตัว ให้ track การเลือกด้วย data index แทนตัวตนของ GameObject แล้ว re-resolve ทุกครั้งหลังเลื่อนหน้าจอว่า pooled view ตัวไหน (ถ้ามี) กำลังแสดง index นั้นอยู่

12. ตัวอย่างจริง: หน้าจอผลลัพธ์ Gacha/Summon

หน้าจอ gacha หรือ summon (พบได้ทั่วไปในเกมมือถือและ live-service RPG — ผู้เล่นจ่าย currency เพื่อสุ่มรับไอเทมหรือตัวละครหนึ่งชิ้นหรือมากกว่านั้น) เป็นตัวอย่างปิดท้ายที่ดี เพราะมันใช้ทั้งสามระบบจากบทนี้พร้อมกัน: การแสดง currency ก็คือ stat bar (Section 2-3), หน้าจอผลลัพธ์ถูก push เข้า menu stack ทับหน้าจอที่เรียกมันขึ้นมา (Section 4-5) และผลลัพธ์ที่สุ่มได้จะแสดงใน grid เล็กๆ ของ slot view (Section 7) โดยใช้ InventorySlotView ตัวเดียวกันเป๊ะๆ กับที่สร้างไว้สำหรับ inventory ซ้ำ


using System;
using System.Collections.Generic;
using UnityEngine;

public class SummonService : MonoBehaviour
{
    public event Action<List<ItemData>> OnSummonCompleted;

    [SerializeField] private List<ItemData> possiblePulls;

    public void Summon(int pullCount)
    {
        var results = new List<ItemData>();
        for (int i = 0; i < pullCount; i++)
        {
            int roll = UnityEngine.Random.Range(0, possiblePulls.Count);
            results.Add(possiblePulls[roll]);
        }
        OnSummonCompleted?.Invoke(results);
    }
}

using System.Collections.Generic;
using UnityEngine;

public class GachaResultScreen : UIScreen
{
    [SerializeField] private SummonService summonService;
    [SerializeField] private UIStackManager uiStack;

    // Small, fixed count (e.g. 10 for a "10-pull") -- no pooling needed
    // here the way Section 9 needed it, because the count never grows
    // past what a single pull screen ever shows at once.
    [SerializeField] private InventorySlotView[] resultSlots;

    void OnEnable()  { summonService.OnSummonCompleted += ShowResults; }
    void OnDisable() { summonService.OnSummonCompleted -= ShowResults; }

    void ShowResults(List<ItemData> results)
    {
        uiStack.Push(this); // same push used for every other screen in this chapter

        for (int i = 0; i < resultSlots.Length; i++)
        {
            bool hasResult = i < results.Count;
            resultSlots[i].gameObject.SetActive(hasResult);
            if (hasResult)
            {
                var slotData = new InventorySlotData { item = results[i], count = 1 };
                resultSlots[i].Bind(slotData);
            }
        }
    }

    // Wired to a full-screen "tap anywhere to continue" button.
    public void OnTapToContinue()
    {
        uiStack.Pop();
    }
}

ผลลัพธ์ที่คาดไว้ ไล่ทีละขั้น: ผู้เล่นแตะ "10x Summon" SummonService.Summon(10) สุ่มผลลัพธ์ ItemData 10 ชิ้น แล้วยิง OnSummonCompleted GachaResultScreen.ShowResults รัน: มัน push ตัวเองเข้า UIStackManager ตัวเดียวกับที่ใช้กับ pause menu — หน้าจอที่เปิดอยู่ข้างใต้ (ร้านค้า, หน้าจอ currency สำหรับ summon) ถูก cover และ freeze ให้อัตโนมัติ โดยไม่ต้องเขียนโค้ดเพิ่มเพื่อ freeze เลยสักบรรทัด เพราะ UIStackManager จัดการให้อยู่แล้ว ผลลัพธ์ 10 ชิ้นถูก bind เข้ากับ InventorySlotView instance ที่มีอยู่แล้ว 10 ตัว — ไม่มีการเรียก Instantiate ตอนสุ่มเลย เพราะเหมือนกับ inventory grid ใน Section 7 view พวกนี้มีอยู่แล้วแค่ถูก re-bind เฉยๆ ผู้เล่นแตะตรงไหนก็ได้: OnTapToContinue เรียก uiStack.Pop() แล้วหน้าจอข้างใต้ก็กลับมาแสดงเหมือนเดิมเป๊ะๆ เพราะนั่นคือสิ่งที่ Pop() ทำเสมอ

Player taps "10x Summon" | v SummonService.Summon(10) --(rolls RNG)--> 10 ItemData results | v OnSummonCompleted event fires | v GachaResultScreen.ShowResults |-- uiStack.Push(this) [Section 4-5: menu stack] |-- resultSlots[i].Bind(...) x10 [Section 7: bind, don't rebuild] | v Player taps to continue --> uiStack.Pop() --> previous screen reappears

13. อภิธานศัพท์

14. แบบฝึกหัด

แบบฝึกหัดที่ 1 — เตือน Mana ต่ำ แบบยังคง Event-Driven ขยาย StatBarUI จาก Section 3 ให้ทุกครั้งที่มันถูก bind ด้วยสัดส่วน (current / max) ต่ำกว่า 0.25 ให้ fill image ของ bar เปลี่ยนเป็นสีแดง และกลับเป็นสีขาวถ้าไม่ใช่ การเปลี่ยนแปลงนี้ต้องเกิดขึ้นเฉพาะข้างใน Bind call ที่เป็น event-driven อยู่แล้วเท่านั้น — ห้ามเพิ่ม method Update() หรือ polling ทุกเฟรมใดๆ ทั้งสิ้น
ดูเฉลย

using UnityEngine;
using UnityEngine.UI;

public class StatBarUI : MonoBehaviour
{
    [SerializeField] private Slider slider;
    [SerializeField] private Image fillImage; // the Slider's Fill Area/Fill image

    private static readonly Color LowColor = Color.red;
    private static readonly Color NormalColor = Color.white;

    public void Bind(int current, int max)
    {
        slider.minValue = 0;
        slider.maxValue = max;
        slider.value = current;

        float ratio = max > 0 ? (float)current / max : 0f;
        fillImage.color = ratio < 0.25f ? LowColor : NormalColor;
    }
}

การเช็คสีอยู่ข้างใน Bind ซึ่งรันอยู่แล้วเฉพาะตอนที่ OnManaChanged (หรือ OnHealthChanged) ยิงเท่านั้น ไม่มีการ subscribe ใหม่ ไม่มี loop ใหม่ และไม่มีต้นทุนต่อเฟรมเพิ่มขึ้นเลย — event pipeline เดิมจาก Section 3 เรียก Bind ในจังหวะที่ถูกต้องอยู่แล้ว เพราะฉะนั้น logic อะไรก็ตามที่ใส่เข้าไปข้างในก็จะได้พฤติกรรม "เฉพาะตอนที่เปลี่ยนเท่านั้น" มาฟรีๆ เหมือนกัน

แบบฝึกหัดที่ 2 — Dialog ยืนยันการออกจากเกม ใช้ UIStackManager และ UIScreen base class จาก Section 5 เขียนหน้าจอ ConfirmQuitDialog ที่มีปุ่มสองปุ่ม: "Yes" เรียก Application.Quit() และ "No" ก็แค่กลับไปหน้าจอที่เปิดอยู่ก่อนหน้า dialog นี้ (น่าจะเป็น pause menu) โดยไม่ต้องรู้ชื่อของหน้าจอนั้นเลย
ดูเฉลย

using UnityEngine;

public class ConfirmQuitDialog : UIScreen
{
    [SerializeField] private UIStackManager uiStack;

    // Wired to the Yes button's OnClick in the Inspector.
    public void OnYesButton()
    {
        Application.Quit();
    }

    // Wired to the No button's OnClick in the Inspector.
    public void OnNoButton()
    {
        uiStack.Pop();
    }
}

OnNoButton ไม่ต้องมี reference ไปหา pause menu เลยแม้แต่น้อย — มันแค่เรียก Pop() แล้วอะไรก็ตามที่อยู่ข้างใต้ dialog นี้ใน stack (ที่ถูก push ไว้โดย PauseMenu.OnQuitButton() ที่เรียก uiStack.Push(confirmQuitDialog)) ก็จะกลับมาแสดงเองผ่าน OnRevealed() นี่คือประโยชน์ทั้งหมดของ stack model จาก Section 4: dialog ไม่ต้องรู้หรือสนใจเลยว่าใครเป็นคนเปิดมันขึ้นมา

แบบฝึกหัดที่ 3 — จับผิด Anti-Pattern เพื่อนร่วมทีมเขียน inventory UI ด้านล่างนี้มา มันแสดงไอเทมถูกต้องทุกอย่าง แต่ frame rate ของเกมตกลงเห็นชัดทุกครั้งที่เปิด panel inventory ทั้งๆ ที่ผู้เล่นยืนนิ่งอยู่เฉยๆ ไม่ได้แตะกระเป๋าเลย ใช้ไอเดียจากบทนี้ อธิบายสักสองสามประโยคว่ามันผิดตรงไหน แล้วเขียนใหม่ให้ถูกต้อง

public class NaiveInventoryUI : MonoBehaviour
{
    public Inventory inventory;
    public InventorySlotView slotPrefab;
    public Transform gridParent;

    void Update()
    {
        foreach (Transform child in gridParent)
        {
            Destroy(child.gameObject);
        }
        for (int i = 0; i < inventory.Capacity; i++)
        {
            InventorySlotView view = Instantiate(slotPrefab, gridParent);
            view.Bind(inventory.GetSlot(i));
        }
    }
}
ดูเฉลย

นี่คือ per-frame rebuild anti-pattern จาก Section 10: Update() ทำลายและสร้าง slot GameObject ใหม่ทุกช่อง 60 ครั้งต่อวินาที ไม่ว่า inventory จะเปลี่ยนหรือไม่ก็ตาม แต่ละเฟรมต้องจ่ายต้นทุนของ Destroy, Instantiate และการคำนวณ layout ใหม่ทั้งหมดในทุก slot ทั้งๆ ที่หน้าจอนี้ดูนิ่งสนิทสำหรับผู้เล่นแทบตลอดเวลา ทางแก้คือสร้าง view แค่ครั้งเดียว แล้วอัปเดตเฉพาะตอนตอบสนอง event การเปลี่ยนแปลงของ inventory เองเท่านั้น เหมือนกับ InventoryGridUI ใน Section 7 เป๊ะๆ:


public class InventoryGridUI : MonoBehaviour
{
    public Inventory inventory;
    public InventorySlotView slotPrefab;
    public Transform gridParent;

    private InventorySlotView[] views;

    void Start()
    {
        views = new InventorySlotView[inventory.Capacity];
        for (int i = 0; i < inventory.Capacity; i++)
        {
            views[i] = Instantiate(slotPrefab, gridParent);
            views[i].Bind(inventory.GetSlot(i));
        }
    }

    void OnEnable()  { inventory.OnSlotChanged += RefreshSlot; }
    void OnDisable() { inventory.OnSlotChanged -= RefreshSlot; }

    void RefreshSlot(int index)
    {
        views[index].Bind(inventory.GetSlot(index));
    }
}

ถ้านี่เป็นกระเป๋า 500 slot แทนที่จะเป็น backpack เล็กๆ ทางแก้แบบเดียวกันนี้ก็ยังต้องมี pooling จาก Section 9 เพิ่มเข้ามาด้วย เพื่อให้จำนวน view ที่ยังใช้งานอยู่ขยายตาม viewport ที่มองเห็น แทนที่จะขยายตามจำนวน slot ทั้งหมด

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