はじめに

以前から、少しだけ企画として考えてた Three.js で作った箱庭空間内に、ブロック崩しゲームを実装できないか?というアイデア。

ブロック崩しは2D なので3D空間でも、以前開発した実装がそのまま生かせるのではないかと…。

今日は、まずそのベースとなる枠組みで、3D空間内にゲーム用のBOXを実装し、尚且つ、ゲームボックスは自由に配置できてリサイズできる仕様にする事。

更に、ボックス内を別のゲームに差し替えられるようにし、ブロック崩しゲーム以外に、インベーダーゲームみたいな物にも差し替えられるようにするなど、 以前のゲーム開発とは違ったアプローチでの設計をしてみました。

今日はとりあえず、枠組みを作り、中でボールが跳ねるところまで作ったので、その実装メモです。

今回は、制作過程のスクショも貼って見ます。

まずはデバック用のワイヤーフレームで親になる枠組み。

次に周囲の壁を作成。
この段階でもテクスチャは貼らず、試験的にワイヤーフレームでやってます。(これ結構便利)

最後にボールをバウンドする所まで作り、テクスチャを貼りました。

完成後に撮影したVR動画。

1. 何を作ろうとしたか

今回作ろうとしたのは、 VR空間の中に「ゲームそのもの」を置くための箱です。

単にブロック崩しを作りたかった、というよりも、

  • VRの世界の中に
  • 他のオブジェクトと同じように存在して
  • 好きな位置・大きさで配置できて
  • 中身だけを後から入れ替えられる

そんな 「ゲーム用の器」 を作りたかった、という方が正確です。


発想としてはかなり単純で、

  • 動画スクリーン
  • UI操作パネル
  • キャラクター

これらがすでに VR空間内の1オブジェクトとして成立しているなら、 ゲームも同じ扱いにできるはず、という考えでした。


そこで条件を整理しました。

  • ゲームは 箱の中で完結する
  • ワールド座標を意識せず、ローカル座標だけで動く
  • 将来、別のゲームに差し替えられる

ブロック崩しは、その構造を検証するための 最初の実験体です。

モデルも不要で、

  • 四角い壁
  • 球体のボール

だけで成立するため、 「設計が正しいかどうか」を確かめるにはちょうど良い題材でした。


この時点では、

  • 見た目の完成度
  • ゲームとしての作り込み

よりも、

ゲームを「空間の中の1オブジェクト」として成立させられるか

そこに一番フォーカスしていました。

この前提が決まったことで、 以降の設計はかなり素直に進みます。

2. GameBox という発想

この構成の中心になるのが GameBox です。

GameBox は「ゲームそのもの」ではなく、 ゲームを入れるための箱(ローカル空間)として設計しました。

Three.js では通常、

  • Scene が世界
  • Mesh がオブジェクト

という構造になりますが、 その間に もう一段、独立した空間 を作りたかった。

そこで使ったのが THREE.Group() です。


ワールドとは独立したローカル空間

GameBox は THREE.Group() を1つ持ちます。

this.group = new THREE.Group();

この Group に対して、

  • ボール
  • パドル
  • そのほかのゲーム要素

をすべて追加します。

こうすることで、 ゲームの中では ワールド座標を一切意識する必要がなく なります。

ボールは「箱の中心からどれくらい動いたか」だけを考えればよく、 ゲームロジックは完全にローカルな座標系で完結します。


THREE.Group() を使った理由

THREE.Group() は、

複数のオブジェクトをまとめて扱うためのコンテナ

ですが、実際に使ってみるとそれ以上の意味を持ちます。

Group 自体が、

  • position
  • rotation
  • scale

を持つため、 Group = 小さな世界 として扱えるからです。

これはつまり、

Three.js の中に、もう1つ Three.js を作る

ような感覚に近い。


position / scale / rotation を一箇所で制御できる強さ

GameBox の一番の強みはここです。

this.group.position.copy(position);
this.group.rotation.copy(rotation);
this.group.scale.copy(scale);

この3つを設定するだけで、

  • ゲーム全体の配置
  • ゲーム全体の大きさ
  • ゲーム全体の向き

を一括で制御できます。

中に入っているボールや壁は、 何も知らなくていい。

「この箱は VR 空間のどこにあるか」 「どれくらいのサイズで見せたいか」

それらはすべて GameBox 側の責務です。


結果として、

  • ゲームはただの部品
  • VR空間の中に自由に配置できる
  • 他のオブジェクトと同じルールで扱える

という状態になります。

この GameBox という1レイヤー を用意したことで、 以降の設計は驚くほどシンプルになりました。

3. GameModule インターフェース

GameBox を「入れ物」として定義したあと、 次に考えたのが 中身をどう差し替えるか でした。

そこで用意したのが GameModule というインターフェースです。


init / update / dispose という最小構成

