[JavaScript] Three.js / WebXRで一生使い回せるMessageBoxを実装

はじめに

Three.jsでアプリ開発を始めた当初、NPCキャラクターとの衝突判定で、ポップアップのメッセージボックスを実装しましたが、 それをさらに作りこんでクラス化し、バトルメッセージや、その他、使いまわし、他のアプリでも即組み込めるように実装したので、メモも兼ねて実装内容を記事にしてみます。

こういうのは一度作っておくと、後は、何度でも使いまわせるので便利です。

動画(パソコン)

1. なぜ MessageBox を作り直したのか

ゲームを作っていると、メッセージ表示は必ず必要になる。

  • 戦闘開始時の「◯◯があらわれた!」
  • イベント中の説明文
  • アイテム取得や通知メッセージ

最初はその場しのぎで実装しても、UIが増えるにつれて問題が出てくる。

  • 戦闘用とイベント用でメッセージ処理が分裂する
  • 入力処理が重なって二重に反応する
  • 表示タイミングがズレてゲーム進行を止めてしまう

特に WebXR / Three.js のように 常に animation loop が回っている環境では、 UIが自分勝手に振る舞うと一気に破綻する。

そこで今回の目標をはっきり決めた。

  • MessageBox は 1インスタンスだけ
  • 戦闘・イベント・通知で 同じクラスを使い回す
  • ゲーム進行(phase)が主導権を持つ
  • UIは「表示するだけ」の存在に徹する

この前提を守った MessageBox を作り直すことにした。

2. MessageBox を「常駐インスタンス」にした理由

MessageBox は、表示するたびに生成して捨てるオブジェクトにはしなかった。

  • 毎回 new しない
  • 表示/非表示は状態で切り替える
  • ゲームUIとして自然なライフサイクルを持たせる

実装上は、次の1行ですべてが決まっている。

UI_CONFIG.mBox = new MessageBox(camera, scene, target)

この MessageBox は「一時的なUI」ではなく、 ゲームが動いている間ずっと存在するUI部品として扱っている。


なぜ毎回 new しないのか

ゲーム中のメッセージは、以下のように頻繁に登場する。

  • 戦闘開始
  • 行動結果
  • イベント会話
  • 通知メッセージ

これを都度生成・破棄する設計にすると、

  • 入力状態が途切れる
  • 表示中に別UIと競合する
  • 管理対象が増えて把握しづらくなる

特に animation loop が常に回っている Three.js / WebXR 環境では、 UIの生存期間が短い設計は事故の元になりやすい。


表示状態だけを切り替える

MessageBox は、生成後は消さない。

代わりに持っているのは、シンプルな状態だけ。

this.visible = false
  • 表示したいときに show()
  • 終わったら hide()
  • animation loop からは常に update() が呼ばれる

この構造にすると、

  • UIが「今出ているかどうか」が一目で分かる
  • 他のUIやバトル処理を止めやすい
  • 入力の優先順位を制御しやすい

ゲームUIとして自然な振る舞いになる。


「UI_CONFIG に置く」意味

MessageBox を UI_CONFIG に置いたのは、 UIの状態を一か所に集めたかったから。

UI_CONFIG.mBox

これがあるだけで、

  • 今メッセージ表示中か
  • 入力をUIに渡すべきか
  • 戦闘や移動を止めるべきか

といった判断が、どこからでもできる。

MessageBox 自体は何も主張しない。 主導権はあくまでゲーム側が持つ。

3. animation loop との関係

MessageBox は、自分で animation loop を持たない。

  • requestAnimationFrame を呼ばない
  • setIntervalsetTimeout に依存しない
  • 常に外部から update される

これは意図的な設計。


MessageBox がループを持たない理由

Three.js や WebXR のアプリケーションでは、 描画ループは基本的に1本である。

renderer.setAnimationLoop((time, frame) => {
  // ゲーム全体の更新
})

このループの中で、

  • プレイヤー更新
  • 敵更新
  • UI更新
  • 描画

がすべて行われる。

もし MessageBox が独自に animation loop を持ってしまうと、

  • 更新タイミングがズレる
  • VR / 非VR 切り替え時に破綻する
  • 入力や表示が二重に動く

といった問題が起きやすい。


update は外から呼ばれる

MessageBox の update() は、とても受動的だ。

