JavaScript

Three.jsとAmmo.jsで作る3Dチンチロとロジック解説

Webブラウザ上でリアルなチンチロを作りたい…!
ということで作ってみたチンチロがこちらです。

今回は「3Dでサイコロを振るチンチロアプリ」をThree.js × Ammo.jsで作った話を書きます。

この記事では、

  • Three.js(3D描画)でサイコロをモデリングする方法
  • Ammo.js(物理演算)で物理的に転がす処理
  • 出目を判定して役(ゾロ目やワンペアなど)を判定するロジック
  • バナー演出・サウンド・UIの追加

までを 完全サンプルコード付き で徹底解説していきます。

この記事では完成コードをすべて公開しつつ、パーツごとに解説します。最後にそのまま動く完全版コードを載せているので、ぜひコピペして遊んでみてください!

UIと効果音周りの実装

まずは画面左上にUIボタン投げた回数表示を設置します。

<div class="ui-container">
  <button id="soundToggleBtn">🔊 サイコロ音 ON</button>
  <button id="throwDiceBtn">🎲 サイコロを投げる</button>
  <div id="diceCount" class="dice-count">投げた回数: 0</div>
</div>

✅解説

  • soundToggleBtnで効果音ON/OFF切替
  • throwDiceBtnでサイコロを投げるアクション
  • diceCountで累計投げ回数を表示

💡ポイント

  • CSSで光沢やアニメーションを付けるとUIが映える
  • モバイルでも押しやすいサイズに調整

次にJavaScriptで効果音のON/OFF切替を実装します。

let isSoundOn = true;
const soundToggleBtn = document.getElementById('soundToggleBtn');
soundToggleBtn.onclick = () => {
  isSoundOn = !isSoundOn;
  soundToggleBtn.textContent = isSoundOn ? '🔊 サイコロ音 ON' : '🔇 サイコロ音 OFF';
};

💡ポイント

  • ボタン状態と変数を同期することで、サウンドのON/OFF判定が簡単

さらにサイコロ音は複数のフリー素材を用意し、ランダム再生します。

const diceSounds = [
  'mp3/glass01.mp3',
  'mp3/glass02.mp3',
  'mp3/pottery01.mp3',
  'mp3/pottery02.mp3'
];
function playRandomDiceSound() {
  if (!isSoundOn) return;
  const src = diceSounds[Math.floor(Math.random()*diceSounds.length)];
  new Audio(src).play();
}

💡ポイント

  • ランダム再生するだけで転がる「リアル感」が大幅アップ
  • 複数サイコロ同時再生も可能

 

Three.jsでシーンを作る

続いて3Dのシーン作り。
カメラやライティング、そしてサイコロを受け止める“お椀”をセットアップしています。

シーンとカメラ

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.set(0, 3.5, 4.5);
camera.lookAt(0, -bowlRadius/2, 0);

 

真上からじゃなくて、ちょっと斜めに見下ろす感じにしてるのがポイント。

✅解説

  • 背景色を暗くしてサイコロが映えるように
  • カメラは真上ではなく斜め上から見下ろすアングル

💡ポイント

  • 視点を少し傾けるだけで立体感が増す

 

光源

scene.add(new THREE.AmbientLight(0x404040, 0.6));
const light1 = new THREE.PointLight(0xFFFFFF, 1.0, 100);
light1.position.set(2,6,6);
light1.castShadow = true;
scene.add(light1);

 

✅解説

  • AmbientLightで全体の明るさを調整
  • PointLightで立体感のある影を付与

💡ポイント

  • 影を有効にするとサイコロやお椀の立体感が大幅にアップ

お椀(サイコロ受け)

const visualGeometry = new THREE.SphereGeometry(
  bowlRadius, 64, 64, 0, Math.PI*2, Math.PI/2, Math.PI/2
);
const visualMaterial = new THREE.MeshStandardMaterial({
  color: 0xD2B48C,
  roughness: 0.4,
  metalness: 0.2,
  side: THREE.DoubleSide,
  transparent: true,
  opacity: 0.95
});
const bowlMesh = new THREE.Mesh(visualGeometry, visualMaterial);
bowlMesh.position.y = -bowlRadius/2;
scene.add(bowlMesh);

 

✅解説

  • 半球形状でサイコロを受け止める
  • 半透明にして光の反射を表現

💡ポイント

  • 見た目だけでなく、物理演算にも対応させる

 

Ammo.jsでサイコロの物理演算を実装

サイコロをリアルに転がすには、見た目だけでなく物理演算が必要です。Ammo.jsを使うことで、重力・衝突判定・慣性などを考慮した自然な動きを再現できます。

物理ワールドの構築

let collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
let dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
let broadphase = new Ammo.btDbvtBroadphase();
let solver = new Ammo.btSequentialImpulseConstraintSolver();
let physicsWorld = new Ammo.btDiscreteDynamicsWorld(dispatcher,broadphase,solver,collisionConfiguration);
physicsWorld.setGravity(new Ammo.btVector3(0, -9.82, 0));

 

✅解説

  • DiscreteDynamicsWorldで剛体シミュレーション
  • 重力は地球並みの -9.82 m/s²

 

お椀の物理登録

const ammoMesh = new Ammo.btTriangleMesh();
// three.jsジオメトリの頂点をAmmoに変換してaddTriangle…
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);
physicsWorld.addRigidBody(bowlBody);

 

✅解説

  • 半球を「静的な三角形メッシュ」として登録
  • サイコロがぶつかってもお椀自体は動かない

💡ポイント

  • 静的オブジェクトは質量0で登録する
  • 衝突判定だけで動かないオブジェクトを作れる

 

サイコロ(動的オブジェクト)の作成

const transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin(new Ammo.btVector3(0, 10, 0));
transform.setRotation(new Ammo.btQuaternion(0, 0, 0, 1));

const motionState = new Ammo.btDefaultMotionState(transform);

const colShape = new Ammo.btBoxShape(new Ammo.btVector3(0.5, 0.5, 0.5));
const localInertia = new Ammo.btVector3(0, 0, 0);
colShape.calculateLocalInertia(1, localInertia);

const rbInfo = new Ammo.btRigidBodyConstructionInfo(1, motionState, colShape, localInertia);
const body = new Ammo.btRigidBody(rbInfo);

physicsWorld.addRigidBody(body);

 

