ตอนนี้คุณเขียน C# class และใส่ค่าลงใน field ได้แล้ว บทนี้จะถามคำถามที่ต่างออกไป: ค่าพวกนั้นควรอยู่ตรงไหน? ในทุกตัวอย่างที่ผ่านมา ตัวเลขถูกใส่ลงไปในสคริปต์ตรง ๆ เลย — hp ของศัตรูถูกพิมพ์เป็นตัวเลขอยู่ในสคริปต์ Enemy เอง วิธีนี้โอเคสำหรับ prototype ห้านาที แต่มันพังทันทีที่เกมจริงมีศัตรูห้าสิบแบบ หรือตอนที่ game designer (คนที่ไม่ได้เขียน C#) ต้องการเปลี่ยนค่า hp แต่รอโปรแกรมเมอร์ไม่ได้ บทนี้จะพูดถึง data-driven design (เก็บ content ไว้ในข้อมูล แทนที่จะ hard-code ลงในสคริปต์), เครื่องมือ ScriptableObject ของ Unity สำหรับเรื่องนี้ แล้วจากนั้นจะพูดถึงอีกครึ่งของหัวข้อคือ: การ save ความคืบหน้าของผู้เล่นลงดิสก์ และทำยังไงให้ save นั้นยังใช้งานได้หลังจากคุณ patch เกม
Data-driven design หมายถึงการที่ตัวเลขและ content เฉพาะของเกม (hp ของศัตรู, damage ของอาวุธ, ข้อความบทสนทนา, ทองที่หีบดรอปออกมา) อยู่ใน data — ไฟล์หรือ asset แยกต่างหาก — แทนที่จะพิมพ์เป็นตัวเลขตรง ๆ อยู่ในสคริปต์ C# ของคุณ สคริปต์จะกลายเป็นแบบ generic คือมันอ่าน data อะไรก็ตามที่ถูกส่งมาให้แล้วทำงานตามนั้น มันไม่รู้และไม่สนใจว่ากำลังรันให้ goblin หรือ dragon อยู่ มันแค่อ่าน maxHealth จาก data object อะไรก็ตามที่ถูกส่งเข้ามา
สิ่งตรงข้ามคือ hard-coding: การเขียนตัวเลขจริงลงไปในโค้ดตรง ๆ เช่น int maxHealth = 20; ที่อยู่ในสคริปต์ชื่อ Goblin ถ้าอยากได้ orc ด้วย วิธีที่มือใหม่มักทำคือ copy สคริปต์นั้นแล้วแก้ตัวเลข ทำให้ได้ Goblin.cs, Orc.cs, Dragon.cs — สามสคริปต์ที่เหมือนกัน 90% ต่างกันแค่ตัวเลข
การ balance เกม (ปรับตัวเลขอย่าง damage, hp, ราคา จนกว่าเกมจะรู้สึกแฟร์และสนุก) ไม่ใช่งานที่ทำครั้งเดียวจบ คุณ playtest แล้วพบว่าการต่อสู้ง่ายเกินไป ก็ลด hp ศัตรูลง 10% แล้ว playtest ใหม่อีกครั้ง ทำแบบนี้ซ้ำเป็นสิบ ๆ รอบต่อศัตรูหนึ่งตัว ถ้าทุกครั้งที่ปรับต้องเปิดไฟล์ C#, แก้ตัวเลข, แล้วรอ Unity recompile สคริปต์ วงจรนี้จะช้ามาก — และหมายความว่ามีแต่โปรแกรมเมอร์เท่านั้นที่ทำได้
ด้วย data-driven design ตัวเลขจะอยู่ใน asset ที่ designer เปิดและแก้ไขได้ตรง ๆ ใน Unity Editor โดยไม่ต้องแตะโค้ดและไม่ต้อง recompile เลย สิ่งนี้ส่งผลใหญ่สองอย่าง: โปรแกรมเมอร์กับ designer ทำงานพร้อมกันได้โดยไม่เหยียบไฟล์กันเอง และการ balance เร็วพอที่จะทำซ้ำเป็นสิบ ๆ รอบได้ในบ่ายเดียว มันยังทำให้การเพิ่ม content ถูกลงด้วย — ศัตรูแบบใหม่คือ data asset ใหม่ที่มีตัวเลขใหม่ ไม่ใช่สคริปต์ใหม่
MonoBehaviour (base class ที่คุณใช้เขียนสคริปต์บน GameObject อยู่แล้ว) จะอยู่ติดกับ object หนึ่งใน scene หนึ่งเสมอ ส่วน ScriptableObject ต่างออกไป: มันคือ class ที่เก็บ data ในรูปแบบ asset — ไฟล์ที่อยู่ใน Project folder ของคุณ ไม่ได้ติดอยู่กับ GameObject หรือ scene ไหนเลย คุณสร้างมันครั้งเดียว แล้ว GameObject กี่ตัวก็ได้ ใน scene กี่ scene ก็ได้ สามารถชี้ไปที่ asset ตัวเดียวกันนั้นแล้วอ่านค่าของมันได้
มีสองอย่างที่ทำให้ ScriptableObject สะดวกสำหรับ data-driven design อย่างแรก attribute [CreateAssetMenu] ทำให้ designer คลิกขวาใน Project window แล้วเลือก Create เพื่อสร้าง instance ใหม่ของ data นั้นได้เลย — ไม่ต้องแตะโค้ด อย่างที่สอง เพราะมันคือ asset จริง ๆ มันจะโผล่ใน Inspector เหมือน Unity object ทั่วไป มี view สำหรับแก้ทีละ field ที่ designer คุ้นเคยอยู่แล้ว
ScriptableObject ไม่ได้ติดอยู่กับอะไรเลย และไม่มี Update() ถูกเรียกเลย — มันแค่นั่งเก็บค่าไว้เฉย ๆ จนกว่าจะมีอะไรมาขอค่านั้น ทำให้มันเหมาะกับ content ที่ไม่เปลี่ยนระหว่างเกมรัน เช่น ค่าพื้นฐานของศัตรู, คำอธิบายไอเทม, damage ของอาวุธ แต่มันไม่เหมาะกับอะไรก็ตามที่เปลี่ยนทุกเฟรม เช่น health bar ที่ขยับสด ๆ (นั่นควรอยู่ในสคริปต์ปกติ อย่างที่จะเห็นในหัวข้อถัดไป)
นี่คือรูปแบบทั่วไปที่มือใหม่มักทำ: หนึ่งสคริปต์ต่อศัตรูหนึ่งแบบ ตัวเลข hard-code ลงไปตรง ๆ ในแต่ละตัว
using UnityEngine;
public class Goblin : MonoBehaviour
{
public int maxHealth = 20;
public int damage = 5;
public float moveSpeed = 2f;
public int goldReward = 10;
}
public class Orc : MonoBehaviour
{
public int maxHealth = 60;
public int damage = 12;
public float moveSpeed = 1.2f;
public int goldReward = 30;
}
ศัตรูแบบใหม่ทุกตัวหมายถึงสคริปต์ใหม่ที่หน้าตาแทบเหมือนตัวก่อนหน้าทุกอย่าง การเปลี่ยน hp ของ goblin หมายถึงต้องเปิด Goblin.cs, แก้ตัวเลข, แล้ว recompile มาแก้เรื่องนี้ด้วย ScriptableObject ที่เก็บ data ไว้ เพื่อให้เราต้องใช้สคริปต์ศัตรูแค่ ตัวเดียว ตลอดไป
using UnityEngine;
[CreateAssetMenu(fileName = "NewEnemyData", menuName = "Data/Enemy")]
public class EnemyData : ScriptableObject
{
public string enemyName;
public int maxHealth;
public int damage;
public float moveSpeed;
public int goldReward;
}
พอสคริปต์นี้ถูก save ไว้ในโปรเจกต์ designer ก็แค่คลิกขวาใน Project window เลือก Create > Data > Enemy แล้วจะได้ไฟล์ asset ใหม่มา ทำแบบนี้สองครั้ง ตั้งชื่อ asset ว่า Goblin กับ Orc แล้วกรอกค่าใน field ต่าง ๆ ใน Inspector — ไม่ต้องแตะโค้ดเลยสักบรรทัด
ทีนี้เขียนสคริปต์ Enemy ตัวเดียวที่ GameObject ศัตรูตัวไหนก็ใช้ได้ มันเก็บ reference ไปยัง EnemyData asset แล้วอ่านค่าจากมัน
using UnityEngine;
public class Enemy : MonoBehaviour
{
public EnemyData data; // shared asset reference, read-only
private int currentHealth; // per-instance state, lives HERE, not in the asset
private void Awake()
{
currentHealth = data.maxHealth;
}
public void TakeDamage(int amount)
{
currentHealth -= amount;
if (currentHealth <= 0)
{
Die();
}
}
private void Die()
{
Debug.Log(data.enemyName + " died. Reward: " + data.goldReward + " gold.");
Destroy(gameObject);
}
}
ลาก Enemy script ไปวางบน GameObject แล้วลาก asset Goblin เข้าไปในช่อง data ของมัน คุณก็จะได้ goblin ตัวหนึ่ง ถ้าลาก Orc เข้าไปในช่อง data ของ GameObject อีกตัวแทน ตัวนั้นก็จะกลายเป็น orc — สคริปต์เดียวกัน แต่ data ต่างกัน ลองไล่ดูว่าจะเกิดอะไรขึ้นถ้า data ของ goblin ตัวนี้มี enemyName = "Goblin", maxHealth = 20, goldReward = 10 แล้วมีอะไรบางอย่างเรียก TakeDamage(25) หนึ่งครั้ง: currentHealth เริ่มที่ 20 ลดลงเหลือ -5 ซึ่งเป็นศูนย์หรือน้อยกว่า ดังนั้น Die() จะทำงานแล้วพิมพ์:
Goblin died. Reward: 10 gold.
currentHealth) ไว้ใน ScriptableObject เอง GameObject ทุกตัวที่ใช้ asset Goblin ร่วมกัน กำลังใช้ asset ตัวเดียวกันใน memory จริง ๆ — ถ้าคุณเขียน data.currentHealth -= amount แทน การทำ damage ให้ goblin ตัวหนึ่งจะไปทำ damage ให้ goblin ทุกตัวที่ใช้ asset นั้นด้วย เพราะทุกตัวชี้ไปที่ object เดียวกัน ให้เก็บ state ที่เปลี่ยนได้เฉพาะของแต่ละตัวไว้ใน MonoBehaviour (อย่าง currentHealth ข้างบน) และเก็บเฉพาะ content ที่ใช้ร่วมกันและไม่เปลี่ยนแปลงไว้ใน data asset เท่านั้นScriptableObject เจ๋งดี แต่มันก็ยังเป็น Unity asset ที่ต้องแก้ใน Unity Editor อยู่ดี บางครั้ง data ของคุณต้องอยู่นอก Unity ไปเลย เช่น spreadsheet ขนาดใหญ่ของค่าไอเทมที่ designer ดูแลอยู่ใน Google Sheets หรือ list ที่ backend ของเกม live-service ส่งลงมาให้หลังเกมเปิดตัวแล้ว สำหรับกรณีแบบนี้ เกมส่วนใหญ่มักใช้ text format ธรรมดาสองแบบคือ JSON กับ CSV
JSON (JavaScript Object Notation) เป็น text format สำหรับ structured data เขียนเป็นคู่ key-value อยู่ใน curly brace โดยใช้ square bracket สำหรับ list ส่วน CSV (Comma-Separated Values) เป็นตารางแบบ plain text: หนึ่งแถวต่อหนึ่งบรรทัด ค่าคั่นด้วย comma — ตรงกับสิ่งที่ได้จากการ export spreadsheet เป๊ะ ๆ ใน Unity ไฟล์ JSON หรือ CSV จะถูก import เข้ามาเป็น TextAsset (asset type ที่เก็บ raw text เฉย ๆ) ซึ่งคุณอ่านได้ในสคริปต์
นี่คือ item database เล็ก ๆ ในรูปแบบไฟล์ JSON พร้อมสคริปต์ C# ที่โหลดมัน สังเกตว่าทั้งไฟล์เป็น JSON object หนึ่งตัวที่มี field items เก็บ list ไว้ — ตัวอ่าน JSON ของ Unity parse list เปล่า ๆ ที่ top level ไม่ได้ เราเลยต้องห่อมันไว้
{
"items": [
{ "itemId": "potion_small", "displayName": "Small Potion", "baseValue": 10 },
{ "itemId": "sword_iron", "displayName": "Iron Sword", "baseValue": 120 }
]
}
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public class ItemDefinitionData
{
public string itemId;
public string displayName;
public int baseValue;
}
[System.Serializable]
public class ItemDatabase
{
public List<ItemDefinitionData> items;
}
public class ItemDatabaseLoader : MonoBehaviour
{
public TextAsset itemsJsonFile; // drag the .json file onto this slot in the Inspector
private void Start()
{
ItemDatabase db = JsonUtility.FromJson<ItemDatabase>(itemsJsonFile.text);
Debug.Log("Loaded " + db.items.Count + " items.");
}
}
Loaded 2 items.
CSV ต้อง parse เองนิดหน่อยเพราะ Unity ไม่มี CSV reader มาให้ในตัว แต่ก็สั้นมาก: split แต่ละบรรทัดด้วย comma
string line = "sword_iron,Iron Sword,120";
string[] fields = line.Split(',');
string itemId = fields[0];
string displayName = fields[1];
int baseValue = int.Parse(fields[2]);
Debug.Log(itemId + " costs " + baseValue);
sword_iron costs 120
ทั้งสองแบบเป็น data-driven เหมือนกัน การเลือกใช้ขึ้นอยู่กับ workflow ไม่ใช่ว่าตัวไหน "ดีกว่า"
Sprite, audio clip, prefab), เมื่อ designer จะแก้มันใน Unity Editor ผ่าน Inspector ที่ใช้งานง่าย หรือเมื่อ content นั้นถูก bake ไปกับตัวเกมเลยและไม่ต้องเปลี่ยนหลังเกมปล่อยแล้วทุกอย่างที่ผ่านมา — EnemyData, ItemDatabase — คือ content: มันเหมือนกันสำหรับผู้เล่นทุกคนและไม่เปลี่ยนระหว่างเกมรัน (มันถูกส่งไปพร้อมกับตัวเกมเลย) Save data ต่างออกไป: มันคือ state เฉพาะของการเล่นรอบปัจจุบันของผู้เล่นคนหนึ่ง — level ของเขา, ทอง, inventory, ตำแหน่งที่ยืนอยู่, quest ไหนที่ทำเสร็จแล้ว content ถูกส่งไปพร้อมเกม ส่วน save data ถูกสร้างและเปลี่ยนแปลงระหว่างที่มีคนเล่นอยู่
Save หมายถึงการเขียน snapshot ของ game state ปัจจุบันลงไฟล์ เพื่อให้เอากลับมาใช้ได้ทีหลัง มีกฎอยู่ข้อหนึ่งที่สำคัญกว่าข้ออื่น ๆ ทั้งหมด: save เฉพาะค่าธรรมดา อย่า save reference ไปยัง live object เด็ดขาด GameObject, MonoBehaviour, Transform, Sprite — ทั้งหมดนี้มีอยู่แค่ตอนเกมกำลังรัน อยู่ใน memory ที่ Unity กำลังจัดการอยู่ พอเกมปิดปุ๊บ มันหายไปเลย reference ไปยังของพวกนี้จะไม่มีความหมายอะไรเลยตอนเกมเปิดครั้งถัดไป เพราะ object ตัวนั้นยังไม่เคยถูกสร้างขึ้นมาด้วยซ้ำ ให้ save ตัวเลขกับ string ที่อธิบาย state แทน: ตำแหน่งเป็น float สามตัว (x, y, z), ไอเทมใน inventory เป็น string ID กับจำนวน ไม่ใช่ GameObject ของไอเทมหรือ instance สคริปต์ของมัน
การแปลง live object ที่อยู่ใน memory ให้กลายเป็นรูปแบบที่เก็บได้ (เช่น text) เรียกว่า serialization การแปลงกลับมาเป็น object ที่ใช้งานได้เรียกว่า deserialization นี่คือสิ่งที่สองหัวข้อถัดไปจะสร้างขึ้นมาพอดี
อย่างแรก ออกแบบ data class ธรรมดาที่เก็บเฉพาะค่าที่คุณอยากจะ save เท่านั้น ไม่มีอะไรมากไปกว่านั้น class แบบนี้ที่มีหน้าที่แค่เก็บ data โดยไม่มี behavior จริงจังอะไร บางทีก็เรียกว่า POCO (Plain Old C# Object)
using System.Collections.Generic;
[System.Serializable]
public class InventoryItemData
{
public string itemId;
public int count;
}
[System.Serializable]
public class SaveData
{
public int saveVersion = 2;
public string playerName;
public int level;
public int gold;
public float posX;
public float posY;
public float posZ;
public List<InventoryItemData> inventory = new List<InventoryItemData>();
}
สังเกต saveVersion ที่อยู่บนสุด — หัวข้อ 9 จะอธิบายว่าทำไมมันถึงอยู่ตรงนั้น ทีนี้มารวบรวมค่าจาก live game ใส่เข้าไปใน class นี้ นี่คือจุดที่คุณ copy ตัวเลข ออกมาจาก live object ไม่ใช่ copy ตัว object เอง
using System.Collections.Generic;
using UnityEngine;
public class SaveController : MonoBehaviour
{
public PlayerStats playerStats;
public Inventory inventory;
public SaveData BuildSaveData()
{
SaveData data = new SaveData();
data.playerName = playerStats.playerName;
data.level = playerStats.level;
data.gold = playerStats.gold;
Vector3 pos = playerStats.transform.position;
data.posX = pos.x;
data.posY = pos.y;
data.posZ = pos.z;
data.inventory = new List<InventoryItemData>();
foreach (InventorySlot slot in inventory.slots)
{
InventoryItemData itemData = new InventoryItemData();
itemData.itemId = slot.item.itemId; // save the ID string, not the item asset itself
itemData.count = slot.count;
data.inventory.Add(itemData);
}
return data;
}
}
JsonUtility ที่ Unity มีมาให้ในตัว แปลง object ที่มี [System.Serializable] ไปเป็น JSON string และแปลงกลับได้ นี่คือ round trip แบบสั้นที่สุดที่เป็นไปได้:
SaveData data = new SaveData();
data.playerName = "Nova";
data.level = 5;
data.gold = 120;
string json = JsonUtility.ToJson(data, true); // true = pretty-print with indentation
Debug.Log(json);
{
"saveVersion": 2,
"playerName": "Nova",
"level": 5,
"gold": 120,
"posX": 0,
"posY": 0,
"posZ": 0,
"inventory": []
}
ลองไล่ดู: ทุก field ที่เราตั้งค่าไว้ (playerName, level, gold) จะโชว์ค่าของมัน และทุก field ที่เราไม่เคยแตะ (posX, posY, posZ, inventory ที่เป็น list ว่าง) จะโชว์ค่า default ของมัน นี่ก็แค่ object C# ธรรมดาที่ถูกแปลงเป็น text เท่านั้นเอง
JsonUtility เรียบง่ายแต่ก็มีข้อจำกัด: class ต้องมี [System.Serializable] กำกับไว้, มีแค่ public field เท่านั้นที่ถูกรวมเข้าไป (property ที่มี get/set จะถูกข้าม รวมถึง private field ด้วย เว้นแต่จะมี [SerializeField]) และมันไม่สามารถ serialize Dictionary หรือ array เปล่า ๆ ที่ top level ได้ — นั่นคือเหตุผลที่ ItemDatabase ในหัวข้อ 4 ต้องห่อ list ของมันไว้ใน field ชื่อ itemsJSON string จะมีประโยชน์ก็ต่อเมื่อมันอยู่บนดิสก์แล้ว Unity มี Application.persistentDataPath ให้ใช้: โฟลเดอร์ที่รับประกันว่าเขียนได้ ซึ่ง Unity เลือกตำแหน่งที่ถูกต้องให้เองตาม platform ที่เกมกำลังรันอยู่ (โฟลเดอร์ต่างกันบน Windows, macOS, และมือถือ — คุณไม่ต้องรู้หรือ hardcode เองว่าเป็นอันไหน) เอาไปรวมกับ System.IO.File เพื่อเขียนและอ่าน text
using System;
using System.IO;
using UnityEngine;
public static class SaveManager
{
private const int CURRENT_SAVE_VERSION = 2;
private static string GetSavePath()
{
return Path.Combine(Application.persistentDataPath, "save.json");
}
public static void Save(SaveData data)
{
data.saveVersion = CURRENT_SAVE_VERSION;
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(GetSavePath(), json);
Debug.Log("Saved game to " + GetSavePath());
}
public static SaveData Load()
{
string path = GetSavePath();
if (!File.Exists(path))
{
Debug.Log("No save file found. Starting a new game.");
return new SaveData();
}
try
{
string json = File.ReadAllText(path);
SaveData data = JsonUtility.FromJson<SaveData>(json);
data = MigrateIfNeeded(data);
return data;
}
catch (Exception e)
{
Debug.LogWarning("Save file was corrupted, starting fresh. " + e.Message);
return new SaveData();
}
}
private static SaveData MigrateIfNeeded(SaveData data)
{
if (data.saveVersion < CURRENT_SAVE_VERSION)
{
Debug.Log("Upgrading save from version " + data.saveVersion + " to " + CURRENT_SAVE_VERSION);
data.saveVersion = CURRENT_SAVE_VERSION;
}
return data;
}
}
ลองไล่ดูตอนรันครั้งแรกสุด ก่อนที่จะมี save file อยู่เลย: Load() เช็ค File.Exists(path) ไม่เจออะไรเลย แล้ว return SaveData ตัวใหม่เอี่ยม:
No save file found. Starting a new game.
เรียก SaveManager.Save(data) มันจะเขียนไฟล์ แล้วพิมพ์ประมาณนี้:
Saved game to /storage/emulated/0/Android/data/com.yourstudio.yourgame/files/save.json
(ข้อความ path จริง ๆ จะต่างกันไปตาม platform — นั่นคือเหตุผลที่เราขอ Application.persistentDataPath แทนที่จะพิมพ์ path เองตรง ๆ)
นี่คือปัญหาที่ versioning แก้ให้ คุณปล่อยเกม version 1.0 ผู้เล่น save ความคืบหน้า — ไฟล์ที่อยู่บนดิสก์ของพวกเขามีรูปร่างของ SaveData ตามที่มันเป็นตอนนั้น สามเดือนต่อมาคุณ patch เกม แล้วสมมติว่าแยก hp (int จาก 0 ถึง 100) ออกเป็น field ใหม่ healthPercent (float จาก 0 ถึง 1) เพราะระบบ health ใหม่ต้องการแบบนั้น save file ทุกไฟล์ที่อยู่บนดิสก์ผู้เล่นอยู่แล้วยังคงมีรูปร่างแบบเก่าอยู่ ถ้าคุณแค่ลบ hp ทิ้งแล้วเพิ่ม healthPercent โดยไม่มีอะไรเชื่อมสองอันนี้เข้าด้วยกัน hp ของผู้เล่นที่กลับมาเล่นทุกคนจะถูกรีเซ็ตแบบเงียบ ๆ เพราะ healthPercent ไม่เคยอยู่ในไฟล์เก่าของพวกเขาเลย มันเลยได้ค่า default ของ C# ไป
วิธีแก้: เพิ่ม field int saveVersion เข้าไป (คุณเห็นมันอยู่ใน SaveData มาตั้งแต่หัวข้อ 7 แล้ว) ทุกครั้งที่ save ให้ประทับตัวเลข version ปัจจุบัน ลงไป ทุกครั้งที่ load ให้เช็ค version ที่อ่านได้เทียบกับ version ปัจจุบันที่โค้ดคุณคาดหวัง ถ้ามันเก่ากว่า ให้รันโค้ด migration — โค้ดที่มีหน้าที่แค่อัปเกรดรูปร่างของ save เก่าให้กลายเป็นรูปร่างใหม่ — ก่อนที่จะมีอะไรมาแตะ data นั้น
[System.Serializable]
public class SaveData
{
public int saveVersion = 2;
// Deprecated: kept only so JsonUtility can still read old version-1 save files.
public int hp;
// New in version 2: health is stored as a percentage instead of a raw hit-point number.
public float healthPercent = 1f;
}
private static SaveData MigrateIfNeeded(SaveData data)
{
if (data.saveVersion == 1)
{
data.healthPercent = data.hp / 100f;
data.saveVersion = 2;
}
return data;
}
ลองไล่ดูสำหรับผู้เล่นที่กลับมาเล่น ซึ่งไฟล์เก่าของเขามี {"saveVersion": 1, "hp": 40}: JsonUtility.FromJson จะเติม hp = 40 ให้ (field นี้ยังอยู่ใน class เลยยัง match กันได้โดยชื่อ) ส่วน healthPercent จะยังเป็นค่า default คือ 1 อยู่ จากนั้น MigrateIfNeeded เห็นว่า saveVersion == 1 ก็คำนวณ healthPercent = 40 / 100f = 0.4 แล้วเพิ่ม saveVersion เป็น 2 ผู้เล่นก็ยังมี hp 40% เหมือนเดิม แทนที่จะถูกรีเซ็ตแบบเงียบ ๆ ไปเป็นเต็มหรือหมด
hp ในตัวอย่างข้างบน) ทิ้งใน patch เดียวกันกับที่เพิ่ม field ใหม่เข้ามา ทันทีที่คุณลบ hp ออกจาก class JsonUtility จะไม่มีอะไรให้ match กับ "hp": 40 ในไฟล์เก่าเลย และค่านั้นก็หายไปทันทีที่ไฟล์ถูกโหลด — โค้ด migration ของคุณไม่มีทางเห็นมันเลยด้วยซ้ำ ให้เก็บ field เก่าไว้ก่อน (comment กำกับว่า deprecated) อย่างน้อยหนึ่ง version หลังจากเพิ่มตัวแทนของมันเข้ามา เขียน migration ที่อ่านค่านั้น แล้วค่อยลบทิ้งเมื่อมั่นใจแล้วว่าไม่มี save เก่าที่มีความหมายเหลืออยู่แล้วpublic GameObject targetEnemy; หรือ public Transform playerTransform; ใน save class ดูน่าดึงดูดแต่ใช้ไม่ได้ JsonUtility จะ serialize มันเป็นค่าว่างเปล่า หรือไม่ก็ error และถึงแม้มัน "ทำงานได้" ในบางแบบ object ตัวนั้นก็จะไม่มีอยู่แล้วตอนเกมเปิดครั้งถัดไปอยู่ดี ให้ save เป็น ID (string หรือ int ที่บอกว่า reference นั้นชี้ไปที่อะไร) แล้วค่อยไปหา object จริงใหม่หลังจาก load เสร็จ"C:/Users/Me/save.json" ใช้ได้แค่กับคอมพิวเตอร์เครื่องเดียวเท่านั้น มันพังทันทีบน macOS, มือถือ, console, หรือ PC เครื่องอื่นของผู้เล่น สร้าง path จาก Application.persistentDataPath เสมอ อย่างที่ SaveManager.GetSavePath() ทำในหัวข้อ 8 — Unity รู้อยู่แล้วว่าโฟลเดอร์ที่เขียนได้ที่ถูกต้องคืออันไหน ไม่ว่า build จะรันบน platform ไหนJsonUtility.FromJson กับ text ที่พังโดยไม่มี try/catch มันจะ throw exception และอาจทำให้หน้าจอ load ของคุณ crash ได้ SaveManager.Load() ในหัวข้อ 8 ห่อการอ่านไว้ด้วย try/catch แล้ว fallback ไปเป็น SaveData ใหม่แทนที่จะ crash สำหรับความปลอดภัยเพิ่มเติมในเกมที่ปล่อยจริง คุณยังเขียนลงไฟล์ชั่วคราวก่อนได้ แล้วค่อยแทนที่ save จริงเมื่อการเขียนเสร็จสมบูรณ์เท่านั้น เพื่อไม่ให้การ crash กลางคัน save ทิ้งไฟล์ที่เขียนไม่ครบไว้"gold": 120 เป็น "gold": 999999 ได้ สำหรับเกม single-player ล้วน ๆ นี่มักไม่มีอันตรายอะไร ผู้เล่นที่อยากโกง save ของตัวเองก็แค่ส่งผลกับตัวเองเท่านั้น แต่ถ้าส่วนไหนของ save ไปเกี่ยวข้องกับอะไรที่เป็นการแข่งขันหรือใช้ร่วมกัน — leaderboard, currency ที่เทรดได้, matchmaking rank — อย่าเชื่อฝั่ง client เด็ดขาด คำนวณใหม่หรือตรวจสอบค่านั้นบน server ที่คุณควบคุมได้ก่อนที่มันจะส่งผลกับใครนอกจากผู้เล่นที่แก้มันเองเกม live-service (เกมที่รันต่อเนื่องและได้รับ update หลังเปิดตัวไปแล้ว มักผูกกับ account ผู้เล่นแบบออนไลน์) ปกติแล้วพึ่งพา save file ที่อยู่บนเครื่องเดียวไม่ได้ — ผู้เล่นคาดหวังว่าความคืบหน้าของพวกเขาจะติดตามไปด้วยถ้าเปลี่ยนมือถือหรือเล่นบน PC เครื่องอื่น วิธีที่นิยมทำคือ: อัปโหลด JSON blob ตัวเดียวกับที่คุณสร้างไว้สำหรับไฟล์ local ขึ้นไปที่ backend service ที่ผูกกับ account ของผู้เล่น (ตัวอย่างเช่น PlayFab, Steam Cloud, หรือ server ของสตูดิโอเอง) แล้วดาวน์โหลดกลับมาใหม่เมื่อพวกเขา sign in จากที่อื่น
สิ่งนี้สร้างปัญหาใหม่ที่ local save ไม่มี: conflict ถ้าผู้เล่นเล่นแบบ offline บนสองเครื่อง แล้วทั้งคู่เชื่อมต่อกลับมาในที่สุด save ของใครจะชนะ? กลยุทธ์ที่ง่ายที่สุด และเป็นค่า default ที่สมเหตุสมผลสำหรับโปรเจกต์ระดับเริ่มต้น คือ last-write-wins: เก็บ timestamp ไว้กับ save แล้วให้ตัวที่อัปโหลดล่าสุดเขียนทับตัวอื่นไปเลย มันไม่สมบูรณ์แบบหรอก — ผู้เล่นอาจเสียความคืบหน้าจริง ๆ ที่ทำไว้บนเครื่องที่ "แพ้" ไป — แต่การ merge แบบซับซ้อนกว่านั้น (รวม field เฉพาะเจาะจงแทนที่จะเลือก save ทั้งก้อนอันเดียว) เป็นปัญหาการออกแบบที่ใหญ่กว่ามากซึ่งขอเก็บไว้พูดทีหลัง ไม่ว่าจะใช้กลยุทธ์ไหน ให้เก็บ field saveVersion เดียวกันไว้ใน cloud copy ด้วย; migration จะทำงานแบบเดียวกันไม่ว่า JSON จะมาจากดิสก์ local หรือจาก server
[System.Serializable] ไปเป็น JSON text และแปลงกลับWeaponData แบบ ScriptableObject (มี [CreateAssetMenu]) ที่เก็บ weaponName, damage, และ attackSpeed แล้วเขียนสคริปต์ Weapon แบบ MonoBehaviour ตัวเดียวที่อ่านจาก reference ของ WeaponData และมี method Attack() ที่ log บรรทัดแบบ "Sword hits for 10 damage."
using UnityEngine;
public class Sword : MonoBehaviour
{
public string weaponName = "Sword";
public int damage = 10;
public float attackSpeed = 1.0f;
}
public class Bow : MonoBehaviour
{
public string weaponName = "Bow";
public int damage = 6;
public float attackSpeed = 1.8f;
}
using UnityEngine;
[CreateAssetMenu(fileName = "NewWeaponData", menuName = "Data/Weapon")]
public class WeaponData : ScriptableObject
{
public string weaponName;
public int damage;
public float attackSpeed;
}
using UnityEngine;
public class Weapon : MonoBehaviour
{
public WeaponData data;
public void Attack()
{
Debug.Log(data.weaponName + " hits for " + data.damage + " damage.");
}
}
วิธีใช้: คลิกขวาใน Project window เลือก Create > Data > Weapon สองครั้ง ตั้งชื่อ asset ทั้งสองว่า Sword กับ Bow กรอกค่าใน field ของแต่ละตัวใน Inspector (10/1.0 สำหรับดาบ, 6/1.8 สำหรับธนู) แล้วลาก asset ที่ตรงกันไปวางในช่อง data ของ GameObject ตัวไหนก็ได้ที่มีสคริปต์ Weapon สคริปต์เดียว อาวุธกี่แบบก็ได้ ไม่ต้อง recompile เลยแม้แต่ครั้งเดียวเวลาจะเพิ่มอาวุธใหม่
SaveData เวอร์ชันปัจจุบันที่ปล่อยออกไปแล้ว (version 1) จงเพิ่ม field highScore แล้วเพิ่ม version เป็น 2 เขียน logic ของ MigrateIfNeeded เพื่อให้ผู้เล่นที่กลับมาเล่นซึ่งมี save เก่าแบบ version 1 (ที่ไม่เคยมี highScore มาก่อน) ได้ค่า highScore เริ่มต้นเท่ากับ level * 100 แทนที่จะปล่อยให้เป็น 0 แบบเงียบ ๆ
[System.Serializable]
public class SaveData
{
public int saveVersion = 1;
public string playerName;
public int level;
public int gold;
}
private const int CURRENT_SAVE_VERSION = 1;
private static SaveData MigrateIfNeeded(SaveData data)
{
return data;
}
[System.Serializable]
public class SaveData
{
public int saveVersion = 2;
public string playerName;
public int level;
public int gold;
public int highScore;
}
private const int CURRENT_SAVE_VERSION = 2;
private static SaveData MigrateIfNeeded(SaveData data)
{
if (data.saveVersion == 1)
{
data.highScore = data.level * 100;
data.saveVersion = 2;
}
return data;
}
ถ้าไม่มีบรรทัด migration ที่ชัดเจนนี้ highScore ก็จะยังจบที่ 0 สำหรับผู้เล่นที่กลับมาเล่นทุกคนอยู่ดี เพราะ JsonUtility จะเติมค่า default ของ C# ให้กับ field ที่หายไปจาก JSON เก่า กรณีนี้บังเอิญดูโอเค (0 เป็นค่าที่สมเหตุสมผลสำหรับ "ไม่เคยทำคะแนนมาก่อน") แต่ประเด็นของ exercise นี้คือ คุณต่างหากที่เป็นคนตัดสินใจว่า field ใหม่ของ save เก่าควรจะเป็นค่าอะไร ไม่ใช่ deserializer — บางทีค่า 0 ก็ถูกต้องแล้ว แต่บางทีก็ไม่ใช่ (เหมือนในตัวอย่างนี้ ที่เราให้เครดิตกับความคืบหน้าของ level ที่มีอยู่แล้ว)
public int hp; (ค่าตั้งแต่ 0 ถึง 100) สำหรับ version 2 ระบบ health ใหม่ต้องการให้เป็น public float healthPercent; (ค่าตั้งแต่ 0 ถึง 1) จงเขียน class SaveData ใหม่ และ method MigrateIfNeeded เพื่อให้ผู้เล่นที่มี hit point เก่าอยู่ 40 ได้ healthPercent ที่ถูกต้องหลังจากโหลด แล้วอธิบายเป็นหนึ่งประโยคว่าจะเกิดอะไรผิดพลาดถ้าคุณลบ field hp ทิ้งใน patch เดียวกัน
// version 1 shape, already shipped and on players' disks:
[System.Serializable]
public class SaveData
{
public int saveVersion = 1;
public int hp; // 0 to 100
}
[System.Serializable]
public class SaveData
{
public int saveVersion = 2;
// Deprecated: kept only so JsonUtility can still read old version-1 save files.
public int hp;
public float healthPercent = 1f;
}
private static SaveData MigrateIfNeeded(SaveData data)
{
if (data.saveVersion == 1)
{
data.healthPercent = data.hp / 100f;
data.saveVersion = 2;
}
return data;
}
สำหรับผู้เล่นที่มี hp = 40: JsonUtility ยังคงเติม hp จาก JSON เก่าให้ เพราะ field นี้ยังอยู่ใน class อยู่ MigrateIfNeeded เห็นว่า saveVersion == 1 ก็คำนวณ healthPercent = 40 / 100f = 0.4 แล้วตั้ง saveVersion = 2 ถ้า hp ถูกลบทิ้งใน patch เดียวกันแทนที่จะเก็บไว้แบบ deprecated JsonUtility จะไม่มีอะไรใน class ให้ match กับ "hp": 40 ในไฟล์เก่าเลย ค่านั้นจะถูกทิ้งแบบเงียบ ๆ ตอนโหลด และโค้ด migration ก็จะไม่มีค่าต้นทางเหลือให้แปลงเลย — health ของผู้เล่นที่กลับมาเล่นทุกคนจะรีเซ็ตเป็นค่า default แทนที่จะติดตัวมาด้วย