インベーダーゲームをReactで作ってみた
はじめに
私が初めてプログラム(Scratch)で作った作品はインベーダーゲームでした。当時のコードを見返してみると、初期値のx, y座標が違うだけのインベーダーをわざわざ20体用意したり、ビームの処理に「インベーダー1に当たった」「インベーダー2に当たった」...と全インベーダー分の当たり判定を書いたりと、若さと力強さが溢れる処理を書いていたようです。
あれから随分と経ちましたので、今回はリベンジを兼ねてReactでインベーダーゲームを作ってみました。
今回Reactで作ったインベーダーゲーム
概要
本家のインベーダーゲームと同様に
・砲台
・インベーダー
・UFO
・ビーム
・ミサイル
などの基本的な要素を実装しました。
もちろん「GAME CLEAR」と「GAME OVER」の画面まで用意しています。
せっかくですので、今回は実装方針といくつかのコンポーネントをピックアップして簡単にご紹介します。
実装方針
まずは今回の実装方針について簡単にご紹介します。
処理を待たせる
インベーダーゲームではインベーダーを動かす、ビーム・ミサイルで攻撃する際など一定時間ごとに処理を実行する場面があります。JavaScriptで一定時間ごとに処理を実行する場合はsetInterval()
を使うと思いますが、頻繁に処理を停止したり、待ち時間を変更するインベーダーゲームとは相性が悪かったので自前のsleep関数を作成しました。
const sleep = (time: number) => {
return new Promise((resolve) => setTimeout(resolve, time));
};
sleep関数の使い方は以下の通りです。このように書くことで「停止する理由」を外部から渡しやすくなったり、「待ち時間」を変更しやすくなるので今回はこちらの方法を採用しました。
const sample = async () => {
while (true) {
// 実行したい処理
if (停止する理由) {
break;
}
await sleep(待ち時間);
}
}
当たり判定
インベーダーゲームを作る上で一番大事な要素はインベーダーの当たり判定だと思います。1秒未満の待ち時間で移動するビームに下手なロジックを書くと処理が重くなってしまいます。そこで今回は2次元配列を使った座標を用意してインベーダーを配置してみました。
イメージ的には以下のような感じです。70 × 97の2次元配列を用意して、ゲーム開始時に赤色の座標にインベーダーを配置します。こちらの2次元配列に対してビームは「xが◯、yが◯の座標にインベーダーが存在するか?」を判定するだけでよくなるので、当たり判定のロジックが軽量になります。
実際は赤色の座標だけだと当たり判定が狭すぎるので、x座標が±2の座標(グレーに着色した部分)まで当たり判定を広げています。
主なコンポーネント
次に今回実装したコンポーネントについていくつかご紹介します。
砲台
・キーボードの左右キーによる移動
・通常時 or 撃破時の画像の切り替え
の処理を持つコンポーネントです。
通常時はpngの画像を表示しており、ミサイルがヒットした際には演出用のgifに切り替えて表示しています。残機も実装しているので、ミサイルに3回ヒットするとゲームオーバーになります。
処理内容のイメージは以下の通りです。インベーダーゲームではコンポーネントの状態(state)がコンポーネント外から変更される場面が多々あるので、砲台に限らずstateは各コンポーネント専用のcontextで保持するようにしています。
export const Battery = (_: IProps) => {
const { batteryCoordinate, isDestroyed } = useBatteryContext();
const getFilePath = (isDestroyed: boolean): string => {
if (isDestroyed) {
return '/images/battery2.gif';
}
return '/images/battery1.png';
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === EKeyCode.ARROW_RIGHT) {
// 砲台を右に移動する
}
if (event.code === EKeyCode.ARROW_LEFT) {
// 砲台を左に移動する
}
};
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<img
style={{
height: '28px',
width: '40px',
position: 'absolute',
top: `calc(${batteryCoordinate.y * 10}px - 28px)`,
left: `calc(${batteryCoordinate.x * 10}px - 20px)`,
objectFit: 'cover',
}}
alt={'battery'}
src={getFilePath(isDestroyed)}
/>
);
};
ビーム
砲台から発射され
・インベーダーに命中
・UFOに命中
・画面の端まで到達
した際に攻撃を停止します。
先ほどご紹介した通り、今回は砲台、インベーダー、UFOなどそれぞれのコンポーネントに対応する専用のcontextを用意しています。
こちらの方法を採用したことでビームコンポーネントが担うべきではない処理を
・発射時の座標取得(砲台のcontext)
・当たり判定(インベーダーのcontext)
・当たり判定(UFOのcontext)
のように切り出すことができました。
Scratchで作っていた頃は1つのコンポーネントが責務過多になっていたり、本来の役割から考えると相応しく無い処理を持たせていたこともありました。あの頃から比べると大きな成長ですね。
インベーダー
インベーダーの種類と状態によって画像を切り替えるコンポーネントです。移動ロジック自体は親コンポーネントが持っているのでシンプルなコードになっております。
画像については
・インベーダーの種類(状態)
・x座標が偶数 or 奇数
で出しわけています。
export const Invader = ({ xCoordinate, yCoordinate, invaderType }: IProps) => {
const getFilePath = (invaderType: TInvaderType, yCoordinate: number): string => {
if (invaderType === EInvaderType.CRAB) {
return `/images/invader1${yCoordinate % 2 === 0 ? '1' : '2'}.png`;
}
if (invaderType === EInvaderType.CRAB_DESTROYED) {
return '/images/invader13.png';
}
if (invaderType === EInvaderType.OCTOPUS) {
return `/images/invader2${yCoordinate % 2 === 0 ? '1' : '2'}.png`;
}
if (invaderType === EInvaderType.OCTOPUS_DESTROYED) {
return '/images/invader23.png';
}
return '';
};
return (
<Visible isOpen={invaderType !== EInvaderType.DEFAULT}>
<img
style={{
height: '32px',
width: '40px',
position: 'absolute',
top: `calc(${xCoordinate * 10}px - 16px)`,
left: `calc(${yCoordinate * 10}px - 20px)`,
objectFit: 'cover',
}}
alt={'invader'}
src={getFilePath(invaderType, yCoordinate)}
/>
</Visible>
);
};
まとめ
今回はReactでインベーダーゲームを作成してみました。Scratchで作った頃と比べてロジックも洗練され、責務も良い感じに分割できたのではないかと思います。一方で当時のロジックでそのまま実装した箇所もあり、「初学者なりに工夫していたんだなぁ」と懐かしさを感じました。
余談ですが最近の業務ではAIにコーディングを任せる機会も増えてきました。しかし、今回はプライベートの開発でスピードや効率は求められていないので、AIには頼らずに実装してみました。やっぱり自力でコードをゴリゴリ書くのは楽しいですね。
Discussion