ジャンルから探す

Web Design English

ブラウザで遊べる3Dチンチロ、Three.js&Ammo.jsで作ったサイコロゲーム

ブラウザで動く3Dのチンチロを、Three.js(描画)とAmmo.js(物理演算)で作りました。実物はチンチロゲームで遊べます。

この構成で一番の山場は「サイコロの出目をどう読むか」です。 物理エンジンは転がしてくれますが、止まったときにどの面が上を向いているかは教えてくれません。ここは自分で計算する必要があります。

この記事では、実際に動いているコードをそのまま引用しながら次を解説します。

  • Three.js でお椀とサイコロを組む方法
  • Ammo.js で転がす物理ワールドの作り方
  • 面の法線とクォータニオンから出目を判定する処理(ここが本題)
  • チンチロの役判定と「ションベン」の扱い
  • スマホで破綻させないための調整

この記事は2025年の公開後、2026年9月に全面的に書き直しました。 公開当時は役判定のコードとして5個振りのポーカー役(フルハウス・ツーペアなど)を載せていましたが、チンチロはサイコロ3個なので、あのコードでは成立しません。実装済みのコードに差し替えたうえで、抜けていた出目判定の解説を追加しています。

スポンサーリンク

全体の構成

描画と物理を別ライブラリで持ち、毎フレーム座標を同期させる構成です。

担当 ライブラリ やること
見た目 Three.js お椀・サイコロ・目のドット・ライティング
動き Ammo.js(Bullet の WebAssembly 版) 重力・衝突・慣性
出目と役 自前のJavaScript 上を向いた面の判定、チンチロの役判定
演出 CSSアニメーション 役名バナー、画面シェイク、コンフェッティ

Three.js と Ammo.js は互いを知りません。 毎フレーム、Ammo側の座標と回転を読み出して Three.js のメッシュに書き写す——これが接続部分のすべてです。

なお three.min.js という名前のファイルはThree.js r161 で配布物から削除されました。古い記事のCDN URLをそのままコピーすると404になるので、バージョンを固定して取得するか、手元に置いたファイルを読み込んでください。

Ammo.jsのロード完了を待つ

Ammo.js は WebAssembly なので、スクリプトタグを書いただけでは使えません。 ロードが終わる前に new Ammo.btVector3() を呼ぶと落ちます。

if (typeof Ammo !== 'undefined') {
  const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
  const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
  const broadphase = new Ammo.btDbvtBroadphase();
  const solver = new Ammo.btSequentialImpulseConstraintSolver();
  physicsWorld = new Ammo.btDiscreteDynamicsWorld(
    dispatcher, broadphase, solver, collisionConfiguration
  );
  physicsWorld.setGravity(new Ammo.btVector3(0, -9.82, 0));
  ammoTmpTransform = new Ammo.btTransform();

  setupPhysics(visualGeometry);
  requestAnimationFrame(animate);
} else {
  console.error('Ammo.js is not loaded');
}

ammoTmpTransform を最初に1個だけ作って使い回しているのがポイントです。 Ammo.js のオブジェクトは WebAssembly のヒープ上に確保されるため、毎フレーム new すると解放されずに積み上がります。

スポンサーリンク

お椀を「動かない三角形メッシュ」として登録する

サイコロを受けるお椀は、Three.js の半球ジオメトリをそのまま Ammo.js に渡しています。

function setupPhysics(geometry) {
  const vertices = geometry.attributes.position.array;
  const indices = geometry.index.array;

  const ammoMesh = new Ammo.btTriangleMesh();
  for (let i = 0; i < indices.length; i += 3) {
    const ai = indices[i] * 3;
    const bi = indices[i + 1] * 3;
    const ci = indices[i + 2] * 3;
    ammoMesh.addTriangle(
      new Ammo.btVector3(vertices[ai], vertices[ai+1], vertices[ai+2]),
      new Ammo.btVector3(vertices[bi], vertices[bi+1], vertices[bi+2]),
      new Ammo.btVector3(vertices[ci], vertices[ci+1], vertices[ci+2]),
      true
    );
  }

  const bowlShape = new Ammo.btBvhTriangleMeshShape(ammoMesh, true, true);
  const bowlBodyInfo = new Ammo.btRigidBodyConstructionInfo(
    0, bowlMotionState, bowlShape, new Ammo.btVector3(0, 0, 0)
  );
  const bowlBody = new Ammo.btRigidBody(bowlBodyInfo);
  bowlBody.setRestitution(1.5);
  physicsWorld.addRigidBody(bowlBody);
}
ポイント 理由
質量 0 で登録する 質量0は静的オブジェクトを意味する。ぶつかっても動かない
btBvhTriangleMeshShape 曲面をそのまま衝突形状にできる。静的専用
setRestitution(1.5) 物理的にはあり得ない値。跳ね方を派手にするための意図的な調整

