見出し画像

【Unity】TextMeshProで日本語を表示する(フォントアセット作成・Localization多言語化・名前入力保存)


第1章 はじめに

主旨

Unity入門書は読み終えたけれど、その先の技術書がない!
そんな方のための「初級から中級への架け橋」を目指す連載記事です。
3Dアクションゲームを開発して行きます🎮

  1. ✅ [公開済み] 第1弾:『【Unity】人型モデルを動かす(Blend Tree・CharacterController・Input Systemで作るTPS移動の基礎)』

  2. ✅ [公開済み] 第2弾:『【Unity】バージョンアップ完全ガイド(移行手順・エラー対応・容量節約まで)』

  3. ✅ [公開済み] 第3弾:『【Unity】ProBuilderで遺跡をブロックアウトする(中空トンネル・T字路・広間をメニュー操作だけで作る)』

  4. ✅ [公開済み] 第4弾:『【Unity】遺跡に光と霧を灯す(暗所ライティング・ランタン・コルーチン・フォグ・環境音で作る探索の空気感)』

  5. ✅ [公開済み] 第5弾:『【Unity】一人称視点と「調べる」を実装する(自作Input Actions・TPS⇄FPS切替・Raycastインタラクション)』

  6. 🆕 【今回の記事】 第6弾:『【Unity】TextMeshProで日本語を表示する(フォントアセット作成・Localization多言語化・名前入力保存)』

  7. 📅 [執筆予定] 第7弾:『データで仕掛けを量産する — ScriptableObjectカタログと抽選機(心臓部) 』

  8. 📅 [執筆予定] 第8弾:『迷いの回廊 — ループ・テレポート・導き』

  9. 📅 [執筆予定] 第9弾:『番人ゴーレム — 巡回AIと、隠れて逃げるアクション』

  10. 📅 [執筆予定] 第10弾:『ゲームの骨格 — シーン・セーブ・音』

  11. 📅 [執筆予定] 第11弾:『演出と結末 — コルーチン演出・推理ボード・マルチエンディング』

  12. 📅 [執筆予定] 第12弾:『仕上げと公開 — 調整・ビルド・itch.io公開』

なお、筆者は入門書を読み終えたばかりの初心者です。
自身の備忘録も兼ね、調べながら書いておりますため、何卒、あたたかい目で見守っていただければと思います🐣

みなさんこんにちは。連載「初級から中級への架け橋」シリーズ、今回で第6弾です。

日本語フォントアセットを自作し、石碑の刻文をタイプライター演出で表示し、言語ドロップダウンで日本語⇄英語を切り替え、最後は冒険者名簿に自分の名前を刻んでゲームを再起動しても残っているところまで …… 今回も盛りだくさんです。

今回の到達点

  • 「[E] 調べる」が日本語で正しく表示される

  • 石碑を調べると、日本語の刻文がタイプライター演出で1文字ずつ読める

  • 通路の手記を読むと、◀▶ボタンで複数ページをめくれる

  • 言語ドロップダウンで日本語⇄英語を切り替えられる

  • 冒険者名簿に自分の名前をキーボードで記帳でき、ゲームを再起動しても名前が残っている

👩‍💻 こんな方に向けた内容です
・TMPで日本語を出そうとして「□□□」になり、途方に暮れた方
・「Noto Sans JPを入れたのに文字が異様に細い(Thin)」で悩んだ方(可変フォントの罠)
・Unity公式のLocalizationパッケージを一度ちゃんと触ってみたい方
・名前入力(InputField)とPlayerPrefsでのセーブを一通り体験したい方
・UI表示中にゲーム操作が暴れて困っている方(入力ゲートで解決します)
・第1〜5弾から続けて作っている方(既存スクリプトを壊さず拡張します)

🛠️ 動作検証環境
・macOS Tahoe 26.5.1
・Unity 6.5(6000.5.5f1)+ URP 17.5.0
・Input System(6000.5バンドル版)/Cinemachine 3.1.7
・UniVRM 0.131.2(VRM0.x のVRoidアバター model_test)
・ProBuilder 6.1.2
・エディタは日本語化(メニュー名は日本語表記+必要に応じて英語併記)
・Unity Localization 1.5.12(本記事で導入)

TMPとLocalizationは「昔の記事と仕様が変わっている」領域の代表格です。特にNoto Sans JPの可変フォント問題、TMPのuGUI統合、Localizationの初期設定まわりは、ネットの記事と画面が違うことがよくあります。本記事では実機で確認した現行仕様(Unity 6.5)の情報を提供します。古い記事の内容とズレていたら、こちらを優先してください。


本記事の構成

本記事は「フォントを直す → 読ませる → 多言語にする → 名前を刻む」という流れの、7章構成です。盛りだくさんですが、章ごとに動く状態で区切ってあるので、時間のある章から進めても大丈夫です。

データ保存までのステップ

上記、6つの章の構成となってます(※ ②〜⑦は「第1章 はじめに」を略しているため)。
時間がない方は第2章だけでも「豆腐退治」の達成感が得られます。

それでは始めましょう!


第2章 日本語フォントアセットを作る

まずは豆腐を見てみる

百聞は一見にしかず。まず「□□□」を自分の目で見ておきましょう。第5弾で作ったPromptText(Canvas子の TextMeshProUGUI)のテキストを、一時的に「[E] 調べる」に書き換えてみます。

PromptTextのTextを「[E] 調べる」に変更

「[E] 」の半角英数字は表示されますが、「調べる」の部分が四角に化けます。これがいわゆる豆腐(とうふ)です。

🧩 なぜ豆腐になるのか?
TMPは、フォントファイル(.ttf / .otf)を直接描画しているわけではありません。あらかじめ文字の形をアトラス(atlas)というテクスチャに焼き込んだ「フォントアセット」を参照して描画します。既定のフォントアセットは「LiberationSans SDF」で、これは欧文(ラテン文字)専用。日本語のグリフが1文字も入っていないため、該当する形が見つからず、代替の「□」が表示される。これが豆腐の正体です。

つまり解決策はシンプルで、日本語のグリフを含むフォントアセットを自分で作って、テキストに割り当てる。これだけです。


Noto Sans JP を入手する(ここに落とし穴があります)

日本語フォントには、Googleが配布する無料のNoto Sans JPを使います。ちなみに「Noto」という名前は、GoogleがMonotypeに出した最初のブリーフ(要件)にあった「no more tofu(豆腐よ、もう出るな)」に由来します(Monotype公式ケーススタディ「More than 800 languages in a single typeface: creating Noto for Google」より)。

まさに今回にうってつけのフォントですね。ライセンスはSIL Open Font License 1.1(OFL)で、ゲームへの同梱・埋め込みが明示的に許可されています(フォント単体で販売しない限りOK。ビルドに同梱して配布・販売しても問題ありません)。

さて、ここが今回いちばんの落とし穴です。

Google Fonts(fonts.google.com)の Noto Sans JP ページで「Get font」→「Download all」を押すと、zipが落ちてきます。これを解凍すると、中身はこうなっています

Google Fonts から Noto Sans JP をダウンロードした場合の注意点

ルートにある「NotoSansJP-VariableFont_wght.ttf 」は可変フォント(variable font)です。1つのファイルの中に太さ(wght軸、100〜900)の情報がまとまっている、Web向けには便利な形式です。 ところが、これをそのままUnityのFont Asset Creatorに入れると、文字がかすれたような極細(Thin)の状態で生成されてしまいます。

📝 原因は、Unity(TextMeshPro)がまだ「可変フォント」という新しい形式に完全対応していないからです。可変フォントのデータの一番最初に「Thin」の太さが入っているため、そこしか読み取れずに細くなってしまいます。

■ 解決策としてはstatic フォルダの『Medium』を使うこと。
解凍したフォルダの中にある static フォルダ を開いてください。こちらが従来の安全な形式(静的フォント)です。

今回は読みやすさのバランスが良い『 NotoSansJP-Medium.ttf 』を使いますこれをUnityのProjectウィンドウ(例:Assets/Fonts/ フォルダを作り、そこへ)ドラッグ&ドロップしてインポートしましょう。


Font Asset Creator で焼き込む

ウィンドウ(Window)▸ TextMeshPro ▸ Font Asset Creator を選びます。

📝 初めて開いたときにダイアログが出たら、「Import TMP Essentials」を押して基本設定を読み込んでおいてください。

まず「Character Set」メニューですが、『Custom Characters』という項目を選択し、文字リストをコピーして貼るというアナログな手順が定番になってます。

📝 他の項目でお任せにすると、漢字の範囲指定が膨大になってしまうからです。 PCによってはUnityがフリーズ・クラッシュしてしまいます(画像も何枚にも膨れ上がり、ゲーム容量を圧迫します)。


■ 文字リストを用意する
と言うことで『Custom Characters』用の文字リストを用意します。

今回は、Unity界隈で定番となっている文字リスト(ひらがな・カタカナ・英数・基本の漢字など約7,000字)をお借りします。
【 🌐 japanese_full.txt(GitHub Gist) 】


■ 設定項目
Font Asset Creatorウィンドウで、以下のように設定します。

  • Source Font File:先ほど入れた NotoSansJP-Medium

  • Sampling Point Size:Auto Sizing(文字数が多いので自動にお任せ)

  • Padding:5(文字の縁取り用のスキマ)

  • Atlas Resolution:4096 × 4096

  • Character Set:Custom Characters を選び、すぐ下の空欄に先ほどコピーした文字リストを貼り付け

  • Render Mode:SDFAA

