セグメント木をネタにゲームをつくってみました。その名も「お前もセグ木で殴ってやろうか?」
長さ8の配列のある要素を指定された値で更新することで指定された区間の和を合わせるという知的なパズル(?)です。
ではさっそく作っていきましょう。
Contents
HTML部分
HTML部分を示します。セグメント木の各ノードの値をtableタグをつかって表示させています。更新ボタンをクリックすると指定された値で各ノードが更新されていきます。下から上へむけて値が更新されていくときに殴っているかのような効果音が再生される仕様にしています。どうです、面白そうでしょう?(完全に自画自賛)
|
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 |
<!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"> <div id = "field"> <div id = "field-header"> <h1>お前もセグ木で殴ってやろうか?</h1> <div id = "score">Score 0</div><div id = "time"></div> </div> <table> <tr> <td colspan="8" id = "1" class = "seg"></td> </tr> <tr> <td colspan="4" id = "2" class = "seg"></td> <td colspan="4" id = "3" class = "seg"></td> </tr> <tr> <td colspan="2" id = "4" class = "seg"></td> <td colspan="2" id = "5" class = "seg"></td> <td colspan="2" id = "6" class = "seg"></td> <td colspan="2" id = "7" class = "seg"></td> </tr> <tr> <td id = "8" class = "seg"></td> <td id = "9" class = "seg"></td> <td id = "10" class = "seg"></td> <td id = "11" class = "seg"></td> <td id = "12" class = "seg"></td> <td id = "13" class = "seg"></td> <td id = "14" class = "seg"></td> <td id = "15" class = "seg"></td> </tr> <tr> <td id = "update-0" class = "seg"><button>更新</button></td> <td id = "update-1" class = "seg"><button>更新</button></td> <td id = "update-2" class = "seg"><button>更新</button></td> <td id = "update-3" class = "seg"><button>更新</button></td> <td id = "update-4" class = "seg"><button>更新</button></td> <td id = "update-5" class = "seg"><button>更新</button></td> <td id = "update-6" class = "seg"><button>更新</button></td> <td id = "update-7" class = "seg"><button>更新</button></td> </tr> <tr> <td class = "idx">[0]</td> <td class = "idx">[1]</td> <td class = "idx">[2]</td> <td class = "idx">[3]</td> <td class = "idx">[4]</td> <td class = "idx">[5]</td> <td class = "idx">[6]</td> <td class = "idx">[7]</td> </tr> </table> <p id = "navi"></p> <p id = "interval-sums"></p> <p id = "q2">更新に使える数: <span id = "update-values"></span></p> <p><button id = "reset">やりなおす</button></p> <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="./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 77 78 79 |
body { background-color: black; color: white; } #container { margin: 40px auto 40px auto; width: 360px; } #field-header { margin-bottom: 45px; } #field { width: 360px; height: 600px; margin-left: auto; margin-right: auto; position: relative; border: 0px #1DA1F2 solid; } #score { float: left; margin-left: 0px; } #time { float: right; margin-right: 0px; } h1 { color:aqua; font-size: x-large; } .seg { border: 1px solid white; width: 40px; text-align: center; height: 30px; } .idx { width: 40px; text-align: center; } #navi { margin-top: 30px; } .interval { width: 100px; } #update-values { margin-left: 50px; } #start-buttons { position: absolute; left: 0px; top: 130px; 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; } #reset { width: 160px; height: 40px; margin-left: 10px; } .red { color: magenta; font-weight: bold; } |
Gameクラスの定義
Gameクラスを定義します。まずはコンストラクタを示します。
|
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 |
class Game { constructor(){ this.IgnoreClick = true; this.Score = 0; this.Stage = 1; this.InitRemainingTime = 180 * 1000; // 制限時間は3分 this.RemainingTime = 0; // お題 this.OrgValues = []; // お題の配列の初期値 this.SumOfIntervals = []; // お題の区間の区間和 this.OrgUpdateValues = []; // お題の更新する値 this.N = 8; // 配列の長さ this.Nodes = []; // セグメント木のノードの値を格納する配列 // 元の配列の長さが 2のn乗なのでノードの個数はそのちょうど2倍でよい this.Nodes.length = this.N * 2; this.UpdateValues = []; // 更新に使われていない値 // 効果音 this.SoundSelect = new Audio('./sounds/select.mp3'); this.SoundOK = new Audio('./sounds/ok.mp3'); this.SoundNG = new Audio('./sounds/ng.mp3'); this.SoundUpdate = new Audio('./sounds/update.mp3'); this.SoundGameClear = new Audio('./sounds/game-clear.mp3'); this.SoundMiss = new Audio('./sounds/miss.mp3'); this.SoundGameover = new Audio('./sounds/gameover.mp3'); this.Sounds = [this.SoundSelect, this.SoundOK, this.SoundNG, this.SoundUpdate, this.SoundGameClear, this.SoundMiss, this.SoundGameover]; // 残り時間の計測と表示で使うDOM要素と変数 this.$time = document.getElementById('time'); this.PrevUpdateTime = Date.now(); } } |
問題を作る
問題を作る処理を示します。
お題となる元の配列に格納される値ですが、まずランダムに1~5の整数を生成します。そして0番目から7番目までの区間(全区間)、1番目から6番目までの区間、乱数で決めた区間の3つの区間の総和を計算します。そして配列の値の1~4個を別の値に置き換えています。3つの区間和から置き換えられる前の配列の値を当ててくださいというのが問題の趣旨です。
|
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 |
class Game { CreateQuestion(){ this.SumOfIntervals = []; this.UpdateValues = []; this.OrgValues = []; this.OrgUpdateValues = []; const indexes = []; for(let i = 0; i < this.N; i++){ this.OrgValues.push(Math.floor(Math.random() * 5) + 1); indexes.push(i); } this.SumOfIntervals.push({left: 0, right: 7, sum: getSum(this.OrgValues, 0, 7)}); this.SumOfIntervals.push({left: 1, right: 5, sum: getSum(this.OrgValues, 1, 5)}); const left = 2 + Math.floor(Math.random() * 3); const right = 7 - Math.floor(Math.random() * 3); this.SumOfIntervals.push({left: left, right: right, sum: getSum(this.OrgValues, left, right)}); function getSum(arr, a, b){ let ans = 0; for(let i = a; i <= b; i++) ans += arr[i]; return ans; } // 現在のステージに応じて1~4個を別の値に置き換える let changeCount = this.Stage; if(this.Stage >= 4) changeCount = 4; // どこを置き換えるか決める const changed = []; for(let i = 0; i < changeCount; i++){ const idx = Math.floor(Math.random() * indexes.length); changed.push(indexes[idx]); indexes.splice(idx, 1); } // 実際に置き換える。置き換える前の値を保存したあと最大 5 ズラす changed.forEach(idx => { this.OrgUpdateValues.push(this.OrgValues[idx]); this.OrgValues[idx] += Math.floor(Math.random() * 4); this.OrgValues[idx] %= 5; this.OrgValues[idx]++; }); this.Reset(); // お題が生成されたのでセグメント木と表示を初期化する(後述) } } |
セグメント木の初期化
セグメント木を初期化する処理を示します。
配列の値をセグメント木の葉(この場合は index が 8 ~ 15)に格納して各ノードの値を親ノードに加算していきます。16個あるノードのうち使用するのは index が 1 ~ 15番で 0 は使いません。なぜ index 0 を飛ばすのかというとこうすることでビットシフトを用いることで容易に親ノードと左の子ノードの位置がわかるからです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Game { InitSegTree(){ for(let i = 0; i < this.Nodes.length; i++) this.Nodes[i] = 0; for(let i = 0; i < this.N; i++) this.Nodes[this.N + i] = this.OrgValues[i]; for(let i = this.Nodes.length - 1; i > 1; i--) this.Nodes[i >> 1] += this.Nodes[i]; for(let i = 1; i < this.Nodes.length; i++) document.getElementById(`${i}`).innerHTML = this.Nodes[i]; } } |
表示の初期化
お題を生成後にセグメント木と表示を初期化する処理を示します。この関数はお題生成後と[やりなおし]選択時に呼び出されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Game { Reset(){ this.UpdateValues = []; for(let i = 0; i < this.OrgUpdateValues.length; i++) this.UpdateValues.push(this.OrgUpdateValues[i]); let html = '区間和が以下の値になるようにセグ木を更新せよ<br>'; for(let i = 0; i < this.SumOfIntervals.length; i++){ const sumOfInterval = this.SumOfIntervals[i]; html += `<span style="margin-right: 20px;">[${sumOfInterval.left}, ${sumOfInterval.right}] => ${sumOfInterval.sum}</span>`; } document.getElementById('interval-sums').innerHTML = html; document.getElementById('navi').innerHTML = `どこを ${this.UpdateValues[0]} に更新しますか?`; document.getElementById('update-values').innerHTML = this.UpdateValues.join(', '); this.InitSegTree(); } } |
セグメント木の更新
セグメント木を更新する処理を示します。第一引数は配列の index、第二引数は更新する値そのものです。セグメント木の葉に更新する値を代入して根に向けて区間和の値を更新していきます。これは普通のセグメント木でおこなわれる処理とまったく同じです。更新時にどのノードの値が更新されているのかがわかるようにするのとともに殴る効果音を再生しています。このパズルゲームはセグメント木でおこなわれている処理を可視化するという教材でもあるのです(←教材っておいおい)。
|
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 |
class Game { async Sleep(ms){ await new Promise(resolve => setTimeout(resolve, ms)); } async UpdateSegTree(idx, value){ document.getElementById('navi').innerHTML = `更新中です`; const ms = 400; idx += this.N; // 配列のindexからノードのindexを算出(ここでは8を足すだけ) this.Nodes[idx] = value; // 時間差をおきながら葉から根にむけて値を更新していく const $cell = document.getElementById(`${idx}`); $cell.innerHTML = this.Nodes[idx]; $cell.style.backgroundColor = '#0f0'; this.SoundUpdate.currentTime = 0; this.SoundUpdate.play(); await this.Sleep(ms); $cell.style.backgroundColor = '#000'; while(true){ idx >>= 1; this.Nodes[idx] = this.Nodes[idx << 1] + this.Nodes[(idx << 1) + 1]; const $cell = document.getElementById(`${idx}`); $cell.innerHTML = this.Nodes[idx]; $cell.style.backgroundColor = '#0f0'; this.SoundUpdate.currentTime = 0; this.SoundUpdate.play(); await this.Sleep(ms); $cell.style.backgroundColor = '#000'; if(idx == 1) // 更新が根に達したら更新処理は終了 break; } } } |
ユーザーが更新ボタンをクリックしたら更新すべき値で配列の対応する要素の値を更新します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Game { async UpdateValue(idx){ if(this.IgnoreClick) // 更新処理中に二重に処理が開始されないように制御する return; this.IgnoreClick = true; const v = this.UpdateValues.shift(); await this.UpdateSegTree(idx, v); // 更新処理が終わったら次に更新する値を表示する // 更新する値がなくなったら正誤判定をする if(this.UpdateValues.length > 0){ document.getElementById('navi').innerHTML = `どこを ${this.UpdateValues[0]} に更新しますか?`; document.getElementById('update-values').innerHTML = this.UpdateValues.join(', '); } else { await this.Check(); } this.IgnoreClick = false; } } |
正誤判定
ユーザーがおこなった更新が正しいかどうかを判定する処理を示します。
まず区間和を調べる処理を示します。根ノードからそれが被覆している区間と調べたい区間を被覆しているかを再帰処理で調べて総和を計算します。このとき調べたい区間を被覆している区間を強調表示してセグメント木ではどのような処理が行われているかを可視化します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Game { GetSum(left, right){ return this.Solve(left, right, 1, 0, this.N - 1); } Solve(left, right, index, l, r){ const $cell = document.getElementById(`${index}`); if(left <= l && r <= right){ $cell.style.backgroundColor = '#f00'; return this.Nodes[index]; } if(right < l || r < left) return 0; const ans1 = this.Solve(left, right, 2 * index, l, Math.floor((l + r) / 2)); const ans2 = this.Solve(left, right, 2 * index + 1, Math.ceil((l + r) / 2), r); return ans1 + ans2; } } |
上記関数を呼び出して実際に正誤判定をする処理を示します。
お題の区間和と一致しているかどうかでチャイムとブザーを鳴らします。3つとも一致していたら正解です。正解のときはステージ数と残り時間に応じて点数が加算され、次のステージに進みます。不正解のときは強制的にお題の初期状態に戻されます。
|
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 |
class Game { async Check(){ document.getElementById('update-values').innerHTML = ''; document.getElementById('navi').innerHTML = `判定中`; await this.Sleep(1000); const ms = 700; // お題の3つの区間和と一致しているかチェック let ok = true; for(let i = 0; i < this.SumOfIntervals.length; i++){ const sumOfInterval = this.SumOfIntervals[i]; if(this.GetSum(sumOfInterval.left, sumOfInterval.right) == sumOfInterval.sum){ this.SoundOK.currentTime = 0; this.SoundOK.play(); } else { this.SoundNG.currentTime = 0; this.SoundNG.play(); ok = false; } await this.Sleep(ms); for(let j = 1; j < this.Nodes.length; j++) document.getElementById(`${j}`).style.backgroundColor = '#000'; } // 正解なら次のステージへ if(ok){ document.getElementById('navi').innerHTML = `クリア!!`; this.SoundGameClear.play(); const add = Math.floor(this.RemainingTime / 100 + this.Stage * 5000); // 100000 -> 1000 document.getElementById('score').innerHTML = `<span class = "red">+ ${add.toLocaleString()}</span>`; await this.Sleep(2000); this.Score += add; document.getElementById('score').innerHTML = `Score ${this.Score.toLocaleString()}`; this.Stage++; this.CreateQuestion(); } else { // 不正解ならやり直し document.getElementById('navi').innerHTML = `失敗!!`; this.SoundMiss.play(); await this.Sleep(2000); this.Reset(); } } } |
ゲームの開始とやり直し
ゲームの開始とやり直しの処理を示します。
ゲーム開始時は、スコアとステージ数のリセットとお題の生成、[ゲームスタート]ボタンの非表示などの処理をしています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Game { GameStart(){ this.Score = 0; this.Stage = 1; document.getElementById('score').innerHTML = `Score ${this.Score.toLocaleString()}`; this.CreateQuestion(); this.RemainingTime = this.InitRemainingTime; this.IgnoreClick = false; document.getElementById('start-buttons').style.display = 'none'; this.SoundSelect.play(); } } |
[やり直し]が選択されたときは前述のReset関数を呼び出します。
|
1 2 3 4 5 6 7 8 |
class Game { OnClickReset(){ if(!this.IgnoreClick){ this.Reset(); this.SoundSelect.play(); } } } |
更新処理
時間の経過とともに残り時間を減算する処理と時間切れになったらゲームオーバー処理をおこなう処理を示します。
|
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 |
class Game { // 更新処理 Update(){ requestAnimationFrame(() => this.Update()); // 前回更新時刻との差を求め、プレイ中であればこれを残り時間から減算する const cur = Date.now(); const diff = cur - this.PrevUpdateTime; this.PrevUpdateTime = cur; if(!this.IgnoreClick){ this.RemainingTime -= diff; if(this.RemainingTime < 0) this.RemainingTime = 0; if(this.RemainingTime == 0) // 残り時間が 0 になったらゲームオーバー this.GameOver(); } const sec = (Math.floor(this.RemainingTime / 1000)).toString(); const ms = (this.RemainingTime % 1000).toString(); const text = `残り ${sec}.${ms.padStart(3, '0')}`; this.$time.innerHTML = text; } } |
ゲームオーバー時の処理
ゲームオーバー時の処理を示します。スコアランキングにスコアを登録します。
|
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 Game { GameOver(){ if(this.IgnoreClick) return; this.IgnoreClick = true; const player_name = document.getElementById('player-name').value; const score = this.Score; this.SaveScore(player_name, score); this.SoundGameover.currentTime = 0; this.SoundGameover.play(); setTimeout(() => { document.getElementById('start-buttons').style.display = 'block'; }, 3000); } SaveScore(player_name, score){ if(player_name == '') player_name = '名無しのゴンベ'; // JSON形式でPOST fetch('./ranking.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: player_name, score: score, }) }); } } |
サーバー側の処理とスコアランキングを表示させる処理はゲーム開始以降の処理 鳩でもわかるXORパズルをつくる(2)と同じなので省略します。
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。Gameオブジェクトを生成してイベントリスナを追加してUpdate関数を呼び出しているだけです。レンジスライダーで効果音の音量をコントロールできるようにする処理はいつもと同じ処理です。
|
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 |
window.addEventListener('load', () => { // テキストボックスに前回のプレイで入力されていた文字列があるならこれを自動設定する const $playerName = document.getElementById('player-name'); const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName && $playerName != null) $playerName.value = savedName; $playerName.addEventListener('change', () => { localStorage.setItem('hatodemowakaru-player-name', $playerName.value ); }); const game = new Game(); document.getElementById('start').addEventListener('click', () => game.GameStart()); document.getElementById('reset').addEventListener('click', () => game.OnClickReset()); for(let i = 0; i < 8; i++) document.getElementById('update-' + i).addEventListener('click', () => game.UpdateValue(i)); game.CreateQuestion(); game.Update(); initVolume('volume', game.Sounds); }); 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; } } |