反発係数1.5は「衝突のたびにエネルギーが増える」設定です。 現実にはあり得ませんが、お椀の中でサイコロが小さく跳ね回る絵になるので、ゲームとしてはこちらが気持ちよく感じました。リアルさより手触りを取った箇所です。

お椀の底が抜けないよう、半球の下に薄い床(btBoxShape)も置いています。三角形メッシュは薄いので、速度が出るとすり抜けることがあるためです。

サイコロを作る

サイコロは立方体と、面ごとに配置した円形のドットで作っています。

let size = 0.25;
if (isMobileNow()) size = 0.52;

const geometry = new THREE.BoxGeometry(size, size, size);
const material = new THREE.MeshStandardMaterial({
  color: 0xFFFFFF, roughness: 0.3, metalness: 0.1
});

const dotRadius = size * 0.08;
const dotOffset = size * 0.22;

const faceNormals = [
  new THREE.Vector3(0, 0, 1),
  new THREE.Vector3(0, 0, -1),
  new THREE.Vector3(0, 1, 0),
  new THREE.Vector3(0, -1, 0),
  new THREE.Vector3(1, 0, 0),
  new THREE.Vector3(-1, 0, 0)
];

faceNormals の並び順が、あとの出目判定でそのまま効いてきます。 ここを適当に決めると、判定側で必ず破綻します。

ドットは面の法線方向にわずかに浮かせて貼り付けます。

const dotGeometry = new THREE.CircleGeometry(dotRadius, 32);
const dotMesh = new THREE.Mesh(dotGeometry, mat);
dotMesh.position.copy(normal.clone().multiplyScalar(size/2 + 0.001));
mesh.add(dotMesh);

+ 0.001 はZファイティング(同一平面のちらつき)対策です。 ぴったり面上に置くと、描画順によって点が消えたり明滅したりします。

1の目だけ赤(0xff0000)にしています。サイコロらしさは、この1点でかなり変わりました。

剛体側の設定

const diceShape = new Ammo.btBoxShape(new Ammo.btVector3(size/2, size/2, size/2));
const diceMass = 0.5;
const diceLocalInertia = new Ammo.btVector3(0, 0, 0);
diceShape.calculateLocalInertia(diceMass, diceLocalInertia);

const diceBody = new Ammo.btRigidBody(
  new Ammo.btRigidBodyConstructionInfo(
    diceMass, diceMotionState, diceShape, diceLocalInertia
  )
);
diceBody.setFriction(0.15);
diceBody.setRestitution(0.3);
physicsWorld.addRigidBody(diceBody);

btBoxShape は「半分の長さ」で指定します。 BoxGeometry(size, size, size) に対して btVector3(size/2, size/2, size/2) になるのはそのためです。ここを size のまま渡すと、当たり判定だけ2倍の大きさになって「見えない壁」ができます。

ちなみにこのアプリでは、見た目のメッシュだけ mesh.scale.set(1.1, 1.1, 1.1) で1割大きくしています。 物理形状は等倍のままなので、厳密には表示と当たり判定がずれています。見た目の詰まり具合を優先した結果ですが、本来は揃えるべき箇所です。

スポンサーリンク

投げる

投げる処理は「前回のサイコロを消す→作り直す→初速と角速度を与える」の順です。

function throwDice() {
  playRandomDiceSound();

  // 前回のサイコロを片付ける
  for (let i = 0; i < diceList.length; i++) {
    scene.remove(diceList[i].mesh);
    physicsWorld.removeRigidBody(diceAmmoList[i]);
  }
  diceList = [];
  diceAmmoList = [];

  createDice();
  diceCount++;

  for (let i = 0; i < diceList.length; i++) {
    let throwSpeed = 2.5 + Math.random() * 1.5;
    let yVel = -4.0;
    let angVel = 10;

    if (isMobileNow()) {
      throwSpeed = 8.0 + Math.random() * 3.0;
      angVel = 34;
    }

    const body = diceAmmoList[i];
    body.setLinearVelocity(new Ammo.btVector3(0, 0, 0));
    body.setAngularVelocity(new Ammo.btVector3(0, 0, 0));
    body.activate();

    const angle = Math.random() * Math.PI * 2;
    body.setLinearVelocity(new Ammo.btVector3(
      Math.cos(angle) * throwSpeed, yVel, Math.sin(angle) * throwSpeed
    ));
    body.setAngularVelocity(new Ammo.btVector3(
      (Math.random()-0.5)*angVel,
      (Math.random()-0.5)*angVel,
      (Math.random()-0.5)*angVel
    ));
  }
}

