はじめに
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を呼ばないsetIntervalやsetTimeoutに依存しない- 常に外部から 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 は派手な機能ではない。 でも、ちゃんと作ると 制作全体のリズムを支える基盤になる。
💬 コメント