GameModule は、次の3つだけを持つシンプルな形にしています。

class GameModule {
  init(box) {}    // 初期化
  update(delta) {} // 毎フレーム更新
  dispose() {}     // 後始末
}

この3つだけで、 ゲームとして必要なライフサイクルは一通り揃います。

  • init GameBox が渡され、壁やサイズを使ってゲームを配置する

  • update 毎フレーム呼ばれ、移動・判定などを行う

  • dispose ゲームを切り替える際に、Mesh や状態を片付ける


「ゲーム=差し替え可能な部品」

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

ゲームを特別扱いしない

という点です。

GameModule は、

  • Scene を知らない
  • ワールド座標を知らない
  • VRかどうかも知らない

ただ 箱(GameBox)を渡されて、その中で動くだけ。

つまり、

  • ブロック崩し
  • インベーダー
  • ピンボール
  • 何も表示しないデモ用ゲーム

すべて 同じ入口 を持ちます。


ブロック崩しもインベーダーも同じ入口

実際の切り替えは、非常に単純です。

setGame(new BreakoutGame(), gameBox);

これだけで、

  • 前のゲームを dispose
  • 新しいゲームを init

という流れが成立します。

ブロック崩しを作っている最中でも、

「これ、インベーダーに変えても動くな」

と常に考えられるのは、この構造のおかげです。


GameModule を挟んだことで、

  • ゲームの数が増えても構造は変わらない
  • UI や VR 操作は共通のまま使える
  • GameBox 自体は一切修正しなくていい

という状態になりました。

ここでようやく、

ゲームは VR空間の中の1オブジェクトに過ぎない

という感覚が、 実装として腑に落ちた気がします。

4. 壁と当たり判定

ブロック崩しの実装で一番時間を使ったのが、 壁と当たり判定の部分でした。

ここは正直に書いた方が伝わるところなので、 きれいにまとめすぎず、そのまま書きます。


見た目と論理を分けた設計

まず意識したのは、

「見えている壁」と「当たり判定」を同一視しない」

という点です。

壁は GameWall クラスとしてまとめ、

  • 見た目用の Mesh
  • 当たり判定用の参照(配列)

を分けて管理しています。

this.group = new THREE.Group();
this.walls = [];

group は表示用、 walls は衝突判定用。

こうしておくことで、

  • 見た目を差し替えてもロジックは壊れない
  • 下の壁だけ無効にする
  • 将来、ブロックや敵も同じ仕組みで扱える

という拡張余地が生まれます。


Box3 ベースのシンプルな衝突判定

当たり判定には Three.js の Box3 を使いました。

const ballBox = new THREE.Box3().setFromObject(this.ball);
const wallBox = new THREE.Box3().setFromObject(wall);

if (ballBox.intersectsBox(wallBox)) {
  // 衝突
}

物理エンジンは使っていません。

理由は単純で、

  • ブロック崩しには十分
  • 中身を理解しやすい
  • 後から差し替えやすい

からです。

「まずは自分で制御できる範囲に収める」 という判断でした。


めり込み・反射で普通に悩んだ話

正直に言うと、 最初は全然うまくいきませんでした。

  • 下の壁を作っても跳ねない
  • 横の壁にめり込む
  • 反射方向がおかしい

理由は単純で、

速度を反転させるだけで、位置を戻していなかった

からです。

衝突処理は、

  1. 衝突を検出する
  2. 反射方向を決める
  3. めり込んだ分だけ位置を補正する

この3点が揃わないと、挙動が安定しません。

特に横壁に当たったとき、

  • X方向に当たったのか
  • Y方向に当たったのか

を判定しないと、 反射方向を間違えます。

そのため、 重なり量(overlap)を使って、

const overlapX = Math.min(
  ballBox.max.x - wallBox.min.x,
  wallBox.max.x - ballBox.min.x
);
const overlapY = Math.min(
  ballBox.max.y - wallBox.min.y,
  wallBox.max.y - ballBox.min.y
);

どちらの軸でぶつかったかを判断するようにしました。


ここは完全に

「2D Canvas 時代に散々悩んだやつだ」

という感覚でした。

ただ、違ったのは 壁が「実体」として存在していることです。

Mesh があり、Box があり、 それに対して衝突している。

このおかげで、

  • なぜおかしくなるのか
  • どこで補正すべきか

を空間的に理解できました。


壁と当たり判定を通して、

Three.js は描画エンジンだけど、 設計次第で十分ゲームロジックを支えられる

という手応えを得られた部分でもあります。

5. 2Dロジックが3Dオブジェクトになる瞬間

この実装で一番衝撃だったのは、

2D Canvasで書いていたロジックが、 そのまま3D空間の中で動いている

と実感できた瞬間でした。


2D Canvasでやっていたことが、そのまま使える

