ハードコードした文字列を多言語化する:外部化の手順と取りこぼしの検査

コードに直書きされたテキストは書き出せず、文字数も数えられず、コードベースごと渡さなければ翻訳者にも渡せません。外す順番は一方向に決まっています。機械的にリテラルを洗い出し、エンジンごとの置き場所を決め、置き換え、最後に擬似ローカライズしたビルドを遊んで取りこぼしを見つける。この記事では実際に動かして確かめた洗い出しの手順とその限界、4つのエンジンでの置き場所、同時に直すべき6点、移行後の3つの検査を書きます。

洗い出しは正規表現だけでは足りず、呼び出し元で絞る

洗い出しは2段階で、効くのは2段目です。1段目はソースコードからダブルクォートで囲まれたリテラルを全部拾う正規表現。2段目は、そのうちどれがプレイヤーの目に触れるかの判定です。文字列そのものを見ても確実には分かりません。絞り込みは、リテラルの形と、渡されている関数名という別々の2つの手がかりを使います。

形による除外で、識別子・ドット区切りのキー・アセットのパス・大文字の定数が落ちます。下のコード断片3本に当てると、13件が7件に減りました。

残った7件には、まだログ出力の文字列が3件混ざっています。しかも見た目は完全にUIテキストと同じ、普通の英文です。形では区別できません。区別できるのは呼び出し元です。ログ関数に渡されたリテラルは、どんな文面であれプレイヤーには見えません。自分のコードベースで実際に使っているログ関数を列挙して2段目の除外をかけると、7件が4件になります。この4件が本当の作業対象です。

結果を信用する前に限界を2つ。短いUIラベルは落ちます。小文字の ok や next は識別子に見え、大文字の OK は定数に見え、進捗表示の 3/5 はパスに見えます。逆に ItemName のような内部的な名前はすべての条件をすり抜けて候補に残ります。連結された文の末尾についた1文字の感嘆符も、最小文字数をどう設定しても漏れるので、断片は句読点を欠いた形で報告されます。洗い出しの結果は、そのまま実行する答えではなく人が読む作業リストです。

日本語を原文にしているプロジェクトは、ここが楽になります。かな・漢字を含むかどうかの判定1つで、UIテキストとキー・パス・識別子が分かれます。キーもパスも識別子もCJK文字を含まないからです。

// 1段目 — 2文字以上のリテラルを全部拾う
/"([^"\\\n]{2,})"/g

// 入力: 3言語のコード断片
//   C#      ShowDialog("ゴールドが足りないため購入できません。");
//           Debug.Log("purchase rejected: insufficient funds");
//           Analytics.Send("shop_purchase");
//           ShowToast(item.name + " を手に入れた!");
//           icon = Resources.Load<Sprite>("UI/Icons/gold.png");
//           state = "IDLE";
//   C++     UE_LOG(LogQuest, Warning, TEXT("no quest available for this actor"));
//           ShowSubtitle(TEXT("この剣を持っていけ。必要になる。"));
//           const FName Tag = TEXT("quest.intro.greeting");
//   GDScript push_warning("save slot is full")
//           label.text = "セーブしました。"
//           var path = "res://saves/slot1.tres"
//           emit_signal("save_completed")

// 1段目の結果: 13件
// 2段目a — 形で6件落ちる
//   "shop_purchase"  "save_completed"     小文字の識別子
//   "quest.intro.greeting"               ドット区切りのキー(空白なし)
//   "UI/Icons/gold.png"  "res://..."     アセットのパス
//   "IDLE"                               大文字の定数
// 2段目b — ログ関数に渡されたリテラルで3件落ちる
//   "purchase rejected: insufficient funds"
//   "no quest available for this actor"
//   "save slot is full"

// 最終候補(4件)
//   "ゴールドが足りないため購入できません。"
//   " を手に入れた!"        <- 断片。移すだけでなくコードの修正が必要
//   "この剣を持っていけ。必要になる。"
//   "セーブしました。"

// 日本語原文なら、形の条件は不要でCJK判定1つで足りる(実測)
//   "ゴールドが足りないため購入できません。"  -> テキスト
//   "セーブしました。"                      -> テキスト
//   "res://saves/slot1.tres"              -> 非テキスト
//   "shop_purchase"                        -> 非テキスト

外部化した後の置き場所(Unity・Unreal・Godot・RPG Maker)

Unity では Localization パッケージの String Table Collection が単位です。1文字列に1つのエントリキー、1ロケールに1列。オブジェクトに LocalizeStringEvent を付けて Text や TextMeshPro の text を駆動させれば、スクリプトが文字列に触らない構成にできます。値を差し込む文字列は手で連結せず Smart Strings を通します。全体像はUnity のローカライズ機能の構成にあります。

Unreal は型のレベルで分かれていて、これが最も重要です。FText がローカライズ対象、FString は収集(gather)されない可変文字列です。実行時に FText::FromString で組んだ文字列は型は FText でも、ソースにリテラルとして存在しないので収集されず、翻訳対象に永久に現れません。型は正しく見えるのにテキストが消えるのは、ほぼこの経路です。流れはUnreal のローカライズパイプラインにあります。