設定できたら「 Generate Font Atlas 」ボタンをクリックします。

⏳ 数千字を一気に処理するため、PCによってはUnityが数秒〜数十秒フリーズしたように止まりますが、裏で頑張って計算しているのでクリックせずに焦らず待ちましょう。

処理が終わると結果が表示されます。Missing characters に数十個の欠落が出ることがありますが、これは「元フォントに存在しない特殊記号」が弾かれただけなので失敗ではありません。 Excluded characters: 0 になっていれば日本語は無事に収まっています。

処理が終わったら Save ボタンを押し、分かりやすい場所(Assets/Fonts/ など)に保存します。

🧩 コラム:なぜ 4096 なの?
日本語の数千文字を1枚の画像に綺麗に収めるには、512や1024では全く面積が足りません。かといって8192まで大きくすると、スマホなどで動かなくなる(重すぎる)原因になります。そのため「4096」が日本語フォントのベストな基準サイズになります。


■ Static と Dynamic について
フォントの作り方には、大きく2種類あります。

TMPのフォントアセットを作る際の2つのアプローチと、その使い分け
  • Static(静的):今回作った方式。使う文字をあらかじめ画像にしておくので、ゲームの動作がとても軽いです。石碑の文章など「あらかじめ決まっている文字」に最適です。

  • Dynamic(動的):チャットや名前入力など「プレイヤーが何を打つか分からない」ときに使います。

今回は石碑やメニューなど決まった文字を出すだけなので、軽くて安全な Static のまま進めます。(名前入力だけは第7章で Dynamic を使います)


「[E] 調べる」を表示させる

作ったフォントアセットを、PromptText(TextMeshProUGUI)の Font Asset 欄にドラッグして割り当てます。

PromptTextのFont Assetに NotoSansJP-Medium SDF を割り当て

これで豆腐が退治できました。ビューで日本語の表示がされます。

💡シーンビューでは方向により鏡文字のように反転する場合もありますが問題ありません

なお、石碑に近づいたときのメッセージは、第5弾の Interactor.cs が、毎フレーム石碑側の文言で上書きしてます。

SetPrompt($"[E] {it.GetPrompt()}"); // 石碑が持つ文字("Examine")で上書き

よって、石碑側の文言を修正します。

石碑(Monument)を選択し、インスペクターの Stone Monument (Script) の Prompt 欄を「Examine」→「調べる」に書き換えます。コードは触りません。


■ ゲーム開始「▶」で確認

FPSモード(Vキーで切替)で石碑に近づき、画面中央を合わせる。画面下に「[E] 調べる」と日本語で表示される。


第3章 世界に文字を置く — 石碑の刻文

UI用(TextMeshProUGUI)と3D用(TextMeshPro)の違い

刻文は「UIとして画面に貼る」のではなく、石碑の表面に刻まれた文字として3D空間に置きたい。ここで使うのがワールド空間の3D TMPです。

用途によって使うコンポーネント(種類)が完全に分かれています
  • TextMeshProUGUI
    Canvasの上に乗るUI用。PromptTextはこれ。

🛠️ ゲームオブジェクト(GameObject) ▸ UI ▸ Text - TextMeshPro

  • TextMeshPro(3D)
    Canvasを必要とせず、普通の3D GameObjectのようにシーンに置ける。石碑の刻文はこちら。

🛠️ ゲームオブジェクト(GameObject)▸ 3D オブジェクト(3D Object)▸ Text - TextMeshPro

名前は「MonumentInscription」などにしておきましょう。Unity公式マニュアルでも「3Dテキストはキャンバス上ではなく、普通の3Dオブジェクトのように振る舞う」と説明されています。

📝 スクリプトから文字を書き換えるとき、型(クラス名)が違います。
UI用は TextMeshProUGUI、3D用は TextMeshPro として変数を宣言([SerializeField])しないと、インスペクターにドラッグ&ドロップで割り当てられないので注意です。


石碑の面へ配置する

作った3Dテキストを、石碑(Monument)の子オブジェクトにして、石碑の見せたい面の前に配置します。

  • ① 「スケール」「フォントサイズ」「幅 / 高さ」の調整
    まずは巨大な文字を小さくします。Rect Transformの Scale(スケール)の X, Y, Z をすべて 0.1 にし、TextMeshProの Font Size を 6 前後に下げます。また、幅:8 / 高さ:10 とします。

  • ② フォントの割り当て(必須)
    Font Asset に、第2章で作った日本語フォントアセット(NotoSansJP-Medium SDF)を割り当てます。これを忘れると3D空間でも豆腐のままになります。

  • ③ 表面への引き出しと「裏側・鏡文字」の罠
    面とテキストが完全に重なると、描画がチラつく「Z-fighting」が起きます。Rect Transformの 位置(Pos)の Z を 0.51 または -0.51(石碑の厚みの半分 + 0.01 の余白)にして、表面から少しだけ浮かせます。また、ゲーム開始「▶」で確認時に鏡文字になる場合は、回転のYを「0 または 180」で試す。

文字が石碑の裏面に配置されて見えない(位置のZのプラスマイナスを逆)

⚠️ 注意:このとき、石碑の向きによっては「文字が石碑の裏面に配置されて見えない」「鏡文字(裏返し)になっている」という罠にハマりやすいです。その場合は、位置のZのプラスマイナスを逆(例:-0.51を0.51)にする、あるいは 回転(Rotation)の Y を 180 にする などして、プレイヤーが立つ側の面に正しく読める向きで張り付くよう調整してください。

  • ④「中央揃え」でビシッと決める
    最後に、TextMeshProの Alignment(配置)を横・縦ともに「中央揃え」 にします。これで石碑のド真ん中に文字がビシッと美しく揃います。
    ※ 縦に関しては、個人的には中央より位置Yを -0.1 の方が見やすかったのでそうしました

■ ゲーム開始「▶」で確認
石碑の面に日本語の刻文が浮かんで見える。
斜めから見てもチラつかない(Z-fightingが出ていない)。

🎥 URPでの見え方
URP環境の暗所(第4弾で作った雰囲気)だと文字が沈んで読みづらいことがあります。その場合は、ランタン等の光が当たるようにするか、Vertex Color(頂点カラー)を少し明るめにする工夫が必要です。もしくは、無理に3D空間で長文を読ませず「石碑には短い見出しだけを刻み、詳細は後述のLoreUI(パネル表示)に委ねる」という二段構えの設計にするのが遊びやすくておすすめです。


第4章 LoreUI — 刻文を読む

タイプライター演出の定石

石碑に刻文は出ましたが、3Dテキストは長文だと読みづらい。そこで、Eキーで全文パネルを開き、タイプライター演出で1文字ずつ表示するようにします。これは第4弾で学んだコルーチンの応用です。

なぜ「文字を足していく」のはダメで、「表示上限を増やす」のが正解なのか?

TMPには maxVisibleCharacters(表示する文字数の上限)というプロパティがあります。これを使うのが定石です。

🧩 「文字を1つずつ足す」のはアンチパターン
初心者がやりがちなのが「text += 文字 で1文字ずつ追加する」方式。
・行末で単語が急に折り返される
・リッチテキストタグが一瞬見える
・毎回文字列を生成してメモリを浪費する
…… という問題があります。

正解は、先に全文を text にセットしてしまい、maxVisibleCharacters を 0 から総文字数まで増やす方式。文字の最終的な配置が最初から確定しているので、折り返しが破綻しません。


ForceMeshUpdate と characterCount の裏取り

総文字数は textInfo.characterCount で取得します。ここでリッチテキストのタグ(<color> 等)はカウントされないので、「見た目の文字数」ぶんだけ正確に進みます。

TMPの文字数(characterCount)は「1フレーム遅れ」で計算される罠があります

注意点として、text をセットした直後のフレームではtextInfo.characterCount が 0 のままのことがあります。ループに入る前に一度 ForceMeshUpdate() を呼んでTMPに解析を強制しておくと安定します(毎フレーム呼ぶ必要はなく、開始前に1回でOK。TMP解説の定番でも「これを呼ばないと初フレームで characterCount が0を返すことがある」とされています)。


新設計「入力ゲート」— つまずき実況から入る

コードを書く前に、必ず起きる競合を先に体験しておきましょう。

現状、各スクリプト(LanternController、FogDemoToggle、CameraModeSwitcher など)は、それぞれが独立に new GameControls() してPlayerマップを読んでいます。この状態でパネルUIを開いて放置し、たとえば後の章で名前欄に「l」「f」「v」を打つと …… ランタンが消え、フォグが切り替わり、視点がFPS/TPSで暴れる。UIを操作しているつもりが、ゲーム側のアクションも同時に発火してしまうのです。

🧩 なぜ起きるのか?
1つのスクリプトのインスタンスをDisableしても、他のスクリプトは別々のGameControlsインスタンスを持っていて動き続けるからです。個別に止めても意味がない。

そこで、全スクリプトが見に行く1つの共有フラグを用意します。シンプルな静的クラスです。Assets/Scripts内に UiGate.cs を作成します。

