CSSとJSで作る、クリックでつかんで振って合計が出る3Dサイコロ

ビジュアル

CSSとJSで作る、クリックでつかんで振って合計が出る3Dサイコロ

投稿日2026/02/15

更新日2026/2/8

サイコロを「クリックで数字が出る」だけにせず、触って遊べるUIにしたい。

このサンプルは、3Dのサイコロをドラッグで持ち上げて振り、手を離すと投げて転がるインタラクションを実装しています。止まったタイミングで出目を自動判定し、合計スコアを表示します。

Preview プレビュー

Code コード

<div id="kumonosu-ui-container">
  <div class="kumonosu-top-bar">
    <p class="kumonosu-bar-text">Dice Magnitude</p>
    <label class="kumonosu-select">
      <select id="kumonosu-dice-count">
        <option value="1">01</option>
        <option value="2">02</option>
        <option value="3" selected>03</option>
        <option value="4">04</option>
        <option value="5">05</option>
      </select>
    </label>
  </div>

  <div id="kumonosu-result-board">
    <span id="kumonosu-total-score">0</span>
    <span class="kumonosu-sub-text" id="kumonosu-detail-score"></span>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;700&family=Syncopate:wght@700&display=swap');
* {
	box-sizing: border-box;
	font-family: "Inter", sans-serif;
	margin: 0;
	padding: 0;
}
body {
	margin: 0;
	overflow: hidden;
	background-color: #0a0a0a;
	user-select: none;
}
#kumonosu-ui-container {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	pointer-events: none;
	display: flex;
	flex-direction: column;
	align-items: center;
	z-index: 10;
}
.kumonosu-top-bar {
	margin-top: 30px;
	background: rgba(255, 255, 255, 0.03);
	backdrop-filter: blur(10px);
	-webkit-backdrop-filter: blur(10px);
	padding: 10px 25px;
	border-radius: 4px;
	border: 1px solid rgba(255, 255, 255, 0.1);
	pointer-events: auto;
	display: flex;
	gap: 20px;
	align-items: center;
	box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.kumonosu-bar-text {
	color: rgba(255, 255, 255, 0.5);
	font-size: 11px;
	letter-spacing: 3px;
	text-transform: uppercase;
	font-weight: 700;
}
#kumonosu-dice-count {
	padding: 6px 12px;
	font-size: 14px;
	border-radius: 2px;
	border: 1px solid rgba(255, 255, 255, 0.2);
	background: transparent;
	color: #00e5ff;
	cursor: pointer;
	outline: none;
	appearance: none;
	-webkit-appearance: none;
	font-weight: 700;
	transition: all 0.2s ease;
	text-align: center;
}
#kumonosu-dice-count:hover {
	border-color: #00e5ff;
	background: rgba(0, 229, 255, 0.1);
}
#kumonosu-result-board {
	margin-top: 60px;
	color: #ffffff;
	font-family: "Syncopate", sans-serif;
	font-size: 80px;
	opacity: 0;
	transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
	text-align: center;
	text-shadow: 0 0 20px rgba(0, 229, 255, 0.3);
	transform: translateY(20px);
}
#kumonosu-result-board.kumonosu-show {
	opacity: 1;
	transform: translateY(0);
}
.kumonosu-sub-text {
	font-family: "Inter", sans-serif;
	font-size: 14px;
	font-weight: 300;
	color: rgba(255, 255, 255, 0.4);
	display: block;
	margin-top: 10px;
	letter-spacing: 4px;
}
canvas {
	display: block;
}
// 直接URLを指定することで単一のスクリプトタグに集約
import * as THREE from "https://esm.sh/three@0.160.0";
import {
	RoundedBoxGeometry
} from "https://esm.sh/three@0.160.0/examples/jsm/geometries/RoundedBoxGeometry.js";
import * as CANNON from "https://esm.sh/cannon-es@0.20.0";
let scene, camera, renderer, world;
let diceObjects = [];
let isHolding = false;
let needsResultCheck = false;
let mouse = new THREE.Vector2();
let raycaster = new THREE.Raycaster();
const FRUSTUM_SIZE = 23;
let dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -15);
const uiResult = document.getElementById("kumonosu-result-board");
const uiTotal = document.getElementById("kumonosu-total-score");
const uiDetail = document.getElementById("kumonosu-detail-score");
const palette = ["#1a1a1a", "#222222", "#2a2a2a", "#333333"];
const commonColors = {
	dots: "#ffffff",
	outline: "#444444",
	shadow: "#000000"
};
init();
animate();

