0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeを使用してPokémonのファンゲームを日本語訳した

0
Posted at

TL;DR

英語のみのファンゲーム Pokémon Reborn(シナリオ約8万行)を日本語化し、パッチとして公開しました。

やる前は「テキストを抜き出して、訳して、書き戻すだけ」だと思っていました。
実際にやってみると、訳文を出す工程が一番簡単で、時間の大半は次の2つに消えています。

  • 訳したのに英語のまま画面に出る —— 国際化の仕組みが取りこぼしている文字列を探す作業
  • 訳せているのに作品が壊れる —— 8万行を分割して訳すと、同じキャラが章ごとに別人になる

どちらもゲーム固有の話ではなく、既存プロダクトを後から多言語化するとき一般に起きることでした。
自分がどこで詰まって、何を決めて先へ進んだかを中心に書きます。


何を作ったか

Pokémon Reborn は Pokémon Essentials(RPG Maker XP のポケモン風フレームワーク)製のファンゲームです。
インストール先に上書きすると、タイトル画面から日英を切り替えられるようになるパッチを作りました。

翻訳行数 82,516 / 82,609 行(99.9%)
差し替えたエンジンスクリプト 44 ファイル
自作ツール Python 約 5,800 行
作業期間 実質 1 週間

翻訳データは Ruby の Marshal 形式だったので、最初に用意したのは
それを読み書きする純 Python 実装(401行) です。ここを Ruby 依存にしないと決めたおかげで、
以降のツールを全部 Python で書けて、中間表現も JSONL に統一できました。

作業を始めてすぐ、進め方を決められる性質を1つ見つけています。
このフレームワークの翻訳テーブルは「英語の原文をキーにした Hash」で、配列のインデックス方式ではない。つまり、

  • 行のずれが構造的に起こらない
  • 未訳のまま実行しても壊れない(キーが無ければ英語がそのまま返る)

部分適用が常に安全だと確認できたので、「全部訳してから初めて動かす」のではなく
「小刻みに実機へ流して確認する」進め方に切り替えました。
後から多言語化する場合、この性質があるかどうかで段取りがまるごと変わります。

分担をどう決めたか

先に書いておくと、私と Claude Code の分担はこうなりました。

私が決めたこと Claude Code に任せたこと
どこが壊れているかの判断、直し方の選択 原因箇所の特定、コードを辿る作業
口調・用語・「訳さないもの」の決定 8万行の訳文生成
どんな検査を入れるか 検査ツールの実装
実機で見て良し悪しを判定する (できない)

最初は逆でした。訳文の生成を主軸に置いて、詰まるたびに個別に直していたのですが、
それだと同じ罠を何度も踏むと気づいて、途中で組み替えています。その経緯を以下に書きます。


第1部: 訳したのに表示されない

翻訳漏れの大半は「訳が無い」ではなく 「訳しようがない」 でした。
踏んだものを後から整理すると、5つのパターンに収まります。
どれも Reborn 固有ではありません。

パターン1: 翻訳テーブルに載る条件がある

多くの i18n フレームワークは、ソースを静的にスキャンして翻訳キーを収集します。
つまり 「スキャナが読める書き方」をしていない文字列は、そもそも翻訳対象に存在しません。

これを、訳を入れたのに変わらない画面を何度か見てから気づきました。
先に抽出器の実装を読んで何を拾わないのかを把握しておけば、もっと早く済んだはずです。
たいてい素朴な正規表現なので、穴は数種類に限られます。

Reborn の場合: ラップ関数を通さず生の文字列を表示している箇所が約150。
さらに、抽出器がダブルクォートしか見ないためシングルクォートで書かれた27件、
括弧の位置が想定外で拾われない数件、エスケープ処理の差で
登録キーと実行時の文字列がずれる3件がありました。

直し方は2通り考えられました。呼び出し側を150箇所書き換えると差分が膨れるので、
表示関数の側で引き直すほうを選んでいます。
訳済みの文字列はキーとして存在しないためそのまま素通りする、という点が決め手でした。
これだけで83件が回収できました。