body.activate() は必須です。 Bullet は動かない剛体を自動的にスリープさせるので、これを呼ばないと速度を設定しても動き出しません。「2回目以降サイコロが動かない」という症状は、だいたいこれが原因です。

投げる向きは Math.random() * Math.PI * 2 の角度から作っています。 XとZに別々の乱数を入れると方向が偏るので、角度で回してから cos / sin に分ける方が均一になります。

本題:出目をどう判定するか

物理エンジンは「どの面が上か」を教えてくれません。 剛体から得られるのは位置と回転(クォータニオン)だけです。

そこで、6つの面の法線を現在の回転で変換し、ワールドの上方向 (0, 1, 0) との内積が最大になる面を探します。内積が最大=もっとも真上を向いている面です。

function getDiceTopValue(mesh) {
  const up = new THREE.Vector3(0, 1, 0);
  const localUps = [
    new THREE.Vector3(0, 0, 1),
    new THREE.Vector3(0, 0, -1),
    new THREE.Vector3(0, 1, 0),
    new THREE.Vector3(0, -1, 0),
    new THREE.Vector3(1, 0, 0),
    new THREE.Vector3(-1, 0, 0)
  ];

  let maxDot = -Infinity;
  let topIdx = 0;
  for (let i = 0; i < 6; i++) {
    const v = localUps[i].clone().applyQuaternion(mesh.quaternion);
    const dot = v.dot(up);
    if (dot > maxDot) {
      maxDot = dot;
      topIdx = i;
    }
  }

  const values = [1, 6, 2, 5, 3, 4];
  return values[topIdx];
}
やっていること
applyQuaternion(mesh.quaternion) ローカル法線を現在の姿勢に回す
v.dot(up) 真上との一致度。1に近いほど上を向いている
values[topIdx] 面の並び順を目の数に変換する

values = [1, 6, 2, 5, 3, 4] の並びは faceNormals の順番と対応しています。 向かい合う面の和が7になるサイコロの慣習に合わせた結果で、ドットを貼った順序を変えたらここも直す必要があります。ここが噛み合っていないと「見た目は6なのに5と判定される」という、原因の分かりにくい不具合になります。

止まったかどうかの判定

出目を読むのは、3つとも十分に止まってからです。

const lv = diceAmmoObj.getLinearVelocity();
const av = diceAmmoObj.getAngularVelocity();
if (lv.length() > 0.02 || av.length() > 0.02) {
  allStopped = false;
}

速度がぴったり0になることは期待できないので、しきい値で判定します。 0.02 は小さすぎると永遠に止まらず、大きすぎると転がっている途中で判定してしまう値です。実際に触りながら決めました。

チンチロの役判定

チンチロはサイコロ3個です。 5個振りのポーカー役(フルハウス、ツーペアなど)は成立しません。実装している役はこれだけです。

出目 扱い
ピンゾロ 1・1・1 最強。専用の伝説級演出
アラシ(ゾロ目) 同じ目が3つ 「4アラシ」のように表示
シゴロ 4・5・6 役あり
目(2つ同じ+1つ) 例:3・3・5 → 5 余った1つが出目になる
ヒフミ 1・2・3 役としては最低
役なし 上記以外 目なし
function showYaku(values) {
  values.sort();

  if (values[0] === 1 && values[1] === 1 && values[2] === 1) {
    playYakuBanner('ピンゾロ');
    return;
  }

  let yaku = '';
  if (values[0] === values[1] && values[1] === values[2]) {
    yaku = `${values[0]}アラシ`;
  } else if (values[0] === values[1] || values[1] === values[2] || values[0] === values[2]) {
    // 2つ揃い:余った1つが目になる
    let diff;
    if (values[0] === values[1]) diff = values[2];
    else if (values[1] === values[2]) diff = values[0];
    else diff = values[1];
    yaku = `${diff}`;
  } else if (values[0] === 4 && values[1] === 5 && values[2] === 6) {
    yaku = 'シゴロ';
  } else if (values[0] === 1 && values[1] === 2 && values[2] === 3) {
    yaku = 'ヒフミ';
  } else {
    yaku = '役なし';
  }

  playYakuBanner(yaku);
}

