前回はC# WindowsFormsでジグソーパズルを作りましたが、今回はJavaScriptでジグソーパズルを作ります。
HTML部分
まずHTML部分を示します。ピースの形状の関係によって端に20ピクセルの隙間ができてしまうので、ボタンを20ピクセル右に寄せています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>鳩でもわかるジグソーパズル</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> #shuffle { width: 100px; margin-left: 20px; } #how-to-play { margin-left: 20px; } #time { margin-left: 20px; font-weight: bold; } </style> </head> <body> <p> <button id = "shuffle" onclick="shuffle()">シャッフル</button> <span id = "time"></span> </p> <p id = "how-to-play">各ピースはドラッグ&ドロップで移動できます。</p> <canvas id="can"></canvas> <script src="./index.js"></script> </body> </html> |
JavaScript部分
主な定数とグローバル変数を示します。ピースのサイズは80ピクセルとします。ピースのサイズとは赤い枠で囲まれている部分です。そのため画像のサイズは120ピクセルになります。
1 2 3 4 5 6 7 8 |
const pieceSize = 80; let can = document.getElementById('can'); let ctx = can.getContext('2d'); let pieces = []; // Pieceオブジェクトを格納する変数 let colMax = 0; // ピースは横に何個並ぶか? let rowMax = 0; // ピースは縦に何個並ぶか? |
Pieceクラスの定義
ピースを描画したり位置情報を管理するためのPieceクラスを定義します。IsClick関数はクリックされた座標が上記の図の赤い矩形内かどうかを返すためのものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class Piece { constructor(image, outline, x, y){ this.Image = image; this.Outline = outline; this.X = x; this.Y = y; this.OriginalCol = Math.round(x / pieceSize); // 本来の位置 this.OriginalRow = Math.round(y / pieceSize); } Draw(){ ctx.drawImage(this.Image, this.X, this.Y); ctx.drawImage(this.Outline, this.X, this.Y); } IsClick(x, y){ let s = pieceSize / 4; if(x < this.X + s) return false; if(this.X + s * 5 < x) return false; if(y < this.Y + s) return false; if(this.Y + s * 5 < y) return false; return true; } } |
ピースの生成
ページが読み込まれたら猫の画像を読み込んで、これを元にピースを生成します。生成されたPieceオブジェクトは配列に格納します。ちなみに管理人は鳩を自称していますが、本当は猫好きです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
window.onload = async() => { let sourceImage = await createSourceImage(); // 後述 // ピースは縦横何列必要か? colMax = Math.floor(sourceImage.width / pieceSize); rowMax = Math.floor(sourceImage.height / pieceSize); // canvasのサイズはピースが占める面積の2倍とする can.width = colMax * pieceSize * 2; can.height = rowMax * pieceSize * 2; pieces = []; for(let row = 0; row < rowMax; row++){ for(let col = 0; col < colMax; col++){ let image = await createPiece(sourceImage, row, col, rowMax, colMax, false); // 後述 let outline = await createPiece(sourceImage, row, col, rowMax, colMax, true); pieces.push(new Piece(image, outline, col * pieceSize, row * pieceSize)); } } drawAll(); // 後述 } |
パズルの元の画像を読み込む処理を示します。image.src = ‘./cat.png’;を実行してから画像が読み込まれるまで時間がかかるのでPromiseオブジェクトを使って完了するまで待機できるようにしています。
1 2 3 4 5 6 7 8 9 |
async function createSourceImage(){ let image = new Image(); return await new Promise(resolve => { image.src = './cat.png'; image.onload =() => { resolve(image); } }); } |
ピースを生成する処理を示します。別のcanvasとコンテキストを生成してピースの形にパスを生成します。ctx.clip();を実行したあとdrawImage関数を実行すればピースの内部だけに画像を描画することができます。あとはこれをbase64に変換し、これをもとに新しいピースを生成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
async function createPiece(sourceImage, row, col, rowMax, colMax, outlineOnly){ let canvas = document.createElement('canvas'); let ctx = canvas.getContext('2d'); let s = pieceSize / 4; canvas.width = s * 6; canvas.height = s * 6; if(ctx == null) return; ctx.beginPath(); ctx.moveTo(s, s); ctx.lineTo(s * 2,s); if(row > 0){ // row == 0 のときは上辺には凹凸をつけない if((row + col) % 2 == 0) ctx.arc(s * 3, s, s, Math.PI, Math.PI * 2, false); // 凸 else ctx.arc(s * 3, s, s, Math.PI, Math.PI * 2, true); // 凹 } ctx.lineTo(s * 5, s); ctx.lineTo(s * 5, s * 2); if(col < colMax - 1){ // col == colMax - 1 のときは右辺には凹凸をつけない if((row + col) % 2 == 1) ctx.arc(s * 5, s * 3, s, Math.PI * 3 / 2, Math.PI / 2, false); // 凸 else ctx.arc(s * 5, s * 3, s, Math.PI * 3 / 2, Math.PI / 2, true); // 凹 } ctx.lineTo(s * 5, s * 5); ctx.lineTo(s * 4, s * 5); if(row < rowMax - 1){ // row == rowMax - 1 のときは下辺には凹凸をつけない if((row + col) % 2 == 0) ctx.arc(s * 3, s * 5, s, Math.PI * 0, Math.PI, false); // 凸 else ctx.arc(s * 3, s * 5, s, Math.PI * 0, Math.PI, true); // 凹 } ctx.lineTo(s, s * 5); ctx.lineTo(s, s * 4); if(col > 0){ // col == 0 のときは左辺には凹凸をつけない if((row + col) % 2 == 1) ctx.arc(s, s * 3, s, Math.PI / 2, Math.PI * 3 / 2, false); // 凸 else ctx.arc(s, s * 3, s, Math.PI / 2, Math.PI * 3 / 2, true); // 凹 } ctx.closePath(); ctx.clip(); ctx.strokeStyle = '#fff'; // 輪郭は白 if(outlineOnly) ctx.stroke(); else ctx.drawImage(sourceImage, s - s * 4 * col, s - s * 4 * row); let base64 = canvas.toDataURL("image/png", 1); // PNGなら"image/png" canvas.remove(); return await createImage(base64); } |
渡されたbase64から新しいピースを生成する処理を示します。これもimage.src = base64;が実行されてから読み込みが完了するまで時間がかかるのでPromiseオブジェクトを生成して待機できるようにしています。
1 2 3 4 5 6 7 8 9 |
async function createImage(base64){ let image = new Image(); return await new Promise(resolve => { image.src = base64; image.onload =() => { resolve(image); } }); } |
ピースの描画
drawAll関数はすべてのピースを描画します。描画の順番によっては現在移動中のピースが他のピースの後ろに隠れてしまうので、この場合だけ最後に再描画しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let movingPiece = null; // 現在移動中のピース let oldX = 0; // 現在移動中のピースの移動前のX座標 let oldY = 0; // 現在移動中のピースの移動前のY座標 function drawAll(){ ctx.clearRect(0, 0, can.width, can.height); // いったん全消去 let s = pieceSize / 4; ctx.strokeStyle = '#000'; ctx.strokeRect(s, s, pieceSize * colMax, pieceSize * rowMax); // 完成形が存在する部分を黒枠で囲む pieces.forEach(piece => { piece.Draw(); }); // 移動中のピースがあれば描画する if(movingPiece != null) movingPiece.Draw(); } |
ピースの移動
ピースをドラッグ&ドロップで移動させる処理を示します。
マウスボタンが押下されたらどのピースがクリックされたかを調べて該当するものが存在する場合はmovingPieceに格納します。そしてもとの座標も保存しておきます。該当するピースが存在しない場合はなにもしません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
let movingPiece = null; // 再掲:現在移動中のピース window.addEventListener('mousedown', (ev) =>{ if(ev.button != 0) // 左クリック以外は無視 return; const rect = can.getBoundingClientRect(); let ps = pieces.filter(piece => piece.IsClick(ev.clientX - rect.left, ev.clientY - rect.top) ); if(ps.length == 0){ console.log('どれもクリックされていない'); return; } console.log(`${ps[0].X}, ${ps[0].Y}`); movingPiece = ps[0]; oldX = ps[0].X; oldY = ps[0].Y; }); |
movingPieceに値が格納されている状態でマウスが移動したときの処理を示します。
マウスポインタの位置に移動中のピースの中央部分が描画されるようにしています。またボタンを押したままマウスポインタをウィンドウの外に移動させるとピースを見えない位置に移動できてしまうので、そうならないように対策をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
window.addEventListener('mousemove', (ev) =>{ if(movingPiece != null){ const rect = can.getBoundingClientRect(); let newX = ev.clientX - rect.left - pieceSize * 0.75; let newY = ev.clientY - rect.top - pieceSize * 0.75; // 見えない位置に移動してしまうのを防ぐ if(newX < - pieceSize / 2) newX = - pieceSize / 2; if(newY < - pieceSize / 2) newY = - pieceSize / 2; if(newX > can.width - pieceSize / 2) newX = can.width - pieceSize / 2; if(newY > can.height - pieceSize / 2) newY = can.height - pieceSize / 2; movingPiece.X = newX; movingPiece.Y = newY; drawAll(); } }); |
マウスボタンを離したときの処理を示します。
ピースが自動的に組み合わさるように完成したパズルが描画される部分へはpieceSizeの整数倍の座標にしか配置されないようにしています。またすでにピースがある部分には置けないようにしています。それ以外の場所であれば自由に配置できるようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
window.addEventListener('mouseup', (ev) =>{ if(movingPiece != null){ let col = Math.round(movingPiece.X / pieceSize); let row = Math.round(movingPiece.Y / pieceSize); if(col < 0) col = 0; if(row < 0) row = 0; if(row < rowMax && col < colMax){ let ps = pieces.filter(_ => _.X == col * pieceSize && _.Y == row * pieceSize); if(ps.length == 0){ movingPiece.X = col * pieceSize; movingPiece.Y = row * pieceSize; } else { movingPiece.X = oldX; movingPiece.Y = oldY; } } movingPiece = null; drawAll(); check(); // 完成したかのチェック(後述) } }); |
シャッフルと完成の判定
ピースをシャッフルする処理を示します。piecesの中身を変数arrのなかへコピーします。そしてランダムにひとつずつ取り出して新しいX座標とY座標を割り当てます。またかかった時間を計測するためにグローバル変数timeを0で初期化して1秒ごとにカウントアップしていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
let timer = null; let time = 0; let $time = document.getElementById('time'); function shuffle(){ let arr = []; pieces.forEach(piece => arr.push(piece)); for(let row = 0; row < rowMax; row++){ for(let col = 0; col < colMax; col++){ let r = Math.floor(Math.random() * arr.length); arr[r].X = col * pieceSize; arr[r].Y = row * pieceSize; arr.splice(r, 1); } } drawAll(); // 時間をカウント time = 0; clearInterval(timer); timer = setInterval(() => { time++; $time.style.color = '#000'; $time.innerHTML = `${time} 秒`; }, 1000); } |
完成したかどうかをチェックする処理を示します。Piece.Check関数がすべてtrueを返せばすべてのピースがあるべき位置に配置されていることになります。この場合はタイマーを停止させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function check(){ let ok = true; for(let i = 0; i < pieces.length; i++){ if(!pieces[i].Check()){ ok = false; break; } } if(!ok){ clearInterval(timer); $time.style.color = '#f00'; // タイムを赤で表示 } } |
はじめまして、こんにちは。
Javascript初学習者です。
Javascriptでゲームをつくることに興味を覚え、ネットでこちらにたどり着きました。
ふんふんなるほどな~と思いつつ勉強させていただいています。
今回一昨日くらいからこちらのジグソーパズルを作成してみたく、手元のVSCodeに打ち込んでみていますが、何度トライしてみても(最終的にはソース丸ごとコピーさせていただいたりしてみても)WEBページにキャンバスが描画されませんでした。
元画像は同じように同階層にネコちゃんのpng画像を格納しています。
F12ツールでみてみても、エラーコードをはじき出したりしていません。シャッフルボタンは正常に表示されておりますが、押下すると「Uncaught TypeError: shuffle is not a function」となります。
おそらくそもそもキャンバスが描画されていないため、対象がないのかなと考えています。
ポンコツなりにいろいろ考えてトライアンドエラーしてみましたが、行き詰ってしまったので、もし何か設定などの部分でアドバイスなどもらえたら嬉しいです。
お忙しいところ突然のコメント失礼いたしました。
「Uncaught TypeError: shuffle is not a function」はshuffleは関数ではない ⇒ shuffle関数が定義されていないというエラーメッセージです。
それからcanvas.toDataURL関数を使っているので実行するときはサーバー上で実行してください。
そうしないとUncaught (in promise) DOMException: The operation is insecure.というエラーがでて何も表示されません。
サーバーがない場合はXAMPPなどでお願いします。
お忙しいところご返信ありがとうございます。
仰る通りサーバー上で実行したところ問題なく動作しました。
お手間をとらせてしまい申し訳ありませんでした。
たいへん助かりました。