[JavaScript] Three.jsでRPG風コマンドUIをゼロから組み上げる(CanvasTexture編)

はじめに

昨日、外の世界のフィールドを作成したので、街の外を歩くとエンカウントで敵と遭遇して戦うというRPG要素を入れてみようと思い立ち、 それにあたって、まずやるべきことは…。

「選択可能なUIを作る」

で、UIを実装してみたのでそのメモです。

といっても、これまで学んできた事を応用すれば十分できる内容で、

クラス設計で、以下の内容を自分でコードを書きたり、AIにリファクタして貰ったり、メソッドの追加・修正を繰り返して完成してます。

・UIの状態管理
・PlaneGeometry + canvastexture
・カメラにmeshを追加(常にカメラ正面)
・キー入力イベント


問題なのは、「コマンド 表示 ➡ 選択」するとサブコマンドが出るという、ドラクエやWindows95のスタートメニューのようなツリー構造をどういうロジックで組むか?という事。

真面目に考えるとシンプルなようで難しく、再帰的ロジックを組めばいいのか?どういう設計にしたらいいのか・・、と、その辺が、AIとの議論のテーマになり、その影響か今回の記事の内容はコードベースではなく、思想設計寄りの内容をAIが出力したので、あまり面白くないかもしれません。

もっと具体的なコードが欲しい人にとっては物足りない内容かもしれません。

[JavaScript] Three.jsでRPG風コマンドUIをゼロから組み上げる(CanvasTexture編)

キー入力で、キャラクターが歩くアニメーション処理を組んでる影響で、キーボード操作するとその場で足踏みしてるのは、コード修正して無いせいです。

キーイベントの関数で、適切なコードを追加する必要がありますが、今日はそれよりUI設計の方に心血を注いで考えてたので修正してないです。

効果音は以下の「効果音ラボ」のサイトからmp3をお借りしてます。

0. 導入:RPGのメニューは思ったより難しい

RPG風のUIを、Three.jsで作ろうとした。 よくある縦に並んだコマンドメニューで、 見た目だけを考えればそれほど複雑なものではない。

ところが実際に手を動かし始めると、 思った以上に考えることが多いことに気づいた。

  • UIをDOMで作るか、Three.jsの中で完結させるか
  • カメラとの関係をどう持たせるか
  • 入力をどこで処理するか
  • 表示とロジックをどこまで分けるか

「ただの縦メニュー」のはずが、 設計の話ばかりが増えていく。

今回はDOMを使わず、 Three.jsの世界の中にUIを“存在させる”形を選んだ。 将来的にWebXRやVR表示を考えると、 UIも3D空間の一部として扱いたかったからだ。

この記事では、 RPGメニューを完成させることは目的にしない。 ゼロからUIを立ち上げて、 表示・選択・入力が通るところまで、 いわゆる「0→1」の段階だけを記録する。

サブメニューや細かいロジックは、 あえて未実装のまま止めている。 その判断に至った理由も含めて、 実装途中の思考をそのまま残しておく。

1. なぜDOM UIを使わなかったか

最初に考えたのは、 このUIを DOMで作るか、Three.jsの中で作るか という点だった。

DOMでメニューを作れば、 HTMLやCSSを使える分、実装自体は楽になる。 文字描画やレイアウトも、ブラウザに任せられる。

ただ、今回はDOM UIを選ばなかった。

理由の一つは、 将来的に WebXRやVR対応を視野に入れている ことだ。 DOMベースのUIは、 3D空間との関係がどうしても分断されてしまう。

もう一つは、 UIを「画面の上に重ねるもの」ではなく、 Three.jsの世界に存在するオブジェクトとして扱いたかったからだ。

カメラに追従するHUDとして、

  • カメラの向きに常に正対する
  • 視点移動と一緒に動く
  • 必要なら距離やスケールを3D的に調整できる

そういった扱いを、 Three.jsのメッシュとして行いたかった。

そのため今回は、 UIもシーンの一部として構築する前提で、 DOMを使わずに進めることにした。

2. UIはPlane + CanvasTextureで作る

Three.jsの中でUIを作るとなると、 まず「何をUIの実体にするか」を決める必要がある。

今回は Plane + CanvasTexture という構成を選んだ。

考え方としてはかなり単純で、

  • Mesh(Plane) は位置・大きさ・向きを持つだけの存在
  • CanvasTexture は見た目をすべて引き受ける

という役割分担にした。

実装していて一番しっくりきたのが、 この構造を 「骨」と「皮」 に分けて考えるやり方だった。

PlaneはUIの「骨」になる。 どこに表示するか、どれくらいのサイズか、 カメラに追従するかどうか。 それだけを担当する。

一方、CanvasTextureは「皮」だ。 背景の色、枠線、文字、選択カーソル、透明部分。 見た目に関することはすべてCanvasで描く。

この分離をはっきりさせておくと、 実装中に迷わなくなる。

Material側では、

  • material.color は常に白
  • opacity は原則いじらない

というルールにした。 色や透明度をMaterialで調整し始めると、 Canvas側の描画と責任が混ざってしまう。

