JavaScriptで15パズルをつくります。
Contents
HTML部分
まずHTML部分を示します。
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>鳩でもわかる15パズル</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { background-color: black; color: white; } #container { margin: 20px; margin-left: 50px; } #shuffle { width: 100px; margin-left: 20px; } #how-to-play { margin-left: 20px; } </style> </head> <body> <div id = "container"> <p> <button id = "shuffle" onclick="gameStart()">シャッフル</button> <span id = "time"></span> </p> <p id = "how-to-play">空白部分の上下左右のピースをクリックまたはタップすれば移動できます。</p> <canvas id="can"></canvas> </div> <script src="./index.js"></script> </body> </html> |
JavaScript部分
JavaScript部分を示します。
主なグローバル変数と定数は以下のとおりです。
ピースの移動処理がおこなわれているときに別の移動処理が始まらないようにフラグとしてisMovingを定義しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const pieceSize = 80; // 各ピースは80ピクセルの正方形 let can = document.getElementById('can'); let ctx = can.getContext('2d'); let pieces = []; // Pieceオブジェクトを格納する配列 const colMax = 4; // 縦横ともに4列 const rowMax = 4; let moveSound = new Audio('./move.mp3'); // 効果音 let badSound = new Audio('./bad.mp3'); let clearSound = new Audio('./clear.mp3'); let isMoving = false; // ピースが移動中かどうか? |
Pieceクラスの定義
ピースを表示させるためのPieceクラスを定義します。
ひとつの写真を縦横4列ずつ合計16分割し、それぞれを各ピースに描画します。また各ピースがどの部分なのかがわかるように番号も表示させます。
コンストラクタにはピースに描画するイメージ、外枠、何行目何列目に存在するピースなのかの情報を渡します。何行目何列目に存在するかで表示させるべき番号もわかります。
IsClick関数は引数として渡された座標がピースの内部かどうかを調べるためのものです。Check関数は完成しているかどうかの判定に使うものです。各ピースがあるべき位置に配置されている場合はtrueを返します。すべてのオブジェクトがtrueを返せば完成していることになります。
DrawOnlyImage関数は枠なしで描画します。これはパズルが完成したときに画像のみを描画させたいので定義しました。
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 |
class Piece{ constructor(image, outline, col, row){ this.Image = image; this.Outline = outline; this.X = pieceSize * col; this.Y = pieceSize * row; this.Number = row * 4 + col + 1; } Draw(){ ctx.drawImage(this.Image, this.X, this.Y); ctx.drawImage(this.Outline, this.X, this.Y); ctx.fillStyle = '#fff'; ctx.strokeStyle = '#000'; ctx.font= '30px Arial'; ctx.textBaseline = 'top'; ctx.fillText(this.Number, this.X + 10, this.Y + 10); } DrawOnlyImage(){ ctx.drawImage(this.Image, this.X, this.Y); } IsClick(x, y){ if(x < this.X) return false; if(this.X + pieceSize < x) return false; if(y < this.Y) return false; if(this.Y + pieceSize < y) return false; return true; } Check(){ let col = Math.round(this.X / pieceSize) let row = Math.round(this.Y / pieceSize) if(row * 4 + col + 1 == this.Number) return true; else return false; } } |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。
元の画像になるファイルを読み込んで、これをもとにピースを生成します。そして各ピースがすべて生成されたら描画処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
window.onload = async() => { let sourceImage = await createSourceImage(); can.width = colMax * pieceSize + 4; can.height = rowMax * pieceSize + 4; pieces = []; for(let row = 0; row < rowMax; row++){ for(let col = 0; col < colMax; col++){ let image = await createImage(sourceImage, row, col, false); let outline = await createImage(sourceImage, row, col, true); pieces.push(new Piece(image, outline, col, row)); } } drawAll(); } |
元になる画像を取得する処理を示します。これはJavaScriptでジグソーパズルを作るでおこなっている処理とほとんど同じです。
1 2 3 4 5 6 7 8 9 |
async function createSourceImage(){ let image = new Image(); return await new Promise(resolve => { image.src = './image.png'; image.onload =() => { resolve(image); } }); } |
元になる画像から各ピースに描画する画像を取得する処理を示します。
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 |
async function createImage(sourceImage, row, col, outlineOnly){ let canvas = document.createElement('canvas'); let ctx = canvas.getContext('2d'); canvas.width = pieceSize; canvas.height = pieceSize; if(ctx == null) return; ctx.rect(0, 0, pieceSize, pieceSize); ctx.clip(); ctx.strokeStyle = '#fff'; if(outlineOnly) ctx.stroke(); else ctx.drawImage(sourceImage, - pieceSize * col, - pieceSize * row); let base64 = canvas.toDataURL("image/png", 1); // PNGなら"image/png" canvas.remove(); let image = new Image(); return await new Promise(resolve => { image.src = base64; image.onload =() => { resolve(image); } }); } |
各ピースを描画する処理
各ピースを描画する処理を示します。16の番号がふられたピース以外を描画します。16番ピースがある位置は白で描画されます。
1 2 3 4 5 6 7 8 9 10 11 |
function drawAll(){ ctx.clearRect(0, 0, can.width, can.height); ctx.fillStyle = '#fff'; ctx.lineWidth= 4; ctx.fillRect(0, 0, pieceSize * colMax, pieceSize * rowMax); for(let i=0; i < pieces.length; i++){ if(pieces[i].Number != 16) pieces[i].Draw(); } } |
枠なしで16番ピースも含めてすべてのピースを描画する処理を示します。
1 2 3 4 5 6 |
function drawAllOnlyImage(){ ctx.clearRect(0, 0, can.width, can.height); for(let i=0; i<pieces.length; i++) pieces[i].DrawOnlyImage(); } |
移動の処理
ピースがクリックまたはタップされたときの処理を示します。
クリックまたはタップされた座標を取得してonClick関数を呼び出してピースの移動処理をおこないます。ピースが移動中であったりパズルが完成しているときはクリックやタップには反応しません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
let isGameCleared = false; // ゲームクリアしているかどうか? window.addEventListener('mousedown', async(ev) =>{ if(ev.button != 0 || isMoving || isGameCleared) // 左ボタンが押下されたときは ev.button == 0 return; onClick(ev.clientX, ev.clientY); // 後述 }); window.addEventListener('touchstart', (ev) => { if(isMoving || isGameCleared) return; if(ev.touches.length != 1) // 触れている指が複数のときは移動処理をしない return; ev.preventDefault(); // デフォルトイベントをキャンセル let x = ev.touches[0].pageX; // 指が触れている座標を取得 let y = ev.touches[0].pageY; onClick(x, y); // 後述 }); |
クリックまたはタップされた座標にピースがある場合、移動可能であれば移動処理をおこなう処理を示します。移動可能なのはそのピースの上下左右に16番ピースがある場合です。
移動はスライドするように演出をいれたいので非同期処理をしています。
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 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
async function onClick(x, y){ const rect = can.getBoundingClientRect(); let ps = pieces.filter(piece => piece.IsClick(x - rect.left, y - rect.top) ); if(ps.length == 0){ return; } // 16番ピースを取得 let piece16 = pieces.filter(piece => piece.Number == 16)[0]; // 16番ピースが存在するのは何行何列目か? let col16 = piece16.X / pieceSize; let row16 = piece16.Y / pieceSize; // クリックされたピースが存在するのは何行何列目か? let clickedPiece = ps[0]; let clickedCol = clickedPiece.X / pieceSize; let clickedRow = clickedPiece.Y / pieceSize; // 双方の位置関係から移動できない場合は移動不可の効果音を鳴らす // 16番ピースそのものをクリックしてしまった場合も移動不可 if((clickedCol != col16 && row16 != clickedRow) || Math.abs(clickedCol - col16) > 1 || Math.abs(clickedRow - row16) > 1 || clickedPiece.Number == 16) { badSound.currentTime = 0; badSound.play(); return; } // 現在移動中のフラグをセット isMoving = true; // 移動の効果音を鳴らす moveSound.currentTime = 0; moveSound.play(); // 16番ピースの座標をクリックされたピースの座標に変更 piece16.X = clickedCol * pieceSize; piece16.Y = clickedRow * pieceSize; // クリックされたピースを16番ピースの座標にゆっくり移動 let speed = 25; let count = pieceSize / speed; for(let i= 0; i < count - 1; i++){ // 移動しすぎ対策。ループの回数はcount回より1減らす if(clickedRow == row16 + 1) // 上へ移動 clickedPiece.Y -= speed; if(clickedRow == row16 - 1) // 下へ移動 clickedPiece.Y += speed; if(clickedCol == col16 + 1) // 左へ移動 clickedPiece.X -= speed; if(clickedCol == col16 - 1) // 右へ移動 clickedPiece.X += speed; drawAll(); await sleep(60); } // 最後に移動後の座標をセット if(clickedRow == row16 + 1 || clickedRow == row16 - 1) // 上へ移動 clickedPiece.Y = row16 * pieceSize; if(clickedCol == col16 + 1 || clickedCol == col16 - 1) // 左へ移動 clickedPiece.X = col16 * pieceSize; drawAll(); // 移動を完了したのでフラグをクリア isMoving = false; // パズルが完成しているかもしれないのでチェックする // 完成している場合は枠なしで16番ピースも含めて全部描画、タイマーを止めて効果音を鳴らす if(check()){ drawAllOnlyImage(); clearInterval(timer); isGameCleared = true; $time.style.color = '#f00'; clearSound.currentTime = 0; clearSound.play(); } } async function sleep(ms){ await new Promise(resolve => setTimeout(resolve, ms)); } |
パズルの完成をチェックする処理
パズルが完成したかをチェックする処理を示します。各PieceのCheck関数を呼び出し、すべてがtrueを返した場合は完成していると判断できます。
1 2 3 4 5 6 7 |
function check(){ for(let i = 0; i < pieces.length; i++){ if(!pieces[i].Check()) return false; } return true; } |
ゲームを開始する処理
ゲームを開始する処理を示します。
15パズルの場合、ただランダムに並べ替えると完成不能の形になる場合があります。そこでランダムに並べ替えたあと、ほんとうに完成できるのかどうかを調べてできない場合はもう一度ランダムに並べ替える処理をしています。
具体的にどのような形ができてできないかは 8パズル,15パズルの不可能な配置と判定法 | 高校数学の美しい物語 を参照してください。
空白の部分が右下の場合は置換のパリティが偶の場合が完成可能な配置です。
shuffle関数は戻り値として置換のパリティを返すので、これが奇数の場合はシャッフルの処理をもう一度繰り返しています。処理が完了したら16番ピース以外を描画してタイマーのカウントアップを開始しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
let timer = null; let time = 0; let $time = document.getElementById('time'); function gameStart(){ while(true){ if(shuffle() % 2 == 0) break; } drawAll(); time = 0; $time.innerHTML = `${time} 秒`; clearInterval(timer); timer = setInterval(() => { time++; $time.style.color = '#fff'; $time.innerHTML = `${time} 秒`; }, 1000); } |
ピースをシャッフルする処理を示します。ランダムにシャッフルしたあとパリティーのチェックをしています。
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 |
function shuffle(){ // 別の配列に16番ピース以外のpiecesの要素をコピー let arr = []; pieces.forEach(piece => { if(piece.Number != 16) arr.push(piece); }); // 16番ピースを右下に配置 let piece16 = pieces.filter(piece => piece.Number == 16)[0]; piece16.X = (colMax - 1) * pieceSize; piece16.Y = (rowMax - 1) * pieceSize; // 各位置に配置するピースをarrのなかからランダムに抜き出す for(let row = 0; row < rowMax; row++){ for(let col = 0; col < colMax; col++){ if(row == rowMax - 1 && col == colMax - 1) break; let r = Math.floor(Math.random() * arr.length); arr[r].X = col * pieceSize; arr[r].Y = row * pieceSize; arr.splice(r, 1); } } // 最後にパリティーのチェック return parityCheck(pieces); } |
置換のパリティを計算する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
function parityCheck(pieces){ // 左上から順番に各ピースの番号を取得して配列に格納する let numbers = []; for(let row = 0; row < rowMax; row++){ for(let col = 0; col < colMax; col++){ let ps = pieces.filter(piece => piece.X / pieceSize == col && piece.Y / pieceSize == row); numbers.push(ps[0].Number); } } // 配列内の要素を1~16まで順番に並ぶようにするには何回2つの要素を入れ替えればいいか数える let parity = 0; for(let i=1; i<= 16; i++){ if(numbers[i - 1] == i) continue; let ret = numbers.indexOf(i); let n = numbers[i - 1]; numbers[i - 1] = i numbers[ret] = n; parity++; } return parity; } |