JavaScriptでミスタードリラーもどきを作る(1)の続きです。
Contents
ページが読み込まれたときの処理
ページが読み込まれたときにおこなう処理は以下のとおりです。
描画で使用するイメージの初期化
PlayerオブジェクトとBlockオブジェクトの生成
プレイヤーの位置を初期位置に設定する
イベントリスナの追加
レンジスライダーでボリューム調整を可能にする
モニターのリフレッシュレートによらず1秒間の更新を60回にする
BGMがエンドレスで再生されるようにする
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 |
window.onload = () => { // canvasのサイズ調整と追加 $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; $canvasOuter.appendChild($canvas); // ローカルストレージに前にプレイしたときのプレイヤー名が保存されているならセットする const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName) $playerName.value = savedName; initImages(); // 描画で使用するイメージの初期化(関数は後述) createBlocks(); // Blockオブジェクトの生成(関数は後述) addEventListeners(); // イベントリスナの追加(関数は後述) player = new Player(); // Playerオブジェクトの生成 player.Init(0, 3); scroll_y = player.Y - 200; initVolume('volume-ctrl', sounds); // レンジスライダーでボリューム調整を可能にする(関数は後述) // モニターのリフレッシュレートによらず1秒間の更新を60回にする const INTERVAL = 1000 / 60; let nextUpdateTime = new Date().getTime() + INTERVAL; frameProc(); function frameProc(){ const curTime = new Date().getTime(); if(nextUpdateTime < curTime){ nextUpdateTime += INTERVAL; update(); } requestAnimationFrame(() => frameProc()); } // BGMがエンドレスで再生されるようにする // フリー音源を使用しているのだが単純なループ再生では不自然になるので // 0.8秒ごとにチェックして1分20秒まで再生したら最初から再生する setInterval(() => { if(bgm.currentTime >= 1 * 60 + 20) bgm.currentTime = 0; }, 800); } |
イメージの初期化
描画で使うイメージを画像ファイルから読み出して定数(または配列)に格納します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function initImages(){ playerImage.src = `./images/player.png`; for(let i=0; i<=5; i++){ const image = new Image(); image.src = `./images/block${i}.png`; blockImages.push(image); } for(let i=0; i<=5; i++){ const image = new Image(); image.src = `./images/spark${i}.png`; sparkImages.push(image); } } |
ブロックを生成する
ブロックを生成する処理を示します。
配列 blocks2x2 を初期化します。そのあとランダムな色で通常のブロックを生成してこれを配列に格納し、その配列をblocks2x2に格納します(配列の配列を生成する)。
そのあと深さ4おきにランダムに選んだ1列だけエアカプセルに置き換え、その周りを破壊するとエアが激減するブロックに置き換えます。
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 |
function createBlocks(){ blocks2x2 = []; // 最初の行にはブロックは置かない const arr = []; for(let col = 0; col<COL_COUNT; col++) arr.push(null); blocks2x2.push(arr); for(let row = 1; row<ROW_COUNT; row++){ const arr = []; for(let col = 0; col<COL_COUNT; col++) arr.push(new Block(row, col)); blocks2x2.push(arr); } // ところどころにエアカプセルを配置する for(let row = 4; row<ROW_COUNT; row++){ if(row > 5 && row % 4 == 0){ const col = Math.floor(Math.random() * COL_COUNT); blocks2x2[row][col].Type = 1; blocks2x2[row + 1][col].SetType0(); blocks2x2[row - 1][col].SetType0(); if(col + 1 < COL_COUNT) blocks2x2[row][col + 1].SetType0(); if(col - 1 >= 0) blocks2x2[row][col - 1].SetType0(); } } } |
レンジスライダーでボリューム調整を可能にする
レンジスライダーでボリューム調整を可能にする処理を示します。まあこれは毎度毎度の処理です。
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 |
function initVolume(elementId, sounds){ // ローカルストレージに前に設定していたボリュームが保存されていたらそれを使う 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 = value / 100; setVolume(); }); // ボリューム設定を変更されたらローカルストレージに保存する $range.addEventListener('change', () => localStorage.setItem('hatodemowakaru-volume', volume.toString())); setVolume(); $span2.innerText = Math.round(volume * 100); $span2.style.marginLeft = '16px'; $range.value = volume * 100; $range.style.width = '250px'; $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; } } |
イベントリスナの追加
イベントリスナを追加する処理を示します。スタートボタンが押下されたらゲームを開始する処理以外はキーやボタンが押下されたり離されたらプレイヤーを移動させるフラグをセットしたりクリアしているだけです。
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 |
function addEventListeners(){ document.getElementById('start')?.addEventListener('click', () => gameStart()); // 関数は後述 function onkeydown(ev, isDown){ if(!isPlaying) return; if(ev.code == 'ArrowLeft'){ pressLeftKey = isDown; ev.preventDefault(); } if(ev.code == 'ArrowRight'){ pressRightKey = isDown; ev.preventDefault(); } if(ev.code == 'ArrowUp'){ pressUpKey = isDown; ev.preventDefault(); } if(ev.code == 'ArrowDown'){ pressDownKey = isDown; ev.preventDefault(); } if(ev.code == 'Space'){ ev.preventDefault(); } } document.onkeydown = (ev) => onkeydown(ev, true); document.onkeyup = (ev) => onkeydown(ev, false); const $up = document.getElementById('up'); const $down = document.getElementById('down'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); const arr = ['mousedown', 'touchstart', 'mouseup', 'touchend']; for(let i=0; i<arr.length; i++){ let b = (i== 0 || i == 1) ? true : false; $up?.addEventListener(arr[i], () => pressUpKey = b); $down?.addEventListener(arr[i], () => pressDownKey = b); $left?.addEventListener(arr[i], () => pressLeftKey = b); $right?.addEventListener(arr[i], () => pressRightKey = b); } const arr2 = [$up, $down, $left, $right]; for(let i=0; i<arr2.length; i++){ arr2[i]?.addEventListener('touchstart', (ev) => ev.preventDefault()); arr2[i]?.addEventListener('touchend', (ev) => ev.preventDefault()); } } |
ゲーム開始時の処理
ゲーム開始時の処理を示します。
スタートボタンの非表示
ディスプレイ幅が狭い場合は操作用のボタンの表示
ブロックの生成
プレイヤーの位置の初期化
スコア、残機数等のリセット
isPlayingフラグのセット
BGM再生の開始
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function gameStart(){ localStorage.setItem('hatodemowakaru-player-name', $playerName.value); $startButtons.style.display = 'none'; if(window.outerWidth < 600) $ctrlButtons.style.display = 'block'; createBlocks(); player.Init(0, 3); score = 0; rest = 5; stage = 1; capsuleTotalCount = 0; getCapsuleCount = 0; isPlaying = true; bgm.currentTime = 0; bgm.play(); } |
更新処理
更新処理を示します。
エアの消費
エアが20%以下に低下している場合は警報音の再生、そうでない場合は警報音を停止
プレイヤーの位置に応じたスクロール量(変数:scroll_y)の設定
ブロックの更新(落下・消滅等)
プレイヤーとブロックの当たり判定
火花オブジェクトが存在する場合はその移動
オブジェクトの描画
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 |
function update(){ if(isPlaying){ // プレイヤーの移動フラグがセットされている場合は移動 playerMove(); // 関数は後述 player.Update(); player.Air--; // エアの消費 // エアが20%以下に低下している場合は警報音の再生、そうでない場合は警報音を停止 if(!player.IsDead && player.Air < player.AirFull * 0.2) playDangerSound(); // 関数は後述 else stopDangerSound(); // プレイヤーの位置に応じたスクロール量(変数:scroll_y)の設定 scroll_y = player.Y - 200; updateBlocks();// ブロックの更新(関数は後述) judge(); // 当たり判定(関数は後述) sparks.forEach(_ => _.Move()); // 火花オブジェクトの移動 sparks = sparks.filter(_ => !_.IsDead); } draw(); // 描画(関数は後述) } |
警報音の再生と停止
警報音はフリー音源を使用しているのですが、短いのでループ再生させています。再生中に別の再生が開始しないように再生が開始されたら停止するまでisDangerフラグをtrueにして、そのあいだは新しい再生処理が開始されないようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function playDangerSound(){ if(isDanger) return; isDanger = true; dangerSound.currentTime = 0; dangerSound.loop = true; dangerSound.play(); } function stopDangerSound(){ isDanger = false; dangerSound.pause(); } |
プレイヤーの移動
プレイヤーを移動させる処理を示します。
press◯◯Keyフラグがセットされている場合はPlayer.Directにその方向の文字列をセットします。これによってPlayer.Update関数が呼び出されたらその方向に移動する処理がおこなわれます。
移動先にブロックがある場合は破壊してから移動します。ただしエアカプセルの場合はここでは破壊せず当たり判定の処理で破壊処理をおこないます(処理内容が異なるため)。
また以下の場合は移動させません。
移動先に向かってブロックが移動しているとき
移動先に破壊するとエアが激減するブロックがありそのLifeが2以上のとき
Lifeが2以上のエアが激減するブロックが移動先にある場合は金属音のような効果音を鳴らします。また移動処理は更新処理のたびにおこなわれますが、これだとLifeが5のブロックはすぐに破壊できてしまうのでignoreHitフラグをセットして0.3秒経過するまではブロックのLife減少がおきないようにします。そのためエアが激減するブロックを破壊するためには最低0.3×5秒必要です。
下向きに移動しようとしたときに配列の範囲外に出てしまう場合はステージクリアです。この場合はステージクリアの処理をおこないます。
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 88 89 90 91 92 |
let ignoreHit = false; function playerMove(){ if(player.IsDead) return; if(player.Direct == ''){ if(pressLeftKey){ if(player.Col == 0) // 範囲外には移動できない return; // 移動先にむかって落下中のブロックがあるときは移動できない(カプセルの場合は別) if(player.Row - 1 >= 0){ const overblock = blocks2x2[player.Row - 1][player.Col - 1]; if(overblock != null && !overblock.IsDead && overblock.WaitForFall <= 0 && overblock.Type != 1) return; } // 移動先にあるブロックを取得 const block = blocks2x2[player.Row][player.Col - 1]; // 移動先にブロックがない場合は移動できる if(block == null || block.IsDead) player.Direct = 'left'; // 移動先に通常のブロックまたはLife1の破壊するとエアが激減するブロックがある場合は // 破壊して移動できる else if(block.Life == 1){ player.Direct = 'left'; playerBreakBlock(block); // 後述 } // 移動先にLife2以上の破壊するとエアが激減するブロックがある場合は // 叩くことができるだけで移動できない(次に叩けるのは0.3秒後) else if(block.Life > 1 && !ignoreHit){ ignoreHit = true; setTimeout(() => ignoreHit = false, 300); playerHitBlockType0(block); // 後述 } } if(pressRightKey){ if(player.Col == COL_COUNT - 1) return; if(player.Row - 1 >= 0){ const overblock = blocks2x2[player.Row - 1][player.Col + 1]; if(overblock != null && !overblock.IsDead && overblock.WaitForFall <= 0 && overblock.Type != 1) return; } const block = blocks2x2[player.Row][player.Col + 1]; if(block == null || block.IsDead) player.Direct = 'right'; else if(block.Life == 1){ player.Direct = 'right'; playerBreakBlock(block); } else if(block.Life > 1 && !ignoreHit){ ignoreHit = true; setTimeout(() => ignoreHit = false, 300); playerHitBlockType0(block); } } if(pressDownKey){ // ステージクリア判定 if(player.Row + 1 >= ROW_COUNT){ onStageClear(); // 後述 return; } const underblock = blocks2x2[player.Row + 1][player.Col]; if(underblock == null || underblock.IsDead) player.Direct = 'down'; else if(underblock.Life == 1){ player.Direct = 'down'; playerBreakBlock(underblock); } else if(underblock.Life > 1 && !ignoreHit){ ignoreHit = true; setTimeout(() => ignoreHit = false, 300); playerHitBlockType0(underblock); } } if(pressUpKey){ if(player.Row - 1 < 0) return; const block = blocks2x2[player.Row - 1][player.Col]; if(block != null && !block.IsDead && block.Type != 1) return; player.Direct = 'up'; } } } |
ブロックを破壊する処理
ブロックを破壊する処理を示します。
対象がエアカプセル(Type == 1 のもの)のときも何もしない
通常のブロック(Type が 2 以上のもの)であれば同色のつながっているブロックすべて破壊する
スコアの加算
対象が破壊するとエアが激減するブロック(Type == 0 のもの)のときはエアを激減させる
ブロックが破壊されることで落下を開始するブロックがある場合は落下処理をする
効果音を鳴らす
効果音は短いドリルっぽい音にしたいのですが、再生時間が1秒以上のものしか見つからず、0.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 43 44 45 46 47 |
let timeoutID = null; // 効果音の再生停止をキャンセルするためにclearTimeout関数にわたす引数 function playerBreakBlock(block){ if(block.IsDead || block.Type == 1) return; if(blocks2x2.length == 0) return; if(block.Type >= 2){ // 通常のブロック const blocks = getConnectedBlocks(block, 1); // 同色のつながっているブロックを取得(後述) for(let i= 0; i<blocks.length; i++){ onBlockBroken(blocks[i]); // 爆発の火花を発生させる(後述) blocks[i].IsDead = true; } score += 10; } else if(block.Type == 0){ // 破壊するとエアが激減するブロック onBlockBroken(block); block.IsDead = true; score += 10; player.Air -= player.AirFull / 10 * 3; } // ブロックが破壊されることで落下を開始するブロックに関する処理 const noSupportBlocks = getNoSupportBlocks(); // 関数定義は次回 blocksToFallMode(noSupportBlocks); // 関数定義は次回 // 効果音の再生 breakSound.currentTime = 0; breakSound.play(); if(timeoutID != null) clearTimeout(timeoutID); // 0.2秒だけ再生する(新しい再生が開始されたときはこの処理はキャンセルされなければならない) timeoutID = setTimeout(() => { timeoutID = null; breakSound.pause(); }, 200); } function onBlockBroken(block){ const dx = [1, -1, -1, 1]; const dy = [1, 1, -1, -1]; for(let i=0; i<4; i++) sparks.push(new Spark(block.X, block.Y, dx[i], dy[i])); } |
これはLifeが2以上のブロックを叩いたときの処理です。Lifeを減らして効果音を再生しています。
1 2 3 4 5 |
function playerHitBlockType0(block){ block.Life--; hitSound.currentTime = 0; hitSound.play(); } |
同色のつながっているブロックを取得する処理を示します。
第一引数で渡されたブロックを起点に上下左右につながっている同色のブロックを幅優先探索で取得しています。また一度取得したブロックを何度も繰り返し探索しないようにしています。
第二引数は同色のつながっているブロックの最低個数です。後に4つ以上つながっている場合は消す処理でもこの関数を利用します。いまは複数のブロックがつながっていなくても取得対象なので 1 を指定します。
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 |
function getConnectedBlocks(block, min){ const set = new Set(); set.add(block); const type = block.Type; const qX = []; const qY = []; qX.push(block.Col); qY.push(block.Row); const dx = [1, -1, 0, 0]; const dy = [0, 0, -1, 1]; while(qX.length > 0){ const x = qX.shift(); const y = qY.shift(); for(let i=0; i<4; i++){ const nx = x + dx[i]; const ny = y + dy[i]; if(nx < 0 || nx >= COL_COUNT || ny < 0 || ny >= ROW_COUNT) continue; if(blocks2x2[ny][nx] == null || blocks2x2[ny][nx].IsDead || blocks2x2[ny][nx].Type != type) continue; if(!set.has(blocks2x2[ny][nx])){ set.add(blocks2x2[ny][nx]); qX.push(nx); qY.push(ny); } } } if(set.size < min) return []; const ret = []; for(let block of set.values()) ret.push(block); return ret; } |
ブロックが破壊されることで落下を開始するブロックがある場合は落下処理が必要ですが、これは次回とします。