// main の animation loop 内
UI_CONFIG.mBox.update(deltaTime)

MessageBox 自身は、

  • 「今表示中か?」
  • 「自動クローズ時間を超えたか?」
  • 「入力を受け付けるべきか?」

を判断するだけ。

いつ更新されるかは決めない。

この構造にすることで、

  • ゲーム進行が主導権を持つ
  • UIが勝手に動き出さない
  • 他のUIと干渉しない

という状態を保てる。


VR / 非VR 両対応になる理由

WebXR 環境では、

  • VR表示中は setAnimationLoop が使われる
  • 非VR時は通常の描画になる
  • カメラやコントローラ更新の流れが変わる

にもかかわらず、MessageBox 側は一切それを意識しない。

update(dt) {
  if (!this.visible) return
  this.updatePosition()
}

MessageBox が知っているのは、

  • 自分が表示中かどうか
  • カメラの位置
  • 表示対象(target)の位置

だけ。

そのため、

  • VRでも
  • 非VRでも
  • カメラ構成が変わっても

同じ update 呼び出しで動作する。


UIは「ループにぶら下がる部品」である

この設計で一番大事なのは、

MessageBox は「時間を進めない」 時間はゲームが進める

という考え方。

UIがループを持たないことで、

  • ゲーム進行の流れが読みやすくなる
  • デバッグが楽になる
  • フェーズ管理と自然に噛み合う

後の章で出てくる 「自動クローズ」や「入力制御」も、 この animation loop との関係が前提になっている。

4. 入力を MessageBox に集約した理由

MessageBox は、表示中に Enter キーの入力を「奪う」UI として設計している。

  • 表示中は MessageBox が入力を処理する
  • 表示されていないときは、ゲーム側が入力を処理する
  • 両方が同時に反応することはない

この役割分担が、バトル実装をかなり楽にしている。


Enter を「奪う」UI

MessageBox 側では、入力処理を非常に限定している。

handleInput() {
  if (consumeKey('Enter')) {
    this.next()
  }
}

ここで重要なのは、consumeKey を使っている点。

  • キー入力を 消費 する
  • 同じフレームで他の処理に渡らない
  • UIが前に出ている間、入力の主導権を握る

これにより、

  • メッセージ送り
  • コマンド決定
  • フェーズ遷移

が同時に発火する事故を防げる。


バトル側は phase だけを見る

バトルロジック側では、 「MessageBox が何をしているか」を一切知らない。

switch (this.phase) {
  case 'command':
    this.commandUI.show()
    this.inputCommand()
    break
}

バトル側が見るのは、

  • 今の phase は何か
  • フェーズが終わったかどうか

だけ。

MessageBox が表示されているかどうかは、 UI_CONFIG.mBox.visible という状態で判断できる。

if (UI_CONFIG.mBox.visible) {
  // バトル入力を止める
}

UIの詳細にバトルが踏み込まない構造になっている。


二重入力を防ぐ設計

UI実装で一番起きやすい事故は、

1回の Enter で メッセージ送りとコマンド決定が同時に起きる

というもの。

この MessageBox では、

  • 表示中 → MessageBox が入力を消費
  • 非表示 → バトルや他UIが入力を受け取る

という 排他的な入力設計 を採用している。

結果として、

  • フラグだらけの if 文を書かなくて済む
  • 入力タイミングのバグが激減する
  • フェーズ管理がシンプルになる

UIは「入力の前に立つ存在」

この設計では、

  • UIは入力を横取りする存在
  • ゲーム側は入力を信じて処理する

という関係になっている。

MessageBox が前に出ている間、 ゲームは「何も起きない」ことが保証される。

これが、 戦闘・イベント・通知で同じ MessageBox を 安心して使い回せる理由のひとつになっている。

5. ページ分割と「続きを示す ▼」

MessageBox のページ分割は、行数ベースで行っている。

  • 文字数ベースではない
  • ピクセル単位の厳密な折返しでもない
  • 1ページあたりの最大行数を決めて割る

これは妥協ではなく、意図した設計。


行数ベースで割り切った理由

完璧なページ分割をやろうとすると、次の問題が出てくる。

  • 全角・半角の幅差
  • 日本語の禁則処理
  • フォント変更による再調整
  • Canvas サイズとの整合性

