はじめに
Three.js内に作ったゲームボックスの中を入れ替えて別のゲームに差し替えて動かす企画、第二弾は予告通り、簡易的なシューティングゲームを実装してみました。
前回の記事は以下。
[JavaScript] GameBoxアーキテクチャで作る Three.js ブロック崩し(実装完了編)
前回紹介したGameBoxアーキテクチャを土台に、Three.jsでブロック崩しを実装完了まで構築する。差し替え可能なGameModule設計、Box3による当たり判定、CanvasTextureを使ったGAME OVER表示、UIボタンとキー入力の統合な …
https://humanxai.info/posts/javascript-threejs-gamebox-breakout-complete/ゲーム骨格は、前回のブロック崩しの際にほぼ出来てるので、ゲームロジックだけ考えるだけだったので比較的短時間で実装しています。
Meta Quest2キャプチャ動画。
素材は以下のサイトからお借りしています。
"author": "Neberkenezer",
"License": "CC Attribution",
"url": "https://sketchfab.com/3d-models/galaga-attack-ship-e13026d0d6ef4f51800689999aee0dd0",
Galaga Attack ship - Download Free 3D model by Neberkenezer
Inspired by the arcade game “Galaga” and Namco’s illustration of the ship. - Galaga Attack ship - Download Free 3D model by Neberkenezer
https://sketchfab.com/3d-models/galaga-attack-ship-e13026d0d6ef4f51800689999aee0dd0"author": "alban"
"License": "CC Attribution",
"url": "https://sketchfab.com/3d-models/space-invader-1-ffd5c24f485949c6a1856502f34c3ead",
Space Invader #1 - Download Free 3D model by alban
Just playing with some 3D space invaders :) - Space Invader #1 - Download Free 3D model by alban
https://sketchfab.com/3d-models/space-invader-1-ffd5c24f485949c6a1856502f34c3ead使用しているモデルに関しては、JSONに制作者とライセンス、URL情報を記録してますので、もしサーバにアップして公開する場合は、ゲーム内かWEBサイトに情報を表示する予定です。
1. 前提:GameBoxという土台
今回のシューティングは、 「ゲームを作った」のではなく「ゲームを載せる箱を作った」ところから始まっている。
three.js の scene の中に、 ひとつの 箱(=世界) を用意し、 すべてのゲームはその箱の中で動く、という前提を置いた。
重要なのは、 ゲームごとに scene や render loop を持たせないこと。
世界は一つ、時間も一つ
GameBox では、
requestAnimationFrameは一つupdate(delta)も一つ- 時間の流れは常に一本
という構造にしている。
どのゲームを動かしていても、 世界の時間は止まらず、分岐もしない。
ゲームは「世界の中で起動される振る舞い」にすぎない。
GameBoxの役割
GameBox は、 ゲームの中身を一切知らない。
責務は以下だけ。
- three.js の scene の一部として存在すること
- サイズ(world の幅・高さ)を管理すること
- 壁など、共通の環境要素を持つこと
- 現在動作中のゲーム(GameModule)を保持すること
構造としては非常に単純。
GameBox
├─ group(three.js sceneの一部)
├─ wall / size
└─ current GameModule
GameBox 自身は 「シューティングなのか、ブロック崩しなのか」を一切判断しない。
ゲームは「差し替え可能な部品」
GameBox の中で動くゲームは、 すべて GameModule として扱う。
GameBox 側が呼ぶのは、
- 初期化
- 毎フレーム更新
- 終了処理
だけ。
ゲームの中身は完全にブラックボックスで、 差し替えても GameBox は壊れない。
この前提を最初に作ったことで、
- 1作目:ブロック崩し
- 2作目:シューティング
を、同じ箱の中でそのまま切り替えられるようになった。
なぜこの構造にしたか
理由は単純で、
- ゲームを増やしたくなるのは確実
- デモ用の演出やミニゲームも載せたい
- VR / PC どちらでも同じ世界を使いたい
から。
「1本のゲームを作る」より先に 「世界を壊さずにゲームを入れ替えられる構造」を決めておく方が、 あとから圧倒的に楽になる。
この GameBox を土台にして、 2作目としてシンプルなシューティングを実装した。
2. GameModuleという契約
GameBox の中で動くゲームは、 すべて GameModule という共通の契約を持つ。
契約といっても難しいものではなく、 必要なのは次の3つだけ。
init(box)
update(delta)
dispose()
この3つが揃っていれば、 どんなゲームでも GameBox に載せられる。
init(box) ― 世界に参加する
init(box) は、
ゲームが 世界に参加する瞬間。
- GameBox への参照を受け取る
- 自分が使うオブジェクトを生成する
- scene(正確には GameBox の group)に追加する
この時点では、 ゲームはまだ「動いていない」。
単に 世界に配置された状態になるだけ。
update(delta) ― 時間の中で動く
update(delta) は、
世界の時間が流れるたびに呼ばれる。
- 移動
- 当たり判定
- クールダウン
- 勝利/敗北判定
すべてここに集約する。
重要なのは、 ゲーム側で時間を持たないこと。
- setInterval を使わない
- requestAnimationFrame を持たない
時間は常に GameBox から渡される。
世界の時間 → GameBox → GameModule.update(delta)
この構造にしておくと、
- VR / PC の違いを意識しない
- 一時停止や再開が簡単
- フレームレート差に強い
という利点が出る。
dispose() ― 世界から消える
dispose() は、
ゲームが 世界から退出する瞬間。
- scene からオブジェクトを remove
- 配列をクリア
- 内部状態をリセット
ここを最初から用意しておくことで、
- ゲーム終了後に再スタートできる
- 別のゲームに差し替えられる
- メモリや参照が残らない
「とりあえず動く」実装では、 この処理が後回しになりがちだが、 差し替え前提なら必須になる。
同じ箱でゲームを切り替える
この契約があるおかげで、
- ブロック崩し
- シューティング(STG)
を、同じ GameBox の中でそのまま切り替えられる。
GameBox 側は、
- 今動いているゲームを dispose
- 新しい GameModule を init
- update を呼び続ける
それだけ。
ゲームの種類は一切知らない。
再スタートできる、という意味
この構造で作ると、 「ゲームオーバー後にリスタートできる」ことが 自然な結果として付いてくる。
- 終了時に dispose
- 同じ GameModule をもう一度 init
最初から作り直すだけ。
特別なリセット処理は必要ない。
なぜ3つだけで足りるのか
理由は単純で、
-
ゲームに必要なのは
- 生まれる
- 時間の中で動く
- 消える
この3段階しかないから。
GameModule は 「ゲームのライフサイクル」を そのままコードに落としただけの契約になっている。
この契約を土台にして、 2作目としてシンプルなシューティングを実装した。
3. 2作目:シンプルなシューティング
2作目として実装したのは、 ごくシンプルな固定画面のシューティングゲームだ。
見た目もロジックも最小限で、 派手な演出や複雑な AI は入れていない。
その代わり、 最初から「終わり」までを通して作ることを重視した。
ゲームの流れを最初に決める
このシューティングでは、 実装に入る前にゲームの流れを固定した。
- UI ボタンでゲーム開始
- プレイ中は update(delta) で進行
- 敵を全滅させたら勝利
- 自機が被弾したらゲームオーバー
- 終了後は dispose
- 同じゲームを再スタート可能
「途中まで動く」状態を作らないよう、 終了条件と後始末を先に決めてから実装している。
ロジックはできるだけ単純に
ゲームロジック自体は、 あえてシンプルにしている。
- 自機は左右移動のみ
- 弾は直進するだけ
- 敵は横移動して弾を撃つ
- 当たり判定は Box3 を使った基本的なもの
複雑な処理を入れなくても、 GameModule の契約が正しく機能しているかは十分に確認できる。
開始・終了・再スタートを同じ構造で扱う
このシューティングの一番のポイントは、 ゲームの開始・終了・再スタートを すべて同じ仕組みで扱っている点だ。
- 開始時は
init(box) - プレイ中は
update(delta) - 終了時は
dispose()
ゲームオーバーでも、勝利でも、 やっていることは同じ。
一度世界から消して、 もう一度同じゲームを世界に載せ直す。
VR / PC 両対応でもゲーム側は変わらない
UI 操作は VR と PC の両方に対応しているが、 シューティング本体はそれを一切意識していない。
- UI は世界の外側
- ゲームは世界の内側
という分離のおかげで、
- 入力方法が変わっても
- 表示環境が変わっても
ゲームロジックは同じまま動く。
「動くデモ」ではなく「ゲーム」
多くのサンプルコードは、
- 1回動かしたら終わり
- 再スタートを考えていない
- dispose が存在しない
ことが多い。
今回のシューティングは、 そうした「動くデモ」ではなく、
何度でも起動できるゲーム
として作っている。
これは派手さよりも、 構造としての完成度を優先した結果だ。
この考え方を前提に、 次の章では UI や操作まわりの話に進む。
4. VR / PC 両対応のUI
この構成で一番やってよかったと感じているのが、 VR と PC の両方で同じゲームをそのまま動かせる点だ。
重要なのは、 「VR対応のゲームを作った」ことではなく、 UIとゲームを完全に分離したこと。
UIは「世界の外側」に置く
UI は three.js の世界そのものではなく、 世界を制御する外側の層として扱っている。
役割はシンプル。
- スタートボタンを押す
- GameBox にゲームをセットする
やっていることはこれだけ。
UI層(外)
└─ GameBox.setGame(new ShootingGame())
UI が VR 用か PC 用かは、 ここでは重要ではない。
ゲーム側は入力方式を知らない
シューティングゲーム側は、
- マウスか
- キーボードか
- VRコントローラか
といった入力方式を一切知らない。
ゲームが知っているのは、
- 世界が開始された
- update(delta) が呼ばれる
- 終了したら dispose される
という事実だけ。
ゲーム層(内)
└─ 入力方式を知らない
この分離のおかげで、
- VR でも
- PC でも
- 将来別の入力方式を足しても
ゲームのコードは一切変えずに済む。
「操作」ではなく「起動」をUIの責務にする
UI に持たせているのは、 細かな操作ではなく ゲームの起動と切り替えだけ。
- スタートする
- 終了後にリスタートする
- 別のゲームに切り替える
このレベルに責務を限定することで、
- UI が肥大化しない
- ゲームロジックに影響しない
- VR / PC の差異を吸収できる
というメリットが出る。
同じ世界、同じゲーム
結果として、
- 同じ three.js の世界の中で
- 同じ GameBox を使い
- 同じ ShootingGame を起動する
という構造が保たれている。
VR 対応は 「特別な分岐」ではなく、 UIの差し替えとして自然に収まった。
この UI / ゲーム分離があるからこそ、 次にゲームを増やしても、 操作まわりを作り直す必要がない。
次の章では、 シューティング内部のロジック設計について触れる。
5. 弾・敵・衝突の設計で意識したこと
このシューティングでは、 派手なアルゴリズムよりも あとから壊れない構造を優先している。
細かいコードの説明は省き、 設計上意識したポイントだけを整理する。
弾は Mesh をそのまま配列で管理する
弾は、
- 専用クラスを作らず
- position や速度を別オブジェクトに分けず
three.js の Mesh をそのまま配列で管理している。
これにより、
- scene への add / remove が直感的
- dispose 時に確実に消せる
- 当たり判定で迷わない
という利点がある。
「弾は画面に存在するオブジェクトである」 という事実を、そのままコードに反映した形だ。
cooldown は update に集約する
発射間隔や待ち時間は、
- 入力処理の中で減らさない
- 弾が消えた瞬間にリセットしない
すべて update(delta) の中で時間として管理している。
- 毎フレーム減算
- 0以下になったら発射可能
という単純な仕組みにすることで、
- フレームレート差に強い
- 入力ロジックが汚れない
- バグの原因が減る
「時間は update で流れる」という前提を崩さないことを意識した。
当たり判定は player / enemy で分離する
当たり判定は、
- 自機の弾 → 敵
- 敵の弾 → 自機
を明確に分けている。
共通化しすぎると、
- 誰の弾か分からなくなる
- 自分の弾に当たる
- 条件分岐が増える
といった問題が起きやすい。
役割ごとに判定を分けることで、 読み返したときに迷わないコードになる。
dispose を最初から用意する
このシューティングでは、
実装の最初から dispose() を前提にしている。
- 敵を消す
- 弾を消す
- 配列を空にする
- 状態をリセットする
ゲームオーバーでも、勝利でも、 やっていることは同じ。
一度世界から消して、 もう一度初期化するだけ。
この構造にしておくことで、
- 再スタートが自然にできる
- ゲーム切り替えで壊れない
- デバッグもしやすい
壊れにくさを優先する
今回意識したのは、
あとから壊れる実装をしない
という一点。
- 動くかどうかより
- 続けて触れるかどうか
を基準に設計している。
結果として、 派手さはないが 何度でも起動できるシューティングになった。
次の章では、 作ってみて分かったことを整理する。
6. 作って分かったこと
今回、2作目としてシューティングを作ってみて、 一番はっきり分かったことがある。
制作時間の差は「才能」ではなかった
- 1作目のブロック崩しは、丸一日かかった
- 2作目のシューティングは、半日で終わった
最初は 「慣れたから早くなったのか」と思ったが、 実際には少し違う。
共通コードが効いていた
早く終わった理由は、
- GameBox という土台があった
- GameModule という契約が決まっていた
- update(delta) の流れを考えなくてよかった
- dispose の形も決まっていた
つまり、 考える場所が最初から減っていた。
「早くなった」のではなく「考えなくてよくなった」
2作目で感じたのは、
- 実装が速くなったというより
- 迷うポイントが消えていた
という感覚だった。
- 時間管理はどうするか
- 終了後どう戻すか
- リスタートできるか
- VR / PC の差をどう吸収するか
これらはすべて 1作目で一度考え終わっている。
2作目では、 ゲーム固有の部分だけに集中できた。
小さく作って、積み上げる
今回のシューティングは、 決して難しいゲームではない。
ただし、
- 最初から最後まで通して作り
- 何度でも起動でき
- 別のゲームに差し替えられる
という条件を満たしている。
この前提を崩さなければ、 次のゲームはさらに楽になる。
次に向けて
この構造をベースにすれば、
- デモ用の物理アニメーション
- さらに別ジャンルのミニゲーム
- 世界の外側から動く演出
を、同じ箱の中に追加できる。
速さよりも、 積み上げが効く設計を選んだのは正解だった。
「早くなった」のではなく 考えなくてよくなった」
これが、 2作目を作って得た一番の実感だ。
今後の実装
私は、バイブコーディングはしないですが、AIと対話・議論しつつ自分で手を動かして実装していて、AIがコードを書いたり、そのコードを修正したり、改造したり、書き換えたりなど、伴走関係でコーディングを続けていますが、今回もそうで、実装が終わった後は、内容を毎回記事まとめてもらってます。
自分で本来記事を書くべきなのですが、コーディングに集中したいので記事は、はじめにと、あとがきで少し書く程度です。。
今回の実装でゲーム内ゲーム的な、ゲームボックスが問題なく動くことが分かったので、このボックス内ゲームを量産できる体制はできたと思います。
次は、実装したゲームを選択できるUIの実装と、最初のでも画面的な物を作成する予定で、本日は、Canvas 2Dで作った、ボールバウンドアニメーションを実装する途中で止まってます。
ゆとりがあれば、本日中に実装する予定ですが、恐らく明日以降になると思います。
💬 コメント