今回はJavaScriptでミスタードリラーもどきを作ります。
Contents
もともとのミスタードリラーとは?
『ミスタードリラー』 (Mr. Driller) は、ナムコ(現バンダイナムコエンターテインメント)が開発・発売したアクションパズルゲームです。
主人公のホリ・ススムを操り、色分けされたブロックで構成された地面をひたすら掘っていき、地下のゴールを目指します。主人公がブロックで押し潰されるとミスになります。
空中であっても落下中のブロックは停止している同色のブロックが隣にあるとこれとくっついて停止します。4個以上くっつくと消滅します。またブロックが消滅することでその上にある他色のブロックの落下が開始されることがあります。パズル的要素があるゲームです。
エア(酸素)という時間制限があり、時間の経過とともにエアの量が減っていきます。酸欠でミスになることを回避するために途中に存在するエアカプセルを取ってエアの補給をしなければならないのですが、ブロックの中には壊してしまうとエアが急激に減少してしまう×ブロックが存在し、これがエアカプセルの四方全てを囲んでいることが多いです。
HTML部分
それではさっそく作っていきましょう。
最初にHTMLとCSSを示します。スマホで操作するためのボタンも表示させますが、これはPCのようにディスプレイの幅が広い時は表示させません(後述)。
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 |
<!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 = "canvas-outer"></div> <div id = "stage-clear"> ステージクリア<br>BONUS 5,000pt </div> <div id = "start-buttons"> <button id = "start">START</button> </div> <div id = "ctrl-buttons"> <p><button id = "up" class = "button">UP</button></p> <p> <button id = "left" class = "button">LEFT</button> <button id = "right" class = "button">RIGHT</button> </p> <p><button id = "down" class = "button">DOWN</button></p> </div> </div> <div id = "volume-ctrl"></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 |
body { background-color: black; color: white; } #container { margin-left: auto; margin-right: auto; width: 360px; } #field { position: relative; height: 480px; overflow: hidden; margin-bottom: 20px; } #canvas-outer { position: absolute; left: 0px; top: 0px; } #start-buttons { position: absolute; width: 340px; left: 0px; top: 300px; text-align: center; } #start { width: 160px; height: 70px; font-size: x-large; } #ctrl-buttons { position: absolute; width: 340px; left: 0px; top: 200px; text-align: center; display: none; } .button { width: 160px; height: 70px; background-color: transparent; color: white; font-size: x-large; } #stage-clear { position: absolute; width: 340px; left: 0px; top: 600px; font-size: 32px; font-weight: bold; text-align: center; display: 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 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 |
const CANVAS_WIDTH = 360; // canvasのサイズ const CANVAS_HEIGHT = 480; let scroll_y = 0; // スクロール量 // DOM要素 const $canvas = document.createElement('canvas'); const $canvasOuter = document.getElementById('canvas-outer'); const ctx = $canvas.getContext('2d'); const $startButtons = document.getElementById('start-buttons'); const $start = document.getElementById('start'); const $ctrlButtons = document.getElementById('ctrl-buttons'); // ブロック、プレイヤー、火花を描画するときに使うイメージ const blockImages = []; const playerImage = new Image(); const sparkImages = []; const BLOCK_SIZE = 48; // ブロックのサイズ const ROW_COUNT = 1000; // ブロックは縦横に何個並んでいるか? const COL_COUNT = 7; let player = null; let blocks2x2 = []; // Blockオブジェクトを格納する二次元配列 let fallingGroups = []; // くっついて落下するブロックのグループ let sparks = []; // 爆発で発生した火花オブジェクトを格納する配列 let isPlaying = false; // 現在プレイ中か? let score = 0; // スコアと残機 let rest = 5; let isDanger = false; // エアが残り少なくなったら警告音を鳴らす。その処理のためのフラグ // 移動のためのキーは押下されているか? let pressLeftKey = false; let pressRightKey = false; let pressUpKey = false; let pressDownKey = false; // 効果音とBGM const bgm = new Audio('./sounds/bgm.mp3'); const breakSound = new Audio('./sounds/break.mp3'); const getSound = new Audio('./sounds/get.mp3'); const hitSound = new Audio('./sounds/hit.mp3'); const deadSound = new Audio('./sounds/dead.mp3'); const dangerSound = new Audio('./sounds/danger.mp3'); const clearSound = new Audio('./sounds/clear.mp3'); const gameoverSound = new Audio('./sounds/gameover.mp3'); const sounds = [gameoverSound, breakSound, getSound, hitSound, deadSound, clearSound, dangerSound, bgm]; let volume = 0.3; // ボリューム |
Blockクラスの定義
ブロックを操作できるようにするためにBlockクラスを定義します。
colにBLOCK_SIZEを掛けた値がブロックを描画するときのX座標、rowにBLOCK_SIZEを掛けてスクロール量を引いた値がブロックを描画するときのY座標となります。
ブロックのタイプはオブジェクトの生成時にランダムに決めます。通常のブロック(タイプ番号が 2以上のもの)は4種類なので 2 ~ 5の整数をランダムに割り振り、0と1のものは座標が決まってからあとで再設定します。
タイプ0は破壊するとエアが激減するブロック、1はエアを回復するアイテム、それ以外は普通のブロックです。エアを回復するアイテム(イメージが卵のもの)はブロックと落下時の挙動が違うのですが、ブロックの一種として扱います。
プレイヤーの移動先にあるブロックは原則すぐに破壊できるのですが、破壊するとエアが激減するブロックだけは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 |
class Block{ constructor(row, col){ this.Row = row; this.Col = col; this.Y = row * BLOCK_SIZE; this.X = col * BLOCK_SIZE; this.IsDead = false; this.Falling = false; // 現在落下中か? this.WaitingFall = false; // 現在落下待ちの状態か?(上下に振動させる) this.DrawCount = 0; this.Type = 0; this.Life = 1; // 通常のブロック(Type が 2以上のもの)は4種類なので 2 ~ 5の整数をランダムに割り振る const types = [2, 3, 4, 5, ] const idx = Math.floor(Math.random() * types.length); this.Type = types[idx]; } Draw(){ // すでに破壊されたブロックと描画範囲がcanvasの範囲外のものは描画の処理をしない if(this.IsDead || this.Y - scroll_y < -BLOCK_SIZE || this.Y - scroll_y > CANVAS_HEIGHT) return; // 落下開始前のブロックを上下に振動させる this.DrawCount++; let plus = 0; if(this.WaitingFall && this.Y % BLOCK_SIZE == 0 && this.DrawCount % 8 < 4) plus = 4; if(this.Type != 1) ctx.drawImage(blockImages[this.Type], this.X, this.Y - scroll_y + plus, BLOCK_SIZE, BLOCK_SIZE); else { // カプセルは明るく描画したり暗く描画する let alpha = this.DrawCount % 240; // 0 ~ 239 alpha -= 120; // -120 ~ 119 alpha = Math.abs(alpha) / 120 * 0.5 + 0.5; // (0 ~ 119) / 120 * 0.5 + 0.5 ctx.globalAlpha = alpha; ctx.drawImage(blockImages[this.Type], this.X, this.Y - scroll_y + plus, BLOCK_SIZE, BLOCK_SIZE); ctx.globalAlpha = 1.0; } } // ブロックのタイプを0に変更する SetType0(){ this.Type = 0; this.Life = 5; } } |
FallingGroupクラスの定義
同時に落下するブロックをまとめて操作できるようにFallingGroupクラスを定義します。
エアカプセル以外のブロックは落下可能状態になってからしばらくしてから落下を開始します。エアカプセルは落下可能状態になったらただちに落下します。
落下処理は1段分落下させたあともう一度落下可能かの判定をするのですが、引き続き落下させる場合は待機することなくすぐに落下処理を繰り返します。
こうなると落下しているブロックが下にある落下待機中のブロックを追い抜いてしまう問題が発生します。そこで同一落下グループに属さない他のブロックが直下に存在するかを調べて追い越し禁止の処理をしています。
コンストラクタ
コンストラクタを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class FallingGroup { constructor(blocks, wait){ blocks.sort((a, b) => b.Row - a.Row); // Block.Rowを大きい順にソートする this.Blocks = blocks; this.WaitCount = wait; // 落下開始までの待機時間。0になったら落下を開始する this.FallDistance = 0; // どれだけ落下したか? BLOCK_SIZE以上になったら落下処理は完了したことになる this.IsDead = false; this.Blocks.forEach(block => block.WaitingFall = true); this.Set = new Set(); this.Blocks.forEach(block => this.Set.add(block)); } } |
下のブロックを追い抜かないようにする
追い越し禁止の処理で必要となる同一落下グループに属さない直下に存在するブロックを取得する処理を示します。ここでは各ブロックの下にあるブロックを取得して、これがコンストラクタ内でSetに追加したブロックとは異なるものかどうかで判定しています。
1 2 3 4 5 6 7 8 9 10 11 |
class FallingGroup { GetUnderBlock(){ for(let i=0; i<this.Blocks.length; i++){ const block = this.Blocks[i]; const under = blocks2x2[block.Row + 1][block.Col]; if(under != null && !under.IsDead && !this.Set.has(under)) return under; } return null; } } |
落下処理
グループに属するブロックを落下させる処理を示します。
追い越しがおきる場合はなにもしません。それ以外の場合はグループに属するブロックのY座標を増加させます。
FallDistance が BLOCK_SIZE 以上になったら落下処理が完了したことになります。この場合は落下状態、落下待機状態のフラグをクリアし、Block.Rowを1増やします。そしてブロックオブジェクトは二次元配列 blocks2x2 のどこかの要素に格納されているのですが、その位置を変更します。
blocks2x2[oldRow][block.Col]をblocks2x2[oldRow + 1][block.Col]に代入してblocks2x2[oldRow][block.Col]にnullを代入するという処理をしていますが、コンストラクタ内で最初にBlock.Rowを大きい順にソートしているので前から順に処理をして問題ありません。
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 |
class FallingGroup { Fall(){ if(this.IsDead) return; // 追い越し禁止の処理 if(this.GetUnderBlock() != null) return; this.FallDistance += 2; this.Blocks.forEach(block => { block.Y += 2; block.Falling = true; }); if(this.FallDistance >= BLOCK_SIZE){ // 落下処理が完了した this.Blocks.forEach(block => { const oldRow = block.Row; block.Row++; block.Y = block.Row * BLOCK_SIZE; block.WaitingFall = false; block.Falling = false; blocks2x2[oldRow + 1][block.Col] = block; blocks2x2[oldRow][block.Col] = null; }); this.IsDead = true; // このオブジェクトは不要 } } } |
Playerクラスの定義
プレイヤーを操作できるようにするためにPlayerクラスを定義します。
XY座標がBLOCK_SIZEの倍数であるときだけユーザーは移動処理を開始できます。それ以外のときは次の倍数まで自動で移動処理を継続します。
また連続でバック(上への移動)できる回数に制限をかけます(2回まで)。この制限は下に移動することでリセットされます。
コンストラクタ
コンストラクタを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Player { constructor(){ this.Row = 0; this.Col = 0; this.Y = 0; this.X = 0; this.Direct = ''; // 移動方向。空文字列以外のときはその方向に移動中 this.IsDead = false; this.Air = 50 * 60; this.AirFull = 50 * 60; // エアの最大値 this.CanBackCount = 2; // バック(上への移動)できる回数 } } |
初期化
ゲーム開始時やプレイヤー死亡状態から復活したときに状態を初期化する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 |
class Player { Init(row, col){ this.Direct = ''; this.CanBackCount = 2; this.Air = this.AirFull; this.Row = row; this.Col = col; this.Y = row * BLOCK_SIZE; this.X = col * BLOCK_SIZE; this.IsDead = false; } } |
更新処理
更新処理を示します。
Directに空文字以外がセットされているときはその方向に座標を移動させます。そして移動先の座標に到達したらDirectに空文字をセットしてそれ以上動かないようにします(移動ボタンが押しっぱなしの場合はすぐにDirectに移動方向の文字列がセットされるが・・・)。
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 |
class Player { Update(){ if(this.IsDead) return; if(this.Direct == 'left') this.X -= 4; if(this.Direct == 'right') this.X += 4; if(this.Direct == 'down') this.Y += 4; if(this.Direct == 'up'){ if(this.CanBackCount > 0) // 移動可のとき this.Y -= 4; else // 移動不可のとき this.Direct = ''; } // 移動完了時の処理 if(this.Direct == 'left' && this.X < (this.Col - 1) * BLOCK_SIZE){ this.Col--; this.X = this.Col * BLOCK_SIZE; this.Direct = ''; } if(this.Direct == 'right' && this.X > (this.Col + 1) * BLOCK_SIZE){ this.Col++; this.X = this.Col * BLOCK_SIZE; this.Direct = ''; } if(this.Direct == 'up' && this.Y < (this.Row - 1) * BLOCK_SIZE){ this.Row--; this.Y = this.Row * BLOCK_SIZE; this.Direct = ''; this.CanBackCount--; } if(this.Direct == 'down' && this.Y > (this.Row + 1) * BLOCK_SIZE){ this.Row++; this.Y = this.Row * BLOCK_SIZE; this.Direct = ''; this.CanBackCount = 2; } } } |
描画
描画処理を示します。死亡時以外はプレイヤーを描画します。
1 2 3 4 5 6 |
class Player { Draw(){ if(!this.IsDead) ctx.drawImage(playerImage, this.X, this.Y - scroll_y, BLOCK_SIZE, BLOCK_SIZE); } } |
Sparkクラスの定義
爆発で発生した火花を操作するためにSparkクラスを定義します。
コンストラクタ
コンストラクタの引数は火花が発生した座標と火花の移動速度です。
1 2 3 4 5 6 7 8 9 10 11 |
class Spark { constructor(x, y, vx, vy){ this.X = x; this.Y = y; this.VX = vx; this.VY = vy; this.MoveCount = 0; this.IsDead = false; this.ImageIndex = 0; } } |
移動
火花を移動させる処理を示します。移動回数によって描画で使用するイメージを変更します。使用できるイメージがなくなったらその火花は消滅します。
1 2 3 4 5 6 7 8 9 10 11 |
class Spark { Move(){ this.MoveCount++; this.X += this.VX; this.Y += this.VY; if(this.MoveCount % 4 == 0) this.ImageIndex++; if(this.ImageIndex >= sparkImages.length) this.IsDead = true; } } |
描画
描画処理を示します。
すでに寿命を終えた火花と描画範囲がcanvasの範囲外のものは描画の処理をしません。描画するときはImageIndexから描画で使用するイメージを取得して描画処理をおこないます。
1 2 3 4 5 6 7 8 9 |
class Spark { Draw(){ if(this.IsDead || this.Y - scroll_y < -BLOCK_SIZE || this.Y - scroll_y > CANVAS_HEIGHT) return; const image = sparkImages[this.ImageIndex]; ctx.drawImage(image, this.X, this.Y - scroll_y, BLOCK_SIZE, BLOCK_SIZE); } } |
使用するクラスの定義が完了したので次回はこれを使ってゲームを完成させます。