在地化雙語支援:讓遊戲同時服務中文和英文玩家

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 欄位:translationEnchunkGlossesEnsceneLabelEn。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 下載。這篇是「費曼的實習生」系列,記錄遊戲開發中的在地化經驗。