HTML5 CanvasとJavaScriptを使って、ブラウザで動作するジグソーパズルゲームを実装してみました。画像の分割処理からドラッグ&ドロップ、スナップ判定まで、実際に動くコードを書きながら説明していきます。
この記事で実装すること
- HTML5 Canvasを使った画像のグリッド分割
- マウスイベントを利用したドラッグ&ドロップ機能
- 距離計算によるスナップ判定の実装
- 完成状態の自動チェック機能
技術スタックは、HTML、JavaScript、Canvas APIです。jQueryなどのライブラリは使わず、バニラJavaScriptで実装しています。
プロジェクト構成と初期設定
まず、基本的なHTMLファイルを用意します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ジグソーパズルゲーム</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: 'Segoe UI', sans-serif;
background-color: #f5f5f5;
}
#container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#puzzle-area {
position: relative;
margin: 20px 0;
}
.piece {
position: absolute;
border: 2px solid #333;
cursor: grab;
transition: box-shadow 0.2s;
}
.piece:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.piece:active {
cursor: grabbing;
}
.placed {
border-color: #4CAF50;
box-shadow: 0 0 10px rgba(76, 175, 80, 0.5);
}
</style>
</head>
<body>
<div id="container">
<h1>ジグソーパズル</h1>
<div id="puzzle-area"></div>
<div id="status"></div>
</div>
<script src="puzzle.js"></script>
</body>
</html>
Canvas要素は動的に生成するため、HTMLには配置場所(puzzle-area)だけを用意しています。
画像読み込みとグリッド分割の実装
元画像を読み込んで、指定した行数・列数で分割する処理を実装します。
class JigsawPuzzle {
constructor(imageSrc, rows = 3, cols = 3) {
this.sourceImage = new Image();
this.sourceImage.src = imageSrc;
this.rows = rows;
this.cols = cols;
this.pieces = [];
this.activePiece = null;
this.dragOffset = { x: 0, y: 0 };
this.snapThreshold = 30;
this.sourceImage.onload = () => {
this.createPieces();
this.renderPieces();
this.setupEventListeners();
};
}
createPieces() {
const pieceWidth = this.sourceImage.width / this.cols;
const pieceHeight = this.sourceImage.height / this.rows;
for (let row = 0; row < this.rows; row++) {
for (let col = 0; col < this.cols; col++) {
const canvas = document.createElement('canvas');
canvas.width = pieceWidth;
canvas.height = pieceHeight;
canvas.className = 'piece';
const ctx = canvas.getContext('2d');
const sourceX = col * pieceWidth;
const sourceY = row * pieceHeight;
ctx.drawImage(
this.sourceImage,
sourceX, sourceY, pieceWidth, pieceHeight,
0, 0, pieceWidth, pieceHeight
);
this.pieces.push({
canvas: canvas,
correctRow: row,
correctCol: col,
correctX: col * pieceWidth,
correctY: row * pieceHeight,
currentX: Math.random() * 400,
currentY: Math.random() * 400 + 600,
isPlaced: false
});
}
}
// ピースをシャッフル
this.shufflePieces();
}
}
drawImageメソッドの引数について:
- 第1引数:描画元の画像オブジェクト
- 第2〜5引数:元画像から切り出す領域(x, y, width, height)
- 第6〜9引数:Canvasに描画する領域(x, y, width, height)
このメソッドを使うことで、元画像の一部分だけを別のCanvasに描画できます。
ピースをシャッフルする関数も追加しておきます。
shufflePieces() {
for (let i = this.pieces.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.pieces[i], this.pieces[j]] = [this.pieces[j], this.pieces[i]];
}
// シャッフル後の位置をランダムに設定
this.pieces.forEach((piece, index) => {
piece.currentX = Math.random() * 400;
piece.currentY = 600 + Math.random() * 300;
});
}
ドラッグ&ドロップ機能の実装
マウスイベントを使って、ピースをドラッグできるようにします。
ポイントは、マウス座標とピース座標の相対位置(オフセット)を記録することです。これがないと、クリックした瞬間にピースがマウス位置にジャンプしてしまいます。
setupEventListeners() {
this.pieces.forEach(piece => {
piece.canvas.addEventListener('mousedown', (e) => {
this.handleMouseDown(e, piece);
});
});
document.addEventListener('mousemove', (e) => {
this.handleMouseMove(e);
});
document.addEventListener('mouseup', (e) => {
this.handleMouseUp(e);
});
}
handleMouseDown(e, piece) {
if (piece.isPlaced) return; // 配置済みのピースは動かせない
this.activePiece = piece;
const rect = piece.canvas.getBoundingClientRect();
const containerRect = document.getElementById('puzzle-area').getBoundingClientRect();
this.dragOffset.x = e.clientX - rect.left;
this.dragOffset.y = e.clientY - rect.top;
piece.canvas.style.zIndex = '1000';
e.preventDefault();
}
handleMouseMove(e) {
if (!this.activePiece) return;
const containerRect = document.getElementById('puzzle-area').getBoundingClientRect();
const newX = e.clientX - containerRect.left - this.dragOffset.x;
const newY = e.clientY - containerRect.top - this.dragOffset.y;
this.activePiece.canvas.style.left = newX + 'px';
this.activePiece.canvas.style.top = newY + 'px';
this.activePiece.currentX = newX;
this.activePiece.currentY = newY;
}
handleMouseUp(e) {
if (!this.activePiece) return;
this.checkSnap(this.activePiece);
this.activePiece.canvas.style.zIndex = '1';
this.activePiece = null;
}
getBoundingClientRect()を使うと、要素の画面座標系での位置を取得できます。これを利用して、マウス座標(clientX, clientY)と要素の相対位置を計算しています。
スナップ判定の実装
ピースが正しい位置に近づいた時に、自動的に配置するスナップ機能を実装します。
判定方法は、ピースの現在位置と正しい位置の距離を計算して、閾値以下なら配置する、というシンプルなものです。
checkSnap(piece) {
const dx = piece.currentX - piece.correctX;
const dy = piece.currentY - piece.correctY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.snapThreshold) {
piece.canvas.style.left = piece.correctX + 'px';
piece.canvas.style.top = piece.correctY + 'px';
piece.currentX = piece.correctX;
piece.currentY = piece.correctY;
piece.isPlaced = true;
piece.canvas.classList.add('placed');
this.checkCompletion();
}
}
距離計算には、ユークリッド距離の公式(三平方の定理)を使っています。
distance = √((x2 - x1)² + (y2 - y1)²)
snapThresholdの値を調整することで、スナップの感度を変更できます。値が小さいほど厳密になり、大きいほど緩くなります。
実際にプレイしてみて、操作感を調整するのが良いでしょう。
完成判定の実装
全てのピースが正しい位置に配置されたかをチェックする処理を追加します。
checkCompletion() {
const allPlaced = this.pieces.every(piece => piece.isPlaced);
if (allPlaced) {
this.onComplete();
}
}
onComplete() {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = '<h2>🎉 パズル完成!</h2>';
statusDiv.style.color = '#4CAF50';
// 演出を追加
this.pieces.forEach(piece => {
piece.canvas.style.transition = 'transform 0.3s';
setTimeout(() => {
piece.canvas.style.transform = 'scale(1.05)';
setTimeout(() => {
piece.canvas.style.transform = 'scale(1)';
}, 300);
}, Math.random() * 500);
});
}
Array.every()メソッドは、全ての要素が条件を満たしている場合にtrueを返します。全てのピースのisPlacedがtrueになれば、パズルが完成したということです。
ピースの表示処理
作成したピースを画面に配置する処理です。
renderPieces() {
const puzzleArea = document.getElementById('puzzle-area');
puzzleArea.innerHTML = ''; // 既存の要素をクリア
this.pieces.forEach(piece => {
piece.canvas.style.left = piece.currentX + 'px';
piece.canvas.style.top = piece.currentY + 'px';
puzzleArea.appendChild(piece.canvas);
});
}
ピースの初期位置は、シャッフル処理でランダムに設定しています。プレイエリアの下側に配置することで、正しい位置への移動が分かりやすくなります。
使い方と初期化
Puzzleクラスをインスタンス化して、ゲームを開始します。
// ページ読み込み時にゲームを開始
window.addEventListener('DOMContentLoaded', () => {
const puzzle = new JigsawPuzzle('puzzle-image.jpg', 3, 3);
// 難易度を変更したい場合
// const puzzle = new JigsawPuzzle('puzzle-image.jpg', 4, 4); // 4×4グリッド
});
コンストラクタの引数は、画像パス、行数、列数の順です。分割数を増やすと難易度が上がります。
よくある問題とデバッグのコツ
実装中に遭遇しやすい問題と対処法をまとめます。
座標のずれ
Canvas座標と画面座標は基準が異なります。getBoundingClientRect()を使って、要素の画面座標を取得してから計算するのが確実です。
デバッグ時は、座標の値をconsole.logで出力して確認しましょう。
console.log({
mouseX: e.clientX,
mouseY: e.clientY,
rect: piece.canvas.getBoundingClientRect(),
offset: { x: this.dragOffset.x, y: this.dragOffset.y }
});
イベントの伝播
イベントが意図しない要素で発火することがあります。e.stopPropagation()やe.preventDefault()を使って、イベントの伝播を制御します。
z-indexの管理
ドラッグ中のピースが他のピースに隠れるのを防ぐため、z-indexを動的に変更しています。ドラッグ開始時は大きな値、終了時は小さな値に戻すのがポイントです。
パフォーマンスの考慮
ピースの数が増えると、描画処理やイベント処理の負荷が気になる場合があります。
- ピースの数は、用途に応じて3×3〜5×5程度が実用的
- requestAnimationFrameを使った最適化も可能
- タッチイベント対応でモバイルでも動作させる場合は、touchstart/touchmove/touchendも実装する
今後の拡張アイデア
基本機能が動いたら、以下のような機能追加も検討できます。
- タイマー機能:完成までの時間を計測
- スコアシステム:配置速度や精度に応じた点数
- カスタム画像アップロード:任意の画像でパズルを作成
- 難易度選択:分割数やスナップ閾値を変更
- アニメーション:スナップ時のエフェクトを強化
まとめ
HTML5 CanvasとJavaScriptだけで、ブラウザ上で動作するジグソーパズルゲームを実装できました。
主な実装ポイントは以下の通りです。
- Canvas APIのdrawImageメソッドで画像を分割
- マウスイベントと座標計算でドラッグ&ドロップを実現
- 距離計算によるスナップ判定
- Array.every()を使った完成判定
コードはクラスベースで整理しているため、機能追加や保守がしやすくなっています。分割数やスナップ閾値は、用途に応じて調整してください。
Canvas APIやイベント処理の基礎を理解していれば、同様の手法で他のインタラクティブなゲームも実装できるはずです。
HTML5とJavaScriptでのゲーム制作に慣れたら、Unityなどの本格的なゲームエンジンに挑戦してみるのも良いかもしれません。より複雑なゲームシステムや3D表現にも対応できるようになります。ゲーム開発の幅を広げたい方は、Unity入門の森ショップの教材も参考にしてみてください。