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>