実装Read this article in English

GameMakerの多言語対応 — エンジンが用意してくれない部分を自分で作る

GameMakerのローカライズ機能を探しているなら、見つかりません。文字列テーブルのアセットも、翻訳の取り込み機能も、言語切り替えの仕組みも用意されていません。エンジンが渡してくれるのはファイルを読む手段、キーと値を保持する手段、テキストを描画する手段の3つで、ローカライズはその3つから自分で組み立てるものです。

これは聞こえるほど悪い話ではありません。GameMakerで動くローカライズ層は数百行で書けますし、自分で書いたぶん、何をしているのか完全に把握できます。厄介なのは複雑さではありません。エンジンが用意してくれない部分は、そのまま「壊れるまで考えない部分」でもある、という点です。必要な文字を持っていないフォント、スペースがある前提の折り返し、改行に化ける特定の記号、そしてルックアップを一度も通っていないまま各オブジェクトに散らばったテキスト。

この記事では、後から覆すのが難しい設計判断を、実際にぶつかる順番でまとめます。

ルックアップ関数は1つ、描画にリテラルは書かない

システム全体は、キーを受け取って現在の言語の文字列を返す1つの関数の上に乗ります。何千回も打つことになるので名前は短く、そしてプレイヤーが読むテキストが画面に出る経路をこの関数だけに限定します。

重要なのは否定形のルールのほうです。描画呼び出しの中にリテラルのテキストを書かない。仮テキストも、デバッグ用のラベルも、あとで直すつもりの1語のボタンも例外にしない。どこか1箇所でもリテラルを許すと、後からそれを見つける作業はプロジェクト内の全オブジェクトを読むことになりますし、警告してくれるものは何もありません。初日に「描画中のリテラルはバグ」と決めるのはタダですが、6か月目に決めると全数調査の費用がかかります。

ルックアップは失敗したときに騒がしくします。エントリが見つからないときにキー自体を返すのが定番で、実際よく機能します。空文字と違って、キーが画面に出ていれば明らかにおかしいと分かるからです。開発ビルドではさらにログにも出しておきます。稀な状態でしか出ないキーの欠落は、誰かがその状態を対象言語で踏むよりずっと早く、ログの側で気づけます。

文はテンプレートから組み立て、翻訳済みの断片を連結して作らないでください。訳した名詞と訳した動詞をつなげて文法が成立するのは、設計時に想定した言語だけです。プレースホルダーを含む文全体を1つのキーにしておけば、翻訳者は自分の言語が要求する位置にプレースホルダーを動かせます。

// 入口は1つ。_key は文字列。現在の言語のテキストを返す。
function L(_key) {
    var _s = global.strings;
    if (variable_struct_exists(_s, _key)) return variable_struct_get(_s, _key);
    if (GM_build_type == "run") show_debug_message("MISSING KEY: " + _key);
    return _key;
}

// 連結ではなくテンプレート
// strings: "msg_found_item" -> "{0}を見つけた!" / "You found {0}!"
draw_text(x, y, string_replace(L("msg_found_item"), "{0}", item_name));

テキストをどこに置き、どう壊さずに読み込むか

テキストファイルはIncluded Filesに置いてビルドに同梱します。CSVかJSONかは、一貫していることのほうが重要ですが、それぞれに実際のトレードオフがあります。CSVは全言語が1枚のグリッドに収まるので、未翻訳が空セルとして見え、表計算ソフトがそのままエディタとして使えます。JSONは複数行のテキストも引用符も入れ子構造も、CSVが必ず最後に生むエスケープの議論なしに扱えますし、GameMakerなら1回の呼び出しで構造体に変換できます。

よく採られる中間案が、言語ごとに1つのJSONファイルを同じキー構成で用意し、起動時か言語切り替え時に読み込む形です。ファイルが小さく保て、翻訳者に渡すのがどのファイルか一目で分かり、壊れた翻訳ファイルが影響する範囲もその言語だけに閉じます。

読み込みは、行単位のテキストファイル関数ではなくバッファ経由で行ってください。テキスト関数は行志向の仕様のため、テキスト中の改行と相性が悪く、UTF-8の内容をそのままの形で取り込むにはバッファ読み込みのほうが確実です。英語のファイルは正常に読めるのに日本語やロシア語の翻訳だけ文字化けする、という現象の原因はほぼこれです。ファイルは正しく、読み手のほうが正しくなかった、というパターンです。

ファイルはBOMなしのUTF-8で保存されているか確認してください。ファイル先頭のBOMは最初のキーにくっついた不可視文字になり、そのキーだけが永久に一致しなくなります。症状としては「なぜか1つだけ文字列が出ない」という分かりにくい形で現れます。CSVを表計算ソフトで編集するチームなら、往復のたびに確認する価値があります。表計算ソフトは文字コードについて独自の考えを持っており、しかもそれを毎回宣言してくれるわけではありません。

// Included Files から言語ファイルを UTF-8 として読み込む
function lang_load(_code) {
    var _file = "lang/" + _code + ".json";
    if (!file_exists(_file)) { _file = "lang/ja.json"; }
    var _buf  = buffer_load(_file);
    var _text = buffer_read(_buf, buffer_text);
    buffer_delete(_buf);
    global.strings  = json_parse(_text);
    global.language = _code;
}

// 起動時: OSの言語を既定にしつつ、保存された設定があればそちらを優先
var _pref = /* セーブ/設定から読み込む */ undefined;
lang_load(is_undefined(_pref) ? os_get_language() : _pref);

