1. はじめに
Web Development for Beginners の3つのレッスンをやっていきます。
- 18日目: Build a Space Game Part 4: Adding A Laser and Detect Collisions
- レーザーオブジェクトと衝突検知機能を追加する。
- 19日目: Build a Space Game Part 5: Scoring and Lives
- 得点表示機能とライフ表示機能を追加する。
- 20日目: Build a Space Game Part 6: End and Restart
- 終了機能と再開機能を追加する。
スペースゲーム(シューティングゲーム)を作成するレッスンの後半にあたります。
※ 本記事は、「個人的なメモ」という意味合いが強いです。
2. Part 4: レーザーと衝突検知を追加する
ソースコード:app.js
(1) 衝突検知のための「オブジェクトの四角形表現を返す関数」を用意する
rectFromGameObject() {
return {
top: this.y,
left: this.x,
bottom: this.y + this.height,
right: this.x + this.width,
};
}
(2) 衝突検知の判定関数を追加する
引数で渡された2つの四角形に重なりがあるか判定する。
function intersectRect(r1, r2) {
return !(
r2.left > r1.right ||
r2.right < r1.left ||
r2.top > r1.bottom ||
r2.bottom < r1.top
);
}
(3) レーザー発射機能を追加する
(i) キー・イベントのメッセージを追加する
KEY_EVENT_SPACE: "KEY_EVENT_SPACE",
COLLISION_ENEMY_LASER: "COLLISION_ENEMY_LASER",
COLLISION_ENEMY_HERO: "COLLISION_ENEMY_HERO",
(ii) スペース・キーを処理する。
} else if(evt.keyCode === 32) {
eventEmitter.emit(Messages.KEY_EVENT_SPACE);
}
(iii) リスナーを追加する。
スペース・キーが押された場合のリスナー
eventEmitter.on(Messages.KEY_EVENT_SPACE, () => {
if (hero.canFire()) {
hero.fire();
}
敵がレーザーと衝突した場合のリスナー
eventEmitter.on(Messages.COLLISION_ENEMY_LASER, (_, { first, second }) => {
first.dead = true;
second.dead = true;
})
(iv) レーザーを動かす。
class Laser extends GameObject {
constructor(x, y) {
super(x,y);
(this.width = 9), (this.height = 33);
this.type = 'Laser';
this.img = laserImg;
let id = setInterval(() => {
if (this.y > 0) {
this.y -= 15;
} else {
this.dead = true;
clearInterval(id);
}
}, 100)
}
}
(v) 衝突を処理する。
function updateGameObjects() {
const enemies = gameObjects.filter(go => go.type === 'Enemy');
const lasers = gameObjects.filter((go) => go.type === "Laser");
// laser hit something
lasers.forEach((l) => {
enemies.forEach((m) => {
if (intersectRect(l.rectFromGameObject(), m.rectFromGameObject())) {
eventEmitter.emit(Messages.COLLISION_ENEMY_LASER, {
first: l,
second: m,
});
}
});
});
gameObjects = gameObjects.filter(go => !go.dead);
}
(vi) クールダウンのクラスを定義する。
class Hero extends GameObject {
constructor(x, y) {
super(x, y);
(this.width = 99), (this.height = 75);
this.type = "Hero";
this.speed = { x: 0, y: 0 };
this.cooldown = 0;
}
fire() {
gameObjects.push(new Laser(this.x + 45, this.y - 10));
this.cooldown = 500;
let id = setInterval(() => {
if (this.cooldown > 0) {
this.cooldown -= 100;
} else {
clearInterval(id);
}
}, 200);
}
canFire() {
return this.cooldown === 0;
}
}
3. Part 5: スコアとライフの処理
ソースコード:app.js
(1) 必要なアセットを solution/assets/ フォルダーから your-work フォルダーにコピーします。
lifeImg = await loadTexture("assets/life.png");
(2) lifeImg をアセットのリストに追加します。
let heroImg,
...
lifeImg,
...
eventEmitter = new EventEmitter();
(3) 変数を追加します。
(4) updateGameObjects() 関数を拡張します。
enemies.forEach(enemy => {
const heroRect = hero.rectFromGameObject();
if (intersectRect(heroRect, enemy.rectFromGameObject())) {
eventEmitter.emit(Messages.COLLISION_ENEMY_HERO, { enemy });
}
})
(5) life と points を追加します。
(i) 変数を初期化します。
this.life = 3;
this.points = 0;
(ii) 画面に変数を描画します。
function drawLife() {
// TODO, 35, 27
const START_POS = canvas.width - 180;
for(let i=0; i < hero.life; i++ ) {
ctx.drawImage(
lifeImg,
START_POS + (45 * (i+1) ),
canvas.height - 37);
}
}
function drawPoints() {
ctx.font = "30px Arial";
ctx.fillStyle = "red";
ctx.textAlign = "left";
drawText("Points: " + hero.points, 10, canvas.height-20);
}
function drawText(message, x, y) {
ctx.fillText(message, x, y);
}
(iii) ゲームループにメソッドを追加します。
drawPoints();
drawLife();
(6) ゲームルールを実装します。
(i) ヒーローと敵の衝突ごとに、ライフを差し引きます。
decrementLife() {
this.life--;
if (this.life === 0) {
this.dead = true;
}
}
(ii) 敵に当たるレーザーごとに、ゲームのスコアを100ポイント増やします。
incrementPoints() {
this.points += 100;
}
これらの関数を衝突イベントエミッターに追加します。
eventEmitter.on(Messages.COLLISION_ENEMY_LASER, (_, { first, second }) => {
first.dead = true;
second.dead = true;
hero.incrementPoints();
})
eventEmitter.on(Messages.COLLISION_ENEMY_HERO, (_, { enemy }) => {
enemy.dead = true;
hero.decrementLife();
});
4. Part 6: 終了と再開
ソースコード:app.js
(1) 終了状態を追跡します。
function isHeroDead() {
return hero.life <= 0;
}
function isEnemiesDead() {
const enemies = gameObjects.filter((go) => go.type === "Enemy" && !go.dead);
return enemies.length === 0;
}
(2) メッセージハンドラにロジックを追加します。
eventEmitter.on(Messages.COLLISION_ENEMY_LASER, (_, { first, second }) => {
first.dead = true;
second.dead = true;
hero.incrementPoints();
if (isEnemiesDead()) {
eventEmitter.emit(Messages.GAME_END_WIN);
}
});
eventEmitter.on(Messages.COLLISION_ENEMY_HERO, (_, { enemy }) => {
enemy.dead = true;
hero.decrementLife();
if (isHeroDead()) {
eventEmitter.emit(Messages.GAME_END_LOSS);
return; // loss before victory
}
if (isEnemiesDead()) {
eventEmitter.emit(Messages.GAME_END_WIN);
}
});
eventEmitter.on(Messages.GAME_END_WIN, () => {
endGame(true);
});
eventEmitter.on(Messages.GAME_END_LOSS, () => {
endGame(false);
});
(3) 新しいメッセージタイプを追加します。
GAME_END_LOSS: "GAME_END_LOSS",
GAME_END_WIN: "GAME_END_WIN",
(4) 選択したボタンを押すだけでゲームを再開するコードコードを追加します。
(i) Enterキーの押下をリッスンします。
else if(evt.key === "Enter") {
eventEmitter.emit(Messages.KEY_EVENT_ENTER);
}
(ii) 再起動メッセージを追加します。
KEY_EVENT_ENTER: "KEY_EVENT_ENTER",
(5) ゲームルールを実装します。
(i) プレイヤーの勝利条件。
a. まず、displayMessage() 関数を作成します。
function displayMessage(message, color = "red") {
ctx.font = "30px Arial";
ctx.fillStyle = color;
ctx.textAlign = "center";
ctx.fillText(message, canvas.width / 2, canvas.height / 2);
}
b. endGame() 関数を作成します。
function endGame(win) {
clearInterval(gameLoopId);
// set a delay so we are sure any paints have finished
setTimeout(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (win) {
displayMessage(
"Victory!!! Pew Pew... - Press [Enter] to start a new game Captain Pew Pew",
"green"
);
} else {
displayMessage(
"You died !!! Press [Enter] to start a new game Captain Pew Pew"
);
}
}, 200)
}
(ii) ロジックを再起動します。
a. resetGame() 関数を作成します。
function resetGame() {
if (gameLoopId) {
clearInterval(gameLoopId);
eventEmitter.clear();
initGame();
gameLoopId = setInterval(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawPoints();
drawLife();
updateGameObjects();
drawGameObjects(ctx);
}, 100);
}
}
(iii) initGame() でゲームをリセットすために、eventEmitterにリスナーを追加します。
eventEmitter.on(Messages.KEY_EVENT_ENTER, () => {
resetGame();
});
(iv) EventEmitter に clear() 関数を追加します。
clear() {
this.listeners = {};
}
5. まとめ
スペースゲームの開発において、ベースとなった手法は以下の2点でした。
- クラスと継承でゲームオブジェクトを表す
- Pub/Subパターン (EventEmitterクラス) を使って動作を実行する
その上でゲームとして具体的に必要な、
- キャラクターを動かす
- 衝突検知
- スコア
- ライフ
といった機能を順番に追加していきました。
実践的な内容なので、今後ゲームを作る予定のある人にとってはかなり参考になるのではないかと思います。