function init() {
	scene = new THREE.Scene();
	scene.background = new THREE.Color("#0a0a0a");
	const aspect = window.innerWidth / window.innerHeight;
	camera = new THREE.OrthographicCamera(
		(FRUSTUM_SIZE * aspect) / -2,
		(FRUSTUM_SIZE * aspect) / 2, FRUSTUM_SIZE / 2, FRUSTUM_SIZE / -2, 1, 1000);
	camera.position.set(50, 50, 50);
	camera.lookAt(0, 0, 0);
	renderer = new THREE.WebGLRenderer({
		antialias: true,
		alpha: true
	});
	renderer.setSize(window.innerWidth, window.innerHeight);
	renderer.setPixelRatio(window.devicePixelRatio);
	renderer.domElement.style.touchAction = 'none';
	document.body.appendChild(renderer.domElement);
	world = new CANNON.World();
	world.gravity.set(0, -40, 0);
	world.broadphase = new CANNON.NaiveBroadphase();
	world.solver.iterations = 20;
	world.allowSleep = true;
	const wallMat = new CANNON.Material();
	const diceMat = new CANNON.Material();
	world.addContactMaterial(new CANNON.ContactMaterial(wallMat, diceMat, {
		friction: 0.1,
		restitution: 0.5
	}));
	createPhysicsWalls(wallMat);
	updateDiceCount(3);
	window.addEventListener("resize", onWindowResize);
	window.addEventListener("mousedown", onInputStart);
	window.addEventListener("mousemove", onInputMove);
	window.addEventListener("mouseup", onInputEnd);
	window.addEventListener("touchstart", onInputStart, {
		passive: false
	});
	window.addEventListener("touchmove", onInputMove, {
		passive: false
	});
	window.addEventListener("touchend", onInputEnd);
	const countSelect = document.getElementById("kumonosu-dice-count");
	if (countSelect) {
		countSelect.addEventListener("change", (e) => {
			updateDiceCount(parseInt(e.target.value));
		});
	}
}

function updateMousePosition(e) {
	let x, y;
	if (e.changedTouches) {
		x = e.changedTouches[0].clientX;
		y = e.changedTouches[0].clientY;
	} else {
		x = e.clientX;
		y = e.clientY;
	}
	mouse.x = (x / window.innerWidth) * 2 - 1;
	mouse.y = -(y / window.innerHeight) * 2 + 1;
}

function onInputStart(e) {
	if (e.target.tagName === "SELECT" || e.target.closest(".kumonosu-top-bar")) return;
	if (e.cancelable) e.preventDefault();
	isHolding = true;
	needsResultCheck = false;
	if (uiResult) uiResult.classList.remove("kumonosu-show");
	updateMousePosition(e);
	diceObjects.forEach(obj => {
		obj.body.wakeUp();
		obj.spinOffset = Math.random() * 100;
		obj.isReturning = false;
	});
}

function onInputMove(e) {
	if (!isHolding) return;
	if (e.cancelable) e.preventDefault();
	updateMousePosition(e);
}

function onInputEnd() {
	if (!isHolding) return;
	isHolding = false;
	releaseDice();
}

function createPhysicsWalls(material) {
	const floorBody = new CANNON.Body({
		mass: 0,
		material: material
	});
	floorBody.addShape(new CANNON.Plane());
	floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
	world.addBody(floorBody);
	const wallDistance = 12;
	const createWall = (x, z, rot) => {
		const body = new CANNON.Body({
			mass: 0,
			material: material
		});
		body.addShape(new CANNON.Plane());
		body.position.set(x, 0, z);
		body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), rot);
		world.addBody(body);
	};
	createWall(wallDistance, 0, -Math.PI / 2);
	createWall(-wallDistance, 0, Math.PI / 2);
	createWall(0, -wallDistance, 0);
	createWall(0, wallDistance, Math.PI);
}