// ==========================================================
// 📄 スクリプト:UiGate.cs
// ==========================================================
// UI(パネルや入力欄)が開いている間、ゲーム側の入力を一括で止めるための共有フラグ。
// static なので、どのスクリプトからでも UiGate.IsOpen 一発で参照できる。
public static class UiGate
{
    // true の間は「UI表示中」。各スクリプトはこれを見て入力処理を早期returnする。
    public static bool IsOpen;
}

第5弾で仕込んだ PlayerLocomotion.InputLocked との役割分担です。

「InputLocked」は、個別に移動だけ止めたいとき用(例:特定演出中はプレイヤーだけ動けない)。『UiGate.IsOpen』は、UI表示中に全体を止めるとき用(移動もランタンもフォグも視点も一括停止)。となります。


各スクリプトにゲートを追加

スクリプトのUpdate冒頭に「UI表示中なら何もしない」を足します。💡が追記箇所です。
■ PlayerLocomotion.cs(Update冒頭)
第5弾で作った「0. 操作ロックの判定」のif文が既にあるので、その条件に || UiGate.IsOpen を足すだけです(新しくif文を追加するのではありません)。

private void Update()
{
    // --------------------------------------------------------
    // 0. 操作ロックの判定
    // --------------------------------------------------------
    // ロック中は入力を読まずに帰ります。ただし重力だけは効かせ続けます。
    // 💡 UI表示中(UiGate.IsOpen)も同じ扱いにするため、条件に追加しました
    if (InputLocked || UiGate.IsOpen)
    {
        _animator.SetFloat(_speedHash, 0f); // 走行アニメを止める
        ApplyGravityOnly();                 // 重力のみ適用
        _animator.SetBool(_groundedHash, _controller.isGrounded);
        return;
    }
    // 以下、既存の移動・ジャンプ処理はそのまま…

■ LanternController.cs(Lキー判定の直前)

void Update()
{
    if (UiGate.IsOpen) return; // 💡 UI表示中はランタンのトグルを受け付けない
    // 以下、既存のLキー処理…
}

■ FogDemoToggle.cs(Fキー判定の直前)

void Update()
{
    if (UiGate.IsOpen) return; // 💡 UI表示中はフォグ切替を受け付けない
    // 以下、既存のFキー処理…
}

■ CameraModeSwitcher.cs(Vキー・マウスルックの直前)

void Update()
{
    if (UiGate.IsOpen) return; // 💡 UI表示中はV切替もマウスルックも止める
    // 以下、既存のV切替・FPSマウスルック処理…
}

■ Interactor.cs(プロンプト表示処理の直前)

void Update()
{
    // 💡 UI表示中はプロンプトを消して早期return("[E] 調べる"を出しっぱなしにしない)
    if (UiGate.IsOpen)
    {
        _current = null;
        SetPrompt(null);
        return;
    }
    // 以下、既存のFPS判定→Raycast→プロンプト表示処理…
}

カーソル制御

UIを開いたらボタンをクリックしたいので、カーソルを解放・表示します。閉じたら元のモードに応じて戻します。ここで、CameraModeSwitcherに「今のモードに合わせてカーソルを整える口」を新設します。

CameraModeSwitcher.cs に公開メソッドを追加(💡追加)

// 💡 追加:今のモード(FPS/TPS)に合わせてカーソル状態を整える公開メソッド。
//        UIを閉じたあと、これを呼べば「FPSならロック非表示/TPSなら解放」に一発で戻せる。
public void ApplyCursorForCurrentMode()
{
    if (IsFirstPerson)
    {
        Cursor.lockState = CursorLockMode.Locked; // FPS中はカーソルを画面中央に固定
        Cursor.visible = false;                   // 見えないように
    }
    else
    {
        Cursor.lockState = CursorLockMode.None;    // TPS中は自由に動かせる
        Cursor.visible = true;                     // 表示
    }
}

LoreUI.cs(全文掲載)

パネルの表示・タイプライター・スキップ/クローズを担当します。表示する文字列は引数で受け取る設計にしておきます(第6章でLocalizedString対応に改修するため)。Assets/Scripts内にLoreUI.csを作成します。

■ LoreUI.cs

// ==========================================================
// 📄 スクリプト:LoreUI.cs
// ==========================================================
using System.Collections;
using UnityEngine;
using TMPro;

// 石碑などの「刻文」を全文パネルで読ませるUI。
// ・Open(文字列) で開く
// ・タイプライターで1文字ずつ表示
// ・Interact(E) 1回目=全文即表示(スキップ)/2回目=閉じる
public class LoreUI : MonoBehaviour
{
    [SerializeField] private GameObject panelRoot;      // パネル全体の親(表示/非表示を切り替える)
    [SerializeField] private TextMeshProUGUI bodyText;  // 本文を表示するTMP(日本語フォント割当済み)
    [SerializeField] private float charsPerSecond = 30f;// 1秒あたり何文字送るか
    [SerializeField] private CameraModeSwitcher cameraSwitcher; // カーソル復帰用の参照

    private GameControls controls;    // 自前の入力(UI表示中もEを受け取るため自分で持つ)
    private Coroutine typing;         // 実行中のタイプライターコルーチン
    private bool isFullyShown;        // 全文表示が終わったか(スキップ判定に使う)

    void Awake()
    {
        controls = new GameControls();      // 生成
        panelRoot.SetActive(false);         // 最初は閉じておく
    }

    void OnEnable()  { controls.Player.Enable();  } // Interact(E)を読むためPlayerマップを有効化
    void OnDisable() { controls.Player.Disable(); }

    // 外部(StoneMonument等)から呼ぶ。表示したい刻文を引数で受け取る。
    public void Open(string text)
    {
        UiGate.IsOpen = true;               // ★ゲートを閉じる=ゲーム側の入力を一括停止
        Cursor.lockState = CursorLockMode.None; // カーソルを解放(今回はクリック不要だが方針統一)
        Cursor.visible = true;

        panelRoot.SetActive(true);          // パネルを開く
        isFullyShown = false;

        bodyText.text = text;               // ★先に全文をセット(アンチパターン回避)
        bodyText.maxVisibleCharacters = 0;  // まだ0文字だけ見せる
        bodyText.ForceMeshUpdate();         // ★characterCountを確定させるため1回強制更新

        if (typing != null) StopCoroutine(typing);
        typing = StartCoroutine(TypeRoutine());
    }

    void Update()
    {
        if (!panelRoot.activeSelf) return;  // 開いていないときは何もしない

        // UI表示中も、このスクリプトだけはEを受け取る(ゲーム側はUiGateで止まっている)
        if (controls.Player.Interact.WasPressedThisFrame())
        {
            if (!isFullyShown) SkipToEnd();  // 1回目:全文を即表示
            else Close();                    // 2回目:閉じる
        }
    }

    // タイプライター本体
    IEnumerator TypeRoutine()
    {
        int total = bodyText.textInfo.characterCount; // 見た目の総文字数(タグは数えない)
        int shown = 0;
        while (shown < total)
        {
            shown++;
            bodyText.maxVisibleCharacters = shown; // 表示上限を1つずつ増やす
            yield return new WaitForSeconds(1f / charsPerSecond);
        }
        isFullyShown = true; // 全文表示完了
    }

    // スキップ:全文を一気に表示
    void SkipToEnd()
    {
        if (typing != null) StopCoroutine(typing);
        bodyText.ForceMeshUpdate();
        bodyText.maxVisibleCharacters = bodyText.textInfo.characterCount;
        isFullyShown = true;
    }

    // 閉じる:ゲートを開けてゲーム側を復帰、カーソルもモードに合わせて戻す
    void Close()
    {
        panelRoot.SetActive(false);
        UiGate.IsOpen = false;                       // ★ゲートを開ける
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode(); // ★カーソルを今のモードに戻す
    }
}

ここで整理しましょう。

UIが開いている間、ゲーム側(移動・ランタン・フォグ・視点・Interactor)は UiGate で全部止まっています。一方でLoreUIだけは自分のGameControlsでEを読み続ける。だから「Eでスキップ→Eで閉じる」がゲーム側と競合しません。これが入力ゲートの狙いです。


StoneMonument.cs の改修

第5弾では Interact() が Debug.Log だけでした(「碑文表示は第6弾で」とコメントしていた箇所)。ここを LoreUI.Open に繋ぎます。

// ==========================================================
// 📄 スクリプト:StoneMonument.cs
// ==========================================================
using UnityEngine;

public class StoneMonument : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "調べる"; // 💡 第2章でインスペクターから変更済み
    [SerializeField, TextArea(3, 6)] private string inscription =
        "この遺構は、古の民が知恵を競った学び舎である。"; // 刻文の全文
    [SerializeField] private LoreUI loreUI;               // シーンのLoreUIを割り当て

    public string GetPrompt() => prompt;                   // Interactorが"[E] {これ}"に使う

    public void Interact()
    {
        loreUI.Open(inscription); // 💡 Debug.LogをやめてLoreUIを開く
    }
}

パネルを作ってアタッチする

■ パネルの作成
コードだけでは動きません。シーンにパネルを作ります。