def Kernel.pbMessage(message, ...)
  message = Kernel.pbLocalize(message)   # 表にあれば訳を返す。無ければそのまま返す

判断の指針: 表示レイヤの入口が1箇所に絞れるなら、そこで受けるのが一番安い。

パターン2: 実行時に組み立てた文字列はキーにならない

件数・厄介さともに、これが最大でした。

pbDisplay(_INTL("#{side} team's Light Screen faded!"))

文字列補間は翻訳関数に渡る前に評価されます。したがって、

  • 抽出器が登録するキー: #{side} team's ...(ソース上のリテラルそのもの)
  • 実行時に引かれるキー: The opposing team's ...

この2つは絶対に一致しません。 訳をどこに書いても引かれない。

最初は「訳が抜けているのだろう」と思って訳を足し直していたのですが、何度やっても変わらず、
キーの登録側と参照側を突き合わせてやっと構造の問題だと分かりました。

これは Ruby に限らず、テンプレートリテラルを翻訳関数に直接渡せる言語すべてで起きます
(JS の t(`Hello ${name}`) も同型)。
「翻訳関数の引数に補間がある」は常に不具合なので、機械的に検出できます。
気づいた時点で検出条件に加えました。

対処は、補間される値の範囲で分けています。

  • 値の範囲が閉じている(列挙できる)→ 展開後のキーを全通りデータとして登録する。
    コードを触らずに済みます。Reborn ではアイテム入手メッセージがこれで、
    カテゴリ8種 × 単数複数 = 16件を追加して解決しました
  • 値の範囲が開いている(人名など)→ プレースホルダ形式に書き換える
# 英語の出力が元と完全に一致するよう分割位置を選ぶ
sides = [_INTL("Your team"), _INTL("The opposing team")]
pbDisplay(_INTL("{1}'s Light Screen faded!", sides[i]))

判断の指針: 「キーを増やす」で済むならコードは触らない。
ただし値の範囲が将来増えないか(=どこを見れば気づけるか)は確認しておく。

パターン3: 表示文字列がアセットのキーを兼ねている

サイレントに壊れるので、一番たちが悪いパターンでした。

表示用に取得した文字列が、そのままファイルパスやクラス名や API のパラメータに
流れ込んでいることがあります。訳を入れた瞬間に、存在しないリソースを指し始める。
しかも読み込み失敗が握り潰されていると、エラーも出ずに要素が消えます。

Reborn の場合: 天気の名前を訳した結果、画像パスが weather_晴れ になりました。
読み込み失敗が rescue されて nil になるので、天気アイコンだけが黙って消えます。

私はこれを「画面がなんとなく寂しい」という違和感からしか見つけられませんでした。
テストでもログでも引っかからないので、訳を入れる前に確認するしかない種類の問題です。

対処は、識別子としての値と表示用の値を分けること。
配列は英語のまま持ち、表示する直前に訳す形に変えました。

判断の指針: 訳を入れる前に「その文字列が識別子を兼ねていないか」を確認する。

パターン4: キャッシュされる中間データが言語に汚染される

初回起動時に生成して以降は再生成しない、というキャッシュはよくあります。
そこに言語依存の値が混ざると、最初に起動した言語で固定されます。

Reborn の場合: ゲーム内の解説文が初回起動時に .dat へ書き出され、以降再生成されません。
生成処理が本文の一部を翻訳済みの用語で埋めていたため、日本語で初回起動すると
訳語が焼き付きます。おまけに用語がアイコン参照名を兼ねていたので(パターン3)、
アイコンも消えました。

生成側は英語で固定し、表示直前に訳す形に変更しました。
既存ユーザーのキャッシュを壊さないよう、
パッチ後の出力がバイト一致することまで確認しています。

判断の指針: 永続化される中間データは言語に依存させない。
「生成時に訳す」ではなく「表示時に訳す」。

パターン5: 表はあるのに読む側がいない

未訳に見えて、実はデータは揃っているのに読み出し経路が無いというケースです。

これは逆にうれしい誤算でした。訳を1行も書かずに直るからです。
「訳が無い」と決めつける前に、テーブルが空なのか、読んでいないのかを確認する価値があります。

