その他

Three.jsとGSAPで作る、画像を使ったスライドパズル

投稿日2026/05/28

更新日2026/5/14

おなじみのスライドパズルを、Three.jsの3D空間上でドラッグ操作で遊べるようにしたデモです。

1枚の画像を3×3の9マスに分割し、右下の1マスを空きスペースにして、隣接するピースをドラッグして入れ替えながら元の画像を完成させます。

ピースの移動にはGSAPのアニメーションを使い、持ち上げ→移動→スナップの動きがなめらかです。

右上に完成図のプレビューが常に表示されるので、目標を確認しながら遊べます。Webサイトのインタラクティブコンテンツやミニゲーム演出に活用できます。

Code コード

<div id="kumonosu-container">
	<div id="kumonosu-preview">
		<img id="kumonosu-previewImage" src="" alt="Target">
	</div>
	<div id="kumonosu-footer">
		<button id="kumonosu-shuffleBtn">Shuffle</button>
	</div>
	<div id="kumonosu-hint">ピースを右下の空きスペースへ動かしてください</div>
	<div id="kumonosu-message"></div>
</div>
@import url(https://fonts.googleapis.com/css2?family=Inter:wght@600;700&family=Noto+Sans+JP:wght@500;700&display=swap);
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
	-webkit-tap-highlight-color: transparent;
	touch-action: none;
	/* ブラウザのスクロール等を無効化 */
}
body {
	font-family: "Inter", "Noto Sans JP", sans-serif;
	overflow: hidden;
	background: #0a0a0a;
	height: 100vh;
	width: 100vw;
}
#kumonosu-container {
	position: relative;
	width: 100%;
	height: 100%;
}
#kumonosu-preview {
	position: absolute;
	top: 20px;
	right: 20px;
	width: 90px;
	height: 90px;
	background: rgba(0, 0, 0, 0.5);
	border-radius: 8px;
	overflow: hidden;
	border: 2px solid rgba(255, 255, 255, 0.2);
	z-index: 10;
}
#kumonosu-preview img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	display: block;
}
#kumonosu-footer {
	position: absolute;
	bottom: 50px;
	left: 0;
	width: 100%;
	display: flex;
	justify-content: center;
	z-index: 20;
}
#kumonosu-shuffleBtn {
	background: #4a9eff;
	color: #fff;
	border: none;
	padding: 16px 50px;
	border-radius: 50px;
	cursor: pointer;
	font-size: 18px;
	font-weight: 700;
	box-shadow: 0 4px 20px rgba(74, 158, 255, 0.4);
}
#kumonosu-hint {
	position: absolute;
	bottom: 20px;
	width: 100%;
	text-align: center;
	color: rgba(255, 255, 255, 0.5);
	font-size: 12px;
	z-index: 10;
}
#kumonosu-message {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	background: rgba(20, 20, 20, 0.9);
	color: #fff;
	padding: 20px 40px;
	border-radius: 12px;
	opacity: 0;
	transition: opacity 0.3s;
	z-index: 100;
	text-align: center;
	pointer-events: none;
}
#kumonosu-message.kumonosu-show {
	opacity: 1;
}
canvas {
	display: block;
	width: 100%;
	height: 100%;
	cursor: grab;
}
const IMAGE_URL = "https://images.unsplash.com/photo-1570913149827-d2ac84ab3f9a?w=800&h=800&fit=crop";
let scene, camera, renderer;
let puzzlePieces = [];
let draggedPiece = null;
const gridSize = 3;
const pieceSize = 1;
const EMPTY_GOAL = {
	r: 2,
	c: 2
};
let emptyPos = {
	...EMPTY_GOAL
};
let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
let dragPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
let planeIntersect = new THREE.Vector3();
let dragOffset = new THREE.Vector3();
async function init() {
	document.getElementById('kumonosu-previewImage').src = IMAGE_URL;
	scene = new THREE.Scene();
	scene.background = new THREE.Color(0x0a0a0a);
	camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
	camera.position.set(0, 0, 6);
	camera.lookAt(0, 0, 0);
	renderer = new THREE.WebGLRenderer({
		antialias: true,
		alpha: true
	});
	renderer.setPixelRatio(window.devicePixelRatio);
	renderer.setSize(window.innerWidth, window.innerHeight);
	renderer.outputColorSpace = THREE.SRGBColorSpace;
	document.getElementById('kumonosu-container').appendChild(renderer.domElement);
	await createPuzzle();
	window.addEventListener('resize', onResize);
	document.getElementById('kumonosu-shuffleBtn').addEventListener('click', shufflePieces);
	// イベント登録
	renderer.domElement.addEventListener('pointerdown', onPointerDown);
	window.addEventListener('pointermove', onPointerMove);
	window.addEventListener('pointerup', onPointerUp);
	animate();
}
async function createPuzzle() {
	const img = new Image();
	img.crossOrigin = "anonymous";
	const fullImage = await new Promise(resolve => {
		img.onload = () => resolve(img);
		img.onerror = () => resolve(null);
		img.src = IMAGE_URL;
	});
	for (let row = 0; row < gridSize; row++) {
		for (let col = 0; col < gridSize; col++) {
			if (row === EMPTY_GOAL.r && col === EMPTY_GOAL.c) continue;
			const piece = createPiece(row, col, fullImage);
			puzzlePieces.push(piece);
			scene.add(piece);
		}
	}
	setTimeout(shufflePieces, 500);
}