「1.」「2.」
「3.」「4. LoreBodyText」
「4. LorePanel」「5.」
  1. ヒエラルキーの Canvas(第5弾で作ったもの)を右クリック ▸ UI (Canvas) ▸ パネル。名前を LorePanel に

  2. LorePanel の子に ▸ UI (Canvas) ▸ テキスト - TextMeshPro を作成。名前を LoreBodyText に

  3. LoreBodyText の Font Asset に、第2章で作った日本語フォントアセットを割り当て(忘れると豆腐に戻ります)

  4. 大きさと位置を整えます。LorePanelは画面に貼り付くUIなので、石碑の前に移動させる必要はありません(3D空間の座標とは無関係です)

    • LorePanel:Rect Transform左上のアンカープリセットでストレッチ(全方向)を選び、Left/Right/Top/Bottom を 150 程度に。画面中央の内側にパネルが収まります

    • LoreBodyText:同じくストレッチにして Left/Right/Top/Bottom を 20 程度に(パネル内側の余白)。Font Size は 24 くらい、Alignment は左揃え+上揃えが読みやすいです

  5. LorePanel に LoreUI.cs をアタッチし、インスペクターの3つの欄を割り当て

    • Panel Root:LorePanel 自身

    • Body Text:LoreBodyText

    • Camera Switcher:アバター(例ではmodel_test。CameraModeSwitcherが付いているオブジェクト)


■ Monumentに割り当て

  • ヒエラルキーで Monument を選択

  • Stone Monument (Script) の Lore UI 欄(今「なし」の箇所)へ、LorePanel をドラッグ


■ ゲーム開始「▶」で確認
石碑を「[E] 調べる」で調べると、全文パネルが開く。
刻文がタイプライターで1文字ずつ表示される。
Eを1回押すと全文即表示、もう1回押すと閉じる。
パネルを開いている間、L/F/Vやプレイヤー移動が反応しない(=ゲートが効いている)。


第5章 ノートUI — ページをめくる

なぜ第5弾でUIマップを消さなかったか

次は、複数ページの「手記」を作ります。石碑の刻文が「短い1枚」なら、手記は「めくって読む数ページ」。ここで、第5弾で温存したUIマップとEventSystemがついに本領発揮します。

🧩 伏線回収:なぜUIマップを消さなかったか
第5弾で「UIマップは消すな」と強調しました。理由がここにあります。uGUIのButtonをマウスでクリックするには、EventSystem と InputSystemUIInputModule(Input System版の入力モジュール)が必要で、これがUIマップ(Point/Click/Navigate等)を使ってボタンのクリックを検知します。第5弾でEventSystemは自動生成済み、UIマップも温存済み。だから今回、追加のアクションを一切足さずに◀▶ボタンのクリックが動くのです。


手記オブジェクトを置く

第4章の石碑と同じ要領で「手記」を配置します。

  1. ヒエラルキー右クリック ▸ 3Dオブジェクト ▸ キューブ。名前を ExplorerJournal に。

  2. 例えば、向かって左の壁に貼る場合、スケール:X:0.05 / Y:0.3 / Z:0.4(薄い方向をXにしておけば、回転は0のままでOK)。位置を調整(例:Y:1.4 / XやZは適宜調整)。

  3. インスペクター右上の Layer を Interactable に変更(忘れるとレイが素通りします)

⚠️ 数値は目安です。また、章の最後の動作確認で「手記の近くに行くと『[E] 読む』でが表示」が出ない場合、手記と石碑の距離を離してください。Interactorは画面中央から3mのレイを撃ち、最初に当たったもの1つだけを対象にするため、2つが近いと石碑のほうが先に反応してしまうことがあります。3〜5mほど離しておけば、照準を向けたほうが確実に選ばれます。

💡 手記を「置くための台座」作成の手間を省くため、少しおかしいですが、今回は『壁に貼り付ける』にしました。もちろん、台座を作って置いても構いません。


また「ここに手記がある」と分かりやすくするため、色やぼんやりとした光も付加すると良いでしょう。

  • Materialsフォルダ / JournalMat の作成
    Projectウィンドウの Assets を右クリック ▸ 作成 ▸ フォルダ、名前を Materials に。そのMaterialsの中で右クリック ▸ 作成 ▸ マテリアル → JournalMat

📝 既存の LanternGlowMat も、このフォルダにドラッグして移動しておくと揃います(Unity内で移動すれば参照は自動で追従するので、割り当てが外れる心配はありません)。

  • マテリアルに色と放出を設定
    インスペクターで Base Map の色を、羊皮紙っぽい明るいベージュ(例:R:230 G:210 B:170)に。マテリアルの 放出(Emission) をオン。色を暗めのベージュに、HDR強度を 0.3 程度(第4弾のランタンで使った手順と同じです)

💡ランタンほど光らせる必要はなく、「暗闇でうっすら浮かぶ」程度が自然です

  • 手記にマテリアルをセット
    ExplorerJournal オブジェクトにドラッグ&ドロップ。


ページをめくるUIを作る(NoteUI.cs)

■ NoteUI.cs
ページ配列を受け取り、◀▶ボタン(uGUI Button)でページを送ります。ページ番号も表示します。

まずはスクリプトから。

// ==========================================================
// 📄 スクリプト:NoteUI.cs
// ==========================================================
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class NoteUI : MonoBehaviour
{
    [SerializeField] private GameObject panelRoot;
    [SerializeField] private TextMeshProUGUI bodyText;   // 本文(日本語フォント割当)
    [SerializeField] private TextMeshProUGUI pageLabel;  // 「2 / 3」などのページ表示
    [SerializeField] private Button prevButton;          // ◀
    [SerializeField] private Button nextButton;          // ▶
    [SerializeField] private Button closeButton;         // 閉じる
    [SerializeField] private CameraModeSwitcher cameraSwitcher;

    private string[] pages;
    private int index;

    void Awake()
    {
        panelRoot.SetActive(false);
        // ボタンのクリックにメソッドを結びつける(EventSystem+UIマップが検知してくれる)
        prevButton.onClick.AddListener(Prev);
        nextButton.onClick.AddListener(Next);
        closeButton.onClick.AddListener(Close);
    }

    public void Open(string[] contents)
    {
        pages = contents;
        index = 0;
        UiGate.IsOpen = true;                    // ★ゲート閉:ゲーム側停止
        Cursor.lockState = CursorLockMode.None;  // ★カーソル解放(ボタンをクリックするため)
        Cursor.visible = true;
        panelRoot.SetActive(true);
        Refresh();
    }

    void Prev() { if (index > 0) { index--; Refresh(); } }
    void Next() { if (index < pages.Length - 1) { index++; Refresh(); } }

    // 表示を今のページに更新
    void Refresh()
    {
        bodyText.text = pages[index];
        pageLabel.text = $"{index + 1} / {pages.Length}"; // 1始まりで表示
        prevButton.interactable = index > 0;              // 端ではボタンを無効化
        nextButton.interactable = index < pages.Length - 1;
    }

    void Close()
    {
        panelRoot.SetActive(false);
        UiGate.IsOpen = false;                            // ★ゲート開
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode();   // ★カーソルを元に戻す
    }
}

※ Assets/Scripts内にNoteUI.csを作成。

📝 ◀▶をクリックで実装したのは、第5弾方針(新しい入力アクションを足さない)を守るためです。UIマップとマウスクリックだけで完結しています。


■ パネルを作ってアタッチする
LoreUIと同じ要領ですが、パーツが多いので順番に作ります。

【 NotePanel(パネル) 】

ヒエラルキーの Canvas を右クリック ▸ UI (Canvas) ▸ パネル。名前を NotePanel に。LorePanelと同じくストレッチ+余白150程度に

【 NoteBodyText (本文)】

NotePanel の子に UI (Canvas) ▸ テキスト - TextMeshPro を作成。名前は NoteBodyText(本文)とする。 Font Asset に日本語フォントアセットを割り当て。
Font Size:24(LoreBodyTextと同じ)、Alignment:左揃え+上揃え。

作成直後は画面中央に小さく置かれているので、LoreBodyTextと同じくストレッチに変更します。Rect Transform左上のアンカープリセット → ストレッチ×ストレッチ(右下のアイコン)。Left 20 / Right 20 / Top 20 / Bottom 80

Bottomだけ80と大きいのは、下にページ番号とボタンを置くスペースを空けるためです。ここを20のままにすると、本文とボタンが重なります。

【 PageLabel (ページ番号)】

NotePanel の子に UI (Canvas) ▸テキスト - TextMeshPro を作成。名前は PageLabel(ページ番号)とする。 Font Asset に日本語フォントアセットを割り当て。
Font Size:20(本文より少し小さめ)、Alignment:中央揃え+中央揃え。

アンカープリセット:bottom center、Pos Y:40 程度(パネル下端から少し上)、Width:200 / Height:40 くらい。

【 PrevButton(←)】
NotePanel の子に UI (Canvas) ▸ ボタン - TextMeshPro(「テキスト - TextMeshPro」ではありません)を作成。名前は PrevButton とする。


PrevButton側:アンカー middle left、位置X 40 / Y 0、幅60 / 高さ60

子のText (TMP)側:文字「←」、Font Asset に日本語フォント、Font Size 24。

【 NextButton(→)】
NotePanel の子に UI (Canvas) ▸ ボタン - TextMeshPro(「テキスト - TextMeshPro」ではありません)を作成。名前は NextButton とする。

 NextButton側:アンカー middle right、位置X -40 / Y 0、幅60 / 高さ60

子のText (TMP)側:文字「→」、Font Asset に日本語フォント、Font Size 24。