Reborn の場合: 地名851件が正しくテーブルに書き出されていたのに、
読み出す側が1つも存在しませんでした。 どの参照もキャッシュの英語名を直接見ていた。
アクセサ経由に変えるだけで、エリア看板・セーブ画面・ポケモンの「出会った場所」が
一斉に日本語になりました。トレーナーの敗北セリフ771件も同型です。

個別に直すのをやめた

ここが転機でした。上の5パターンは、すべてソース上の形で表せます。

それまでは症状を1つ見つけるたびに個別に直していたのですが、
毎回「同じ形の別の箇所」が後から出てくる。数を数えたら、まだ相当残っている感触がありました。

そこで方針を変えて、訳文を作らせるのをいったん止め、検出器を1本ずつ育てることにしました。
検出したいものを私が挙げて、実装は Claude Code に任せる、という進め方です。

検出対象として挙げたのは、

  • 表示関数に生の文字列を渡している行
  • 翻訳関数の引数に文字列補間を含むもの
  • 抽出器の正規表現から外れる書き方(クォート種別・括弧位置・エスケープ)
  • アクセサを通さない生の名前参照
  • 描画ヘルパに渡される配列リテラル(そもそも「表示関数の呼び出し」ですらない)

最後のものは他のどの検査にも引っかかりません。
Reborn では操作説明の一覧48行が丸ごとこれで、検出器を書いて初めて存在に気づきました。

同時に、「英語のまま残すのが正しいもの」は理由つきで許可リストに入れるようにしました。
アクセサの実装本体、型番から数字を取り出す並べ替え処理、券面の略号、
スタッフロールの実在の人名などです。ここを曖昧にすると、検出器がノイズだらけになって使われなくなります。

最終的に、意図的に英語のまま残した93行だけが残り、内訳を README に表で載せました。

ゴールを「未訳ゼロ」から「未訳の理由が全部説明できる」に置き換えました。
前者は到達判定ができませんが、後者は許可リストの差分で判定できます。


第2部: 大量生成を機械で受け止める

LLM に大量のテキストを書かせる以上、壊れ方を先に列挙して、それぞれに検査を1本用意する必要があります。
どんな検査を入れるかは私が決め、実装は任せました。
書き戻しツールは各行を検査し、通らない行は書き込まずに報告します。

