ゲームのローカライズとは — 翻訳以外に何が変わるのかを実測で示す
ローカライズとは、ゲームをその土地で正しく成立させる作業のことです。翻訳はその中でいちばん目に見える部分ですが、全部ではありません。同じ画面が長さの違うテキストを収め、その市場の書き方で数値と日付を表示し、その言語の数え方と並べ方に従い、そもそも自分がどのロケールで動いているかを申告する。これらが全部含まれます。
この記事は、その範囲を形容詞ではなく数字で示します。以下の値はすべて 1 台のマシンで実行したコマンドの出力です(Node v22.13.0、ICU 76.1、CLDR 46.0、Unicode 16.0)。コマンドも併記しているので、自分の文字列で試せます。規格ではなくエンジンやストアの事情で決まる部分は、推測せずにそう書きます。
文字数は変わる。しかも「文字数」は 3 通りある
英日の対訳で存在する 40 本の記事を実測しました。記事ごとに「英語 1 語あたり日本語◯文字」の中央値を出して 40 本を並べると、真ん中が 2.19、下端が 1.66、上端が 2.58 です。数字より先に単位を見てください。語と文字は別の単位なので、これは「分量が 2.19 倍になる」という意味ではありません。同じ書き手が同じ基準で書いた文章でも、いちばん広い記事といちばん狭い記事で 50 パーセント以上の開きがあります。レイアウトが 1 つの係数を前提にしているなら、その前提はこの実測に裏づけがありません。
もう少し厄介なのは、「文字数」という言葉に定義が 1 つではないことです。
- 「ゲーム開始」に「12 文字以内」という制限をかけると、JavaScript の String.length で測れば通り、12 バイトの列や固定長バッファで測れば落ちます。どちらの測り方も間違いではなく、答えている問いが違うだけです。
- 絵文字では罠が逆向きになります。コードポイントは 1 つなのに UTF-16 コード単位では 2 つなので、JavaScript は 1 文字のグリフに対して長さ 2 を返します。添字で 1 つずつ進む処理は、この 1 文字を「文字ではない半分」2 つに割ってしまいます。
- バイト数はテキストだけでなく符号化方式にも依存します。方式を書いていないバイト上限は意味を持ちませんし、日本語が常にラテン文字より重いわけでもありません。UTF-16 では「ゲーム開始」と Start はまったく同じ 10 バイトです。
- 日本語は語と語の間に空白を置かないので、空白で折り返したり切り詰めたりする実装は、切る場所そのものを見つけられません。語の切れ目は文字としてではなく言語の規則として存在します。どこで改行させるかは別の問題で、日本語・中国語の改行と折り返しにまとめてあります。
node -e 'for (const s of ["Start","ゲーム開始","👾"]) console.log(JSON.stringify(s), s.length, [...s].length, Buffer.byteLength(s,"utf8"));'
# 文字列 UTF-16 コード単位 コードポイント UTF-8 バイト
"Start" 5 5 5
"ゲーム開始" 5 5 15
"👾" 2 1 4
/usr/bin/python3 -c "
for s in ['Start','ゲーム開始']:
print(repr(s), len(s.encode('utf-8')), len(s.encode('cp932')), len(s.encode('utf-16-le')))"
# 文字列 UTF-8 CP932 UTF-16
'Start' 5 5 10
'ゲーム開始' 15 10 10
node -e 'const seg = new Intl.Segmenter("ja", { granularity: "word" });
console.log([...seg.segment("セーブデータを削除しますか")].filter(x => x.isWordLike).map(x => x.segment).join(" | "));'
# セーブ | データ | を | 削除 | し | ます | か (入力に空白は 1 つも無い)数値・日付・金額は、1 文字も翻訳しないまま書き換わる
作業のかなりの部分には翻訳が 1 文字も含まれません。同じ数値・日付・価格を 4 つの市場の書式規則に渡すと、桁はそのままで 4 通りの文字列が返ってきます。この種類は翻訳ファイルでは直せません。置き換えるべき原文が存在せず、実行時にコードが組み立てているからです。
- 小数点と桁区切りが入れ替わります。ドイツ語とブラジルのポルトガル語の表記を素朴に parseFloat に渡すと 1.234 が返ります。例外も警告も出ず、値だけが 6 桁ずれます。プレイヤーが数値を入力する場所や、数値が一度テキストになって戻る経路は、どのロケールが書いた表記なのかを知っている必要があります。
- フランス語の桁区切りは U+202F、幅の狭い改行禁止スペースです。見た目は普通の空白と区別がつきませんが、テスト用の固定値や等価比較を U+0020 で書いていると一致しません。
- 短い数字だけの日付は、見た目ではなく意味が変わります。その形式を複数の地域の読者に見せる設計は、事故ではなく構造として曖昧です。
- 通貨には固有の丸め規則があります。日本円は補助単位を持たないので 1234.5 は 1,235 になり、同じ金額が米ドルでは 1,234.50 のままです。返ってくる円記号は U+FFE5 の全角形で、テストに書きがちな U+00A5 ではありません。記号の位置も一定ではなく、ドイツ語とフランス語では数値の後ろに付きます。
- 各通貨で実際にいくら請求されるかは書式の話とは別で、地域別価格はストア側の価格設定機能で決まります。価格を自分で表示する前に、そのストアの公式ドキュメントで地域別価格の決まり方を確認してください。
node -e 'const n = 1234567.89;
for (const l of ["en-US","de-DE","fr-FR","ja-JP","pt-BR"]) console.log(l, "|", new Intl.NumberFormat(l).format(n));'
# en-US | 1,234,567.89 de-DE | 1.234.567,89 fr-FR | 1 234 567,89
# ja-JP | 1,234,567.89 pt-BR | 1.234.567,89
node -e 'const d = new Date(Date.UTC(2026,2,4));
for (const l of ["en-US","en-GB","de-DE","ja-JP"]) console.log(l, "|", new Intl.DateTimeFormat(l,{dateStyle:"short",timeZone:"UTC"}).format(d));'
# en-US | 3/4/26 en-GB | 04/03/2026 de-DE | 04.03.26 ja-JP | 2026/03/04
node -e 'for (const [l,c] of [["en-US","USD"],["de-DE","EUR"],["ja-JP","JPY"],["fr-FR","EUR"]])
console.log(l, c, "|", new Intl.NumberFormat(l,{style:"currency",currency:c}).format(1234.5));'
# en-US USD | $1,234.50 de-DE EUR | 1.234,50 €
# ja-JP JPY | ¥1,235 fr-FR EUR | 1 234,50 € (¥ は U+FFE5、U+00A5 ではない)
node -e 'console.log(parseFloat("1.234.567,89"), Number("1.234.567,89"));'
# 1.234 NaN数え方・並び順・大文字小文字は、原稿ではなくコードの側にある
言語の規則のうち、翻訳ファイルには一度も現れないものがあります。翻訳者の代わりにコードが決めてしまっている部分です。中でも次の 3 つは静かに壊れ、どれもコマンド 1 本で目に見えます。
- 「個数 + アイテム」のように英語式に連結して組み立てたラベルは、複数形カテゴリが 4 つあるポーランド語やロシア語、6 つあるアラビア語には翻訳しようがありません。カテゴリごとに文面を持つメッセージに作り替える必要があり、そのための仕組みがICU MessageFormatです。
- カテゴリは数の大小では決まりません。1 だけを特別扱いして残りを複数形にする書き方は、英語では正しく、上の表のポーランド語とロシア語では間違っています。
- 小文字の i を大文字にすると、英語では I、トルコ語では点の付いた大文字になります。ラベルを大文字化して見せるボタン装飾や、ロケールを指定しない大文字小文字無視の比較は、トルコ語の 1 文字を別の文字に変えてしまいます。表示のために大文字化するなら、必ずロケールを渡してください。
- 並び順も言語の規則です。ドイツ語はウムラウト付きの文字を元の文字と同じ位置に並べ、スウェーデン語はアルファベットの末尾に置きます。コードポイント順は、このリストではたまたまスウェーデン語と一致しますが、ドイツ語では依然として誤りです。1 つのロケールに偶然一致することは、並んでいることとは違います。
node -e 'for (const l of ["en","ja","pl","ru","ar","fr"])
console.log(l, "|", new Intl.PluralRules(l).resolvedOptions().pluralCategories.join(" "));'
# en | one other ja | other
# pl | few many one other ru | few many one other
# ar | few many one two zero other fr | many one other
node -e 'for (const n of [0,1,2,5,22]) console.log("pl", n, new Intl.PluralRules("pl").select(n),
"| ru", new Intl.PluralRules("ru").select(n), "| en", new Intl.PluralRules("en").select(n));'
# 0 -> pl many | ru many | en other
# 1 -> pl one | ru one | en one
# 2 -> pl few | ru few | en other
# 5 -> pl many | ru many | en other
# 22 -> pl few | ru few | en other
node -e 'console.log(JSON.stringify("i".toLocaleUpperCase("en")), JSON.stringify("i".toLocaleUpperCase("tr")), JSON.stringify("I".toLocaleLowerCase("tr")));'
# "I" "İ" "ı" -- 入力 2 種類から 3 種類の別の文字
node -e 'const names = ["Zoe","Ärger","Apfel","Ost","Öl"];
console.log("codepoint |", [...names].sort().join(" "));
for (const l of ["de","sv"]) console.log(l, " |", [...names].sort(new Intl.Collator(l).compare).join(" "));'
# codepoint | Apfel Ost Zoe Ärger Öl
# de | Apfel Ärger Öl Ost Zoe
# sv | Apfel Ost Zoe Ärger Öl出荷した言語タグが、選んでいないことまで決める
ストアのページでは言語は 1 単語です。システムの中ではタグになり、足りない部分は勝手に補完されます。言語サブタグだけのタグを ICU に展開させると、何も指定しなかったときにプラットフォームが置く前提がそのまま出てきます。
- pt とだけ書いて出すと、想定読者はブラジルになります。zh なら中国本土の簡体字、es ならラテンアメリカのどこかではなくスペインです。この補完は CLDR の likely subtags、つまり「そのタグが最も高い確率で何を指すか」を集めた公開データに基づくもので、あなたのプロジェクトの事情は 1 つも見ていません。前提が翻訳した内容と食い違っていても、パイプラインは何も教えてくれません。タグとしてはどちらも妥当だからです。
- 地域は飾りではありません。ブラジルのポルトガル語とヨーロッパのポルトガル語は別のロケールで、違いは 1 語も翻訳しないうちから出ます。pt-BR は桁をドットで区切り、pt-PT は空白で区切ります。
- 文字体系は 3 本目の軸です。簡体字と繁体字は方言のラベルではなく別の書記体系です。どのサブタグが存在してどの順に並ぶかという文法はBCP 47 の言語タグ、空欄を埋めるデータ(この既定値を含む)はUnicode CLDRです。
- ゲームエンジンとストアが受け付ける識別子は、規格ともお互いとも別の独立したリストです。BCP 47 として完全に妥当なタグでも、ストアの言語一覧に無いことがあります。エンジンの公式ローカライズドキュメントと、ストアの対応言語に関する公式ヘルプで確認してください。
node -e 'for (const t of ["pt","zh","es","en"]) console.log(t, "->", new Intl.Locale(t).maximize().toString());' # pt -> pt-Latn-BR zh -> zh-Hans-CN es -> es-Latn-ES en -> en-Latn-US node -e 'for (const l of ["pt-BR","pt-PT","zh-Hans","zh-Hant"]) console.log(l, "|", new Intl.NumberFormat(l).format(1234567.89), "|", new Intl.Locale(l).maximize().toString());' # pt-BR | 1.234.567,89 | pt-Latn-BR # pt-PT | 1 234 567,89 | pt-Latn-PT # zh-Hans | 1,234,567.89 | zh-Hans-CN # zh-Hant | 1,234,567.89 | zh-Hant-TW
残りは、そもそも文字列ではない
文字数、書式、文法、ロケールタグは、プログラムで測れる範囲です。残りは翻訳ファイルが届かない素材で、早めの棚卸しに価値があります。コストが語数ではなく、絵と音の作業時間で数えられるからです。
- 画像に描き込まれた文字。テクスチャの看板、スプライトのタイトル、カットシーンのラベル。どれも文字列テーブルからは触れず、言語ごとに新しいアセットが増えます。
- フォント。ラテン文字向けに選んだ書体が、日本語・韓国語・キリル文字のグリフを持つとは限りません。ブラウザや OS は文字体系ごとのフォールバック連鎖で埋めますが、ゲームエンジンは自前の描画系で、既定ではフォント 1 つ・フォールバック先なしから始まることが多く、埋めるまで四角い箱が出ます。UI の設計サイズで一式が揃わないピクセルフォントや手描きの書体は、特に厳しくなります。
- コードが外に出していない文字列。スクリプトやシーンに直接書かれたテキストは翻訳者から見えないので、洗い出しは後片付けではなく前提条件です。やり方はハードコードされた文字列の外部化にあります。
- ボイス、字幕のタイミング、年齢レーティング、ストアページの各項目は、それぞれ独立した工程です。要件はストアごと・地域ごとに違うので、出すプラットフォームの公式な提出要件を直接読んでください。
- ハードコードされた文字列のようにソース走査で実行前に見つかるものもありますが、見切れ・字形の欠け・日付の並びは、そのロケールでビルドを動かすまで出ません。ローカライズしたビルドの確認は、翻訳文の読み合わせとは別の作業です(ローカライズQA(LQA)とは)。
つまりローカライズの範囲とは、対応言語の一覧のことではありません。「このゲームが伝えている内容のうち、翻訳者が編集できない場所にあるのはどれだけか」という、自分のプロジェクトについての 1 つの問いへの答えです。ここまでの数字は、今日ある文字列に対して今日測れます。測れない部分(絵、音、ストアの要件)は、期日を約束する前に手で数えておく価値がある部分です。