透明にしたい場合も、 Canvasで rgba() を使って描く。 背景だけ半透明、文字は不透明、 そういった表現をCanvas側だけで完結させたかった。

この構成にしておくと、

  • UIの見た目を直したいときはCanvasだけを見る
  • UIの位置や大きさを直したいときはMeshだけを見る

という整理ができる。

結果的に、 UIの見た目と配置がきれいに分離されて、 後の実装がかなり楽になった。

3. カメラにUIをaddするという選択

UIをどこに配置するか、という問題も 意外と最初に悩むポイントだった。

今回は、 UI用のMeshを camera.add(mesh) する形を選んでいる。 つまり、UIはワールド座標ではなく、 カメラのローカル座標系に属する。

この方法にすると、 UIは常にカメラと一緒に動く。 視点を回転しても、移動しても、 UIは画面内の決まった位置に表示され続ける。

いわゆるHUD的な扱いとしては、 かなり素直な構成だ。

ただし、このやり方には 最初かなり戸惑う点がある。

mesh.position.set(x, y, z) を調整しても、 思った方向に動かない。 数値を変えているのに、 直感と一致しない動きをする。

これはバグではなく、 座標系がカメラ基準に変わっているだけだ。

  • 原点はカメラの位置
  • 向きはカメラの向き
  • 手前・奥・上下左右もカメラ基準

ワールド座標で考えていた感覚を、 一度捨てる必要がある。

慣れてしまえば、

  • 「少し左下に出したい」
  • 「少し奥に引っ込めたい」

といった調整を、 カメラ基準でそのまま考えられるようになる。

UIを「画面に貼り付けるもの」ではなく、 カメラの前に存在するオブジェクトとして扱う。 この考え方に切り替えたことで、 HUD的なUIの扱いがかなり自然になった。

4. Canvas描画の設計(論理サイズと実サイズ)

CanvasTextureでUIを描くときに、 もう一つ重要だったのが Canvasのサイズ設計 だった。

最初にやったのは、 Canvasをそのまま小さなサイズで作ることだったが、 これだと文字や線がどうしても荒れる。 拡大表示される以上、 元になるCanvasの解像度が低いと見た目が破綻する。

そこで今回は、

  • Canvasの実サイズ:512 × 512
  • UIの論理サイズ:256 × 256

という2段構えにした。

Canvas自体は高解像度で用意し、 描画時に 論理サイズを基準にスケール する。

const LOGICAL_SIZE = 256;
const scale = canvas.width / LOGICAL_SIZE;
ctx.scale(scale, scale);

こうしておくと、

  • レイアウトは256px基準で考えられる
  • 解像度だけが上がり、見た目はシャープになる
  • Canvasサイズを変えても設計が崩れない

というメリットがある。

Canvas上では、

  • 背景の角丸矩形
  • 半透明の背景
  • 枠線
  • コマンド文字
  • 選択中のカーソル枠

といった要素をすべて手描きしている。 UIの見た目は、このCanvas描画だけで完結する。

もう一つ意識したのが、 毎フレーム描画しない という点だ。

Canvasの再描画は、

  • メニューを開いたとき
  • 選択が上下に移動したとき

といった、 状態が変わったタイミングだけで行う。

UIはアニメーションさせない限り、 常時更新する必要はない。 イベント駆動で描画する方が、 実装も分かりやすく、無駄も少ない。

この設計にしておくと、 UIが増えてもCanvas描画の管理がしやすくなる。

5. キー入力の扱い方

UI実装で地味に重要だったのが、 キー入力をどこで、どう扱うか という点だった。

今回は、

  • keydown / keyup の取得
  • 押されているかどうかの状態管理

これらを UIとは別のファイル に切り出している。

UI側はキーイベントを直接 listen しない。 あくまで、

「今、どのキーが押されたことになっているか」

を読むだけにしている。


UI側の責務は、 入力を解釈することだけだ。

  • メニューが開いているか
  • 上下キーなら選択を動かす
  • Enterなら決定する

それ以上のことはしない。


もう一つ重要なのが、 consumeKey() という考え方。

function consumeKey(code) {
  if (!config.keyState[code]) return false;
  config.keyState[code] = false;
  return true;
}

この関数は、

  • 押されたキーを1回だけ反応させる
  • 処理後は「消費済み」にする

という役割を持っている。

これを入れないと、

  • キーを押しっぱなしにした瞬間に高速スクロールする
  • メニューが一気に開閉する
  • 操作が不安定になる

といった問題がすぐに出る。


キー入力を

  • 取得(keydown / keyup)
  • 状態管理(keyState)
  • 解釈(UI側)

の3段階に分けたことで、 UIのコードはかなり落ち着いたものになった。

将来、 ゲームパッドやVRコントローラに対応する場合でも、 「入力の取得部分」を差し替えるだけで済む。

UI側は、 「意味のある入力が来たかどうか」だけを見る。 この切り分けは、後々かなり効いてくる。

6. コマンドUIクラスの責務

CommandUI クラスには、 あらかじめ 役割をかなり限定して持たせている。