【 CloseButton(×)】
NotePanel の子に UI (Canvas) ▸ ボタン - TextMeshPro(「テキスト - TextMeshPro」ではありません)を作成。名前は CloseButton とする。

 CloseButton側:アンカー top right、位置X -40 / Y -40、幅40 / 高さ40

子のText (TMP)側:文字「×」、Font Asset に日本語フォント、Font Size 20。


■ NotePanel へのアタッチ
NotePanel に NoteUI.cs をアタッチし、7つの参照を割り当て(Panel Root / Body Text / Page Label / Prev Button / Next Button / Close Button / Camera Switcher)

Panel Root:NotePanel 自身
Body Text:NoteBodyText
Page Label:PageLabel
Prev Button:PrevButton
Next Button:NextButton
Close Button:CloseButton
Camera Switcher:アバター(model_test)


手記(ExplorerJournal)のスクリプト

■ ExplorerJournal.cs

// ==========================================================
// 📄 スクリプト:ExplorerJournal.cs
// ==========================================================
using UnityEngine;

// 通路に置く「手記」。調べるとNoteUIを複数ページで開く。
public class ExplorerJournal : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "読む";
    [SerializeField] private NoteUI noteUI;

    // 手記の中身(ページごと)。明るい探索トーンのサンプル文面。
    [SerializeField, TextArea(2, 5)] private string[] pages = new string[]
    {
        "一日目。我らはついに学び舎の入口へたどり着いた。胸が高鳴る。",
        "三日目。広間の中央に、知恵を試す仕掛けがあると記されていた。",
        "五日目。同志の名を、この地の名簿に刻むと約束を交わした。"
    };

    public string GetPrompt() => prompt;
    public void Interact() => noteUI.Open(pages); // ページ配列を渡して開く
}

※ Assets/Scripts内にExplorerJournal.csを作成。

  1. ExplorerJournal.cs を手記(ExplorerJournal)オブジェクトにアタッチ。

  2. ヒエラルキーの NotePanel を、この Note UI 欄にドラッグ&ドロップ(うまく入らなければ、欄の右端の ⊙ ボタン → 一覧から NotePanel を選択)

📝 キューブには最初からBox Colliderが付いているので、当たり判定はそのままでOKです。石碑から3〜5mほど離しておくと、照準が競合せず確認しやすくなります(石碑と同じく、TreasureHallの子にはしないでください)。

💡 ここで注目してほしいのは、Interactor.cs を1文字も書き換えていないこと。IInteractable という共通の約束を第5弾で決めておいたおかげで、調べる側は相手が石碑だろうと手記だろうと気にせず動きます。インターフェースの恩恵が、さっそく効いてきました。

■ ゲーム開始「▶」で確認
手記の近くに行くと『[E] 読む』でが表示。開くと1ページ目が表示される。
→で次ページ、←で前ページ、端ではボタンが無効(灰色)になる。
「1 / 3」のようにページ番号が出る。
閉じるとゲーム操作・カーソルが元に戻る。

⚠️ 石碑から3〜5mほど離して置いてください。 Interactorは画面中央から3mのレイを撃ち、最初に当たったもの1つだけを対象にします。石碑と手記が近いと、照準のわずかなズレでどちらが選ばれるか変わってしまい、「石碑を狙ったのに『[E] 読む』が出る」といった混乱が起きます。離しておけば、プロンプトが「調べる」「読む」と切り替わるのを確認でき、動作テストもしやすくなります。


第6章 多言語対応 — Unity公式Localization

パッケージ導入

日本語が出せるようになったので、次は日本語⇄英語の切り替えです。自前で辞書を書いてもいいのですが、ここはUnity公式のLocalizationパッケージを使います。

ウィンドウ(Window)▸ Package Managment ▸ ぱケージマネージャーを開き、上部の絞り込みを「Unity Registry」にして「Localization」を検索、「インストール」押下。

📝 バージョンは、Unity公式マニュアルに「Package version 1.5.12 is released for Unity Editor version 6000.5」と明記されており、Unity 6.5ではLocalization 1.5.12が対応版です。手動で入れるなら Packages/manifest.json に com.unity.localization を追記する方法もあります。導入時、依存の Addressables も自動で入ります。


初期設定とLocale作成

編集(Edit)▸ プロジェクト設定(Project Settings)▸ Localization を開き、Create で Localization Settings アセットを生成(既定で Assets/Settings/ に保存)。

  1. Add Locale をクリック(画面中央の3つ並んだボタンの左)

  2. Locale Generatorウィンドウが開くので、検索欄で絞り込みながら Japanese (ja) と English (en) の2つにチェック

  3. Add Locales をクリック

  4. 保存先を聞かれるので、Assets/Settings/Locales などのフォルダを作って保存

これで Available Locales の「List is Empty」が解消され、2つの言語が並びます。

⚠️ Add All は押さないでください。 数百の言語が全部追加されてしまいます。

■ 既定Localeの設定

Locale追加後、Locale Selectors の中の Specific Locale Selector の ▶ を展開すると、Locale を指定する欄が出ます。ここに Japanese を設定してください。(※ これを忘れると、再生時に「SelectedLocale is null」で例外が出ます)

📝 なお Project Locale Identifier が「English (en) (Locale)」になっていますが、こちらはプロジェクト全体の基準ロケールで、必須ではありません。今回は放置でOKです。


String Table Collection を作る

ウィンドウ(Window)▸ アセット管理(Asset Management)▸ Localization Tables を開き、New Table Collection → Type を String Table Collection、名前を「GameText」などにして、ja/en 両方にチェックして Create。

Assets/Settings/内に保存します。

📝 保存すると GameText Shared Data(共有データ)と、言語ごとの GameText_ja GameText_en という3つのアセットが生成されます。フォルダを作っておくと、これらがまとまって見やすくなります。保存先はあとから移動もできる(Unity内でドラッグすれば参照が追従する)ので、迷ったら Assets/Settings/ で問題ありません。


■ エントリを追加

テーブルに New Entry でエントリを追加します。今回の刻文・手記・プロンプト・UIラベルを登録します。


テキストへの適用(Localize String Event方式)

多言語化のやり方は、大きく2つあります。

  1. Localize String Event 方式(コンポーネントを付けるだけ)
    固定の文言向き。MonumentInscription(石碑の表面に彫られた刻文)が該当します。

  2. LocalizedString 方式(コードから取得する)
    状況で変わる文言向き。StoneMonument(プロンプトの「調べる」と、パネルに出す刻文の全文)が該当します。

📝 名前がそっくりですが別物です。Localize String Event は「コンポーネント」、LocalizedString は「コードで使う型」。前者はインスペクターだけで完結し、後者はスクリプトに書く必要があります。

本項「テキストへの適用(Localize String Event方式)」ではLocalize String Event、他項「コードから文字列を取る(LocalizedString 方式)」でLocalizedStringを扱います。

そして、どちらも使わないテキストもあります。全体を整理すると次のようになります。

📝 表の「どちらも付けない」と「不要」は理由が違います。前者は付けると競合して壊れるもの、後者はそもそも翻訳する内容がないものです。

つまり原則は、「スクリプトが書き換えるテキストには付けない。ずっと同じ文言を出し続けるテキストに付ける」です。1つのテキストを2人が奪い合う状態になると、言語を切り替えた直後にスクリプトが上書きして元に戻る、といった競合が起きます。

⚠️ 特に PromptText は要注意です。「プロンプトを多言語化したいのだから、ここに付けるのでは?」と考えたくなりますが、付けると毎フレームInteractorと奪い合いになります。プロンプトの文言は、次項でStoneMonument側をLocalizedString化することで切り替わるようになります。


■ Localize String Event
作った辞書(String Table)の言葉を、実際のゲーム画面のテキストに繋ぎます。 Unity公式パッケージには「右クリックで一発設定」できる便利なショートカットが用意されています。

さっそく、MonumentInscription(石碑の刻文)に適用しようと思います。

「UI用」と「3D用」2つの TextMeshPro

ところが、このショートカット「TextMeshPro - Text(UI)」には登録されてますが、『TextMeshPro - Text』にはされてません(Unity6.5)

その場合は、手動でコンポーネントを追加します。

  1. MonumentInscription を選択

  2. 「コンポーネントを追加」→ Localize String Event を検索して追加

  3. String Reference に GameText/monument_inscription を指定

  4. Update String (String) の「+」を押してイベントを追加

  5. 左のオブジェクト欄に MonumentInscription 自身をドラッグ

  6. 右のドロップダウンから TextMeshPro → string text を選択(※動的(Dynamic)側の text を選ぶこと)

💡 「Runtime Only」 は再生中のみ反映です。シーンビューも確認したい場合は「Editor And Runtime」にします

⚠️ 手順6で、リストが「Static Parameters」と「Dynamic string」に分かれています。Dynamic側の text を選んでください。Static側を選ぶと固定文字列になってしまい、翻訳が反映されません。

ゲーム開始「▶」。この際、ゲームビュー上部に「英語⇄日本語」切り替えのドロップダウンが表示されます。これはUnity側が用意した開発テスト用のもので、ビルド後には出ません。これを切り替えると、石碑の刻文が英語⇄日本語で入れ替わります。 ユーザーが操作できる切替UIは別途自作する必要があります。


言語選択ドロップダウン

