今回はJavaScriptで音ゲーをつくります。音ゲーなのでアイドルらしい気の利いた音楽を鳴らしてみたいものです。無料で使える音源はないか調べてみたところ、以下がありました。
目指せ!KIRAKIRAアイドル! @ フリーBGM DOVA-SYNDROME OFFICIAL YouTube CHANNEL
これをつかって以下のような音ゲーをつくります。下の四角い部分をクリックまたはタップしたらHitまたはMiss、クリックできずに通り過ぎてしまった場合はThroughと表示させます。最後に成績を表示します。
HTML部分
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 |
<!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" type = "text/css" media = "all"> </head> <body> <div id = "container"> <div id = "field"> <canvas id = "canvas"></canvas> <button class = "button" id = "zero"></button> <button class = "button" id = "one"></button> <button class = "button" id = "two"></button> <button class = "button" id = "three"></button> </div> <button id = "start">START</button> <p class = "mt50">音源はここから拝借しました。</p> <p><a href="https://www.youtube.com/watch?v=e9NOhg2Iv9E" target="_blank" rel="noopener">目指せ!KIRAKIRAアイドル! @ フリーBGM DOVA-SYNDROME OFFICIAL YouTube CHANNEL</a></p> </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 |
body { color: white; background-color: #000; } #container { width: 360px; } #field { position: relative; } .button { color: white; position: absolute; background-color: transparent; } #start { color: white; position: absolute; background-color: transparent; width: 160px; height: 60px; top: 300px; left: 110px; border: 2px outset #fff; } a{ color: #0ff; font-weight: bold; } a:hover{ color: #f00; } .mt50 { margin-top: 50px; } |
グローバル変数と定数
JavaScript部分を示します。最初に主なグローバル変数と定数を示します。
index.js
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 |
const BUTTONS_TOP = 400; // 各レーンのボタン上部のY座標 const BUTTONS_HEIGHT = 50; // 各レーンのボタンの高さ const LANE_WIDTH = 70; // レーンの幅 const LANE_LEFTS = [10, 100, 190, 280]; // 各レーンの左側のX座標 const BLOCK_HEIGHT = 50; // 落ちてくるブロックの当たり判定のある部分の高さ // 落ちてくるブロックの当たり判定のある部分のY座標の最小値と最大値 const HIT_Y_MIN = BUTTONS_TOP - BLOCK_HEIGHT; const HIT_Y_MAX = BUTTONS_TOP + BUTTONS_HEIGHT; // canvasの幅と高さ const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 540; // 開始ボタンと各レーン(0番~3番)のボタンの要素 const $start = document.getElementById('start'); const $zero = document.getElementById('zero'); const $one = document.getElementById('one'); const $two = document.getElementById('two'); const $three = document.getElementById('three'); // canvas要素とコンテキスト const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); // 効果音とBGM const okSound = new Audio('./ok.wav'); const missSound = new Audio('./miss.mp3'); const bgm = new Audio('./bgm.mp3'); const drumrollSound1 = new Audio('./drumroll1.mp3'); const drumrollSound2 = new Audio('./drumroll2.mp3'); // 落ちてくるブロックの配列 let blocks = []; // ヒット、ミス、見逃しの文字を表示するレーンの配列 const hitLaneNumbers = []; const missLaneNumbers = []; const throughLaneNumbers = []; let isPlaying = false; // 現在プレイ中か? let speed = 3; // 落下速度 let hitCount = 0; // 成功数 let missCount = 0; // ミス数 let throughCount = 0; // 見逃し数 |
描画処理
canvasをクリアする処理を示します。
1 2 3 4 |
function clearCanvas(){ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, $canvas.width, $canvas.height); } |
各レーンを描画する処理を示します。
1 2 3 4 5 |
function drawLanes(){ ctx.strokeStyle = '#ccc'; for(let i =0; i < LANE_LEFTS.length; i++) ctx.strokeRect(LANE_LEFTS[i], 0, LANE_WIDTH, $canvas.height); } |
ヒットしたとき該当レーンに’HIT’と表示する処理を示します。
1 2 3 4 5 6 |
function drawHit(laneNum){ ctx.fillStyle = '#0ff'; ctx.font = '20px bold MS ゴシック'; const textWidth = ctx.measureText('Hit').width; ctx.fillText('HIT', LANE_LEFTS[laneNum] + (LANE_WIDTH - textWidth) / 2, HIT_Y_MAX + 10); } |
見逃したときにしたとき該当レーンに’Miss’と表示する処理を示します。’HIT’の文字と重ならないようにY座標を少し下にします。
1 2 3 4 5 6 |
function drawThrough(laneNum){ ctx.fillStyle = '#ff0'; ctx.font = '20px bold MS ゴシック'; const textWidth = ctx.measureText('Miss').width; ctx.fillText('Miss', LANE_LEFTS[laneNum] + (LANE_WIDTH - textWidth) / 2, HIT_Y_MAX + 30); } |
間違えてタップしたとき該当レーンに’Miss’と表示する処理を示します。見逃しと扱いを変えたいのでY座標だけでなく表示色も変えます。
1 2 3 4 5 6 |
function drawMiss(laneNum){ ctx.fillStyle = '#f0f'; ctx.font = '20px bold MS ゴシック'; const textWidth = ctx.measureText('Miss').width; ctx.fillText('Miss', LANE_LEFTS[laneNum] + (LANE_WIDTH - textWidth) / 2, HIT_Y_MAX + 50); } |
ヒット時の処理を示します。hitCountをインクリメントして効果音を再生します。またレーンの番号を配列hitLaneNumbersに格納し、0.5秒後に取り除きます。
1 2 3 4 5 6 7 8 9 10 |
function onHit(laneNum){ hitCount++; okSound.currentTime = 0; okSound.play(); hitLaneNumbers.push(laneNum); setTimeout(() => { hitLaneNumbers.shift(); }, 500); } |
ミス時の処理を示します。missCountをインクリメントして効果音を再生します。またレーンの番号を配列missLaneNumbersに格納し、0.5秒後に取り除きます。
1 2 3 4 5 6 7 8 9 10 |
function onMiss(laneNum){ missCount++; missSound.currentTime = 0; missSound.play(); missLaneNumbers.push(laneNum); setTimeout(() => { missLaneNumbers.shift(); }, 500); } |
見逃し時の処理を示します。throughCountをインクリメントし、レーンの番号を配列throughLaneNumbersに格納し、0.5秒後に取り除きます。
1 2 3 4 5 6 7 8 |
function onThrough(laneNum){ throughCount++; throughLaneNumbers.push(laneNum); setTimeout(() => { throughLaneNumbers.shift(); }, 500); } |
Blockクラスの定義
上から落ちてくるブロックに関する処理をおこなうためにBlockクラスを定義します。
コンストラクタの第二引数は上から落ちてくるときにどれだけ遅れてくるかを示すものです。
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 |
class Block{ constructor(laneNum, delay){ this.LaneNumber = laneNum; this.X = LANE_LEFTS[laneNum]; this.Y = - 80 * delay; this.Width = LANE_WIDTH; this.Height = BLOCK_HEIGHT; // ヒットと見逃しを二重に評価しないためのフラグ this.IsHit = false; this.IsThrough = false; } Update(){ // ヒットされていないのにHIT_Y_MAXより下に落ちてきたら見逃しと判断する if(!this.IsHit && !this.IsThrough && this.Y > HIT_Y_MAX){ this.IsThrough = true; onThrough(this.LaneNumber); } this.Y += speed; } Draw(){ ctx.fillStyle = '#f00'; ctx.fillRect(this.X, this.Y + 20, this.Width, this.Height - 40); //ctx.fillRect(this.X, this.Y, this.Width, this.Height);でもよいがブロックが厚くなりすぎるので・・・ } } |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。canvasのサイズを調整し、レーンを描画します。また各レーンのボタンを適切な位置に移動させるとともにイベントリスナーを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
window.onload = () => { $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; clearCanvas(); drawLanes(); $start.addEventListener('click', (ev) => { ev.preventDefault(); gameStart(); }); setPositionButtons(); // 後述 addEventListeners(); // 後述 } |
各レーンのボタンを適切な位置に移動させる処理を示します。定数で定義されている値をセットしているだけです。
1 2 3 4 5 6 7 8 9 |
function setPositionButtons(){ const buttons = [$zero, $one, $two, $three]; for(let i = 0; i < buttons.length; i++){ buttons[i].style.left = LANE_LEFTS[i] + 'px'; buttons[i].style.top = BUTTONS_TOP + 'px'; buttons[i].style.width = LANE_WIDTH + 'px'; buttons[i].style.height = BUTTONS_HEIGHT + 'px'; } } |
イベントリスナーを追加する処理を示します。
STARTボタンがクリックされたらgameStart関数を呼びだしてゲームを開始します。また各レーンのボタンをタップしたらヒットした場合はonHit関数と、ミスタップの場合はonMiss関数を呼び出します。
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 addEventListeners(){ $start.addEventListener('click', (ev) => { ev.preventDefault(); gameStart(); }); // PCスマホ両方に対応させる(clickイベントだと反応が遅くなるので'mousedown', 'touchstart'を使う) const buttons = [$zero, $one, $two, $three]; const events = ['mousedown', 'touchstart']; for(let i = 0; i < LANE_LEFTS.length; i++){ for(let k = 0; k < events.length; k++){ buttons[i].addEventListener(events[k], (ev) => { ev.preventDefault(); if(!isPlaying) return; // タップしたときにそのレーンにヒットの対象になるブロックは存在するか調べる。 const hits = blocks.filter(rect => !rect.IsHit && rect.X == LANE_LEFTS[i] && HIT_Y_MIN < rect.Y && rect.Y < HIT_Y_MAX); if(hits.length > 0){ hits[0].IsHit = true; // 二重に評価しないためのフラグをセット onHit(i); } else onMiss(i); }); } } } |
更新処理
タイマー処理の部分を示します。
現在プレイ中でない場合は何もしません。更新時はブロックの落下速度を少しずつ上げていきます。ブロックのUpdate関数とDraw関数を呼び出して移動と描画の処理をおこないます。そのあとhitLaneNumbers、throughLaneNumbers、missLaneNumbersに値が格納されているときは’Hit’、’Miss’の文字を表示させます。またcanvasの上部にスコアを表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
setInterval(() => { if(!isPlaying) return; if(speed < 5) speed += 0.005; clearCanvas(); drawLanes(); blocks.forEach(block => block.Update()); blocks.forEach(block => block.Draw()); hitLaneNumbers.forEach(num => drawHit(num)); throughLaneNumbers.forEach(num => drawThrough(num)); missLaneNumbers.forEach(num => drawMiss(num)); // canvas上部にスコアを表示 ctx.font = '20px bold MS ゴシック'; ctx.textBaseline = 'top'; ctx.fillStyle = '#fff'; ctx.fillText(`Hit ${hitCount} Through ${throughCount} Miss ${missCount}`, 10, 10); }, 1000 / 60); |
ゲーム開始時の処理
ゲーム開始時の処理を示します。
ゲーム開始されたらisPlayingフラグをセットします。上から落ちてくるブロックをランダムに生成し、スコアをリセットします。BGMを再生して開始ボタンを非表示にします。
BGMの終了近くになったら以降は新しいブロックを落とさないようにして、BGMの再生が終わったらisPlayingフラグをクリアして更新処理を停止します。そして結果を表示します。
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 |
function gameStart(){ blocks.length = 0; // 上から落ちてくるブロックをランダムに生成する // だんだん間隔を詰める for(let i=0; i < 40; i += 2) blocks.push(new Block(Math.floor(Math.random() * 4), i)); for(let i=40; i < 70; i += 1.5) blocks.push(new Block(Math.floor(Math.random() * 4), i)); for(let i=70; i < 600; i ++) blocks.push(new Block(Math.floor(Math.random() * 4), i)); // スコアをリセット hitCount = 0; missCount = 0; throughCount = 0; speed = 3; isPlaying = true; // BGMを鳴らす bgm.currentTime = 0; bgm.play(); // 開始ボタンを非表示に $start.style.display = 'none'; // BGMの終了近くになったら以降は新しいブロックを落とさないようにする // blocksからY座標が-10以下のものと取り除く(ついでに必要ないCANVAS_HEIGHT以上のものの取り除く) setTimeout(() => { blocks = blocks.filter(rect => rect.Y > -10 && rect.Y < CANVAS_HEIGHT); }, 1000 * 100); // BGMが終了したタイミングで更新処理を停止してドラムロールを鳴らして結果を表示する setTimeout(async() => { isPlaying = false; bgm.pause(); await playDrumroll(); // 後述 const resultText = `Hit: ${hitCount}\n見逃し: ${throughCount}\nMiss: ${missCount}`; showResult(resultText); // 後述 }, 1000 * 103); } |
終了時の処理
ドラムロールを鳴らす処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
async function playDrumroll(){ drumrollSound1.currentTime = 0; drumrollSound1.play(); return new Promise((resolve) => { setTimeout(() => { drumrollSound1.pause(); setTimeout(() => { drumrollSound2.currentTime = 0; drumrollSound2.play(); resolve(); }, 300); }, 2500); }); } |
結果を表示する処理を示します。結果はHit、Through、Missの3行で渡されるので、改行で分割して一番横幅が広いThroughの行の描画幅を調べます。あとはこれをcanvasの中央付近に描画します。この関数が実行されるときはタイマー処理は停止しているので1回実行すればcanvas上から文字列が消えることはありません。
またゲームが終了しているので再挑戦できるようにスタートボタンを再表示させます。
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 showResult(resultText){ const arr = resultText.split('\n'); if(arr.length < 3) return; ctx.fillStyle = '#ff0'; ctx.font = '20px bold MS ゴシック'; const textWidth1 = ctx.measureText('結果').width; const x1 = (CANVAS_WIDTH - textWidth1) / 2; ctx.fillStyle = '#fff'; ctx.fillText('結果', x1, 160); const textWidth = ctx.measureText(arr[1]).width; const x = (CANVAS_WIDTH - textWidth) / 2; ctx.fillStyle = '#0ff'; ctx.fillText(arr[0], x, 200); ctx.fillStyle = '#ff0'; ctx.fillText(arr[1], x, 230); ctx.fillStyle = '#f0f'; ctx.fillText(arr[2], x, 260); $start.style.display = 'block'; } |