Godot は tr() にキーを渡し、プロジェクト設定に登録した翻訳リソースから引きます。未翻訳時の挙動はテストに好都合で、未知のキーを渡すとキー自体が返ります。エントリが欠けると画面に ui.continue がそのまま出るので、空白や英語のまま通り過ぎるより気づきやすいです。

RPG Maker MV / MZ は事情が違い、そこから引き出される結論がたいてい間違っています。テキストはすでに data フォルダの JSON にありますが、それは翻訳の準備ができていることとは別です。これらのファイルはテキストと数値項目が混在し、レコードを自分で決めたキーではなく内部の id で参照し、メッセージ本文に制御文字を含みます。外部にあることは翻訳の前提条件で、キー付きテーブルの代わりにはなりません。実務はRPG Maker プロジェクトのローカライズに、切り替え時に反応すべき箇所はMV/MZの言語切り替えに書いています。

// Unity — String Table Collection のキーで引く
var text = LocalizationSettings.StringDatabase
    .GetLocalizedString("UI", "shop.error.insufficient_gold");

// Unreal — マクロ内のリテラルなので収集される
FText Msg = NSLOCTEXT("Shop", "InsufficientGold",
                      "ゴールドが足りないため購入できません。");
// 収集されない: ソースに収集対象のリテラルが存在しない
FText Bad = FText::FromString(BuildMessageAtRuntime());

// Godot — エントリが無いときはキー自体が返る
label.text = tr("shop.error.insufficient_gold")

// RPG Maker MV/MZ — data/*.json にあるが、自分で決めたキーではなく
// レコード id で参照され、テキスト以外の項目と混在している
{ "id": 4, "name": "ポーション", "price": 50, "description": "HPを50回復する。" }

// 確認先(いずれも章の名前。バージョンで細部が変わるため原典を見ること)
//   Unity   Localization パッケージ / String Tables, Localized Strings
//   Unreal  エンジン公式ドキュメント / Text Localization
//   Godot   公式ドキュメント / Internationalizing games, Importing translations
//           元データは翻訳としてインポートした CSV か gettext の PO

外部化と同時に直すべき6点

移行は、翻訳者の作業を止める他の問題をまとめて直す最も安い機会です。どうせ全部の呼び出し箇所を編集するからです。通り道で拾う価値があるのは次の6点です。

  • 文字列連結。実行時に断片をつないで作った文は、翻訳者が語順を直せません。断片しか見えていないからです。プレースホルダーを含む1文として翻訳できる形にします。個数や性別で変わる文はICU MessageFormatなら1エントリで表現できます。
  • 複数形。末尾に s を付ける処理や、個数が1かどうかで2つの文字列を切り替える処理は、英語の文法をコードに焼き付けています。3つ以上の形を持つ言語があります。
  • コード側での大文字・小文字変換。翻訳済み文字列に対する大文字化・小文字化はロケール依存で、言語によっては誤りになるか、まったく効きません。
  • 画像に焼き込まれた文字。看板、文字入りのロゴ、ラベルを描き込んだボタン画像。文字列テーブルは届かないので、ロケールごとのアセットが必要です。
  • 列挙型の名前をそのまま表示。enum に ToString して表示すると、画面に識別子が出ます。識別子には引くべき翻訳がありません。
  • デバッグ用と製品用の文字列が同じ経路を通る。1つの関数が両方を表示していると、呼び出し元による絞り込みが効かず、書き出しを読む翻訳者にも区別できません。

コード側の処理が静かに壊すもの:大文字変換・文字列連結・enum 表示

大文字・小文字変換はレビューを通り抜けます。i を大文字にすると英語では I、トルコ語ではドット付きの大文字になり、ロケールを指定しない大文字化はトルコ語のメニューにスペルミスに見える単語を作ります。ドイツ語のエスツェットを含む語は大文字化で6文字が7文字になり、元の文字数で幅を確保したレイアウトが破綻します。日本語では何も起きず、つづける は4文字のまま返るので、全部大文字にする強調は多くのロケールで消えます。大文字で見せたい文字列は、それが正しいロケールのテーブルに大文字で入れてください。

// 脆い例 — 語順が連結に固定され、翻訳者には
// " を " と " 個手に入れた" が別の行として届く
itemName + " を " + count + " 個手に入れた"

// 1エントリ、プレースホルダーは名前付き、語順は翻訳者のもの
ja: "{itemName} を {count} 個手に入れた"
en: "You received {itemName} x{count}"

// enum をそのまま表示: プレイヤーに見えるのは識別子で、単語ではない
label.text = damageType.ToString();          // -> "Fire"
label.text = t("damage.type." + damageType); // -> ロケールごとに引く

// ロケール依存の大文字変換(実測)
//   "i" 大文字化   英語 -> "I"        トルコ語 -> ドット付き大文字 I
//   "I" 小文字化   英語 -> "i"        トルコ語 -> ドットなし i
//   エスツェットを含む語を大文字化    6文字 -> 7文字
//   "つづける" を大文字化             変化なし、4文字

