この記事は タンク・チェンジの問題を解く(Cマガ電脳クラブ 第127回)の続きです。
タンク・チェンジは1989年10月号から2006年4月号まで全199号が発行されたプログラミング技術情報誌『C MAGAZINE』内のCマガ電脳クラブというコーナーで出題された問題ですが、これをそのままパズルゲームにしてみようというのが今回の試みとなります。
Contents
HTML部分
HTML部分を示します。戦車が位置する広場に該当する部分がボタンになっていて、クリックして戦車が移動させたい戦車と移動先を指定します。広場をつなぐ斜めの道をHTMLだけでは表現するのが難しかったので(やろうとすればできなくはないが…)この部分は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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
<!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" type="text/css" href="./style.css"> </head> <body> <div id = "container"> <div id = "navi"> <span id = "navi-text"></span> </div> <div id = "field"> <canvas id = "canvas"></canvas> <div id = "btn-11" class = "move-buttons"></div> <div id = "btn-31" class = "move-buttons"></div> <div id = "btn-51" class = "move-buttons"></div> <div id = "btn-22" class = "move-buttons"></div> <div id = "btn-42" class = "move-buttons"></div> <div id = "btn-13" class = "move-buttons"></div> <div id = "btn-33" class = "move-buttons"></div> <div id = "btn-53" class = "move-buttons"></div> <div id = "btn-24" class = "move-buttons"></div> <div id = "btn-44" class = "move-buttons"></div> <div id = "btn-15" class = "move-buttons"></div> <div id = "btn-35" class = "move-buttons"></div> <div id = "btn-55" class = "move-buttons"></div> <div id = "btn-26" class = "move-buttons"></div> <div id = "btn-46" class = "move-buttons"></div> <div id = "btn-17" class = "move-buttons"></div> <div id = "btn-37" class = "move-buttons"></div> <div id = "btn-57" class = "move-buttons"></div> </div> <p class="link"><a href="./how-to-play.html">遊び方</a> ルールが独特なので必ず読んでください。</p> <button id = "start-button">開始</button> <button id = "giveup-button">降参して答えをみる</button> <div id = "volume">音量:<input type="range" id = "volume-range"><span id = "volume-text">10</span></div> <button id = "volume-test">音量テスト</button> </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 |
body { background-color: #000; color: #fff; } #container { width: 360px; } #field { width: 360px; height: 480px; border: solid 1px #000; position: relative; } #navi { width: 360px; height: 50px; text-align: center; border: solid 1px #000; color: #fff; } #start-button, #giveup-button { margin-top: 20px; margin-right: 20px; width: 150px; height: 60px; } .move-buttons { border: solid 4px #ccc; position: absolute; } #volume { color: #fff; margin-top: 20px; margin-bottom: 20px; } #volume-range { width: 200px; vertical-align: middle; } #volume-text { margin-left: 20px; color: #fff; } #volume-test { display: block; } #volume-label { color: #fff; } .link a{ color: #0ff; font-weight: bold; } .link a:hover{ color: #f00; font-weight: bold; } |
Positionクラスの定義
場所を指定するために使うPositionクラスを定義します。
1 2 3 4 5 6 |
class Position { constructor(row, col){ this.Row = row; this.Col = 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 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 |
let phase = 0; // 0:青の戦車を指定せよ // 1:青の戦車の移動先を指定せよ // 2:赤の戦車を指定せよ // 3:赤の戦車の移動先を指定せよ let moveCount = 0; // 何回移動したか? let selectedPosition = null; // 移動元として指定された場所 let ignoreClick = true; // 広場をクリックしても無視する(移動中など) const BUTTON_WIDTH = 70; // 広場のサイズ const BUTTON_HEIGHT = 55; const BUTTON_PITCH_X = 60; // つぎの広場との間隔 const BUTTON_PITCH_Y = 65; // DOM要素 const $field = document.getElementById('field'); const $naviText = document.getElementById('navi-text'); const $startButton = document.getElementById('start-button'); const $giveupButton = document.getElementById('giveup-button'); // DOM要素(広場) const $btn11 = document.getElementById('btn-11'); const $btn31 = document.getElementById('btn-31'); const $btn51 = document.getElementById('btn-51'); const $btn22 = document.getElementById('btn-22'); const $btn42 = document.getElementById('btn-42'); const $btn13 = document.getElementById('btn-13'); const $btn33 = document.getElementById('btn-33'); const $btn53 = document.getElementById('btn-53'); const $btn24 = document.getElementById('btn-24'); const $btn44 = document.getElementById('btn-44'); const $btn15 = document.getElementById('btn-15'); const $btn35 = document.getElementById('btn-35'); const $btn55 = document.getElementById('btn-55'); const $btn26 = document.getElementById('btn-26'); const $btn46 = document.getElementById('btn-46'); const $btn17 = document.getElementById('btn-17'); const $btn37 = document.getElementById('btn-37'); const $btn57 = document.getElementById('btn-57'); // 広場のボタンを2次元配列として格納する const matMoveButtons = [ [$btn11, null, $btn31, null, $btn51], [null, $btn22, null, $btn42, null ], [$btn13, null, $btn33, null, $btn53], [null, $btn24, null, $btn44, null], [$btn15, null, $btn35, null, $btn55], [null, $btn26, null, $btn46, null], [$btn17, null, $btn37, null, $btn57] ] let matTanks; // 初期の戦車の位置(青:0~2、赤:3~5) // = [ // [0, -1, 1, -1, 2], // [-1, -1, -1, -1, -1], // [-1, -1, -1, -1, -1], // [-1, -1, -1, -1, -1], // [-1, -1, -1, -1, -1], // [-1, -1, -1, -1, -1], // [3, -1, 4, -1, 5], // ] const tankImages = []; // 戦車描画用のimg要素 // 効果音 const badSound = new Audio('./sounds/bad.mp3'); const selectSound = new Audio('./sounds/select.mp3'); const deadSound = new Audio('./sounds/dead.mp3'); const clearSound = new Audio('./sounds/clear.mp3'); let volume = 0.2; // ボリューム |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。
ゲームをしていてどうしても答えがわからない場合は答えを表示する降参ボタンを用意しましたが、これはゲーム時のみ表示させます。最初は非表示にします。
戦車は縦横ではなく斜めに移動します。斜めに接する要素の位置を決めるときに CSSに直接書くよりJavaScriptで操作したほうがやりやすいと考えて、ここで座標を決定します。グローバル変数の二次元配列 matMoveButtons にひとつとびにボタン要素が格納されてるので、nullではないものを見つけたときはここで座標や見た目を設定します。
そのあと画像を読み込んで戦車を表示するためのimg要素を合計 6 個生成します。そして戦車の位置を記録する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 35 36 37 38 39 40 41 42 |
window.onload = async() => { $giveupButton.style.display = 'none'; // 降参ボタンを非表示 // 広場の初期化 for(let row = 0; row < matMoveButtons.length; row++){ for(let col = 0; col < matMoveButtons[0].length; col++){ const btn = matMoveButtons[row][col]; if(btn == null) continue; btn.style.width = (BUTTON_WIDTH) + 'px'; btn.style.height = (BUTTON_HEIGHT) + 'px'; btn.style.borderRadius = '15px'; btn.style.backgroundColor = '#eee'; btn.style.top = (10 + BUTTON_PITCH_Y * row) + 'px'; btn.style.left = (10 + BUTTON_PITCH_X * col) + 'px'; } } // img要素の初期化 for(let i = 0; i < 2; i++){ for(let k = 0; k < 3; k++){ const tankImage = new Image(); if(i == 0) tankImage.src = './images/blue.png'; else tankImage.src = './images/red.png'; tankImage.style.position = 'absolute'; tankImage.style.width = '54px'; tankImage.style.height = '54px'; tankImage.id = 'tank-' + i + '-' + k; $field?.appendChild(tankImage); tankImages.push(tankImage); } } initMatTanks(); // 戦車の位置の初期化(後述) showTanksStartPosition(); // 戦車を初期位置に表示させる(後述) drawPaths(); // 通路の描画(後述) addEventListeners(); // イベントリスナの追加(後述) initVolume(); // ボリューム設定を可能にする(後述) } |
ボリューム設定
定番のボリューム設定を可能にする処理です。スライダーを動かすとボリューム調整とテストができるようにしています。
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 initVolume(){ const $volumeRange = document.getElementById('volume-range'); const $volumeText = document.getElementById('volume-text'); function setVolume(){ badSound.volume = volume; selectSound.volume = volume; deadSound.volume = volume; clearSound.volume = volume; } $volumeRange?.addEventListener('input', () => { const value = $volumeRange.value; $volumeText.innerText = value; volume = value / 100; setVolume(); }); $volumeRange.value = volume * 100; $volumeText.innerText = $volumeRange.value; setVolume(); const $volumeTest = document.getElementById('volume-test'); $volumeTest?.addEventListener('click', () => { selectSound.play(); }); } |
戦車の位置の初期化
戦車の位置を初期化する処理を示します。0~2が青い戦車、3~5が赤い戦車の位置です。-1 は戦車が存在しない(または移動不能)位置です。
1 2 3 4 5 6 7 8 9 10 |
function initMatTanks(){ matTanks = []; matTanks.push([0, -1, 1, -1, 2]); matTanks.push([-1, -1, -1, -1, -1]); matTanks.push([-1, -1, -1, -1, -1]); matTanks.push([-1, -1, -1, -1, -1]); matTanks.push([-1, -1, -1, -1, -1]); matTanks.push([-1, -1, -1, -1, -1]); matTanks.push([3, -1, 4, -1, 5]); } |
戦車を初期位置に表示する処理を示します。配列で指定された要素の上に重なるように戦車を表示させます。show関数では戦車と広場の中心同士が重なるように戦車の座標を求めています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function showTanksStartPosition(){ const btns = [$btn11, $btn31, $btn51,$btn17, $btn37, $btn57,]; for(let i = 0; i < 6; i++) show(tankImages[i], btns[i]); } function show(image, $btn){ image.style.left = '0px'; image.style.top = '0px'; const rect1 = $btn.getBoundingClientRect(); const centerX1 = (rect1.left + rect1.right) / 2; const centerY1 = (rect1.top + rect1.bottom) / 2; const rect2 = image.getBoundingClientRect(); const centerX2 = (rect2.left + rect2.right) / 2; const centerY2 = (rect2.top + rect2.bottom) / 2; image.style.left = centerX1 - centerX2 + 'px'; image.style.top = centerY1 - centerY2 + 'px'; } |
通路を描画する処理
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 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 |
function drawPaths(){ const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); $canvas.width = 360; $canvas.height = 480; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 360, 480); ctx.strokeStyle = '#fff'; ctx.lineWidth = 20; const x0 = 10 + BUTTON_PITCH_X * 0 + BUTTON_WIDTH / 2; const x2 = 10 + BUTTON_PITCH_X * 2 + BUTTON_WIDTH / 2; const x4 = 10 + BUTTON_PITCH_X * 4 + BUTTON_WIDTH / 2; const y0 = 10 + BUTTON_PITCH_Y * 0 + BUTTON_HEIGHT / 2; const y2 = 10 + BUTTON_PITCH_Y * 2 + BUTTON_HEIGHT / 2; const y4 = 10 + BUTTON_PITCH_Y * 4 + BUTTON_HEIGHT / 2; const y6 = 10 + BUTTON_PITCH_Y * 6 + BUTTON_HEIGHT / 2; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x4, y4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x4, y0); ctx.lineTo(x0, y4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x0, y2); ctx.lineTo(x4, y6); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x4, y2); ctx.lineTo(x0, y6); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y0); ctx.lineTo(x4, y2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y0); ctx.lineTo(x0, y2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x0, y4); ctx.lineTo(x2, y6); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x4, y4); ctx.lineTo(x2, y6); ctx.stroke(); } |
イベントリスナの追加
イベントリスナを追加する処理を示します。スタートボタンや降参ボタンをクリックしたときの処理や広場やその上に表示されている戦車の画像をクリックしたときの処理を定義しています。
戦車をクリックしたときはボタン要素が画像で覆われているため画像しかクリックできないはずなのですが、サイズ差によって端のほうをクリックした場合に反応してしまう場合があります。そのため phase が 0 と 2 のときはここでもイベントが発火する場合の対策をしています。戦車のクリックとみなさなければならない場合は戦車の画像がクリックされたときと同じ処理をしています。
スタートボタンがクリックされたときはphase と moveCount をリセットして初期位置に配置・表示させ、スタートボタンの非表示と降参ボタンの表示をしています。また次にどうするかユーザーが迷わないように次にする操作(ゲーム開始直後であれば移動させたい青い戦車の指定)を表示させています。
降参ボタンがクリックされたときはその状態から全探索をして最短手順を表示させます。もし手詰まりで最短手順が存在しない場合はその旨を表示します。答えを表示し終わったら最後にスタートボタンを再表示させます。
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 |
function addEventListeners(){ // 広場をクリックしたときの処理 for(let row = 0; row < matMoveButtons.length; row++){ for(let col = 0; col < matMoveButtons[0].length; col++){ const btn = matMoveButtons[row][col]; if(btn == null) continue; btn.addEventListener('click', async(ev) => { if(ignoreClick) // ケーム開始前や戦車の移動中はなにもできないようにする return; ignoreClick = true; const imageIndex = matTanks[row][col]; if((phase == 0 || phase == 2) && imageIndex != -1) onClickTank(row, col); // 戦車がクリックされた処理と同じ処理をする(次回) else await onClickMoveButton(row, col); // 広場がクリックされたときの処理(次回) ignoreClick = false; }); } } // 広場の上に表示されている戦車の画像をクリックしたときの処理 for(let i = 0; i < tankImages.length; i++){ tankImages[i].addEventListener('click', (ev) => { if(ignoreClick) return; for(let row = 0; row < matTanks.length; row++){ for(let col = 0; col < matTanks[0].length; col++){ if(matTanks[row][col] == i) onClickTank(row, col); // 戦車がクリックされたときの処理(次回) } } }); } // スタートボタンがクリックされたとき $startButton?.addEventListener('click', () => { ignoreClick = false; phase = 0; moveCount = 0; initMatTanks(); showTanksStartPosition(); $startButton.style.display = 'none'; $giveupButton.style.display = 'block'; $naviText.innerText = (moveCount + 1) + '手目:移動させたい青軍の戦車を選択せよ'; }); // 降参ボタンがクリックされたとき $giveupButton?.addEventListener('click', async() => { $giveupButton.style.display = 'none'; $naviText.innerText = '答えを表示しています'; ignoreClick = true; // 最短手順を表示(次回) // 答えを表示し終わったらtrueを返す。最短手順が存在しない場合はfalseを返す。 const ret = await Solve(); if(ret) $naviText.innerText = ''; else $naviText.innerText = '手詰まりです。どうすることもできません。'; $startButton.style.display = 'block'; }); } |
移動する戦車を指定する処理と実際に移動する処理は次回とします。