はじめに
以前から、少しだけ企画として考えてた 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つだけで、 ゲームとして必要なライフサイクルは一通り揃います。
-
initGameBox が渡され、壁やサイズを使ってゲームを配置する -
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)) {
// 衝突
}
物理エンジンは使っていません。
理由は単純で、
- ブロック崩しには十分
- 中身を理解しやすい
- 後から差し替えやすい
からです。
「まずは自分で制御できる範囲に収める」 という判断でした。
めり込み・反射で普通に悩んだ話
正直に言うと、 最初は全然うまくいきませんでした。
- 下の壁を作っても跳ねない
- 横の壁にめり込む
- 反射方向がおかしい
理由は単純で、
速度を反転させるだけで、位置を戻していなかった
からです。
衝突処理は、
- 衝突を検出する
- 反射方向を決める
- めり込んだ分だけ位置を補正する
この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をクリックする事で、パドルを動かすことができらほぼ完成な気はします。
それが終わったら、簡易的なシューティングゲームを作りたいと思ってますが、何処まで出来るかはわかりません。
💬 コメント