Canvasの隅に TMP Dropdown(ゲームオブジェクト ▸ UI(Canvas) ▸ ドロップダウン - TextMeshPro)を1つ置き(オブジェクト名はLanguageDropdown)、以下のスクリプトを付けます。公式サンプルと同じく、初期化完了を待ってからLocale一覧を流し込むのがコツです。

// ==========================================================
// 📄 スクリプト:LanguageDropdown.cs
// ==========================================================
using System.Collections;
using UnityEngine;
using UnityEngine.Localization.Settings;
using TMPro;

public class LanguageDropdown : MonoBehaviour
{
    [SerializeField] private TMP_Dropdown dropdown;

    IEnumerator Start()
    {
        // ★初期化を待つ。待たないとLocale一覧が空だったりnull例外になる
        yield return LocalizationSettings.InitializationOperation;

        dropdown.ClearOptions();
        var locales = LocalizationSettings.AvailableLocales.Locales;
        foreach (var locale in locales)
            dropdown.options.Add(new TMP_Dropdown.OptionData(locale.LocaleName));

        // いま選ばれている言語をドロップダウンの初期値に同期
        dropdown.value = locales.IndexOf(LocalizationSettings.SelectedLocale);
        dropdown.RefreshShownValue();

        // 選択が変わったら言語を切り替える
        dropdown.onValueChanged.AddListener(index =>
        {
            LocalizationSettings.SelectedLocale = locales[index];
        });
    }
}

※ Assets/Scripts内にLanguageDropdown.csを作成。

LanguageDropdownにスクリプトをアタッチした後は、インスペクター下部の Language Dropdown (スクリプト) にある 「ドロップダウンを作成」(英語名 Dropdown)の欄へ、ヒエラルキーの LanguageDropdown をドラッグ。

📝 同じオブジェクトに TMP_Dropdown コンポーネントとスクリプトの両方が付いているので、自分自身を参照させる形になります。


ドロップダウンは既定で中央に配置されます。デモ用なので邪魔にならない隅であればどこでも構いません。右上が一般的ですが先のとおり開発用プルダウンがあります、今回は左上(top left、位置X 120 / Y -30)にでもしておきましょう。


なお、Options(Option A / B / C)はこれはドロップダウン作成時の初期サンプルです。LanguageDropdown.cs の Start() で dropdown.ClearOptions() を実行し、Localeの一覧で置き換えるので、再生すると自動的に「English」「Japanese」に入れ替わります。

つまり、今回は放置でOKです。インスペクターで消しておいても構いませんが、そのままでも動作に影響しません。


■ ゲーム開始「▶」で確認

左上のドロップダウンが実装したもの。右上の方のはUnity側がつけた開発中のみ表示されるドロップダウン

TPSモード(Vキー)でカーソルが自由に動く状態にして、ドロップダウンをクリック。「English」「Japanese」が並び、切り替えると石碑の刻文が変わるはずです。

⚠️ FPSモード中はカーソルがロックされているので触れません。TPSで操作してください。


コードから文字列を取る(LocalizedString 方式)

Localize String Eventは「TMPに自動で流す」のは得意ですが、LoreUIのようにコードで受け取ってからタイプライターに流したい場合は、コードから文字列を取得します。LocalizedString を使います。

StoneMonument.cs の💡改修(LocalizedString化)

// ==========================================================
// 📄 スクリプト:StoneMonument.cs(第6章版)
// ==========================================================
using UnityEngine;
using UnityEngine.Localization;

public class StoneMonument : MonoBehaviour, IInteractable
{
    // 💡 文字列直書きをやめ、String Tableのエントリを参照するLocalizedStringに
    [SerializeField] private LocalizedString inscription; // 例:GameText/monument_inscription
    [SerializeField] private LocalizedString promptEntry; // 例:GameText/prompt_examine
    [SerializeField] private LoreUI loreUI;

    // 💡 GetPrompt()も言語連動に。同期取得で"調べる"/"Examine"を返す
    public string GetPrompt() => promptEntry.GetLocalizedString();

    public void Interact()
    {
        // 💡 現在の言語の刻文を取得してLoreUIへ渡す
        loreUI.Open(inscription.GetLocalizedString());
    }
}

■ 同期 GetLocalizedString() と 非同期の使い分け

  • GetLocalizedString()(同期)
    その場で文字列を返す。初回ロード時に一瞬止まる可能性があるが、コードが素直で初心者向き。「調べる」ボタンを押した瞬間に取るような用途に向く。

  • GetLocalizedStringAsync()(非同期)
    バックグラウンドで読み、完了時に受け取る。WaitForCompletion() で強制的に同期化もできる。公式APIドキュメントにも両方のサンプルが載っています。

⚠️ どちらも、Localizationの初期化(InitializationOperation)が終わる前に呼ぶと空やnullになり得ます(公式も「InitializationOperation完了前に呼ぶとnullが返り得る」と明記)。ゲーム開始直後に取りにいく処理では、yield return LocalizationSettings.InitializationOperation; で待つのが安全です。今回のように「プレイヤーが石碑を調べた瞬間」に取る分には、その頃には初期化が終わっているので同期取得で十分です。


■ Table Collectionの割り当て

[SerializeField] private LocalizedString inscription; // 例:GameText/monument_inscription
[SerializeField] private LocalizedString promptEntry; // 例:GameText/prompt_examine

コード改修で inscription と promptEntry を string 型から LocalizedString 型に変更したので、インスペクターから「どのテーブルの、どのKeyを参照するか」を割り当てる必要があります。

Monument を選択し、Stone Monument (スクリプト) の Inscription の ▶ を展開。(同様にPrompt Entryも展開しておきましょう)

  1. InscriptionのTable Collection:GameText

  2. Inscription:GameText/monument_inscription

  3. Prompt EntryのTable Collection:GameText

  4. Prompt Entry:GameText/monument_inscription

にします。Inscriptionであれば

GameText = テーブル名(String Table Collectionの名前)
monument_inscription = キー名(Key列に入力した識別子)

となります。選択順としては「テーブル名 → キー名」の順が無難です。
上記の 1. 〜 4. は番号順に行うと良いでしょう。


■ ゲーム開始「▶」で確認

[E] Examine 押下

言語ドロップダウンを「English」にすると、プロンプトやUIが英語に変わる。

[E] 調べる 押下

「Japanese」にすると、プロンプトやUIが日本語に変わる。


💡 手記(ExplorerJournal)はどうする?

ここまでで石碑は多言語対応できましたが、第5章で作った手記は日本語のままです。プロンプトの「読む」も、3ページ分の文面も、スクリプトに直書きしたままだからです。

本記事では、学習効率を重視して手記は未対応のままにしておきます。石碑でやったことと完全に同じ手順の繰り返しになるので、記事が長くなるわりに新しく学べることがないためです。

とはいえ「やり方だけ知っておきたい」という方のために、方針だけ書いておきます。石碑と考え方は同じで、直書きの文字列をLocalizedStringに置き換えるだけです。

まずString Tableに、ページ数ぶんのエントリを追加します(journal_page_1 〜 journal_page_3 など)。そのうえで ExplorerJournal.cs をこう変えます。

using UnityEngine;
using UnityEngine.Localization;

public class ExplorerJournal : MonoBehaviour, IInteractable
{
    // 💡 直書きの文字列をやめ、String Tableのエントリを参照する
    [SerializeField] private LocalizedString promptEntry;   // 例:GameText/prompt_read
    [SerializeField] private LocalizedString[] pageEntries; // 例:journal_page_1〜3
    [SerializeField] private NoteUI noteUI;

    public string GetPrompt() => promptEntry.GetLocalizedString();

    public void Interact()
    {
        // 各ページを現在の言語で取得してから、配列としてNoteUIへ渡す
        string[] pages = new string[pageEntries.Length];
        for (int i = 0; i < pageEntries.Length; i++)
            pages[i] = pageEntries[i].GetLocalizedString();

        noteUI.Open(pages);
    }
}

ページが配列になっているぶんループが1つ増えますが、やっていることは石碑と変わりません。「文字列を直書きしている箇所を探して、LocalizedStringに置き換える」多言語対応の作業は、突き詰めればこの
繰り返しです。腕試しに挑戦してみてください。

※ その他、String Tableへのエントリ追加(journal_page_1〜3。prompt_read は作成済み)と、インスペクターでのTable Collection・Entryの割り当ても忘れずに行ってください(石碑の改修を参考に)。

📝 ページが配列(LocalizedString[])なので、インスペクターでは Size を 3 にしてから、各要素それぞれにTable CollectionとEntryを設定する必要があります。

なお、String Tableに prompt_read(読む/Read)のエントリは既に作ってあります。手記に対応するときにそのまま使えるので、消さずに残しておいてください。


【⚠️ 要注意】多言語化とフォント

本記事で採用している『 Noto Sans JP 』には「日本語(ひらがな・カタカナ・漢字)」+「ラテン文字(英数字)」+「一部の記号」が含まれますので、日本語と英語の両方の対応が可能です。

しかし、他の言語ではどうでしょう?

  • キリル文字(ロシア語)

  • ハングル(韓国語)

  • 簡体字の一部(中国語。日本語と字形が違う漢字も多い)

  • アラビア文字・タイ文字・デーヴァナーガリー(アラビア語・タイ語・ヒンディー語)

