เกมแบบที่ HoYoverse ทำ ตอนเปิดตัววันแรกก็รองรับสิบสองภาษาขึ้นไปแล้ว แล้วยังเพิ่มภาษาใหม่ ๆ ต่อหลังเกมออกอีก ทุก label ปุ่ม ทุกคำอธิบายเควส ทุกชื่อไอเทม ทุกบรรทัดบทพูด ต้องมีครบทุกภาษา ต้องถูกต้องเวลาเกม update และต้องโหลดเร็วบนมือถือด้วย บทนี้พูดถึง localization (การปรับ product ให้เข้ากับภาษาและภูมิภาคหนึ่ง ๆ — ย่อว่า l10n, เลข "10" มาจากจำนวนตัวอักษรระหว่าง "l" กับ "n") จากมุมมองโปรแกรมเมอร์: โค้ดควรเขียนยังไงให้การแปลไม่ต้องมาแตะโค้ดเลย และกับดักเฉพาะเรื่อง — ไวยากรณ์, พหูพจน์, วันที่, เลย์เอาต์, ฟอนต์, ทิศทางการอ่าน, และเสียง — ที่ทำให้วิธีแบบซื่อ ๆ พังได้
Hard-coding ข้อความ คือการเขียนคำภาษาอังกฤษจริง ๆ ลงไปในโค้ด gameplay ตรง ๆ แบบนี้
using UnityEngine;
using UnityEngine.UI;
public class StartButton : MonoBehaviour
{
void Start()
{
// BAD: the English sentence lives inside the C# file.
GetComponentInChildren<Text>().text = "Start Game";
}
}
วิธีนี้ใช้ได้ดีถ้าเกมมีภาษาเดียว แต่พังทันทีที่ต้องมีภาษาที่สอง จะเพิ่มภาษาญี่ปุ่น ก็ต้องมีคนเปิดไฟล์นี้เป๊ะ ๆ หาบรรทัดนี้เป๊ะ ๆ แล้วแก้ — และมีบรรทัดแบบนี้อีกเป็นพันกระจายอยู่ในสคริปต์หลายร้อยไฟล์ ถ้าจะออกสิบสองภาษา ก็ต้องมีสคริปต์สิบสองเวอร์ชันของทุกไฟล์ หรือไม่ก็ต้องมี if สิบสองอันในทุกสคริปต์ แค่เพื่อพิมพ์ข้อความออกมา
ยังมีปัญหาอีกข้อที่แอบซ่อนอยู่: นักแปลไม่ใช่โปรแกรมเมอร์ พวกเขาไม่ควรต้องเปิดไฟล์ C#, เข้าใจ MonoBehaviour, หรือเสี่ยงทำบิลด์พังแค่เพื่อจะแก้ typo ในประโยคเดียว การ hard-code ข้อความบังคับให้เป็นแบบนั้นพอดี ทางแก้ที่จะพูดถึงตลอดบทนี้ คือแยก ข้อความอะไรควรแสดง (งานของนักแปล) ออกจาก แสดงที่ไหนและตอนไหน (งานของโค้ด) — และเครื่องมือที่แยกสองอย่างนี้ออกจากกัน คือ lookup table
แทนที่จะเขียนประโยคภาษาอังกฤษลงในโค้ด เราเขียน key สั้น ๆ ที่คงที่ (identifier ที่ไม่ซ้ำใคร โปรแกรมเมอร์เป็นคนตั้ง ไม่เคยแสดงให้ผู้เล่นเห็นเอง — เช่น "menu.start") แล้วมี string table แยกต่างหาก (โครงสร้าง lookup ที่ map แต่ละ key ไปยังข้อความที่แปลแล้ว หนึ่ง table ต่อหนึ่งภาษา) เก็บคำจริง ๆ ไว้ โค้ดจะถามแค่ว่า "ขอข้อความของ key นี้ ในภาษาปัจจุบันของผู้เล่นหน่อย"
key อย่าง "menu.start" ไม่เปลี่ยนเลยไม่ว่าจะเพิ่มกี่ภาษาก็ตาม จะโตขึ้นก็แค่ table เท่านั้น เพิ่มคอลัมน์ใหม่หนึ่งอันต่อหนึ่งภาษา นี่คือแนวคิดในรูปแบบ C# ธรรมดา ๆ ใช้ table เขียนมือเล็ก ๆ แค่เพื่อโชว์กลไก (table จริงจะโหลดมาจากไฟล์ ซึ่งอยู่ใน Section 12)
using System.Collections.Generic;
using UnityEngine;
Dictionary<string, string> englishTable = new Dictionary<string, string>
{
{ "menu.start", "Start Game" },
{ "menu.settings", "Settings" },
};
Dictionary<string, string> thaiTable = new Dictionary<string, string>
{
{ "menu.start", "เริ่มเกม" },
{ "menu.settings", "ตั้งค่า" },
};
string keyToShow = "menu.start";
Debug.Log(englishTable[keyToShow]);
Debug.Log(thaiTable[keyToShow]);
ผลลัพธ์ที่คาดหวัง:
Start Game
เริ่มเกม
key เดิม คือ keyToShow ไม่เปลี่ยนเลย เปลี่ยนแค่ว่าไปถาม dictionary ตัวไหน — และนี่คือ design ที่เราต้องการพอดี: มีจุดเดียวที่ตัดสินใจว่า "ภาษาไหนกำลัง active อยู่" ส่วนที่เหลือแค่ถาม table ไปเรื่อย ๆ
เกมจริง ๆ ไม่เก็บ dictionary แยกต่อภาษาลอย ๆ ไว้ในสคริปต์ แต่จะรวม lookup ทั้งหมดไว้หลัง object เดียว: LocalizationManager ที่เป็นเจ้าของทุก table และรู้ว่าภาษาไหนกำลัง active อยู่
using System.Collections.Generic;
using UnityEngine;
public class LocalizationManager : MonoBehaviour
{
public static LocalizationManager Instance { get; private set; }
// key -> (language code -> localized text)
private Dictionary<string, Dictionary<string, string>> table;
private string currentLanguage = "en";
void Awake()
{
Instance = this;
table = LoadTable(); // a real game reads this from a file, Section 12
}
public void SetLanguage(string languageCode)
{
currentLanguage = languageCode;
}
public string Get(string key)
{
if (table.TryGetValue(key, out var perLanguage) &&
perLanguage.TryGetValue(currentLanguage, out var text))
{
return text;
}
// Visible placeholder instead of a crash or blank text --
// easy to spot a missing translation during testing.
return "!!" + key + "!!";
}
private Dictionary<string, Dictionary<string, string>> LoadTable()
{
return new Dictionary<string, Dictionary<string, string>>
{
{ "menu.start", new Dictionary<string, string> {
{ "en", "Start Game" }, { "th", "เริ่มเกม" }, { "ja", "ゲーム開始" } } },
{ "menu.quit", new Dictionary<string, string> {
{ "en", "Quit" }, { "th", "ออกจากเกม" }, { "ja", "終了" } } },
};
}
}
LocalizationManager.Instance.SetLanguage("th");
Debug.Log(LocalizationManager.Instance.Get("menu.start"));
LocalizationManager.Instance.SetLanguage("ja");
Debug.Log(LocalizationManager.Instance.Get("menu.start"));
Debug.Log(LocalizationManager.Instance.Get("menu.does_not_exist"));
ผลลัพธ์ที่คาดหวัง:
เริ่มเกม
ゲーム開始
!!menu.does_not_exist!!
ไม่มีอะไรนอกจาก class นี้ที่ต้องรู้ว่า table เก็บยังไง มาจาก file format ไหน หรือมีกี่ภาษา สคริปต์อื่นทุกตัวแค่เรียก LocalizationManager.Instance.Get(key)
!!menu.does_not_exist!! เวลา key หาไม่เจอ อย่า return string ว่างเปล่า และอย่าปล่อยให้ crash เงียบ ๆ label ที่ว่างเปล่ามักถูกมองข้ามตอน test แต่ !!menu.does_not_exist!! จะเด้งเข้าตาทันทีบนหน้าจอ ไม่ว่าจะภาษาไหนก็ตามภาษาของผู้เล่นมักเลือกครั้งเดียวตอนต้น — จากภาษา system ของเครื่อง (Application.systemLanguage ใน Unity) เป็นค่า default หรือจากเมนูตั้งค่าที่ผู้เล่นเปลี่ยนเองได้ พอภาษาเปลี่ยนกลางเกม ข้อความทุกชิ้นที่แสดงอยู่บนจอต้องอัปเดตทันที โดยที่เราไม่ต้องเขียนโค้ดไล่หาและอัปเดต label ทีละอันเอง
วิธีที่สะอาดที่สุดคือใช้ Observer pattern ตัวเดียวกับที่บทก่อนหน้าสอนไปแล้ว (publisher ประกาศว่ามีอะไรเกิดขึ้น โดยไม่ต้องรู้ว่าใครฟังอยู่บ้าง) LocalizationManager จะยิง event ทุกครั้งที่ภาษาเปลี่ยน
using System;
using System.Collections.Generic;
using UnityEngine;
public class LocalizationManager : MonoBehaviour
{
public static LocalizationManager Instance { get; private set; }
public event Action OnLanguageChanged;
private Dictionary<string, Dictionary<string, string>> table;
private string currentLanguage = "en";
void Awake()
{
Instance = this;
table = LoadTable();
}
public void SetLanguage(string languageCode)
{
currentLanguage = languageCode;
OnLanguageChanged?.Invoke(); // tell every listener to refresh
}
public string Get(string key)
{
if (table.TryGetValue(key, out var perLanguage) &&
perLanguage.TryGetValue(currentLanguage, out var text))
{
return text;
}
return "!!" + key + "!!";
}
private Dictionary<string, Dictionary<string, string>> LoadTable() { /* Section 3 */ return null; }
}
label แต่ละอันบนจอเป็น component เล็ก ๆ ที่ subscribe event นี้ไว้ แล้ว refresh ตัวเอง
using UnityEngine;
using UnityEngine.UI;
public class LocalizedText : MonoBehaviour
{
public string key; // e.g. "menu.start", set in the Inspector
private Text label;
void Awake()
{
label = GetComponent<Text>();
}
void OnEnable()
{
Refresh();
LocalizationManager.Instance.OnLanguageChanged += Refresh;
}
void OnDisable()
{
LocalizationManager.Instance.OnLanguageChanged -= Refresh;
}
void Refresh()
{
label.text = LocalizationManager.Instance.Get(key);
}
}
ผลลัพธ์ที่คาดหวัง: เรียก SetLanguage("ja") แค่ครั้งเดียว ที่ไหนก็ได้ในเกม (เช่น ปุ่มในเมนูตั้งค่า) ข้อความ UI ทุกชิ้นบนจอจะสลับเป็นภาษาญี่ปุ่นทันทีในเฟรมเดียวกัน — ไม่ต้องไปบอกสคริปต์ทีละตัว เพิ่ม label อีกร้อยอันทีหลัง แต่ละ LocalizedText ใหม่ก็ต่อสายตัวเองแบบเดียวกันโดยอัตโนมัติ
String concatenation คือการสร้างประโยคด้วยการเอาชิ้นข้อความหลาย ๆ ชิ้นมาต่อกันด้วย + ดูเหมือนไม่มีพิษภัยในภาษาอังกฤษ
void ShowFound(string itemName)
{
// BAD: glues three separate pieces into one sentence.
label.text = "You found " + itemName + "!";
}
วิธีนี้พังในภาษาอื่นด้วยเหตุผลที่ไม่เกี่ยวกับฝีมือนักแปลเลย ลำดับคำไม่เหมือนกันในแต่ละภาษา — นักแปลที่ทำเวอร์ชันญี่ปุ่นไม่สามารถแค่สลับ "You found" เป็นวลีภาษาญี่ปุ่นได้ตรง ๆ เพราะในภาษาญี่ปุ่นชื่อไอเทมต้องมาก่อนคำกริยา ไม่ใช่มาทีหลัง นักแปลที่ทำเวอร์ชันเยอรมันอาจต้องใส่ article ("der"/"die"/"das") ไว้หน้า itemName ซึ่งขึ้นอยู่กับเพศทางไวยากรณ์ของคำนามตัวนั้นโดยเฉพาะ — ข้อมูลที่โค้ดนี้ไม่เคยให้พวกเขาเลย สามชิ้นที่แปะต่อกันนี้สมมติลำดับคำแบบตายตัวไว้หนึ่งแบบ และสมมติฐานนั้นเป็นเรื่องบังเอิญเฉพาะภาษาอังกฤษ ไม่ใช่กฎของภาษาโดยทั่วไป
ทางแก้คือ format string (template ที่สมบูรณ์หนึ่งอันต่อหนึ่งภาษา มี placeholder อยู่ข้างใน — เครื่องหมายอย่าง {0} ที่จะถูกแทนที่ด้วยค่าจริงตอน runtime) แทนที่จะเป็นชิ้นส่วนที่แปะต่อกันแยก ๆ ทั้งประโยคอยู่ใน table เป็นก้อนเดียว ทำให้นักแปลย้าย placeholder ไปตรงไหนก็ได้ที่ไวยากรณ์ภาษาปลายทางต้องการ
void ShowFound(string itemName)
{
string template = LocalizationManager.Instance.Get("item.found");
label.text = string.Format(template, itemName);
}
ShowFound("Sword");
ผลลัพธ์ที่คาดหวัง (ตอนภาษาอังกฤษ active): You found Sword! พอเปลี่ยนภาษา active เป็นไทย โดยไม่แก้โค้ดเลยสักบรรทัด เรียกฟังก์ชันเดิมก็ได้ คุณพบ Sword! — โครงสร้างประโยคมาจาก table ล้วน ๆ
+ ไม่ว่าจะดูเป็นเรื่องเล็กแค่ไหนก็ตาม — "Level " + levelNumber, หรือ itemName + " (equipped)" ทุกอันแบบนี้ฝัง English word order ลงไปทั้งนั้น เก็บทั้งประโยคเต็ม ๆ พร้อม placeholder ไว้เป็น key เดียวเสมอภาษาอังกฤษมีรูปพหูพจน์แค่สองแบบ: เอกพจน์ ("1 item") กับพหูพจน์ ("2 items", "0 items") มันชวนให้เขียนโค้ดโดยสมมติแบบนั้นไปเลย
// BAD: assumes every language works like English's two-form rule.
label.text = count + " items collected";
อันนี้ผิดตั้งแต่ในภาษาอังกฤษเองแล้วด้วยซ้ำ ("1 items collected" อ่านแล้วแปลก ๆ ควรเป็น "1 item collected") แล้วยิ่งแย่ลงข้ามภาษา ภาษาไทยไม่มีการผันคำนามตามพหูพจน์เลย — คำเดียวใช้ได้ทุกจำนวน ภาษาอาหรับมีรูปพหูพจน์แยกกันถึงหกแบบ (นักภาษาศาสตร์เรียกว่า plural category: zero, one, two, few, many, other) แต่ละแบบไวยากรณ์ไม่เหมือนกัน ภาษารัสเซียมีสามแบบ เช็คแค่ if (count == 1) อันเดียวรองรับได้แค่รูปแบบปัญหาของภาษาอังกฤษเท่านั้น
ทางแก้ใช้แนวคิดเดียวกับ Section 5: เก็บ template เต็ม ๆ หนึ่งอันต่อหนึ่ง plural category แล้วเลือก category ที่ถูกต้องด้วย rule เล็ก ๆ เฉพาะแต่ละภาษา
using System;
using System.Collections.Generic;
public static class Pluralizer
{
// A tiny demo covering two shapes: English's one/other split,
// and Thai's rule of always using the same form.
private static readonly Dictionary<string, Func<int, string>> rules =
new Dictionary<string, Func<int, string>>
{
{ "en", n => n == 1 ? "one" : "other" },
{ "th", n => "other" }, // Thai nouns do not change for quantity
};
public static string FormattedCount(string baseKey, int count, string language)
{
string category = rules[language](count); // "one" or "other"
string template = LocalizationManager.Instance.Get(baseKey + "." + category);
return string.Format(template, count);
}
}
table ต้องมีหนึ่งแถวต่อหนึ่ง category ไม่ใช่หนึ่งแถวต่อหนึ่งภาษา
Debug.Log(Pluralizer.FormattedCount("item.count", 1, "en"));
Debug.Log(Pluralizer.FormattedCount("item.count", 5, "en"));
Debug.Log(Pluralizer.FormattedCount("item.count", 5, "th"));
ผลลัพธ์ที่คาดหวัง:
1 item collected
5 items collected
เก็บได้ 5 ชิ้น
สังเกตว่า rule ของภาษาไทยไม่มองไป item.count.one เลย — ขอ item.count.other อยู่เสมอ เพราะไวยากรณ์ไทยไม่มีอะไรที่ตรงกับกรณี "one" ของภาษาอังกฤษเลย rule ของภาษาฝรั่งเศสจะต้องมีรูปแบบอีกแบบหนึ่ง: ภาษาฝรั่งเศสนับทั้ง 0 และ 1 เป็น category "one" ต่างจากภาษาอังกฤษที่นับแค่ 1 เท่านั้น — ข้อเท็จจริงแบบนี้จะรู้ได้ก็ต่อเมื่อไปเช็คจริง ๆ ไม่ใช่เดาเอาจากภาษาอังกฤษ (rule นี้เป๊ะ ๆ คือ Exercise 2)
เพศ ก็สร้างปัญหาคล้ายกัน: บางภาษาเปลี่ยนคำในประโยคตามเพศทางไวยากรณ์ของประธาน — ภาษาฝรั่งเศส "Il est prêt" (เขา (ชาย) พร้อมแล้ว) กับ "Elle est prête" (เธอ (หญิง) พร้อมแล้ว) เป็นคนละ string กันเลย ไม่ใช่ string เดียวที่แค่สลับคำสรรพนาม เวลาการแปลของประโยคขึ้นอยู่กับเพศของตัวละครผู้เล่นจริง ๆ สตูดิโอมักเพิ่ม key แยกสำหรับแต่ละกรณี ("ready.male", "ready.female") แทนที่จะพยายามยัด template เดียวให้ครอบคลุมทั้งสองแบบ — เป็นแนวคิดเดียวกับ key-ต่อ-variant ที่ใช้กับพหูพจน์ แค่เอามาใช้กับความแตกต่างอีกแบบหนึ่ง
ตัวเลขก็เขียนไม่เหมือนกันในทุกที่เช่นกัน ภาษาอังกฤษเขียน 1,234.50 — comma คั่นหลัก, period เป็นจุดทศนิยม ภาษาเยอรมันเขียนค่าเดียวกันเป็น 1.234,50 — สองสัญลักษณ์สลับกัน วันที่ก็มีปัญหาแบบเดียวกัน: 3/5/2026 คือ 5 มีนาคม หรือ 3 พฤษภาคม ขึ้นอยู่กับว่าใช้ convention ของประเทศไหนโดยสิ้นเชิง ถ้าสร้าง string พวกนี้ด้วยมือด้วย string concatenation ก็ต้อง hand-code rule แยกต่อประเทศ — ตรงกับปัญหาที่ CultureInfo ของ .NET (built-in class ที่อธิบาย formatting convention ของภาษาและภูมิภาคหนึ่ง ๆ) แก้ให้เราอยู่แล้ว
using System;
using System.Globalization;
using UnityEngine;
DateTime patchDate = new DateTime(2026, 3, 5);
double price = 1234.5;
CultureInfo en = CultureInfo.GetCultureInfo("en-US");
CultureInfo de = CultureInfo.GetCultureInfo("de-DE");
Debug.Log(patchDate.ToString("d", en));
Debug.Log(patchDate.ToString("d", de));
Debug.Log(price.ToString("N2", en));
Debug.Log(price.ToString("N2", de));
Debug.Log(price.ToString("C", en));
Debug.Log(price.ToString("C", de));
ผลลัพธ์ที่คาดหวัง:
3/5/2026
05.03.2026
1,234.50
1.234,50
$1,234.50
1.234,50 €
DateTime เดิม, double เดิม — เปลี่ยนแค่ CultureInfo ที่ส่งเข้าไป แล้วทุกสัญลักษณ์ ตัวคั่น ลำดับ และเครื่องหมายสกุลเงิน ก็เปลี่ยนตามให้อัตโนมัติ เราไม่ต้องใส่ comma เองด้วยมือ หรือตัดสินใจเองว่าสัญลักษณ์สกุลเงินอยู่ตรงไหน CultureInfo รู้ rule ของแต่ละภูมิภาคอยู่แล้ว
"1.234,50" แล้วโค้ดของผู้เล่นอังกฤษพยายามอ่านกลับด้วย double.Parse โดยใช้ rule culture ของตัวเอง comma กับ period จะถูกอ่านผิด ค่าที่ได้กลับมาจะผิด (หรือ throw exception) เซฟและ parse ข้อมูลภายในด้วย CultureInfo.InvariantCulture เสมอ (format ที่คงที่ ไม่ขึ้นกับ culture) แล้วเก็บ culture จริงของผู้เล่นไว้ใช้แค่ตอนแสดงผลบนจอเท่านั้นText expansion คือข้อเท็จจริงที่ว่าประโยคเดียวกันใช้พื้นที่ไม่เท่ากันมากในแต่ละภาษา ประโยคภาษาเยอรมัน ฟินแลนด์ และรัสเซีย มักยาวกว่าต้นฉบับภาษาอังกฤษ 30-50% เมื่อนับจำนวนตัวอักษร ภาษาไทยเป็นปัญหาอีกแบบหนึ่ง: ไม่ได้ยาวกว่าเสมอไปเมื่อนับจำนวนตัวอักษร แต่ตัวอักษรไทยมีวรรณยุกต์และสระซ้อนอยู่ทั้งด้านบนและด้านล่างตัวพยัญชนะหลัก ทำให้บรรทัดข้อความไทยมักต้องการพื้นที่แนวตั้ง (line height) มากกว่าบรรทัดข้อความ Latin ที่ font size เท่ากันอย่างเห็นได้ชัด แม้จะไม่ได้กว้างกว่าก็ตาม
UI ที่สร้างและ test แค่ในภาษาอังกฤษ โดยกำหนดความกว้างปุ่มเป็น pixel เป๊ะ ๆ จะพังทันทีที่คำแปลจริงเข้ามา
ทางแก้คือ layout ที่ปรับตามเนื้อหาแทนที่จะสมมติขนาดตายตัว: ปุ่มที่ความกว้างโตตาม label ของมัน (ContentSizeFitter ของ Unity กับ layout group ทำแบบนี้ได้), ข้อความที่ย่อ font size ให้พอดีกับกล่องขนาดสูงสุด (feature auto-size ของ TextMeshPro), หรือ design ที่เผื่อ margin ว่างกว้าง ๆ รอบทุก label ไว้ตั้งแต่แรก แทนที่จะกำหนดกล่องให้พอดีเป๊ะกับข้อความภาษาอังกฤษ
"Start Game" เป็น "[[[ Ştärt Gämé ~~~ ]]]") ตั้งใจให้ยาวขึ้นประมาณ 40% และใช้ตัวอักษรมี accent ด้วย เพื่อให้ layout ไหนที่รอดการแปลไม่ได้ พังให้เห็นทันทีตอน test ก่อนที่นักแปลจะเห็นข้อความจริงด้วยซ้ำไฟล์ฟอนต์ไม่ได้มีทุกตัวอักษรที่เป็นไปได้ครบทุกตัว — มันมีชุด glyph ที่ตายตัว (รูปทรงจริง ๆ ที่วาดไว้สำหรับตัวอักษรแต่ละตัว) ฟอนต์ Latin ทั่วไปมี glyph สำหรับตัวอักษรภาษาอังกฤษบวกตัวอักษรยุโรปที่มี accent ประมาณ 200-300 รูปทรงรวมกัน แล้ว render ข้อความด้วยการหาตัวอักษรแต่ละตัวแล้ววาด glyph ของมัน ถ้าตัวอักษรไหนไม่มี glyph ในฟอนต์ที่ active อยู่ engine ส่วนใหญ่จะ fallback ไปเป็นกล่อง placeholder ที่มองเห็นได้ — มักเรียกว่า tofu — แทนตัวอักษรจริง
นี่สร้างปัญหาเรื่องขนาดที่เฉพาะกับ CJK (จีน ญี่ปุ่น เกาหลี — ภาษาที่ใช้ตัวอักษรหลายพันตัวแทนที่จะเป็นตัวอักษรแบบ alphabet) font atlas ของ Latin (texture เดียวที่อัด glyph bitmap ที่ render ไว้ล่วงหน้า ให้ GPU sample เพื่อวาดข้อความแบบประหยัด) อาจต้องการแค่ไม่กี่ร้อย glyph แล้วใส่ลง texture เล็ก ๆ ได้สบาย ๆ ภาษาจีนอย่างเดียวมีตัวอักษรที่ใช้บ่อยถึง 3,000-9,000+ ตัว — เยอะเกินกว่าจะ pre-bake ลง atlas เดียวล่วงหน้าได้โดยไม่ต้องใช้ texture ขนาดมหึมา engine แก้ปัญหานี้ด้วยการ generate glyph ลง atlas แบบ dynamic ตามที่ต้องใช้จริง (render แล้ว cache glyph ตัวหนึ่งตั้งแต่ครั้งแรกที่ตัวอักษรนั้นปรากฏบนจอ) หรือไม่ก็ ship font asset CJK ขนาดใหญ่แยกต่างหาก ที่โหลดเฉพาะตอนภาษา CJK ถูกเลือกใช้จริง แทนที่จะฝังไว้ในทุก build ของทุกภาษา
ภาษาไทยเพิ่มความท้าทายด้าน rendering อีกแบบหนึ่ง: วรรณยุกต์และสระเป็น combining mark ที่ซ้อนอยู่บนหรือใต้พยัญชนะหลัก แทนที่จะอยู่ใน slot ตัวอักษรของตัวเองข้าง ๆ กัน และพยัญชนะหลักหนึ่งตัวสามารถแบกมากกว่าหนึ่ง mark พร้อมกันได้ renderer ที่วาง glyph ทีละตัวเรียงจากซ้ายไปขวาแบบซื่อ ๆ โดยไม่เข้าใจวิธีจัดตำแหน่ง combining mark จะวาดข้อความไทยโดยมี mark อยู่ผิดตำแหน่งหรือซ้อนทับกันผิด ๆ การซ้อน mark เหล่านี้ให้ถูกต้องเรียกว่า text shaping (การจัดตำแหน่งและรวม glyph ตาม rule เฉพาะของสคริปต์นั้น ๆ ไม่ใช่แค่วางเรียงจากซ้ายไปขวา) และต้องใช้ text rendering system ที่สร้างมารองรับเรื่องนี้โดยเฉพาะ — นี่คือเหตุผลหนึ่งที่ TextMeshPro ของ Unity มักถูกเลือกใช้มากกว่า UI Text component แบบเก่า สำหรับเกมไหนก็ตามที่ ship ข้อความไทย อาหรับ หรือ CJK
using TMPro;
// Real font-asset APIs vary by TextMeshPro version, but the idea
// is always the same: check whether a font actually has a glyph
// for a character before assuming it will render correctly.
public bool HasGlyphFor(TMP_FontAsset font, char c)
{
return font.HasCharacter(c);
}
ภาษาอาหรับกับฮีบรูเป็นภาษา right-to-left (RTL): ข้อความอ่านและไหลจากขวาไปซ้าย แทนที่จะเป็นซ้ายไปขวา เรื่องนี้กระทบมากกว่าแค่ทิศทางที่ประโยคอ่าน — UI ที่สร้างมาสำหรับภาษาที่เขียนซ้ายไปขวา มักต้อง mirror ทั้ง layout ไม่ใช่แค่ปรับ alignment ของข้อความ
ลูกศร "back" ที่ชี้ไปทางซ้ายใน layout แบบ LTR ควรชี้ไปทางขวาใน layout แบบ RTL ที่ mirror แล้ว เพราะมันยังต้องสื่อความหมายว่า "ไปหน้าจอก่อนหน้า" เหมือนเดิม — ความหมายเหมือนเดิมแม้ icon จะกลับด้าน progress bar, health bar, และทิศทาง swipe มักจะ mirror ด้วย เพื่อให้เกมยังรู้สึกเป็นธรรมชาติสำหรับคนที่สายตาเคลื่อนจากขวาไปซ้ายบนหน้าจอ
แต่ไม่ใช่ทุกอย่างจะ mirror และถ้าทำผิดก็ดูพังพอ ๆ กับไม่ mirror เลย ตัวเลขในข้อความอาหรับยังคงเขียนจากซ้ายไปขวาอยู่ดี แม้คำรอบข้างจะอ่านจากขวาไปซ้าย — การผสมสองทิศทางในบรรทัดเดียวกันเรียกว่า bidirectional text (ย่อว่า "bidi") ต้องใช้ text rendering system ที่เข้าใจ rule นี้ ไม่ใช่แค่กลับตัวอักษรทุกตัวแบบมั่ว ๆ ภาพถ่าย หน้าปัดนาฬิกา วิดีโอ และอะไรก็ตามที่มีความหมายทางกายภาพในโลกจริง ไม่ควรถูกกลับด้านเลย — mirror แค่ layout chrome ที่เป็นนามธรรม (icon, alignment, ทิศทาง navigation) เท่านั้น โลโก้บริษัทก็ไม่ควร mirror เช่นกัน
ข้อความไม่ใช่สิ่งเดียวที่ต้อง localize Voice-over (VO) — บทพูดที่อัดเสียงไว้ — ปกติมีการตั้งค่าภาษาแยกต่างหากของตัวเอง เลือกอิสระจากภาษาข้อความ นี่คือเหตุผลตรง ๆ ที่เกมใหญ่ ๆ ให้เราเล่นโดยมีข้อความบนจอเป็นภาษาไทย พร้อมกับเสียงพากย์เป็นภาษาญี่ปุ่นได้พร้อมกัน: สองระบบนี้ไม่ใช่ lookup เดียวกัน
using UnityEngine;
using System.Collections.Generic;
public class LocalizationManager : MonoBehaviour
{
public static LocalizationManager Instance { get; private set; }
public string currentTextLanguage = "en";
public string currentVoiceLanguage = "ja";
private Dictionary<string, Dictionary<string, AudioClip>> voiceTable;
public AudioClip GetVoiceClip(string voiceKey)
{
if (voiceTable.TryGetValue(voiceKey, out var perLanguage) &&
perLanguage.TryGetValue(currentVoiceLanguage, out var clip))
{
return clip;
}
return null; // fall back to no audio, or a default-language clip
}
}
รูปร่างเหมือนกับ string table ใน Section 3 เป๊ะ — key หนึ่งอัน แล้วค่าหนึ่งค่าต่อภาษา — ต่างกันแค่ค่านั้นเป็น AudioClip แทนที่จะเป็น string ความแตกต่างในทางปฏิบัติคือขนาดไฟล์: ไฟล์เสียงมีขนาดใหญ่ พอคูณทุกบรรทัดบทพูดด้วยสิบสองภาษา ก็ได้ข้อมูลจำนวนมหาศาล เกมใหญ่ ๆ ส่วนใหญ่แก้ปัญหานี้ด้วยการ ship เสียงเฉพาะภาษาที่เลือกไว้เท่านั้น (ดาวน์โหลดหรือติดตั้งตามต้องการ) แทนที่จะรวม voice pack ทั้งสิบสองอันไว้ในเครื่องของผู้เล่นทุกคน
Localized texture คือ asset ที่ไม่ใช่ข้อความอีกแบบที่พบบ่อย: รูปภาพที่มีคำถูกฝังลงไปในพิกเซลโดยตรง — ป้ายร้านค้า หน้าหนังสือในเกมที่ render เป็นภาพ art, screenshot สอนวิธีเล่นที่มี callout ภาษาอังกฤษวาดทับไว้ string table แก้ปัญหานี้ไม่ได้เลย เพราะคำพวกนั้นไม่ใช่ตัวอักษร เป็นแค่พิกเซลสี แต่ละภาษาต้องมี texture แยกของตัวเอง สลับด้วย key และภาษาปัจจุบัน เหมือนกับ voice clip
ทุกอย่างที่ผ่านมาสมมติว่า string table มีอยู่แล้ว ในการทำงานจริง การกรอกข้อมูลลง table เป็น workflow ของมันเองเลย เรียกว่า localization pipeline: เส้นทางที่ string เดินทางจากโปรแกรมเมอร์ที่เขียน key ไปถึงนักแปลที่เขียนคำ ไปจนถึงคำเหล่านั้นปรากฏในเกมที่ ship จริง
นักแปลทำงานในสเปรดชีตหรือเครื่องมือแปลเฉพาะทาง ไม่เคยแตะ codebase เลย ขั้นตอน export ง่าย ๆ จะไล่ทุก key ใน master table แล้วเขียนออกมาเป็นหนึ่งแถวต่อหนึ่ง key โดยกรอกภาษาต้นฉบับไว้ให้แล้ว ส่วนคอลัมน์ภาษาอื่นเว้นว่างไว้ให้นักแปลกรอก
using System.IO;
using System.Text;
using System.Collections.Generic;
public static void ExportForTranslators(Dictionary<string, string> sourceTable, string path)
{
var sb = new StringBuilder();
sb.AppendLine("key,en,th,ja"); // header row: key, then one column per language
foreach (var pair in sourceTable)
{
// Source language is filled in already; translated columns start blank.
sb.AppendLine(pair.Key + "," + pair.Value + ",,");
}
File.WriteAllText(path, sb.ToString());
}
พอนักแปลกรอกคอลัมน์ของตัวเองเสร็จแล้วส่งไฟล์กลับมา ขั้นตอน import จะอ่านไฟล์นั้นแล้วสร้าง dictionary ต่อภาษาที่ LocalizationManager โหลดขึ้นมาใหม่ มีสองกฎที่ทำให้ pipeline นี้ไม่พังเมื่อ project โตขึ้น: ห้าม rename key เด็ดขาดหลังจากนักแปลเริ่มทำงานจาก key นั้นแล้ว (การ rename จะดูเหมือน "ลบ key เดิม แล้วเพิ่ม key ใหม่ว่างเปล่า" ในสายตาของ pipeline ทำให้คำแปลที่ทำไปแล้วหายไปเปล่า ๆ) และให้ context แก่นักแปลมากกว่าแค่ข้อความภาษาอังกฤษเปล่า ๆ — comment สั้น ๆ อธิบายว่า string นี้ไปโผล่ที่ไหน หรือ character limit สูงสุด เพราะคำภาษาอังกฤษคำเดียวกัน (เช่น "Level") อาจต้องแปลไม่เหมือนกันเลย ขึ้นอยู่กับว่ามันหมายถึงด่านในดันเจี้ยน หรือการได้ level ขึ้น
"menu.start") ที่โค้ดใช้ถามหาข้อความ ไม่เคยแสดงให้ผู้เล่นเห็นเอง"You found {0}!" ที่ {0} ถูกแทนที่ด้วยค่าจริงตอน runtime โดยที่ทั้งประโยคยังอยู่ครบสำหรับการแปล+ ให้เขียนใหม่โดยใช้ key และ format string ที่มี placeholder แทน ตามที่ Section 5 อธิบายไว้ สมมติว่า LocalizationManager.Instance.Get("combat.damage_dealt") return template ของภาษาปัจจุบัน
void ShowDamage(string attackerName, int amount)
{
// BAD: word order is hard-coded, cannot be fixed by translation alone.
label.text = attackerName + " dealt " + amount + " damage!";
}
// Table entry (English): "combat.damage_dealt" -> "{0} dealt {1} damage!"
void ShowDamage(string attackerName, int amount)
{
string template = LocalizationManager.Instance.Get("combat.damage_dealt");
label.text = string.Format(template, attackerName, amount);
}
ShowDamage("Slime", 12);
ผลลัพธ์ที่คาดหวัง (ตอนภาษาอังกฤษ active): Slime dealt 12 damage! เพราะทั้งประโยค รวมถึงลำดับของ attackerName กับ amount ตอนนี้อยู่ข้างใน template ที่แปลแล้ว นักแปลภาษาญี่ปุ่นจะเขียน template ที่วางตัวเลขก่อนชื่อ หรือตรงไหนก็ได้ที่ไวยากรณ์ญี่ปุ่นต้องการก็ได้อย่างอิสระ — โค้ด C# เองไม่ต้องเปลี่ยนอีกเลย ไม่ว่าจะเพิ่มกี่ภาษาก็ตาม
Pluralizer จาก Section 6 เป็นจุดเริ่มต้น เพิ่ม rule ภาษาฝรั่งเศสลงใน dictionary rules ภายใต้ key "fr" แล้วบอกว่า Pluralizer.FormattedCount("item.count", 0, "fr") กับ Pluralizer.FormattedCount("item.count", 2, "fr") จะ print อะไรออกมา สมมติว่า table มี item.count.one = "{0} pomme" และ item.count.other = "{0} pommes"
private static readonly Dictionary<string, Func<int, string>> rules =
new Dictionary<string, Func<int, string>>
{
{ "en", n => n == 1 ? "one" : "other" },
{ "th", n => "other" },
{ "fr", n => (n == 0 || n == 1) ? "one" : "other" }, // French: 0 AND 1 both count as "one"
};
Debug.Log(Pluralizer.FormattedCount("item.count", 0, "fr"));
Debug.Log(Pluralizer.FormattedCount("item.count", 2, "fr"));
ผลลัพธ์ที่คาดหวัง:
0 pomme
2 pommes
rule ของภาษาอังกฤษจะส่ง count == 0 ไปที่ category "other" (เพราะ special-case แค่ 1 เท่านั้น) ซึ่งผิดพอดีสำหรับภาษาฝรั่งเศส นี่คือเหตุผลที่การจัดการพหูพจน์ต้องเขียนแยกต่อภาษา โดยใช้ rule จริงของแต่ละภาษา ไม่ใช่เดาเอาจากภาษาที่โปรแกรมเมอร์พูดเอง
using UnityEngine;
public class SettingsButtonSetup : MonoBehaviour
{
void Awake()
{
// BAD: width is a fixed number of pixels, sized for the English word.
GetComponent<RectTransform>().sizeDelta = new Vector2(90f, 30f);
}
}
"Settings" มี 8 ตัวอักษรและพอดีกับ 90 pixel แต่ "Einstellungen" มี 13 ตัวอักษร — text expansion ของภาษาเยอรมัน (Section 8) แปลว่า label ที่แปลแล้วจะล้นความกว้างตายตัวนี้ แล้วก็จะถูกตัดขาดหรือล้นออกนอกขอบปุ่ม ภาษาไทยมีปัญหาที่ดูตรงข้ามแต่จริง ๆ เกี่ยวข้องกัน: แม้คำจะไม่ได้กว้างขึ้นมากนัก แต่วรรณยุกต์และสระที่ซ้อนกันในภาษาไทยต้องการพื้นที่แนวตั้งมากกว่า ทำให้ความสูงตายตัว 30f ที่กำหนดมาสำหรับตัวพิมพ์ใหญ่ Latin ธรรมดา อาจทำให้ข้อความไทยดูอัดแน่นหรือถูกตัดที่ด้านบนกับด้านล่างได้
ทางแก้คือเลิก hard-code sizeDelta ไปเลย เพิ่ม ContentSizeFitter ตั้งให้ resize แนวนอนตาม preferred width ของ label (ปุ่มจะโตตามคำที่ใส่เข้ามา) คงความกว้างขั้นต่ำไว้เพื่อความสะดวกในการแตะบนอุปกรณ์ touch แล้วก็ให้ text component มี padding แนวตั้งเผื่อไว้พอ หรือเปิด auto-size ของ TextMeshPro เพื่อให้ label ที่สูงเกินคาดในภาษาไทยย่อ font ลงเล็กน้อยแทนที่จะถูกตัด ทั้งหมดนี้ไม่ต้องรู้ข้อความที่แปลแล้วจริง ๆ ล่วงหน้าเลย เพราะประเด็นทั้งหมดคือให้ layout ปรับตามข้อความที่ table ส่งมาให้เองโดยอัตโนมัติ