✅解説

  • btBoxShapeで立方体の衝突判定を作成
  • setOriginで初期位置を指定
  • calculateLocalInertiaで質量に応じた慣性を計算
  • btRigidBodyで物理オブジェクト化

💡ポイント

  • 初期位置や回転をランダムに設定すると自然な転がりに
  • モバイルでは力の値を調整して安定させる

 

サイコロの生成

Three.js側

サイコロは立方体として作成し、各面にマテリアルを設定します。

const diceGeometry = new THREE.BoxGeometry(1, 1, 1);

const diceMaterials = [
  new THREE.MeshStandardMaterial({ color: 0xffffff }),
  new THREE.MeshStandardMaterial({ color: 0xffffff }),
  new THREE.MeshStandardMaterial({ color: 0xffffff }),
  new THREE.MeshStandardMaterial({ color: 0xffffff }),
  new THREE.MeshStandardMaterial({ color: 0xffffff }),
  new THREE.MeshStandardMaterial({ color: 0xffffff })
];

const diceMesh = new THREE.Mesh(diceGeometry, diceMaterials);
scene.add(diceMesh);

 

✅解説

  • BoxGeometry(1,1,1)で1x1x1の立方体を作成
  • MeshStandardMaterialは光源に反応するマテリアル
  • Meshでジオメトリとマテリアルを結合し、シーンに追加

💡ポイント

  • サイコロのサイズとAmmo.jsの衝突形状を合わせること
  • マテリアルにテクスチャを貼るとよりリアルな演出が可能

 

const geometry = new THREE.BoxGeometry(size,size,size);
const material = new THREE.MeshStandardMaterial({color:0xFFFFFF});
const mesh = new THREE.Mesh(geometry, material);

// ドットを追加
const dotGeometry = new THREE.CircleGeometry(dotRadius, 32);
const dotMesh = new THREE.Mesh(dotGeometry, dotMaterial);
dotMesh.position.copy(normal.clone().multiplyScalar(size/2 + 0.001));
mesh.add(dotMesh);

 

✅解説

  • サイコロの目は黒・赤で表現
  • 1の面だけ赤にして「雰囲気アップ」

 

Ammo.js側

const diceShape = new Ammo.btBoxShape(new Ammo.btVector3(size/2,size/2,size/2));
const diceMass = 0.5;
const diceBodyInfo = new Ammo.btRigidBodyConstructionInfo(diceMass, diceMotionState, diceShape, diceLocalInertia);
const diceBody = new Ammo.btRigidBody(diceBodyInfo);
diceBody.setFriction(0.15);
diceBody.setRestitution(0.3);
physicsWorld.addRigidBody(diceBody);

 

✅解説

  • 質量・摩擦・反発係数を設定
  • サイコロが自然に転がる

💡ポイント

  • 摩擦と反発のバランスで挙動が大きく変わる

 

サイコロを投げる動作

function throwDice() {
  playRandomDiceSound();
  createDice(); // 3つ生成
  diceAmmoObj.setLinearVelocity(new Ammo.btVector3(Math.cos(angle)*throwSpeed, yVel, Math.sin(angle)*throwSpeed));
  diceAmmoObj.setAngularVelocity(new Ammo.btVector3((Math.random()-0.5)*angVel, …));
}

 

✅解説

  • ランダムな速度・回転で投げる
  • スマホとPCで速度を調整可能

💡ポイント

  • 投げ方次第で転がり方が変わるので、ユーザー体験に直結

 

出目判定と役演出

出目が揃ったら「ゾロ目」や「ピンゾロ」などの役を判定して、画面中央にド派手なバナーを出しています。

CSSでめちゃくちゃ頑張ってアニメーションを作りこみました。

function checkRole(diceValues) {
  const counts = {};
  diceValues.forEach(v => counts[v] = (counts[v] || 0) + 1);

  if (Object.values(counts).includes(5)) return '五つ子';
  if (Object.values(counts).includes(4)) return '四つ子';
  if (Object.values(counts).includes(3) && Object.values(counts).includes(2)) return 'フルハウス';
  if (Object.values(counts).includes(3)) return '三つ子';
  if (Object.values(counts).filter(v=>v===2).length===2) return 'ツーペア';
  if (Object.values(counts).includes(2)) return 'ワンペア';
  return 'ノーペア';
}

 

バナー演出(CSS)

特にピンゾロ専用の「伝説級」演出を入れたら、めちゃくちゃ盛り上がるようになりました。

画面シェイク、光のレイズ、コンフェッティまで出るので、ぜひ投げて確認してみてください。

.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;
  animation: … legendAura 3200ms ease-in-out infinite;
}

 

✅解説

  • ピンゾロなどのレア役は「伝説級演出」で盛り上げる
  • 画面シェイク、光のレイズ、コンフェッティも連動

 

💡ポイント

  • CSSアニメーションで華やかさを演出
  • バナーとサウンド・カメラ演出を同期すると臨場感が増す

 

まとめ

  • Three.jsで3D描画、Ammo.jsで物理演算
  • Three.jsのジオメトリとAmmo.jsの物理形状を合わせるのが一番難しい
  • サイコロ生成・投げる動作・サウンドのランダム化でよりリアルな挙動に
  • サイコロの質感や光源の調整で見た目のクオリティが上がる
  • UIと効果音で操作感・リアル感をアップ
  • 演出部分はCSSアニメーションだけでかなり派手にできる

 

