1. はじめに
ブラウザ上で立体的なブロックを引き抜く3Dゲーム「ブロック・タワー(ジェンガ風ゲーム)」をThree.jsとPhysijsで作りました。
Three.js と Physijs(Web Worker上で動作するBullet/Ammo.js物理エンジン)を組み合わせた3D空間の構築から、Raycasterを使った直感的な3Dブロックのドラッグ操作、そして3D物理空間におけるタワー崩壊・クリア判定のゲームロジックについて解説します。
2. システム構成と全体アーキテクチャ
メインの物理演算をブラウザのメインスレッドで実行すると、描画フレームレート(60fps)が低下して操作がカクつく原因になります。
本作では Physijs を採用し、物理シミュレーションを Web Worker 内(physijs_worker.js / ammo.js)で非同期実行 しています。
3. 主要ロジックの解説
① 16段(全48個)の3Dタワー生成と互い違い配置
16段のタワーを生成する際、段ごとに90度回転(偶数段はZ軸沿い、奇数段はX軸沿い)させて積み上げます。
各ブロックには初期レイヤー情報(_initialY)を持たせ、後述のクリア判定で使用します。
for (var i = 0; i < 16; i++) {
for (var j = 0; j < 3; j++) {
var tower = jengaPiece(i);
tower.position.x = posx;
posx += 10;
tower.position.y += 5 * i;
if (i % 2 === 0) {
// 偶数段: X=0, Z軸方向に3本並べる
tower.rotation.set(0, 0, Math.PI / 2);
tower.position.x = -3;
tower.position.z = 10 * j - 10;
} else {
// 奇数段: Z=0, X軸方向に3本並べる
tower.rotation.set(Math.PI / 2, Math.PI / 2, 0);
tower.position.x = 10 * j - 13;
tower.position.z = 0;
}
// 初期位置とフラグを保持
tower._initialY = 5 * i;
tower._wasRemoved = false;
tower._hasFallen = false;
scene.add(tower);
blocks.push(tower);
}
posx = -13;
}
② Raycaster とドラッグ平面による3Dブロックの移動操作
2Dの画面座標(マウス/タッチ)を3D空間の座標へ変換するため、THREE.Raycaster とカメラに水平な非表示平面(intersect_plane)を組み合わせてドラッグ処理を行います。
また、物理エンジン上でブロックを滑らかに動かすため、直接座標を書き換えるのではなく setLinearVelocity(線形速度の設定) を使用しています。
handleMouseDown = function (evt) {
if (!isStarted || isGameOver) return;
var coords = getClientCoords(evt);
// 画面座標を正規化 (-1 ~ +1)
_vector.set(
(coords.x / window.innerWidth) * 2 - 1,
-(coords.y / window.innerHeight) * 2 + 1,
1
);
_vector.unproject(camera);
var ray = new THREE.Raycaster(camera.position, _vector.sub(camera.position).normalize());
var intersections = ray.intersectObjects(blocks);
if (intersections.length > 0) {
var target = intersections[0].object;
// 【ルール制限】の上にブロックが乗っていない最上段は選択不可
if (!hasBlocksOnTop(target)) {
showToast("上にブロックが乗っているものを選んでください", 1500);
return;
}
selected_block = target;
controls.enabled = false; // カメラ操作を一時無効化
// ブロックの回転を一時固定し、ドラッグ用の速度計算を開始
_vector.set(0, 0, 0);
selected_block.setAngularVelocity(_vector);
mouse_position.copy(intersections[0].point);
block_offset.subVectors(selected_block.position, mouse_position);
intersect_plane.position.y = mouse_position.y;
}
};
// 物理エンジンの毎フレーム更新時
scene.addEventListener('update', function () {
if (selected_block !== null) {
// マウス位置への補正速度を計算して物理オブジェクトに適用
_v3.copy(mouse_position).add(block_offset).sub(selected_block.position).multiplyScalar(5);
_v3.y = 0; // 垂直方向の飛び出しを防止
selected_block.setLinearVelocity(_v3);
}
checkGameOver();
});
③ 上のブロック判定(hasBlocksOnTop)
本家ジェンガのルールに従い、「最上段や上に重なるブロックがないブロック」を引き抜けないように判定します。
function hasBlocksOnTop(targetBlock) {
for (var i = 0; i < blocks.length; i++) {
var other = blocks[i];
if (other === targetBlock || other._wasRemoved || other._hasFallen) continue;
// ターゲットより上にあり、水平方向の距離が重なっているか
if (other.position.y > targetBlock.position.y + 2.5) {
var dx = Math.abs(other.position.x - targetBlock.position.x);
var dz = Math.abs(other.position.z - targetBlock.position.z);
if (dx < 16 && dz < 16) {
return true; // 上にブロックが存在する
}
}
}
return false;
}
④ 3D物理空間における崩壊(GAME OVER)とクリア(CLEAR)の判定
崩壊判定(GAME OVER)
毎フレーム物理更新時に、各ブロックの高さ(Y座標)とタワー中心からの距離(XZ平面)をチェックします。
地面(テーブル)に落下したブロック数がライフ数(3個)を超えると、タワーが崩壊したとみなしてゲームオーバーになります。
function checkGameOver() {
if (isGameOver) return;
var fallenBlocks = 0;
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i];
if (b._wasRemoved || b === selected_block) continue;
var dist = Math.sqrt(b.position.x * b.position.x + b.position.z * b.position.z);
// テーブル面(y < 0.2)への落下、またはタワーからの大幅な滑落(dist > 60)
var hasHitTable = (b._initialY > 0 && b.position.y < 0.2);
var isOffTower = dist > 60;
if (hasHitTable || isOffTower) {
if (!b._hasFallen) b._hasFallen = true;
fallenBlocks++;
}
}
// ライフ表示(ハート3つ)の更新
updateHearts(3 - fallenBlocks);
// 3個以上落ちたらゲームオーバー
if (fallenBlocks > 2) {
triggerGameOver(false);
}
}
完全攻略判定(CLEAR)
引き抜くたびに、タワーに残っているブロックの初期レイヤー(Y座標)ごとの本数を集計します。
「最上段以外のすべての段が残り1本」になった瞬間に完全クリア判定を呼び出します。
function checkWinCondition() {
var layers = {};
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i];
if (b._wasRemoved || b._hasFallen) continue;
var y = Math.round(b._initialY);
if (!layers[y]) layers[y] = [];
layers[y].push(b);
}
var layerYValues = Object.keys(layers).map(Number).sort((a, b) => a - b);
if (layerYValues.length === 0) return false;
// 最上段以外のすべての段が1本以下になっているかチェック
for (var i = 0; i < layerYValues.length - 1; i++) {
var y = layerYValues[i];
if (layers[y].length > 1) {
return false; // まだ2本以上残っている段がある
}
}
return true; // 完全クリア!
}
4. まとめ
Webブラウザ上で快適に動作する3Dバランスゲームの実装ポイントを解説しました。
- Physijs + Web Worker による描画スレッドを阻害しない非同期物理演算
- Raycaster + 速度制御(LinearVelocity) による直感的な3Dドラッグ&ドロップ
- 位置・レイヤー追跡アルゴリズム によるルール制限・崩壊・クリア判定の自動化
Web 3Dコンテンツや物理シミュレーションゲームを開発する際の参考にしてみてください。