function createPiece(row, col, fullImage) {
	const canvas = document.createElement('canvas');
	const ctx = canvas.getContext('2d');
	canvas.width = 256;
	canvas.height = 256;
	if (fullImage) {
		const sw = fullImage.width / gridSize;
		const sh = fullImage.height / gridSize;
		ctx.drawImage(fullImage, col * sw, row * sh, sw, sh, 0, 0, 256, 256);
	}
	const texture = new THREE.CanvasTexture(canvas);
	texture.colorSpace = THREE.SRGBColorSpace;
	const geometry = new THREE.BoxGeometry(pieceSize * 0.98, pieceSize * 0.98, 0.1);
	const sideMat = new THREE.MeshBasicMaterial({
		color: 0x222222
	});
	const frontMat = new THREE.MeshBasicMaterial({
		map: texture
	});
	const materials = [sideMat, sideMat, sideMat, sideMat, frontMat, sideMat];
	const piece = new THREE.Mesh(geometry, materials);
	piece.userData = {
		origRow: row,
		origCol: col,
		currRow: row,
		currCol: col
	};
	updatePosition(piece, true);
	return piece;
}

function updatePosition(piece, instant = false) {
	const tx = (piece.userData.currCol - gridSize / 2 + 0.5) * pieceSize;
	const ty = (gridSize / 2 - 0.5 - piece.userData.currRow) * pieceSize;
	if (instant) {
		piece.position.set(tx, ty, 0);
	} else {
		gsap.to(piece.position, {
			x: tx,
			y: ty,
			z: 0,
			duration: 0.3,
			ease: "power2.out"
		});
	}
}

function shufflePieces() {
	let moves = 0;
	while (moves < 100) {
		const adjacents = puzzlePieces.filter(p => Math.abs(p.userData.currRow - emptyPos.r) + Math.abs(p.userData.currCol - emptyPos.c) === 1);
		const pick = adjacents[Math.floor(Math.random() * adjacents.length)];
		const tr = pick.userData.currRow,
			tc = pick.userData.currCol;
		pick.userData.currRow = emptyPos.r;
		pick.userData.currCol = emptyPos.c;
		emptyPos.r = tr;
		emptyPos.c = tc;
		moves++;
	}
	while (emptyPos.c < EMPTY_GOAL.c) slideLogic(emptyPos.r, emptyPos.c + 1);
	while (emptyPos.r < EMPTY_GOAL.r) slideLogic(emptyPos.r + 1, emptyPos.c);
	puzzlePieces.forEach(p => updatePosition(p));
	showMessage("Shuffled!", 1000);
}

function slideLogic(r, c) {
	const piece = puzzlePieces.find(p => p.userData.currRow === r && p.userData.currCol === c);
	if (piece) {
		const tr = piece.userData.currRow,
			tc = piece.userData.currCol;
		piece.userData.currRow = emptyPos.r;
		piece.userData.currCol = emptyPos.c;
		emptyPos.r = tr;
		emptyPos.c = tc;
	}
}

function onPointerDown(e) {
	updateMouse(e);
	raycaster.setFromCamera(mouse, camera);
	const intersects = raycaster.intersectObjects(puzzlePieces);
	if (intersects.length > 0) {
		const piece = intersects[0].object;
		if (Math.abs(piece.userData.currRow - emptyPos.r) + Math.abs(piece.userData.currCol - emptyPos.c) === 1) {
			draggedPiece = piece;
			raycaster.ray.intersectPlane(dragPlane, planeIntersect);
			dragOffset.copy(draggedPiece.position).sub(planeIntersect);
			gsap.to(draggedPiece.position, {
				z: 0.2,
				duration: 0.1
			});
			document.body.style.cursor = "grabbing";
		}
	}
}

function onPointerMove(e) {
	if (!draggedPiece) return;
	updateMouse(e);
	raycaster.setFromCamera(mouse, camera);
	if (raycaster.ray.intersectPlane(dragPlane, planeIntersect)) {
		draggedPiece.position.x = planeIntersect.x + dragOffset.x;
		draggedPiece.position.y = planeIntersect.y + dragOffset.y;
	}
}

