今回は以下の問題をヒントに石取りゲームをつくります。
N 個の石からなる山を用意します。一番最初に先手が石をとるときは 1 個以上 P 個以下の好きな個数だけ石をとれます。それ以降については、各プレーヤーは 1 個以上、直前にとられた石の個数 + 1 個以下の好きな個数だけ石をとれます(例:前の人が石を 3 個取った場合、次の人は 1 個以上 4 個以下の石を取ることができる)。
ただ実際に作ってみると必勝法があまりに簡単にわかってしまいました。
なので「1 個以上、直前にとられた石の個数 + 1 個以下」を「直前にとられた石の個数 + 3 個」に変更します。
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 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous"> <link rel="stylesheet" href="./style.css"> </head> <body> <div id= "container"> <div id= "container-inner"> <h1>石取りゲーム</h1> <p>取ることができる石は 1 個以上、直前にとられた石の個数 + 3 個までです。先に石を取ることができなくなったら負けです。</p> <div id= "navi"></div> <div id = "stones" class = "center"></div> <div class="d-grid gap-2 buttons"> <button id = "start" class = "button btn btn-primary btn-lg">ゲームスタート</button> <button id = "first" class = "button btn btn-primary btn-lg">先手</button> <button id = "second" class = "button btn btn-primary btn-lg">後手</button> <button id = "pick" class = "button btn btn-primary btn-lg">石を取る</button> <button id = "determine" class = "button btn btn-primary btn-lg">確定</button> <button id = "cancel" class = "button btn btn-primary btn-lg">キャンセル</button> </div> <div id = "volume"> 音量:<input type="range" id = "volume-range"><span id = "volume-text">0</span> <button id = "volume-test" class = "button btn btn-secondary">テスト</button> </div> </div><!--/#container-inner --> </div><!--/#container --> <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 |
body { background-color: #000; color: #fff; } h1 { font-size: 24px; } #container { width: 360px; } #container-inner { margin-top: 20px; margin-left: 20px; } #navi { margin-top: 20px; } .button { display: none; margin-bottom: 10px; } .buttons { width: 260px; margin: 20px auto 20px; } #volume { margin-top: 20px; } #volume-range { width: 200px; vertical-align: middle; margin-left: 10px; margin-right: 10px; } #volume-test { display: block; margin-left: 10px; margin-top: 10px; } .large { font-size: 80px; } .center { text-align: center; } .red { color: #ff69b4; font-weight: bold; } |
グローバル変数と定数
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 |
// DOM要素 const $stones = document.getElementById('stones'); const $navi = document.getElementById('navi'); // DOM要素(ボタン類) const $start = document.getElementById('start'); const $first = document.getElementById('first'); const $second = document.getElementById('second'); const $pick = document.getElementById('pick'); const $determine = document.getElementById('determine'); const $cancel = document.getElementById('cancel'); const buttons = [$start, $first, $second, $pick, $determine, $cancel]; let playerNumber = 0; // プレイヤーが先手を選択しているとき:0、後手を選択しているとき:1 let cpuNumber = 1; // プレイヤーが先手を選択しているとき:1、後手を選択しているとき:0 let stones = 0; // 残りの石の数 let max = 0; // 取ることができる石の数の最大値 let removeCount = 0; // プレイヤーのターンで取り除こうとしている石の数 let memo; // CPUの最善手を求めるときにメモ化再帰の処理をするのでメモ用 let ismemo; let addition = 3; // 「直前にとられた石の個数 + 3 個」。この部分を変えてみると面白くなるかも // 効果音 const selectSound = new Audio('./sounds/select.mp3'); const badSound = new Audio('./sounds/bad.mp3'); const cpuSound = new Audio('./sounds/cpu.mp3'); const winSound = new Audio('./sounds/win.mp3'); const loseSound = new Audio('./sounds/lose.mp3'); let volume = 0.1; // 初期のボリューム |
Modeクラスの定義
現在どのような状態(ゲーム開始前、先手後手選択時、プレイヤーの手番、CPUの手番)なのかでボタンの表示・非表示を変えます。そのため Mode クラスを定義して現在の状態がわかるようにします。
1 2 3 4 5 6 |
class Mode { static Start = 0; // ゲーム開始前 static FirstSecond = 1; // 先手後手選択時 static Player = 2; // プレイヤーの手番 static Cpu = 3; // CPUの手番 } |
ページが読み込まれたときの処理
ページが読み込まれたときにおこなわれる処理を示します。
ここでは[ゲームスタート]ボタンの表示、レンジスライダーでボリューム調整ができるようにする処理、イベントリスナの追加をおこなっています。
1 2 3 4 5 |
window.onload = () => { showButtons(Mode.Start); // 後述 initVolume(); // 後述 addEventListeners(); // 後述 } |
ボタンの表示・非表示
ゲームの状態に応じてボタンの表示・非表示を切りかえる処理を示します。ここではまずボタンをすべて非表示にしたあと、ゲームの状態に応じて表示すべきボタンだけ表示させる処理をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function showButtons(mode){ // ボタンをすべて非表示に for(let i = 0; i < buttons.length; i++) buttons[i].style.display = 'none'; // 必要なボタンだけ表示させる if(Mode.Start == mode) $start.style.display = 'block'; if(Mode.FirstSecond == mode){ $first.style.display = 'block'; $second.style.display = 'block'; } if(Mode.Player == mode){ $pick.style.display = 'block'; $determine.style.display = 'block'; $cancel.style.display = 'block'; } } |
ボリューム調整関連の処理
レンジスライダーでボリューム調整ができるようにする処理を示します。これはゲームをつくるときは毎度おなじみの処理です。
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 initVolume(){ const $volumeRange = document.getElementById('volume-range'); const $volumeText = document.getElementById('volume-text'); $volumeRange?.addEventListener('input', () => { const value = $volumeRange.value; $volumeText.innerText = value; volume = value / 100; setVolume(); }); setVolume(); $volumeText.innerText = volume * 100; $volumeRange.value = volume * 100; function setVolume(){ selectSound.volume = volume; badSound.volume = volume; cpuSound.volume = volume; winSound.volume = volume; loseSound.volume = volume; } const $volumeTest = document.getElementById('volume-test'); $volumeTest?.addEventListener('click', () => playSelectSound()); } |
効果音を再生する処理を示します。
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 playSelectSound(){ selectSound.currentTime = 0; selectSound.play(); } function playCpuSound(){ cpuSound.currentTime = 0; cpuSound.play(); } function playBadSound(){ badSound.currentTime = 0; badSound.play(); } function playWinSound(){ winSound.currentTime = 0; winSound.play(); } function playLoseSound(){ loseSound.currentTime = 0; loseSound.play(); } |
イベントリスナの追加
イベントリスナを追加する処理を示します。ボタンがクリックされたら対応する関数を呼び出します。
1 2 3 4 5 6 7 8 |
function addEventListeners(){ $start?.addEventListener('click', () => gameStart()); // gameStart関数等は後述 $first?.addEventListener('click', () => onClickFirst()); $second?.addEventListener('click', () => onClickSecond()); $pick?.addEventListener('click', () => onClickPick()); $determine?.addEventListener('click', () => onClickDetermine()); $cancel?.addEventListener('click', () => onClickCancel()); } |
ゲーム開始時の処理
ゲーム開始時の処理を示します。
[スタート]ボタンがクリックされたら乱数で、石の総数と初手で取ることができる石の数の上限を決めます。そのあとナビゲーションや現在の石の数の表示、ユーザーが先手と後手どちらを選択するかを決めるボタンの表示をおこないます。
1 2 3 4 5 6 7 8 |
function gameStart(){ stones = 50 + Math.floor(Math.random() * 20); max = Math.floor(Math.random() * 7) + 1; $navi.innerHTML = `先手または後手を選択してください。<br>先手は初手で <span class = "red">最大 ${max} 個</span> 取ることができます。`; showStonesCount(stones); // 残りの石数を表示する showButtons(Mode.FirstSecond); // 先手後手選択ボタンを表示させる playSelectSound(); } |
残りの石数を表示する処理を示します。
1 2 3 |
function showStonesCount(count){ $stones.innerHTML = `残り <span class ="large">${count}</span> 個`; } |
先手後手を選択する
ゲームが開始されたらユーザーに先手と後手のどちらを選択するかを決めさせるボタンが表示されます。このボタンをクリックしたときの処理を示します。
この段階でプレイヤーとCPUのどちらが先手になるかが決まるので、playerNumber と cpuNumber に適切な値を代入します。
先手選択時の処理
onPlayerTurn関数はプレイヤーにターンが回ってきたときにおこなう処理が定義されています。ここではナビゲーションの表示とプレイヤーが石を取るために操作するボタンを表示させています。removeCount はプレイヤーが取ろうとしている石の数を暫定的に格納するためのものです。キャンセルボタンをクリックしたらもとに戻せるように確定前は stones の値は変更させないようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function onClickFirst(){ playerNumber = 0; cpuNumber = 1; onPlayerTurn(); playSelectSound(); } function onPlayerTurn(){ showButtons(Mode.Player) removeCount = 0; $navi.innerHTML = `あなたの手番です。<span class = "red">最大 ${max} 個</span> 取ることができます。`; showStonesCount(stones); } |
後手選択時の処理
ユーザーが後手を選択したときの処理を示します。
後手を選択した場合は初手をCPUの着手させたあとプレイヤーの着手となります。
1 2 3 4 5 6 7 8 |
async function onClickSecond(){ playerNumber = 1; cpuNumber = 0; playSelectSound(); await cpuThink(); // 初手をCPUの着手させる(後述) onPlayerTurn(); } |
プレイヤーが石を取る処理
プレイヤーが石を取る処理を示します。removeCount をインクリメントして stones – removeCount の値を残りの石の数として表示させるのですが、このとき石の数が負数になったり max を超えてはいけません。不正な操作がおこなわれた場合は警告音を出します。
1 2 3 4 5 6 7 8 9 10 |
function onClickPick(){ if(removeCount + 1 <= max && stones - removeCount - 1 >= 0){ removeCount++; showStonesCount(stones - removeCount); playSelectSound(); } else { playBadSound(); // 不正な操作がおこなわれた } } |
[キャンセル]ボタンがクリックされたときはプレイヤーにターンが回ってきたときと同じ状態に戻します。
1 2 3 |
function onClickCancel(){ onPlayerTurn(); } |
確定後の処理
プレイヤーが石を取り[確定]ボタンをクリックしたときの処理を示します。
石をひとつも取っていない場合は不正な操作となります。そうでない場合は stones を removeCount だけ減らしてCPUが取ることができる石の数の上限を removeCount + addition に変更します。そのあと手番を CPU に移すのですが、ゲームセットになっているかもしれないのでそのチェックをおこないます。ゲームセットになっていない場合はCPUに着手させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
async function onClickDetermine(){ if(removeCount > 0){ stones -= removeCount; max = removeCount + addition; showButtons(Mode.Cpu); playSelectSound(); if(await checkWin()) // プレイヤーの勝利かもしれないのでチェック(後述) return; await cpuThink(); // CPUの着手 if(checkLose()) // CPUの勝利かもしれないのでチェック(後述) return; onPlayerTurn(); } else playBadSound(); } |
着手後、勝負がついているかもしれないのでそれをチェックする処理を示します。それぞれが着手したあと石の数が 0 になっていれば着手した側の勝利です。
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 |
// 待機するための関数 async function sleep(ms){ await new Promise(resolve => setTimeout(resolve, ms)); } async function checkWin(){ if(stones == 0){ showStonesCount(0); await sleep(500); $navi.innerText = `あなたの勝ちです。`; showButtons(Mode.Start); playWinSound(); return true; } else return false; } function checkLose(){ if(stones == 0){ showStonesCount(0); $navi.innerText = `あなたの負けです。`; showButtons(Mode.Start); playLoseSound(); return true; } else return false; } |
CPU の着手
CPUに着手させる処理を示します。勝つための手が存在する場合はそれを選択します。勝つための手が存在しない場合は乱数で適当な数の石を取ります。
そのあと残りの石数と取れる数の上限を更新します。CPUの着手で勝負がつかない場合は手番をプレイヤーに戻します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
async function cpuThink(){ $navi.innerText = `CPU が考えています。`; await sleep(500); const bests = bestMove(cpuNumber, stones, max); // 後述 let removeCount = 0; if(bests.length > 0) // 勝つための手が存在する場合はそれを選択 removeCount = bests[0]; else removeCount = Math.floor(Math.random() * max) + 1; // ない場合は乱数で適当に取る stones -= removeCount; // 残りの石数と取れる数の上限を更新する max = removeCount + addition; showStonesCount(stones); playCpuSound(); $navi.innerText = `CPU は ${removeCount} 個 取りました。`; await sleep(1000); } |
メモ用の3次元配列の初期化
CPUが最善手を探すときにメモ化再帰の処理をおこないます。initArray 関数は メモ用の3次元配列 memo[2][rowCount][colCount] を生成する処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function initArray(rowCount, colCount){ const memo = []; for(let i=0; i<2; i++){ const arr1 = []; for(let row=0; row<rowCount; row++){ const arr2 = []; for(let col=0; col<colCount; col++){ arr2.push(false); } arr1.push(arr2); } memo.push(arr1); } return memo; } |
CPU の最善手を探す
bestMove関数は第位置引数が 0 のときは先手の、1 のときは後手の最善手を探します。第二引数は残されている石の数、第三引数は取ることができる上限です。メモ用の配列を初期化して相手に手番を渡した場合、どうやっても相手が負けてしまう手を探し、結果を配列で返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function bestMove(number, n, maxCount){ const enemy = number == 0 ? 1 : 0; memo = initArray(n + 1, n + 1 + addition); ismemo = initArray(n + 1, n + 1 + addition); const list = []; for (let i = 1; i <= maxCount; i++) { if (n - i >= 0) { if (win(n - i, enemy, i + addition) == false) list.push(i); } } return list; } |
メモ化再帰
win 関数は第位置引数が 0 のときは先手の、1 のときは後手の最善手を探します。第二引数は残されている石の数、第三引数は取ることができる上限です。
第一引数を変えて再帰呼び出しをすることで、相手に手番を渡した場合、どうやっても相手が負けてしまう手を探し、あれば true を返します。同じ引数で何度も呼び出されることがあるため、一度計算した結果は配列に保存してまた同じ引数で呼び出されたときは配列に格納されている値を返すことで処理を高速化しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function win(n, number, maxCount) { if (ismemo[number][n][maxCount]) return memo[number][n][maxCount]; const enemy = number == 0 ? 1 : 0; if (n == 0) return false; let b = false; for (let i = 1; i <= maxCount; i++) { if (n - i >= 0) { if (win(n - i, enemy, i + addition) == false) { b = true; break; } } } ismemo[number][n][maxCount] = true; memo[number][n][maxCount] = b; return memo[number][n][maxCount]; } |