前回、8パズルの最短手数のもとめる処理を考えたのでこれでゲームを作ってみることにします。8パズルは15ゲームの簡略版であり簡単なので、最短手数以外は不正解とし、どこまで解くことができるかを競うゲームとします。実際にXで公開してみたところ、パズルガチ勢が多数参加し、全問クリアされてしまいました。パズルガチ勢恐るべし・・・
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>最短手順で完成させろ!鳩でもわかる8パズル</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <div id = "field"> <div id = "field-header"> <div id = "question"></div> <div id = "move-count"></div><div id = "time"></div> </div> <div id = "canvas-outer"></div> <div id = "ctrl-buttons"> <button id = "reset">最初から</button> <button id = "giveup">ギブアップ</button> </div> <div id = "volume"></div> <div id = "start-buttons"> <p><label for="player-name">プレイヤー名:</label> <input id = "player-name" maxlength="32"></p> <p><button id = "start">START</button></p> <p><button id = "go-ranking" onclick="location.href = './ranking.html'">ランキング</button></p> </div> </div> </div> <script src = "./app.js"></script> </body> </html> |
style.css
|
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 83 84 85 86 87 88 89 90 |
body { background-color: black; color: white; } #container { width: 360px; margin-left: auto; margin-right: auto; margin-top: 10px; border: 0px solid #1DA1F2; } #field { width: 300px; height: 460px; margin-left: auto; margin-right: auto; position: relative; } #field-header { margin-bottom: 16px; font-size: large; } #question { float: left; } #move-count { float: right; } #time { text-align: right; clear: both; } #canvas-outer { width: 300px; } #start-buttons { position: absolute; left: 0px; top: 120px; width: 100%; text-align: center; } #start, #go-ranking { font-weight: bold; text-align: center; font-size: 18px; width: 280px; border: none; font-size: 16px; background-color: #1DA1F2; padding: 10px 32px; border-radius: 100vh; color: white; cursor: pointer; } #ctrl-buttons { margin-top: 8px; margin-bottom: 16px; display: none; } #reset, #giveup { width: 120px; height: 40px; margin-right: 20px; } .red { color: red; font-weight: bold; } .aqua { color: aqua; font-weight: bold; } #giveup { display: inline; } |
グローバル変数
グローバル変数を示します。
1~9のピースの初期配置を’1’~’9’で構成される文字列(2次元配列を通常の配列に変換したもの)で表し、これをquestions[0]~questions[31]に格納します(詳しくは後述)。そしてquestions[4], questions[8], questions[10], それ以降は連番から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 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 |
const CANVAS_WIDTH = 300; const CANVAS_HEIGHT = 300; const PIECE_SIZE = 100; const ROW_COL_COUNT = 3; // 8パズルなので3行3列 const questions = []; // 問題が格納される配列 let pieces = []; // 描画用のPieceオブジェクトの配列 let moving = false; // 現在移動処理中なので操作不可 let move_limits = []; // 「◯手で完成させよ」の課題手数の配列 let move_count = 0; // 各ステージで実際に移動させた回数 let cur_question = ''; // 現在の問題 let playing = false; // 現在プレイ中か? let start_time = Date.now(); // 経過時間測定用 let timer_moving = false; // 現在経過時間計測中か? let time_sum = 0; // これまでにクリアしたステージの消費時間の総和 const INIT_CONGRATULATION_Y = 400; // 全ステージクリア時に表示される文字列「全問クリア」の表示位置 let congratulation_y = INIT_CONGRATULATION_Y; // DOM要素 const $canvas = document.createElement('canvas'); document.getElementById('canvas-outer')?.appendChild($canvas); $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; const ctx = $canvas.getContext('2d'); const $player_name = document.getElementById('player-name'); // 現在解いている問題に関する情報を表示させる const $question = document.getElementById('question'); const $move_count = document.getElementById('move-count'); const $time = document.getElementById('time'); const $start_buttons = document.getElementById('start-buttons'); const $ctrl_buttons = document.getElementById('ctrl-buttons'); // ピースに描画される画像 const img = new Image(); img.src = './hatoko-300px.png'; // 効果音 const select_sound = new Audio('./sounds/select.mp3'); const ng_sound = new Audio('./sounds/ng.mp3'); const miss_sound = new Audio('./sounds/miss.mp3'); const clear_sound = new Audio('./sounds/clear.mp3'); const congratulation_sound = new Audio('./sounds/congratulation.mp3'); const sounds = [select_sound, ng_sound, miss_sound, clear_sound, congratulation_sound]; |
Pieceクラスの定義
各ピースを描画するためのクラスを定義します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Piece { constructor(){ this.X = 0; // ピースの描画位置とビースに書かれている番号 this.Y = 0; this.Number = 0; } Draw(){ // 画像から一部を切り取ったものを描画して番号と枠をつける ctx.fillStyle = '#f0f'; ctx.fillRect(this.X, this.Y, PIECE_SIZE, PIECE_SIZE); const num = this.Number - 1; ctx.drawImage(img, 100 * (num % ROW_COL_COUNT), 100 * Math.floor(num / ROW_COL_COUNT), PIECE_SIZE, PIECE_SIZE, this.X, this.Y, PIECE_SIZE, PIECE_SIZE); ctx.fillStyle = '#fff'; ctx.strokeStyle = '#000'; ctx.textBaseline = 'top'; ctx.font = 'bold 36px Arial'; ctx.fillText(this.Number, this.X + 10, this.Y + 10); ctx.strokeText(this.Number, this.X + 10, this.Y + 10); ctx.strokeStyle = '#fff'; ctx.strokeRect(this.X, this.Y, PIECE_SIZE, PIECE_SIZE); } } |
QueueStackクラスの定義
幅優先探索の処理をするのですが、JavaScriptにはQueueクラスがないので自分で実装します。通常の配列だとpushやpopの動作は速いのですが、配列の先頭や途中に要素を挿入したり削除する処理をしようとすると時間がかかります。
なのでEnqueue用とDequeue用のふたつの配列を定義して、DequeueするときはDequeue用の配列からpopし、もしこれが空のときはEnqueue用の配列の要素を全部Dequeue用の配列に移動させてからpopします。移動させるときに前後関係が入れ替わるので、先頭の要素を取り出すときに時間がかかるunshift関数ではなくpop関数で代用できるのがポイントです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class QueueStack { constructor(){ this.stackPush = []; this.stackPop = []; } Enqueue(value) { this.stackPush.push(value); } Count = () => this.stackPop.length + this.stackPush.length; Dequeue() { if (this.stackPop.length === 0) { while (this.stackPush.length > 0) this.stackPop.push(this.stackPush.pop()); } return this.stackPop.pop(); } } |
ページが読み込まれたときの処理
ページが読み込まれたときにおこなわれる処理を示します。
|
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 |
window.addEventListener('load', () => { // ローカルストレージに過去のプレイで入力されたプレイヤー名が存在するなら設定する // これまで同様の定番の処理 const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName && $player_name != null) $player_name.value = savedName; $player_name?.addEventListener('change', () => localStorage.setItem('hatodemowakaru-player-name', $player_name.value )); addEventListeners(); // イベントリスナの追加(後述) const start = '123456789'; // この文字列をシャッフルして問題をつくる const map = bfs(start); // 幅優先探索をして問題をつくる(後述) questions.length = 32; // 問題を格納する配列(ジャグ配列)を初期化する for(let i=0; i<=31; i++) questions[i] = []; // 取得された問題をジャグ配列に格納する for(let key of map.keys()) questions[map.get(key)].push(key); // 描画用のPieceオブジェクトを生成する(後述) pieces = createPieces(); setPieces('123456789'); // 最初は'1'~'9'が順番に並んだ状態で(後述) draw(); // 描画する(後述) initVolume('volume', sounds); // ボリュームを調整できるようにするための定番の処理(後述) }); |
イベントリスナの追加
キー操作やボタンを押下したときに適切に動作させるため、イベントリスナを追加します。
|
1 2 3 4 5 6 7 8 |
function addEventListeners(){ document.getElementById('start').addEventListener('click', () => gameStart()); document.addEventListener('keydown', ev => onKeyDown(ev)); $canvas.addEventListener('click', ev => onclickCanvas(ev)); $canvas.addEventListener('touchstart', ev => onclickCanvas(ev)); document.getElementById('reset').addEventListener('click', () => showQuestion()); document.getElementById('giveup').addEventListener('click', () => showAnswer()); } |
幅優先探索で問題を生成する
幅優先探索で問題を生成する処理を示します。
その準備として引数として与えられた文字列のなかから遷移可能な状態の文字列を取得する処理を示します。例えば’413296758’であれば9番ピースは真ん中にあるので、上のピースを下にずらせば’493216758’が得られます。ずらしかたは他にも3つあるので、’413256798′, ‘413926758’, ‘413269758’が得られます。
|
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 |
function getSwapStrings(s){ const rets = []; const idx = s.indexOf('9'); const r = Math.floor(idx / ROW_COL_COUNT); // '9'は二次元配列であればどこになるか? const c = idx % ROW_COL_COUNT; const dx = [1, -1, 0, 0]; // '9'を上下左右に移動させるとどうなる? const dy = [0, 0, 1, -1]; for (let i = 0; i < 4; i++){ const nr = r + dy[i]; const nc = c + dx[i]; if (nr < 0 || nr >= ROW_COL_COUNT || nc < 0 || nc >= ROW_COL_COUNT) // 移動不可 continue; const n_idx = nr * ROW_COL_COUNT + nc; // 移動可能なら位置を入れ替えた文字列を配列に格納 const chars = s.split(''); chars[idx] = chars[n_idx]; chars[n_idx] = '9'; rets.push(chars.join('')); } return rets; } |
幅優先探索をして遷移可能な状態とそこまでの手数を取得する処理を示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function bfs(start){ const map = new Map(); const q = new QueueStack(); map.set(start, 0); q.Enqueue(start); while (q.Count() > 0){ const s = q.Dequeue(); const d = map.get(s); const swaps = getSwapStrings(s); // 遷移可能な状態を取得して swaps.forEach(_ => { if (!map.has(_)) { // 未探索の状態であればそれは現在の状態に1を足した手数で到達できる q.Enqueue(_); map.set(_, d + 1); } }); } return map; } |
ピースの生成と描画
ピースを生成する処理を示します。
|
1 2 3 4 5 6 |
function createPieces(){ const pieces = []; for (let i = 0; i < 9; i++) pieces.push(new Piece()); return pieces; } |
setPieces関数は生成されたピースに番号をつけ引数の文字列で指定された初期位置にセットするためのものです。
|
1 2 3 4 5 6 7 8 9 10 |
function setPieces(text){ for (let i = 0; i < 9; i++) { const num = Number(text[i]); // row = i / 3, col = i % 3 の位置にtext[i]番ピースをセットする pieces[i].Number = num; pieces[i].X = (i % 3) * PIECE_SIZE; pieces[i].Y = Math.floor(i / 3) * PIECE_SIZE; pieces[i].Draw(); } } |
描画処理を示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function draw(){ requestAnimationFrame(() => draw()); // ピースを描画 ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); for (let i = 0; i < 9; i++) { if(pieces[i].Number != 9 || check()) // ステージクリア状態なら9番ピースも描画する pieces[i].Draw(); } // タイマー動作中であれば経過時間も表示する if(timer_moving){ const time = Date.now() - start_time + time_sum; const ms = time % 1000; let sec = Math.floor(time / 1000); const min = Math.floor(sec / 60); sec %= 60; const timeText = `経過時間 ${min} : ${sec.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`; $time.innerHTML = timeText; } drawCongratulation(); // 全問クリア時に文字列を表示する(それ以外のときは見えない位置に) } |
ステージクリアかどうかを判定する処理を示します。各ピースの番号からあるべき位置を取得し、これと照合しているだけです。
|
1 2 3 4 5 6 7 8 9 |
function check(){ for (let i = 0; i < 9; i++) { const row = pieces[i].Y / PIECE_SIZE; const col = pieces[i].X / PIECE_SIZE; if(row * ROW_COL_COUNT + col + 1 != pieces[i].Number) return false; } return true; } |
全問クリア時に’全問クリア’の文字列を描画させる処理です(それ以外のときはcongratulation_yが大きな値なので見えない位置に描画される)。
|
1 2 3 4 5 6 7 8 |
function drawCongratulation(){ ctx.fillStyle = '#fff'; ctx.strokeStyle = '#000'; ctx.textBaseline = 'top'; ctx.font = 'bold 56px Arial'; const x = (CANVAS_WIDTH - ctx.measureText('全問クリア').width) / 2; ctx.fillText('全問クリア', x, congratulation_y); } |
ボリューム調整を可能にする処理
もはや定番となったレンジスライダーでボリューム調整を可能にするための処理です。
|
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 |
function initVolume(elementId, sounds){ let volume = 0.3; const savedVolume = localStorage.getItem('hatodemowakaru-volume'); if(savedVolume) volume = Number(savedVolume); const $element = document.getElementById(elementId); const $div = document.createElement('div'); const $span1 = document.createElement('span'); $span1.innerHTML = '音量'; $div.appendChild($span1); const $range = document.createElement('input'); $range.type = 'range'; $div.appendChild($range); const $span2 = document.createElement('span'); $div.appendChild($span2); $range.addEventListener('input', () => { const value = $range.value; $span2.innerText = value; volume = Number(value) / 100; setVolume(); }); $range.addEventListener('change', () => localStorage.setItem('hatodemowakaru-volume', volume.toString())); setVolume(); $span2.innerText = Math.round(volume * 100).toString(); $span2.style.marginLeft = '16px'; $range.value = Math.round(volume * 100).toString(); $range.style.width = '230px'; $range.style.verticalAlign = 'middle'; $element?.appendChild($div); const $button = document.createElement('button'); $button.innerHTML = '音量テスト'; $button.style.width = '120px'; $button.style.height = '45px'; $button.style.marginTop = '12px'; $button.style.marginLeft = '32px'; $button.addEventListener('click', () => { sounds[0].currentTime = 0; sounds[0].play(); }); $element.appendChild($button); function setVolume(){ for(let i = 0; i < sounds.length; i++) sounds[i].volume = volume; } } |
長くなってしまったので続きは次回とします。
