前回の記事ではゲームエンジンを作成しました。そのゲームエンジンを使って弾幕シューティングを作成してみましょう。
対象となるブラウザはChrome51以降です。
自機と弾
前回、自機を動かすところまでは行きました。今度は以下のようにしてみましょう。
- 自機は画面内だけを動くことができる
- スペースキーを押すと弾が撃てる
- 弾は画面外に行くと消える
まず弾を作ってみましょう。Fighterクラスの上に追加します。
class Bullet extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
const hitArea = new Rectangle(4, 0, 8, 16);
super(x, y, sprite, hitArea, ['playerBullet']);
this.speed = 6;
}
update(gameInfo, input) {
this.y -= this.speed;
if(this.isOutOfBounds(gameInfo.screenRectangle)) {
this.destroy();
}
}
}
Bulletクラスの仕組みは簡単です。毎フレームspeedだけ進んで、画面外に行くと破棄されます。また、タグplayerBulletを振っておきます(後で使います)。
次にFIghterクラスを弄りましょう。画面外に移動できないようにして、スペースキーを押すと弾を撃てるようにします。
class Fighter extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 0, 16, 16));
const hitArea = new Rectangle(8, 8, 2, 2);
super(x, y, sprite, hitArea);
this._interval = 5;
this._timeCount = 0;
this._speed = 3;
this._velocityX = 0;
this._velocityY = 0;
}
update(gameInfo, input) {
// キーを押されたら移動する
this._velocityX = 0;
this._velocityY = 0;
if(input.getKey('ArrowUp')) { this._velocityY = -this._speed; }
if(input.getKey('ArrowDown')) { this._velocityY = this._speed; }
if(input.getKey('ArrowRight')) { this._velocityX = this._speed; }
if(input.getKey('ArrowLeft')) { this._velocityX = -this._speed; }
this.x += this._velocityX;
this.y += this._velocityY;
// 画面外に行ってしまったら押し戻す
const boundWidth = gameInfo.screenRectangle.width - this.width;
const boundHeight = gameInfo.screenRectangle.height - this.height;
const bound = new Rectangle(this.width, this.height, boundWidth, boundHeight);
if(this.isOutOfBounds(bound)) {
this.x -= this._velocityX;
this.y -= this._velocityY;
}
// スペースキーで弾を打つ
this._timeCount++;
const isFireReady = this._timeCount > this._interval;
if(isFireReady && input.getKey(' ')) {
const bullet = new Bullet(this.x, this.y);
this.spawnActor(bullet);
this._timeCount = 0;
}
}
}
画面外からの押し戻しを簡単にするため、移動の仕組みを変更しています。押し戻しは、移動距離を巻き戻すだけです。
スペースキーで弾を撃てるようにしましたが、インターバルを取り入れています。単に「押したから撃つ」にすると毎フレーム発射されて、秒間60発も撃ってしまうからです。
動作確認
これで一度動作確認してみましょう。前回と同様、スペースキーで開始できます。
敵とHPバー
次は敵と、敵のHPバーを追加してみましょう。
Fighterの下にEnemyクラスを追加します。
class Enemy extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 0, 16, 16));
const hitArea = new Rectangle(0, 0, 16, 16);
super(x, y, sprite, hitArea, ['enemy']);
this.maxHp = 50;
this.currentHp = this.maxHp;
// プレイヤーの弾に当たったらHPを減らす
this.addEventListener('hit', (e) => {
if(e.target.hasTag('playerBullet')) {
this.currentHp--;
this.dispatchEvent('changehp', new GameEvent(this));
}
});
}
update(gameInfo, input) {
// HPがゼロになったらdestroyする
if(this.currentHp <= 0) {
this.destroy();
}
}
}
EnemyクラスはHP(ヒットポイント)を持ちます。タグplayerBulletを持つプレイヤーの弾に当たるとHPが減ります。またHPが減ったときにchangehpイベントを発火しておきます。これはHPバーで利用するためです。
次にHPバーを作成してみましょう。
class EnemyHpBar extends Actor {
constructor(x, y, enemy) {
const hitArea = new Rectangle(0, 0, 0, 0);
super(x, y, hitArea);
this._width = 200;
this._height = 10;
this._innerWidth = this._width;
// 敵のHPが変わったら内側の長さを変更する
enemy.addEventListener('changehp', (e) => {
const maxHp = e.target.maxHp;
const hp = e.target.currentHp;
this._innerWidth = this._width * (hp / maxHp);
});
}
render(target) {
const context = target.getContext('2d');
context.strokeStyle = 'white';
context.fillStyle = 'white';
context.strokeRect(this.x, this.y, this._width, this._height);
context.fillRect(this.x, this.y, this._innerWidth, this._height);
}
}
HPバーの仕組みは簡単です。長方形を描画し、敵のHPが変わったら長さを変えるというものです。
また、Bulletクラスに変更を加えて、敵に当たったら消えるようにしておきましょう。
class Bullet extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
const hitArea = new Rectangle(4, 0, 8, 16);
super(x, y, sprite, hitArea, ['playerBullet']);
this._speed = 6;
// 敵に当たったら消える
this.addEventListener('hit', (e) => {
if(e.target.hasTag('enemy')) { this.destroy(); }
});
}
update(gameInfo, input) {
this.y -= this._speed;
if(this.isOutOfBounds(gameInfo.screenRectangle)) {
this.destroy();
}
}
}
全て準備出来たら、メインシーンに敵とHPバーを追加します。
class DanmakuStgMainScene extends Scene {
constructor(renderingTarget) {
super('メイン', 'black', renderingTarget);
const fighter = new Fighter(150, 300);
const enemy = new Enemy(150, 100);
const hpBar = new EnemyHpBar(50, 20, enemy);
this.add(fighter);
this.add(enemy);
this.add(hpBar);
}
}
動作確認
これで一度動作確認してみましょう。敵を攻撃するとHPが減るはずです。
敵の弾と敵の動き
敵の弾と敵の動きを作りましょう。Enemyクラスの上に追加します。
class EnemyBullet extends SpriteActor {
constructor(x, y, velocityX, velocityY) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 16, 16, 16));
const hitArea = new Rectangle(4, 4, 8, 8);
super(x, y, sprite, hitArea, ['enemyBullet']);
this.velocityX = velocityX;
this.velocityY = velocityY;
}
update(gameInfo, input) {
this.x += this.velocityX;
this.y += this.velocityY;
if(this.isOutOfBounds(gameInfo.screenRectangle)) {
this.destroy();
}
}
}
EnemyBulletクラスは初期座標とX軸・Y軸の速度が与えられると、その方向に向かって動きます。画面外に出た時に消える処理を忘れず実装しておきます。また、タグenemyBulletを割り当てておきます。
Enemyクラスを編集して弾を撃てるようにしましょう。複数の弾を円形に撃つようにします。またEnemy自身も動くようにしておきます。
class Enemy extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 0, 16, 16));
const hitArea = new Rectangle(0, 0, 16, 16);
super(x, y, sprite, hitArea, ['enemy']);
this.maxHp = 50;
this.currentHp = this.maxHp;
this._interval = 120;
this._timeCount = 0;
this._velocityX = 0.3;
// プレイヤーの弾に当たったらHPを減らす
this.addEventListener('hit', (e) => {
if(e.target.hasTag('playerBullet')) {
this.currentHp--;
this.dispatchEvent('changehp', new GameEvent(this));
}
});
}
// degree度の方向にspeedの速さで弾を発射する
shootBullet(degree, speed) {
const rad = degree / 180 * Math.PI;
const velocityX = Math.cos(rad) * speed;
const velocityY = Math.sin(rad) * speed;
const bullet = new EnemyBullet(this.x, this.y, velocityX, velocityY);
this.spawnActor(bullet);
}
// num個の弾を円形に発射する
shootCircularBullets(num, speed) {
const degree = 360 / num;
for(let i = 0; i < num; i++) {
this.shootBullet(degree * i, speed);
}
}
update(gameInfo, input) {
// 左右に移動する
this.x += this._velocityX;
if(this.x <= 100 || this.x >= 200) { this._velocityX *= -1; }
// インターバルを経過していたら弾を撃つ
this._timeCount++;
if(this._timeCount > this._interval) {
this.shootCircularBullets(15, 1);
this._timeCount = 0;
}
// HPがゼロになったらdestroyする
if(this.currentHp <= 0) {
this.destroy();
}
}
}
角度をつけて動かすにはsinやcosなどの数学的な要素が入ってきます。sin(rad)やcos(rad)で単位円(半径1の円)上でのX座標・Y座標の値が取れるので、それに速度を掛けてやれば、ある速度でrad度動くときのX速度・Y速度の値がわかります。
次にFighterクラスを、敵の弾が当たったときに消えるようにしましょう。
class Fighter extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 0, 16, 16));
const hitArea = new Rectangle(8, 8, 2, 2);
super(x, y, sprite, hitArea);
this._interval = 5;
this._timeCount = 0;
this._speed = 3;
this._velocityX = 0;
this._velocityY = 0;
// 敵の弾に当たったらdestroyする
this.addEventListener('hit', (e) => {
if(e.target.hasTag('enemyBullet')) {
this.destroy();
}
});
}
update(gameInfo, input) {
// キーを押されたら移動する
this._velocityX = 0;
this._velocityY = 0;
if(input.getKey('ArrowUp')) { this._velocityY = -this._speed; }
if(input.getKey('ArrowDown')) { this._velocityY = this._speed; }
if(input.getKey('ArrowRight')) { this._velocityX = this._speed; }
if(input.getKey('ArrowLeft')) { this._velocityX = -this._speed; }
this.x += this._velocityX;
this.y += this._velocityY;
// 画面外に行ってしまったら押し戻す
const boundWidth = gameInfo.screenRectangle.width - this.width;
const boundHeight = gameInfo.screenRectangle.height - this.height;
const bound = new Rectangle(this.width, this.height, boundWidth, boundHeight);
if(this.isOutOfBounds(bound)) {
this.x -= this._velocityX;
this.y -= this._velocityY;
}
// スペースキーで弾を打つ
this._timeCount++;
const isFireReady = this._timeCount > this._interval;
if(isFireReady && input.getKey(' ')) {
const bullet = new Bullet(this.x, this.y);
this.spawnActor(bullet);
this._timeCount = 0;
}
}
}
動作確認
ここまでできたら動作確認します。よりゲームらしくなってきました。
クリア画面とゲームオーバー画面
次にクリア画面とゲームオーバー画面のシーンを作りましょう。
テキストを簡単に描画できるように、Titleクラスを改造してTextLabelクラスを作りましょう。Titleクラスは削除してしまってかまいません。
class TextLabel extends Actor {
constructor(x, y, text) {
const hitArea = new Rectangle(0, 0, 0, 0);
super(x, y, hitArea);
this.text = text;
}
render(target) {
const context = target.getContext('2d');
context.font = '25px sans-serif';
context.fillStyle = 'white';
context.fillText(this.text, this.x, this.y);
}
}
タイトルシーンのTitleクラスをTextLabelクラスに置き換えます。
class DanmakuStgTitleScene extends Scene {
constructor(renderingTarget) {
super('タイトル', 'black', renderingTarget);
const title = new TextLabel(100, 200, '弾幕STG');
this.add(title);
}
update(gameInfo, input) {
super.update(gameInfo, input);
if(input.getKeyDown(' ')) {
const mainScene = new DanmakuStgMainScene(this.renderingTarget);
this.changeScene(mainScene);
}
}
}
同じようにしてクリアシーンとゲームオーバーシーンを作りましょう。メインシーンであるDanmakuStgMainSceneクラスの上に追加します。
class DanmakuStgEndScene extends Scene {
constructor(renderingTarget) {
super('クリア', 'black', renderingTarget);
const text = new TextLabel(60, 200, 'ゲームクリア!');
this.add(text);
}
}
class DanmakuStgGameOverScene extends Scene {
constructor(renderingTarget) {
super('ゲームオーバー', 'black', renderingTarget);
const text = new TextLabel(50, 200, 'ゲームオーバー…');
this.add(text);
}
}
ふたつのシーンができたらメインシーンを編集して、自分がやられたらゲームオーバーシーンを、敵がやられたらクリアシーンを表示するようにします。
class DanmakuStgMainScene extends Scene {
constructor(renderingTarget) {
super('メイン', 'black', renderingTarget);
const fighter = new Fighter(150, 300);
const enemy = new Enemy(150, 100);
const hpBar = new EnemyHpBar(50, 20, enemy);
this.add(fighter);
this.add(enemy);
this.add(hpBar);
// 自機がやられたらゲームオーバー画面にする
fighter.addEventListener('destroy', (e) => {
const scene = new DanmakuStgGameOverScene(this.renderingTarget);
this.changeScene(scene);
});
// 敵がやられたらクリア画面にする
enemy.addEventListener('destroy', (e) => {
const scene = new DanmakuStgEndScene(this.renderingTarget);
this.changeScene(scene);
});
}
}
動作確認
それでは動作確認です。
ひとまず完成
自機がいて、敵がいて、倒すとクリアで、倒されるとゲームオーバー、と一通りゲームの流れができました。これでひとまず完成です!
その3に続く
JavaScriptで弾幕STGをフルスクラッチで作る その3 弾幕編に続きます。
ここまでのプログラム
'use strict';
class TextLabel extends Actor {
constructor(x, y, text) {
const hitArea = new Rectangle(0, 0, 0, 0);
super(x, y, hitArea);
this.text = text;
}
render(target) {
const context = target.getContext('2d');
context.font = '25px sans-serif';
context.fillStyle = 'white';
context.fillText(this.text, this.x, this.y);
}
}
class Bullet extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
const hitArea = new Rectangle(4, 0, 8, 16);
super(x, y, sprite, hitArea, ['playerBullet']);
this._speed = 6;
// 敵に当たったら消える
this.addEventListener('hit', (e) => {
if(e.target.hasTag('enemy')) { this.destroy(); }
});
}
update(gameInfo, input) {
this.y -= this._speed;
if(this.isOutOfBounds(gameInfo.screenRectangle)) {
this.destroy();
}
}
}
class Fighter extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 0, 16, 16));
const hitArea = new Rectangle(8, 8, 2, 2);
super(x, y, sprite, hitArea);
this._interval = 5;
this._timeCount = 0;
this._speed = 3;
this._velocityX = 0;
this._velocityY = 0;
// 敵の弾に当たったらdestroyする
this.addEventListener('hit', (e) => {
if(e.target.hasTag('enemyBullet')) {
this.destroy();
}
});
}
update(gameInfo, input) {
// キーを押されたら移動する
this._velocityX = 0;
this._velocityY = 0;
if(input.getKey('ArrowUp')) { this._velocityY = -this._speed; }
if(input.getKey('ArrowDown')) { this._velocityY = this._speed; }
if(input.getKey('ArrowRight')) { this._velocityX = this._speed; }
if(input.getKey('ArrowLeft')) { this._velocityX = -this._speed; }
this.x += this._velocityX;
this.y += this._velocityY;
// 画面外に行ってしまったら押し戻す
const boundWidth = gameInfo.screenRectangle.width - this.width;
const boundHeight = gameInfo.screenRectangle.height - this.height;
const bound = new Rectangle(this.width, this.height, boundWidth, boundHeight);
if(this.isOutOfBounds(bound)) {
this.x -= this._velocityX;
this.y -= this._velocityY;
}
// スペースキーで弾を打つ
this._timeCount++;
const isFireReady = this._timeCount > this._interval;
if(isFireReady && input.getKey(' ')) {
const bullet = new Bullet(this.x, this.y);
this.spawnActor(bullet);
this._timeCount = 0;
}
}
}
class EnemyBullet extends SpriteActor {
constructor(x, y, velocityX, velocityY) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 16, 16, 16));
const hitArea = new Rectangle(4, 4, 8, 8);
super(x, y, sprite, hitArea, ['enemyBullet']);
this.velocityX = velocityX;
this.velocityY = velocityY;
}
update(gameInfo, input) {
this.x += this.velocityX;
this.y += this.velocityY;
if(this.isOutOfBounds(gameInfo.screenRectangle)) {
this.destroy();
}
}
}
class Enemy extends SpriteActor {
constructor(x, y) {
const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 0, 16, 16));
const hitArea = new Rectangle(0, 0, 16, 16);
super(x, y, sprite, hitArea, ['enemy']);
this.maxHp = 50;
this.currentHp = this.maxHp;
this._interval = 120;
this._timeCount = 0;
this._velocityX = 0.3;
// プレイヤーの弾に当たったらHPを減らす
this.addEventListener('hit', (e) => {
if(e.target.hasTag('playerBullet')) {
this.currentHp--;
this.dispatchEvent('changehp', new GameEvent(this));
}
});
}
// degree度の方向にspeedの速さで弾を発射する
shootBullet(degree, speed) {
const rad = degree / 180 * Math.PI;
const velocityX = Math.cos(rad) * speed;
const velocityY = Math.sin(rad) * speed;
const bullet = new EnemyBullet(this.x, this.y, velocityX, velocityY);
this.spawnActor(bullet);
}
// num個の弾を円形に発射する
shootCircularBullets(num, speed) {
const degree = 360 / num;
for(let i = 0; i < num; i++) {
this.shootBullet(degree * i, speed);
}
}
update(gameInfo, input) {
// 左右に移動する
this.x += this._velocityX;
if(this.x <= 100 || this.x >= 200) { this._velocityX *= -1; }
// インターバルを経過していたら弾を撃つ
this._timeCount++;
if(this._timeCount > this._interval) {
this.shootCircularBullets(15, 1);
this._timeCount = 0;
}
// HPがゼロになったらdestroyする
if(this.currentHp <= 0) {
this.destroy();
}
}
}
class EnemyHpBar extends Actor {
constructor(x, y, enemy) {
const hitArea = new Rectangle(0, 0, 0, 0);
super(x, y, hitArea);
this._width = 200;
this._height = 10;
this._innerWidth = this._width;
// 敵のHPが変わったら内側の長さを変更する
enemy.addEventListener('changehp', (e) => {
const maxHp = e.target.maxHp;
const hp = e.target.currentHp;
this._innerWidth = this._width * (hp / maxHp);
});
}
render(target) {
const context = target.getContext('2d');
context.strokeStyle = 'white';
context.fillStyle = 'white';
context.strokeRect(this.x, this.y, this._width, this._height);
context.fillRect(this.x, this.y, this._innerWidth, this._height);
}
}
class DanmakuStgEndScene extends Scene {
constructor(renderingTarget) {
super('クリア', 'black', renderingTarget);
const text = new TextLabel(60, 200, 'ゲームクリア!');
this.add(text);
}
}
class DanmakuStgGameOverScene extends Scene {
constructor(renderingTarget) {
super('ゲームオーバー', 'black', renderingTarget);
const text = new TextLabel(50, 200, 'ゲームオーバー…');
this.add(text);
}
}
class DanmakuStgMainScene extends Scene {
constructor(renderingTarget) {
super('メイン', 'black', renderingTarget);
const fighter = new Fighter(150, 300);
const enemy = new Enemy(150, 100);
const hpBar = new EnemyHpBar(50, 20, enemy);
this.add(fighter);
this.add(enemy);
this.add(hpBar);
// 自機がやられたらゲームオーバー画面にする
fighter.addEventListener('destroy', (e) => {
const scene = new DanmakuStgGameOverScene(this.renderingTarget);
this.changeScene(scene);
});
// 敵がやられたらクリア画面にする
enemy.addEventListener('destroy', (e) => {
const scene = new DanmakuStgEndScene(this.renderingTarget);
this.changeScene(scene);
});
}
}
class DanmakuStgTitleScene extends Scene {
constructor(renderingTarget) {
super('タイトル', 'black', renderingTarget);
const title = new TextLabel(100, 200, '弾幕STG');
this.add(title);
}
update(gameInfo, input) {
super.update(gameInfo, input);
if(input.getKeyDown(' ')) {
const mainScene = new DanmakuStgMainScene(this.renderingTarget);
this.changeScene(mainScene);
}
}
}
class DanamkuStgGame extends Game {
constructor() {
super('弾幕STG', 300, 400, 60);
const titleScene = new DanmakuStgTitleScene(this.screenCanvas);
this.changeScene(titleScene);
}
}
assets.addImage('sprite', 'sprite.png');
assets.loadAll().then((a) => {
const game = new DanamkuStgGame();
document.body.appendChild(game.screenCanvas);
game.start();
});