01. 通学電車の息抜きから「立体2048」の構想へ
さて、大学に進学したわけですが、通学電車が往復で1時間20分(片道40分)もあるわけですよ。電車の車内ってシンプルに通信が途切れがちですし、月末のギガも無駄に消費したくない。そこでオフラインでもサクッと遊べる暇つぶしとして愛用していたのが、Gabriele Cirulli氏のオープンソース数字パズル『2048』でした。
そんな折、2026年8月にGeminiの学割プラン(コスパ重視の学生にはありがたい選択肢ですね)を契約したのをきっかけに、Geminiモデルを基盤とする次世代エージェント型AI「Antigravity」を触り始めました。そこでふと、「2048のあの平らな盤面を、X・Y・Z軸の3次元空間に拡張したWebゲームをブラウザで作れないか?」と思い立ち、開発をスタートしたわけです。
02. AIコーディング(Antigravity)への指示と最初の失敗
大学の講義でPythonの基礎とHTMLを少し触った経験くらいはありましたが、Three.jsを使ったWeb3D開発なんて完全に未経験なわけです。
とりあえずAntigravityに、以下のようなざっくりとした初期プロンプトを投げてみました。
【初期プロンプトの実例】
「Three.jsを使って、ブラウザ上で3×3×3の立体的な2048パズルを作ってください。
キューブ型のタイルがグリッド上に配置され、キーボードの矢印キーでタイルがスライドして合体するロジックを実装してください。
カメラはマウスドラッグで自由に回転できるようにしてください。」
Antigravityは一瞬でThree.jsのシーン構築、極座標カメラドラッグ、3次元グリッドのスライド合体ロジックを組み上げてくれました。「お、これすぐ完成するやん…」と思ったわけですが、いざ動かしてみたら速攻で壁にぶつかりました。操作がシンプルに崩壊したのです。
AIが最初に生成したコードは、固定されたワールド座標系(X軸・Y軸)のプラス・マイナス方向に数値を動かしているだけでした。
そのため、カメラを裏側(180°)に回すと、右キーを押したのにタイルが左へ動く。(いや、そりゃそうやん…と)。斜め45°から見ているときは、右を押したはずなのに奥へと滑っていく始末です。
視点の角度に合わせて、入力と動く方向を動的に再マッピングしなければゲームになりません。そこでAntigravityと画面を突き合わせて議論を重ね、「Three.jsのカメラ姿勢行列(matrixWorld)から現在の視線基底ベクトルを取り出し、立体空間の直交6軸との内積を比較して最大値を選ぶ」という幾何学的なアプローチへと舵を切りました。
あちこちのチャットツールを反復横跳びするのではなく、Antigravity単体とのペアプログラミングとデバッグ機能に集中し、特異点のクランプや境界条件の整理、コード修正を一気通貫で進めていきました。
【人間側の泥臭い検証と介入:Math.acosの罠】
AIが最初に出してきたコードは、入力方向とカメラ各軸のなす角を求めるために、内積をわざわざ Math.acos(targetVector.dot(camRight)) に渡す逆三角関数ロジックになっていました。ところが、浮動小数点演算の丸め誤差で内積値がごくわずかに 1.0 を超えて(例: 1.0000000000000002)、Math.acos の定義域(-1.0 〜 +1.0)を外れて NaN を吐き出すという地味に致命的なバグを踏んだわけです。結果としてカメラを回した後に軸が選ばれず、スワイプしても完全に無視される(無反応)状態に。「動かんやん…」と頭を抱えましたが、そもそも角度(ラジアン)なんて出さなくても、内積のコサイン値そのものを直接比較すればいいじゃないかと気づき、シンプルな直接比較アルゴリズムへとリファクタリングしました。
【修正指示プロンプト】
「カメラの姿勢行列(matrixWorld)から現在の視線基底ベクトル(右方向・上方向・水平視線方向)を取得してください。
ユーザーが入力した目標方向ベクトルと、立体空間の直交6軸(±X, ±Y, ±Z)との内積(Dot Product)を計算し、
内積が最大となる軸へタイルをスライドさせる動的マッピング関数 getRelativeDirection を作成してください。」
03. 視点相対スワイプの内積アルゴリズムと境界条件の処理
実装された関数 getRelativeDirection は以下の通りです。浮動小数点誤差を考慮した許容誤差 EPS と、ゼロベクトルの安全ガードを組み込んでいます。
// game-renderer.js より抜粋:カメラの回転角度に応じた最適軸の動的判定
getRelativeDirection(dirName) {
if (!this.camera) return null;
const camRight = new THREE.Vector3();
const camUp = new THREE.Vector3();
this.camera.matrixWorld.extractBasis(camRight, camUp, new THREE.Vector3());
const camForward = new THREE.Vector3();
this.camera.getWorldDirection(camForward);
let targetVector = new THREE.Vector3();
// 画面上の操作入力(右・左・上・下・奥・手前)に応じた目標ベクトルを定義
if (dirName === 'right') targetVector.copy(camRight);
else if (dirName === 'left') targetVector.copy(camRight).negate();
else if (dirName === 'up') targetVector.copy(camUp);
else if (dirName === 'down') targetVector.copy(camUp).negate();
else if (dirName === 'in' || dirName === 'out') {
// 水平視線ベクトルの生成
targetVector.set(camForward.x, 0, camForward.z);
// 直上見下ろし時の安全ガード(normalize前に長さを判定)
if (targetVector.lengthSq() < 1e-6) return null;
targetVector.normalize();
if (dirName === 'out') targetVector.negate();
}
// 立体空間の直交6軸との内積を比較
const axes = [
{ axis: 'x', positive: true, dir: new THREE.Vector3(1, 0, 0) },
{ axis: 'x', positive: false, dir: new THREE.Vector3(-1, 0, 0) },
{ axis: 'y', positive: true, dir: new THREE.Vector3(0, 1, 0) },
{ axis: 'y', positive: false, dir: new THREE.Vector3(0, -1, 0) },
{ axis: 'z', positive: true, dir: new THREE.Vector3(0, 0, 1) },
{ axis: 'z', positive: false, dir: new THREE.Vector3(0, 0, -1) }
];
const EPS = 1e-6; // 45°境界付近の浮動小数点誤差を考慮する許容誤差
let bestMatch = null;
let maxDot = -Infinity;
for (const a of axes) {
const dot = targetVector.dot(a.dir);
// 許容誤差EPSを超えて明確に大きい場合のみ更新(同点時は配列定義順で先頭が優先)
if (dot > maxDot + EPS) {
maxDot = dot;
bestMatch = { axis: a.axis, positive: a.positive };
}
}
return bestMatch;
}
内積(Dot Product)による比較の仕組み
高校数学を思い出すと、2つの単位ベクトル a と b の内積は a · b = cos θ(θはなす角)なわけです。方向が完全に一致していれば cos 0° = 1.0、直交していれば 0、真逆なら -1.0 になります。
つまり、ユーザーの画面入力ベクトルと立体空間の直交6軸との内積をサクッと計算し、一番数値が大きい(1.0に近い)軸を選んであげるだけで、カメラがどんな角度を向いていようが、画面上の「右」や「上」に最も近い軸が自動的に決まるわけです。シンプルですがこれが一番強固でした。
直面した境界条件と幾何学的対処
ただ、理屈通りに綺麗にいかないのが個人開発の常なわけで、実装してみて2つの地味な境界条件(罠)に直面しました。
- 真上(極角0°)でのゼロベクトル問題: 視線ベクトル
camForwardのY成分をゼロにして水平化(camForward.x, 0, camForward.z)しているため、真上から真下を見下ろすとXもZもほぼ0になってしまいます。Three.jsのnormalize()はゼロ除算回避が入っていますが、真上付近だと浮動小数点ノイズで向きが不定になってしまうわけです。そこでnormalize()の直前に長さ二乗ガード(targetVector.lengthSq() < 1e-6)を入れて安全に入力を無効化し、さらにカメラ制御側で極角を14°(0.25 rad)〜 166°(π - 0.25 rad)の範囲にクランプして特異点に入れないようにしました。 - 深い俯角での軸の縮退現象: カメラを上から見下ろす角度が深くなると(斜め45°方向では
tan φ = √2付近)、画面の「上(up)」ベクトルと視線の「奥(in)」ベクトルが直交性を失い、最大内積の判定で同じ水平軸へ縮退してしまいます。要するに、画面を上にスワイプしたのにY軸(上昇)ではなく水平方向にタイルが滑ってしまう現象です。
本作ではこの現象を「画面の見た目方向に忠実な直感マッピング」として残す一方、「確実にY軸(1F〜3F)やZ軸(奥・手前)へ動かしたい」というときのために、画面下部に専用のD-Padボタン(1F〜3F / 奥・手前)を独立配置しました。無理に複雑な数学的補正をこねくり回すより、UI側で仕組み化して逃げ道を用意する方が、プレイヤーにとっても開発者にとっても平和なわけです(※D-Padはスライサー切替ではなく、タイルを直接その軸方向へ動かすボタンです)。
ちなみに、斜め45°などの境界で2つの軸の内積が完全に同点になった場合は、許容誤差(EPS = 1e-6)により、axes 配列の定義順(+X, -X, +Y, -Y, +Z, -Z)の先頭が決定論的に選ばれるようにして挙動のブレを抑えています。
スワイプとカメラ回転の振り分け、入力方向の変換
「同じ画面ドラッグで、タイルの移動とカメラ回転をどう見分けるのか?」という疑問もあると思うわけですが、これはタッチ開始時のRaycaster判定で振り分けています。キューブを触ったらスワイプ、虚空(背景)を触ったらカメラ回転。当たり前といえば当たり前ですが、CSSの touch-action: none; や setPointerCapture、pointercancel をちゃんと設定しておかないと、指が画面外に出た瞬間に操作不能でフリーズする地獄を見るので、ここも泥臭くケアしています。
// game-renderer.js より抜粋:Raycaster判定とスワイプ方向の特定
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
// 1. タッチ開始:キューブ上か背景余白かを判定(ポインタキャプチャ設定)
dom.addEventListener('pointerdown', (e) => {
dom.setPointerCapture(e.pointerId);
const rect = dom.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, this.camera);
const hits = raycaster.intersectObjects(this.tileMeshes, true);
if (hits.length > 0) {
this.isSwiping = true;
this.swipeStart = { x: e.clientX, y: e.clientY };
} else {
this.isOrbiting = true;
this.orbitStart = { x: e.clientX, y: e.clientY };
}
});
// 2. タッチ終了:ドラッグ差分(dx, dy)から上下左右のスワイプを判定
dom.addEventListener('pointerup', (e) => {
if (this.isSwiping) {
const dx = e.clientX - this.swipeStart.x;
const dy = e.clientY - this.swipeStart.y;
const absX = Math.abs(dx);
const absY = Math.abs(dy);
// 誤操作防止の閾値(20px以上でスワイプ認識)
if (Math.max(absX, absY) > 20) {
const dirName = absX > absY ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
const match = this.getRelativeDirection(dirName);
if (match) this.gameCore.move(match.axis, match.positive);
}
this.isSwiping = false;
}
this.isOrbiting = false;
});
// キャンセル時の安全リセット
dom.addEventListener('pointercancel', () => {
this.isSwiping = false;
this.isOrbiting = false;
});
※なお、3次元の奥(in)と手前(out)は2次元画面のスワイプでは直感的に指定できないため、スワイプでは上下左右の4方向を発行し、奥・手前は画面UIのD-Padボタンから直接発行する設計としています。
3次元合体ロジック(move / slideAndMergeLine / applyMergedLine)
決定された操作軸(例: { axis: 'x', positive: true })は、ゲームコアの move(axis, positive) に渡されます。3×3×3空間は操作軸に垂直な9本のラインに分解され、正負方向に応じた抽出と1手1回合体ルールが適用されます。
// game-core.js より抜粋:3次元合体・抽出・書き戻しの全処理
move(axis, positive) {
let moved = false;
// 操作軸と垂直な2軸の平面(3×3 = 9本のライン)を走査
for (let u = 0; u < 3; u++) {
for (let v = 0; v < 3; v++) {
const line = this.extractLine(axis, u, v, positive);
const mergedLine = this.slideAndMergeLine(line);
if (this.applyMergedLine(axis, u, v, positive, mergedLine)) {
moved = true;
}
}
}
if (moved) this.spawnRandomTile(); // スライド・合体成功時のみ新規タイル出現
return moved;
}
// 指定軸・座標から長さ3の配列を抽出
// positive=true(正方向へ寄せる)の場合は、先端(idx 2)をline[0]として抽出し手前へ詰める
extractLine(axis, u, v, positive) {
const line = [];
for (let i = 0; i < 3; i++) {
const idx = positive ? (2 - i) : i;
if (axis === 'x') line.push(this.grid[idx][u][v]);
else if (axis === 'y') line.push(this.grid[u][idx][v]);
else if (axis === 'z') line.push(this.grid[u][v][idx]);
}
return line;
}
// 2048伝統の「0詰め ➔ 隣接同値合体 ➔ 再0詰め」処理(1手1回合体)
slideAndMergeLine(line) {
let filtered = line.filter(val => val !== 0);
for (let i = 0; i < filtered.length - 1; i++) {
if (filtered[i] === filtered[i + 1]) {
filtered[i] *= 2; // 合体
filtered[i + 1] = 0; // 合体済みスロットを消去(連鎖合体を防止)
i++; // 次の要素をスキップ
}
}
filtered = filtered.filter(val => val !== 0);
while (filtered.length < 3) filtered.push(0); // 空きマスを0で埋める
return filtered;
}
// 合体結果を3次元配列へ書き戻す(1マスでも変化があればtrueを返却)
applyMergedLine(axis, u, v, positive, mergedLine) {
let changed = false;
for (let i = 0; i < 3; i++) {
const idx = positive ? (2 - i) : i;
let oldVal;
if (axis === 'x') { oldVal = this.grid[idx][u][v]; this.grid[idx][u][v] = mergedLine[i]; }
else if (axis === 'y') { oldVal = this.grid[u][idx][v]; this.grid[u][idx][v] = mergedLine[i]; }
else if (axis === 'z') { oldVal = this.grid[u][v][idx]; this.grid[u][v][idx] = mergedLine[i]; }
if (oldVal !== mergedLine[i]) changed = true;
}
return changed;
}
3×3×3の立体空間を「軸に直交する9本の1次元ライン」へと分解して合体を走査することで、従来の2次元2048アルゴリズムを数学的に破綻なく3次元空間へ拡張しています。
04. 空間設計:4×4×4から3×3×3への縮小と中心マスの可視化
① 4×4×4(64マス)から3×3×3(27マス)への縮小判断
元の2048が4×4(16マス)だったので、安直に「じゃあ3Dなら4×4×4(64マス)だな」と試作したわけです。ところがシミュレーションを回してみたら、64マスはパズルとしてシンプルに広すぎました。
| 測定項目 | 設定条件・プロトコル |
|---|---|
| 初期配置・シード | 初期タイル2枚(空きマスに2または4を配置)。Xorshift32(seed: 42)による擬似乱数 |
| スポーン・終了判定 | 有効手(スライド・合体成功)時のみ1枚出現(2: 90%, 4: 10%)。全マス埋まり隣接合体不能で終了 |
| 入力アルゴリズム | 6方向(±X, ±Y, ±Z)から一様ランダム選択(空振り手は手数から除外、N=500回試行) |
| 4×4×4 (64マス) 結果 | 中央値 >1000手(1000手打ち切り生存率 100%, 空間が広すぎて合体が連鎖し終わらない) |
| 3×3×3 (27マス) 結果 | 平均 670.5手(中央値 630手, 標準偏差 σ=214.5, 最小304手〜最大1189手で全試行終了。到達タイル全区分: 64: 0.2%, 128: 1.0%, 256: 15.0%, 512: 45.4%, 1024: 36.8%, 2048: 1.6%) |
27マス空間では初期2枚から最低でも25手(一切合体しない場合)必要ですが、ランダム入力でも平均670.5手(中央値630手)で確実に手詰まりを迎えます。一方、64マス空間は適当にガチャガチャ動かすだけでも空間が広すぎて合体が連鎖し続け、1,000手動かしても生存率100%(中央値>1,000手)で終わりません。「これ、普通に終わらんやん…」となったので、サクッと遊ぶゲームとして即座に却下しました。
【考察:なぜ乱数ボットより人間手動プレイの方が手数が少なくなるのか?】
一見すると「人間より完全ランダムのボットの方が粘れるってどういうこと?」というパラドックスに見えるわけですが、これには3次元パズル特有の幾何学構造と、通学電車でのリアルなプレイスタイルが絡んでいます。
- ① 2D定石(角固定)の崩壊: 2D版2048の鉄則である「角に最大数を隔離し、2方向のみで動かす」戦術を3D(座標 0,0,0)で再現しようとすると、避けるべき直交方向が2方向から「右・上・奥」の3方向に増えるわけです。斜め45°等から立体を眺めている状況では、1手でも視点相対の判断を誤って角からタイルを盤面中央へ飛び出させた瞬間に、高数値が孤立して連鎖が即座に崩壊します。
- ② 中心マス不可視による局所最適(内部目詰まりの罠): 人間プレイヤーは無意識に「画面手前に見えているタイル」同士を合体させようと局所的に操作しがちです。その結果、外から見えない中心マスや背面に「2」や「4」の小タイルが取り残され、内部から目詰まりを引き起こして自滅します。一方、乱数ボットは全6方向を一様に動かすため、内部の空間を絶えずシャッフルして空きマスを延命させ、結果として偶然の合体寿命が大幅に伸びるわけです。
- ③ 通学電車を前提とした直感操作テンポ: 作者自身による実機テストプレイ(PC / スマホ各10回実測)は、「通学電車の3〜5分の隙間時間」を想定し、1手約1秒の直感的な高速スワイプで進めました。数手先をじっくり読む競技プレイとは違って、直感操作では平均120〜180手で手詰まりとなり、到達タイルは「128」〜「256」(スコア約1,500〜3,000点)へと収束しました。
1手あたりの盤面加算期待値は 2×0.9 + 4×0.1 = 2.2 です。数学的に盤面合計が2048を超えるには最低約930手(2048 ÷ 2.2)を要します。3次元空間(3×3×3)は自由度が高い反面、角に高数値を隔離し続ける難度が高いため、本作では「第一クリア目標: 512タイル(約250〜350手)」、そして熟練者向けの「究極の勝利条件: 2048タイル(約900手超)」と位置付けたスコアアタック設計としました。隙間時間の3〜5分で128や256を目指してテンポよく周回できるバランスとして、3×3×3を標準ルールに決定したわけです。
② 外から見えない中心マス (gx=1, gy=1, gz=1) の対策
3×3×3空間では、中心の1マス(1, 1, 1)が外周26マスに完全に囲まれて外から一切見えません。不透明なキューブのままだと、中心にどの数字が埋まっているか分からないまま手探りでプレイすることになります。見えないところで手詰まりになるのはシンプルに理不尽なので、以下の2つの可視化機能を実装しました。
この問題に対して以下の2つの可視化機能を用意しています。
- 🦴 X線モード(半透明化): 外周のタイル(
isOuterTile === true)のマテリアルを半透明化し、中心タイルのエミッシブ発光(Emissive)を強調して内部の数字を透視可能にしました。 - 階層スライサー(1F〜3F): 盤面をフロア単位でスライスし、対象の段以外を非表示にしてネオングリッドガイドを表示。中央段(2階)のみを平面感覚で確認できるようにしました。
05. Service Workerによるオフラインキャッシュと次回予告
2026.10.09追記:以下のコードと転送量は公開当時の記録です。現行版はHTML・JS・CSSをオンライン時にネットワーク優先で取得し、APIをキャッシュから除外しています。オフラインでは保存済みの資産を使い、未保存のページは案内を表示します。現在の通信量を0 bytesと保証するものではありません。
そもそもの動機が「通学電車の電波が途切れる区間でもギガを気にせず遊びたい」だったので、Service Worker(PWA)によるCache-first配信は最初から必須要件でした。
なお、3D描画ライブラリとしてThree.js r128を採用しています。近年のThree.js(r150以降等)では、非モジュールの単一グローバルスクリプト(three.min.js)の公式配布が廃止され、バンドラを介したESモジュール構築が前提となっています。しかし本作は「ビルドツール不要のピュアなWeb標準技術の身軽さ」を重視し、単一CDNファイルをService Workerで丸ごと事前キャッシュ(Cache-first、gzip約140KB)して完全オフライン動作を保証する設計としたため、安定した最終世代の単一配布版であるr128を採用しました。
// 公開当時のsw.jsの抜粋(現行版とは異なります)
const CACHE_NAME = '2048-3d-cache-v1.3.0';
const PRECACHE_ASSETS = [
'/',
'/game',
'/css/style.css',
'/js/game-core.js',
'/js/game-renderer.js',
'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'
];
// インストール時に全リソースをローカルキャッシュへ事前格納
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_ASSETS))
);
self.skipWaiting();
});
// バージョン更新時に古いキャッシュを一括パージ
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
))
);
self.clients.claim();
});
// Cache-first(キャッシュ優先・オフライン対応)フェッチ
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});
Chrome DevToolsのApplicationタブで「Clear site data」を実行後(Disable cache有効、実測5回平均)および実機での検証値は以下の通りです。
- 初回ロード転送量(Transfer Size): 約450KB(Brotli/gzip圧縮適用済、Three.js minライブラリ含む。未圧縮時のResource Sizeは約1.25MB)
- 2回目以降の通信量: キャッシュ対象のゲーム本体リソースについて、ネットワーク通信なし(0 bytes / ServiceWorker経由)で即時起動
- PCでの検証手順: Chrome DevToolsのNetworkタブで「Offline」に設定し、ページ再読み込み後もゲームが正常に初期化・プレイ可能であることを確認。
- モバイル実機での検証手順: 初回ロード完了後にブラウザを再起動し、端末設定で「機内モード」をONにして通信を完全遮断。その状態でゲーム本編の起動・リセット・タイル合体プレイが完全動作することを確認(Windows 11 PC / Chrome 130、iOS 18.0 Safari、Android 15 / Chrome 130、Three.js r128環境にて実証)。
※なお、当ブログ記事ページ自体はGoogle Analytics(GA4)や広告スクリプトの外部通信を含むため、オフラインキャッシュの対象外(ネットワーク優先)としています。
次回の連載第2回では、今回解説したような数学的検証やデバッグ、機能実装をいかにAIと進めたのか、その裏側を公開します。具体的には、Antigravityを活用して「企画・開発・品質管理・宣伝・法務・監査」という仮想会社組織をプロンプト上で立ち上げ、複数のAIエージェントに役割を分担させてプロジェクトを推進した運用のリアルをお届けする予定です。更新は公式X(@game2048_3d)にて告知します。
🎮 『2048 3D』をブラウザで今すぐプレイ
本記事で解説した「視点相対スワイプ」「X線モード」「階層スライサー」の実物を、PC・スマホのブラウザからインストール不要で体験できます。
ブラウザでプレイを始める ➔