今回は初歩的なシューティングゲームをつくります。簡単なシューティングゲーム 本当に鳩でも分かるC#講座のJavaScript版です。
Contents
HTML部分
最初にHTMLとCSS部分を示します。
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>初歩的なシューティングゲーム 本当に鳩でもわかるJavaScript</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="./style.css" type="text/css" media="all"> </head> <body> <canvas id = "canvas"></canvas> <div> <button id = "start" onclick="Start()">START</button> </div> <script src="./app.js"></script> </body> </html> |
style.css
1 2 3 4 5 |
#start { width: 200px; height: 50px; margin-left: 60px; } |
JavaScript部分
主なグローバル変数と定数を示します。
app.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 |
// 画面の幅と高さ const FIELD_WIDTH = 320; const FIELD_HEIGHT = 440; // 自機、敵、弾丸の幅と高さ const PLAYER_WIDTH = 44; const PLAYER_HEIGHT = 46; const ENEMY_WIDTH = 24; const ENEMY_HEIGHT = 28; const BULLET_WIDTH = 14; const BULLET_HEIGHT = 14; const SPARK_WIDTH = 25; const SPARK_HEIGHT = 28; // 弾丸のスピード const BULLET_SPEED = 8; let $start = document.getElementById('start'); let isPlaying = false; // ゲーム中かどうか? let score = 0; // スコア let img = new Image(); img.src = "./image.png"; let canvas = document.getElementById("canvas"); var ctx = canvas.getContext("2d"); canvas.width = FIELD_WIDTH; canvas.height = FIELD_HEIGHT; |
画像ファイルの全体から一部を描画する
読み込む画像はこれです。
ここから必要な部分を切り取って表示させます。drawImage関数の引数は9個ありますが、1つ目が画像、2つ目から5つ目が切り取る左上のX座標とY座標、幅、高さです。6つ目から最後までが描画したい場所の左上の座標のX座標とY座標、幅、高さです。
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 |
// 戦闘機(プレーヤー)を描画する function DrawPlayer(centerX, centerY){ let x = centerX - PLAYER_WIDTH / 2; let y = centerY - PLAYER_HEIGHT / 2; ctx.drawImage(img, 57, 1, PLAYER_WIDTH, PLAYER_HEIGHT, x, y, PLAYER_WIDTH, PLAYER_HEIGHT); } // 黄色のひよこを描画する function DrawEnemy1(centerX, centerY){ let x = centerX - ENEMY_WIDTH / 2; let y = centerY - ENEMY_HEIGHT / 2; ctx.drawImage(img, 4, 61, ENEMY_WIDTH, ENEMY_HEIGHT, x, y, ENEMY_WIDTH, ENEMY_HEIGHT); } // ピンク色のひよこを描画する function DrawEnemy2(centerX, centerY){ let x = centerX - ENEMY_WIDTH / 2; let y = centerY - ENEMY_HEIGHT / 2; ctx.drawImage(img, 4, 94, ENEMY_WIDTH, ENEMY_HEIGHT, x, y, ENEMY_WIDTH, ENEMY_HEIGHT); } // 青色のひよこを描画する function DrawEnemy3(centerX, centerY){ let x = centerX - ENEMY_WIDTH / 2; let y = centerY - ENEMY_HEIGHT / 2; ctx.drawImage(img, 4, 125, ENEMY_WIDTH, ENEMY_HEIGHT, x, y, ENEMY_WIDTH, ENEMY_HEIGHT); } // 弾丸を描画する function DrawBullet(centerX, centerY){ let x = centerX - BULLET_WIDTH / 2; let y = centerY - BULLET_HEIGHT / 2; ctx.drawImage(img, 41, 46, BULLET_WIDTH, BULLET_HEIGHT, x, y, BULLET_WIDTH, BULLET_HEIGHT); } // 火花を描画する function DrawSpark(centerX, centerY, type){ let x = centerX - SPARK_WIDTH / 2; let y = centerY - SPARK_HEIGHT / 2; if(type == 0) ctx.drawImage(img, 17, 340, 24, 27, x, y, SPARK_WIDTH, SPARK_HEIGHT); if(type == 1) ctx.drawImage(img, 44, 340, 30, 30, x, y, SPARK_WIDTH, SPARK_HEIGHT); if(type == 2) ctx.drawImage(img, 79, 340, 32, 32, x, y, SPARK_WIDTH, SPARK_HEIGHT); if(type == 3) ctx.drawImage(img, 116, 340, 36, 36, x, y, SPARK_WIDTH, SPARK_HEIGHT); if(type == 4) ctx.drawImage(img, 153, 340, 34, 34, x, y, SPARK_WIDTH, SPARK_HEIGHT); if(type == 5) ctx.drawImage(img, 188, 340, 28, 30, x, y, SPARK_WIDTH, SPARK_HEIGHT); } |
プレーヤーの動作に関する処理
プレーヤーの描画に必要なグローバル変数を示します。
1 2 3 4 5 6 7 8 9 |
let playerX = FIELD_WIDTH / 2; // プレーヤーを描画する地点の中心のX座標 let playerY = FIELD_HEIGHT * 0.85; // プレーヤーを描画する地点の中心のY座標 // キーが押されているかどうか? 左、右、上、下と発射ボタン(スペースキー) let left = false; let right = false; let up = false; let down = false; let shot = false; |
PlayerBulletクラスの定義
プレーヤーから放たれた弾丸に関する処理をするためにPlayerBulletクラスを定義します。
コンストラクタの引数は弾丸が発射された座標と移動速度です。PlayerBulletオブジェクトを生成したら配列に格納します。弾丸はまっすぐ上方向に移動します。敵に命中したり一番上(Y座標が0)まで命中することなく移動したらIsDeadフラグをセットします。IsDeadフラグがセットされたオブジェクトは配列から除去されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class PlayerBullet { constructor(startX, startY, vy) { this.CenterX = startX; this.CenterY = startY; this.VY = vy; this.IsDead = false; } Move(){ this.CenterY += this.VY; if (this.CenterY < 0) this.IsDead = true; } Draw(){ if(!this.IsDead) DrawBullet(this.CenterX, this.CenterY); } } // PlayerBulletオブジェクトを格納する配列 let playerBullets = []; |
キーが押されたら自機を移動させます。その方向に移動するフラグをセットして実際の移動は更新時(後述)におこないます。弾丸の発射はスペースキーが押されたときにおこないますが、このままだとキーを押しっぱなしにしていると更新処理のたびに連射されてしまうので、一度キーを押したら一度離してもう一度押さないと発射されないようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
document.onkeydown = (ev) =>{ if(!isPlaying) return; if(ev.code == 'ArrowLeft') left = true; if(ev.code == 'ArrowUp') up = true; if(ev.code == 'ArrowRight') right = true; if(ev.code == 'ArrowDown') down = true; if(ev.code == 'Space'){ if(shot) return; shot = true; playerBullets.push(new PlayerBullet(playerX, playerY - PLAYER_HEIGHT / 2, -BULLET_SPEED)); } } |
キーが離されたらセットしていたフラグをクリアします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
document.onkeyup = (ev) =>{ if(ev.code == 'ArrowLeft') left = false; if(ev.code == 'ArrowUp') up = false; if(ev.code == 'ArrowRight') right = false; if(ev.code == 'ArrowDown') down = false; if(ev.code == 'Space') shot = false; } |
更新時にプレーヤーの座標を変更する処理をしめします。フィールドの外(見えない部分)に移動してしまわないように気をつけます。またIsDeadフラグがセットされているオブジェクトは配列から除去します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function UpdatePlayer(){ // フラグがセットされているなら自機の位置を移動する if(left && PLAYER_WIDTH / 2 < playerX) playerX -= 8; if(right && playerX < FIELD_WIDTH - PLAYER_WIDTH / 2) playerX += 8; if(up && PLAYER_HEIGHT / 2 < playerY) playerY -= 8; if(down && playerY < FIELD_HEIGHT - PLAYER_HEIGHT / 2) playerY += 8; // 自機から発射された弾丸も移動させる for(let i=0; i<playerBullets.length; i++) playerBullets[i].Move(); // IsDeadフラグがセットされている弾丸は配列から除去する playerBullets = playerBullets.filter(_ => !_.IsDead); } |
敵の動作に関する処理
敵の動作と描画に関する処理を示します。敵と敵の弾丸も配列にいれて管理します。
Enemyクラスの定義
Enemyクラスを定義します。
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 |
class Enemy { constructor(startX, startY, moveRight, type) { this.CenterX = startX; this.CenterY = startY; this.MoveRight = moveRight; this.IsDead = false; this.Type = type; this.UpdateCount = 0; } Move(){ this.UpdateCount++; if (FIELD_WIDTH < this.CenterX) this.MoveRight = false; if (0 > this.CenterX) this.MoveRight = true; this.CenterY++; if(this.MoveRight) this.CenterX += 3; else this.CenterX -= 3; if (FIELD_HEIGHT < this.CenterY) this.IsDead = true; } Draw(){ if(!this.IsDead && this.Type == 0) DrawEnemy1(this.CenterX, this.CenterY); if(!this.IsDead && this.Type == 1) DrawEnemy2(this.CenterX, this.CenterY); if(!this.IsDead && this.Type == 2) DrawEnemy3(this.CenterX, this.CenterY); } } |
敵の生成
敵を生成する処理を示します。上方で左右のどちらかから新しい敵を出現させます。また乱数で色(黄色・ピンク・青)も変化させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Emenyオブジェクトを格納する配列 let enemies = []; function CreateEmeny(){ let type = Math.floor(Math.random() * 3); let isRight = Math.floor(Math.random() * 2); let y = Math.random() * 48; if(isRight) enemies.push(new Enemy(0,y,true, type)); else enemies.push(new Enemy(FIELD_WIDTH, y, false, type)); } |
EnemyBulletクラスの定義
敵の弾丸の移動と描画をするためのEnemyBulletクラスを定義します。生成したオブジェクトは配列に格納します。
コンストラクタの引数は弾丸が出現するXY座標と1回の更新処理で移動する移動量です。自機と命中したりフィールドの外にでるとIsDeadフラグをセットします。そのあと配列のなかから除去されます。
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 |
class EnemyBullet { constructor(startX, startY, vx, vy) { this.CenterX = startX; this.CenterY = startY; this.VX = vx; this.VY = vy; this.IsDead = false; } Move(){ this.CenterX += this.VX; this.CenterY += this.VY; if (this.CenterX < 0) this.IsDead = true; if (this.CenterY < 0) this.IsDead = true; if (FIELD_WIDTH < this.CenterX) this.IsDead = true; if (FIELD_HEIGHT < this.CenterY) this.IsDead = true; } Draw(){ if(!this.IsDead) DrawBullet(this.CenterX, this.CenterY); } } // 敵の弾丸を格納するための配列 let enemyBullets = []; |
敵弾の発射
敵が弾丸を発射する処理を示します。敵弾は自機がある方向に発射されますが、すべてが自機がある方向にそのまま飛んでくるのを避けるため、角度を乱数で30度ズラします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function EmeniesShot(){ if(enemies.length == 0) return; for(let i = 0; i < enemies.length; i++){ // 敵が生成されたあとの更新回数が31の倍数のときに20%の確率で弾丸を発射する if(enemies[i].UpdateCount % 31 != 0) continue; if(Math.random() > 0.8) continue; let rad = Math.atan2(playerY - enemies[i].CenterY, playerX - enemies[i].CenterX); // 乱数で最大30度ズラす。この行がないと弾丸はつねに自機めがけて飛んでくるようになる rad += Math.random() * (Math.PI / 3) - Math.PI / 6; enemyBullets.push(new EnemyBullet(enemies[i].CenterX, enemies[i].CenterY, BULLET_SPEED * Math.cos(rad), BULLET_SPEED * Math.sin(rad))); } } |
敵の更新処理
敵の更新処理を示します。敵と敵弾を移動させ、IsDeadフラグがセットされているオブジェクトを配列から除去します。
1 2 3 4 5 6 7 8 9 10 |
function UpdateEnemies(){ for(let i = 0; i < enemies.length; i++) enemies[i].Move(); for(let i = 0; i < enemyBullets.length; i++) enemyBullets[i].Move(); enemies = enemies.filter(_ => !_.IsDead); enemyBullets = enemyBullets.filter(_ => !_.IsDead); } |
火花の描画に関する処理
敵と自機に弾丸が接触したら爆発の描画をおこないます。爆発を構成する火花を管理するためにSparkクラスを定義します。
火花は発生してから消えるまで複数の画像を使います。更新回数を数えてどのイメージを使うかを決めています。
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 |
class Spark { constructor(startX, startY, vx, vy) { this.CenterX = startX; this.CenterY = startY; this.VX = vx; this.VY = vy; this.UpdateCount = 0; this.IsDead = false; } Move(){ this.UpdateCount++; this.CenterX += this.VX; this.CenterY += this.VY; if (this.CenterX < 0) this.IsDead = true; if (this.CenterY < 0) this.IsDead = true; if (FIELD_WIDTH < this.CenterX) this.IsDead = true; if (FIELD_HEIGHT < this.CenterY) this.IsDead = true; // 更新2回でイメージを変更する let type = Math.floor(this.UpdateCount / 2); // 12回更新したら消滅 if(type >= 6) this.IsDead = true; } Draw(){ let type = Math.floor(this.UpdateCount / 2); if(!this.IsDead) DrawSpark(this.CenterX, this.CenterY, type); } } // 火花を格納する配列 let sparks = []; |
火花を移動させる処理を示します。更新時に火花が描画される座標を変更しIsDeadがセットされているものは配列から除去します。
1 2 3 4 5 6 |
function UpdateSparks(){ for(let i=0; i<sparks.length; i++) sparks[i].Move(); sparks = sparks.filter(_ => !_.IsDead); } |
当たり判定
当たり判定の処理を示します。効果音を出すためにAudioオブジェクトを生成しています。またゲームオーバーになったときは当たり判定はしません。自機も表示させません(後述)。
自機、敵、弾丸をここでは円とみなします。また半径は幅の半分とします。弾丸と接触しているかどうかは自機または敵と弾丸の距離と両者の半径の和を比較すればわかります。距離はX座標とY座標の差をそれぞれ二乗して足したものの平方根です。ただ平方根をとると処理に時間がかかるため、両者の2乗を比較します。
弾丸と敵が命中している場合は両者のIsDeadフラグをtrueにします。すると配列内から取り除かれ、描画もされなくなります。
自機から発射された弾丸が敵に命中した場合はスコアを増やして効果音もならします。敵弾が自機に命中した場合はゲームオーバーとします。どちらの場合も爆発の描画のための処理を開始します。
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 |
let hit = new Audio('./hit.mp3'); let dead = new Audio('./dead.mp3'); function HitCheck(){ // ゲームオーバー時は当たり判定をしない if(!isPlaying) return; // 自機から発射された弾丸は敵に命中したか? for(let i = 0; i < playerBullets.length; i++){ for(let k = 0; k < enemies.length; k++){ // すでに死亡フラグが立っている敵との当たり判定はしない if(enemies[k].IsDead) continue; // 弾丸と敵の距離とそれぞれの半径の和を比較する let d2 = Math.pow(enemies[k].CenterX - playerBullets[i].CenterX, 2) + Math.pow(enemies[k].CenterY - playerBullets[i].CenterY, 2); let r2 = Math.pow(BULLET_WIDTH / 2 + ENEMY_WIDTH / 2, 2); if(d2 < r2){ // 命中時はIsDeadフラグをセット enemies[k].IsDead = true; playerBullets[i].IsDead = true; // 爆発の開始 for(let m = 0; m < 24; m++){ let rad = Math.random() * 2 * Math.PI; let speed = 2 + Math.random() * 4; sparks.push(new Spark(enemies[k].CenterX, enemies[k].CenterY, speed * Math.cos(rad), speed * Math.sin(rad))); } // スコアの加算と効果音 score += 10; hit.currentTime = 0; hit.play(); } } } for(let i = 0; i < enemyBullets.length; i++){ // すでに死亡フラグが立っている敵弾との当たり判定はしない if(enemyBullets[i].IsDead) continue; // 敵弾と自機の距離とそれぞれの半径の和を比較する let d2 = Math.pow(enemyBullets[i].CenterX - playerX, 2) + Math.pow(enemyBullets[i].CenterY - playerY, 2); let r2 = Math.pow(BULLET_WIDTH / 2 + PLAYER_WIDTH / 2, 2); if(d2 < r2){ // 敵弾が命中した場合はゲームオーバーとする isPlaying = false; enemyBullets[i].IsDead = true; // 爆発の開始 for(let m = 0; m < 24; m++){ let rad = Math.random() * 2 * Math.PI; let speed = 2 + Math.random() * 4; sparks.push(new Spark(playerX, playerY, speed * Math.cos(rad), speed * Math.sin(rad))); } // 効果音 dead.currentTime = 0; dead.play(); // ゲームオーバーになったのでゲームを再開するためのボタンを表示させる $start.style.display = 'block'; // ひとつの敵弾が自機に当たったら他の弾丸の当たり判定は不要なのでループをぬける break; } } } |
更新時の処理
更新時の処理を示します。キーが押されている場合は自機を移動させ、一定確率で敵を新しく生成します。ただし敵が1つも存在しない場合は必ず生成します。敵の移動の処理と弾丸の発射、弾丸の移動、当たり判定などを30分の1秒おきにおこないます。
またゲーム中はBGMを鳴らします。エンドレスで鳴り続けるように終わりのほうに近づいたら最初から再生しなおします(音が最後のほうは切れているので単純に繰り返し再生できない)。
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 |
let bgm = new Audio('./bgm.mp3'); let updateCount = 0; function Update(){ updateCount++; UpdatePlayer(); // 更新20回に1回の割合で70%の確率で敵を新たに生成する // ただし10個以上は生成しない // 敵が0個の場合は必ず生成する if(updateCount % 20 == 0 && enemies.length < 10 && Math.random() < 0.7) CreateEmeny(); else if(enemies.length == 0) CreateEmeny(); // 敵が弾丸を発射する EmeniesShot(); // 敵を移動させる UpdateEnemies(); // 当たり判定 HitCheck(); // 火花を移動させる UpdateSparks(); // 移動したオブジェクトを描画する Draw(); // BGMの再生 if(bgm.currentTime >= 78) bgm.currentTime = 0; // ゲームオーバー時は再生を停止する if(!isPlaying) bgm.pause(); } setInterval(() => { Update(); }, 1000 / 30); |
描画処理
描画に関する処理を示します。
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 |
function Draw(){ // canvas全体を黒で塗りつぶす ctx.fillStyle = '#000'; ctx.fillRect(0,0, FIELD_WIDTH, FIELD_HEIGHT); // 敵の描画 for(let i = 0; i < enemies.length; i++) enemies[i].Draw(); // 敵の弾丸の描画 for(let i = 0; i < enemyBullets.length; i++) enemyBullets[i].Draw(); // 自機から発射された弾丸の描画 for(let i = 0; i < playerBullets.length; i++) playerBullets[i].Draw(); // プレイ中だけ自機を描画する if(isPlaying) DrawPlayer(playerX, playerY); // 火花の描画 for(let i = 0; i < sparks.length; i++) sparks[i].Draw(); // ゲームオーバーのときは'GAME OVER'の文字を描画する DrawGameOver(); // スコアの描画 DrawScore(); } |
ゲームオーバー時は’GAME OVER’と描画します。
1 2 3 4 5 6 7 8 9 10 11 |
function DrawGameOver(){ // ゲームオーバー時ではないなら何もしない if(isPlaying) return; let gameover = 'GAME OVER'; ctx.fillStyle="#fff"; ctx.font="30px Arial"; let x = (FIELD_WIDTH - ctx.measureText(gameover).width) / 2; // 中央に表示するためのx座標を取得する ctx.fillText(gameover, x, 150); } |
スコアを描画します。
1 2 3 4 5 6 7 |
function DrawScore(){ let text = score.toString().padStart( 5, '0'); // スコアが4桁以下のときは左0埋めして5桁にする ctx.fillStyle="#fff"; ctx.font="20px Arial"; ctx.textBaseline="top"; ctx.fillText(text, 20, 10); } |
ゲーム開始のための処理
ゲーム開始時の処理を示します。
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 |
function Start(){ // 配列を空にする enemies = []; playerBullets = []; enemyBullets = []; // 自機を初期位置に戻す playerX = FIELD_WIDTH / 2; playerY = FIELD_HEIGHT * 0.85; // スコアをリセットしてプレイ中のフラグを立てる score = 0; isPlaying = true; // ゲーム開始ボタンを非表示にする $start.style.display = 'none'; // 一つ目の敵をすぐ生成する(乱数任せにするとなかなか出てこない場合があるので) let isRight = Math.floor(Math.random() * 2); let y = Math.random() * 48; let type = Math.floor(Math.random() * 3); if(isRight) enemies.push(new Enemy(0,y,true, type)); else enemies.push(new Enemy(FIELD_WIDTH, y, false, type)); // BGMの再生開始 bgm.currentTime = 0; bgm.play(); } |