Canvas Confettiを使ってゲームをつくる(1)の続きです。今回は前回定義したクラスをつかってゲームを完成させます。
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 |
window.onload = () => { // 画像ファイルを読み込んでイメージを作成 playerImage.src = './images/player.png'; playerBulletImage.src = './images/bullet.png'; enemyBulletImage.src = './images/enemy-bullet.png'; for(let i=0; i<4; i++){ const image = new Image(); image.src = `./images/enemy${i}.png`; enemyImages.push(image); } // Playerオブジェクトの生成 player = new Player(); // canvasの初期化(サイズ調整) $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; $confettiCanvas.width = CANVAS_WIDTH; $confettiCanvas.height = CANVAS_HEIGHT; // 紙吹雪を表示できるようにする $confettiCanvas.confetti = confetti.create($confettiCanvas, { resize: true }); // canvasを背景色で塗りつぶす ctx.fillStyle = backColor; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // チェックボックスのチェックをはずす $rapidFire.checked = false; $hideButtons.checked = false; showCtrlButtons(false); // 自機操作用のボタン。最初は非表示(後述) addEventListeners(); // イベントリスナの追加(後述) initInterval(); // インターバルの設定(更新処理とBGMのエンドレス再生)(後述) initVolumes(0.5); // ボリュームを設定できるようにする(後述) } |
showCtrlButtons関数は自機操作用のボタンの表示非表示を切り替えるためのものです。ボタン要素は配列ctrlButtonsに格納されています(前回の記事参照)。
1 2 3 4 5 6 |
function showCtrlButtons(show){ const display = show ? 'block' : 'none'; ctrlButtons.forEach(btn => { btn.style.display = display; }); } |
イベントリスナを追加する処理を示します。
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 |
function addEventListeners(){ // PCのキー操作に対応させる // 方向キーが押下されているあいだだ移動けフラグをtrueにする // スペースキーが押下されたら弾丸発射 document.onkeydown = (ev) => { // プレイ中はデフォルトの動作を抑止する if(isPlaying) ev.preventDefault(); if(ev.code == 'ArrowUp') up = true; if(ev.code == 'ArrowDown') down = true; if(ev.code == 'ArrowLeft') left = true; if(ev.code == 'ArrowRight') right = true; if(ev.code == 'Space') player.Shot(); } document.onkeyup = (ev) => { if(ev.code == 'ArrowUp') up = false; if(ev.code == 'ArrowDown') down = false; if(ev.code == 'ArrowLeft') left = false; if(ev.code == 'ArrowRight') right = false; } // SHOTボタンをクリックしたら弾丸発射 $start.addEventListener('click', () => gameStart()); // UP、DOWNボタンなどを押下されているあいだだけ移動フラグをtrueにする const arr1 = ['mousedown', 'touchstart']; const arr2 = ['mouseup', 'touchend']; for(let i = 0; i < 2; i++){ $up.addEventListener(arr1[i], (ev) => up = true); $down.addEventListener(arr1[i], (ev) => down = true); $left.addEventListener(arr1[i], (ev) => left = true); $right.addEventListener(arr1[i], (ev) => right = true); $shot.addEventListener(arr1[i], (ev) => player.Shot()); $up.addEventListener(arr2[i], (ev) => up = false); $down.addEventListener(arr2[i], (ev) => down = false); $left.addEventListener(arr2[i], (ev) => left = false); $right.addEventListener(arr2[i], (ev) => right = false); } // スマホでボタンを操作したときデフォルトの動作を抑止する const arr3 = [$up, $down, $left, $right, $shot]; for(let i = 0; i < arr3.length; i++){ arr3[i].addEventListener('touchstart', (ev) => ev.preventDefault() ); arr3[i].addEventListener('touchend', (ev) => ev.preventDefault() ); } // PCでマウスボタンを押下したあとボタン以外の場所でマウスボタンを離したときの誤作動を防ぐ // この場合は移動フラグをすべてクリアする document.addEventListener('mouseup', () =>{ up = false; down = false; left = false; right = false; }); // 連射のチェックボックスがチェックされているときは0.1秒間隔でPlayer.Shot関数を実行する // チェックをはずしたらインターバルを削除する let rapidFireInterval = null; $rapidFire.addEventListener('change', () => { if($rapidFire.checked){ rapidFireInterval = setInterval(() => { player.Shot(); }, 100); } else clearInterval(rapidFireInterval); }); // スマホ用ボタンを非表示にするチェックボックスを操作した場合、自機操作用のボタンの表示非表示を切り替える $hideButtons.addEventListener('change', () => { if($hideButtons.checked){ showCtrlButtons(false); } if(!$hideButtons.checked && !player.IsDead){ showCtrlButtons(true); } }); } |
initInterval関数は更新処理を1秒間に30回、BGMをエンドレス再生できるようにします。このゲームのBGMにつかっている音源は1分47秒まで再生したら最初に巻き戻すと自然に聞こえるので、1秒おきにどこまで再生されているのかを調べています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function initInterval(){ setInterval(() => { if(!isPlaying) return; update(); // 更新処理(後述) judge(); // 当たり判定(後述) draw(); // 描画処理(後述) enemiesShot(); // 敵に弾丸を発射させる(後述) createEnemy(); // 新しい敵を生成する(後述) }, 1000 / 30); setInterval(() => { if(bgm.currentTime > 107) bgm.currentTime = 0; }, 1000); } |
レンジスライダーでボリュームをコントロールできるようにする処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function initVolumes(initValue){ const $elemVolume = document.getElementById("volume"); const $elemRange = document.getElementById("vol-range"); $elemVolume.addEventListener('change', () => setVolume($elemVolume.value)); setVolume(initValue); function setVolume(value){ $elemVolume.value = value; $elemRange.textContent = value; hitSound.volume = value; damageSound.volume = value; bgm.volume = value; gameoverSound.volume = value; } } |
[音量テスト]ボタンをクリックするとplaySound関数が呼び出され、設定したボリュームで効果音を再生します。
1 2 3 4 |
function playSound(){ gameoverSound.currentTime = 0; gameoverSound.play(); } |
更新処理と当たり判定
更新処理を示します。新しい敵の生成は更新回数で決めるのでupdateCountをインクリメントしています。そのあと自機、敵、弾丸の更新処理をおこないます。
1 2 3 4 5 6 7 |
function update(){ updateCount++; player.Update(); playerBullets.forEach(bullet => bullet.Update()); enemies.forEach(enemy => enemy.Update()); enemyBullets.forEach(bullet => bullet.Update()); } |
当たり判定の処理を示します。すでに死亡フラグがセットされているオブジェクトは処理の対象外です。
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 |
function judge(){ // 自機から発射された弾丸は敵に命中したか? for(let i = 0; i < playerBullets.length; i++){ if(playerBullets[i].IsDead) continue; for(let k=0; k<enemies.length; k++){ if(enemies[k].IsDead) continue; // オブジェクトの中心の距離と両者の半径の合計を比較して衝突しているか調べる const bx = playerBullets[i].X + PLAYER_BULLET_SIZE / 2; const by = playerBullets[i].Y + PLAYER_BULLET_SIZE / 2; const ex = enemies[k].X + ENEMY_SIZE / 2; const ey = enemies[k].Y + ENEMY_SIZE / 2; if(Math.pow(bx - ex, 2) + Math.pow(by - ey, 2) < Math.pow(PLAYER_BULLET_SIZE / 2 + ENEMY_SIZE / 2, 2)){ playerBullets[i].IsDead = true; enemies[k].IsDead = true; onHit(ex, ey); // 命中時の処理(後述) break; } } } // 敵弾と自機の当たり判定 for(let i = 0; i< enemyBullets.length; i++){ // 自機死亡時と自機が無敵状態のときはなにもしない if(muteki || player.IsDead) break; if(enemyBullets[i].IsDead) continue; const bx = enemyBullets[i].X + ENEMY_BULLET_SIZE / 2; const by = enemyBullets[i].Y + ENEMY_BULLET_SIZE / 2; const px = player.X + PLAYER_SIZE / 2; const py = player.Y + PLAYER_SIZE / 2; // オブジェクトの中心の距離と半径の合計を比較するが、 // 当たり判定がシビアになりすぎるので敵弾の半径は0とする if(Math.pow(bx - px, 2) + Math.pow(by - py, 2) < Math.pow(PLAYER_SIZE / 2, 2)){ enemyBullets[i].IsDead = true; onMiss(bx, by); // 被弾時の処理(後述) break; } } // 敵そのものと自機の当たり判定 for(let i = 0; i < enemies.length; i++){ // 自機死亡時と自機が無敵状態のときはなにもしない if(muteki || player.IsDead) break; if(enemies[i].IsDead) continue; const px = player.X + PLAYER_SIZE / 2; const py = player.Y + PLAYER_SIZE / 2; const ex = enemies[i].X + ENEMY_SIZE / 2; const ey = enemies[i].Y + ENEMY_SIZE / 2; if(Math.pow(px - ex, 2) + Math.pow(py - ey, 2) < Math.pow(PLAYER_BULLET_SIZE / 2 + ENEMY_SIZE / 2, 2)){ enemies[i].IsDead = true; onMiss(ex, ey); // 衝突時の処理(後述) break; } } } |
命中または被弾時の処理
自機の弾丸が敵に命中したときの処理を示します。この場合は紙吹雪を生成します。引数は紙吹雪が飛び出す部分の座標です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function onHit(x, y){ // 10点加算で上方70度で紙吹雪を発射する player.Score += 10; $confettiCanvas.confetti({ scalar:1.2, particleCount: 24, spread: 70, startVelocity: 16, origin: { x:x / CANVAS_WIDTH, y: y / CANVAS_HEIGHT }, }); // ついでに効果音も鳴らす hitSound.currentTime = 0; hitSound.play(); } |
自機が敵、敵弾に衝突したときの処理を示します。この場合は0.1秒間背景を赤に変更し、Lifeを1減らします。そのあと汚物を周辺にぶちまけます。絵文字が回転するとなにかわからないので半分は回転しない状態で周囲に放出させます。
また立て続けにLifeが減らないように、1秒間は無敵状態にします。Lifeを1減らした結果、Lifeが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 37 38 39 40 41 42 43 44 |
function onMiss(x, y){ // 0.1秒間背景色を赤に変更 backColor = backColor2; setTimeout(() => backColor = backColor1, 100); // 1秒間無敵状態にする muteki = true; setTimeout(() => muteki = false, 1000); player.Life--; damageSound.currentTime = 0; damageSound.play(); // 汚物をぶちまける処理 const scalar = 2; const spark = confetti.shapeFromText({ text: '■', scalar:scalar }); // ■はウ○コの絵文字 const defaults = { spread: 90, ticks: 60, gravity: 1, startVelocity: 10, decay: 0.98, shapes: [spark], particleCount: 8, scalar:scalar, origin: { x:x / CANVAS_WIDTH, y: y / CANVAS_HEIGHT }, }; // 絵文字が回転する紙吹雪 8個 $confettiCanvas.confetti({ ...defaults, }); // 絵文字が回転しない紙吹雪 8個 $confettiCanvas.confetti({ ...defaults, flat: true, }); // Lifeが0以下になった場合はゲームオーバー処理をする if(player.Life <= 0) onGameOver(x, y); // 後述 } |
ゲームオーバー時の処理
ゲームオーバー時の処理を示します。
自機の死亡フラグをセットします。これで自機は描画されなくなります。自機操作用のボタンを非表示にしてBGMを停止します。1.5秒後に’GAME OVER’の文字列を表示して効果音を鳴らし、ゲームスタート用のボタンを表示させます。またisPlayingフラグをfalseにします。これによって更新処理も停止します。
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 |
function onGameOver(x, y){ // 死亡フラグをセットすると自機は描画されなくなる player.IsDead = true; // 自機操作用のボタンを非表示にしてBGMを停止 showCtrlButtons(false); bgm.pause(); setTimeout(() => { // 'GAME OVER'の文字列を表示 ctx.fillStyle = '#fff'; ctx.font='30px Arial'; ctx.textBaseline='top'; const width = ctx.measureText('GAME OVER').width; ctx.fillText('GAME OVER', (CANVAS_WIDTH - width) / 2, 200); // 以降の更新処理をさせないためにisPlayingフラグをクリア isPlaying = false; // ゲームスタート用のボタンを表示させる $start.style.display = 'block'; // ゲームオーバーの効果音を鳴らす gameoverSound.currentTime = 0; gameoverSound.play(); }, 1500); } |
描画処理
描画のための処理を示します。
まず敵と弾丸オブジェクトのなかで死亡フラグがセットされたものは配列のなかから取り除きます。
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 draw(){ // 死亡フラグがセットされたものは配列のなかから取り除く playerBullets = playerBullets.filter(bullet => !bullet.IsDead); enemies = enemies.filter(enemy => !enemy.IsDead); enemyBullets = enemyBullets.filter(bullet => !bullet.IsDead); // canvas全体を背景色で塗りつぶす ctx.fillStyle = backColor; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // 各オブジェクトのDraw関数を呼び出してキャラクタを描画する playerBullets.forEach(bullet => bullet.Draw()); enemyBullets.forEach(bullet => bullet.Draw()); enemies.forEach(enemy => enemy.Draw()); player.Draw(); // canvasの上側にスコアとLifeを描画する ctx.font='18px Arial'; ctx.textBaseline='top'; ctx.fillStyle = '#fff'; ctx.fillText('Score ' + player.Score, 20, 10); ctx.fillText('Life ' + player.Life, 270, 10); } |
敵弾発射と新しい敵を生成する処理
敵に弾丸を発射させる処理を示します。
1回の更新ごとにそれぞれの敵に0.01の確率で弾丸を発射させます。発射方向は自機がいる方向です。
1 2 3 4 5 6 7 8 9 10 |
function enemiesShot(){ for(let i = 0; i < enemies.length; i++){ if(Math.random() < 0.01){ // 敵からみて自機がいる方向を求める const rad = Math.atan2(player.Y - enemies[i].Y, player.X - enemies[i].X); // 敵弾の初期座標(敵の現在位置)と弾丸の初速をコンストラクタに渡す enemyBullets.push(new EnemyBullet(enemies[i].X, enemies[i].Y, ENEMY_BULLET_SPEED * Math.cos(rad), ENEMY_BULLET_SPEED * Math.sin(rad))); } } } |
新しい敵を生成する処理を示します。
64更新ごとに敵の編隊を追加します。どのタイプの敵にするかは乱数で決めます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function createEnemy(){ if(updateCount % 64 == 32){ const r = Math.floor(Math.random() * 3); if(r == 0){ const initX = Math.floor(Math.random() * CANVAS_WIDTH); // 出現位置のX座標 for(let i = 0; i < 8; i++) enemies.push(new Enemy0(initX, 6 * i)); } if(r == 1){ const minX = Math.floor(Math.random() * 160); // ジグザグ移動のもっとも左のX座標 for(let i = 0; i < 8; i++) enemies.push(new Enemy1(minX, 6 * i)); } if(r == 2){ const centerX = Math.floor(Math.random() * 200) + 80; // 回転の中心のX座標 for(let i = 0; i < 8; i++) enemies.push(new Enemy2(centerX, 6 * i)); } } } |
ゲーム開始時の処理
ゲーム開始時の処理を示します。
プレイ中であることを示すisPlayingをtrueにし、自機の状態を初期化するためにPlayer.Init関数を呼び出します。各オブジェクトが格納されている配列をクリアしてBGM再生などの処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function gameStart(){ isPlaying = true; player.Init(); // 自機の状態を初期化 // 各オブジェクトが格納されている配列をクリアする enemies = []; enemyBullets = []; playerBullets = []; // スタートボタンを非表示にする $start.style.display = 'none'; // BGMを再生する bgm.currentTime = 0; bgm.play(); // チェックボックスの状態によっては自機操作用のボタンを表示する if(!$hideButtons.checked) showCtrlButtons(true); } |