はじめに
文字を入力すると、その文字をドミノ倒しの演出として表示できるWebアプリ「ドミノ倒しメーカー」を個人開発で構築しました。
サーバー側でコースを生成するのではなく、ブラウザ上で次の処理を完結させています。
- URLパラメータからメッセージとコースパターンを復元する
- メッセージをCanvas画像へ変換する
- コース定義からドミノ・球・橋などの物理剛体を配置する
- Cannon.jsで衝突と倒壊を計算する
- Three.jsで物理オブジェクトを3D表示する
- 倒壊地点へカメラを追従させ、最後に全体を見せる
- 生成結果を圧縮したURLとして共有する
ドミノ倒しメーカー: https://domino.work/
1. 技術スタックと全体構成
1.1 採用している技術
| 技術 | 役割 |
|---|---|
| HTML/CSS | 入力画面、ダイアログ、Canvas、WebGL表示領域の土台 |
| JavaScript ES Modules | アプリケーションの初期化、コース展開、描画制御 |
| Three.js | 3Dシーン、カメラ、ライト、メッシュ、WebGLレンダリング |
| Cannon.js | 重力、剛体、衝突、ドミノの倒壊 |
| jQuery | 一部のフォーム制御と表示アニメーション |
| RawDeflate | メッセージのURLパラメータ化 |
| ProgressBar.js | 初期化中の進捗表示 |
ビルドツールやフレームワークは使っていません。Three.js、Cannon.js、jQueryなどの依存ファイルをリポジトリ内に置き、index.htmlから直接読み込む構成です。そのため、静的ファイルを配信できる環境に載せやすく、アプリの動作確認にも特別なビルド手順を要求しません。
1.2 ディレクトリ構成
主要ファイルだけを抜き出すと、次のようになります。
domino.work/
├── index.html # UI、初期化、物理配置、描画、共有処理の中心
├── policies.html # プライバシーポリシー
├── sitemap.xml # 多言語URLを含むサイトマップ
├── css/
│ └── domino.css # 入力パネル、ボタン、モーダルなどのスタイル
├── images/ # favicon、スクリーンショットなど
└── js/
├── cannon_0.6.2.js # 物理エンジン
├── domino_const.js # 共通定数
├── domino_i18n.js # 日本語・英語などの翻訳定義
├── domino_parts.js # 橋やグループなどの定義ヘルパー
├── rawdeflate.js # URL共有用の圧縮処理
├── jquery-3.6.3.js
├── progressbar.min.js
├── three.js/ # Three.js本体とアドオン
└── domino_object/
├── domino_object_1.js # コースパターン1
├── domino_object_2.js # コースパターン2
└── domino_object_3.js # コースパターン3
index.htmlは大きなファイルですが、すべての処理を無秩序に持っているわけではありません。共通定数、共通部品、コース固有の定義を外部ファイルへ分け、実行時の制御を中心に担当しています。
1.3 アプリケーションの処理フロー
入力がない初回表示では、入力パネルを表示します。入力後はメッセージを圧縮してURLへ埋め込み、ページを再読み込みします。この設計により、バックエンドにメッセージ保存用APIやデータベースを用意しなくても、URLを知っている人が同じ演出を再生できます。
2. コアロジック・設計のこだわり
2.1 コースをデータとして記述する
コースパターンは、個々のドミノを直接生成するコードではなく、次のようなオブジェクト定義の配列として記述されています。
const OBJECT_LINE_AFTER_START_BALL = [
{ shape: 'box', course: 'straightUp', count: 75, option: { objectSpace: 0.6 } },
{ shape: 'box', course: 'turnUpLeft', count: 10,
option: { turnAngle: 90, turnRadius: 2 } },
{ shape: 'box', course: 'straightLeft', count: 2 },
];
shapeは箱か球か、courseは直線・カーブ・分岐などの形状、countは生成数、optionは間隔や色、サイズ、初速などを表します。
配置グループにはobjectIdとconnectionObjectIdを持たせます。あるグループの終端座標を保存しておき、次のグループがそのIDを参照することで、コースを部品の接続として構築できます。
const OBJECT_GROUP_LIST = createObjectGroupList(
OBJECT_GROUP_BASE,
OBJECT_GROUP_START_BALL,
OBJECT_GROUP_FIRST_BRANCH,
OBJECT_GROUP_CARPET,
);
この方式の利点は、コースを増やすときに物理生成ロジックを変更しなくてよいことです。domino_object_4.jsのようなパターン定義を追加し、PATTERN_COUNTを更新すれば、既存の配置エンジンを再利用できます。
2.2 配置エンジンは「終端座標」を受け渡す
実際の座標計算はindex.htmlのsetPhyObjectGroupが担当します。最初のグループはstartPositionX/Y/Zから始まり、それ以降は接続元グループの末端座標から配置を始めます。
直線は一定間隔で座標を進め、カーブは半径と角度から三角関数で座標と回転を計算します。例えば、turnUpLeftのような形状では、各ドミノの位置を円弧上に配置し、接線方向に合わせてY軸回転を設定します。
分岐は、幹と左右の枝を生成した後、それぞれの末端をbranchNo付きで保存します。次のグループはconnectionObjectIdとbranchNoを指定することで、意図した枝へ接続できます。
これは、全オブジェクトの絶対座標を手作業で管理するよりも保守しやすい設計です。一方で、座標系の向きやカーブ半径を間違えると、見た目だけでなく物理的な接触も壊れます。新しいパターンを作る場合は、まず直線と分岐だけで接続を確認し、その後に球や大型パネルを追加するのが安全です。
2.3 橋やカーペットをヘルパーで再利用する
js/domino_parts.jsには、複数のコースで使う部品があります。createBridgeは、階段、橋脚、天板、降り階段、その上のドミノ列をまとめて生成します。橋の土台には大きな質量を設定し、ドミノが衝突しても動きにくくしています。
また、20行のカーペットはcreateCarpetRowsで下側10行と上側10行を異なるハブへ接続します。パターンファイルでは色配列や画像分割フラグだけを指定し、行の接続規則はヘルパーに寄せています。
このように、コース定義ファイルを「何をどの順番で置くか」に集中させ、座標計算や繰り返し処理は共通ヘルパーへ寄せています。
2.4 物理演算と描画を分離する
物理オブジェクトと表示用メッシュは別々に管理されます。
- Cannon.jsの
Body: 質量、形状、位置、回転、速度、衝突イベントを持つ - Three.jsの
Mesh: ジオメトリ、マテリアル、テクスチャ、影を持つ -
phyObjectListとviewMeshList: 同じインデックスで両者を対応付ける
アニメーションループでは、まず物理ワールドを進め、その後に剛体の位置と回転をメッシュへコピーします。
function animate() {
animationId = requestAnimationFrame(animate);
world.step(1 / 20);
for (let i = 0; i < objectCountTotal; i++) {
const phyObject = phyObjectList[i];
const viewMesh = viewMeshList[i];
viewMesh.position.copy(phyObject.position);
viewMesh.quaternion.copy(phyObject.quaternion);
}
}
物理計算と描画を同じループに置きつつも、責務はデータ構造で分離されています。そのため、表示をThree.jsの別表現へ置き換えたり、物理パラメータだけを調整したりできます。
2.5 先読みと遅延投入で長いコースを動かす
長いコースの全剛体を最初からワールドへ追加すると、初期衝突判定の負荷が高くなります。そこで、生成した剛体はphyObjectListに保持し、最初はOBJECT_READ_COUNT_INITIALの1,200体だけをワールドへ追加します。
その後、衝突回数がOBJECT_READ_COUNT_ADDPOINTの600に達すると、後半のオブジェクトを追加します。見た目のコース全体は先に作っておき、物理計算の対象だけを段階的に増やす考え方です。
倒れたドミノは衝突から一定時間後にワールドから除外されます。ただし、Three.jsのメッシュは残るため、画面上では倒れた状態を維持できます。物理計算の負荷を抑えながら、演出としての履歴を残す工夫です。
2.6 Canvas画像をドミノへ貼る
メッセージは、まずCanvasへ描画してBase64画像に変換されます。現在の定数では最大10文字を扱い、char2モードでは2文字ずつを1枚の画像にします。
パターン1と2では大型パネルへ画像を貼り、カーペットでは画像を20行20列へ分割して、各ドミノへ1マスずつ貼ります。Three.jsのメッシュ生成時にimageFlgとimageSplitFlgを見てテクスチャを選択します。
const texture = imageFlg
? textureLoader.load(imageList[objectImageIndex].data)
: imageSplitFlg
? textureLoader.load(imageSplitList[objectImageIndex][row][column])
: null;
メッセージを画像として扱うため、文字フォントの違いをDOM上で調整する必要がありません。絵文字や記号にも対応しやすい一方、Canvasのフォントに存在しない文字は環境依存の表示になる点には注意が必要です。
2.7 カメラは衝突地点を平均して追従する
カメラは通常のOrbitControlsだけでなく、ドミノの倒壊地点を自動追従するドローンモードを持っています。衝突イベントが発生するたびにカメラを直接移動すると、同じフレームに複数の衝突が発生したときに視点が細かく揺れます。
そこで、同一フレーム中の衝突地点を一度加算し、平均位置を求めてからVector3.lerpで追従します。球は高速に移動するため、パターン設定によって優先追従対象にもできます。
最後のトリガーオブジェクトが一定以上の衝撃で衝突すると、コース全体のバウンディングボックスから中心と半径を求め、カメラを引いたフィナーレ画角へ切り替えます。約4.5秒後にアニメーションを停止し、手動カメラ操作と共有ダイアログを有効にします。
3. 開発でハマったポイントと解決策
3.1 分岐の接続位置をどう管理するか
分岐を絶対座標で書くと、前段のコースを少し変更するだけで後続の座標をすべて修正することになります。実装では各グループの終端を接続情報として保存し、connectionObjectIdとbranchNoで参照する方式にしています。
ただし、IDの重複や接続順の変更は発見しにくい不具合につながります。コース定義ではOBJECT_IDに配置IDを集約し、OBJECT_*の命名と対応させる方針が取られています。
3.2 大型パネルや球がコースを壊す
大型パネルは倒れると高さ・奥行きの分だけ広い範囲を掃きます。隣のラインとの距離が小さいと、意図しない衝突で別の列が先に倒れたり、最後の衝突判定が早く発火したりします。
球も転がり続けるため、進行方向の先に構造物を置くと、球が停止するまで別の物体を巻き込みます。球の前後には十分な空間を確保し、受け側には適切な間隔のドミノ列を置く必要があります。
パターン3では、最後の表示が小さな球ではなく、明示的にfinaleTriggerFlgを付けた最終ドミノになるようにしています。演出トリガーを「リスト末尾」に暗黙依存させないことが重要です。
3.3 スマートフォンの画角とCanvasのサイズ
カメラのアスペクト比は固定値ではなく、window.innerWidth / window.innerHeightから設定されます。resizeViewではウィンドウサイズ変更時にカメラの投影行列とレンダラーサイズを更新します。
縦長画面では同じ距離のままだと大型文字やコースの横幅が切れます。そのため、パターンごとのcameraPortraitScaleMaxを使ってドローンカメラの距離と高さを引き、必要に応じて大型パネルや球を追従対象にしています。
3.4 URL共有は便利だが、暗号化ではない
作成ボタンを押すと、メッセージをURLエンコードしてRawDeflateで圧縮し、Base64化した値をvalパラメータへ入れます。
location.href = "/?val=" + deflate(message)
+ "&ptn=" + ptn + langParam;
この方式には、サーバー保存が不要、共有URLだけで状態を再現できる、という利点があります。一方、DeflateとBase64は難読化や圧縮であって暗号化ではありません。URLに入れる文字列を秘密情報として扱ってはいけません。また、URL長にも上限があるため、現在のようにメッセージ長を10文字に制限する設計と相性がよいです。
3.5 物理ステップを固定値にしている
animateでは毎フレームworld.step(1 / 20)を呼び出しています。描画フレームレートとは別に物理の刻みを固定するため、端末の性能差による挙動の揺れをある程度抑えられます。
ただし、実時間の経過を厳密に追う方式ではありません。負荷が高い端末では描画と物理の見え方が変わる可能性があり、より一般的なゲームループへ発展させるなら、実経過時間を使った固定タイムステップの蓄積方式や、物理演算回数の上限を検討できます。
4. 改善ポイントと今後の課題
現状の構成は小規模な静的アプリとして分かりやすい一方、長期保守では次の改善余地があります。
-
index.htmlに集約されたアプリケーション処理を、URL状態、コース展開、物理、描画、共有へ分割する - グローバル変数と暗黙の配列インデックスによる物理メッシュ対応を、オブジェクト単位の管理へ置き換える
-
courseの文字列を定数またはTypeScriptの型で管理し、未知の形状を早期検出する - コース定義をJSONなどのデータ形式へ寄せ、配置エンジンをテストしやすくする
- 分岐の接続先、重なり、最終トリガーを検証するオフラインチェックを追加する
- 物理オブジェクトの遅延投入や削除を、衝突イベントだけに頼らずライフサイクルとして整理する
- Three.jsのジオメトリ・マテリアルキャッシュを共有モジュールへ切り出す
- Canvasのフォントフォールバックと、未対応文字の表示を確認する
- 依存ライブラリのバージョン、ライセンス、更新手順をドキュメント化する
-
valの内容は公開情報であることを明示し、URL共有時のプライバシー期待値を下げる
特にテストについては、ブラウザを起動しない単体テストが難しい部分と、Playwrightなどで実画面を確認する部分を分けるとよいでしょう。座標計算だけなら、入力したコース定義に対して終端座標が存在すること、オブジェクト同士が過度に重ならないことをNode.jsなどで検査できます。カメラや衝突演出は、実ブラウザでスクリーンショットと完走時間を確認するのが現実的です。
5. まとめ
このアプリの中心的な設計は、コースを「形状とオプションの配列」として定義し、共通の配置エンジンでCannon.jsの剛体とThree.jsのメッシュへ展開することです。
その上に、次の工夫が積み重ねられています。
- 終端座標と分岐番号によるコース接続
-
createBridgeやcreateCarpetRowsによる部品の再利用 - Canvas画像を使った文字テクスチャ
- 物理オブジェクトの遅延投入と倒壊後の除外
- 衝突地点の平均化と補間によるカメラ追従
- コース全体のバウンディング計算によるフィナーレ演出
- Deflate圧縮URLによるサーバーレスな共有
- URL、localStorage、ブラウザ言語を組み合わせた多言語対応
Three.jsの見た目だけを作るのではなく、物理計算、データ駆動のコース設計、カメラ演出、共有までを一つのブラウザアプリにまとめている点が、この実装の面白さです。新しいコースを追加するときも、既存の物理・描画コードを再利用して、定義ファイルの組み合わせとして試行錯誤できます。
ドミノ倒しメーカー: https://domino.work/
