箱入り娘とはブロックを動かして「娘」を外に出すのが目的のパズルゲームです。一般的な初期配置のものは動画の通りなのですが、これはやや難しい配置で最短手数は81手(ただし1回の移動で2マス移動を含めた場合(直線ではなくL字移動も含む)。L字移動も含まない場合は90手、1マス移動に限定した場合は116手)です。
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 41 42 43 44 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>箱入り娘</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./style.css"> </head> <body> <div id ="container"> <h1>鳩でもわかる箱入り娘</h1> <div id = "how-to-play">「娘」を下の出口まで移動させてください</div> <div id = "number-of-steps"></div> <div id = "canvas-outer"> <div id = "start-buttons"> <div id = "player-name-outer" class = "center"> プレイヤー名:<br> <input id = "player-name" maxlength="32"> </div> <div class = "center"> <button id = "game-start-1" class = "button">超カンタン</button> </div> <div class = "center"> <button id = "game-start-2" class = "button">カンタン</button> </div> <div class = "center"> <button id = "game-start-3" class = "button">ちょいムズ</button> </div> </div> </div> <div id = "nav"></div> <div class = "center"> <button id = "give-up" class = "button">あきらめて答えをみる</button> </div> <div id = "volume-ctrl"></div> </div> <script src="./index.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 |
body { background-color: black; color: white; } #container { width: 360px; } h1 { font-size: 24px; color: magenta; font-weight: bold; text-align: center; } #how-to-play { color: yellow; font-weight: bold; text-align: center; } #number-of-steps { margin-left: 20px; margin-bottom: 10px; color: cyan; text-align: center; font-weight: bold; } #canvas-outer { margin-left: 20px; margin-top: 0px; width: 320px; height: 400px; position: relative; } #nav { margin-top: 20px; margin-left: 20px; } #start-buttons { background-color: black; position: absolute; width: 240px; height: 280px; z-index: 10; top: 100px; left:40px; } #player-name-outer { margin-top: 10px; margin-bottom: 20px; } .button { margin-bottom: 10px; width: 180px; height: 50px; } #give-up { display: none; } #volume-ctrl { margin-top: 20px; } .magenta { color: magenta; font-weight: bold; } .cyan { color: cyan; font-weight: bold; } .center { text-align: center; } |
グローバル変数と定数
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 = document.createElement('canvas'); const ctx = $canvas.getContext('2d'); const $canvasOuter = document.getElementById('canvas-outer'); const $nav = document.getElementById('nav'); const $numberOfSteps = document.getElementById('number-of-steps'); const $startButtons = document.getElementById('start-buttons'); const $playerName = document.getElementById('player-name'); const $giveup = document.getElementById('give-up'); const backImage = new Image(); // 背景のイメージ const ROW_COUNT = 5; const COL_COUNT = 4; const BLOCK_SIZE = 72; const BORDER_WIDTH = 16; // 周りの木の枠の太さ const CANVAS_WIDTH = BLOCK_SIZE * 4 + BORDER_WIDTH * 2; const CANVAS_HEIGHT = BLOCK_SIZE * 5 + BORDER_WIDTH * 2; const array2x2 = [ ['', '', '', ''], ['', '', '', ''], ['', '', '', ''], ['', '', '', ''], ['', '', '', ''], ]; let rectangles = []; // ブロックを表す矩形オブジェクトを格納する配列 let fromRow = -1; // 移動しようとしているブロックの位置(-1なら非選択) let fromCol = -1; let numberOfSteps = 0; // 手数 let time = 0; // ゲーム開始からの経過時間 let isGameCleared = false; // ゲームをクリアできたか? let intervalID = null; // setInterval関数を利用した繰り返し動作取り消しのために必要なID let ignoreClick = true; // クリックに反応させない // 効果音 const ngSound = new Audio('./sounds/ng.mp3'); const selectSound = new Audio('./sounds/select.mp3'); const moveSound = new Audio('./sounds/move.mp3'); const clearSound = new Audio('./sounds/clear.mp3'); const bgm = new Audio('./sounds/bgm.mp3'); const sounds = [selectSound, moveSound, ngSound, clearSound, bgm]; let volume = 0.3; |
2次元配列 array2x2 にブロックの情報を格納します。以下は格納例です。
‘単’は1×1のブロック、’上’は上下に2個つながったブロックの上側、’下’はその下側、’左’は左右に2個つながったブロックの左側、’右’はその右側、’娘’は「娘」の左上、’娘1’は「娘」の右上、’娘2’は「娘」の左下、’娘3’は「娘」の右下、’空’はブロックが存在しない部分です。
1 2 3 4 5 6 7 |
[ ['上', '娘', '娘1', '上'], ['下', '娘2', '娘3', '下'], ['上', '左', '右', '上'], ['下', '単', '単', '下'], ['単', '空', '空', '単'], ] |
Positionクラス
位置を表現するためにPositionクラスを定義します。
1 2 3 4 5 6 |
class Position { constructor(row, col){ this.Row = row; this.Col = col; } } |
Rectangleクラスの定義
canvasに描画されるブロックを操作できるようにRectangleクラスを定義します。
コンストラクタ
コンストラクタを示します。引数は左上にくる部分の位置(上から何番目、左から何番目)、横と縦に何個つながっているか? 表示色、ブロックに書かれている文字列です。引数から各ブロックを描画する座標を算出し、メンバー変数 X と Y に格納します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Rectangle{ constructor(row, col, hCount, vCount, color, name){ this.Row = row; this.Col = col; this.HCount = hCount; this.VCount = vCount; this.Y = row * BLOCK_SIZE + BORDER_WIDTH; this.X = col * BLOCK_SIZE + BORDER_WIDTH; this.Width = hCount * BLOCK_SIZE; this.Height = vCount * BLOCK_SIZE; this.Color = color; this.Name = name; } } |
ブロックを移動可能であるかどうか調べる処理の準備としてブロックの上下左右にある隣の位置を取得する処理を示します。
たとえばRectangle.RowとRectangle.Colから各ブロックの右隣りの位置を算出する場合、ブロックが横に何個つながっているかで変わってきます。1個ならRectangle.Colに1足した値になりますが、2個つながっている場合は2をたさなければなりません。また右隣りの位置の個数は縦に何個つながっているかで変わってきます。
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 |
class Rectangle{ GetRightNextPositions(){ const rets = []; for(let i=0; i< this.VCount; i++) rets.push(new Position(this.Row + i, this.Col + this.HCount)); // フィールドの内側に存在する座標(0~ROW_COUNT-1, 0~COL_COUNT-1)のみを返す return rets.filter(_ => _.Col >= 0 && _.Col < COL_COUNT && _.Row >= 0 && _.Row < ROW_COUNT); } GetLeftNextPositions(){ const rets = []; for(let i=0; i< this.VCount; i++) rets.push(new Position(this.Row + i, this.Col - 1)); return rets.filter(_ => _.Col >= 0 && _.Col < COL_COUNT && _.Row >= 0 && _.Row < ROW_COUNT); } GetUpperNextPositions(){ const rets = []; for(let i=0; i< this.HCount; i++) rets.push(new Position(this.Row - 1, this.Col + i)); return rets.filter(_ => _.Col >= 0 && _.Col < COL_COUNT && _.Row >= 0 && _.Row < ROW_COUNT); } GetLowerNextPositions(){ const rets = []; for(let i=0; i< this.HCount; i++) rets.push(new Position(this.Row + this.VCount, this.Col + i)); return rets.filter(_ => _.Col >= 0 && _.Col < COL_COUNT && _.Row >= 0 && _.Row < ROW_COUNT); } } |
ブロックを移動可能であるかどうか調べる処理を示します。
引数で指定された方向の隣が存在し、それがすべて’空’であれば移動可能です。それ以外は移動不可です。
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 |
class Rectangle{ CanMove(direct){ if(direct == 'up'){ const len = this.GetUpperNextPositions().length; if(len > 0 && this.GetUpperNextPositions().filter(_ => array2x2[_.Row][_.Col] == '空').length == len) return true; else return false; } if(direct == 'down'){ const len = this.GetLowerNextPositions().length; if(len > 0 && this.GetLowerNextPositions().filter(_ => array2x2[_.Row][_.Col] == '空').length == len) return true; else return false; } if(direct == 'left'){ const len = this.GetLeftNextPositions().length; if(len > 0 && this.GetLeftNextPositions().filter(_ => array2x2[_.Row][_.Col] == '空').length == len) return true; else return false; } if(direct == 'right'){ const len = this.GetRightNextPositions().length; if(len > 0 && this.GetRightNextPositions().filter(_ => array2x2[_.Row][_.Col] == '空').length == len) return true; else return false; } return false; } } |
移動
実際に移動させる処理を示します。
まずCanMove関数を呼び出し移動できるか確認します。移動可能であるなら移動先の座標を求め、その方向に移動させます。アニメーションさせたいので16回にわけ少しずつ移動させ、移動のたびにcanvasを再描画しています。
移動が終わったらupdateArray2x2関数を呼び出してarray2x2に変更を反映させています。
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 |
class Rectangle{ async Move(direct){ if(!this.CanMove(direct)) return false; const oldY = this.Y; const oldX = this.X; let newY = this.Y; let newX = this.X; if(direct == 'up'){ newY -= BLOCK_SIZE; this.Row--; } if(direct == 'down'){ newY += BLOCK_SIZE; this.Row++; } if(direct == 'left'){ newX -= BLOCK_SIZE; this.Col--; } if(direct == 'right'){ newX += BLOCK_SIZE; this.Col++; } const count = 16; for(let i=0; i<count; i++){ if(direct == 'up') this.Y -= BLOCK_SIZE / count; if(direct == 'down') this.Y += BLOCK_SIZE / count; if(direct == 'left') this.X -= BLOCK_SIZE / count; if(direct == 'right') this.X += BLOCK_SIZE / count; draw(); await new Promise(resolve => setTimeout(resolve, 1000 / 60)); } this.Y = newY; // 小数による誤差補正 this.X = newX; updateArray2x2(); // 後述 draw(); // 後述 return true; } } |
ブロックの位置が変更されたら二次元配列に変更を反映させる updateArray2x2関数とcanvas全体を再描画する draw関数は以下のように定義されています。
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 updateArray2x2(){ // 全体を'空'で初期化し、ブロックの形状から適切な文字列を格納していく for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++) array2x2[row][col] = '空'; } for(let i= 0; i<rectangles.length; i++){ const rect = rectangles[i]; if(rect.HCount == 1 && rect.VCount == 1) array2x2[rect.Row][rect.Col] = '単'; if(rect.HCount == 1 && rect.VCount == 2){ array2x2[rect.Row][rect.Col] = '上'; array2x2[rect.Row + 1][rect.Col] = '下'; } if(rect.HCount == 2 && rect.VCount == 1){ array2x2[rect.Row][rect.Col] = '左'; array2x2[rect.Row][rect.Col + 1] = '右'; } if(rect.HCount == 2 && rect.VCount == 2){ array2x2[rect.Row][rect.Col] = '娘'; array2x2[rect.Row][rect.Col + 1] = '娘1'; array2x2[rect.Row + 1][rect.Col] = '娘2'; array2x2[rect.Row + 1][rect.Col + 1] = '娘3'; } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function draw(){ // 背景は木目の画像 ctx.drawImage(backImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // 内側と下の出口の部分を白で塗りつぶす ctx.fillStyle = '#fff'; ctx.fillRect(BORDER_WIDTH, BORDER_WIDTH, BLOCK_SIZE * COL_COUNT, BLOCK_SIZE * ROW_COUNT); ctx.fillRect(BORDER_WIDTH + BLOCK_SIZE, BORDER_WIDTH + BLOCK_SIZE * 5, BLOCK_SIZE * 2, BLOCK_SIZE * ROW_COUNT); // ブロックを描画 for(let i= 0; i<rectangles.length; i++) rectangles[i].Draw(); } |
描画
各ブロックを描画する処理を示します。
Rectangle.Colorでブロックを描画し、白で縁取りをして内部に文字列Rectangle.Nameを書き込んでいるだけです。
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 Rectangle{ Draw(){ ctx.fillStyle = this.Color; ctx.fillRect(this.X, this.Y, this.Width, this.Height); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.strokeRect(this.X, this.Y, this.Width, this.Height); ctx.fillStyle = '#fff'; ctx.textBaseline = 'top'; if(this.HCount == 1 && this.VCount == 1){ ctx.font='48px Arial'; ctx.fillText(this.Name, this.X + 12, this.Y + 14); } if(this.HCount == 1 && this.VCount == 2){ ctx.font='48px Arial'; ctx.fillText(this.Name[0], this.X + 12, this.Y + 14); ctx.fillText(this.Name[1], this.X + 12, this.Y + 14 + BLOCK_SIZE); } if(this.HCount == 2 && this.VCount == 1){ ctx.font='48px Arial'; ctx.fillText(this.Name[0], this.X + 12, this.Y + 14); ctx.fillText(this.Name[1], this.X + 12 + BLOCK_SIZE, this.Y + 14); } if(this.HCount == 2 && this.VCount == 2){ ctx.font='bold 128px Arial'; ctx.fillText('娘', this.X + 10, this.Y + 20); } } } |
Exist関数は指定された位置にブロックが存在するかどうかを返します。
1 2 3 4 5 6 7 8 |
class Rectangle{ Exist(row, col){ if(row == this.Row && col == this.Col) return true; else return false; } } |
ページが読み込まれたときの処理
ページが読み込まれたときにおこなわれる処理を示します。
背景に使う画像の読み込み、canvasの初期化、レンジスライダーによるボリューム設定の有効化、問題の作成、ボタンクリックのイベントリスナの追加、エンドレスでBGMを再生するための処理、描画処理をおこなっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
window.onload = async() => { backImage.src = './images/back.png'; initCanvas(); // 後述 initVolume('volume-ctrl', sounds); // 後述 $numberOfSteps.innerHTML = '[開始]ボタンをクリックしてください'; createProblem(1); // 後述 document.getElementById('game-start-1').addEventListener('click', () => gameStart(1)); // 後述 document.getElementById('game-start-2').addEventListener('click', () => gameStart(2)); document.getElementById('game-start-3').addEventListener('click', () => gameStart(3)); setInterval(() => { if(bgm.currentTime > 2 * 60 + 35) bgm.currentTime = 0; }, 500); backImage.onload = () => draw(); } |
canvasの初期化
canvas初期化の処理を示します。
canvasのサイズ調整をしたあと親要素の中に追加し、5行4列のセルを生成します。これによってcanvasのブロックが描画される部分に該当するセルがクリックされたら移動処理ができるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function initCanvas(){ $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; $canvasOuter.appendChild($canvas); for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ const $div = document.createElement('div'); $div.style.width = BLOCK_SIZE + 'px'; $div.style.height = BLOCK_SIZE + 'px'; $div.style.position = 'absolute'; $div.style.top = BLOCK_SIZE * row + BORDER_WIDTH + 'px'; $div.style.left = BLOCK_SIZE * col + BORDER_WIDTH + 'px'; $canvasOuter?.appendChild($div); addEventListenerToCell($div, row, col); // 後述 } } } |
レンジスライダーによるボリューム設定
レンジスライダーでボリュームを調節できるようにする処理を示します。
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 |
function initVolume(elementId, sounds){ 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 = value / 100; setVolume(); }); setVolume(); $span2.innerText = volume * 100; $span2.style.marginLeft = '16px'; $range.value = volume * 100; $range.style.width = '250px'; $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; } } |
問題の生成
問題を生成する処理を示します。問題は3パターン用意しました。
1 2 3 4 5 6 7 8 |
function createProblem(type){ if(type == 1) createProblem1(); if(type == 2) createProblem2(); if(type == 3) createProblem3(); } |
カンタンな問題
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function createProblem1(){ rectangles = []; addBlock1x1(3, 1, '兄', '#a00'); addBlock1x1(3, 2, '弟', '#0a0'); addBlock1x1(4, 0, '姉', '#00f'); addBlock1x1(4, 3, '妹', '#077'); addBlock1x1(0, 0, '兄', '#a00'); addBlock1x1(1, 0, '弟', '#0a0'); addBlock1x1(0, 3, '姉', '#a00'); addBlock1x1(1, 3, '妹', '#0a0'); addBlock2x1(2, 0, '父親', '#a00'); addBlock2x1(2, 3, '母親', '#0aa'); addBlock1x1(2, 1, '兄', '#a00'); addBlock1x1(2, 2, '妹', '#a00'); addBlockMusume(0, 1, '#f0f'); addBlank(4, 1); addBlank(4, 2); } |
これはまあカンタンな問題です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function createProblem2(){ rectangles = []; addBlock1x1(3, 1, '兄', '#a00'); addBlock1x1(3, 2, '弟', '#0a0'); addBlock1x1(4, 0, '姉', '#00f'); addBlock1x1(4, 3, '妹', '#077'); addBlock1x1(0, 0, '兄', '#a00'); addBlock1x1(1, 0, '弟', '#0a0'); addBlock1x1(0, 3, '姉', '#a00'); addBlock1x1(1, 3, '妹', '#0a0'); addBlock2x1(2, 0, '父親', '#a00'); addBlock2x1(2, 3, '母親', '#0aa'); addBlock1x2(2, 1, '居候', '#0aa'); addBlockMusume(0, 1, '#f0f'); addBlank(4, 1); addBlank(4, 2); } |
代表的な問題ですが、難しめの問題です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function createProblem3(){ rectangles = []; addBlock1x1(3, 1, '兄', '#a00'); addBlock1x1(3, 2, '弟', '#0a0'); addBlock1x1(4, 0, '姉', '#00f'); addBlock1x1(4, 3, '妹', '#077'); addBlock2x1(0, 0, '父親', '#a00'); addBlock2x1(0, 3, '母親', '#0a0'); addBlock2x1(2, 0, '祖父', '#880'); addBlock2x1(2, 3, '祖母', '#808'); addBlock1x2(2, 1, '居候', '#0aa'); addBlockMusume(0, 1, '#f0f'); addBlank(4, 1); addBlank(4, 2); } |
以下はブロックのオブジェクトを生成して配列に格納するとともに二次元配列に文字列を格納する処理です。
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 |
function addBlock1x1(row, col, name, color){ rectangles.push(new Rectangle(row, col, 1, 1, color, name)); array2x2[row][col] = '単'; } // 縦にふたつつながったブロック function addBlock2x1(row, col, name, color){ rectangles.push(new Rectangle(row, col, 1, 2, color, name)); array2x2[row][col] = '上'; array2x2[row + 1][col] = '下'; } // 横にふたつつながったブロック function addBlock1x2(row, col, name, color){ rectangles.push(new Rectangle(row, col, 2, 1, color, name)); array2x2[row][col] = '左'; array2x2[row][col + 1] = '右'; } function addBlockMusume(row, col, color){ rectangles.push(new Rectangle(row, col, 2, 2, color, '娘')); array2x2[row][col] = '娘'; array2x2[row][col + 1] = '娘1'; array2x2[row + 1][col] = '娘2'; array2x2[row + 1][col + 1] = '娘3'; } |
以下は空白部分に該当する文字列を二次元配列に格納する処理です。
1 2 3 |
function addBlank(row, col){ array2x2[row][col] = '空'; } |
ゲーム開始の処理
ゲーム開始の処理を示します。
手数をリセットし、クリックされた問題に応じて問題を生成して表示させます。同時にゲーム開始ボタンやギブアップボタンの表示や非表示、タイマーのカウントアップの開始処理をおこないます。
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 gameStart(type){ isGameCleared = false; createProblem(type); draw(); numberOfSteps = 0; $numberOfSteps.innerHTML = `${numberOfSteps} 手`; $startButtons.style.display = 'none'; $giveup.style.display = 'inline'; ignoreClick = false; // 効果音とBGM selectSound.currentTime = 0; selectSound.play(); bgm.currentTime = 0; bgm.play(); intervalID = setInterval(() => { time++; showNumberOfStepsTime(); // 手数と経過時間を表示(後述) }, 1000); } |
手数と経過時間を表示する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function showNumberOfStepsTime(){ $numberOfSteps.innerHTML = `${numberOfSteps} 手 <span style = "margin-left:50px">${getTimeText()}</span>`; } function getTimeText(){ let seconds = time % 60; let minutes = Math.floor(time / 60); if(seconds < 10) seconds = '0' + seconds; if(minutes < 10) minutes = '0' + minutes; return `${minutes} 分 ${seconds} 秒`; } |
ブロックの移動
canvas上に存在する見えないセルをクリックしたらブロックの移動処理をおこなうイベントリスナを追加する処理を示します。ignoreClickフラグがセットされているときはクリックしてもなにもしません。
1 2 3 4 5 6 7 8 9 10 11 |
function addEventListenerToCell($div, row, col){ $div.addEventListener('click', async() => { if(ignoreClick) return; if(fromRow == -1) // fromRow が -1 でないときは移動対象のブロックが指定されている await selectRectangle(row, col); // 移動対象のブロックを選択(後述) else await moveRectangle(row, col); // 移動対象のブロックを移動(後述) }); } |
移動対象のブロックを選択する
移動対象のブロックを選択する処理を示します。
セルの位置から対応するRectangleオブジェクトがあるか調べます。オブジェクトが存在し、それが移動可能であるならRectangle.RowとRectangle.ColをfromRowとfromColに格納します。またナビゲーションに◯◯の移動先を指定せよと表示させます。
(追記)
ユーザーに移動元を選択させて、さらにそのあと移動先を選択するというのは操作性が悪すぎるのではないかという指摘があったので、移動元を選択したときに移動先がひとつしかない場合はただちに移動させます。移動先がふたつある場合は選択されているブロックと移動先のセルに色をつけてどこへ移動可能なのかが視覚的にわかりやすくします。
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 |
async function selectRectangle(row, col){ const rect = getRectangle(row, col); // セルの位置から対応するオブジェクトを探す(後述) if(rect == null){ onBadSelect(null); // みつからない(onBadSelect関数は後述) return; } // 4方向にそれぞれ移動可能か? const pairs = [ {key:'up', value:rect.CanMove('up')}, {key:'down', value:rect.CanMove('down')}, {key:'left', value:rect.CanMove('left')}, {key:'right', value:rect.CanMove('right')}, ]; const count = pairs.filter(_ => _.value == true).length; // 移動不能 if(count == 0){ onBadSelect(rect); return; } let moveNow = false; // このフラグがtrueならただちに移動させる // 移動可能な方向がひとつしかない場合 // ただしふたつ移動可能な場合は選択肢が唯一ではないので確認する if(count == 1){ const rect = getRectangle(row, col); const find = pairs.find(_ => _.value == true); // 移動可能なとなりのセルの座標を取得する const direct = find.key; let nexts = []; if(direct == 'up') nexts = rect.GetUpperNextPositions(); if(direct == 'down') nexts = rect.GetLowerNextPositions(); if(direct == 'left') nexts = rect.GetLeftNextPositions(); if(direct == 'right') nexts = rect.GetRightNextPositions(); moveNow = true; // 上側のみ移動可能でひとつ上のセルのさらに上側が'空'の場合は移動先が唯一ではない if(direct == 'up' && nexts.length == 1 && nexts[0].Row - 1 >= 0 && array2x2[nexts[0].Row - 1][nexts[0].Col] == '空') moveNow = false; // 下、左、右も同様に考える if(direct == 'down' && nexts.length == 1 && nexts[0].Row + 1 < ROW_COUNT && array2x2[nexts[0].Row + 1][nexts[0].Col] == '空') moveNow = false; if(direct == 'left' && nexts.length == 1 && nexts[0].Col - 1 >= 0 && array2x2[nexts[0].Row][nexts[0].Col - 1] == '空') moveNow = false; if(direct == 'right' && nexts.length == 1 && nexts[0].Col + 1 < COL_COUNT && array2x2[nexts[0].Row][nexts[0].Col + 1] == '空') moveNow = false; // moveNow == true なら直ちに移動させる if(moveNow){ ignoreClick = true; // 移動中はクリックに反応させない await rect.Move(direct); onMoveFinished(); // 移動完了時の処理(後述) ignoreClick = false; checkGameClear(); // クリア判定(後述) } } // 直ちに移動させることはできないので選択されたブロックの情報をグローバル変数に保存する if(!moveNow) { fromRow = rect.Row; fromCol = rect.Col; $nav.innerHTML = `<span class ="cyan">「${rect.Name}」の移動先</span>を指定してください`; selectSound.currentTime = 0; selectSound.play(); // 選択されているブロックを可視化するために半透明の白を重ねる ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx?.fillRect(rect.Col * BLOCK_SIZE + BORDER_WIDTH, rect.Row * BLOCK_SIZE + BORDER_WIDTH, rect.Width, rect.Height); // 移動先のセルを可視化するために薄い赤でセルを塗りつぶす for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ if(array2x2[row][col] == '空'){ ctx.fillStyle = '#ffbcff'; ctx?.fillRect(col * BLOCK_SIZE + BORDER_WIDTH, row * BLOCK_SIZE + BORDER_WIDTH, BLOCK_SIZE, BLOCK_SIZE); } } } } } |
セルの位置から対応するオブジェクトを取得する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function getRectangle(row, col){ if(row < 0 || col < 0) return null; if(array2x2[row][col] == '下' || array2x2[row][col] == '娘2') row--; if(array2x2[row][col] == '右' || array2x2[row][col] == '娘1') col--; if(array2x2[row][col] == '娘3'){ row--; col--; } for(let i=0; i<rectangles.length; i++){ if(rectangles[i].Exist(row, col)) return rectangles[i]; } return null; } |
移動対象を選択しようとしたけれどもできなかった場合の処理を示します。
1 2 3 4 5 6 7 8 9 |
function onBadSelect(rect){ if(rect != null) $nav.innerHTML = `「${rect.Name}」は移動できないので選択できません`; else $nav.innerHTML = '選択できません'; ngSound.currentTime = 0; ngSound.play(); } |
移動対象を移動させる
移動対象を移動させる処理を示します。
移動対象が選択されている場合、クリックされた場所が移動対象の隣であるか調べます。ひとつ隣またはふたつ隣の場合は移動可能であれば移動させます。
移動できた場合はonMoveFinished関数を呼び出し、移動できなかった場合はonMoveFailured関数を呼び出します。また移動できた場合はゲームクリア判定もおこないます。
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 |
async function moveRectangle(row, col){ let done = false; if(array2x2[row][col] == '空'){ // 移動先が'空'でないとそもそも移動できない ignoreClick = true; const rect = getRectangle(fromRow, fromCol); if(rect.GetLeftNextPositions().filter(_ => _.Row == row && _.Col == col).length > 0) done = await rect.Move('left'); if(rect.GetLeftNextPositions().filter(_ => _.Row == row && _.Col - 1 == col).length > 0){ done = await rect.Move('left'); await rect.Move('left'); } if(rect.GetRightNextPositions().filter(_ => _.Row == row && _.Col == col).length > 0) done = await rect.Move('right'); if(rect.GetRightNextPositions().filter(_ => _.Row == row && _.Col + 1 == col).length > 0){ done = await rect.Move('right'); await rect.Move('right'); } if(rect.GetUpperNextPositions().filter(_ => _.Row == row && _.Col == col).length > 0) done = await rect.Move('up'); if(rect.GetUpperNextPositions().filter(_ => _.Row - 1 == row && _.Col == col).length > 0){ done = await rect.Move('up'); await rect.Move('up'); } if(rect.GetLowerNextPositions().filter(_ => _.Row == row && _.Col == col).length > 0) done = await rect.Move('down'); if(rect.GetLowerNextPositions().filter(_ => _.Row + 1 == row && _.Col == col).length > 0){ done = await rect.Move('down'); await rect.Move('down'); } if(done) onMoveFinished(); // 移動完了(後述) ignoreClick = false; checkGameClear(); // ゲームクリア判定(後述) } if(!done) onMoveFailured(row, col); // 移動できない(後述) } |
移動を完了したときの処理を示します。
移動を完了したので移動対象のブロックに関する情報はリセットします(fromRowとfromColを-1で初期化)。そのあと表示されている手数を1増やします。
1 2 3 4 5 6 7 8 9 10 11 12 |
function onMoveFinished(){ fromRow = -1; fromCol = -1; moveSound.currentTime = 0; moveSound.play(); numberOfSteps++; showNumberOfStepsTime(); $nav.innerHTML = '<span class ="magenta">移動元</span>を指定してください'; } |
移動できなかったときの処理を示します。この場合は移動対象を再指定するところからやり直しとなります。
1 2 3 4 5 6 7 8 9 10 11 |
function onMoveFailured(row, col){ draw(); // 全体を再描画することで移動先として強調表示されていたものがクリアされる fromRow = -1; fromCol = -1; $nav.innerHTML = 'そこへは移動できません。<br><span class ="magenta">移動元</span>指定からやりなおしてください' + array2x2[row][col]; ignoreClick = false; ngSound.currentTime = 0; ngSound.play(); } |
ゲームクリア時の処理
ゲームクリア時の処理を示します。
二次元配列 array2x2 を更新した結果、array2x2[3][1] == ‘娘’ となれば娘を出口まで移動させたことになります。この場合はゲームクリア処理をおこないます。娘をcanvas下部から退場させ、ゲームクリアを示す文字列とボタン類の表示非表示の処理をおこないます。
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 |
async function checkGameClear(){ if (array2x2[3][1] == '娘'){ isGameCleared = true; clearInterval(intervalID); // クリアしたので経過時間計測のタイマーは止める ignoreClick = true; sendData(); // ランキングに登録(後述) await new Promise(resolve => setTimeout(resolve, 1000)); await musumeGoOut(); await new Promise(resolve => setTimeout(resolve, 1000)); bgm.pause(); await new Promise(resolve => setTimeout(resolve, 500)); $numberOfSteps.innerHTML = `${numberOfSteps} 手 でクリアしました!`; $nav.innerHTML = ''; clearSound.currentTime = 0; clearSound.play(); await new Promise(resolve => setTimeout(resolve, 1000)); $giveup.style.display = 'none'; $startButtons.style.display = 'block'; } } function sendData(){ // 詳細は後日 if(isGameCleared) ; else ; } |
娘を退場させる処理を示します。count×3回少しずつ下に移動させてcanvasの外に移動させます。
1 2 3 4 5 6 7 8 9 |
async function musumeGoOut(){ const musume = getRectangle(3, 1); const count = 24; for(let i = 0; i < count * 3; i++){ musume.Y += BLOCK_SIZE / count; draw(); await new Promise(resolve => setTimeout(resolve, 1000 / 60)); } } |
ふぎゃあ。クソ長い記事になってしまった。次回はギブアップ時に解を表示する処理の解説をおこないます。