function createVectorDiceTexture(number, colorHex) {
	const size = 256;
	const canvas = document.createElement("canvas");
	canvas.width = size;
	canvas.height = size;
	const ctx = canvas.getContext("2d");
	ctx.fillStyle = colorHex;
	ctx.fillRect(0, 0, size, size);
	ctx.fillStyle = "#ffffff";
	const dotSize = size / 6.5;
	const center = size / 2;
	const q1 = size / 4;
	const q3 = (size * 3) / 4;

	function drawDot(x, y) {
		ctx.beginPath();
		ctx.arc(x, y, dotSize / 2, 0, Math.PI * 2);
		ctx.fill();
	}
	if (number === 1) drawDot(center, center);
	else if (number === 2) {
		drawDot(q1, q1);
		drawDot(q3, q3);
	} else if (number === 3) {
		drawDot(q1, q1);
		drawDot(center, center);
		drawDot(q3, q3);
	} else if (number === 4) {
		drawDot(q1, q1);
		drawDot(q3, q1);
		drawDot(q1, q3);
		drawDot(q3, q3);
	} else if (number === 5) {
		drawDot(q1, q1);
		drawDot(q3, q1);
		drawDot(center, center);
		drawDot(q1, q3);
		drawDot(q3, q3);
	} else if (number === 6) {
		drawDot(q1, q1);
		drawDot(q3, q1);
		drawDot(q1, center);
		drawDot(q3, center);
		drawDot(q1, q3);
		drawDot(q3, q3);
	}
	const tex = new THREE.CanvasTexture(canvas);
	tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
	return tex;
}

function updateDiceCount(count) {
	diceObjects.forEach((obj) => {
		scene.remove(obj.mesh);
		scene.remove(obj.outline);
		scene.remove(obj.shadow);
		world.removeBody(obj.body);
	});
	diceObjects = [];
	if (uiResult) uiResult.classList.remove("kumonosu-show");
	const boxSize = 2.5;
	const geometry = new RoundedBoxGeometry(boxSize, boxSize, boxSize, 3, 0.25);
	const outlineGeo = geometry.clone();
	const shadowGeo = new THREE.CircleGeometry(boxSize * 0.6, 32);
	const shape = new CANNON.Box(new CANNON.Vec3(boxSize / 2, boxSize / 2, boxSize / 2));
	const outlineMat = new THREE.MeshBasicMaterial({
		color: commonColors.outline,
		side: THREE.BackSide
	});
	const shadowMat = new THREE.MeshBasicMaterial({
		color: commonColors.shadow,
		transparent: true,
		opacity: 0.4
	});
	for (let i = 0; i < count; i++) {
		let diceColor;
		if (i === 0) {
			diceColor = "#9b0000";
		} else if (i === 1) {
			diceColor = "#002a7a";
		} else {
			diceColor = palette[Math.floor(Math.random() * palette.length)];
		}
		const diceMaterials = [];
		for (let j = 1; j <= 6; j++) {
			diceMaterials.push(new THREE.MeshBasicMaterial({
				map: createVectorDiceTexture(j, diceColor)
			}));
		}
		const matArray = [
			diceMaterials[0], diceMaterials[5], diceMaterials[1],
			diceMaterials[4], diceMaterials[2], diceMaterials[3]
		];
		const mesh = new THREE.Mesh(geometry, matArray);
		scene.add(mesh);
		const outline = new THREE.Mesh(outlineGeo, outlineMat);
		outline.position.copy(mesh.position);
		outline.scale.setScalar(1.02);
		scene.add(outline);
		const shadow = new THREE.Mesh(shadowGeo, shadowMat);
		shadow.rotation.x = -Math.PI / 2;
		shadow.position.y = 0.01;
		scene.add(shadow);
		const startX = (i - (count - 1) / 2) * 3.5;
		const body = new CANNON.Body({
			mass: 5,
			shape: shape,
			position: new CANNON.Vec3(startX, boxSize, 0),
			sleepSpeedLimit: 0.5
		});
		body.quaternion.setFromEuler(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
		world.addBody(body);
		diceObjects.push({
			mesh,
			outline,
			shadow,
			body,
			spinOffset: 0,
			isReturning: false
		});
	}
}

function releaseDice() {
	const SAFE_LIMIT = 9;
	diceObjects.forEach((obj) => {
		const {
			body
		} = obj;
		const isOutside = Math.abs(body.position.x) > SAFE_LIMIT || Math.abs(body.position.z) > SAFE_LIMIT;
		if (isOutside) {
			obj.isReturning = true;
		} else {
			body.wakeUp();
			applyThrowForce(body);
		}
	});
	setTimeout(() => {
		needsResultCheck = true;
	}, 500);
}

function applyThrowForce(body) {
	const xDist = -body.position.x;
	const zDist = -body.position.z;
	body.velocity.set(xDist * 1.8 + (Math.random() - 0.5) * 20, -20 - Math.random() * 10, zDist * 1.8 + (Math.random() - 0.5) * 20);
	body.angularVelocity.set(
		(Math.random() - 0.5) * 40,
		(Math.random() - 0.5) * 40,
		(Math.random() - 0.5) * 40);
}

function calculateResult() {
	let total = 0;
	let details = [];
	const faceNormals = [
		new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
		new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0),
		new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1)
	];
	const faceValues = [1, 6, 2, 5, 3, 4];
	diceObjects.forEach(({
		mesh
	}) => {
		let maxDot = -Infinity;
		let resultValue = 1;
		faceNormals.forEach((normal, index) => {
			const worldNormal = normal.clone().applyQuaternion(mesh.quaternion);
			if (worldNormal.y > maxDot) {
				maxDot = worldNormal.y;
				resultValue = faceValues[index];
			}
		});
		total += resultValue;
		details.push(resultValue);
	});
	if (uiTotal) uiTotal.innerText = total;
	if (uiDetail) uiDetail.innerText = details.length > 1 ? `LOG: [ ${details.join(" | ")} ]` : "";
	if (uiResult) uiResult.classList.add("kumonosu-show");
	needsResultCheck = false;
}

