在地化雙語支援:讓遊戲同時服務中文和英文玩家
Crystal Hangul 是一個韓文學習遊戲。遊戲裡有三種語言同時在場:韓文(學習目標)、中文(母語解說)、英文(給非中文母語的玩家)。韓文永遠原文呈現,中文和英文之間可以在標題頁切換。
聽起來很單純,做起來踩了不少坑。
三種語言,兩種角色
韓文在這個遊戲裡的角色和中英文不同。韓文是學習內容,玩家要看、要拼、要聽 TTS 發音。韓文字塊上面的文字、句子的韓文原文,不管選什麼語系都不會變。
中文和英文是「解說語言」。UI 上的按鈕文字(「開始」/「Start」)、文法解說(「은/는 主題助詞」/「은/는 topic particle」)、句子翻譯、字塊的逐字釋義,這些跟著玩家選的語系走。
這個區分很重要。如果我不小心把韓文也加進在地化系統,玩家切到英文的時候韓文字塊會變成英文翻譯,遊戲就失去了學韓文的意義。
Loc.t:最簡單的雙語切換
我沒有用 Apple 的 Localizable.strings 系統。Crystal Hangul 只有兩種語言,字串分散在 UI 各處,用 .strings 檔管理要在 code 和資源檔之間來回跳,容易漏。
我寫了一個 Loc.t() 函式,接受中文和英文兩個參數,根據當前語系回傳對應的那個:
enum Loc {
static var isEnglish: Bool {
UserDefaults.standard.string(forKey: key) == "en"
}
static func t(_ zh: String, _ en: String) -> String {
isEnglish ? en : zh
}
}
UI 裡面這樣用:
Text(Loc.t("暫停", "Paused"))
Text(Loc.t("下一波即將來襲…", "Next wave incoming…"))
Button(Loc.t("離開戰鬥", "Quit battle")) { ... }
中英文寫在同一行,一眼就能看到兩邊有沒有對上。如果用 .strings 檔,中文和英文分在兩個不同的檔案裡,很容易改了一邊忘了另一邊。
內容層的雙語:SentenceCard
UI 字串用 Loc.t() 解決了,但句子的翻譯和字塊釋義存在 JSON 內容檔裡,不走 Loc.t()。
每句韓文句子的 JSON 裡有中文翻譯和逐字釋義。我在 JSON schema 加了三個 optional 欄位:translationEn、chunkGlossesEn、sceneLabelEn。SentenceCard 在取翻譯的時候,根據語系選中文或英文,英文欄位沒填的話 fallback 到中文:
var translation: String {
Loc.isEnglish ? (translationEn ?? translationZh) : translationZh
}
var displayGlosses: [String]? {
Loc.isEnglish ? (chunkGlossesEn ?? chunkGlosses) : chunkGlosses
}
fallback 機制讓我可以分批翻譯。一開始英文欄位全部是空的,遊戲照常跑,英文玩家看到中文翻譯。我有空的時候一批一批加英文翻譯,加完的句子自動切過去,沒加的繼續顯示中文。上架前全部補完。
章節名和文法解說的查表
章節名稱存在資料層,中文是「黎明礦脈」「靛晶森林」「熔光峽谷」。這些不是通用的字串,是遊戲世界觀的專有名詞,沒辦法用 Loc.t()(因為 Loc.t 的中文參數是寫死在 UI code 裡的,但章節名來自資料)。
我在 Loc 裡加了一個查表函式:
static func chapterName(_ zh: String) -> String {
guard isEnglish else { return zh }
switch zh {
case "黎明礦脈": return "Dawn Lode"
case "靛晶森林": return "Indigo Forest"
case "熔光峽谷": return "Molten Canyon"
default: return zh
}
}
文法解說也用同樣的方式。「은/는 主題助詞」在英文模式下變成「은/는 topic particle」。韓文的部分保留原文,只翻譯解說的中文部分。
關卡標題「黎明礦脈・一」要變成「Dawn Lode · 1」,中文數字也要轉成阿拉伯數字。我寫了一個 levelTitle() 函式處理:先拆開章節名和數字,各自查表轉換,再組合起來。
SwiftUI 的語系切換陷阱
語系在標題頁切換。玩家按「中文 / English」的 toggle,UserDefaults 寫入新值,UI 要即時更新。
第一版的做法是讓所有 View 直接讀 Loc.isEnglish。問題是 Loc.isEnglish 讀 UserDefaults,UserDefaults 的變更不會自動觸發 SwiftUI 的重繪。玩家按了切換按鈕,UserDefaults 寫進去了,但畫面上的文字沒變,要退出再進來才會更新。
我在 TitleView 用 @AppStorage 來驅動重繪:
@AppStorage(Loc.key) private var language = "zh"
private var isEn: Bool { language == "en" }
@AppStorage 會在 UserDefaults 的值變化時自動觸發 View 的 body 重算。標題頁的所有文字判斷都依賴這個屬性,切換的瞬間整頁重繪,加上 crossfade 動畫。
其他頁面(戰鬥、結算、選關)不需要即時切換,因為玩家只能在標題頁切語系。這些頁面在語系切換之後才會被建立,建立的時候讀到的 Loc.isEnglish 已經是新的值了。
容易漏掉的地方
做完雙語之後,我用英文模式把整個遊戲跑了一遍,找到幾個漏掉的地方:
- 結算畫面的戰鬥統計標籤。「最高 Combo」和「剩餘核心」這兩個標籤我一開始寫死中文,因為它們藏在 HStack 裡面跟數字混在一起,grep「Loc.t」的時候掃不到。
- Coming Soon 頁面。全章通關之後的過場頁面,我寫完就沒再測過,英文版本的文字是後來才補的。
教訓是:雙語測試不能只跑主路線。要把所有分支畫面都走一遍,包括通關、失敗、暫停、Coming Soon 這些低頻路徑。
這套做法的適用範圍
Crystal Hangul 的 Loc.t() 做法適合語言數量少、字串數量可控的情況。兩種語言、幾十個 UI 字串,寫在同一行比分成兩個 .strings 檔更不容易漏。
如果語言超過三種,或者字串超過幾百個,.strings 加上 Xcode 的 export/import 翻譯流程會更合適。因為 Loc.t() 的每個呼叫點都要手動維護兩個參數,字串一多就會開始漏。
另一個限制是查表函式。章節名、文法解說的中英對照寫在 switch case 裡,加新章節的時候要記得去 Loc.swift 加對應的英文。我忘了一次,第三章上線的時候英文版的章節名顯示了中文原文。
水晶韓語塔防是一個用 RealityKit 做的 iOS 塔防遊戲,拼韓文句子建塔,打敗水晶怪物。App Store 下載。這篇是「費曼的實習生」系列,記錄遊戲開發中的在地化經驗。