ทุกเกม RPG ต้องมี inventory: potion ที่เก็บมาจากหีบ, แร่ที่ขุดจากก้อนหิน, ดาบที่ดรอปจากบอส และในเกม gacha ก็มี weapon กับ material สำหรับตัวละครเป็นร้อยเป็นพันชิ้น บทนี้พูดถึงวิธีเก็บของทั้งหมดนี้เป็น data ให้สะอาดพอที่ designer จะเพิ่ม item ใหม่ห้าร้อยชิ้นได้โดยไม่ต้องให้โปรแกรมเมอร์แตะโค้ดเลย และให้ inventory logic ชุดเดียวกันเป๊ะ ๆ ไปรองรับ UI ได้หลายแบบ ทั้งหน้าจอมือถือ, เมนูที่เล่นด้วยจอย, หรือระบบแนบไอเทมใน mail ก็ได้ เราจะสร้างมันไปทีละส่วน: definition ของ item, instance ของ item ที่อยู่ใน slot, list ของ slot ที่รวมกันเป็น inventory, กฎการ add/remove, equipment, UI และการ save ลงดิสก์
คุณรู้จัก list, dictionary และ Big-O cost ของมันมาแล้วจากบท data structures — เรื่องนี้จะโผล่มาอีกครั้งในบทนี้ เพราะการค้นหาใน inventory แบบทื่อ ๆ คือ O(n) ส่วนแบบที่ทำ index ดี ๆ จะเป็น O(1) เก็บเรื่องนี้ไว้ในหัว เดี๋ยวเราจะย้อนมาพูดถึงมันอีกทีตอนท้ายบท
สัญชาตญาณแรกของมือใหม่คือสร้าง class C# แยกให้ทุก item: class HealthPotion, class Sword, class IronOre แต่ละอันมี field ของตัวเอง แถมอาจจะมี method Use() ของตัวเองด้วย วิธีนี้ใช้ได้กับโปรเจกต์การบ้านที่มีแค่ห้า item แต่พอเจอ RPG จริง ๆ มันจะพังเร็วมาก และใช้ไม่ได้เลยกับเกม gacha ที่มี item เป็นร้อยเป็นพันชิ้น
// The instinct that does NOT scale: one class per item
public class HealthPotion { public string name = "Health Potion"; public int healAmount = 20; }
public class ManaPotion { public string name = "Mana Potion"; public int manaAmount = 15; }
public class IronSword { public string name = "Iron Sword"; public int damage = 8; }
// ...repeat this pattern 500 more times for a gacha game's item list
ปัญหาของวิธีนี้มีสามข้อ: (1) การเพิ่ม item ใหม่ต้องเขียนแล้ว compile class C# ใหม่ ทำให้ designer ที่ไม่มีพื้นความรู้เขียนโปรแกรมเพิ่ม item เองไม่ได้ (2) ไม่มี list เดียวที่ loop ผ่านได้ เพราะ class แต่ละตัวเป็นคนละ type กัน (3) จริง ๆ แล้ว item ส่วนใหญ่ทำงานในโค้ดไม่ต่างกันเลย — "Health Potion" กับ "Mana Potion" ต่างก็มีแค่ชื่อ, icon และตัวเลขตัวหนึ่ง data ต่างกัน แต่ behavior แทบจะเหมือนกัน
ทางแก้คือทำให้ item เป็น data: class เดียวที่มี field อย่างชื่อ, icon, stack size แล้วสร้าง instance ของ class เดียวนั้นหลาย ๆ ตัว — หนึ่งตัวต่อหนึ่ง item โดยใส่ค่าต่างกันไป Unity มีเครื่องมือที่ทำมาเพื่อเรื่องนี้โดยเฉพาะ นั่นคือ ScriptableObject (base class ของ Unity สำหรับ data ที่อยู่เป็นไฟล์บนดิสก์ ไม่ต้องแปะอยู่กับ GameObject ใด ๆ ใน scene)
ScriptableObject คือ class ที่คุณเขียนขึ้นแล้ว Unity สามารถ save เป็น asset file ของมันเอง (ไฟล์ .asset ใน Project window) เหมือนกับที่ texture หรือ prefab เป็นไฟล์หนึ่งไฟล์ ต่างจาก MonoBehaviour (ที่ต้องอยู่บน GameObject ใน scene ถึงจะทำงานได้) ScriptableObject คือ data ล้วน ๆ ที่วางอยู่บนดิสก์ ให้อะไรก็ตามในโปรเจกต์ reference ถึงได้ asset เดียว ใช้ซ้ำได้ทุกที่ที่ต้องการ ไม่มีการก็อปปี้ซ้ำต่อ scene
using UnityEngine;
public enum ItemType { Material, Consumable, Weapon, Armor, QuestItem }
[CreateAssetMenu(fileName = "NewItem", menuName = "Inventory/Item Definition")]
public class ItemDefinition : ScriptableObject
{
[Header("Identity")]
public string id; // stable id used for saving, e.g. "potion_health_01"
public string itemName; // shown in the UI, e.g. "Health Potion"
public Sprite icon; // the picture the UI draws for this item
[Header("Stacking")]
public int maxStackSize = 1; // 1 = cannot stack (most weapons), 99 = stacks a lot (materials)
[Header("Type")]
public ItemType itemType;
[TextArea]
public string description;
}
บรรทัด [CreateAssetMenu(...)] คือ Unity attribute (tag ที่วางไว้เหนือ class หรือ member เพื่อบอก Unity หรือ editor ให้จัดการมันเป็นพิเศษ) ตัวนี้จะเพิ่มเมนูภายใต้ Assets > Create > Inventory > Item Definition พอคลิก Unity จะสร้างไฟล์ .asset ใหม่บนดิสก์ — เป็น instance ของ ItemDefinition ที่ถูก save จริง ๆ มีค่าของตัวเอง แก้ไขได้ใน Inspector เหมือน Unity object ทั่วไป designer สร้าง IronSword.asset ลาก icon sprite เข้าไป พิมพ์ "Iron Sword" ลงใน itemName ตั้ง maxStackSize เป็น 1 — ไม่ต้องเขียนโค้ดเลย ไม่ต้อง recompile
นี่คือสคริปต์เล็ก ๆ ที่แค่อ่านค่าแล้ว print ออกมา เพื่อเช็คว่าค่าที่ใส่ไปตกลงมาถูกต้อง
using UnityEngine;
public class ItemPrinter : MonoBehaviour
{
public ItemDefinition item; // dragged onto this field in the Inspector
void Start()
{
Debug.Log($"{item.itemName} (id={item.id}) stacks up to {item.maxStackSize}");
}
}
Output (หน้าต่าง Console หลังจากลาก asset IronSword ใส่ item แล้วกด Play):
Iron Sword (id=sword_iron_01) stacks up to 1
id string เป็นตัวอักษรที่พิมพ์เองให้ทุก ItemDefinition แล้วอย่าเปลี่ยนมันอีกเลยเมื่อ item นั้นเริ่มมีอยู่ใน save file ที่ปล่อยไปแล้ว ชื่อไฟล์ (IronSword.asset) กับชื่อที่โชว์ (itemName) เปลี่ยนได้อิสระตอน redesign แต่ id คือสิ่งเดียวที่ต้องคงที่ เพราะ save กับข้อมูลฝั่ง server จะอ้างถึง item ด้วย id (จะพูดละเอียดในหัวข้อ 9)นี่คือแนวคิดหลักที่บททั้งบทถูกสร้างขึ้นมารอบมัน และเป็นความผิดพลาดที่มือใหม่ทำบ่อยที่สุดเรื่อง inventory IronSword.asset คือไฟล์เดียว มีอยู่แค่ชิ้นเดียวในทั้งโปรเจกต์ ถ้าผู้เล่นเก็บดาบเหล็กมาสามเล่ม คุณไม่ได้มี asset สามก็อปปี้ — คุณยังมี IronSword.asset อยู่ตัวเดียว แล้วเก็บตัวเลข 3 ไว้ที่ไหนสักที่ asset คือ definition ("ดาบเหล็กคืออะไร": ชื่อ, icon, stack size สูงสุด — ใช้ร่วมกัน มีตัวเดียว ไม่เปลี่ยนตามผู้เล่นแต่ละคน) ส่วนตัวเลข 3 ที่อยู่ใน slot คือ instance ("ผู้เล่นคนนี้มีของสิ่งนี้อยู่กี่ชิ้น ใน slot นี้" — มีทุกครั้งที่เก็บของ เปลี่ยนตลอดเวลา)
instance ต้องมีวิธีบอกว่า "item อะไร" กับ "มีกี่ชิ้น" นั่นคือ class เล็ก ๆ ที่ถือ reference ไปยัง definition บวกกับตัวนับ:
[System.Serializable]
public class ItemStack
{
public ItemDefinition definition; // reference to the shared asset -- the "what"
public int count; // how many in THIS slot -- the "how many"
public bool IsEmpty => definition == null || count <= 0;
}
มาพิสูจน์กันว่า stack สองตัวใช้ definition object ตัวเดียวกันจริง ๆ แต่นับ count แยกกันเป็นอิสระ
void Start()
{
ItemStack slotA = new ItemStack { definition = ironSword, count = 1 };
ItemStack slotB = new ItemStack { definition = ironSword, count = 1 };
slotB.count = 5; // change only slotB's count
Debug.Log($"same asset in memory? {ReferenceEquals(slotA.definition, slotB.definition)}");
Debug.Log($"slotA.count={slotA.count}, slotB.count={slotB.count}");
}
Output:
same asset in memory? True
slotA.count=1, slotB.count=5
ทั้งสอง slot ชี้ไปที่ object IronSword.asset ตัวเดียวกันเป๊ะ ๆ นั่นคือเหตุผลที่ ReferenceEquals ตอบว่า True แต่ count อยู่บน stack ไม่ใช่บน definition ดังนั้นแต่ละ slot จึงนับจำนวนของตัวเองแยกกันอย่างเป็นอิสระ นี่คือเคล็ดลับทั้งหมดของเรื่องนี้
count ลงไปตรง ๆ บน ItemDefinition แล้วแก้ไขมัน (เช่น item.count++ ตอนผู้เล่นเก็บของ) เพราะ ItemDefinition เป็น asset ตัวเดียวที่ใช้ร่วมกัน ตัวแปร count ตัวนั้นจึงถูกใช้ร่วมกันโดยทุก reference ที่ชี้ไปหามันทั้งเกม — ผู้เล่นทุกคน slot ทุกอัน ทุกที่ที่ item นั้นปรากฏ แย่กว่านั้นคือถ้าคุณแก้ค่ามันตอนทดสอบใน Play mode ของ Unity Editor Unity อาจปล่อยให้การเปลี่ยนแปลงนั้นถูกเขียนลงใน asset file จริง ๆ หลังจากหยุด Play ทำให้ข้อมูล item เสียหายแบบเงียบ ๆ ตัวเลขที่ผูกกับผู้เล่นแต่ละคน แต่ละ slot (count, ความทนทานปัจจุบัน, "ใส่อยู่หรือเปล่า") ต้องอยู่บน instance เสมอ ไม่ใช่บน definitioninventory คือ collection ของ slot ที่มีขนาดคงที่ แต่ละ slot ถือ ItemStack หนึ่งตัว (ซึ่งอาจจะว่างก็ได้) เราเก็บมันเป็น List<ItemStack> ธรรมดา ที่กำหนด capacity ไว้ล่วงหน้า — แนวคิด "reserve ขนาดที่ต้องใช้ไว้ก่อน" แบบเดียวกับบท data structures เพื่อให้ list ไม่ต้อง resize ระหว่างที่ผู้เล่นกำลังเล่นอยู่
using System.Collections.Generic;
using UnityEngine;
public class Inventory
{
public List<ItemStack> slots = new List<ItemStack>();
public int capacity;
public Inventory(int capacity)
{
this.capacity = capacity;
for (int i = 0; i < capacity; i++)
slots.Add(new ItemStack()); // starts empty: definition == null, count == 0
}
}
สังเกตว่า slot list ไม่สนว่าแต่ละ slot มี item ประเภท ไหนอยู่ — weapon กับ potion อยู่ใน entry ของ list แบบเดียวกันหมด behavior พิเศษทั้งหมด (stack ได้ไหม ใช้แล้วเกิดอะไรขึ้น) มาจาก ItemDefinition ที่ slot นั้นชี้ไปหา ไม่ได้มาจากตัว slot เอง นี่คือแนวคิด "data ไม่ใช่ class แยกทีละ item" จากหัวข้อ 1 ที่เอามาใช้กับการเก็บข้อมูลอีกครั้ง
การเก็บ item ไม่ควรกิน slot ใหม่ทุกครั้ง ถ้าผู้เล่นมี Health Potion อยู่แล้ว 5 ชิ้น (max 99) แล้วเก็บมาเพิ่มอีก 3 ชิ้น 3 ชิ้นนั้นควรเติมเข้า stack เดิม ไม่ใช่สร้าง stack ที่สอง จะไปหา slot ว่างก็ต่อเมื่อ stack ที่ตรงกันทุกอันเต็มหมดแล้วเท่านั้น algorithm นี้ทำงานสองรอบ:
public int AddItem(ItemDefinition item, int amount)
{
// Pass 1: top up existing stacks of the same item first.
for (int i = 0; i < slots.Count && amount > 0; i++)
{
ItemStack slot = slots[i];
if (slot.definition == item && slot.count < item.maxStackSize)
{
int room = item.maxStackSize - slot.count;
int add = Mathf.Min(room, amount);
slot.count += add;
amount -= add;
}
}
// Pass 2: whatever is left over goes into empty slots.
for (int i = 0; i < slots.Count && amount > 0; i++)
{
ItemStack slot = slots[i];
if (slot.IsEmpty)
{
int add = Mathf.Min(item.maxStackSize, amount);
slot.definition = item;
slot.count = add;
amount -= add;
}
}
return amount; // 0 = everything fit; > 0 = this many did not fit (inventory full)
}
ลอง trace ด้วย inventory เล็ก ๆ capacity 3 และ Health Potion มี maxStackSize เท่ากับ 10 (ตัวเลขเล็ก ๆ เพื่อให้ trace ตามง่าย):
Inventory inv = new Inventory(3);
inv.AddItem(healthPotion, 5); // nothing existed yet -> pass 2 creates slot 0 = 5
int leftover = inv.AddItem(healthPotion, 8); // pass 1 tops slot 0 to 10 (+5), pass 2 makes slot 1 = 3 (+3)
Debug.Log($"leftover: {leftover}");
for (int i = 0; i < inv.slots.Count; i++)
{
ItemStack s = inv.slots[i];
Debug.Log($"slot {i}: {(s.IsEmpty ? "empty" : $"{s.definition.itemName} x{s.count}")}");
}
Output:
leftover: 0
slot 0: Health Potion x10
slot 1: Health Potion x3
slot 2: empty
ไล่ดูทีละขั้น: call แรกไม่มีอะไรให้เติม รอบ 1 จึงไม่ทำอะไร รอบ 2 เติม slot 0 ด้วย 5 ชิ้น call ที่สองในรอบ 1 เจอ slot 0 อยู่ที่ 5/10 เติมเพิ่มอีก 5 (กลายเป็น 10 ใช้ไป 5 จาก 8) เหลืออีก 3 รอบ 2 จึงเอา 3 ที่เหลือไปใส่ slot ว่างตัวถัดไปคือ slot 1 leftover เป็น 0 เพราะ 3 slot × 10 capacity เหลือเฟือ
ทีนี้มาดูตอนที่ inventory เต็มจริง ๆ — capacity 2 potion stack ละ 10 เท่าเดิม เพิ่มทีเดียว 25 ชิ้น:
Inventory small = new Inventory(2);
int leftover2 = small.AddItem(healthPotion, 25);
Debug.Log($"leftover: {leftover2}");
leftover: 5
2 slot × max 10 = ที่ว่างรวม 20 เราพยายามใส่ 25 ใส่ได้ 20 อีก 5 ใส่ไม่ได้ — AddItem ส่ง 5 นี้กลับมาเป็น return value แทนที่จะทำหายไปเงียบ ๆ ผู้เรียกใช้ (caller) เป็นคนตัดสินใจว่าจะทำยังไงกับมัน จะทิ้งลงพื้น จะโชว์ popup "inventory เต็ม" หรือจะปฏิเสธการเก็บของไปเลยก็ได้
การเอาออกต้องมีขั้นตอนเพิ่มอีกหนึ่งขั้นที่มือใหม่มักข้าม: เช็คก่อนว่ามีของพอจริง ๆ ก่อนจะเริ่มลบ recipe crafting ที่ต้องใช้แร่เหล็ก 3 กับไม้ 2 ไม่ควรใช้แร่เหล็กไป 3 ก่อนแล้วค่อยพบว่าไม้ไม่พอ — จะทำให้ inventory ของผู้เล่นอยู่ในสถานะที่ใช้ไปครึ่งเดียวและผิดพลาด
public bool RemoveItem(ItemDefinition item, int amount)
{
int have = 0;
foreach (ItemStack slot in slots)
if (slot.definition == item)
have += slot.count;
if (have < amount)
return false; // not enough -- change nothing
for (int i = 0; i < slots.Count && amount > 0; i++)
{
ItemStack slot = slots[i];
if (slot.definition == item)
{
int take = Mathf.Min(slot.count, amount);
slot.count -= take;
amount -= take;
if (slot.count == 0)
slot.definition = null; // slot becomes empty again, ready for reuse
}
}
return true;
}
ลอง trace บน inventory จากหัวข้อ 5 (slot 0 = Health Potion x10, slot 1 = Health Potion x3, slot 2 ว่าง) เรียก RemoveItem(healthPotion, 12):
bool ok = inv.RemoveItem(healthPotion, 12);
Debug.Log($"removed ok: {ok}");
for (int i = 0; i < inv.slots.Count; i++)
{
ItemStack s = inv.slots[i];
Debug.Log($"slot {i}: {(s.IsEmpty ? "empty" : $"{s.definition.itemName} x{s.count}")}");
}
removed ok: True
slot 0: empty
slot 1: Health Potion x1
slot 2: empty
เรามี potion รวม 13 ชิ้น (10 + 3) แล้วขอลบ 12: slot 0 เสีย 10 ชิ้นทั้งหมดจนว่าง slot 1 เสีย 2 จาก 3 ชิ้น เหลือ 1 ถ้าเราขอลบ 20 ชิ้น have จะเท่ากับ 13 ซึ่งน้อยกว่า 20 method จึงจะ return false และไม่แตะอะไรเลย
Equipment เป็นรูปแบบการเก็บข้อมูลที่ต่างจากกระเป๋า (backpack) กระเป๋าคือ slot ที่ใช้แทนกันได้จำนวนมาก ใส่ตามลำดับไหนก็ได้ ส่วน Equipment คือชุด slot เล็ก ๆ ที่ ตายตัวและมีชื่อ — slot Weapon หนึ่งอัน, slot Head หนึ่งอัน, slot Chest หนึ่งอัน — แต่ละอันถือ item ได้อย่างมากแค่ชิ้นเดียว Dictionary ที่ key เป็น slot type เหมาะกับงานนี้มาก เพราะเราค้นหาด้วยชื่อ (Weapon, Head, ...) ไม่ใช่ด้วย index:
using System.Collections.Generic;
public enum EquipmentSlotType { Weapon, Head, Chest, Accessory }
public class Equipment
{
private Dictionary<EquipmentSlotType, ItemDefinition> equipped =
new Dictionary<EquipmentSlotType, ItemDefinition>();
public ItemDefinition Get(EquipmentSlotType slot)
{
equipped.TryGetValue(slot, out ItemDefinition item);
return item; // null if nothing is equipped there
}
public ItemDefinition Equip(EquipmentSlotType slot, ItemDefinition item)
{
equipped.TryGetValue(slot, out ItemDefinition previous);
equipped[slot] = item;
return previous; // whatever WAS there -- caller decides where it goes
}
}
Equip ไม่เคยทำลาย item เดิมทิ้ง — มันส่งกลับคืนให้ caller เพื่อเอาไปเก็บไว้ที่ที่เหมาะสม ส่วนใหญ่ก็คือกลับเข้าไปใน backpack inventory นี่คือตัวอย่างการสลับ item แบบเต็ม ๆ ที่เอา Inventory กับ Equipment มาต่อกัน:
Equipment gear = new Equipment();
Inventory backpack = new Inventory(5);
backpack.AddItem(ironSword, 1);
backpack.RemoveItem(ironSword, 1);
ItemDefinition previous = gear.Equip(EquipmentSlotType.Weapon, ironSword);
Debug.Log($"equipped: {gear.Get(EquipmentSlotType.Weapon).itemName}");
Debug.Log($"previous weapon: {(previous == null ? "none" : previous.itemName)}");
// later, equip a second weapon
backpack.AddItem(steelAxe, 1);
backpack.RemoveItem(steelAxe, 1);
ItemDefinition oldWeapon = gear.Equip(EquipmentSlotType.Weapon, steelAxe);
backpack.AddItem(oldWeapon, 1); // the iron sword goes back into the backpack
Debug.Log($"equipped now: {gear.Get(EquipmentSlotType.Weapon).itemName}");
Debug.Log($"backpack slot 0: {backpack.slots[0].definition.itemName} x{backpack.slots[0].count}");
equipped: Iron Sword
previous weapon: none
equipped now: Steel Axe
backpack slot 0: Iron Sword x1
การใส่ครั้งแรกไม่มีอะไรให้สลับออกมา previous จึงเป็น null การใส่ครั้งที่สองส่ง Iron Sword กลับมา ซึ่ง caller ส่งต่อให้ AddItem ทันที — ไม่มีอะไรหายไปเลย มันแค่ย้ายไปมาระหว่าง container สองตัว
ItemStack ต่อ slot แทนที่จะเก็บ ItemDefinition เปล่า ๆ เพื่อให้ดาบที่ใส่อยู่มี durability ของตัวเอง แยกจากเล่มสำรองที่วางอยู่ใน backpackความผิดพลาดยอดฮิตของมือใหม่คือเขียน item logic ไว้ ใน UI — คำนวณเรื่อง stack อยู่ใน drag-and-drop handler หรือปุ่ม "Use Potion" ที่ไปแก้ field ตรง ๆ กฎที่ต้องยึดไว้คือ: data เป็นตัวขับเคลื่อนการแสดงผล; UI ไม่เคยตัดสินใจกฎของเกม มันแค่โชว์สิ่งที่ data บอกอยู่ตอนนั้น Inventory ควรทำงานได้สมบูรณ์แบบโดยไม่มี UI เลยแม้แต่นิดเดียว (ซึ่งแปลว่าคุณ test มันได้โดยไม่ต้องกด Play เลยด้วย) หน้าที่เดียวของ UI คือ: อ่าน slot ปัจจุบัน วาดมันออกมา แล้วส่ง input ของผู้เล่น (เช่น "ใช้ slot 2") กลับไปหา Inventory เป็น method call
วิธีเชื่อมที่สะอาดที่สุดคือ event (วิธีให้ object หนึ่งประกาศว่า "มีอะไรบางอย่างเกิดขึ้น" โดยไม่ต้องรู้ว่าใครกำลังฟังอยู่หรือเปล่า): Inventory จะยิง OnChanged ทุกครั้งที่ slot เปลี่ยน แล้ว UI ก็แค่วาดตัวเองใหม่ทุกครั้งที่ได้ยินสัญญาณนั้น
public class Inventory
{
public event System.Action OnChanged;
public List<ItemStack> slots = new List<ItemStack>();
// ... capacity, AddItem, RemoveItem as before ...
public int AddItem(ItemDefinition item, int amount)
{
// ... same two-pass logic as section 5 ...
OnChanged?.Invoke(); // tell any listeners: something changed, redraw
return amount;
}
}
using UnityEngine;
public class InventoryUI : MonoBehaviour
{
public Inventory inventory; // just reads the data, never edits slots directly
public SlotView[] slotViews; // one pre-made UI element per capacity slot
void OnEnable()
{
inventory.OnChanged += Redraw;
Redraw();
}
void OnDisable()
{
inventory.OnChanged -= Redraw;
}
void Redraw()
{
for (int i = 0; i < slotViews.Length; i++)
{
ItemStack stack = inventory.slots[i];
if (stack.IsEmpty)
slotViews[i].SetDisplay(null, "");
else
slotViews[i].SetDisplay(stack.definition.icon, stack.count.ToString());
}
}
public void OnSlotClicked(int index)
{
// forward the click as a request -- Inventory decides what it means, not the UI
inventory.UseSlot(index);
}
}
ด้วยการแยกแบบนี้ คุณสามารถทิ้ง UI ทั้งชุดแล้วสร้างใหม่ที่ต่างไปเลยก็ได้ (grid ที่เล่นด้วยจอย, layout กระชับสำหรับมือถือ) โดย Inventory ไม่ต้องเปลี่ยนอะไรเลย คุณยังเขียน automated test ที่ add แล้ว remove item แล้วเช็คผลลัพธ์ของ slot ได้ โดยไม่ต้องมี GameObject ไม่ต้องมี scene และไม่ต้องเข้า Play mode เลยด้วย
Inventorysave file ไม่ใช่เกมที่กำลังรันอยู่ — มันคือ text หรือ byte ที่วางอยู่บนดิสก์ (หรือบน server) นานหลังจากเกมปิดไปแล้ว คุณเอา C# object reference ที่ยัง live อยู่ใส่ลงไปไม่ได้ เพราะไม่มีอะไรอยู่อีกฝั่งให้ reference นั้นชี้ไปหาเมื่อเกม restart ใหม่ ดังนั้น save จะเก็บ data ล้วน ๆ: สำหรับแต่ละ slot คือ id string ของ item (จากหัวข้อ 2) กับ count — ไม่มีอย่างอื่นแล้ว
[System.Serializable]
public class ItemStackSaveData
{
public string id; // e.g. "potion_health_01" -- NOT an object reference
public int count;
}
[System.Serializable]
public class InventorySaveData
{
public List<ItemStackSaveData> slots = new List<ItemStackSaveData>();
}
การแปลง slot ที่ live อยู่ไปเป็น save data แล้วแปลงกลับ ต้องมี lookup table จาก id ไปยัง asset ItemDefinition จริง ๆ — ปกติจะสร้างครั้งเดียวตอนเกมเริ่ม โดยสแกน item asset ทุกตัวในโปรเจกต์ใส่ลงใน Dictionary<string, ItemDefinition>:
public InventorySaveData ToSaveData()
{
InventorySaveData data = new InventorySaveData();
foreach (ItemStack slot in slots)
{
data.slots.Add(new ItemStackSaveData
{
id = slot.IsEmpty ? "" : slot.definition.id,
count = slot.count
});
}
return data;
}
public void LoadFromSaveData(InventorySaveData data, Dictionary<string, ItemDefinition> itemDatabase)
{
for (int i = 0; i < slots.Count && i < data.slots.Count; i++)
{
ItemStackSaveData entry = data.slots[i];
if (string.IsNullOrEmpty(entry.id))
{
slots[i].definition = null;
slots[i].count = 0;
}
else
{
slots[i].definition = itemDatabase[entry.id]; // look the real asset back up
slots[i].count = entry.count;
}
}
}
ลอง trace บน inventory จากหัวข้อ 6 (slot 0 ว่าง, slot 1 = Health Potion x1, slot 2 ว่าง):
InventorySaveData data = inv.ToSaveData();
Debug.Log(JsonUtility.ToJson(data));
{"slots":[{"id":"","count":0},{"id":"potion_health_01","count":1},{"id":"","count":0}]}
JSON บรรทัดเดียวนี้ (JavaScript Object Notation — data แบบ key/value เป็น text ล้วน ๆ เขียนลงไฟล์หรือส่งไป server ได้ง่าย) คือทุกอย่างที่ต้องใช้ในการสร้าง slot ชุดเดิมเป๊ะ ๆ กลับมาทีหลัง: ไม่มี icon ไม่มี description ไม่มี C# object reference มีแค่ id กับตัวเลข ตอน load itemDatabase["potion_health_01"] จะส่ง reference ของ HealthPotion.asset ตัวเดียวที่ใช้ร่วมกันกลับมา แล้ว slot ก็ชี้ไปที่มันอีกครั้ง — เหมือนกับตอนก่อน save เป๊ะ ๆ
id string ที่พิมพ์เอง instance ID ใช้ได้แค่ตอนรันครั้งนั้น ๆ พอรันใหม่ก็เปลี่ยนไปอีก asset GUID ก็เปลี่ยนได้ถ้าคุณจัดระเบียบโปรเจกต์ใหม่ ขึ้นอยู่กับ workflow ของคุณ และทั้งสองอย่างไม่มีความหมายอะไรเลยสำหรับ server ที่ไม่เคยเปิดโปรเจกต์ Unity มาก่อน id string สั้น ๆ ที่คุณควบคุมเองทั้งหมด (และไม่เปลี่ยนอีกเลยเมื่อผู้เล่นมีมันอยู่ใน save แล้ว) คือสิ่งเดียวที่ปลอดภัยพอจะเก็บไว้ระยะยาว — มันยังใช้ได้เหมือนเดิมด้วยถ้า inventory ของคุณต้อง sync กับ server ทีหลัง ซึ่งเกม gacha แทบทุกเกมต้องทำแบบนั้นเกมแบบนี้อาจมี ItemDefinition asset เป็นพัน ๆ ตัว (weapon, relic, material สำหรับ ascension, material เฉพาะของตัวละครแต่ละตัว) และ inventory ของผู้เล่นคนเดียวอาจมี stack เป็นร้อย ๆ method ทั้งหมดที่พูดมายังทำงานได้ที่ scale นี้ แต่มีนิสัยบางอย่างที่ช่วยให้มันเร็วแทนที่จะช้าและกระตุก
ต้นทุนแรกที่ต้องระวัง: "ผู้เล่นมีแร่เหล็กอย่างน้อย 5 ชิ้นไหม" ตามที่เขียนใน RemoveItem จะสแกนทุก slot — O(n) (ใช้ Big-O notation จากบท data structures) ครั้งเดียวก็ไม่เป็นไร แต่หน้าจอ crafting ที่เช็ควัตถุดิบสิบห้าอย่าง ทุกเฟรม ตอนผู้เล่นเอาเมาส์ไปวางเหนือ recipe นั้นกำลังสแกน inventory เต็มรูปแบบสิบห้ารอบต่อเฟรม ทางแก้คือแบบเดียวกับในบทนั้น: เก็บ index เสริมไว้ เป็น Dictionary<string, int> ที่ map item id ไปยัง count รวม อัปเดตทีละนิดทุกครั้งที่ slot เปลี่ยน:
private Dictionary<string, int> countByItemId = new Dictionary<string, int>();
public int GetTotalCount(ItemDefinition item)
{
countByItemId.TryGetValue(item.id, out int total);
return total; // O(1) average, instead of scanning every slot
}
// inside AddItem, after slot.count += add:
// countByItemId[item.id] = countByItemId.GetValueOrDefault(item.id) + add;
// inside RemoveItem, after slot.count -= take:
// countByItemId[item.id] -= take;
ต้นทุนที่สองอยู่ฝั่ง UI ไม่ใช่ฝั่ง data: การวาดหน้าจอ inventory ขนาดใหญ่ใหม่ด้วยการ destroy แล้ว instantiate GameObject ใหม่ต่อ slot ทุกครั้งที่มีอะไรเปลี่ยน ทำให้ช้าและสร้าง garbage (memory ที่ถูกจองแล้วถูกทิ้ง ซึ่ง garbage collector ต้องมาเก็บกวาดทีหลัง อาจทำให้เฟรมกระตุกให้เห็นชัด) แทนที่จะทำแบบนั้น ให้สร้าง UI element ของ slot ไว้ครั้งเดียว — เป็น pool ตายตัว — แล้วอัปเดตแค่ icon กับ text เท่านั้น เหมือนกับที่ InventoryUI.Redraw() ในหัวข้อ 8 ทำอยู่แล้ว pattern นี้ scale ไปถึงหน้าจอที่ scroll list ของ material เป็นพันชิ้นได้โดยไม่ต้อง allocate อะไรเลยต่อเฟรม
นิสัยสุดท้าย: หลีกเลี่ยงโค้ดสะดวก ๆ ที่แอบ allocate ใน hot path query บรรทัดเดียวที่เขียนด้วย LINQ (library แบบ query-style เช่น slots.Where(...).Sum(...)) อ่านง่ายดี แต่มัน allocate object เพิ่มทุกครั้งที่เรียก loop for ธรรมดา หรือ dictionary index ด้านบนทำงานแบบเดียวกันโดยไม่มีต้นทุนแอบแฝง เรื่องนี้ไม่สำคัญเลยสำหรับโปรเจกต์ทดสอบที่มีแค่ห้า item — แต่สำคัญมากเมื่อ inventory มี stack เป็นร้อยและมี UI ที่เช็คมันทุกเฟรม
AddItem แค่ครั้งเดียวต่อการเก็บของ คือแรงที่เสียไปกับต้นทุนที่ไม่เคยมีอยู่จริง[CreateAssetMenu] ที่วางไว้เหนือ class หรือ member เพื่อบอก Unity (หรือ editor) ให้จัดการมันเป็นพิเศษInventory) ประกาศว่ามีอะไรเกิดขึ้น โดยไม่ต้องรู้ว่ามี listener อยู่หรือเปล่าDictionary<string, int> ที่เก็บไว้คู่กับ slot list เพื่อให้ "มี item X กี่ชิ้น" เป็น O(1) lookup แทนที่จะเป็น O(n) scanInventory ที่ว่างเปล่ามี capacity 2 "Wood" มี maxStackSize = 20 ให้ trace สอง call นี้ แล้วบอกเนื้อหาสุดท้ายของทั้งสอง slot บวกกับ return value ของแต่ละ call
inv.AddItem(wood, 15);
int leftover = inv.AddItem(wood, 30);
Call ที่ 1, AddItem(wood, 15): รอบ 1 ไม่เจออะไรให้เติม (ทั้งสอง slot ว่างเปล่า) รอบ 2 เติม slot 0: add = min(20, 15) = 15 slot 0 กลายเป็น wood x15 และ amount เหลือ 0 call นี้ return 0
Call ที่ 2, AddItem(wood, 30): รอบ 1 เจอ slot 0 มี wood อยู่และยังมีที่เหลือ (15 < 20) ที่เหลือคือ 20 - 15 = 5 จึงเติม min(5, 30) = 5 slot 0 กลายเป็น wood x20 และ amount ลดจาก 30 เหลือ 25 รอบ 2 เจอ slot 1 ว่าง เติม min(20, 25) = 20 ทำให้ slot 1 เป็น wood x20 เหลือ amount = 5 5 นี้ใส่ไม่ได้ที่ไหนอีก (ทั้งสอง slot เต็มที่ 20/20 แล้ว) call จึง return 5
สถานะสุดท้าย: slot 0 = wood x20, slot 1 = wood x20, leftover จาก call ที่ 2 = 5 ตรงกับที่ว่างรวม: 2 slot × 20 = capacity 40 ส่วนที่ขอใส่คือ 15 + 30 = 45 จึงมี 5 ที่ไม่มีที่ไป
ItemDefinition กับสคริปต์เก็บของนี้ ให้อธิบายว่าจะเกิดอะไรผิดพลาดเมื่อของเก็บ potion หลายจุดใน scene อ้างถึง HealthPotion.asset ตัวเดียวกัน แล้วบอกว่าคุณจะแก้อย่างไร
[CreateAssetMenu(menuName = "Inventory/Item Definition")]
public class ItemDefinition : ScriptableObject
{
public string itemName;
public int count; // teammate added this to track how many the player has
}
public class Pickup : MonoBehaviour
{
public ItemDefinition item;
void OnTriggerEnter(Collider other)
{
item.count += 1;
Destroy(gameObject);
}
}
bug นี้คือความผิดพลาดตัวเดียวกับในกล่องเตือนของหัวข้อ 3 เป๊ะ ๆ: count ถูกเพิ่มไว้บน definition แต่ ItemDefinition เป็น asset ตัวเดียวที่ใช้ร่วมกัน Pickup ทุกตัวใน scene ที่อ้างถึง HealthPotion.asset ตัวเดียวกัน กำลังแก้ field count ตัวเดียวกันบน object ตัวเดียวกันเป๊ะ ๆ การเดินผ่านจุดเก็บ potion สามจุดแยกกัน ไม่ได้ทำให้ผู้เล่นได้ "potion 1 ชิ้นในสามที่ต่างกัน" — มันแค่เพิ่มตัวเลขตัวเดียวที่ใช้ร่วมกันเป็น 3 โดยไม่รู้เลยว่าเป็นของ slot ไหน ผู้เล่นคนไหน หรือ inventory ไหน ถ้าเป็นเกม multiplayer ที่ผู้เล่นสองคนเก็บ potion พร้อมกัน ทั้งคู่ก็จะแย่งกันแก้ตัวเลขเดียวกันด้วยซ้ำ และถ้าทดสอบด้วยการกด Play ใน editor Unity อาจปล่อยให้ค่าที่เพิ่มไปแล้วถูกเขียนลงใน .asset file จริง ๆ หลังจากหยุด Play mode ทำให้ข้อมูลต้นฉบับเสียหาย
วิธีแก้: เอา count ออกจาก ItemDefinition ไปเลย มันไม่ควรอยู่บน definition ให้ผู้เล่นมี Inventory (หัวข้อ 4) แล้วให้ Pickup เรียก inventory.AddItem(item, 1) แทนที่จะไปแตะ definition count ก็จะไปอยู่บน ItemStack instance ภายใน slot ที่เจาะจงของ inventory ที่เจาะจง ตรงตามที่มันควรจะอยู่
{"slots":[{"id":"ore_iron_01","count":40},{"id":"","count":0},{"id":"sword_iron_01","count":1}]}
อธิบายด้วยคำพูดของคุณเองว่า LoadFromSaveData ต้องใช้อะไรบ้างเพื่อสร้าง slot จริงกลับมาจากข้อมูลนี้ และทำไมการเก็บข้อความที่โชว์อย่าง "Iron Sword" แทน id "sword_iron_01" จะเป็นทางเลือกที่แย่กว่าในระยะยาว
เพื่อสร้าง slot กลับมา LoadFromSaveData ต้องมี itemDatabase: Dictionary<string, ItemDefinition> ที่ map id ของแต่ละ item ไปยัง asset จริงของมัน ปกติสร้างครั้งเดียวตอนเกมเริ่ม โดยสแกน ItemDefinition asset ทุกตัวในโปรเจกต์ จากนั้นมันจะ loop ผ่าน entry ที่ save ไว้ตามลำดับ: entry 0 มี id "ore_iron_01" slot 0 จึงได้ itemDatabase["ore_iron_01"] กับ count 40; entry 1 มี id ว่าง slot 1 จึงยังว่างอยู่ (definition = null, count = 0); entry 2 มี id "sword_iron_01" slot 2 จึงได้ definition นั้นกับ count 1
การเก็บชื่อที่โชว์ "Iron Sword" แทน id จะแย่กว่าด้วยเหตุผลหลายข้อ: ชื่อที่โชว์ถูกออกแบบมาให้เปลี่ยนได้อิสระ (เปลี่ยนชื่อ, redesign, แปลเป็นภาษาอื่น) ในขณะที่ id ถูกออกแบบมาให้ไม่เปลี่ยนเลยเมื่อมันถูกปล่อยไปอยู่ใน save แล้ว ถ้า designer เปลี่ยนชื่อ itemName เป็น "Iron Longsword" ทีหลังเพื่อความสวยงาม save file ใดก็ตามที่ match ด้วยข้อความเดิมจะหา item ไม่เจอแบบเงียบ ๆ ในขณะที่ match ด้วย id = "sword_iron_01" ยังใช้ได้ปกติเพราะ id ไม่เคยเปลี่ยน การ match ด้วยข้อความยังเปราะบางด้วย (ตัวพิมพ์ใหญ่เล็ก, ช่องว่าง, เครื่องหมายวรรคตอน) และเกมที่แปลหลายภาษาจะมีชื่อที่โชว์ต่างกันไปในแต่ละภาษา ซึ่งใช้เป็น lookup key ที่มั่นคงข้ามทุกภาษาไม่ได้เลย
นี่คือ inventory system แบบครบชุด: asset ItemDefinition ที่ใช้ร่วมกันต่อหนึ่ง item, instance ItemStack ต่อหนึ่ง slot, inventory แบบ List<ItemStack> ที่ add แบบสองรอบและ remove แบบที่เช็คก่อน, ชุด equipment slot เล็ก ๆ ที่ตายตัวมีชื่อ, UI ที่อ่านและวาดใหม่อย่างเดียว, save data ที่สร้างจาก id กับ count และ count index สำหรับตอนที่ inventory ใหญ่ขึ้น แนวคิดหลักที่ควรพกติดตัวไปต่อ: แยก data ที่ใช้ร่วมกันว่า "item นี้คืออะไร" ออกจาก data ที่ผูกกับผู้เล่นแต่ละคนว่า "มีกี่ชิ้น" แล้วให้ data เป็นตัวขับเคลื่อนทุกอย่างที่สร้างขึ้นบนฐานนั้น