判定の順番が重要です。 ピンゾロ → アラシ → 2つ揃い → シゴロ → ヒフミ の順で、上から確定させています。順番を入れ替えると、1・1・1 が「1アラシ」として処理されてしまいます。

values.sort() を引数なしで呼んでいる点だけ注意してください。 JavaScript の sort() は既定で文字列比較なので、本来は sort((a, b) => a - b) と書くべきです。ここでは値が1〜6の1桁に限られるため、たまたま結果が一致しています。 目の数が2桁になる仕様に広げるなら、真っ先に直す箇所です。

「ションベン」の判定

お椀の外にサイコロが飛び出したときの処理も要ります。 チンチロでは器から出したら失敗(ションベン)です。

function isInsideBowl(pos) {
  const r = Math.sqrt(pos.x * pos.x + pos.z * pos.z);
  return (r <= (bowlRadius * 0.98)) && (pos.y <= 0.1);
}

水平方向の距離が半径の98%以内で、かつ高さが一定以下なら「中」と判定しています。これを毎フレーム3個分数えます。

const insideCount = insideStates.filter(Boolean).length;

if (insideCount >= 1 && insideCount <= 2) {
  // 1〜2個だけ中 = 残りが外に出た
  playYakuBanner('ションベン');
} else if (allStopped && insideCount === 3) {
  showYaku(diceValues);
}

ションベンは「止まるのを待たずに」即座に出しています。 外に出た時点で結果は確定しているので、待たせても意味がないからです。一方、役の判定は3つとも止まってからでなければ出目が確定しません。 この非対称性が、実装上いちばん悩んだところでした。

毎フレームの同期

Ammo.js の座標を Three.js に書き写すのが animate() です。

function animate() {
  requestAnimationFrame(animate);

  const now = performance.now();
  const deltaTime = Math.min((now - lastTime) / 1000, 0.1);
  lastTime = now;

  physicsWorld.stepSimulation(deltaTime, 10);

  for (let i = 0; i < diceList.length; i++) {
    const ms = diceAmmoList[i].getMotionState();
    ms.getWorldTransform(ammoTmpTransform);
    const p = ammoTmpTransform.getOrigin();
    const q = ammoTmpTransform.getRotation();

    diceList[i].mesh.position.set(p.x(), p.y(), p.z());
    diceList[i].mesh.quaternion.set(q.x(), q.y(), q.z(), q.w());
  }

  renderer.render(scene, camera);
}

Math.min(deltaTime, 0.1) のクランプが効きます。 タブを裏に回してから戻すと deltaTime が数秒になり、その分をまとめてシミュレートするとサイコロが壁をすり抜けます。上限を切っておけば、遅れた分は捨てられて破綻しません。

stepSimulation の第2引数10は、1フレームで最大10回まで内部ステップを回すという指定です。フレームレートが落ちても物理の刻みを保てます。

スマホで破綻させないための調整

PCと同じ値ではスマホでまともに遊べませんでした。 画面が縦長で狭いため、同じ大きさのサイコロが豆粒になります。

項目 PC スマホ
サイコロのサイズ 0.25 0.52
お椀の半径 2 2.6
初速 2.5〜4.0 8.0〜11.0
角速度 10 34
効果音 初期ON 初期OFF

サイコロを大きくすると相対的に慣性が効いて転がらなくなるので、初速と角速度も一緒に上げる必要がありました。 どれか1つだけ変えても駄目です。

スマホ判定は UserAgent だけに頼らない

iPadOS は既定で自身を Mac として名乗ります。 UserAgent の文字列だけ見ていると取りこぼします。

function isMobileNow() {
  const ua = navigator.userAgent || '';
  const uaMobile = navigator.userAgentData?.mobile === true;

  const touchCapable =
    ('ontouchstart' in window) || ((navigator.maxTouchPoints || 0) > 0);

  const iOSLike =
    /iPhone|iPad|iPod/i.test(ua) ||
    (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1);

  return (
    uaMobile ||
    iOSLike ||
    /Android/i.test(ua) ||
    (window.matchMedia?.('(pointer: coarse)')?.matches === true) ||
    (touchCapable && window.innerWidth < 900)
  );
}