これらをすべて正しく扱うと、 MessageBox 1つで別プロジェクトが始まる。

今回の目的は、

  • 読めること
  • テンポを壊さないこと
  • 戦闘やイベントで使い回せること

だったため、 「1ページに何行表示するか」を基準に割り切った。

const MAX_LINES = UI_CONFIG.messageBox.maxLines

この1つの数字を調整するだけで、

  • 表示密度
  • 読みやすさ
  • ページ送りのテンポ

を制御できる。


完璧な自動折返しを捨てた判断

長い1行が来た場合だけは、 最大幅を超えたら折り返すという最低限の処理を入れている。

wrapLine(ctx, text, maxWidth)

ただし、

  • 美しい改行
  • 読書向けの禁則
  • レイアウト最適化

は一切やっていない。

理由は単純で、

ゲーム中のメッセージに 書籍レベルの組版は必要ない

から。

UIは読ませるためのものではなく、 状況を伝えるためのもの。

ここを割り切ったことで、

  • 実装が軽くなる
  • バグが入りにくくなる
  • 修正コストが下がる

というメリットを取った。


「▼」を描画するだけで UX が激変する

ページに続きがある場合、 MessageBox の右下に「▼」を描画している。

if (hasNextPage) {
  ctx.fillText('▼', x, y)
}

これだけの処理だが、体験は大きく変わる。

  • ユーザーが迷わない
  • Enter を押していいことが直感的に分かる
  • 説明文が不要になる

特に VR 環境では、

  • UI説明を読む余裕がない
  • 視線と操作が分離している

ため、 記号で伝える設計は非常に効果が高い。


UIは「正確さ」より「伝わりやすさ」

この MessageBox では、

  • 完璧な折返し
  • 理想的な組版

よりも、

  • 分かりやすい区切り
  • 次に何をすればいいかの提示

を優先している。

ページ分割と「▼」は、 その思想を一番シンプルに体現している部分だと思っている。

6. 自動クローズという“通知UI”

MessageBox には、入力を待たずに自動で閉じるモードを用意している。

UI_CONFIG.mBox.show(
  'アライグマがあらわれた',
  { autoCloseTime: 3 }
);

この1行で、

  • 表示される
  • 一定時間そのまま残る
  • 自動的に消える

という振る舞いになる。


入力を待たない UI

通常のメッセージUIは、

  • 表示
  • 入力待ち
  • 次へ進む

という流れを前提にしている。

しかし、ゲーム中には

  • 読ませる必要のない情報
  • 雰囲気だけ伝えたい演出
  • 進行を止めたくない通知

も多い。

「◯◯があらわれた!」は、その代表例だ。

これを Enter 入力待ちにすると、

  • 毎回操作を要求される
  • テンポが悪くなる
  • 没入感が切れる

自動クローズは、その問題を一気に解消する。


戦闘テンポを壊さない

自動クローズを MessageBox 側に実装したことで、 バトルロジックはとてもシンプルになる。

case 'fade':
  UI_CONFIG.mBox.show(text, { autoCloseTime: 3 })
  if (this.fadeIn(delta)) this.phase = 'command'
  break
  • バトルはフェーズだけを管理する
  • MessageBox は勝手に消える
  • フェーズ遷移を邪魔しない

UIが主導権を握らない構造になっている。


VR では特に重要

WebXR / VR 環境では、

  • キーボードが使えない場合がある
  • 操作に一瞬のラグがある
  • 視線と入力が分離している

そのため、

「Enter を押してください」

という前提のUIは、体験を壊しやすい。

自動クローズの通知UIは、

  • 見るだけで理解できる
  • 操作を要求しない
  • 視線を奪いすぎない

という点で、VRとの相性が非常に良い。


同じ MessageBox を使い回せる理由

重要なのは、 自動クローズ専用のUIを作っていないこと。

  • 通常メッセージ → 入力待ち
  • 通知メッセージ → 自動クローズ

挙動の違いは、show() のオプションだけで切り替えている。

show(text, { autoCloseTime })

この設計により、

  • クラスが増えない
  • 管理が分散しない
  • バグの入り口が増えない

MessageBox は「用途別UI」ではなく、 振る舞いを切り替えられるUI部品になっている。


UIは止めるためにあるとは限らない

自動クローズを入れて強く感じたのは、

UIは、必ずしもゲームを止める必要はない