移行の順番と、擬似ローカライズを翻訳より先に置く理由

手順は5段階で、順番に意味があります。棚卸し。洗い出しを実行し、候補リストを読み、各エントリをプレイヤー向け・内部用・文の断片のどれかに分類します。断片は移すだけでなくコードの修正が必要なので、早く数えるほど見積もりが実態に合います。キー設計。命名規則は2,000件のキーが溜まる前に一度で決めます。後から改名すると、そのキーに紐づいて蓄積された翻訳メモリの一致が無駄になるからです。判断材料は文字列キーの命名設計にあります。置き換え。1画面ずつ、1システムずつ進め、各バッチの後でゲームが動く状態を保ちます。不具合が小さな差分に紐づけられます。

擬似ローカライズ。原文テーブルから偽のロケールを生成し、そのロケールで実際にゲームを遊びます。ここは飛ばされやすく、そして時間が返ってくる工程です。コードレビューでは答えの出ない3つの問いに同時に答えます。目印が付いていないテキストが画面に出たら、それはまだ直書きされています。文字を伸ばした状態で枠からあふれるテキストは、ドイツ語やロシア語でもあふれます。1行に囲み記号が2組見えたら、その文はまだ実行時に断片から組み立てられています。

そこまで来たら検査して引き渡します。翻訳者に渡すのは原文テーブルと文脈情報で、コードではありません。同梱すべき残りはローカライズキットの中身に一覧があります。

// 変換: アクセント対応表 + 1.4倍まで埋める + 囲み記号で挟む
// プレースホルダーは先に分離し、そのまま通す
// 文字数 -> 文字数   原文                                擬似ロケール
     8 -> 14   "Continue"                          [Ĉöñţíñûé!!!!]
    11 -> 18   "Game saved."                       [Ğáɱé šáṽéđ.!!!!!]
    32 -> 40   "You received {itemName} x{count}"   [Ýöû řéĉéíṽéđ {itemName} ẋ{count}!!!!!!]

// 日本語原文: アクセントは効かないので、目印を付けて約2倍まで埋める
// (伸び方の実測。"設定" 2文字 -> "Settings" 8文字、"つづける" 4文字 -> 8文字)
     4 -> 10   "つづける"                            【つづけるーーーー】
     2 ->  6   "設定"                                【設定ーー】
    19 -> 40   "ゴールドが足りないため購入できません。"    【ゴールドが足りないため購入できません。ーーーーーーーーーーーーーーーーーーー】

移行後にかける3つの検査

この3つはゲームではなくテーブルに対して、書き出しのたびに掛けます。どれも機械的で、翻訳者の判断を必要としません。

訳文が原文と同一。移行後にいちばん多い欠陥は、対象ロケールにコピーされたまま翻訳されていないエントリです。ビルドは完成したように見えるのに、そこだけ原文の言語が出ます。2つのテーブルをキーごとに比べれば一度に全部見つかります。OK は多くのロケールで OK ですし固有名詞はそのままなので、出力はバグ一覧ではなく確認すべき一覧として扱い、確認できたものは記録して次の報告に出てこないようにします。

プレースホルダーの集合が一致。原文と訳文からプレースホルダーを取り出し、集合として比べます。訳文で落ちたものは値が描画されず、数値が欠けた文が出ます。訳文で増えたものは、コードが渡していない値をフォーマッタに要求します。名前の大文字小文字が変わったものが最も厄介で、小文字にされた itemname は人間の校正者には正しく見えて、フォーマッタには何とも一致しません。

キーが一意。これは機械で確かめてください。他のどこも教えてくれません。同じキーが2回入った JSON の文字列テーブルはパースに失敗せず、パーサは後の値を残して前のものを警告なしに捨てます。3件のうち1件が重複したファイルは2件として読み込まれ、ファイルを開いて見える翻訳はゲームが使う翻訳ではありません。CSV のインポータも多くが同じです。ファイル内のキー数と読み込み後のエントリ数を比べ、差があれば追いかけます。

人が必要なのは、訳文が枠に収まるか、自然に読めるか、その場面に合っているかの判断で、そこはローカライズQAの担当範囲です。上の3つが保証するのは、引き渡すテーブルが構造としてゲームが読むテーブルと同じであること、それだけです。

// 1. キー重複 — JSON.parse は例外を出さず、後の値を残す
{"shop.buy":"購入","shop.sell":"売却","shop.buy":"買う"}
  -> 読み込みは2件、shop.buy = "買う"   (「購入」の行は消える)

// 2. プレースホルダーの集合(原文 vs 訳文)
  ok    {count} {itemName}  vs  {count} {itemName}
  NG    {count} {itemName}  vs  {count} {itemname}   大文字小文字の変化
  NG    {count} {itemName}  vs  {itemName}           {count} が欠落

// 3. 訳文が原文と同一
  ui.continue  "Continue" / "つづける"   ok
  ui.quit      "Quit"     / "Quit"      未翻訳
  ui.ok        "OK"       / "OK"        確認して意図どおりと記録

関連記事