個人開発のブラウザゲームを多言語対応する話〜自前で多言語対応をして、自分の欲しい多言語対応ライブラリを考える〜

この記事は [ドワンゴ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/dwango) 11日目の記事です。

この記事では、多言語対応のライブラリを選定するために、ひとまず自分で実際にライブラリを使わずに多言語対応の実装を行い、自分の求める機能が何なのかを明確にした経験についての話をします。

## 多言語対応の困難さとは

### 多言語対応は難しい？

多言語対応は難しいですね。……というのは簡単ですが、実際、どの部分が難しいのでしょうか？

- どういった方法で言語切替機能を提供するか？
- 言語を変えても破綻しないUI設計はどうするか？
- 複数の言語の保守・運用はどうするか？
- etc...

きっと様々な意見があると思います。
しかし、その中でも特に僕が最大級に難しいと感じているものがあります。

それは **「イメージができない」** ことです。

僕もお仕事上、何度か多言語対応をするようなアプリケーションを作る機会はありましたが、イケると思って実際にやってみたら、あとで上記の例のような部分で「うわー！＞＜」と言いたくなるようなツラみを感じることがありました。

### 知見がないと選択もできない

イメージできないことの原因の1つとして、素振り量の少なさは無視できない要因です。個人のプロダクトでいろいろ試すことはあっても、あまり多言語の対応をすることはありません[^somosomo]。

[^somosomo]: そもそも僕は英語が苦手すぎて書けないし喋れない……DeepLとChatGPTが無いと生きていけない！

そんな中、先日、僕が個人で開発しているブラウザゲームの英語版を作る機会がありました。これは良い機会なので、ここで色々学びを得ようと思いましたが……

**ぼく「……どのライブラリを使えばいいんだッ！」**

過去に [i18next](https://www.i18next.com/) や [@formatjs/intl](https://formatjs.io/docs/intl)（[react-intl](https://formatjs.io/docs/react-intl)） などを使ったことはありますが、ちゃんと選定をしたのかというと僕が自分で選んだわけではなく、チームの詳しい人にやってもらったような気がします……＞＜；

僕自身がちゃんと多言語対応についての知見を得ていないため「何が嬉しいのか」「どういう観点で選ぶべきなのか」という判断指標が曖昧であることを強く自覚しました。

### ひとまず「自分でやってみる」ことにした

そこで、既存のライブラリを借りずにまずは自分でやってみることにしました。おそらく、やっていく中で困ることや、こうなってると良いというものを片っ端から踏み抜くはずです。

業務であまり変な車輪の再発明をするのは気が引けますが、今回は僕の個人開発なので僕以外の人間は困りません。

## 今回やろうとした内容

https://rutan.itch.io/unum-machina

今回対象としたのはHTML5のブラウザゲームです。
DOMではなくCanvasで画面描画を行っているもののため、特に React や Vue.js のようなUIライブラリとは関係なく、純粋に文字列の翻訳を行います。

要件としてはシンプルに以下のようにしました。

- 言語データはGoogleスプレッドシートで管理する
- 言語データはJSONとしてゲームと一緒に配信し、非同期に読み込む
- `translate('hoge.fuga')` のような記法でテキストを得ることができる

### 言語データはGoogleスプレッドシートで管理する

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25109/1af357cc-9543-5127-c16c-1a8b544240cb.jpeg)

翻訳データを管理するサービスなどは色々ありますが、個人開発なのでGoogleスプレッドシートを使用します。シートが1枚しかない場合はCSV出力も簡単にできますし、API経由でデータをダウンロードすることも容易です。

### 言語データはJSONとしてゲームと一緒に配信し、非同期に読み込む

スプレッドシートから取得したデータをJSONに変換し、ゲームデータと共に配信するようにします。

ビルド時にJavaScript類と共にbundleしてしまう（＝dynamic importを併用する）という手段もありますが、ゲーム内の他のリソースがJSON形式でランタイムに読み込む方法を取っていたため、それと合わせるようにしました。

### `translate('hoge.fuga')` のような記法でテキストを得ることができる