function animate() {
	requestAnimationFrame(animate);
	if (isHolding) {
		raycaster.setFromCamera(mouse, camera);
		const targetPoint = new THREE.Vector3();
		const intersect = raycaster.ray.intersectPlane(dragPlane, targetPoint);
		if (intersect) {
			const time = performance.now() * 0.01;
			diceObjects.forEach((obj, i) => {
				const offsetX = Math.sin(time + i) * 1.2;
				const offsetZ = Math.cos(time + i * 2) * 1.2;
				obj.body.position.x += (targetPoint.x + offsetX - obj.body.position.x) * 0.2;
				obj.body.position.y += (15 - obj.body.position.y) * 0.2;
				obj.body.position.z += (targetPoint.z + offsetZ - obj.body.position.z) * 0.2;
				obj.body.quaternion.setFromEuler(time * 3 + obj.spinOffset, time * 2, time * 1.5);
				obj.body.velocity.set(0, 0, 0);
				obj.body.angularVelocity.set(0, 0, 0);
				obj.isReturning = false;
			});
		}
	} else {
		const time = performance.now() * 0.01;
		diceObjects.forEach((obj) => {
			if (obj.isReturning) {
				obj.body.position.x += (0 - obj.body.position.x) * 0.15;
				obj.body.position.z += (0 - obj.body.position.z) * 0.15;
				obj.body.position.y += (12 - obj.body.position.y) * 0.1;
				obj.body.quaternion.setFromEuler(time * 5, time * 5, 0);
				obj.body.velocity.set(0, 0, 0);
				obj.body.angularVelocity.set(0, 0, 0);
				if (Math.abs(obj.body.position.x) < 9 && Math.abs(obj.body.position.z) < 9) {
					obj.isReturning = false;
					obj.body.wakeUp();
					applyThrowForce(obj.body);
				}
			}
		});
		world.step(1 / 60);
	}
	for (let i = 0; i < diceObjects.length; i++) {
		const {
			mesh,
			outline,
			shadow,
			body
		} = diceObjects[i];
		mesh.position.copy(body.position);
		mesh.quaternion.copy(body.quaternion);
		outline.position.copy(mesh.position);
		outline.quaternion.copy(mesh.quaternion);
		shadow.position.x = body.position.x;
		shadow.position.z = body.position.z;
		const height = Math.max(0, body.position.y - 1);
		const scale = Math.max(0.4, 1 - height * 0.05);
		const opacity = Math.max(0, 0.4 - height * 0.02);
		shadow.scale.setScalar(scale);
		shadow.material.opacity = opacity;
	}
	if (needsResultCheck) {
		let allStopped = true;
		for (let o of diceObjects) {
			if (o.isReturning) {
				allStopped = false;
				break;
			}
			if (o.body.velocity.lengthSquared() > 0.1 || o.body.angularVelocity.lengthSquared() > 0.1) {
				allStopped = false;
				break;
			}
		}
		if (allStopped) calculateResult();
	}
	renderer.render(scene, camera);
}

