動的迷路でドットイートゲームを作る(1)の続きです。今回はGameクラスを実装してゲームを完成させます。
Contents
Gameクラスの定義
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 36 37 38 39 40 41 42 43 44 45 46 47 |
class Game { constructor(){ // スコアと残機を表示するためのDOM要素 this.$score = document.getElementById('score'); this.$life = document.getElementById('life'); this.IsPlaying = false; this.StopingUpdate = true; // trueのときは各キャラの位置を変更しない this.Score = 0; this.Stage = 1; this.InitLife = 5; this.Life = this.InitLife; this.InitTimeUntilGenerateMaze = 3000; // 3秒おきに迷路を変更する this.TimeUntilGenerateMaze = this.InitTimeUntilGenerateMaze; // 迷路が変更されるまでの時間 this.VerticesMap = this.InitVertices(); // 頂点オブジェクトのMap this.EdgesMap = this.InitEdges(); // 辺オブジェクトのMap // 自機と敵の初期化 this.Player = new Player(); this.Player.Init(); this.Enemies = []; for(let i = 0; i < ENEMIES_COUNT; i++){ this.Enemies[i] = new Enemy(i); this.Enemies[i].Init(); } // 迷路の生成 this.GenerateMaze(); // 描画 this.Draw(); // 効果音 this.BGM = new Audio('./sounds/bgm.mp3'); this.SoundSelect = new Audio('./sounds/select.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.BGM, this.SoundGameClear, this.SoundMiss, this.SoundGameover]; // 前回の更新時刻(1秒間に60回更新する) this.PrevUpdateTime = Date.now(); } } |
Vertexオブジェクトの初期化
Vertexオブジェクトを生成します。また行番号と列番号からオブジェクトを取得できるようにGetVertexByRowCol関数も定義しておきます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Game { InitVertices(){ const map = new Map(); for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++) map.set(`${row},${col}`, new Vertex(row, col)); } return map; } GetVertexByRowCol(r, c){ return this.VerticesMap.get(`${r},${c}`); } } |
Edgeオブジェクトの初期化
Edgeオブジェクトを生成します。また辺の両端の頂点の行番号と列番号からオブジェクトを取得できるようにGetEdgeByRowCol関数も定義しておきます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class Game { InitEdges(){ // 上下左右にある頂点との間に辺を設置する const map = new Map(); for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT - 1; col++) map.set(`${row},${col},${row},${col + 1}`, new Edge(row, col, row, col + 1)); } for(let row = 0; row < ROW_COUNT - 1; row++){ for(let col = 0; col < COL_COUNT; col++) map.set(`${row},${col},${row + 1},${col}`, new Edge(row, col, row + 1, col)); } return map; } GetEdgeByRowCol(r1, c1, r2, c2){ // (r1, c1) と(r2, c2) が入れ替わっていても取得できるようにする const ret1 = this.EdgesMap.get(`${r1},${c1},${r2},${c2}`); if(ret1 != null) return ret1; else return this.EdgesMap.get(`${r2},${c2},${r1},${c1}`); } } |
迷路を生成する
迷路を生成する処理を示します。
各辺の重みとして乱数を割り当て、これをもとに最小全域木を構築します。ただし迷路生成時のタイミングで辺をわたっているキャラが存在する場合、辺がなくなってしまうと困った問題がおきます。そこでそのような辺が存在する場合は重みを 0(最小値)にして必ず辺が存在するようにします。
あとは辺を重みでソートして閉路ができないように辺の重みが小さいものからつないでいきます。閉路ができるかどうかはUnionFind木で判定することができます。辺をつないだら両端の頂点を同じグループにすることで、同じグループに属するかどうかを調べることで閉路ができるかどうかがわかります。
最小全域木を構築すると必ず葉ができます。迷路において葉とは行き止まりとなる部分です。行き止まりの部分ができないように葉は隣にある頂点と適当につないで行き止まりを解消します。
|
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 |
class Game { GenerateMaze(){ for(let e of this.EdgesMap.values()) e.W = Math.random(); // 現在キャラが移動中の辺があるかもしれない const passing = []; passing.push(this.GetEdgeByPosition(this.Player.X, this.Player.Y)); for(let i = 0; i < ENEMIES_COUNT; i++) passing.push(this.GetEdgeByPosition(this.Enemies[i].X, this.Enemies[i].Y)); passing.forEach(edge => { if(edge != null) edge.W = 0; // その場合は辺の重みは最小値の 0 とする }); // 頂点と辺のMapを配列に変換 const vertices = []; for(let v of this.VerticesMap.values()) vertices.push(v); const edges = []; for(let edge of this.EdgesMap.values()) edges.push(edge); // まず最小全域木を構築する // 辺を重みでソート edges.sort((a, b) => a.W - b.W); // 閉路ができないように辺の重みが小さいものからつないでいく // 閉路ができるかどうかはUnionFind木で判断する const tree = new UnionFindTree(edges.length); const nexts = []; // その頂点とつながっている頂点のindexを格納する(葉の判定でつかう) for(let i = 0; i < ROW_COUNT * COL_COUNT; i++) nexts.push(new Set()); edges.forEach(edge => { if(!tree.IsSame(edge.Index1, edge.Index2)){ // つないでも閉路はできないので tree.Unite(edge.Index1, edge.Index2); edge.IsUsed = tree; // つなぐ nexts[edge.Index1].add(edge.Index2); nexts[edge.Index2].add(edge.Index1); } else edge.IsUsed = false; // つながない }); // 葉となる頂点を調べる(辺でつながっている頂点がひとつしかないなら葉) const is_leafs = []; for (let i = 0; i < ROW_COUNT * COL_COUNT; i++) is_leafs[i] = nexts[i].size == 1; // 葉は上下左右にある頂点と適当につなぐ for (let i = 0; i < ROW_COUNT * COL_COUNT; i++){ if(is_leafs[i]){ const leaf = vertices[i]; const diffs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; for(let i = 0; i < diffs.length; i++){ // 上下左右に頂点があるならつなぐ const next_v = this.GetVertexByRowCol(leaf.Row + diffs[i][0], leaf.Col + diffs[i][1]); if(next_v != null){ const edge = this.GetEdgeByRowCol(leaf.Row, leaf.Col, next_v.Row, next_v.Col); if(!edge.IsUsed){ edge.IsUsed = true; is_leafs[edge.Index1] = false; // つながった頂点同士は葉ではなくなる is_leafs[edge.Index2] = false; break; } } } } } } } |
ゲーム開始の処理
ゲーム開始時の処理を示します。
|
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 { GameStart(){ this.Score = 0; // スコア、残機等の初期化 this.Stage = 1; this.Life = this.InitLife; document.getElementById('score').innerHTML = `Score ${this.Score.toLocaleString()}`; // 各キャラを初期位置に this.Player.Init(); this.Enemies.forEach(enemy => enemy.Init()); // 迷路の生成 this.TimeUntilGenerateMaze = this.InitTimeUntilGenerateMaze; this.GenerateMaze(); this.IsPlaying = true; this.StopingUpdate = false; document.getElementById('start-buttons').style.display = 'none'; this.SoundSelect.play(); this.BGM.currentTime = 0; this.BGM.play(); } } |
自機の移動
移動処理に関する関数を示します。
GetVertexByPosition関数は指定された座標と一致するVertexオブジェクトを返します。完全に一致したものがない場合はnullを返します。これによって現在位置が方向転換可能地点かどうかがわかります。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
class Game { GetVertexByPosition(x, y){ x -= MARGIN; y -= MARGIN; if(x % SIDE_LENGTH == 0 && y % SIDE_LENGTH == 0){ const c = Math.floor(x / SIDE_LENGTH); const r = Math.floor(y / SIDE_LENGTH); return this.GetVertexByRowCol(r, c); } return null; } } |
GetEdgeByPosition関数は指定された座標に対応するEdgeオブジェクトを返します。これはnullを返すことはありません。これによってキャラが現在通行中の辺がどれなのかがわかります。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
class Game { GetEdgeByPosition(x, y){ x -= MARGIN; y -= MARGIN; const c1 = Math.floor(x / SIDE_LENGTH); const c2 = Math.ceil(x / SIDE_LENGTH); const r1 = Math.floor(y / SIDE_LENGTH); const r2 = Math.ceil(y / SIDE_LENGTH); return this.GetEdgeByRowCol(r1, c1, r2, c2); } } |
CanMove関数は指定された座標から指定された方向に移動可能なのかを返します。指定方向に1ピクセルずらした座標は通行可能な辺なのかを調べています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Game { CanMove(x, y, dir){ let dx = 0; let dy = 0; if(dir == 'U') dy = -1; if(dir == 'D') dy = 1; if(dir == 'L') dx = -1; if(dir == 'R') dx = 1; const vertex = this.GetVertexByPosition(x + dx, y + dy); const edge = this.GetEdgeByPosition(x + dx, y + dy); return (vertex != null || (edge != null && edge.IsUsed)); } } |
SetPlayerDirct関数は自機が次に方向転換可能点に来たら方向転換する方向を設定します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Game { SetPlayerDirct(dir){ if(dir == 'N') return; if(dir == 'L') this.Player.NextDirect = 'L'; if(dir == 'R') this.Player.NextDirect = 'R'; if(dir == 'U') this.Player.NextDirect = 'U'; if(dir == 'D') this.Player.NextDirect = 'D'; } } |
MovePlayer関数は自機を移動させる関数です。
|
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 |
class Game { MovePlayer(){ // Player.NextDirectで指定された方向に移動可能であれば方向転換する if(this.Player.NextDirect == 'L' && this.CanMove(this.Player.X, this.Player.Y, 'L')) this.Player.Direct = 'L'; if(this.Player.NextDirect == 'R' && this.CanMove(this.Player.X, this.Player.Y, 'R')) this.Player.Direct = 'R'; if(this.Player.NextDirect == 'U' && this.CanMove(this.Player.X, this.Player.Y, 'U')) this.Player.Direct = 'U'; if(this.Player.NextDirect == 'D' && this.CanMove(this.Player.X, this.Player.Y, 'D')) this.Player.Direct = 'D'; // 設定された方向に移動できない場合は停止する if(this.Player.Direct == 'L' && !this.CanMove(this.Player.X, this.Player.Y, 'L')) this.Player.Direct = 'N'; if(this.Player.Direct == 'R' && !this.CanMove(this.Player.X, this.Player.Y, 'R')) this.Player.Direct = 'N'; if(this.Player.Direct == 'U' && !this.CanMove(this.Player.X, this.Player.Y, 'U')) this.Player.Direct = 'N'; if(this.Player.Direct == 'D' && !this.CanMove(this.Player.X, this.Player.Y, 'D')) this.Player.Direct = 'N'; // 設定された方向に実際に移動させる if(this.Player.Direct == 'L') this.Player.X--; if(this.Player.Direct == 'R') this.Player.X++; if(this.Player.Direct == 'U') this.Player.Y--; if(this.Player.Direct == 'D') this.Player.Y++; // 移動した先に餌があるなら食べる。そしてステージクリア判定をする const vertex = this.GetVertexByPosition(this.Player.X, this.Player.Y); if(vertex != null && !vertex.IsPlayerVisited){ vertex.IsPlayerVisited = true; this.Score += 10; } this.CheckStageClear(); } } |
ステージクリア
CheckStageClear関数はステージクリアしたかどうかを調べ、ステージクリアしているときはボーナスポイントを加算して次のステージに移行します。
|
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 { CheckStageClear(){ for(let v of this.VerticesMap.values()){ if(!v.IsPlayerVisited) return; } this.StopingUpdate = true; // つぎのステージが始まるまで2秒間更新処理を停止 this.SoundGameClear.play(); setTimeout(() => { for(let v of this.VerticesMap.values()) v.IsPlayerVisited = false; this.Score += this.Stage * 1000; this.Stage++; this.Player.Init(); this.Enemies.forEach(enemy => enemy.Init()); this.TimeUntilGenerateMaze = this.InitTimeUntilGenerateMaze; this.GenerateMaze(); this.StopingUpdate = false; }, 2000); } } |
敵の移動
敵を移動させる処理を示します。
最初に各敵の移動方向を決めます。その敵が方向転換可能地点にいなければなにもしません。
4つの敵が自機めがけて一斉突撃してくるとゲームにならないので乱数でバラします。一番かしこい(?)のが赤い敵で一番マヌケな敵がオレンジの敵です。
移動方向の候補を格納する配列を宣言して、一定確率で自機にむけて移動可能であればその方向を配列に格納します。
この方法だと候補となるものが存在しない場合がありえます。この場合は移動可能な方向をすべて配列に格納します。最後に候補が複数ある場合は乱数で適当に決めます。
移動方向が確定したらその方向に敵を移動させます。
|
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 |
class Game { MoveEnemies(){ for(let i = 0; i < ENEMIES_COUNT; i++){ const enemy = this.Enemies[i]; if(this.GetVertexByPosition(enemy.X, enemy.Y) == null) continue; const dirs = []; // 移動方向の候補 // 一定確率で自機にむけて移動可能であればその方向を移動方向の候補とする if(Math.random() > 0.2 * i){ if(enemy.Direct != 'R' && this.Player.X < enemy.X && this.CanMove(enemy.X, enemy.Y, 'L')) dirs.push('L'); if(enemy.Direct != 'L' && this.Player.X > enemy.X && this.CanMove(enemy.X, enemy.Y, 'R')) dirs.push('R'); if(enemy.Direct != 'D' && this.Player.Y < enemy.Y && this.CanMove(enemy.X, enemy.Y, 'U')) dirs.push('U'); if(enemy.Direct != 'U' && this.Player.Y > enemy.Y && this.CanMove(enemy.X, enemy.Y, 'D')) dirs.push('D'); } // 移動方向の候補がない場合は移動可能な方向を候補にいれる if(dirs.length == 0){ if(enemy.Direct != 'R' && this.CanMove(enemy.X, enemy.Y, 'L')) dirs.push('L'); if(enemy.Direct != 'L' && this.CanMove(enemy.X, enemy.Y, 'R')) dirs.push('R'); if(enemy.Direct != 'D' && this.CanMove(enemy.X, enemy.Y, 'U')) dirs.push('U'); if(enemy.Direct != 'U' && this.CanMove(enemy.X, enemy.Y, 'D')) dirs.push('D'); } // 複数の候補があるなら乱数で選択する const idx = Math.floor(Math.random() * dirs.length); enemy.Direct = dirs[idx]; } // 設定されている方向に移動させる for(let i = 0; i < ENEMIES_COUNT; i++){ const enemy = this.Enemies[i]; if(enemy.Direct == 'U') enemy.Y--; if(enemy.Direct == 'D') enemy.Y++; if(enemy.Direct == 'L') enemy.X--; if(enemy.Direct == 'R') enemy.X++; } } } |
当たり判定
当たり判定の処理を示します。自機と敵の距離を調べ、一定距離より近い場合があればミスとして扱います。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
class Game { HitJudge(){ for(let i = 0; i < ENEMIES_COUNT; i++){ const enemy = this.Enemies[i]; const d = Math.abs(this.Player.X - enemy.X) + Math.abs(this.Player.Y - enemy.Y); if(d < 16){ this.PlayerDead(); // ミス時の処理(後述) break; } } } } |
ミス時の処理
ミス時の処理を示します。
ミス時は残機を 1 減らしたあと、それでも残機が存在するときは 2 秒後に各キャラを初期位置に戻してゲームを再開します。残機が 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 30 31 32 33 34 35 36 |
class Game { PlayerDead(){ if(this.StopingUpdate) return; this.StopingUpdate = true; this.SoundMiss.play(); this.Life--; setTimeout(() => { if(this.Life > 0){ this.Player.Init(); this.Enemies.forEach(enemy => enemy.Init()); this.TimeUntilGenerateMaze = this.InitTimeUntilGenerateMaze; this.GenerateMaze(); this.StopingUpdate = false; } else if (this.IsPlaying) { // ゲームオーバー this.IsPlaying = false; this.BGM.pause(); this.SoundGameover.play(); // スコアランキングへの登録(後述) const player_name = document.getElementById('player-name').value; this.SaveScore(player_name, this.Score); setTimeout(() => { document.getElementById('start-buttons').style.display = 'block'; }, 3000); } }, 2000); } } |
更新と描画
更新と描画に関する処理を示します。
更新は16msおき(1秒間に60回)におこないます。これは前回更新時刻を記録しておけば実現できます。
更新時は各キャラの移動処理と当たり判定をするのですが、それとは別に迷路の変更処理も必要です。迷路を変更する時刻になったら迷路を変更する処理をおこないます。
|
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 |
class Game { Update(){ requestAnimationFrame(() => this.Update()); const cur = Date.now(); const diff = cur - this.PrevUpdateTime; if(diff >= 16) this.PrevUpdateTime = cur; if(diff >= 16 && this.IsPlaying && !this.StopingUpdate){ this.MovePlayer(); this.MoveEnemies(); this.HitJudge(); this.TimeUntilGenerateMaze -= diff; if(this.TimeUntilGenerateMaze < 0){ this.GenerateMaze(); this.TimeUntilGenerateMaze = this.InitTimeUntilGenerateMaze; } } this.Draw(); if(this.BGM.currentTime >= 32.5) this.BGM.currentTime = 0; } } |
描画処理をしめします。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Game { Draw(){ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); for(let edge of this.EdgesMap.values()) edge.Draw(); for(let v of this.VerticesMap.values()) v.Draw(); this.Player.Draw(); this.Enemies.forEach(enemy => enemy.Draw()); this.$score.innerHTML = `Score ${this.Score.toLocaleString()}`; this.$life.innerHTML = `Life ${this.Life}`; } } |
スコアランキングへの登録
スコアランキングに登録する処理をしめします。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Game { 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オブジェクトを生成してキー操作やボタンの押下に対応できるようにイベントリスナを追加します。そのあと更新処理を開始します。
|
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 |
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.addEventListener('keydown', (ev) => { if(game.IsPlaying) ev.preventDefault(); if(ev.key == 'ArrowLeft') game.SetPlayerDirct('L'); if(ev.key == 'ArrowRight') game.SetPlayerDirct('R'); if(ev.key == 'ArrowUp') game.SetPlayerDirct('U'); if(ev.key == 'ArrowDown') game.SetPlayerDirct('D'); }); const $up = document.getElementById('up'); const $down = document.getElementById('down'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); $up.addEventListener('mousedown', () => game.SetPlayerDirct('U')); $up.addEventListener('touchstart', () => game.SetPlayerDirct('U')); $down.addEventListener('mousedown', () => game.SetPlayerDirct('D')); $down.addEventListener('touchstart', () => game.SetPlayerDirct('D')); $left.addEventListener('mousedown', () => game.SetPlayerDirct('L')); $left.addEventListener('touchstart', () => game.SetPlayerDirct('L')); $right.addEventListener('mousedown', () => game.SetPlayerDirct('R')); $right.addEventListener('touchstart', () => game.SetPlayerDirct('R')); game.Update(); initVolume('volume', game.Sounds); }); |
レンジスライダーでボリューム調整ができるようにする処理はいつもどおりです。
|
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 |
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; } } |
