JavaScriptでミスタードリラーもどきを作る(2)の続きです。今回はブロックを落下させる処理を実装します。
Contents
ブロックの落下処理
ブロックが破壊されることで落下を開始するブロックがある場合は落下処理をおこないます。カプセル以外のブロックは支えがなくなるとグラグラ振動し、しばらくすると落下を開始ます。
支えがないブロックを取得する
そこでまず「支えがないブロック」を取得します。その取得方法ですが、幅優先探索で取得します。
上に破壊されていないし落下を開始していないブロックがあるならたどっていく
上下左右に破壊されていないし落下を開始していない同色のブロックがあるならたどっていく
(ただしエアカプセルはくっつかないので下・左・右にはたどらない)
たどりつくことができなかったブロックは支えがないブロックである
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 |
function getNoSupportBlocks(){ const checked2x2 = []; for(let row = 0; row < ROW_COUNT; row++){ const arr = []; for(let col = 0; col < COL_COUNT; col++) arr.push(false); checked2x2.push(arr); } const dx = [1, -1, 0, 0]; const dy = [0, 0, -1, 1]; const qX = []; const qY = []; const set = new Set(); for(let col = 0; col < COL_COUNT; col++){ const lowest = blocks2x2[ROW_COUNT - 1][col]; if(lowest == null || lowest.IsDead) continue; qY.push(ROW_COUNT - 1); qX.push(col); set.add(lowest); } while(qX.length > 0){ const x = qX.shift(); const y = qY.shift(); const type = blocks2x2[y][x].Type; 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 || blocks2x2[ny][nx] == null || blocks2x2[ny][nx].IsDead) continue; if(dy[i] != -1){ // 左右と下方向につながっている場合 if(blocks2x2[ny][nx].Type != type) // 異なる色でつながっている場合は無視 continue; if(blocks2x2[ny][nx].Type == 1) // 同色でもエアカプセルなら無視 continue; } if(blocks2x2[ny][nx].WaitForFall <= 0) // すでに落下を開始している場合も無視 continue; if(!checked2x2[ny][nx]){ checked2x2[ny][nx] = true; qX.push(nx); qY.push(ny); set.add(blocks2x2[ny][nx]); } } } // set内に存在しないブロックは支えのないブロックである // ここからすでに落下(待機)モードにあるブロックは除いたものを配列で返す const blocks = []; for(let row = 0; row < checked2x2.length; row++){ for(let col = 0; col < COL_COUNT; col++){ const block = blocks2x2[row][col]; if(set.has(block)) continue; if(block != null && !block.IsDead && block.WaitForFall > WAIT_FOR_FALL) blocks.push(block); } } return blocks; } |
落下待機モードへの変更
支えがないブロックを取得したら落下待機状態にします。ただしエアカプセルはただちに落下させます。ただし、
エアカプセルであっても下にブロックがある場合はただちに落下することができないので待機状態にします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function blocksToFallMode(noSupportBlocks){ // 引数を落下待機にするブロックとエアカプセルにわける const blocks = noSupportBlocks.filter(_ => _.Type != 1); const capsules = noSupportBlocks.filter(_ => _.Type == 1); // エアカプセルであって下にブロックがある場合は他のブロックと同様に待機状態にする // このときエアカプセルは下にあるものから評価しなければならないのでソートする const noWait = []; capsules.sort((a,b) => b.Row - a.Row); capsules.forEach(capsule => { if(blocks.find(_ => _.Col == capsule.Col && _.Row == capsule.Row + 1) != null) blocks.push(capsule); else noWait.push(capsule); }); // 待機状態のブロックは60更新後に落下を開始する // そうでないならただちに落下を開始する blocks.forEach(_ => _.StartFall(WAIT_FOR_FALL)); noWait.forEach(_ => _.StartFall(0)); } |
ブロックの更新
ブロックの更新処理は以下のようになります。
Block.FinishedFall == true になっているものがあれば落下が完了したブロックなのであるなら確認する
Block.FinishedFall == true になっているブロックはBlock.Rowの値をインクリメントし、二次元配列 blocks2x2の格納位置を変更する
落下が完了したブロックがない場合はこれで処理は終了
そうでない場合は他のブロックとくっつくものと引き続き落下するものにわける
引き続き落下する場合は待機時間なしで次の落下処理を開始する。ただし下(同色のブロックであれば上下左右)に落下待機モードのブロックがある場合は自分自身もその時間は待機する
くっつくものは同色で4つ以上くっついている場合、消滅させる
消滅した場合、新たな支えがないブロックが発生するので blocksToFallMode(getNoSupportBlocks()); を実行する
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 93 94 |
function updateBlocks(){ const finishedBlocks = []; // いままさに落下が完了したブロック for(let row = ROW_COUNT - 1; row >= 0; row--){ for(let col = 0; col < COL_COUNT; col++){ const block = blocks2x2[row][col]; if(block != null && !block.IsDead){ block.Update(); if(block.FinishedFall){ finishedBlocks.push(block); block.FinishedFall = false; // 格納位置を変更 if(blocks2x2[block.Row][block.Col] == null || blocks2x2[block.Row][block.Col].IsDead) blocks2x2[block.Row][block.Col] = block; if(blocks2x2[row][col] == block) blocks2x2[row][col] = null; } } } } // 落下が完了したブロックがない場合はこれで処理は終了 if(finishedBlocks.length == 0) return; // 他のブロックとくっつくものと引き続き落下するものにわける // 支えのないブロックを取得してこのなかにfinishedBlocksが含まれるか調べる const noSupportBlocks = getNoSupportBlocks(); const setNoSupport = new Set(); noSupportBlocks.forEach(_ => setNoSupport.add(_)); const falls = []; // 引き続き落下するブロック const fix = []; // 他のブロックとくっついて止まるブロック finishedBlocks.forEach(_ => { if(setNoSupport.has(_)) falls.push(_); else fix.push(_); }); // 引き続き落下するブロックであっても // その下(同色であれば上下左右)にあるのが落下待機中のブロックかもしれないので注意する // この場合は落下待機時間の最大値をセットする const setFalls = new Set(); falls.forEach(_ => setFalls.add(_)); falls.forEach(fall => { if(fall.Row + 1 < ROW_COUNT){ // 自身に結合したブロック(ただしfalls内に存在するブロックは除く)の // 待機時間の最大値を取得したい const waitForFalls = []; waitForFalls.push(0); const under = blocks2x2[fall.Row + 1][fall.Col]; if(under != null && !under.IsDead) waitForFalls.push(under.WaitForFall); if(fall.Type != 1){ const connectedBlocks = getConnectedBlocks(fall, 1); connectedBlocks.forEach(_ => { if(_ != null && !_.IsDead && !setFalls.has(_)) waitForFalls.push(_.WaitForFall); }); } let max = 0; waitForFalls.forEach(_ => { if(max < _) max = _; }); fall.StartFall(max); } }); // 停止したブロックは4つ以上くっついているかもしれないので確認する if(delete4(fix)) blocksToFallMode(getNoSupportBlocks()); function delete4(fixBlocks){ let done = false; for(let i= 0; i<fixBlocks.length; i++){ if(fixBlocks[i].Type == 1) continue; const connectedBlocks = getConnectedBlocks(fixBlocks[i], 4); connectedBlocks.forEach(_ => { _.IsDead = true; onBlockBroken(_); done = true; }); } return done; } } |
あたり判定
あたり判定(プレイヤーの死亡判定)は以下のようにおこないます。
エアカプセル以外の破壊されていないブロックがプレイヤーと同じ位置に存在する場合、プレイヤーと同じX座標でY座標の差がBLOCK_SIZE / 8未満の位置に存在する場合はプレイヤー死亡とする
プレイヤーがいる位置にカプセルがある場合はカプセル回収の処理をおこなう
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 judge(){ if(player.IsDead) return; if(player.Air <= 0){ onPlayerDead(); return; } if(player.Row < 1) // この位置なら頭上にブロックが落ちてくることはない return; const overBlock = blocks2x2[player.Row - 1][player.Col]; if(overBlock != null && !overBlock.IsDead && overBlock.Type != 1 && player.Y - overBlock.Y < BLOCK_SIZE / 8) onPlayerDead(); const block = blocks2x2[player.Row][player.Col]; if(block != null && !block.IsDead){ if(block.Type == 1) playerBreakCapsule(block); else onPlayerDead(block); } } |
カプセルを回収時の処理
カプセルを回収したときの処理を示します。
回収数の加算
カプセルが破壊されることで支えがないブロックが発生するかもしれないので blocksToFallMode(getNoSupportBlocks()) を実行する
エアの回復(最大値を超えない範囲で最大値の10%分)
スコアの加算
爆発の火花の発生
効果音の再生
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function playerBreakCapsule(capsule){ capsule.IsDead = true; getCapsuleCount++; blocksToFallMode(getNoSupportBlocks()); player.Air += player.AirFull / 10; if(player.Air > player.AirFull) player.Air = player.AirFull; score += 300; const dx = [4, -4, -4, 4]; const dy = [4, 4, -4, -4]; for(let i=0; i<4; i++) sparks.push(new Spark(capsule.X, capsule.Y, dx[i], dy[i])); getSound.currentTime = 0; getSound.play(); } |
プレイヤー死亡時の処理
プレイヤー死亡時は以下の処理をおこないます。
残機 1 減らす
効果音の再生
爆発の火花の発生
自機がまだ存在する場合はプレイヤーを復活させる
存在しない場合はゲームオーバー処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function onPlayerDead(block){ player.IsDead = true; rest--; deadSound.currentTime = 0; deadSound.play(); for(let i=0; i<16; i++){ const v = 4 + 2 * Math.random(); const angle = 2 * Math.PI * Math.random(); const vx = v * Math.cos(angle); const vy = v * Math.sin(angle); sparks.push(new Spark(player.X, player.Y, vx, vy)); } if(rest > 0) resurrect(); else onGameovered(); } |
プレイヤーを復活させる処理を示します。
プレイヤーがいる位置から上にあるブロックは必要ないので対応するblocks2x2[row][col]にnullを代入しています。またそれよりも下の部分は残すのですが、落下中のブロックが宙ぶらりんの状態で浮いてしまうので落下状態と待機状態をいずれも解除して落下処理前の位置に再設定しています。
そのあと死亡位置の中央でプレイヤーを復活させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function resurrect(){ setTimeout(() => { for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ const block = blocks2x2[row][col]; if(block == null || block.IsDead) continue; if(row <= player.Row) blocks2x2[row][col] = null; // プレイヤーの位置より上なら取り除く else{ block.WaitForFall = 100000000; // 落下(待機)状態を解除 block.Y = row * BLOCK_SIZE; // 落下開始前の座標を再セット block.X = col * BLOCK_SIZE; } } } player.Init(player.Row, 3); }, 3000); } |
ゲームオーバー時とステージクリア時の処理
ゲームオーバー時は以下の処理をおこないます。
スマホ用の操作ボタンの非表示
ゲームスタートボタンの再表示
press◯◯Keyフラグのクリア
地味に重要なのがプレイヤーを移動させるためのpress◯◯Keyフラグのクリアです。スマホでプレイしていてゲームオーバーになった場合、これにともなって操作用ボタンが消えてしまうのでこれらのフラグはユーザーの操作ではクリアされなくなるのです(PCからの操作であれば問題ない)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function onGameovered(){ bgm.pause(); $ctrlButtons.style.display = 'none'; pressLeftKey = false; pressRightKey = false; pressUpKey = false; pressDownKey = false; setTimeout(() => { if(isPlaying){ isPlaying = false; $startButtons.style.display = 'block'; gameoverSound.currentTime = 0; gameoverSound.play(); } }, 3000); } |
ステージクリア時の処理を示します。
BGMの停止と効果音の再生
落下中のブロックに関する情報のクリア
ステージクリアを示す文字列の表示
次のステージのブロックの生成
プレイヤーの位置の初期化
停止していたBGMの再生
ステージクリアを示す文字列(DOM要素を使う)を下から上へ移動するように表示させます。
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 |
function onStageClear(){ player.IsDead = true; bgm.pause(); clearSound.currentTime = 0; clearSound.play(); // ステージクリアを示す文字列(DOM要素)を下から上へ流れるように表示する let stageClearY = CANVAS_HEIGHT; const $stageClear = document.getElementById('stage-clear'); $stageClear.style.display = 'block'; const intervalID = setInterval(() => { stageClearY -= 4; $stageClear.style.top = stageClearY + 'px'; }, 1000 / 60); setTimeout(() => { clearInterval(intervalID); player.IsDead = false; score += 5000; createBlocks(); player.Init(0, 3); player.Direct == ''; $stageClear.style.display = 'none'; bgm.currentTime = 0; bgm.play(); }, 3000); } |
描画処理
描画処理を示します。
ブロックを描画する
ゴールが見える位置にあるときは描画する
火花があるときは描画する
プレイヤーが生存するときは描画する
上部にスコア等を描画する
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 |
function draw(){ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // ブロックの描画 if(blocks2x2.length == ROW_COUNT){ for(let row = 0; row<ROW_COUNT; row++){ for(let col = 0; col<COL_COUNT; col++){ const block = blocks2x2[row][col]; if(block != null && !block.IsDead) blocks2x2[row][col].Draw(); } } } // ゴールの描画 const y = BLOCK_SIZE * ROW_COUNT - scroll_y; if(y < CANVAS_HEIGHT){ ctx.fillStyle = '#f00'; ctx?.fillRect(0, y + 30, CANVAS_WIDTH, 60); ctx.fillStyle = '#fff'; ctx.font='48px Arial'; ctx.textBaseline = 'top'; const w = ctx.measureText('GOAL').width; ctx.fillText('GOAL',(CANVAS_WIDTH - w) / 2, y + 30 + 10); } sparks.forEach(_ => _.Draw()); player.Draw(); // スコアの描画 ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, 70); ctx.fillStyle = '#fff'; ctx.font='24px Arial'; ctx.textBaseline = 'top'; ctx.fillText(`Score ${score.toLocaleString()}`,10, 10) ctx.fillText(`残 ${rest}`,10, 40); const air = Math.max(Math.floor(player.Air / 60 * 2), 0); const airText = `Air ${air}`; const airTextWidth = ctx.measureText(airText).width; ctx.fillText(airText,CANVAS_WIDTH - airTextWidth - 30, 10); const depthText = `Depth ${player.Row} / ${ROW_COUNT-1} - ${stage.toString()}`; const depthTextWidth = ctx.measureText(depthText).width; ctx.fillText(depthText, CANVAS_WIDTH - depthTextWidth - 30, 40) } |