rails-i18n とか i18next を親だと思ってるので……

### おまけ：画像

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25109/1dc74b61-7f2b-046c-562d-2a4594f025a5.jpeg)

ゲームの場合、どうしてもロゴとかボタンなどビジュアルの中に文字が入ることが多いです。

これらについては今回は `logo__ja.png` / `logo__en.png` のようなファイル名の命名規則による対応を行いました。画像読み込み用の共通処理の部分で言語設定を見て分岐しています。



## 実際にやった方法

実装としては非常にミニマムなものです。

```typescript
import { LocaleKey } from '../types';
import { ResourceLoadManager } from '../managers';

export const SUPPORT_LANGUAGE = ['en', 'ja'] as const;
export const AUTO_DETECT_LANGUAGE = 'auto' as const;
export type SupportedLanguage = (typeof SUPPORT_LANGUAGE)[number];

const dictionary: {
  [key in SupportedLanguage]?: {
    [key in LocaleKey]: string;
  };
} = {};

export let currentLanguage: SupportedLanguage | undefined;

// ブラウザの言語設定を取得する
function detectClientLanguage() {
  if (typeof navigator === 'undefined') return 'ja';

  return navigator.language.split('-')[0] || 'en';
}

// ゲームの言語設定を取得する
// ゲームで非対応の言語はすべて英語とする
export function detectGameLanguage(): SupportedLanguage {
  const clientLang = detectClientLanguage();
  if (SUPPORT_LANGUAGE.includes(clientLang as any)) {
    return clientLang as SupportedLanguage;
  } else {
    return SUPPORT_LANGUAGE[0];
  }
}

// 現在の言語設定の変更
export function setLanguage(lang: SupportedLanguage | 'auto') {
  if (lang === 'auto') lang = detectGameLanguage();
  currentLanguage = lang;

  return loadDictionary(currentLanguage);
}

// 現在の言語設定の取得
export function getLanguage() {
  return currentLanguage;
}

// 翻訳テキストの取得
export function translate(key: LocaleKey, ...args: string[]) {
  if (!currentLanguage) {
    console.warn(`not set language!`);
    return '-';
  }

  const lang = currentLanguage;
  const dic = dictionary[lang];
  if (!dic) {
    console.warn(`Language (${lang}) is not loaded!`);
    return '-';
  }

  // 'Hello, \1 !' のような単語の場合、\n 部分を引数の内容で置換
  let word = dic[key] || '-';
  args.forEach((arg, i) => {
    word = word.replace(`\\${i + 1}`, arg);
  });

  return word;
}

// 辞書データの読み込み
export async function loadDictionary(lang: SupportedLanguage) {
  if (dictionary[lang]) return;

  dictionary[lang] = await ResourceLoadManager.loadJSON(`data/locale/${lang}.json`);
}
```

```typescript
// 利用イメージ

// ユーザーのブラウザ言語設定から、ゲームで対応してる言語を取得
const lang = detectGameLanguage();

// 言語設定をセット＆辞書JSONの読み込み
await setLanguage(lang);

// 使う
console.log(translate('sample.message')); // => 'Hello!' or 'こんにちは！'
```

### (1) 言語設定の取得方法を考える
https://developer.mozilla.org/ja/docs/Web/API/Navigator/language

ブラウザの言語設定は `navigator.language` で取得できます。が、当然あらゆる言語が返ってきてしまうため、対応の言語以外の場合はデフォルト言語に倒すようにしています。

なお、今のブラウザは対応言語を配列で返す `navigator.languages` が使えるため、こっちを参照してなるべくユーザーが使える言語を優先したほうが良さそうです。 ~~実装してるときはSafariが対応してないと思いこんでました、ごめんなさい。~~

https://developer.mozilla.org/ja/docs/Web/API/Navigator/languages

### (2) 言語データを読み込むタイミング

基本的にはゲームの起動時に必要な言語データのみを読み込み、ゲーム内で言語設定を変更した場合は「変更後の画面遷移時[^loadError]」に読み込むことにしました。言語切替が行われると、自動的にその言語の辞書データのダウンロードが始まります。