navigator.platform === 'MacIntel' かつ maxTouchPoints > 1 が iPadOS の見分け方です。タッチできる Mac は存在しないので、この組み合わせで判別できます。

効果音は「使い回す」

毎回 new Audio() するのはやめました。 投げるたびにインスタンスが増え、スマホでは再生が詰まります。

const diceAudio = new Audio();
diceAudio.preload = 'auto';
diceAudio.playsInline = true;

function playRandomDiceSound() {
  if (!isSoundOn) return;
  const src = diceSounds[Math.floor(Math.random() * diceSounds.length)];
  try {
    diceAudio.pause();
    diceAudio.currentTime = 0;
    diceAudio.src = src;
    const p = diceAudio.play();
    if (p && typeof p.catch === 'function') p.catch(() => {});
  } catch (e) {}
}

play() は Promise を返すので、必ず catch してください。 ユーザー操作前の再生はブラウザに拒否され、握りつぶさないとコンソールが例外で埋まります。

playsInline = true は iOS Safari 向けです。これがないと、音声の再生でフルスクリーン動画プレイヤーが立ち上がることがあります。

ガラスと陶器で4種類の音を用意して、ランダムに切り替えています。 同じ音が続かないだけで、転がっている感じがかなり出ます。

役の演出はCSSだけで作れる

役名バナーは div 1枚にクラスを付け替えているだけです。

.role-banner.show {
  animation: yakuPop 1500ms cubic-bezier(0.2,0.7,0.2,1.2) forwards,
             glowPulse 3000ms ease-in-out infinite,
             floatEffect 4000ms ease-in-out infinite;
}
.role-banner.legendary {
  --glow-color: #ffd54f;
  background: linear-gradient(180deg, #fff7e6 0%, #ff9800 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

役を4段階に分けて、クラスで演出を切り替えています。

  • legendary:ピンゾロ。画面シェイク+光+コンフェッティ
  • rare:アラシ、シゴロ
  • normal:目あり
  • bad:ヒフミ、役なし

CSS変数 --glow-color を切り替えるだけで、同じアニメーションを色違いで使い回せます。 役ごとにキーフレームを書くと保守できなくなります。

詰まりやすい箇所

症状 原因
2回目以降サイコロが動かない body.activate() の呼び忘れ(スリープ状態)
見た目と当たり判定がずれる btBoxShape は「半分の長さ」で指定する
タブを戻すとすり抜ける deltaTime の上限を切っていない
出目が1つずれる 面の法線の並びと目の対応表がずれている
サイコロの点が明滅する Zファイティング。法線方向にわずかに浮かせる
動作が徐々に重くなる Ammoオブジェクトを毎フレーム new している

最後の点は、このアプリにも改善余地が残っています。 投げ直しのたびに scene.remove() していますが、ジオメトリとマテリアルは dispose() していませんscene.remove() はシーングラフから外すだけで、GPU側のリソースは解放されないためです。長時間遊ぶ用途なら、次のように明示的に解放すべき箇所です。

mesh.traverse(obj => {
  if (obj.geometry) obj.geometry.dispose();
  if (obj.material) obj.material.dispose();
});
scene.remove(mesh);

まとめ

  • Three.js で描画、Ammo.js で物理演算。毎フレーム座標と回転を書き写すだけで繋がる
  • 出目の判定は自前で書く。 面の法線をクォータニオンで回し、真上との内積が最大の面を選ぶ
  • 面の並び順と目の対応表がずれると、原因の分かりにくいバグになる
  • チンチロはサイコロ3個。役はピンゾロ・アラシ・シゴロ・目・ヒフミ・役なし
  • 判定は上から順に確定させる。 順序を変えると 1・1・1 がアラシ扱いになる
  • ションベンは止まる前に判定してよい。 役は3つ止まるまで待つ
  • btBoxShape半分の長さで指定する
  • body.activate() を忘れると、2投目以降が動かない
  • deltaTime に上限を設けないと、タブ復帰時にすり抜ける
  • スマホはサイズ・初速・角速度をまとめて上げる。1つだけでは足りない
  • iPadOS は Mac を名乗るので、maxTouchPoints で見分ける

物理エンジンは「それらしく転がす」ところまでしかやってくれません。そこから先の、出目を読んで役を決める部分こそがゲームの本体だと、作ってみて分かりました。実際の挙動はチンチロゲームで確認できます。