これらを追加する場合、Noto Sans JPにはそれらの字形が入っていないので、言語ごとにフォントアセットを用意して切り替える必要があります。その場合は Asset Table(Localize Font Event)という仕組みを使います。容量の面でも、日本語7,000字だけで4096×4096を使い切っているので、中国語まで含めるなら分割が必須です。


第7章 名を刻む — TMP_InputFieldで記帳

冒険者名簿(RegisterDesk)とUI

第6弾の締めくくりとして、冒険者名簿(自分の名前を記帳)を実装します。

通路か広間に「名簿(RegisterDesk)」を新IInteractableとして置きます。調べると名前入力UI(NameEntryUI)が開く流れです。


入力フィールドUIを作る

パネルとパーツを配置していきましょう。


■ パネル(NameEntryPanel)

  1. ヒエラルキーの Canvas を右クリック ▸ UI (Canvas) ▸ パネル。名前を NameEntryPanel に

  2. アンカープリセットでストレッチを選び、Left/Right/Top/Bottom を 200 程度に(入力欄だけなので、LorePanelより小さめで十分です)


■ 見出しテキスト(任意ですが、何の画面か分かるので推奨)

  1. NameEntryPanel の子に UI (Canvas) ▸ テキスト - TextMeshPro。名前を NameEntryTitle に

  2. アンカー top center、位置Y -40、幅300 / 高さ40

  3. Font Asset に日本語フォント、Font Size 24、中央揃え

  4. 文字は「冒険者名簿に名を刻む」など


■ 入力欄

NameEntryPanel の子に UI (Canvas) ▸ 入力フィールド - TextMeshPro。名前を NameInputField に。アンカー middle center、位置Y 0、幅240 / 高さ40。

Input Field Settings欄のFont Assetに「2章で作った日本語フォントアセット」を割り当て(忘れると入力文字が豆腐に)、文字数制限は名前なので 12 程度に制限しておくと安心です。

親だけでなく、2つの子のフォントアセットも確認。

Placeholder

Font Assetは日本語フォントか?また、Placeholder の文字を「名前を入力」などに変更(初期値は「Enter text...」)

Text

Font Assetは日本語フォントか?

親(NameInputField)だけでなく、2つの子オブジェクト(Placeholder、Text)のFont Assetも日本語フォントにする必要があります。

📝 親に割り当てておけば、子にも反映されると思いますが、一応、確認はしておきましょう。

このような形となります。


■ 決定ボタン

NameEntryPanel の子に UI (Canvas) ▸ ボタン - TextMeshPro。名前を ConfirmButton に。アンカー bottom center、位置Y 60、幅160 / 高さ40。

子の Text (TMP) の文字を「決定」に。Font Asset に日本語フォント、Font Size 20


■ 閉じるボタン

NameEntryPanel の子に UI (Canvas) ▸ ボタン - TextMeshPro。名前を NameCloseButton に。アンカー top right、位置X -40 / Y -30、幅60 / 高さ40。

子の Text (TMP) の文字を「×」に。Font Size 24

📝 ボタンにスクリプトを紐付ける作業は不要です。NameEntryUI.cs の Awake() で onClick.AddListener() を実行しているので、インスペクターの参照を割り当てるだけで動きます(NoteUIと同じ方式です)。


入力フィールド(文字入力)の罠と対策

基本的には、記事の解説通りに進めていけば問題ありません。
もし、不具合が生じた場合は、以下も参考にしてください。

 ■ Staticフォントだと、打った文字が表示されない

プレイヤーが「どんな文字を入力するか」は事前に予測できない!

第2章で作った日本語フォントは「Static(静的)」という設定でした。これは「あらかじめ決まった文字だけを画像にしておく」方式です。 しかし、プレイヤーが自分の名前としてどんな漢字を入力するかは、事前の予測ができません。画像に用意されていない文字を打つと、何も表示されなかったり豆腐になったりしてしまいます。

💡 対策:入力欄には「Dynamic」フォントを使う
入力欄には「Dynamic(動的)」という、打たれた文字をその場で画像に追加していく設定のフォントアセットを使います。

NotoSansJP-Medium.ttf からもう一つフォントアセットを作成し、インスペクターで Atlas Population Mode を「Dynamic」に変更してください。そして、InputFieldの中にある「Text」と「Placeholder」の両方に、このDynamicフォントを割り当てます。


 ■ 日本語特有の「Enterキー」事故

なぜ OnSubmit(Enterで決定) を使わずに、ボタンの OnClick を使うのか?

日本語を入力するとき、漢字に変換して「Enterキー」で確定します。

しかし、もし入力欄の設定で「Enterキーを押したら名前を送信する(onSubmit)」という機能を使ってしまうと、変換を確定したつもりのEnterで、意図しないヒラガナのまま名前が保存されるという事故が起きます。(これは海外製のPCゲームでよくある「日本語チャット誤爆問題」と同じ原因です)

💡 対策:「決定ボタンのクリック」だけで保存する
この競合事故を防ぐため、本記事では Enter キーでの送信機能は使いません。プレイヤーには必ずマウスで「決定」ボタンをクリックして名前を保存してもらうという、一番事故が起きない安全な設計にします。


■ Input System での文字入力について
Input Systemを使っていても、キーボードからの文字入力は、第5弾で自動追加された EventSystem が裏で勝手に拾ってくれるので、難しい追加設定は不要です。

ただし、日本語変換(IME)はパソコンのOS(MacかWindowsか)の影響を強く受けます。変換中の文字が少しズレて表示されるなどの現象が起きることがあるので、必ず一度、ご自身の環境で名前を入力してテストプレイしてみてください。


■ 将来のための補足(ブラウザゲームにする場合)
今回はPC向けのゲームとして解説していますが、もし将来このゲームを「ブラウザ用(WebGL)」として出力した場合、Unityの標準機能だけでは日本語(漢字変換)が入力できないという制限があります。将来ブラウザゲームを作る際は「WebGLInput」などの外部ツールの導入が必要になる、とだけ心の片隅に留めておいてください。


PlayerPrefsとは?

Unityが標準で用意している、いちばん手軽なデータ保存の仕組みです。ゲームを閉じても消えてほしくない小さな情報——プレイヤー名、音量設定、クリア回数など——を覚えておくのに使います。

使い方は驚くほど単純で、「名札(キー)」と「中身(値)」をセットで預けるだけです。

// 保存する
PlayerPrefs.SetString("PlayerName", "ラン");

// 読み込む(第2引数は「まだ何も預けていないとき」に返る値)
string name = PlayerPrefs.GetString("PlayerName", "");

コインロッカーを想像すると分かりやすいかもしれません。「PlayerName」という番号の箱に「ラン」という中身を預けておくと、次にゲームを起動したとき、同じ番号を言えば中身を返してもらえます。

扱えるのは string(文字列)/ int(整数)/ float(小数)の3種類だけです。

PlayerPrefs.SetInt("ClearCount", 3);        // 整数
PlayerPrefs.SetFloat("Volume", 0.8f);       // 小数
PlayerPrefs.SetString("PlayerName", "ラン"); // 文字列

裏を返すと、「インベントリの中身」「マップのどこを踏破したか」といった構造の複雑なデータは、そのままでは保存できません。そういうものはJSONなどの形式に変換してから文字列として預けるか、別の保存方法を使うことになります。

つまりPlayerPrefsは、「単純な値をいくつか覚えておく」ための道具です。逆に言えば、その範囲なら数行で済むのが最大の強みです。


■ なぜ Save() を呼ぶのか?
SetString() を実行した時点では、値はまだメモリの中にあるだけです。PlayerPrefs.Save() を呼ぶと、その瞬間にディスクへ書き出されます。

PlayerPrefs.SetString("PlayerName", "ラン");
PlayerPrefs.Save();   // ここで確定

実はUnityは、アプリが正常に終了するときは自動でSaveしてくれます。ですが、クラッシュしたり強制終了されたりすると、その機会が失われます。「決定を押した瞬間」のような、絶対に失いたくないタイミングでは明示的に呼んでおくのが確実です。

⚠️ 毎フレーム呼ぶような使い方はしないでください。ディスクへの書き込みは処理が重いので、「保存ボタンを押したとき」「ステージをクリアしたとき」など、区切りのタイミングだけで十分です。

📝 記帳をやり直したいときは、編集 ▸ すべての PlayerPrefs を消去(Clear All PlayerPrefs) で保存データを消せます。初回起動時の見え方を何度でも確認できるので、動作テストに便利です。

本連載では第10弾で、周回数や集めた秘宝のフラグなど、PlayerPrefsを本格的に使い倒します。今回はその入り口です。


NameEntryUI.csを作成

■ NameEntryUI.cs