検査 何を防ぐか
プレースホルダの多重集合一致 制御コードや {1} の欠落。LLM の取りこぼしを一番よく捕まえた
使用可能文字のチェック 表外漢字が混ざってフォントに無く、豆腐になる
フォントのグリフ照合 同上。同梱フォントに存在しない文字の検出
表示幅の検証 固定幅 UI からのはみ出し。訳文の自動改行も同じツール
構文チェック(ruby -c スクリプトが1つでも壊れるとゲームが起動しない
公開前スクラブ 配布物に作者を特定できる文字列が混入していないか

運用してみて調整したことを3つ書きます。

プレースホルダ検査には例外を設けました。 主人公名のプレースホルダは
「原文より多く使ってよい」——引数を持たず表示時に展開されるだけで、
日本語の語順では英語の "you" の位置に名前を置くほうが自然だからです。
最初は厳密一致にしていましたが、まともな訳が弾かれ続けたので緩めました。
欠けている場合は従来どおり拒否します。厳密さより、言語差を許容する方向に一箇所ずつ穴を開けるほうが実用的でした。

構文チェックは「書く前」に全部走らせて、1件でも落ちたら何も書かずに中止する形にしました。

  ** 構文エラー  Scripts/RegionMap.rb
                 Scripts/RegionMap.rb:628: syntax error, unexpected end-of-input, expecting `end'

1 ファイルが壊れているため、書き込みを中止しました。

中途半端に当たった状態より、未適用のほうがましだからです。
ただし検査できるのは構文だけで、API の誤用や表示崩れは落ちません。そこは実機で見るしかない。

公開前スクラブは、配布物にホームディレクトリのパスや作業環境の痕跡が
混ざっていないかを見ます。アカウント名は実行時に機械から取り、それ以外は
/home/<名前> のようなパスの形で捕まえる形にしました
(検査したい文字列を公開ファイルに書かずに済ませるためです)。
見つかったら成果物を削除して異常終了させています。

やる気になれば手で確認できることほど、リリースのたびに手でやるのが続きません。


第3部: 世界観を壊さないためにしたこと

ここからが後半です。技術的に正しく表示されても、訳文の質が悪いと作品は壊れます。
ここは機械では判定できないので、私の判断が一番効いた部分でした。

なぜ「分割して訳す」と壊れるのか

8万行は1回のセッションには載らないので、バッチに切って順に処理します。
ここで日英の構造的な差が効いてきます。

英語の "I" は1つですが、日本語には「私/僕/俺/あたし/わたくし」があります。
語尾も敬体・常体・「〜だぜ」「〜わね」と分岐する。
つまり原文に存在しない情報を、訳す側が毎回ゼロから決めているわけです。

決め方が場面ごとに違えば、同じキャラが章ごとに別人になります。
私が最初に出した訳文がまさにそれで、1行ずつ見れば全部正しいのに、
通しで読むと人格が定まっていませんでした。

これは翻訳に限らないと思っています。出力に「入力が指定していない自由度」があるとき、
分割して生成すると必ずぶれます。
コード生成における命名規則やエラーメッセージの文体と同じ問題です。

対処は単純で、先に決めて、ファイルで共有するしかありませんでした。

決めたことをファイルに落とす

翻訳ガイドを1枚書き、担当を分けても必ずそれを参照させるようにしました。中身は3種類です。

1. 口調表(21キャラ+追加分)

キャラ 一人称 / 特徴
シエル わたくし / 「〜かしら」
カイン オレ / 「〜だよねぇ」
テラ アタシ / ネットスラング多用
フロリニア である調・無機質なAIのような話し方
フェルン オレ様

決め方の順序で一度失敗しています。
最初に理屈で表を書いたら、実際のセリフに当てた時点で破綻しました。
結局、まず一部を訳して、良かったものを承認済みサンプルとして固定し、
そこから表を起こすという順序に変えています。

そのうえで「迷ったら該当キャラの既訳を grep して合わせる」を手順に入れました。
表に載っていない揺れは、既訳が正解になります。

2. 表記の一元管理

人名948件、地名365件、公式訳の用語集2,850件を JSON で持たせました。
用語集はゲームデータから自動生成しているので、
画像に描き込む文字も同じ表から引けて、画像とゲーム内表記が食い違いません。

3. 訳さないものの一覧(後述)

「用語集に無い=独自語」ではない

ここが世界観に一番効いた判断でした。

用語集はゲームのデータから生成しているので、
会話の中でしか言及されないものは載っていません。
実装されていない世代のポケモンでも、セリフには出てきます。

つまり 表に無いからといって、そのタイトルの独自語とは限らない。
取り違えると、原作からの引用が独自語に見えたり、逆が起きたりします。
実際、最初は表に無いものを独自語として訳してしまい、後から直しました。

そこで判断基準を明文化しました。

  • 実在するもの → 表に無くても公式名を使う。 確定したら補遺ファイルに追記して次の担当へ渡す
  • その作品オリジナルで公式名が存在しないもの → 自然な日本語に訳す

判断が要る語の例として面白かったのがもじりです。
作中に敵組織の名前を言い間違えた造語が出てきます。音が原語に近く、かつ別の単語が掛かっている。
音を優先すると言い間違いに見えず、意味を優先すると音が繋がらない。
両方が残る第三の訳語を探す必要がありました。

こういう語は1つずつ自分で決めるしかないので、確定したものは全部ガイドに書き足しています。

テーマがあるものは「シリーズ」で渡す

トレーナーが手持ちに付けているニックネームが、37トレーナー・404体・229種類ありました。

これがトレーナーごとにテーマを持っています。

トレーナー 命名テーマ
アマリア 雲と沈んだ大陸
ルナ 不思議の国のアリス
ジュリア 擬音
イカル / クリム

1体ずつ渡して訳させたら、このテーマが消えました。
個別に見れば「ただのカタカナ」なので、訳す側は気づきようがない。
同じトレーナーの分をまとめて渡し、シリーズとして訳させる形に組み直しています。

一般化すると: 入力を分割する軸は、意味のまとまりに合わせる。
件数で機械的に切ると、まとまりを跨いだ瞬間に一貫性が落ちます。

「訳さない」を先に決める

逆に、訳してはいけないものもあります。
ここを先に決めておかないと、訳す側は「未訳=手抜き」と判断して全部訳してしまいます。

  • 字形そのものが内容になっているもの(記号を文字に見立てた表記、顔文字、連番)は原文のまま。
    カタカナにすると成立しません
  • 表記の揺れが演出のもの。 ある人格は原文で大文字小文字を変えた3通りの綴りで登場しますが、
    日本語ではこの演出が再現できないので、私の判断で1つの表記に統一しました
  • 文字化けを模した演出、ラテン語の銘、伏せ字、パロディの入力コマンド列もそのまま

「意図的に英語のまま残す93行」の大半がこれです。

用語の出典を揃える

作中の専門用語には、すでに公式訳が存在するものがありました。
旧版の資料にあたるマップに訳語が入っているのを見つけたので、そちらに合わせています。

同じ概念が画面によって別の呼び方になるのが、地味に世界観を削ります。
新しく訳す前に、作品内に既訳が無いかを探すほうが早いと学びました。


第4部: Claude Code の使い方で効いたこと

1. 1件直すより、検出器を書く

翻訳そのものは、正直どの LLM でもそれなりにできます。
差が出たのは 「なぜ英語のまま出るのか」を突き止める工程でした。

「天気アイコンが消える」という症状を渡して、
表示用に取得した文字列がパス組み立てに流れ込んでいる、というところまで
コードを辿ってもらう。ここは任せてよい作業だと分かりました。

そのうえで私がやるべきだったのは、原因が分かったら「同じ形をした他の箇所」を探す検出器に落とすことです。
最初はこれをやらず、報告された1件を直しては次に進んでいました。
結果として同じ罠を何度も踏んでいます。

症状を渡す相手としては優秀ですが、「これは他にもあるのでは」と聞くのは自分の仕事でした。

2. 訳す側に検証をさせない

品質側で一番効いたのは、訳す工程と検証する工程を分けたことです。

ガイドに 「検証コマンドを自分で走らせないこと」 と明記しました。
文字種チェックも表記ゆれも、後処理ツールが一括でやるからです。
訳す側が守るのは1点だけ ——「プレースホルダを原文と同じ個数だけ残す」。

最初は分けていませんでした。その結果、
検証を通すために原文を改変するという事故を2件出しています。

  • 制御コードの直後に空白なしで英単語が続く箇所で、単語までをプレースホルダだと誤解し、
    訳文に英単語を残していた
  • 訳す必要のない略号の行を、「原文をそのまま返すと弾かれる」と考えて全角文字に置き換えていた

どちらも「検証に落とされたくない」という動機から出た改変です。
私が検証の存在を伝えたことが原因でした。

評価関数を見せると、評価関数を回避する方向に最適化される。

そこで「取りこぼしは後処理が検出して報告するので、迷ったら加工せずそのまま出して、
報告に一言書いてほしい」という形に書き換えました。これで事故が止まっています。

3. コードへの改変は「宣言」として持つ

エンジンスクリプトの改変は、編集後のファイルではなく old → new の宣言の集合として保持しています。
本体が更新されて上書きされても、流し直せば復元できるようにするためです。

ただし単純な文字列置換には罠がありました。
置換後のテキストが他の箇所に既に存在すると、「適用済み」と誤判定して永久に当たりません。
実際に1件踏んで、しばらく原因が分からず時間を使いました。
判定用のマーカーは置換後テキストと別に持つか、一意になるまで置換範囲を広げる必要があります。

4. ドキュメントを「次のセッションへの入力」にする

apply_batch.py → dejoyo.py → build.py → sync.sh

この順序は必ず守る、と開発メモに書いています。
セッションは切れるので、手順・落とし穴・判断理由を
リポジトリ内のドキュメントに成果物として残すのが結果的に一番効きました。

口調表・用語集・命名テーマも同じです。
一貫性は記憶ではなくファイルで担保する。
このドキュメントは作業の副産物ではなく、次のセッションへの入力として書いています。
この記事の内容も、ほぼそこから起こしました。

5. 最後は自分でプレイするしかない

構文チェックが通っても API の誤用は落ちません。表示崩れも落ちません。
「収まるはず」と計算した結果が実機で重なっていた、というのは何度もありました。

中間確認として一番早かったのは、実フォントを使って原寸の PNG モックを描いて目視することです。
実機に流す前に大半の崩れが分かります。

そして訳文の良し悪しは、通しでプレイしないと判定できません。
口調表が効いているか、用語がぶれていないかは、
検査項目に落とせなかった部分です。ここだけは最後まで自分でやりました。


ついでに作った独自改善

翻訳中に「これは元から使いづらいな」と思った箇所を1つだけ直しました。

ゲーム内の地図画面は、カーソルを1マスずつ動かして地名を読むしかありません。
しかもカーソルにキーリピートの待ちが無く、毎秒10マス進みます。
横が15マスしかないので、端から端まで1.5秒。目的のマスで止まれない。

地図を左に寄せ、右に地名の一覧を出し、
一覧を上下すると地図のカーソルがその地点へ飛ぶ(逆も追従する)ようにしました。

この画面だけ元に戻せるようにもしてあります。追加したスクリプトには翻訳が含まれておらず、
地名は翻訳データから引かれるので、日本語表示を失わずに元の挙動へ戻せます。


権利まわり

ファンゲームの翻訳パッチなので、ここは慎重にやっています。

  • ゲーム本体は同梱しない。 配布するのは差分のみ
  • ポケモン・わざ・とくせい・どうぐ名は公式日本語名を使うが、
    権利が任天堂・株式会社ポケモン・ゲームフリーク・クリーチャーズに帰属することを明記
  • 同梱フォントは M+ FONT LICENSE と CC BY-SA 3.0 の合成物なので、継承条件に従って CC BY-SA 3.0 で再配布し、内訳を NOTICE に書く
  • 翻訳の不具合を本家フォーラムへ報告しないよう README に明記。 開発チームに迷惑をかけないための線引き
  • 自作部分(ツール類)だけ MIT

公開にあたっては開発チームへ確認することを勧める、とも書いています(他言語翻訳の前例があります)。


まとめ

既存プロダクトを後から多言語化するとき、私が学んだこと:

  • 訳文が出ない原因は数パターンしかなく、どれもソース上の形で検出できる。
    1件直して終わりにせず、同じ形を探す検出器に落とす
  • 実行時に組み立てた文字列は翻訳キーにならない。 補間を含む翻訳呼び出しは常に不具合
  • 表示用の値が識別子を兼ねていないかを、訳を入れる前に確認する。ここは静かに壊れる
  • 永続化される中間データを言語に依存させない
  • ゴールを「未訳ゼロ」ではなく 「未訳の理由が全部説明できる」 に置き換えると、着地点が決まる

LLM に大量生成させるとき、私が調整したこと:

  • 壊れ方を先に列挙して、それぞれに検査を1本。 通らないものは書き込まずに報告する
  • 生成する側に検証をさせない。 評価関数を見せると、評価関数を回避する方向に最適化される
  • 出力に入力が指定していない自由度があるなら、先に決めてファイルで共有する。
    決めずに分割すると必ずぶれる。しかも先に理屈で決めると破綻するので、
    まず生成させ、良かったものを固定してから規則を起こす
  • 入力の分割は件数ではなく意味のまとまりで切る
  • セッションは切れる。 判断理由をリポジトリのドキュメントに残せば、次のセッションの入力になる

一番の学びは、判断を自分の側に残せたところだけがうまくいったということでした。
何を検出するか、どの口調にするか、何を訳さないか。
そこを決めずに任せた部分は、後から全部やり直しています。


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?