やっていることは、大きく分けて次の4つだけだ。

  • UIの表示/非表示を切り替える
  • 選択中のインデックスを管理する
  • Canvasを再描画する
  • 入力に応じて状態を更新する

逆に言うと、 やらせていないこと がはっきりしている。


まず、表示と非表示。

show()
hide()

UIが見えるかどうかは、 Meshの visible を切り替えるだけ。 アニメーションや演出はまだ持たせていない。


次に、選択インデックスの管理。

moveUp()
moveDown()

ここでは単純に、

  • 配列の範囲内でインデックスを回す
  • 上下移動に応じて数値を更新する

それだけを行っている。 「この選択が何を意味するか」は一切知らない。


描画の更新も同様だ。

drawCommands() は、

  • 現在の commandList
  • 現在の selectedIndex

この2つだけを使ってCanvasを描き直す。 条件分岐はあっても、 意味的な判断は入っていない。


意識的に避けているのが、 ゲームロジックを持たせないこと。

  • アイテムを使えるかどうか
  • 戦闘中かどうか
  • 次にどのメニューに遷移するか

そういった判断を CommandUI の中に書き始めると、 UIクラスが急激に複雑化する。

今回の実装では、

UIは「選択装置」 意味は外で決める

という立場を崩さないようにしている。

その結果、 CommandUI は単純で見通しのいいクラスになり、 「どこまでがUIの責任か」がコードからも分かりやすくなった。

7. サブメニューで止めた理由

現在の実装では、 コマンドを選択すると次の行で止まる。

this.detailUI = new DetailUI(this.camera);

DetailUI は定義されていない。 当然、エラーになる。

これは単なる未実装ではあるが、 同時に 意図的な停止点 でもある。


ここから先、 UIは単なる「見た目」ではなく、 意味を持ち始める。

  • 「アイテム」を選んだら何が起きるのか
  • 「ステータス」は表示専用か、操作可能か
  • サブメニューは右に展開するのか、置き換えるのか

こういった判断が必要になる。

この段階で選択肢が一気に増える。

  • サブメニュー専用のクラスを作るか
  • CommandUI の状態遷移として持つか
  • コマンドツリー構造を別で管理するか

どれを選んでも成立するが、 一度決めると後戻りしづらい。


今回の目的は、 「とりあえず動くメニューを完成させる」ことではなかった。

  • Three.jsでUIをどう立ち上げるか
  • 入力と描画をどう分けるか
  • UIクラスの責務をどこまでにするか

そこを整理することが主眼だった。

サブメニューまで踏み込むと、 設計の軸そのものが変わってしまう。

そのため、 UIが意味を持ち始める直前、 DetailUI is not defined の地点で あえて止めている。

これは未完成というより、 設計を壊さないための区切りに近い。

8. 実装して分かったこと

実際に手を動かしてみて、 いくつかはっきり分かったことがある。

まず、 RPGのメニューは再帰構造になりやすい。

メニューの中にメニューがあり、 その中にさらに選択肢がある。 階層を持ち始めた瞬間、 構造は自然とツリーになる。

ここを何も考えずに、

if (...)
else if (...)
switch (...)

で書き始めると、 コードはすぐに読めなくなる。

条件分岐がUIと意味の両方を抱え込み、 「今どこにいるのか」が分からなくなる。 これはほぼ確実に地獄を見るパターンだ。


次に、 UIと意味を分けないと破綻するということ。

UIは、

  • 選択を受け取る
  • 表示を更新する

それだけでいい。

「この選択は何をするのか」 「ゲームの状態はどう変わるのか」

そこまで持たせると、 UIクラスは一気に重くなる。

今回、サブメニューで止めたのも、 この境界が見えたからだ。


そしてもう一つ、 ファミコン時代の設計が今も通用する理由。

当時のRPGは、

  • メモリも少ない
  • 処理能力も低い
  • 実装できる構造も限られていた

だからこそ、

  • 状態を最小限に持つ
  • データ駆動で分岐する
  • UIとロジックを分離する

そういった設計が自然に洗練されていった。

Three.jsでUIを作っていても、 同じ問題にぶつかる。 環境が変わっても、 本質はあまり変わっていない。

RPGメニューが難しいのは、 見た目の問題ではなく、 構造の問題なのだと実感した。

9. 次にやること(予告)

今回は、 RPGメニューをThree.jsで立ち上げるところまでで止めた。 次に進むとしたら、やるべきことははっきりしている。

まずは、 コマンドツリー構造の整理。

メニューを単なる配列ではなく、 「選択すると次に何が開くか」を データとして持たせる必要がある。 ここをどう設計するかで、 UI全体の見通しが決まる。

次に、 サブメニューの実装。

右に展開するのか、 画面を切り替えるのか、 情報表示専用にするのか。 UIが「意味」を持つ段階に入る。

最後に、 VR表示用の位置・スケール調整。

カメラにaddしたUIは、 VRでは距離感や大きさが大きく変わる。 視認性を保つための再調整が必要になる。

どれも独立したテーマなので、 一気にやるより、 一つずつ切り出して進めるつもりだ。