function onWindowResize() {
	const aspect = window.innerWidth / window.innerHeight;
	camera.left = (-FRUSTUM_SIZE * aspect) / 2;
	camera.right = (FRUSTUM_SIZE * aspect) / 2;
	camera.top = FRUSTUM_SIZE / 2;
	camera.bottom = -FRUSTUM_SIZE / 2;
	camera.updateProjectionMatrix();
	renderer.setSize(window.innerWidth, window.innerHeight);
}

Explanation 詳しい説明

仕様

Three.jsは「見た目」、cannon-esは「物理」を担当し、毎フレームで物理ボディの位置・回転をメッシュへ同期する構成です。カメラは正射影(Orthographic)なので、奥行きの歪みが少なく、UIっぽい見た目で安定します。

ドラッグ中はレイと平面の交点を追従ターゲットにし、サイコロを「宙でまとまって揺れる」ように移動させています。この間は速度・角速度をゼロにして物理の影響を止め、見た目を意図通りにコントロールしています。リリースすると各サイコロに速度と角速度を与えて投げ、壁(平面)と床の中で転がるようにしています。

停止判定は「速度・角速度が小さくなったか」で行い、完全に止まったら上向き面を法線ベクトルの最大値で判定して出目に変換します。結果は合計値+複数個の内訳ログとして表示されます。

  • Three.js(表示)+cannon-es(物理)を同期
  • ドラッグ中は物理を止めて追従&回転演出
  • リリース時に投げる力(速度・角速度)を付与
  • 停止後に上面を判定して合計と内訳を表示

カスタム

挙動を変えるなら、まず「重力」「投げる力」「壁の反発」を触ると変化が分かりやすいです。UI面はダイス数セレクトと結果表示をCSSで整えているため、色やフォント、配置は独立して調整できます。

  • サイコロ数:<select id="kumonosu-dice-count"> の選択肢
  • 重力:world.gravity.set(0, -40, 0)
  • 転がり方:ContactMaterialfriction / restitution
  • 投げる勢い:applyThrowForce() 内の velocity / angularVelocity
  • つかんでいる感:ドラッグ時の追従係数 * 0.2 や高さ 15

見た目は、Canvasテクスチャで目を描いているので、点のサイズや配置、面色を差し替えるだけでデザインを変えられます。影は円形メッシュを床に置いて、高さに応じてスケールと不透明度を変えています。

注意点

ドラッグ中は物理を強制的に止めて位置を上書きしているため、現実の「つかんで投げる」挙動とは少し違う動きになります。自然さを上げたい場合は、ドラッグ中も物理を生かしてスプリング(拘束)で引っ張る方式にすると改善できます。

また、出目判定は「上方向に最も向いている面」を選ぶ方式なので、サイコロが完全に斜めで止まったり、微小に揺れていると結果確定が遅れることがあります。停止しきい値(0.1)や判定待ちのタイミング(setTimeout 500ms)は、端末性能や演出に合わせて調整すると安定します。

  • ドラッグ中は物理を止めている(より自然にするなら拘束方式)
  • 停止判定の閾値次第で結果確定が早すぎ/遅すぎになる
  • 物理演算は端末差が出やすい(反発・回転の値は要調整)
  • リサイズ時はカメラ更新+レンダラー更新が必須(実装済み)