[^loadError]: 読み込みエラーのリトライの都合で、画面遷移時以外は極力リソースの読み込みをしない設計にしているためです。 

#### i18next の場合
[addResources](https://www.i18next.com/overview/api#addresources) を用いることで辞書データに言語指定で追加読み込みを行うことができるため、読み込み状態をもとに言語切替時に辞書を読み込むようにすることで同じことが可能そうです。

### (3) メッセージのkeyにTypeScriptの型の力を借りる

```typescript
function translate(key: LocaleKey, ...args: string[]): string;
```

最初、 `translate` の第一引数を `string` にしていました。しかしゲーム内で使い始めた瞬間に、僕がkey名をtypoするというミスを犯し、この設計で続けていくのは厳しいと実感しました。

そのため、スプレッドシートのデータをJSONに変換するのと同時に、辞書データに存在するすべての key を列挙した型定義 `LocaleKey` を生成して出力するようにしました。

```typescript
export type LocaleKey =
  | 'error.save.title'
  | 'error.save.description'
  | 'achievements.completeCount'
  | 'achievements.hidden.title'
  | 'achievements.hidden.description'
  | 'achievements.hidden.date'
  //（以下略
```

#### `i18next` の場合
https://www.i18next.com/overview/typescript

型定義ファイルを作成し、その中で辞書データのJSONを参照させることで型の恩恵を受ける方法が公式のガイドに書かれています。確かにJSONはTypeScriptから参照できるので、それを使って型定義を作るのは完全に正しいですね……！

#### @formatjs/intl の場合
https://formatjs.io/docs/intl/#message-descriptor

@formatjs/intl の場合は MessageDescriptor を使うことで TypeScript のコードとして辞書の定義を作成することができます。僕のやり方はスプレッドシートを正とするアプローチでしたが、TypeScriptファーストで管理を行いたい場合は @formatjs/intl のやり方が良さそうです。

## やって「うわー！＞＜」となったこと

### 辞書データがまぁまぁでかい

各言語ごとに1ファイルの辞書データとして出力されます。つまり日本語の辞書データには、そのゲーム内に登場するすべての日本語が含まれています。

今回対象としたゲームは小規模なゲームだったためギリギリ許容可能と言えなくもないですが、これがもしRPGやノベルゲームのような膨大なテキストを持つゲームだった場合、起動時に全テキストの取得を行うのはブラウザゲームとしては無理があります。

つまり、ゲームのシーン（場面）ごとに辞書データを分割し、シーン切り替えのタイミングでうまくそれを読み込む術を用意する必要がありそうです。

#### `i18next` の場合
https://www.i18next.com/principles/namespaces

i18next には namespace という機能があります。これを使うことで辞書データの分割が簡単にできます。前述の [addResources](https://www.i18next.com/overview/api#addresources) を使うことで、namespace 指定での追加登録も可能なため、シーン切替時にうまく使うことで対応できそうです。これが欲しかった！

## 実際にやってみて

実際にやる中で「こうやって解決しよう！」と思ってやったことについて調べると、既存のライブラリ達はそれらについてのアプローチをきちんと提供してくれていました。

ドキュメントをさらっと読んでいるときは何に使うかよくわからなかったものも、実際のアプリケーションに適用してみると「あぁ、こういうときに必要なんだな」というのが実感としてわかり、実際に手を動かすことの大事さを改めて実感します。

今回の僕の用途の場合は i18next が多くのケースで僕の要望を叶えてくれそうだなと感じたため、次に同じようなことをする場合は i18next を実際に使ってみたいなと思いました。

ライブラリ選定は適用するアプリケーションの内容やチームの規模などによって解がまったく異なるため、 **「具体的にどういうことをしたいのか」「どういう課題を解決したいのか」を明確にする** ために、あえてライブラリを使わずにやってみる……そして、その知見をもとにライブラリを選ぶ、というのも方法の1つだなと感じました。[^kojin]

[^kojin]: もちろん業務アプリケーションでそんなことやったらぶっ飛ばされ案件ですが……