// ==========================================================
// 📄 スクリプト:NameEntryUI.cs
// ==========================================================
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class NameEntryUI : MonoBehaviour
{
    [SerializeField] private GameObject panelRoot;
    [SerializeField] private TMP_InputField inputField;  // Dynamicフォント割当済み
    [SerializeField] private Button confirmButton;       // 決定
    [SerializeField] private Button closeButton;         // 閉じる
    [SerializeField] private TextMeshPro registryText;   // 名簿の3D TMP(結果反映先)
    [SerializeField] private CameraModeSwitcher cameraSwitcher;

    private const string Key = "PlayerName"; // PlayerPrefsのキー名

    void Awake()
    {
        // ★重要:パネルを閉じる「前」に、保存済みの名前を名簿へ反映しておく。
        // この処理をStart()に書くと動きません(理由は下の「よくあるつまずき」で解説)。
        ApplySavedName();

        panelRoot.SetActive(false);
        confirmButton.onClick.AddListener(Confirm);
        closeButton.onClick.AddListener(Close);
    }

    // 保存済みの名前を、名簿の3D TMPへ反映するメソッド。
    // 起動時(Awake)と、決定時(Confirm)の両方から呼ぶので切り出しておく。
    private void ApplySavedName()
    {
        string saved = PlayerPrefs.GetString(Key, "");
        registryText.text = string.IsNullOrEmpty(saved)
            ? "まだ誰の名も刻まれていない"   // 一度も記帳していないとき
            : $"{saved} の名が刻まれた";      // 記帳済みのとき
    }

    public void Open()
    {
        UiGate.IsOpen = true;                    // ★ゲート閉
        Cursor.lockState = CursorLockMode.None;  // ★カーソル解放(入力とクリックのため)
        Cursor.visible = true;
        panelRoot.SetActive(true);

        // 既に保存済みなら初期値として入れておく
        inputField.text = PlayerPrefs.GetString(Key, "");
        inputField.Select();                     // 入力欄にフォーカス
    }

    void Confirm()
    {
        string name = inputField.text.Trim();
        if (string.IsNullOrEmpty(name)) return;  // 空なら何もしない

        PlayerPrefs.SetString(Key, name);        // ★保存
        PlayerPrefs.Save();                       // ★ディスクに確実に書き出す

        ApplySavedName();                         // 名簿の表示を更新(Awakeと同じ処理を使い回す)
        Close();
    }

    void Close()
    {
        panelRoot.SetActive(false);
        UiGate.IsOpen = false;                              // ★ゲート開
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode();     // ★カーソル復帰
    }
}

※ Assets/Scripts内にNameEntryUI.csを作成。

■ コードの読みどころ

💡 Awake() で保存済みの名前を読み込む
ゲーム開始時に一度だけ、PlayerPrefsを覗きに行きます。前回の名前が残っていれば、名簿にその名前を表示します。GetString(Key, "") の第2引数は「まだ何も保存されていないとき」に返る値で、その場合は「まだ誰の名も刻まれていない」と表示します。

  • Open() で既存の名前を初期値にする
    入力欄に前回の名前をあらかじめ入れておきます。書き直したい人はそのまま編集でき、そうでない人は決定を押すだけで済みます。inputField.Select() は入力欄にフォーカスを当てる処理で、これがあると開いた直後からキーボードで打ち始められます。

  • Confirm() で保存する
    Trim() は前後の空白を取り除く処理です。うっかりスペースだけ入力しても保存されないよう、空なら何もせず戻ります。保存後は名簿の表示を更新し、パネルを閉じます。

  • Close() でゲートを開ける
    LoreUI・NoteUIと同じく、UiGate.IsOpen = false でゲーム側の操作を復帰させ、カーソルを元のモードに戻します。この3点セットは、UIを作るたびに繰り返し登場する型です。


■ パネルにアタッチする

  1. NameEntryPanel を選択し、「コンポーネントを追加」→ NameEntryUI をアタッチ

  2. 現れた6つの欄を割り当てます

⚠️ Registry Text だけは、次項で名簿オブジェクトを作ってから割り当てます。空のまま再生すると、Start() で名簿に書き込もうとした瞬間に NullReferenceException が出ます。


冒険者名簿(RegisterDesk)を置く

第4章の石碑とほぼ同じ作りです。「調べる対象の本体」と「表面に文字を出す3D TMP」の組み合わせになります。

■ 冒険者名簿(RegisterDesk)
石碑と同じ「縦置きの板」として作ります。

  1. ヒエラルキー右クリック ▸ 3Dオブジェクト ▸ キューブ。名前を RegisterDesk に

  2. スケールを縦長の板に:X:1.0 / Y:0.8 / Z:0.1(幅1m・高さ80cm・厚さ10cm)

  3. 位置Y を 1.4 に(プレイヤーの目線の高さ。これが最重要)

  4. X / Z は、石碑から3〜5m以上離れた、プレイヤーが歩いて行ける場所に

  5. また、回転のYも適宜調整してください。

  6. インスペクター右上の Layer を Interactable に変更

本来は台座を作成し置くところですが、学習効率のため省略します(宙に浮いたような状態になります)

⚠️ 石碑・手記と同じく、石碑から3〜5mほど離して配置してください。近すぎると照準が競合します。

そして名簿(RegisterDesk)側は、調べるとこのUIを開くだけのシンプルなIInteractableにします。

■ 名簿の文字(RegistryText)

石碑の刻文(MonumentInscription)とまったく同じ要領です。

  1. RegisterDesk の子に 3Dオブジェクト ▸ Text - TextMeshPro を作成。名前を RegistryText に

  2. Font Asset に日本語フォントアセットを割り当て

  3. 位置:X:0 / Y:0 / Z:-0.55 程度(板の手前の面から少しだけ浮かせる。めり込みとチラつき防止)

  4. 回転:基本0, 0, 0 のまま。板が縦置きなので、文字も自然に正面を向きます。ただし、場合によって適宜調整。

  5. Font Size を大きめ(例:24)にして、Scale を小さく(例:0.05)第3章で触れたとおり、この組み合わせのほうが文字がくっきりします

  6. テキストは「保存」などにしておきます。

⚠️ 文字が裏返って見える場合は、回転 Y を 180 にしてください。キューブのどちらの面を正面にするかで変わります。シーンビューで実際に見ながら調整するのが確実です。

📝 石碑では文字が板に対して寝ていましたが(Rotation Y:180)、名簿は縦置きなので回転の考え方が変わります。「板の面に対して、文字が平行に、こちらを向いて立っている」状態が正解です。


「RegisterDesk(冒険者名簿)」 に 『RegisterDesk.cs』 をアタッチ

■ RegisterDesk.cs

// ==========================================================
// 📄 スクリプト:RegisterDesk.cs
// ==========================================================
using UnityEngine;

public class RegisterDesk : MonoBehaviour, IInteractable
{
    [SerializeField] private string prompt = "名を刻む";
    [SerializeField] private NameEntryUI nameEntryUI;

    public string GetPrompt() => prompt;
    public void Interact() => nameEntryUI.Open(); // 名前入力UIを開く
}

※ Assets/Scripts内にRegisterDesk.csを作成。

このRegisterDesk.csを「RegisterDesk」にアタッチします。

■ 参照を割り当てる
最後に、2箇所の参照を繋ぎます。

  • RegisterDesk の Name Entry UI 欄 → NameEntryPanel をドラッグ

  • NameEntryPanel の Registry Text 欄 → 先ほど作った RegistryText をドラッグ


ゲーム開始「▶」で確認


  1. 名簿を「[E] 名を刻む」で調べ、名前を入力(日本語も可。IME確定は決定ボタンで)

  2. 決定すると名簿の3D TMPに「◯◯ の名が刻まれた」と反映

  3. Unityエディタの再生を止めて、もう一度再生する

  4. 名簿に前回の名前が残っている(PlayerPrefsから読み込まれている)


🧩 なぜ床に置くと反応しないのか?

いちばんハマりやすい『レイキャスト式インタラクションの本質的な性質』
落とし穴を潰しておきます。

第5弾で作った Interactor は、カメラの中央から前方へレイ(細い光線)を1本だけ撃っています。当たり判定はこの線の上にしかありません。

床にある薄いオブジェクトの上を通過してしまう

一方、プレイヤーの目線はおよそ 1.4m の高さにあります。ここから水平にレイを撃つと、線は1.4mの高さをまっすぐ飛んでいくので、床に置いた薄い板には、絶対に当たりません。

当てるには、名簿の真上まで来て、マウスを大きく下に振って見下ろす必要があります。当然、ユーザー側もこの仕様を理解しておく必要がありますし、面倒だと思われるかもしれません。

解決策:2つのアプローチ

💡 対策は2つあります
・目線の高さに置く(今回はこちら)
・当たり判定を実物より大きく取る(見た目は小さな巻物でも、コライダーは1m四方の箱にする、など)

市販のゲームでも「拾えるアイテムの当たり判定が見た目より大きい」のはよくある話で、これは同じ問題への対処です。もっと踏み込むなら、レイをやめて「一定距離に入ったら反応する」近接方式にする手もあります(第4章のコラムで触れたOverlapSphere方式)。

⚠️ 本記事では学習効率を優先し、名簿を目線の高さに縦置きしました。石碑と同じ「立って読む掲示板」のような形です。遺跡の雰囲気としては床置きの巻物のほうが自然かもしれませんが、まずは確実に動くものを作りましょう。位置は後からいくらでも変えられます。


おわりに

第7弾予告

次回・第7弾は『データで仕掛けを量産する 〜 ScriptableObjectカタログと抽選機』。同じ仕組みの仕掛けを、コードをコピペせずデータで量産する方法を学びます。今回作った刻文や手記も、いずれデータ化して管理できるようになります。

最後までお読みいただき、ありがとうございました。次回もあたたかい目で見守っていただけると嬉しいです🐣

いいなと思ったら応援しよう!

辛島信芳 ここまで読んでくれてありがとうございます!記事は基本的に無料で出していくので、気が向いたら応援してもらえると嬉しいです☕