function onPointerUp() {
	if (!draggedPiece) return;
	const ex = (emptyPos.c - gridSize / 2 + 0.5) * pieceSize;
	const ey = (gridSize / 2 - 0.5 - emptyPos.r) * pieceSize;
	const dist = Math.sqrt(Math.pow(draggedPiece.position.x - ex, 2) + Math.pow(draggedPiece.position.y - ey, 2));
	if (dist < 0.6) {
		const tr = draggedPiece.userData.currRow,
			tc = draggedPiece.userData.currCol;
		draggedPiece.userData.currRow = emptyPos.r;
		draggedPiece.userData.currCol = emptyPos.c;
		emptyPos.r = tr;
		emptyPos.c = tc;
	}
	updatePosition(draggedPiece);
	if (puzzlePieces.every(p => p.userData.currRow === p.userData.origRow && p.userData.currCol === p.userData.origCol)) {
		setTimeout(() => showMessage("Completed!", 3000), 400);
	}
	draggedPiece = null;
	document.body.style.cursor = "default";
}

function updateMouse(e) {
	mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
	mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
}

function showMessage(txt, dur) {
	const m = document.getElementById('kumonosu-message');
	m.textContent = txt;
	m.classList.add('kumonosu-show');
	if (dur) setTimeout(() => m.classList.remove('kumonosu-show'), dur);
}

function onResize() {
	camera.aspect = window.innerWidth / window.innerHeight;
	camera.updateProjectionMatrix();
	renderer.setSize(window.innerWidth, window.innerHeight);
}

function animate() {
	requestAnimationFrame(animate);
	renderer.render(scene, camera);
}
init();
https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.min.js
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js

Explanation 詳しい説明

仕様

このデモは、Three.jsで3Dボックス型のパズルピースを描画し、ポインターイベントとRaycasterを使ったドラッグ操作でスライドパズルを実現しています。

主な機能は以下のとおりです。

  • 画像分割: 1枚の画像を3×3のグリッドに分割し、各ピースをCanvasで切り出してテクスチャとしてBoxGeometryの前面に貼り付けます。右下(3行3列目)が空きマスです。
  • ドラッグ移動: 空きマスに隣接するピースのみドラッグ可能です。Raycasterでクリック判定を行い、ドラッグ中はマウス/指の位置に追従します。
  • スナップ判定: ドラッグを離した時点で空きマスとの距離が一定以内であれば入れ替えが成立し、そうでなければ元の位置に戻ります。
  • GSAPアニメーション: ピースの移動やスナップにGSAPのpower2.outイージングを使い、なめらかな動きを実現しています。ドラッグ開始時にはピースがわずかに浮き上がる演出もあります。
  • シャッフル: 「Shuffle」ボタンで100回のランダム移動を行い、その後空きマスを右下に戻した状態でスタートします。初期表示時にも自動でシャッフルされます。
  • 完成判定: すべてのピースが元の位置に戻ると「Completed!」メッセージが表示されます。
  • プレビュー表示: 右上に完成図の小さなサムネイルが常に表示され、目標を確認しながら遊べます。
  • タッチ対応: Pointer Eventsを使用しているため、PC・スマホ・タブレットで動作します。

カスタマイズ

  • 画像の変更: IMAGE_URLを差し替えるだけで、任意の画像でパズルを作成できます。正方形の画像が最適です。
  • グリッドサイズ: gridSizeの値を変更すると、4×4や5×5などより難易度の高いパズルにできます。ただし空きマスの位置(EMPTY_GOAL)も合わせて調整が必要です。
  • ピースの厚み: BoxGeometryの第3引数(デフォルト0.1)を変更すると、ピースの奥行きが変わります。
  • ピースの隙間: pieceSize * 0.980.98を小さくするとピース間の隙間が広がり、1.0にすると隙間がなくなります。
  • シャッフル回数: shufflePieces内の100を変更すると、シャッフルの深さ(難易度)を調整できます。
  • スナップ距離: onPointerUp内のdist < 0.6の値を大きくするとスナップしやすくなり、小さくすると正確な位置への移動が求められます。
  • 背景色: scene.backgroundやCSSのbodybackgroundを変更すれば、全体の雰囲気を変えられます。
  • ボタンのスタイル: #kumonosu-shuffleBtnのCSS(色・角丸・影)を変更すると、サイトのデザインに合わせられます。

注意点

  • Three.jsとGSAPの読み込み: CDN(cdnjs.cloudflare.com)からThree.js r160とGSAP 3.12.5を読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。
  • 画像のCORS: UnsplashからcrossOrigin = "anonymous"で読み込んでいます。CORS非対応のサーバーの画像ではテクスチャ生成に失敗します。
  • touch-action: none: CSS全体にtouch-action: noneを設定しているため、ブラウザのスクロールやピンチズームが無効化されます。ページ内の一部として組み込む場合は、対象要素のみに限定してください。
  • 正方形以外の画像: 正方形でない画像を使うと、ピースの表示が歪む場合があります。URLパラメータでfit=cropを指定するか、事前にトリミングしておくことを推奨します。
  • グリッドサイズ変更時の注意: gridSizeを変更する場合、カメラのposition.zも調整しないとパズル全体が画面に収まらなくなる場合があります。
  • パフォーマンス: 3×3(8ピース)では軽量ですが、グリッドサイズを大きくするとメッシュ数が増えるため、低スペック端末ではパフォーマンスに注意してください。