やっていること自体は、本当にいつものブロック崩しです。

  • ボールに速度ベクトルを持たせる
  • 毎フレーム position を更新する
  • 壁に当たったら反射する

ロジック自体は、

this.ball.position.x += this.velocity.x * delta;
this.ball.position.y += this.velocity.y * delta;

この程度のものです。

2D Canvas時代と何も変わっていません。

「Three.jsだから特別な物理が必要」 そんなことは一切なく、 単に座標が Vector3 になっただけです。


違うのは「見る自由度」

決定的に違ったのは、 ロジックではなく体験でした。

Canvasだと、

  • 上から見る
  • 正面から見る

このどちらかに固定されます。

でも今回は違います。

  • 横から覗ける
  • 斜めから見られる
  • 真横に立って眺められる
  • 上からも下からも見える

しかもそれが、

「ゲーム画面」ではなく 「空間の中の物体」

として存在しています。


VRで覗いた瞬間の感覚

QuestでVR表示に切り替えて、 ゲームボックスの横に立った瞬間、

正直、言葉が出ませんでした。

  • 中でボールが跳ねている
  • 壁に当たって反射している
  • それをどの角度からでも見られる

しかも、

これは「VR用に作り直したゲーム」ではない

という点が一番おかしい。

2Dで書いていたロジックが、 そのまま立体空間に放り込まれただけ。

それなのに体験は完全に別物です。


この時点で気づきました。

これはゲームを作っているというより、 「ゲームという装置」を空間に配置している

という感覚に近い、と。

ブロック崩しはただの通過点で、

  • ピンボール
  • シューティング
  • インベーダー風

何にでも差し替えられる。

そう確信できたのが、 この「3Dオブジェクトとして動いた瞬間」でした。

6. これは箱庭の中の1オブジェクトに過ぎない

ここで一番大きく認識が変わったのは、

ゲームが特別な存在ではなくなった

という点でした。


UIも、動画も、キャラも、全部同じ world

すでにこの空間には、

  • 動画を再生するスクリーン
  • レーザーポインタで操作するUI
  • 歩き回るキャラクター
  • セリフを喋る吹き出し

が存在しています。

そして今回作ったゲームボックスも、

scene.add(gameBox.group);

ただそれだけです。

  • 専用シーンはない
  • 特別なレンダリングもない
  • VR用の別コードもない

他のオブジェクトと全く同じ扱いです。


ゲームは特別じゃない

普通、ゲームを作るときは、

  • 画面全体を占有する
  • UIも入力も専用
  • 他の要素とは切り離される

そういう前提で考えがちです。

でも今回は違います。

  • 横に動画が流れている
  • 手前に操作パネルがある
  • キャラが近づくと喋る
  • その奥でブロック崩しが動いている

どれも同じ world の中にあるだけ。

ゲームだから特別 という扱いは、どこにもありません。


だから拡張できる

この設計にしたことで、 先のイメージが一気に広がりました。

  • 操作パネルでゲームを切り替える
  • 箱の中身をインベーダーに差し替える
  • ボールを複数にする
  • モデルデータを敵として並べる
  • キャラがゲームを観戦する

どれも、

「箱の中身を差し替えるだけ」

で実現できます。

ゲームを作っているというより、

ゲームが入る箱を作った

この感覚がしっくり来ています。


ここまで来てようやく、

  • なぜ Group が便利なのか
  • なぜ init / update / dispose に分けたのか
  • なぜ world と切り離したのか

すべてが一本につながりました。

このブロック崩しは完成形ではありません。 でも、

この構造そのものは、もう完成している

そう思えるところまで来ました。


タイトル案(例)

  • 「Three.js / WebXRで“差し替え可能なゲーム箱”を作る」
  • 「2Dで作っていたブロック崩しが、VR空間の1オブジェクトになった話」
  • 「THREE.Groupが理解できた瞬間、設計が一気に楽になった」

これは 実装ログであり、設計思考の記録。

あとから自分が読み返しても価値があるやつ。 勢いが残ってる今のうちに、ラフでもいいから書くのが正解。

おわりに

いつも、AIと対話して実装議論をしながら開発をしてますが、今回も本日実装した内容をAIにまとめてもらいました。

ゲームロジックは、ブロック崩しゲームの中で完成してるので、ゲームコードはそこまで複雑ではないですが、ただThree.jsの空間内で実装するという点と、 クラス設計で尚且つ、THREE.Group を使うという点で、苦労してます。

でも、実際頑張ればここまで出来るのかというのが驚きです…。

枠組みとベースとなる、animationフレーム制御まで出来てるので、もうここまで来たらゲームの実装は楽かもしれません。

後は、レーザーポインタでUIをクリックする事で、パドルを動かすことができらほぼ完成な気はします。

それが終わったら、簡易的なシューティングゲームを作りたいと思ってますが、何処まで出来るかはわかりません。