JavaScriptでライツアウトをつくります。
ライツアウトは、5×5の形に並んだライトをある法則にしたがってすべて消灯 (lights out) させることを目的としたパズルです。よくあるのはあるライトを押すと、自身とその上下左右最大4個のライトが一緒に反転するというものです。
ライツアウト攻略法は検索すると多くの情報が公開されていて問題を入力すると答えが表示されるサイトもあります。ここでは既存のものと差別化するつもりで点灯・消灯の反転だけでなく複数の色が変化するタイプのものをつくります。といってもこれも既存品が多数あるのですが・・・。
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 | <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Lights out 4</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= "container-inner">         <h1>Lights out 4</h1>         <div id= "how-to-play">             <p>クリックするとクリックされたマスとその上下左右のマスの色が                 <span class ="red">赤</span> →                 <span class ="green">緑</span> →                 <span class ="blue">青</span> →                 <span class ="white">黒</span> と変化します。すべて黒にしてください。</p>         </div>         <div id= "field">         </div>         <div id= "navi">開始するときはスタートボタンをクリックしてください</div>         <button id = "start" class = "button">スタート</button>         <button id = "giveup" class = "button">ギブアップ</button>         <div id = "volume">             音量:<input type="range" id = "volume-range"><span id = "volume-text">0</span>             <button id = "volume-test">テスト</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 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 | body {     background-color: #000;     color: #fff; } #container {     width: 360px; } #container-inner {     margin-top: 20px;     margin-left: 20px; } #field {     margin-bottom: 20px; } #giveup {     display: none; } #volume {     margin-top: 20px; } #volume-range {     width: 200px;     vertical-align: middle;     margin-left: 10px;     margin-right: 10px; } #volume-test {     width: 100px;     height: 50px;     margin-left: 10px;     margin-top: 10px; } .cell {     border: 1px solid #fff;     vertical-align: middle;     border-radius: 8px;     box-shadow: 0 2px 0 #fff; } /* クリック時 */ .cell:active {     box-shadow: none;     transform: translateY(2px); } .red {     color: #f00;     font-weight: bold; } .green {     color: #0f0;     font-weight: bold; } .blue {     color: #0ff;     font-weight: bold; } .white {     color: #fff;     font-weight: bold; } .button {     width: 120px;     height: 60px; } | 
グローバル変数と定数
グローバル変数と定数を示します。
| 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 | const ROW_COUNT = 5; // マスは 5 x 5 とする const COL_COUNT = 5; const CELL_WIDTH = 60; // セルのサイズ const CELL_HEIGHT = 60; // DOM要素 const $field = document.getElementById('field'); const $start = document.getElementById('start'); const $giveup = document.getElementById('giveup'); const $navi = document.getElementById('navi'); const $cells = []; const COLOR_COUNT = 4; // セルの色は全部で4色 const colors = [ // '#000' => '#f00' => '#0f0' => '#0ff' と変化する(事情があって逆順にしている)     '#000',     '#0ff',     '#0f0',     '#f00', ]; let cellStates = []; // セルの状態(何回クリックすると黒になるか?) let giveuped = false; // ユーザーがギブアップした状態か? let ignoreClick = true; // セルをクリックしても反応させない let limit = 0; // 残りのクリック数(クリアできたとしてもこれが負数なら失敗) let limitMax = 3; // クリアに必要なクリック数 // 効果音 const clickSound = new Audio('./sounds/click.mp3'); const badSound = new Audio('./sounds/bad.mp3'); const clearSound = new Audio('./sounds/clear.mp3'); const badEndSound = new Audio('./sounds/bad-end.mp3'); let volume = 0.1; // ボリューム | 
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。セルを表示するためのdiv要素を追加し、イベントリスナを追加します。
| 1 2 3 4 5 | window.onload = () => {     createCells(); // 後述     addEventListeners(); // 後述     initVolume(); // 後述 } | 
セルの生成
セルを表示するためのdiv要素を追加する処理を示します。
セルは 1 行に 5 個、これを 5 行分生成します。生成されたセルはサイズを適切な大きさに調整したあと、何行何列目のものかすぐわかるように2次元配列に格納しておきます。またセルに文字が表示されているときにクリックすると自動的に文字が選択されてしまうのでそうはならないように、userSelect = ‘none’を設定しています。
| 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 | function createCells(){     for(let row = 0; row < ROW_COUNT; row++){         const $row = document.createElement('div');         $field?.appendChild($row);         $cells.push([]);         for(let col = 0; col < COL_COUNT; col++){             const $cell = document.createElement('div');             $cell.style.width =  CELL_WIDTH + 'px';             $cell.style.height = CELL_HEIGHT + 'px';             $cell.style.marginRight = col != COL_COUNT - 1 ? 5 + 'px' : '0px';             $cell.style.marginBottom = row != ROW_COUNT - 1 ? 5 + 'px' : '0px';             $cell.style.color = '#000';             $cell.style.lineHeight = CELL_HEIGHT + 'px';             $cell.style.textAlign = 'center';             $cell.style.fontSize = '32px';             $cell.innerHTML ='';             $cell.className = 'cell';             $cell.id = `cell-${row}-${col}`;             $cell.style.display = 'inline-block';             $cell.style.userSelect = 'none'; // クリックしても文字選択されない             $row?.appendChild($cell);             $cells[row].push($cell);         }     } } | 
ボリューム設定と効果音の再生
効果音の初期化とボリューム設定を可能にする処理を示します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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(){         clickSound.volume = volume;         badSound.volume = volume;         clearSound.volume = volume;         badEndSound.volume = volume;     }     const $volumeTest = document.getElementById('volume-test');     $volumeTest?.addEventListener('click', () => playClickSound()); } | 
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | function playClickSound(){     clickSound.currentTime = 0;     clickSound.play(); } function playBadSound(){     badSound.currentTime = 0;     badSound.play(); } function playClearSound(){     clearSound.currentTime = 0;     clearSound.play(); } function playBadEndSound(){     badEndSound.currentTime = 0;     badEndSound.play(); } | 
イベントリスナの追加
| 1 2 3 4 5 6 7 8 | function addEventListeners(){     for(let row=0; row<$cells.length; row++){         for(let col=0; col<$cells[row].length; col++)             $cells[row][col].addEventListener('click', () => onClickCell(row, col));     }     $start?.addEventListener('click', () => gameStart());     $giveup?.addEventListener('click', () => giveup()); } | 
ゲーム開始の処理
ゲームを開始する処理(クリア後続きを再開する処理でもある)を示します。
最初は簡単な問題からはじめてクリアできたらクリアまでに必要なクリックの回数が多い問題に変更します。クリアまでにクリックできる回数には上限を設けてそれを超えた場合は不正解として扱います。どうしても答えがわからない場合のために、ギブアップボタンをクリックしたら、各セルをクリックする回数を表示させます。
スタートボタンをクリックしたらスタートボタンは非表示にしてギブアップ用のボタンを表示させます。またセルをクリックしたらクリックに反応するようにします。ギブアップしたときに表示されていた数字を非表示にしてクリックできる回数を limitMax に設定します。
そのあと問題を生成します。initCellStates関数でセルの初期状態を設定します。そしてその状態から各セルに適切な色をつけて表示させます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function gameStart(){     playClickSound();     giveuped = false; // ギブアップしていた場合、フラグをクリア     ignoreClick = false;     $start.style.display = 'none';     $giveup.style.display = 'inline';     hideNumbers(); // 表示されていた答えを非表示に(後述)     limit = limitMax;     cellStates = initCellStates(); // セルの新しい初期状態を設定(後述)     setCellColors(); // セルに色をつける     $navi.style.color = '#fff';     $navi.innerText = `残り ${limit} 手`; } | 
問題を生成する
セルに新しい初期状態を設定する処理を示します。
2 次元配列に 0 を設定します。そのあと各要素にランダムに値を設定します。この値は各セルをこの回数だけクリックすればよいというものです。最初は (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 | function initCellStates(){     let cellStates = [];     for(let row = 0; row < $cells.length; row++){         cellStates.push([]);         for(let col = 0; col < $cells[row].length; col++)             cellStates[row].push(0);     }     let row = 2;     let col = 2;     const arr = [-2, -1, 1, 2];     // limitMax が大きくなりすぎると無限ループになるので注意する     for(let done = 0; done < limitMax; ){         const dx = arr[Math.floor(Math.random() * 4)];         const dy = arr[Math.floor(Math.random() * 4)];         row += dy;         col += dx;         if(row < 0)             row = 0;         if(row >= ROW_COUNT)             row = ROW_COUNT - 1;         if(col < 0)             col = 0;         if(col >= COL_COUNT)             col = COL_COUNT - 1;         if(cellStates[row][col] + 1 < COLOR_COUNT){             cellStates[row][col]++;             done++;         }     }     return cellStates; } | 
getCellColors関数はcellStates[row][col]を調べてそのセルの色を取得する関数です。クリックするとそのセルとその上下左右のセルの色が変わるので、それらのセルに対応する cellStates の値を合計したものを COLOR_COUNT で割ったときの剰余から色がわかります。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | function getCellColorNumber(row, col){     let value = cellStates[row][col];     const dx = [1, -1, 0, 0];     const dy = [0, 0, 1, -1];     for(let i = 0; i < 4; i++){         const nrow = row + dy[i]         const ncol = col + dx[i]         if(nrow < 0 || ncol < 0 || nrow >= $cells.length || ncol >= $cells[row].length)             continue;         value += cellStates[nrow][ncol];     }     return value % COLOR_COUNT; } | 
setCellColors関数はgetCellColorNumber関数から取得できた色をセルに反映させるためのものです。
| 1 2 3 4 5 6 7 8 | function setCellColors(){     for(let row = 0; row < $cells.length; row++){         for(let col = 0; col < $cells[row].length; col++){             const num = getCellColorNumber(row, col);             $cells[row][col].style.backgroundColor = colors[num];         }     } } | 
showNumbers関数は2次元配列 cellStates の値を対応するセルに表示させます。また hideNumbers関数はこの値を非表示にします。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function showNumbers(){     for(let row = 0; row < ROW_COUNT; row++){         for(let col = 0; col < COL_COUNT; col++){             const number = cellStates[row][col];             $cells[row][col].innerHTML = number;         }     } } function hideNumbers(){     for(let row = 0; row < ROW_COUNT; row++){         for(let col = 0; col < COL_COUNT; col++){             $cells[row][col].innerHTML = '';         }     } } | 
ギブアップ時の処理
ギブアップしたときの処理を示します。
giveuped フラグをセットして cellStates の値を表示させます。giveuped フラグがセットされているときにゲームクリアしても失敗として処理されます。ゲームを再開できるようにスタートボタンを再表示させます。
| 1 2 3 4 5 6 7 8 9 | function giveup(){     playClickSound();     giveuped = true;     $start.style.display = 'inline';     $giveup.style.display = 'none';     showNumbers(); // 答えを表示     $navi.innerText = 'マスに表示されている数が答えです。'; } | 
セルをクリックしたとき
セルをクリックしたときにおこなわれる処理を示します。ignoreClickフラグがセットされていなければセルを選択したときの処理をおこないます。
対応するcellStatesの値をデクリメントします。そして各セルの色を求めて反映させます。そのあとステージクリアチェックをして必要であれば処理をおこないます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | function onClickCell(row, col){     if(ignoreClick){ // この場合は警告音だけ鳴らして何もしない         playBadSound();         return;     }     playClickSound();     cellStates[row][col]--;     if(cellStates[row][col] < 0)         cellStates[row][col] += COLOR_COUNT;     setCellColors();     checkClear(); // ステージクリアチェック(後述)     if(giveuped)         showNumbers(); } | 
ステージクリア判定
ステージクリアをチェックする処理を示します。
残り手数をデクリメントしてステージクリアしているか調べます。すべてのセルにおいてgetCellColorNumber関数が0を返したときはステージクリアです。そうでない場合は残り手数を表示させます。すでに手数オーバーしているときはその旨を表示します。ステージクリアの場合はonGameClear関数を呼び出します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | function checkClear(){     limit--;     for(let row = 0; row < $cells.length; row++){         for(let col = 0; col < $cells[row].length; col++){             const num = getCellColorNumber(row, col)             if(num != 0){                 if(limit >= 0)                     $navi.innerText = `残り ${limit} 手`;                 else {                     $navi.style.color = '#f00';                     $navi.innerText = `${limit * -1} 手 オーバーしています`;                 }                 return;             }         }     }     onStageClear(); } | 
ステージクリア時におこなわれる処理を示します。
ステージクリア以降はクリックに反応しないようにします。giveuped フラグが立っていない状態で limit が負数でなければステージクリアです。ステージクリアのときは次の問題を2手分難しくします。クリア判定されてもステージクリアではない場合はその旨を表示します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | function onStageClear(){     ignoreClick = true;     $start.style.display = 'inline';     $giveup.style.display = 'none';     if(!giveuped){         if(limit >= 0){             $navi.innerText = 'クリア!!';             playClearSound();             limitMax += 2;         }         else {             $navi.innerText = '手数オーバーです。';             playBadEndSound();         }     }     else {         $navi.innerText = '次回は答えを見ないでクリアを目指しましょう。';         playBadEndSound();     } } | 
