今回は矢を飛ばすゲームをつくります。放たれた矢は重力の影響をうけて飛びます。発射地点は固定で時間の経過とともに発射可能方向がかわります。敵を撃ち落とすことができなかった場合は爆弾を投下され、一定のダメージをうけたらゲームオーバーという仕様です。
Contents
HTML部分
HTML部分を示します。ゲームで使うボタンはスタートボタンを除くと1つだけです。タイミングよく発射しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<!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 id = "start">START</button> <button id = "shot">SHOT</button> </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 |
body{ background-color: #000; } #container{ width: 360px; } #start { width: 140px; height: 50px; margin-left: 110px; } #shot { width: 140px; height: 50px; margin-left: 110px; } |
グローバル変数と定数
JavaScript部分を示します。
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 |
// canvasのサイズ const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 480; const G_ACCELERATION = 0.2; // 重力加速度(本当は 9.8 なのだが・・・) const ALLOW_SPEED = 10; // 矢の初速 const ALLOW_LENGTH = 30; // 矢の長さ const INIT_X = 180; // 自機の座標(固定) const INIT_Y = 350; const MAX_LIFE = 5; // 自機のライフ // 敵と爆弾のサイズ const ENEMY_SIZE = 36; const BOMB_SIZE = 32; // canvas要素とコンテキスト const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); // ボタン要素 const $start = document.getElementById('start'); const $shot = document.getElementById('shot'); const enemyImages = []; // 3種類の敵のイメージを格納する配列 const bombImage = new Image(); // 爆弾のイメージ const lifeImage = new Image(); // ライフ(ハートマーク)のイメージ // 効果音 const shotSound = new Audio('./sounds/shot.mp3'); const hitSound = new Audio('./sounds/hit.mp3'); const bombSound1 = new Audio('./sounds/bomb1.mp3'); const bombSound2 = new Audio('./sounds/bomb2.mp3'); const gameOverSound = new Audio('./sounds/gameover.mp3'); let isPlaying = false; // 現在プレイ中か? let score = 0; // スコア let life = MAX_LIFE; // ライフ let updateCount = 0; // 更新回数(敵の出現処理で必要) let angle = 60; // 矢を放つ角度(度数法) let isLeft = true; // 矢を放つ角度は左より(増加)しているかどうか? // ゲームの進行速度(だんだん速くする) const INIT_GAME_SPEED = 0.5; let gameSpeed = INIT_GAME_SPEED; let arrows = []; // 飛んでいる矢を格納する配列 let enemies = []; // 敵を格納する配列 let bombs = []; // 投下された爆弾を格納する配列 let nextArrow = null; // 次に放たれる矢の描画用変数 let allowShot = true; // 矢は発射できる状態か? |
Arrowクラスの定義
矢の状態を更新・描画できるようにArrowクラスを定義します。
コンストラクタ
最初にコンストラクタを示します。引数はXYの初期座標とXY方向の初速です。これらをメンバ変数に格納します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Arrow { constructor(x0, y0, xv0, yv0){ // 矢の根元部分の座標 this.X = x0; this.Y = y0; // 初期座標 this.X0 = x0; this.Y0 = y0; // 初速 this.VX0 = xv0; this.VY0 = yv0; // 矢の先端の座標 this.HeadX = 0; this.HeadY = 0; this.IsDead = false; // 死亡フラグ this.UpdateCount = 0; // 更新回数 } } |
更新処理
更新処理を示します。経過時間(更新回数)と重力加速度から現在の速度を求め移動させます。そのあと当たり判定で必要になる先端部分の座標を求めます。また一番下まで落下していた場合は死亡フラグをセットします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Arrow { Update(){ // 経過時間(更新回数)と重力加速度から現在の速度を求める const vx = this.VX0; const vy = this.VY0 + G_ACCELERATION * this.UpdateCount; // 移動させる this.UpdateCount++; this.X += vx; this.Y += vy; // 現在の速度から進行方向を求める const rad = Math.atan2(vy, vx); // 進行方向から先端の座標を求める this.HeadX = this.X + ALLOW_LENGTH * Math.cos(rad); this.HeadY = this.Y + ALLOW_LENGTH * Math.sin(rad); // 一番下まで落下したら死亡フラグをセット this.IsDead = this.Y > CANVAS_HEIGHT; } } |
描画処理
描画処理を示します。矢の本体は先端と根元を直線でつなぐだけですが、矢らしく見えるように先端も描画することにしました。
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 |
class Arrow { Draw(){ // 矢の進行方向を取得 const rad = Math.atan2(this.VY0 + G_ACCELERATION * this.UpdateCount, this.VX0); this.Draw2(rad); } // 矢の進行方向に対応して描画する Draw2(rad){ this.HeadX = this.X + ALLOW_LENGTH * Math.cos(rad); this.HeadY = this.Y + ALLOW_LENGTH * Math.sin(rad); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; // 矢の本体(先端と根元を直線でつなぐ) ctx.beginPath(); ctx.moveTo(this.X, this.Y); ctx.lineTo(this.HeadX, this.HeadY); ctx.stroke() const headLength = 6; // 矢の先端の描画(矢印みたいなものをつける) ctx.beginPath(); ctx.moveTo(this.HeadX, this.HeadY); ctx.lineTo(this.HeadX - headLength * Math.cos(rad + 0.5), this.HeadY - headLength * Math.sin(rad + 0.5)); ctx.stroke() ctx.beginPath(); ctx.moveTo(this.HeadX, this.HeadY); ctx.lineTo(this.HeadX - headLength * Math.cos(rad - 0.5), this.HeadY - headLength * Math.sin(rad - 0.5)); ctx.stroke() } } |
Enemyクラスの定義
敵の状態の更新と描画処理をするためにEnemyクラスを定義します。
コンストラクタ
敵は左右から現れ反対側にむけて移動します。中央までくると爆弾を1発だけ投下します。そのため出現して移動した距離と爆弾を投下したかを示すメンバ変数を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class Enemy { constructor(x, y, xv, yv){ // 座標 this.X = x; this.Y = y; // 移動速度 this.VX = xv; this.VY = yv; // 描画に使うイメージ const index = Math.floor(Math.random() * enemyImages.length); this.Image = enemyImages[index]; this.DistanceMoved = 0; // 出現して移動した距離 this.IsBombed = false; // 爆弾を投下したか? this.IsDead = false; } } |
更新処理と描画処理
更新処理と描画処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Enemy { Update(){ this.X += this.VX; this.Y += this.VY; this.DistanceMoved += Math.abs(this.VX); // canvasの外へ移動したら死亡フラグをセット this.IsDead = this.X < 0 || this.X > CANVAS_WIDTH; } Draw(){ ctx.drawImage(this.Image, this.X, this.Y, ENEMY_SIZE, ENEMY_SIZE); } } |
矢との当たり判定
矢との当たり判定をおこなう処理を示します。
1 2 3 4 5 6 7 8 |
class Enemy { IsHit(arrow){ // (敵の中心と矢の先端の距離の二乗)と(敵のサイズの半分の二乗)を比較する const d2 = Math.pow(this.X + ENEMY_SIZE / 2 - arrow.HeadX, 2) + Math.pow(this.Y + ENEMY_SIZE / 2 - arrow.HeadY, 2); const r2 = Math.pow(ENEMY_SIZE / 2 + 8, 2); // 当たり判定がシビアになりすぎるので8を足して調整している return d2 < r2; } } |
Bombクラスの定義
爆弾の状態と描画処理をするためにBombクラスを定義します。やっていることはコメントのとおりです(説明手抜き)。
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 |
class Bomb { constructor(x, y){ // 座標 this.X = x; this.Y = y; // 初期Y座標(X方向には動かないので初期X座標はthis.Xをそのまま使う) this.Y0 = y; this.UpdateCount = 0; this.IsDead = false; } Update(){ // 自由落下の公式よりY座標を求める this.UpdateCount++; this.Y = this.Y0 + 0.5 * G_ACCELERATION * this.UpdateCount * this.UpdateCount; // 矢の発射地点まで落下したら死亡フラグをセット if(this.Y > INIT_Y) this.IsDead = true; } Draw(){ ctx.drawImage(bombImage, this.X, this.Y, BOMB_SIZE, BOMB_SIZE); } } |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。
まず画像ファイル(敵の画像ファイルはimagesフォルダ内にenemy0.png~enemy2.png)を読み込んで敵のイメージを配列に格納します。そのあと爆弾とライフ表示用の画像ファイルも読み込みます。また次に発射される矢のオブジェクトを生成します。引数は発射地点の座標を渡します。初速は更新処理で変化するので適当な値(0)でOKです。
そのあと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 25 26 27 28 29 |
window.onload = () => { // 敵のイメージを配列に格納 for(let i = 0; i < 3; i++){ const image = new Image(); image.src = `./images/enemy${i}.png`; enemyImages.push(image); } // 爆弾とライフ表示用の画像ファイルの読み込み bombImage.src = './images/bomb.png'; lifeImage.src = './images/life.png'; // 次に発射される矢のオブジェクトを生成 nextArrow = new Arrow(INIT_X, INIT_Y, 0, 0); // canvasサイズを調整 $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; ctx.fillStyle = '#000' ctx.fillRect(0, 0, $canvas.width, $canvas.height); // イベントリスナーの追加 $start.addEventListener('click', () => gameStart()); // 後述 $shot.addEventListener('click', () => shot()); // 後述 // ゲーム開始前なので発射ボタンは非表示 $shot.style.display = 'none'; } |
ゲーム開始の処理
ゲームを開始するときにおこなわれる処理を示します。
isPlayingフラグをセットしてスコア、ライフ、ゲームスピードに初期値をセットします。矢が発射可能かを示すフラグをセットして矢の発射角は60度とします。矢の発射角は時間の経過とともに変化しますが、最初は発射角は大きくしていきます。そのため矢の先端は左方向に移動していきます。
配列 arrows、enemies、bombsをクリアして最初の敵を生成します。そのあとスタートボタンを非表示にして発射ボタンを表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function gameStart(){ isPlaying = true; // 変数の初期化 score = 0; life = MAX_LIFE; gameSpeed = INIT_GAME_SPEED; allowShot = true; angle = 60; isLeft = true; arrows = []; enemies = []; bombs = []; // 最初の敵を生成する enemies.push(new Enemy(0, 80, 0.4, 0)); // ボタンの表示非表示 $start.style.display = 'none'; $shot.style.display = 'block'; } |
矢を発射する処理
矢を発射する処理を示します。
矢を無制限に発射できるとゲーム的に面白くないので連射制限をかけます。矢を発射したあとは0.5秒待たないとallowShotフラグがtrueになりません。発射可能でない場合はこの関数は呼び出されてもなにもしません。プレイ中でない場合も同様です。
発射処理をおこなう場合は現在の発射角度からXY方向の初速を求めます。Arrowオブジェクトを生成したらarrowsに格納します。そのあと効果音を鳴らします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function shot(){ if(!isPlaying || !allowShot) return; allowShot = false; setTimeout(() => { allowShot = true; }, 500); // 発射角度(angle)から矢の初速を求める // angleは反時計回りを正としているので符号を逆にして渡す const vx0 = ALLOW_SPEED * Math.cos(-Math.PI * angle / 180); const vy0 = ALLOW_SPEED * Math.sin(-Math.PI * angle / 180); arrows.push(new Arrow(INIT_X, INIT_Y, vx0, vy0)); shotSound.currentTime = 0; shotSound.play(); } |
更新処理
更新処理を示します。プレイ中でない場合は何もしません。
1 2 3 4 5 6 7 |
setInterval(() => { if(!isPlaying) return; update(); // 更新処理(後述) draw(); // 描画処理(後述) }, 1000 / 60); |
ゲームスピードを少し速くしてupdateCountをインクリメントします。そのあと次に発射される矢の角度を変更するとともに各キャラクタを移動します。そのあと当たり判定をして死亡フラグが立っているオブジェクトを配列から除去します。
条件(後述)を満たしているのであれば新しい敵を生成します。また生きのこっている敵が中央まで移動しているのであれば爆弾を投下(ただし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 |
function update(){ gameSpeed += 0.0007; updateCount++; // 次に発射される矢の角度を変更(後述) changeNextArrowAngle(); // 各キャラクタの移動 arrows.forEach(arrow => arrow.Update()); enemies.forEach(enemy => enemy.Update()); bombs.forEach(bomb => bomb.Update()); // 当たり判定(後述) checkHit(); // 死亡フラグが立っているオブジェクトを配列から除去 arrows = arrows.filter(arrow => !arrow.IsDead); enemies = enemies.filter(enemy => !enemy.IsDead); bombs = bombs.filter(bomb => !bomb.IsDead); // 新しい敵の生成と爆撃(後述) createNewEnemy(); bomb(); } |
矢の発射角の変更
矢の発射角を変更する処理を示します。更新されるたびに矢の発射角を60~120度で変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function changeNextArrowAngle(){ if(isLeft) angle += gameSpeed; else angle -= gameSpeed; // 120度を超えたら減少させる if(angle > 120){ angle = 120; isLeft = false; } // 60度を下回ったら増加させる if(angle < 60){ angle = 60; isLeft = true; } } |
当たり判定
当たり判定の処理を示します。矢が当たっている敵があるか調べてあれば敵と矢に死亡フラグをセットします。そして効果音を鳴らして50点を加算します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function checkHit(){ for(let i = 0; i < arrows.length; i++){ // 矢が当たっている敵を取得する const hits = enemies.filter(enemy => enemy.IsHit(arrows[i])); // 命中時の処理 if(hits.length > 0){ hits[0].IsDead = true; arrows[i].IsDead = true; hitSound.currentTime = 0; hitSound.play(); score += 50; } } } |
新しい敵の生成
新しい敵を生成する処理を示します。updateCountが128の倍数の場合、新しい敵を生成します。出現する場所は左端か右端、Y座標は80~180です(あまり高い位置だと矢が届かない)。水平方向の移動速度は 0.4 とゲームスピードの積です。
1 2 3 4 5 6 7 8 9 |
function createNewEnemy(){ if(updateCount % 128 == 0){ const y = Math.random() * 100 + 80; if(Math.random() > 0.5) enemies.push(new Enemy(0, y, 0.4 * gameSpeed, 0)); else enemies.push(new Enemy(CANVAS_WIDTH, y, -0.4 * gameSpeed, 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 |
function bomb(){ // 爆弾を投下していない敵で中央まで移動している敵がいるか調べる const canBombs = enemies.filter(enemy => !enemy.IsBombed && enemy.DistanceMoved >= CANVAS_WIDTH / 2); // 該当する敵がいる場合はそのなかのひとつに爆弾を投下させる if(canBombs.length > 0){ // 2回以上投下しないようにフラグをセット canBombs[0].IsBombed = true; // 爆弾の初期座標は敵と同じ位置 bombs.push(new Bomb(canBombs[0].X, canBombs[0].Y)); // 落下音を鳴らす bombSound1.currentTime = 0; bombSound1.play(); // 着弾音を鳴らしライフをデクリメントする // ライフが0になったらゲームオーバーの処理をする setTimeout(() => { if(isPlaying){ bombSound2.currentTime = 0; bombSound2.play(); life--; if(life <= 0) gameOver(); // 後述 } },1500); } } |
描画処理
描画処理の部分を示します。canvas全体を黒で塗りつぶし、爆弾、敵、矢、スコアを描画します。
矢を発射できる状態であれば次に発射される矢も描画します。
1 2 3 4 5 6 7 8 9 10 11 |
function draw(){ ctx.fillStyle = '#000' ctx.fillRect(0, 0, $canvas.width, $canvas.height); bombs.forEach(bomb => bomb.Draw()); enemies.forEach(enemy => enemy.Draw()); arrows.forEach(arrow => arrow.Draw()); drawScoreLife(); drawNextArrow(); } |
スコアとライフを描画する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function drawScoreLife(){ // スコアの描画 ctx.textBaseline = 'top'; ctx.font = '24px Arial'; ctx.fillStyle = '#fff'; ctx.fillText('SCORE ' + score, 10, 10); // ライフが残っている場合は描画する if(life > 0){ ctx.font = '20px Arial'; const textWidth = ctx.measureText('LIFE' + life).width; ctx.fillText('LIFE', (CANVAS_WIDTH - textWidth) / 2, INIT_Y + 50); // ライフの個数だけハートマークを並べる for(let i = 0; i < life; i++) ctx.drawImage(lifeImage, (CANVAS_WIDTH - 40 * life) / 2 + 40 * i, INIT_Y + 80, 32, 32); } } |
次の矢の発射方向がわかるように次の矢を描画する処理を示します。描画は発射可能の場合だけおこないます。
1 2 3 4 5 6 |
function drawNextArrow(){ // angleを弧度法に変換する。angleは反時計回りが正なので符号を逆にして渡す if(allowShot){ nextArrow.Draw2(-Math.PI * angle / 180) } } |
ゲームオーバー時の処理
ゲームオーバー時の処理を示します。
isPlayingフラグをクリアして発射ボタンを非表示にします。isPlayingフラグがfalseになることで更新処理は行なわれなくなります。そのあとcanvas中央に’GAME OVER’と描画し、効果音を鳴らします。そのあとしばらく待機してからゲームスタート用のボタンを再表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function gameOver(){ setTimeout(() => { isPlaying = false; $shot.style.display = 'none'; ctx.textBaseline = 'top'; ctx.font = '28px MS ゴシック'; ctx.fillStyle = '#fff'; const textWidth = ctx.measureText('GAME OVER').width; ctx.fillText('GAME OVER', (CANVAS_WIDTH - textWidth) / 2, 160); gameOverSound.currentTime = 0; gameOverSound.play(); setTimeout(() => { $start.style.display = 'block'; }, 3000); }, 100); } |