完全版コード

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>チンチロ</title>

  <script async src="https://www.googletagmanager.com/gtag/js?id=G-91QM1EM75W"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());

    gtag('config', 'G-91QM1EM75W');
  </script>

  <script src="./three.min.js"></script>
  <script src="./ammo.js"></script>
  <style>
    body {
      margin: 0;
      font-family: 'Arial', sans-serif;
      overflow: hidden;
      background: #1a1a1a;
    }
    
    .ui-container {
      position: absolute;
      top: 8px;
      left: 8px;
      z-index: 10;
      background: rgba(0, 0, 0, 0.7);
      padding: 8px 10px;
      border-radius: 8px;
      color: white;
      max-width: 180px;
      backdrop-filter: blur(4px);
    }
    
    .ui-container button {
      background: linear-gradient(45deg, #4CAF50, #45a049);
      border: none;
      color: white;
      padding: 7px 10px;
      font-size: 13px;
      border-radius: 4px;
      cursor: pointer;
      margin-bottom: 6px;
      transition: all 0.3s ease;
      display: block;
      width: 100%;
    }
    
    .ui-container button:hover {
      background: linear-gradient(45deg, #45a049, #4CAF50);
      transform: translateY(-2px);
      box-shadow: 0 4px 8px rgba(0,0,0,0.3);
    }
    
    .ui-container button:active {
      transform: translateY(0);
    }
    
    .dice-count {
      font-size: 11px;
      opacity: 0.8;
    }
    
    .instructions {
      position: absolute;
      bottom: 20px;
      left: 20px;
      background: rgba(0, 0, 0, 0.7);
      padding: 10px;
      border-radius: 5px;
      color: white;
      font-size: 12px;
    }

    /* =======================================================================================
       役名バナー演出(showYaku / playYakuBanner と連携)
       ======================================================================================= */
    .role-banner {
      --glow-color: #00e5ff;
      position: fixed;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%) scale(0.8);
      z-index: 100;
      pointer-events: none;
      color: #ffffff;
      font-weight: 900;
      letter-spacing: 0.08em;
      text-align: center;
      opacity: 0;
      filter: drop-shadow(0 4px 16px rgba(0,0,0,0.5));
      text-shadow: 0 0 0 rgba(255,255,255,0);
      white-space: nowrap;
      font-size: clamp(28px, 8vw, 120px);
      transition: opacity 0.3s ease;
    }

    .role-banner.show {
      animation: yakuPop 1500ms cubic-bezier(0.2, 0.7, 0.2, 1.2) forwards,
                 glowPulse 3000ms ease-in-out 300ms infinite,
                 floatEffect 4000ms ease-in-out 500ms infinite;
    }

    .role-banner.rare { 
      --glow-color: #ffeb3b; 
      animation: yakuPop 1500ms cubic-bezier(0.2, 0.7, 0.2, 1.2) forwards,
                 glowPulse 3000ms ease-in-out 300ms infinite,
                 floatEffect 4000ms ease-in-out 500ms infinite,
                 rareShine 2000ms ease-in-out 800ms infinite;
    }
    .role-banner.normal { --glow-color: #00e5ff; }
    .role-banner.bad { --glow-color: #ff3b3b; }

    /* 背景エフェクト */
    .role-banner::before {
      content: '';
      position: absolute;
      top: -50%;
      left: -50%;
      width: 200%;
      height: 200%;
      background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
      opacity: 0;
      animation: bgPulse 3000ms ease-in-out 500ms infinite;
      pointer-events: none;
    }

    @keyframes yakuPop {
      0% { 
        opacity: 0; 
        transform: translate(-50%, -50%) scale(0.3) rotateX(60deg) rotateY(20deg); 
        filter: blur(12px) drop-shadow(0 4px 16px rgba(0,0,0,0.5));
        text-shadow: 0 0 0 rgba(255,255,255,0);
      }
      20% { 
        opacity: 0.8; 
        transform: translate(-50%, -50%) scale(1.2) rotateX(10deg) rotateY(5deg); 
        filter: blur(4px) drop-shadow(0 8px 32px rgba(0,0,0,0.7));
        text-shadow: 0 0 20px var(--glow-color);
      }
      40% { 
        opacity: 1; 
        transform: translate(-50%, -50%) scale(0.95) rotateX(0) rotateY(0); 
        filter: blur(0) drop-shadow(0 12px 48px rgba(0,0,0,0.8));
        text-shadow: 0 0 30px var(--glow-color);
      }
      60% { 
        transform: translate(-50%, -50%) scale(1.05); 
        text-shadow: 0 0 40px var(--glow-color);
      }
      80% { 
        transform: translate(-50%, -50%) scale(1.0); 
        text-shadow: 0 0 35px var(--glow-color);
      }
      100% { 
        opacity: 1; 
        transform: translate(-50%, -50%) scale(1.0); 
        text-shadow: 0 0 30px var(--glow-color);
      }
    }

    @keyframes glowPulse {
      0%, 100% { text-shadow: 0 0 30px var(--glow-color), 0 0 60px var(--glow-color); }
      50% { text-shadow: 0 0 50px var(--glow-color), 0 0 100px var(--glow-color), 0 0 150px var(--glow-color); }
    }

    @keyframes floatEffect {
      0%, 100% { transform: translate(-50%, -50%) scale(1.0); }
      50% { transform: translate(-50%, -50%) scale(1.02); }
    }

    @keyframes rareShine {
      0%, 100% { filter: drop-shadow(0 12px 48px rgba(0,0,0,0.8)) brightness(1); }
      50% { filter: drop-shadow(0 12px 48px rgba(0,0,0,0.8)) brightness(1.3) saturate(1.2); }
    }

    @keyframes bgPulse {
      0%, 100% { opacity: 0; transform: scale(0.8); }
      50% { opacity: 0.3; transform: scale(1.2); }
    }

    /* 消去指示 */
    .dismiss-hint {
      position: fixed;
      bottom: 40px;
      left: 50%;
      transform: translateX(-50%);
      color: rgba(255,255,255,0.7);
      font-size: 14px;
      z-index: 101;
      opacity: 0;
      transition: opacity 0.5s ease;
      pointer-events: none;
    }

    .dismiss-hint.show {
      opacity: 1;
      animation: hintPulse 2000ms ease-in-out infinite;
    }

    @keyframes hintPulse {
      0%, 100% { opacity: 0.7; }
      50% { opacity: 1; }
    }

    /* ====== ピンゾロ専用:伝説級(legendary)演出 ====== */
    .role-banner.legendary {
      --glow-color: #ffd54f;
      background: linear-gradient(180deg, #fff7e6 0%, #ffe082 35%, #ffca28 60%, #ff9800 100%);
      -webkit-background-clip: text;
      background-clip: text;
      -webkit-text-fill-color: transparent;
      animation: yakuPop 1600ms cubic-bezier(0.2, 0.7, 0.2, 1.2) forwards,
                 glowPulse 2600ms ease-in-out 200ms infinite,
                 legendAura 3200ms ease-in-out 400ms infinite,
                 floatEffect 3800ms ease-in-out 500ms infinite,
                 rareShine 2000ms ease-in-out 800ms infinite;
    }

    @keyframes legendAura {
      0%, 100% { filter: drop-shadow(0 0 24px rgba(255, 215, 64, 0.85)) saturate(1.2); }
      50% { filter: drop-shadow(0 0 64px rgba(255, 215, 64, 0.95)) saturate(1.6); }
    }

    /* 画面全体エフェクトコンテナ */
    .fx-container {
      position: fixed;
      inset: 0;
      z-index: 99;
      pointer-events: none;
      overflow: hidden;
    }
    /* 衝撃波 */
    .shockwave {
      position: absolute;
      left: 50%;
      top: 50%;
      width: 40vmin;
      height: 40vmin;
      transform: translate(-50%, -50%) scale(0.2);
      border-radius: 50%;
      border: 3px solid rgba(255, 255, 255, 0.65);
      box-shadow: 0 0 24px rgba(255, 215, 64, 0.9), inset 0 0 18px rgba(255, 215, 64, 0.6);
      animation: shockExpand 1600ms ease-out forwards;
      filter: blur(0.4px);
    }
    .shockwave.second { animation-delay: 200ms; opacity: 0.9; }
    @keyframes shockExpand {
      0% { transform: translate(-50%, -50%) scale(0.2); opacity: 1; }
      70% { opacity: 0.9; }
      100% { transform: translate(-50%, -50%) scale(2.4); opacity: 0; }
    }

    /* 光芒(レイズ) */
    .rays {
      position: absolute;
      left: 50%;
      top: 50%;
      width: 160vmin;
      height: 160vmin;
      transform: translate(-50%, -50%);
      background: conic-gradient(from 0deg, rgba(255, 223, 128, 0.0), rgba(255,223,128,0.35) 12%, rgba(255,223,128,0.0) 24%, rgba(255,223,128,0.3) 36%, rgba(255,223,128,0.0) 48%, rgba(255,223,128,0.35) 60%, rgba(255,223,128,0.0) 72%, rgba(255,223,128,0.3) 84%, rgba(255,223,128,0.0) 100%);
      mix-blend-mode: screen;
      filter: blur(2px) brightness(1.1);
      animation: raysRotate 6000ms linear infinite;
      opacity: 0.9;
      mask-image: radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.1) 60%, rgba(255,255,255,0) 70%);
    }
    @keyframes raysRotate {
      to { transform: translate(-50%, -50%) rotate(360deg); }
    }

    /* コンフェッティ */
    .confetti { position: absolute; width: 6px; height: 12px; opacity: 0.9; will-change: transform; border-radius: 1px; }
    .confetti.a { background: #ff5252; }
    .confetti.b { background: #ffd740; }
    .confetti.c { background: #69f0ae; }
    .confetti.d { background: #40c4ff; }
    .confetti.e { background: #b388ff; }
    @keyframes fall {
      0% { transform: translateY(-10vh) rotate(0deg); }
      100% { transform: translateY(110vh) rotate(720deg); }
    }

    /* 画面シェイク(キャンバスに適用) */
    canvas.shake { animation: screenShake 700ms cubic-bezier(0.36, 0.07, 0.19, 0.97) 1; }
    @keyframes screenShake {
      10%, 90% { transform: translate3d(-1px, 0, 0); }
      20%, 80% { transform: translate3d(2px, 0, 0); }
      30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
      40%, 60% { transform: translate3d(4px, 0, 0); }
      100% { transform: translate3d(0, 0, 0); }
    }

    @media (max-width: 600px) {
      .ui-container { max-width: 120px; padding: 5px 4px; top: 140px; left: 4px; }
      .ui-container button { font-size: 11px; padding: 5px 4px; }
      .dice-count { font-size: 9px; }
      #throwDiceBtn { display: none !important; }
    }


    html, body {
      margin: 0;
      padding: 0;
      min-height: 100%;
    }
    canvas {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 0;
    }
    .adFooter {
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      background: #fff;
      border-top: 1px solid #ddd;
      text-align: center;
      z-index: 9999;
    }
    .adFooter ins.adsbygoogle {
      display: block !important;
      margin: 0 auto;
    }
  </style>
</head>
<body>
  <!-- =====================================================================================
       UIパネル(ボタンとカウンタ)
       ===================================================================================== -->
  <div class="ui-container">
    <button id="soundToggleBtn" style="margin-bottom:8px;">🔊 サイコロ音 ON</button>
    <button id="throwDiceBtn">🎲 サイコロを投げる</button>
    <div id="diceCount" class="dice-count">投げた回数: 0</div>
  </div>

  <div id="canvasWrap">
  <!-- =====================================================================================
       アプリ本体スクリプト
       ===================================================================================== -->
  <script type="text/javascript">
  let isSoundOn = true;
  const soundToggleBtn = document.getElementById('soundToggleBtn');
  if (soundToggleBtn) {
    soundToggleBtn.onclick = () => {
      isSoundOn = !isSoundOn;
      soundToggleBtn.textContent = isSoundOn ? '🔊 サイコロ音 ON' : '🔇 サイコロ音 OFF';
    };
  }
  // サイコロ音ファイルリスト
  const diceSounds = [
    'mp3/glass01.mp3',
    'mp3/glass02.mp3',
    'mp3/pottery01.mp3',
    'mp3/pottery02.mp3'
  ];
  function playRandomDiceSound() {
  if (!isSoundOn) return;
  const src = diceSounds[Math.floor(Math.random()*diceSounds.length)];
  const audio = new Audio(src);
  audio.currentTime = 0;
  audio.play();
  }
  // ===================================================================
  // Ammo.jsロード完了を明示的に待つ自己呼出し関数
  // ===================================================================
  (function initWhenAmmoReady() {

    // ================================================================
    // アプリ開始(Ammo 読み込み後に呼ばれる)
    // ================================================================
    function startApp() {
      // =====================
      // Three.js & Ammo.js セットアップ
      // =====================
      // bowlRadiusとカメラをスマホ向けに調整
      let bowlRadius = 2;
      if (window.innerWidth < 600) bowlRadius = 2.8;

      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x1a1a1a);

      const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      if (window.innerWidth < 600) {
        camera.position.set(0, 3.5, 4.5);
      } else {
        camera.position.set(0, 3.5, 4.5);
      }
      camera.lookAt(0, -bowlRadius/2, 0);

      const renderer = new THREE.WebGLRenderer({ antialias: true });
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.shadowMap.enabled = true;
      renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      document.body.appendChild(renderer.domElement);
      console.log('Three.js canvas appended');

      // 役名バナー要素を作成
      let roleBanner = document.createElement('div');
      roleBanner.id = 'roleBanner';
      roleBanner.className = 'role-banner';
      document.body.appendChild(roleBanner);

      // エフェクト用コンテナ
      const fxContainer = document.createElement('div');
      fxContainer.className = 'fx-container';
      document.body.appendChild(fxContainer);

      // 消去指示要素を作成
      let dismissHint = document.createElement('div');
      dismissHint.className = 'dismiss-hint';
      dismissHint.textContent = 'クリックまたはスペースキーで消去';
      document.body.appendChild(dismissHint);

      // === ゲーム状態用フラグ ===
      let lastYakuKey = null;     // 同一出目での多重再生防止用キー
      let isYakuDisplayed = false;

      // リサイズ対応
      window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        camera.lookAt(0, -bowlRadius/2, 0);
        renderer.setSize(window.innerWidth, window.innerHeight);
      });

      // ライティング
      scene.add(new THREE.AmbientLight(0x404040, 0.6));
      const light1 = new THREE.PointLight(0xFFFFFF, 1.0, 100);
      light1.position.set(2, 6, 6);
      light1.castShadow = true;
      scene.add(light1);

      const light2 = new THREE.PointLight(0xFFFFFF, 0.8, 100);
      light2.position.set(-2, 4, -4);
      scene.add(light2);

      // =====================
      // 容器(箱型の物理壁 + 半球の視覚的表現)
      // =====================
      // Three.js 半球(見た目)— 下半球のみ(thetaStart: Math.PI/2, thetaLength: Math.PI/2)
      const visualGeometry = new THREE.SphereGeometry(
        bowlRadius, 64, 64,
        0, Math.PI * 2,
        Math.PI/2, Math.PI/2
      );
      const visualMaterial = new THREE.MeshStandardMaterial({
        color: 0xD2B48C,
        roughness: 0.4,
        metalness: 0.2,
        side: THREE.DoubleSide,
        transparent: true,
        opacity: 0.95
      });
      const bowlMesh = new THREE.Mesh(visualGeometry, visualMaterial);
      bowlMesh.position.y = -bowlRadius/2;
      bowlMesh.castShadow = true;
      bowlMesh.receiveShadow = true;
      scene.add(bowlMesh);
      console.log('Bowl mesh added');

      // =====================
      // 物理エンジンセットアップ
      // =====================
      let physicsWorld;
      let ammoTmpTransform;

      if (typeof Ammo !== 'undefined') {
        console.log('Ammo.js initialized (direct)');
        let collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
        let dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
        let broadphase = new Ammo.btDbvtBroadphase();
        let 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');
      }

      // -----------------------------------------------------------------
      // Ammo.js物理セットアップ(半球メッシュをそのままTrimesh化)
      // -----------------------------------------------------------------
      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;
          const a = new Ammo.btVector3(vertices[ai], vertices[ai + 1], vertices[ai + 2]);
          const b = new Ammo.btVector3(vertices[bi], vertices[bi + 1], vertices[bi + 2]);
          const c = new Ammo.btVector3(vertices[ci], vertices[ci + 1], vertices[ci + 2]);
          ammoMesh.addTriangle(a, b, c, true);
        }

        // Bowl shape(静的)
        const bowlShape = new Ammo.btBvhTriangleMeshShape(ammoMesh, true, true);
        const bowlTransform = new Ammo.btTransform();
        bowlTransform.setIdentity();
        bowlTransform.setOrigin(new Ammo.btVector3(0, 0, 0));
        const bowlMotionState = new Ammo.btDefaultMotionState(bowlTransform);
        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);

        // 底面を薄いBoxで物理的に表現(半球の底に薄い地面)
        const groundThickness = 0.02;
        const groundSize = geometry.parameters.radius * 1.01;
        const groundShape = new Ammo.btBoxShape(new Ammo.btVector3(groundSize, groundThickness/2, groundSize));
        const groundTransform = new Ammo.btTransform();
        groundTransform.setIdentity();
        groundTransform.setOrigin(new Ammo.btVector3(0, -geometry.parameters.radius + groundThickness/2, 0));
        const groundMotionState = new Ammo.btDefaultMotionState(groundTransform);
        const groundBodyInfo = new Ammo.btRigidBodyConstructionInfo(0, groundMotionState, groundShape, new Ammo.btVector3(0, 0, 0));
        const groundBody = new Ammo.btRigidBody(groundBodyInfo);
        physicsWorld.addRigidBody(groundBody);
        console.log('Physics setup complete');
      }

      // =====================
      // サイコロ作成・管理
      // =====================
      let diceList = [];
      let diceAmmoList = [];
      let diceCount = 0;

      // ---------------------------------------------------------------
      // サイコロを3個作成してシーン・物理ワールドへ追加
      // ---------------------------------------------------------------
      function createDice() {
        // サイコロサイズをスマホで大きく
        let size = 0.25;
        if (window.innerWidth < 600) size = 0.38;
        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 dotMaterialRed = new THREE.MeshBasicMaterial({ color: 0xff0000 });
        const dotMaterialBlack = new THREE.MeshBasicMaterial({ color: 0x000000 });

        // 面のローカル法線
        const faceNormals = [
          new THREE.Vector3(0, 0, 1),   // +Z (1)
          new THREE.Vector3(0, 0, -1),  // -Z (6)
          new THREE.Vector3(0, 1, 0),   // +Y (2)
          new THREE.Vector3(0, -1, 0),  // -Y (5)
          new THREE.Vector3(1, 0, 0),   // +X (3)
          new THREE.Vector3(-1, 0, 0)   // -X (4)
        ];

        // 各面の目ドット配置
        const dotPos = [
          [ {x:0, y:0, color:'red'} ], // 1
          [ // 6
            {x:-dotOffset, y:-dotOffset, color:'black'},
            {x:0, y:-dotOffset, color:'black'},
            {x:dotOffset, y:-dotOffset, color:'black'},
            {x:-dotOffset, y:dotOffset, color:'black'},
            {x:0, y:dotOffset, color:'black'},
            {x:dotOffset, y:dotOffset, color:'black'}
          ],
          [ // 2
            {x:-dotOffset, y:-dotOffset, color:'black'},
            {x:dotOffset, y:dotOffset, color:'black'}
          ],
          [ // 5
            {x:-dotOffset, y:-dotOffset, color:'black'},
            {x:-dotOffset, y:dotOffset, color:'black'},
            {x:0, y:0, color:'black'},
            {x:dotOffset, y:-dotOffset, color:'black'},
            {x:dotOffset, y:dotOffset, color:'black'}
          ],
          [ // 3
            {x:-dotOffset, y:-dotOffset, color:'black'},
            {x:0, y:0, color:'black'},
            {x:dotOffset, y:dotOffset, color:'black'}
          ],
          [ // 4
            {x:-dotOffset, y:-dotOffset, color:'black'},
            {x:-dotOffset, y:dotOffset, color:'black'},
            {x:dotOffset, y:-dotOffset, color:'black'},
            {x:dotOffset, y:dotOffset, color:'black'}
          ]
        ];

        // 3個作る
        for (let i = 0; i < 3; i++) {
          const mesh = new THREE.Mesh(geometry, material);
          mesh.castShadow = true;
          mesh.receiveShadow = true;

          // 各面に目を描画
          for (let f = 0; f < 6; f++) {
            const normal = faceNormals[f];
            dotPos[f].forEach(dot => {
              const mat = dot.color === 'red' ? dotMaterialRed : dotMaterialBlack;
              const dotGeometry = new THREE.CircleGeometry(dotRadius, 32);
              const dotMesh = new THREE.Mesh(dotGeometry, mat);
              dotMesh.position.copy(normal.clone().multiplyScalar(size/2 + 0.001));
              if (normal.x === 1) {
                dotMesh.position.y += dot.x;
                dotMesh.position.z += dot.y;
                dotMesh.rotation.y = Math.PI / 2;
              } else if (normal.x === -1) {
                dotMesh.position.y -= dot.x;
                dotMesh.position.z -= dot.y;
                dotMesh.rotation.y = -Math.PI / 2;
              } else if (normal.y === 1) {
                dotMesh.position.x += dot.x;
                dotMesh.position.z += dot.y;
                dotMesh.rotation.x = -Math.PI / 2;
              } else if (normal.y === -1) {
                dotMesh.position.x -= dot.x;
                dotMesh.position.z -= dot.y;
                dotMesh.rotation.x = Math.PI / 2;
              } else if (normal.z === 1) {
                dotMesh.position.x += dot.x;
                dotMesh.position.y += dot.y;
              } else if (normal.z === -1) {
                dotMesh.position.x -= dot.x;
                dotMesh.position.y -= dot.y;
                dotMesh.rotation.y = Math.PI;
              }
              mesh.add(dotMesh);
            });
          }

          // ワイヤーフレーム
          const edges = new THREE.EdgesGeometry(geometry);
          const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 });
          const wireframe = new THREE.LineSegments(edges, lineMaterial);
          mesh.add(wireframe);

          // 位置・スケール
          mesh.position.set((i-1)*0.3, 1.5 - bowlRadius/2, 0);
          mesh.scale.set(1.1, 1.1, 1.1);
          scene.add(mesh);

          // Ammo.js の剛体設定
          const diceShape = new Ammo.btBoxShape(new Ammo.btVector3(size/2, size/2, size/2));
          const diceTransform = new Ammo.btTransform();
          diceTransform.setIdentity();
          diceTransform.setOrigin(new Ammo.btVector3((i-1)*0.3, 1.5 - bowlRadius/2, 0));
          const diceMotionState = new Ammo.btDefaultMotionState(diceTransform);
          const diceMass = 0.5;
          const diceLocalInertia = new Ammo.btVector3(0, 0, 0);
          diceShape.calculateLocalInertia(diceMass, diceLocalInertia);
          const diceBodyInfo = new Ammo.btRigidBodyConstructionInfo(diceMass, diceMotionState, diceShape, diceLocalInertia);
          const diceBody = new Ammo.btRigidBody(diceBodyInfo);
          diceBody.setFriction(0.15);
          diceBody.setRestitution(0.3); // サイコロ同士は弱い反発
          physicsWorld.addRigidBody(diceBody);

          diceList.push({ mesh });
          diceAmmoList.push(diceBody);
        }
        console.log('3 Dice created');
      }

      // ---------------------------------------------------------------
      // サイコロ投げ入れ:既存をクリアしてから再生成+初速を与える
      // ---------------------------------------------------------------
      function throwDice() {
        playRandomDiceSound();
        // 既存サイコロを物理・描画ともに削除
        if (diceList && diceList.length) {
          for (let i = 0; i < diceList.length; i++) {
            if (diceList[i] && diceList[i].mesh) {
              scene.remove(diceList[i].mesh);
            }
            if (diceAmmoList[i]) {
              physicsWorld.removeRigidBody(diceAmmoList[i]);
            }
          }
        }
        diceList = [];
        diceAmmoList = [];

        // 新規作成
        createDice();
        diceCount++;

        // 位置と速度を与える
        // サイコロの投げる位置・速度をスマホ時はさらに高く速く
        for (let i = 0; i < diceList.length; i++) {
          const diceObj = diceList[i];
          const diceAmmoObj = diceAmmoList[i];
          let yPos = 0.5 - bowlRadius/2;
          let throwSpeed = 2.5 + Math.random() * 1.5;
          let yVel = -4.0;
          let angVel = 10;
          // スマホ判定
          const isMobile = (typeof window.orientation !== 'undefined') || (navigator.userAgent.indexOf('Mobi') !== -1);
          if (isMobile) {
            yPos = 1.5 - bowlRadius/2;
            throwSpeed = 4.5 + Math.random() * 2.0;
            yVel = -8.5;
            angVel = 16;
          }
          if (diceObj && diceObj.mesh) {
            diceObj.mesh.position.set((i-1)*0.3, yPos, 0);
            diceObj.mesh.quaternion.set(0, 0, 0, 1);
          }
          if (diceAmmoObj) {
            diceAmmoObj.setLinearVelocity(new Ammo.btVector3(0, 0, 0));
            diceAmmoObj.setAngularVelocity(new Ammo.btVector3(0, 0, 0));
            diceAmmoObj.activate();
            const angle = Math.random() * Math.PI * 2;
            diceAmmoObj.setLinearVelocity(new Ammo.btVector3(
              Math.cos(angle) * throwSpeed,
              yVel,
              Math.sin(angle) * throwSpeed
            ));
            diceAmmoObj.setAngularVelocity(new Ammo.btVector3(
              (Math.random()-0.5)*angVel,
              (Math.random()-0.5)*angVel,
              (Math.random()-0.5)*angVel
            ));
          }
        }

        updateDiceCount();
        console.log('3 Dice thrown');

        // 新しい投擲で演出をリセット
        if (roleBanner) roleBanner.classList.remove('show', 'legendary', 'rare', 'normal', 'bad');
        if (dismissHint) dismissHint.classList.remove('show');
        if (fxContainer) fxContainer.innerHTML = '';
        if (renderer && renderer.domElement) renderer.domElement.classList.remove('shake');

        lastYakuKey = null;
        isYakuDisplayed = false;
      }

      function updateDiceCount() {
        const el = document.getElementById('diceCount');
        if (el) el.textContent = `投げた回数: ${diceCount}`;
      }

      // =====================
      // アニメーションループ
      // =====================
      function animate() {
        requestAnimationFrame(animate);

        if (physicsWorld) {
          physicsWorld.stepSimulation(1/60, 10);

          let allStopped = true;
          let diceValues = [];
          let insideStates = [];

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

                diceObj.mesh.position.set(p.x(), p.y(), p.z());
                diceObj.mesh.quaternion.set(q.x(), q.y(), q.z(), q.w());

                // 停止判定(やや厳しめ)
                let lv = diceAmmoObj.getLinearVelocity();
                let av = diceAmmoObj.getAngularVelocity();
                if (lv.length() > 0.02 || av.length() > 0.02) {
                  allStopped = false;
                }

                // 上面の目
                diceValues[i] = getDiceTopValue(diceObj.mesh);
                // お椀内判定
                insideStates[i] = isInsideBowl(diceObj.mesh.position);
              }
            }
          }

          // --- ションベン即時判定 ---
          const insideCount = insideStates.filter(Boolean).length;
          if (insideCount >= 1 && insideCount <= 2) {
            if (lastYakuKey !== 'SHONBEN') {
              lastYakuKey = 'SHONBEN';
              isYakuDisplayed = true;
              setTimeout(() => playYakuBanner('ションベン'), 100);
            }
          } else if (allStopped) {
            // 全部お椀内で停止時のみ役判定
            if (insideCount === 3) {
              const key = [...diceValues].sort().join(',');
              if (key !== lastYakuKey && !isYakuDisplayed) {
                lastYakuKey = key;
                isYakuDisplayed = true;
                showYaku(diceValues);
              }
            }
          } else {
            // 動作中は演出を隠す
            if (!isYakuDisplayed && roleBanner) roleBanner.classList.remove('show');
            if (dismissHint) dismissHint.classList.remove('show');
          }
        }

        renderer.render(scene, camera);
      }

      // ===================================================================
      // ▼ 役関連ユーティリティ(animate 外に1セットだけ定義 — 重複排除)
      // ===================================================================

      // サイコロの上面の目を取得(最もY軸正方向に近い面)
      function getDiceTopValue(mesh) {
        const up = new THREE.Vector3(0, 1, 0);
        const localUps = [
          new THREE.Vector3(0, 0, 1),   // +Z (1)
          new THREE.Vector3(0, 0, -1),  // -Z (6)
          new THREE.Vector3(0, 1, 0),   // +Y (2)
          new THREE.Vector3(0, -1, 0),  // -Y (5)
          new THREE.Vector3(1, 0, 0),   // +X (3)
          new THREE.Vector3(-1, 0, 0)   // -X (4)
        ];
        let maxDot = -Infinity;
        let topIdx = 0;
        for (let i = 0; i < 6; i++) {
          let v = localUps[i].clone().applyQuaternion(mesh.quaternion);
          let dot = v.dot(up);
          if (dot > maxDot) {
            maxDot = dot;
            topIdx = i;
          }
        }
        const values = ;
        return values[topIdx];
      }

      // お椀内かどうかの簡易判定(XY半径とY高さでチェック)
      function isInsideBowl(pos) {
        const r = Math.sqrt(pos.x * pos.x + pos.z * pos.z);
        // 半径ちょい外を境界にし、上に飛び出たものも除外
        return (r <= (bowlRadius * 0.98)) && (pos.y <= 0.1);
      }

      // 役判定・表示
      function showYaku(values) {
        values.sort();

        // ピンゾロは最優先で伝説演出
        if (values[0] === 1 && values === 1 && values === 1) {
          console.log('PINZORO detected');
          playYakuBanner('ピンゾロ');
          return;
        }

        let yaku = '';
        // ゾロ目
        if (values[0] === values && values === values) {
          yaku = `${values[0]}アラシ`;
        }
        // 2つ同じ数字
        else if (values[0] === values || values === values || values[0] === values) {
          let same, diff;
          if (values[0] === values) {
            same = values[0];
            diff = values;
          } else if (values === values) {
            same = values;
            diff = values[0];
          } else {
            same = values[0];
            diff = values;
          }
          yaku = `${diff}`;
        }
        // シゴロ
        else if (values[0] === 4 && values === 5 && values === 6) {
          yaku = 'シゴロ';
        }
        // ヒフミ
        else if (values[0] === 1 && values === 2 && values === 3) {
          yaku = 'ヒフミ';
        }
        else {
          yaku = '役なし';
        }

        console.log('showYaku called, setting isYakuDisplayed to true');
        playYakuBanner(yaku);
      }

      // 役に応じてカラーを変えつつ、アニメーションを再生
      function playYakuBanner(yakuText) {
        if (!roleBanner) return;

        roleBanner.textContent = yakuText;
        roleBanner.classList.remove('show', 'rare', 'normal', 'bad', 'legendary');

        // 演出の色分け
        let category = 'normal';
        if (yakuText.includes('アラシ') || yakuText === 'シゴロ') category = 'rare';
        if (yakuText === 'ヒフミ' || yakuText === '役なし') category = 'bad';
        if (yakuText === 'ピンゾロ') category = 'legendary';
        roleBanner.classList.add(category);

        // アニメーションを再トリガーするためにリフロー
        void roleBanner.offsetWidth;
        roleBanner.classList.add('show');

        // 伝説級(ピンゾロ)専用の追加エフェクト
        if (category === 'legendary') {
          triggerLegendaryEffects();
        } else if (category === 'rare' && (yakuText.includes('アラシ') || yakuText === 'シゴロ')) {
          // シゴロ・アラシ共通: 衝撃波のみ(デフォルト)
          if (yakuText === 'シゴロ') {
            if (fxContainer) {
              fxContainer.innerHTML = '';
              const wave1 = document.createElement('div');
              wave1.className = 'shockwave';
              fxContainer.appendChild(wave1);
            }
          } else if (yakuText.includes('アラシ')) {
            // アラシは盛大: 衝撃波2枚+光芒+シェイク
            if (fxContainer) {
              fxContainer.innerHTML = '';
              const wave1 = document.createElement('div');
              wave1.className = 'shockwave';
              const wave2 = document.createElement('div');
              wave2.className = 'shockwave second';
              fxContainer.appendChild(wave1);
              fxContainer.appendChild(wave2);
              // 光芒
              const rays = document.createElement('div');
              rays.className = 'rays';
              fxContainer.appendChild(rays);
            }
            // シェイク
            if (renderer && renderer.domElement) {
              const cv = renderer.domElement;
              cv.classList.remove('shake');
              void cv.offsetWidth;
              cv.classList.add('shake');
              setTimeout(() => cv.classList.remove('shake'), 800);
            }
          }
        }

        // 消去指示を表示
        if (dismissHint) {
          setTimeout(() => {
            dismissHint.classList.add('show');
          }, 2000);
        }
      }

      // 伝説(ピンゾロ)専用:エフェクト一式
      function triggerLegendaryEffects() {
        if (!fxContainer) return;

        // 既存の残骸を掃除
        fxContainer.innerHTML = '';

        // 衝撃波2枚
        const wave1 = document.createElement('div');
        wave1.className = 'shockwave';
        const wave2 = document.createElement('div');
        wave2.className = 'shockwave second';
        fxContainer.appendChild(wave1);
        fxContainer.appendChild(wave2);

        // 光芒
        const rays = document.createElement('div');
        rays.className = 'rays';
        fxContainer.appendChild(rays);

        // コンフェッティ
        const colors = ['a','b','c','d','e'];
        const confettiCount = 80;
        for (let i = 0; i < confettiCount; i++) {
          const piece = document.createElement('div');
          piece.className = 'confetti ' + colors[Math.floor(Math.random()*colors.length)];
          const startX = Math.random() * 100; // vw
          const delay = Math.random() * 400; // ms
          const duration = 2000 + Math.random() * 2000;
          piece.style.left = startX + 'vw';
          piece.style.top = (-10 - Math.random()*20) + 'vh';
          piece.style.animation = `fall ${duration}ms linear ${delay}ms forwards`;
          fxContainer.appendChild(piece);
        }

        // 画面(canvas)シェイク
        if (renderer && renderer.domElement) {
          const cv = renderer.domElement;
          cv.classList.remove('shake');
          // リフローで再トリガ
          void cv.offsetWidth;
          cv.classList.add('shake');
          // 終了後に自動解除
          setTimeout(() => cv.classList.remove('shake'), 800);
        }

        // 自動で光芒を薄める(演出持続はするが控えめに)
        setTimeout(() => {
          const raysEl = fxContainer.querySelector('.rays');
          if (raysEl) raysEl.style.opacity = '0.4';
        }, 1800);
      }

      // 役名バナーを消去(Space/Click で使用)
      function dismissYakuBanner() {
        console.log('dismissYakuBanner called, isYakuDisplayed:', isYakuDisplayed);
        if (roleBanner) {
          roleBanner.classList.remove('show', 'legendary', 'rare', 'normal', 'bad');
        }
        if (dismissHint) {
          dismissHint.classList.remove('show');
        }
        // 伝説演出など画面エフェクトを全てクリア
        if (fxContainer) {
          fxContainer.innerHTML = '';
        }
        // 画面シェイク解除
        if (renderer && renderer.domElement) {
          renderer.domElement.classList.remove('shake');
        }
        isYakuDisplayed = false;
        console.log('dismissYakuBanner completed, isYakuDisplayed:', isYakuDisplayed);
      }

      // ===================================================================
      // イベントリスナー(UIボタン / Space / クリック)
      // ===================================================================
      const throwDiceBtn = document.getElementById('throwDiceBtn');
      if (throwDiceBtn) {
        throwDiceBtn.onclick = () => {
          if (isYakuDisplayed) {
            // 表示中はまず消去(連打時の視覚ノイズを抑える)
            dismissYakuBanner();
          }
          throwDice();
        };
      }

      document.addEventListener('keydown', (event) => {
        if (event.code === 'Space') {
          event.preventDefault();
          console.log('Space key pressed, isYakuDisplayed:', isYakuDisplayed);
          if (isYakuDisplayed) {
            dismissYakuBanner();
          } else {
            throwDice();
          }
        }
      });
      
      // 画面タップ(スマホ/PC両対応)でサイコロ投げ
      renderer.domElement.addEventListener('touchstart', (event) => {
        event.preventDefault();
        if (isYakuDisplayed) {
          dismissYakuBanner();
        } else {
          throwDice();
        }
      });
      // PCでもcanvasクリックで投げる(演出表示中は消去)
      renderer.domElement.addEventListener('click', (event) => {
        if (isYakuDisplayed) {
          dismissYakuBanner();
        } else {
          throwDice();
        }
      });

      // 初期化
      updateDiceCount();
      console.log('Three.js/Ammo.js app started');
    } // ---- startApp 終了 ----

    // ================================================================
    // Ammo.jsグローバル変数が定義されているか確認して開始
    // ================================================================
    function waitForAmmo() {
      if (typeof Ammo !== 'undefined') {
        startApp();
      } else {
        setTimeout(waitForAmmo, 30);
      }
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
      waitForAmmo();
    } else {
      window.addEventListener('DOMContentLoaded', waitForAmmo);
    }

  })();
  </script>
  </div>

  <!--
  <div class="instructions">
    <div>・🎲ボタン または スペースキー:投げ入れ</div>
    <div>・停止後、演出表示中に クリック/スペース:演出を消去</div>
  </div>
  -->

  <footer id="adFooter">
    <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-6050835590729534"
       crossorigin="anonymous"></script>
    <ins class="adsbygoogle"
         style="display:block"
         data-ad-client="ca-pub-6050835590729534"
         data-ad-slot="9367265617"
         data-ad-format="auto"
         data-full-width-responsive="true"></ins>
    <script>
         (adsbygoogle = window.adsbygoogle || []).push({});
    </script>
  </footer>
</body>
</html>

 

ABOUT ME
りん
このブログでは、Web開発やプログラミングに関する情報を中心に、私が日々感じたことや学んだことをシェアしています。技術と生活の両方を楽しめるブログを目指して、日常で触れた出来事や本、ゲームの話題も取り入れています。気軽に覗いて、少しでも役立つ情報や楽しいひとときを見つけてもらえたら嬉しいです。