GameMakerで一番やっかいなのはフォント

GameMakerのフォントアセットは、指定した文字範囲を指定サイズでテクスチャに焼き込みます。この設計は高速で挙動も読めますが、同時に、日本語・中国語・韓国語、あるいはキリル文字一式を追加するのが小さな変更では済まない理由でもあります。既定のフォントアセットが持っているのは基本的なラテン文字の範囲で、そこから外れた文字は描画されません。

現実的な選択肢は3つです。広い文字範囲を含めて大きなテクスチャを許容する — キリル文字やギリシャ文字なら成立しますが、実用範囲が数千字に及ぶCJKでは厳しくなります。翻訳済みテキストが実際に使っている文字だけを含むフォントを生成する — テクスチャは小さく保てますが、テキストが変わるたびに再生成が必要なので、誰かの記憶ではなくビルド手順の中に組み込む必要があります。あるいは実行時にフォントを描画する拡張機能を使い、事前に文字集合を決めなくてよいことと引き換えに導入の手間を取る。

どれを選ぶにしても、フォントはグローバル定数ではなく言語の属性として持ってください。ラテン文字とCJKが同じピクセルサイズ・同じ行間で同時に整って見えることはまずありません。言語データ側に、使用するフォントアセット、描画時の行の高さ、サイズの補正値を持たせ、描画コードはそれを読む。フォント名を直接書かない、というだけの話です。

文字範囲の指定方法や生成オプションは、使っているGameMakerのバージョンのフォントエディタのドキュメントで確認してください。この部分はリリースごとに画面が変わっており、一般論よりも細部のほうが結果を左右します。

日本語のテキストを描画するときの3つの落とし穴

GML固有の挙動が、だいたいこの順番で問題になります。

折り返しはスペースで切れます。折り返し付きのテキスト描画関数はピクセル幅を受け取り、単語の境界で改行します。つまりスペースを一切含まない日本語や中国語の文は、1つの巨大な単語として扱われ、枠から真横に飛び出していきます。これらの言語では自分で改行を入れる必要があります。1文字ずつ利用可能な幅と比較し、次の文字がはみ出すところで改行を挿入する。聞くほど大変ではないのは、一般的なフォントではCJKの文字送り幅がどれも同じで、測定がほぼ算術になるからです。その上に基本的な禁則処理 — 行頭に句点や閉じ括弧を置かない — を足しておくと、機械的ではなく意図して組んだ見た目になります。数十行で書ける追加です。

シャープ記号には旧来の意味があります。GameMakerのテキスト描画では、エスケープしていないシャープ記号は文字として出力されるのではなく改行として働いてきました。英語の原文には含まれないことが多い一方、翻訳文には入りえます。特に順位、番号、チャンネルに関する文言で起きます。使っているバージョンでの挙動を確認し、該当するなら、翻訳者全員がエンジンの仕様を知っていることを期待するのではなく、読み込み時にこちらでエスケープしてください。

推測せず測る。文字列の描画時の幅と高さを返す関数だけが、その訳文が収まるかどうかを確実に知る方法で、実際に使用中のフォントを踏まえた値を返します。現在の言語で全テキスト要素の外接矩形を描くデバッグ表示を作っておくと、はみ出しを1画面ずつ確認するのではなく一目で見つけられます。収まらないときにどうするか — 縮める、折り返す、スクロールさせる、省略する — は設計判断なので、テキスト要素ごとに方針を先に決めておくほうが、はみ出し報告に個別対応するより速く終わります。

描画テキスト以外のすべて

ルックアップ関数がカバーするのは文字列です。ゲームが持っている「言語」はそれだけではありません。以下は、システムのどこも管理していないために、未対応のまま出荷されやすい部分です。

  • 文字が描き込まれたスプライト。タイトルロゴ、看板、チュートリアル画像、ラベル付きのボタン表示。言語ごとにスプライトを用意して言語コードで差し替えるか、実行時に画像の上から文字を描く設計に変える
  • 音声。ボイスと、実質的にセリフである効果音
  • ウィンドウタイトル。起動時に1回設定したきり、ほぼ見直されない
  • セーブデータ内のテキスト、実績名、そして自分の描画コードではなくファイルやプラットフォーム側が表示する文字列
  • 数値・日付・通貨の書式。周囲の文言とは独立にロケールで変わる
  • ストアページのテキスト、スクリーンショット、説明文。プロジェクトの外にあり、そして他言語のプレイヤーが最初に読むもの

2言語目を足す前のチェックリスト

GameMakerプロジェクトのローカライズ費用の大半は、翻訳が届く前に決まっています。そもそもプロジェクトが翻訳を受け入れられる形になっているかどうかです。まだ1言語のうちにこのリストを通しておくほうが、3言語になってからやるより桁違いに安く済みます。

  • 描画されるすべての文字列がルックアップ関数を経由し、描画コード内にリテラルが1つもない
  • 文はプレースホルダー付きのテンプレートから組み立てており、断片の連結で作っていない
  • 言語ファイルはバッファ経由で読み込み、BOMなしのUTF-8で、ファイルが無いときは静かに失敗しない
  • フォント・行の高さ・サイズ補正が言語データの一部になっており、描画コードの定数になっていない
  • スペースのない言語で折り返しが機能する。同じ文字の繰り返しではなく実際の訳文でテストしてある
  • 開発ビルドではキーの欠落がログに出る。現在の言語でテキストの外接矩形を表示するデバッグ表示がある
  • 選択された言語が保存・復元される。OSの言語は初回の既定値としてのみ使う

関連記事