JavaScript

Building a 3D Chinchirorin (Japanese Dice Game) with Three.js and Ammo.js: Logic Explained

I wanted to build a realistic Chinchiro in the web browser—so here it is!

This post walks through how I built a 3D Chinchiro dice app using Three.js × Ammo.js.

In this article, I’ll thoroughly explain—with a complete sample code—the following topics:

  • How to model dice with Three.js (3D rendering)
  • How to make them physically roll using Ammo.js (physics simulation)
  • Logic to read the upward faces and determine the hand (e.g., triples, one pair)
  • Adding banner animations, sound effects, and UI

I’ll publish the full finished code and break it down part by part. At the end, you’ll find a complete, copy-and-paste version that runs as-is—feel free to try it out!

UI and Sound Effects Implementation

First, let’s place the UI buttons and the dice throw counter at the top-left corner of the screen.

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

 

Explanation

  • soundToggleBtn → Toggles sound ON/OFF
  • throwDiceBtn → Action to throw dice
  • diceCount → Displays total number of throws

💡 Point

  • Add gloss and animations with CSS to make the UI stand out
  • Adjust button sizes so they’re easy to tap on mobile devices

Next, let’s implement sound ON/OFF toggling with JavaScript.

let isSoundOn = true;
const soundToggleBtn = document.getElementById('soundToggleBtn');
soundToggleBtn.onclick = () => {
  isSoundOn = !isSoundOn;
  soundToggleBtn.textContent = isSoundOn ? '🔊 Dice Sound ON' : '🔇 Dice Sound OFF';
};

💡 Point
By syncing the button state with the variable, it’s simple to determine sound ON/OFF.

Now, prepare multiple free dice sound effects and play one randomly.

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();
}

 

💡 Point

  • Random playback adds a big boost of realism 🎲
  • Multiple dice sounds can even be played simultaneously

 

Creating the Scene with Three.js

Now for the 3D scene setup: camera, lighting, and the “bowl” to catch the dice.

Scene and Camera

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);

 

Instead of looking directly from above, we set the camera at a slightly tilted angle.

Explanation

  • Dark background so dice stand out
  • Camera positioned diagonally, not directly above

💡 Point
A slightly angled viewpoint makes the scene feel much more three-dimensional.

 

Lighting

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);

 

Explanation

  • AmbientLight adjusts overall brightness
  • PointLight creates shadows for depth

💡 Point
Enabling shadows adds a huge boost of realism to dice and bowl.

Bowl (Dice Catcher)

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);

 

Explanation

  • Hemisphere shape to catch dice
  • Semi-transparent to reflect lighting

💡 Point
Not just visuals—this bowl is also used for physics simulation.

 

Implementing Dice Physics with Ammo.js

To make dice roll realistically, we need physics simulation—gravity, collisions, inertia—using Ammo.js.

Physics World Setup

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));

 

Explanation

  • DiscreteDynamicsWorld runs rigid body simulation
  • Gravity set to Earth’s standard: -9.82 m/s²

 

Bowl Physics Registration

const ammoMesh = new Ammo.btTriangleMesh();
// convert three.js geometry to Ammo vertices and 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);

 

Explanation

  • Register hemisphere as a static triangle mesh
  • Bowl doesn’t move when dice collide

💡 Point
Static objects must be registered with mass = 0.

 

Dice (Dynamic Object)

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);

 

Explanation

  • btBoxShape creates cube collision detection
  • setOrigin sets initial position
  • calculateLocalInertia adjusts inertia per mass
  • btRigidBody creates the physical dice object

💡 Point
Randomize initial position/rotation for natural rolls.

 

Dice Creation

Three.js side

The dice are created as cubes, with materials assigned to each face.

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);

 

Explanation

  • BoxGeometry(1,1,1) creates a cube of size 1×1×1.
  • MeshStandardMaterial is a material that reacts to light sources.
  • A mesh combines geometry and material, which is then added to the scene.

💡 Point

  • The dice size must match the Ammo.js collision shape.
  • Adding textures to the materials makes the dice look more realistic.

 

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);

 

Explanation

  • Dice dots are represented in black or red.
  • Only the “1” face is marked in red for added realism.

 

Ammo.js side

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);

 

Explanation

  • Sets mass, friction, and restitution.
  • Ensures dice roll naturally.

💡 Point

  • The balance between friction and restitution greatly affects how the dice behave.

 

Throwing the Dice

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

 

Explanation

  • Throws the dice with random speed and rotation.
  • Adjust speeds for smartphones vs. PCs.

💡 Point

  • Rolling behavior changes depending on how the dice are thrown, directly affecting the user experience.

 

Result Judging and Role Presentation

When the dice results match, the app checks for roles such as “three-of-a-kind” or “one pair,” and displays a flashy banner in the center of the screen.
The CSS animations were carefully crafted for dramatic presentation.

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

  if (Object.values(counts).includes(5)) return 'Five of a kind';
  if (Object.values(counts).includes(4)) return 'Four of a kind';
  if (Object.values(counts).includes(3) && Object.values(counts).includes(2)) return 'Full House';
  if (Object.values(counts).includes(3)) return 'Three of a kind';
  if (Object.values(counts).filter(v=>v===2).length===2) return 'Two Pair';
  if (Object.values(counts).includes(2)) return 'One Pair';
  return 'No Pair';
}

 

Banner Effects (CSS)

Special effects were added for legendary roles such as “all ones,” making the game far more exciting.
The screen shakes, light rays appear, and confetti bursts—definitely worth checking out.

.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;
}

 

Explanation

  • Rare roles like “all ones” are highlighted with legendary animations.
  • Screen shake, light rays, and confetti are synchronized.

💡 Point

  • CSS animations bring festive effects.
  • Syncing banners with sound and camera effects boosts immersion.

 

Summary

  • Use Three.js for 3D rendering and Ammo.js for physics.
  • Matching Three.js geometry with Ammo.js physics shapes is the most challenging part.
  • Dice creation, throwing mechanics, and randomized sounds enhance realism.
  • Adjusting dice textures and lighting improves visual quality.
  • UI and sound effects increase interactivity and immersion.
  • CSS animations alone can create highly flashy effects.

 

Full Source Code

<!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>