ということだった。

伝えるだけでいい情報は、 伝わった時点で役目を終えていい。

この“通知UI”という考え方が、 MessageBox を戦闘・イベント・演出すべてで 使い回せる存在にしてくれた。

7. CanvasTexture がピンボケする話(正直パート)

MessageBox の描画には CanvasTexture を使っている。

これは、

  • 実装が簡単
  • 動的テキストに強い
  • Three.js との相性が良い

という利点がある一方で、 どうしても避けられない弱点もある。

それが、文字の「ピンボケ感」だ。


dpr を入れても完全には直らない

まず試したのは、いわゆる retina 対応。

const dpr = window.devicePixelRatio || 1;
canvas.width  = logicalWidth * dpr;
canvas.height = logicalHeight * dpr;
ctx.scale(dpr, dpr);

2D Canvas や通常の Web UI であれば、 これで文字はかなりシャープになる。

しかし、Three.js の SpriteMaterial を通すと話が変わる。

  • テクスチャはワールド空間で拡大・縮小される
  • GPU 側で補間が必ず入る
  • 角度や距離によってサンプリングが変わる

結果として、

多少は改善するが、 完全にシャープにはならない

という状態になる。


Sprite + VR の限界

この問題は、実装ミスではない。

原因は構造的なものだ。

  • Sprite は常にカメラ正面を向く
  • VR ではレンズ補正がかかる
  • 視線距離が近く、にじみが目立ちやすい

つまり、

Sprite + CanvasTexture + VR = ある程度のボケは避けられない

という組み合わせになっている。


本気で直す方法は存在する(が、別物になる)

完全にシャープな文字を出そうと思えば、選択肢はある。

  • PlaneGeometry + Mesh
  • 高解像度テクスチャ管理
  • SDF フォント
  • 専用の文字描画システム

ただし、これはもう

MessageBox を作る話 ではなく 文字レンダリングの話

になる。

実装量も、管理コストも、バグの入り口も一気に増える。


「諦める判断」も設計の一部

最終的に選んだのは、

  • 読める
  • 雰囲気を壊さない
  • VRでも破綻しない
  • 実装が軽い

という条件を満たしている現状を受け入れることだった。

ここで深追いすると、

  • MessageBox が主役になる
  • ゲーム作りが止まる
  • 本来の目的から逸れる

だから、あえて止めた。

これは妥協ではなく、 目的を守るための選択

だと思っている。


実装には「限界を理解して止める瞬間」がある

この MessageBox は、

  • 完璧ではない
  • でも、十分に役目を果たしている

UIは、常に理想形を目指す必要はない。

「ここまでで十分」だと判断できることも、 設計の一部だ。

この正直パートを書くことで、 この記事が「綺麗事ではない実装記録」になる。

8. 結論:MessageBox は“作ったら終わり”じゃない

MessageBox は、完成した瞬間に役目を終えるものではない。

むしろ逆で、 一度きちんと作ると、以後は存在を意識しなくなるUIになる。


一度作ったら、以後は使うだけ

今回作った MessageBox は、

  • 戦闘開始の通知
  • イベント中の説明
  • 入力待ちの会話
  • 自動で消える演出メッセージ

すべてを同じクラスで処理できる。

UI_CONFIG.mBox.show(text)
UI_CONFIG.mBox.show(text, { autoCloseTime: 3 })

使う側は、 「表示したいか」「自動で消したいか」だけを考えればいい。


UIに思考コストを割かなくてよくなる

UIが未整理な状態だと、

  • 毎回表示方法を考える
  • 入力の競合を疑う
  • 表示タイミングで悩む

といった、本質ではない思考が発生する。

MessageBox を一度整理しておくと、

  • UIの存在を前提として使える
  • バグを疑う箇所が減る
  • 「またUIか……」という気分にならない

これは地味だが、かなり大きい。


ゲーム作りが前に進む

この MessageBox を作って一番よかったのは、

UIをどうするか ではなく ゲームをどうするかを考えられるようになったこと

だった。

  • 新しい敵を追加する
  • 演出を足す
  • バトルフローを詰める

そういった作業の途中で、 MessageBox が足を引っ張ることはもうない。


MessageBox は派手な機能ではない。 でも、ちゃんと作ると 制作全体のリズムを支える基盤になる。