Edited at

[連載] javascriptで作るシューティングゲーム的な何か(7)

More than 3 years have passed since last update.


さあ、はじめよう


はじめに

本稿は掲題の通り javascript を用いて[ シューティングゲーム的な何か ]を作ろうという試みについて解説するテキストの第七回です。


想定する読者


  • 割と暇である

  • プログラミングに興味がある

  • ゲーム作りに興味がある

  • javascriptの基本をマスターしたけど特に作るものがない

  • javascriptを使った動きのある処理を実装してみたい

  • canvas でなんか作ってみたい


本連載の狙い

本連載はどちらかというと初心者向けです。

このページに検索からやってきた「ゲーム作りてえええええ」と日に三十回くらい叫んでいる小中学生諸君は、まずjavascriptの基本をお勉強してから本連載を読みましょう。

また、最終的に出来上がる[ シューティングゲーム的な何か ]は、そんなに大層なものではありません。シューティングゲームがどのような感じで作られていくのか、その過程を眺めていろいろ考えていただくキッカケを作ることが本連載の狙いです。

また、本連載では伝わりやすさ優先でテキストを書きます。たとえば javascript には厳密にはクラスはありませんが、連載内でクラスという言葉を使って解説します。このあたりは理解しやすさや、雰囲気を伝えることを優先して書きます。


オンラインサンプル

本連載は全10回を予定しています。

各回にはその時点までの[ シューティングゲーム的な何か ]のサンプルが付属します。

各テキストからリンクを張っておきますので、オンラインで実際に動作確認が行えます。

サンプルプログラム一式については著作権とかライセンスとかそういったものは一切ありません。

ちなみに、最終的に完成する[ シューティングゲーム的な何か ]はこんな感じです。

マウスによる移動、クリックによるショットが可能です。ESC キーでプログラムを停止します。

javascriptで作るシューティングゲーム的な何か(1)

javascriptで作るシューティングゲーム的な何か(2)

javascriptで作るシューティングゲーム的な何か(3)

javascriptで作るシューティングゲーム的な何か(4)

javascriptで作るシューティングゲーム的な何か(5)

javascriptで作るシューティングゲーム的な何か(6)

javascriptで作るシューティングゲーム的な何か(7)

javascriptで作るシューティングゲーム的な何か(8)

javascriptで作るシューティングゲーム的な何か(9)

javascriptで作るシューティングゲーム的な何か(最終回)


書いてる人

書いてる人はdoxasという人です。

こんな企画もやってますので、少しでも javascript でシューティングゲームを作成することに興味がわいたら、ぜひ参加してください。待ってますよ!!


さて、つくろう


サンプルの実行結果


前回は敵キャラクターが自発的にショットを撃って、それが自機に向かって飛んでくるようにしました。

ベクトルという概念が登場したために、いままでそういったものに馴染みのなかった人にはちょっと難しかったかもしれません。

今回は、シューティングゲームを作成するうえで避けては通れない、衝突判定について考えてみます。


衝突判定の実装方法

シューティングゲームにおける鬼門と呼ばれることもある衝突判定。

前回苦労してベクトルの解説をしたことが、実は今回の内容で非常に生きてきます。

というのも、前回 common.js に加えたPointクラスのメソッドを利用すれば、衝突判定は実に簡単だからです。

誤解のないように付け加えておくと、衝突判定にはいろいろやり方が考えられます。ゲームの内容やどのようにデータを管理しているかによって、実装方法はさまざまです。ただし、本連載のようにベクトルベースで座標を管理している場合には、単純に双方の距離を計測して、どれくらいの距離が離れているのかを知るのは簡単です。

距離が一定以下にまで縮まっていたら、それを衝突しているとみなしてやります。今回はこれをコードとして記述してみます。


main.jsへの修正

先ほども書いたように、本連載では衝突判定を対象同士の距離で判断するようにします。

これには、前回実装したPointクラスのメソッドが活躍してくれます。

今回は main.js に対してのみ修正を加えます。

それも、大したコード量ではないのでさっくり解説してしまいます。


main.js

// 衝突判定 -----------------------------------------------------------

// すべての自機ショットを調査する
for(i = 0; i < CHARA_SHOT_MAX_COUNT; i++){
// 自機ショットの生存フラグをチェック
if(charaShot[i].alive){
// 自機ショットとエネミーとの衝突判定
for(j = 0; j < ENEMY_MAX_COUNT; j++){
// エネミーの生存フラグをチェック
if(enemy[j].alive){
// エネミーと自機ショットとの距離を計測
p = enemy[j].position.distance(charaShot[i].position);
if(p.length() < enemy[j].size){
// 衝突していたら生存フラグを降ろす
enemy[j].alive = false;
charaShot[i].alive = false;

// 衝突があったのでループを抜ける
break;
}
}
}
}
}


今回はまず、自機のショットと、敵キャラクターとの、両者の衝突を判定してみます。

ショットや敵を新規にセットするときと同様に、まずはaliveプロパティをチェックして、双方が既に登場しているかどうかを確認します。双方共にaliveプロパティがtrueだった場合には、実際に衝突判定を行います。

衝突判定を行っている部分は以下の抜粋箇所ですね。


衝突判定箇所を抜粋

// エネミーと自機ショットとの距離を計測

p = enemy[j].position.distance(charaShot[i].position);
if(p.length() < enemy[j].size){
// 衝突していたら生存フラグを降ろす
enemy[j].alive = false;
charaShot[i].alive = false;

// 衝突があったのでループを抜ける
break;
}


敵キャラクターからショットを発射するときにやったのと同じように、敵キャラクターの現在位置と自機ショットのふたつを使ってdistanceメソッドを走らせます。結果は、変数pに格納しておきます。

そして、この変数pも、Pointクラスのインスタンスなので、p.length()というようにメソッドを呼び出してやり、これと敵キャラクターのサイズを比較します。

これを図解すると以下のようになります。図で見ると、わかりやすいと思います。

敵キャラクターのサイズよりもp.length()の結果が小さい場合には、自機ショットは敵キャラクターの円の内側に入っているか、少なくとも、ショットの中心部分の座標が敵キャラクターの円の中に入ってめり込んだような状態になっているということですね。

このように、Pointクラスのメソッドを用いて衝突を判定し、もし衝突が起こっていた場合には、敵キャラクターのaliveプロパティにfalseを設定して生存フラグを降ろしておきます。

同様に、自機ショットの生存フラグを降ろすことで、ヒットしたショットは一緒に消えることになります。

もし、貫通力のあるショットなどを実装する場合には、ショット側のフラグはそのままにしておけばいいわけです。


全体の構成を再確認

さて、今回まででだいぶコードの量も増えてきました。

サンプルの実行ページに行ってソースを見ればすべてのコードを見ることができますが、今回は解説すべき内容も少なかったので、せっかくなので main.js の全コードを一度掲載してみます。


main.js

// - global -------------------------------------------------------------------

var screenCanvas, info;
var run = true;
var fps = 1000 / 30;
var mouse = new Point();
var ctx;
var fire = false;
var counter = 0;

// - const --------------------------------------------------------------------
var CHARA_COLOR = 'rgba(0, 0, 255, 0.75)';
var CHARA_SHOT_COLOR = 'rgba(0, 255, 0, 0.75)';
var CHARA_SHOT_MAX_COUNT = 10;
var ENEMY_COLOR = 'rgba(255, 0, 0, 0.75)';
var ENEMY_MAX_COUNT = 10;
var ENEMY_SHOT_COLOR = 'rgba(255, 0, 255, 0.75)';
var ENEMY_SHOT_MAX_COUNT = 100;

// - main ---------------------------------------------------------------------
window.onload = function(){
// 汎用変数
var i, j;
var p = new Point();

// スクリーンの初期化
screenCanvas = document.getElementById('screen');
screenCanvas.width = 256;
screenCanvas.height = 256;

// 2dコンテキスト
ctx = screenCanvas.getContext('2d');

// イベントの登録
screenCanvas.addEventListener('mousemove', mouseMove, true);
screenCanvas.addEventListener('mousedown', mouseDown, true);
window.addEventListener('keydown', keyDown, true);

// その他のエレメント関連
info = document.getElementById('info');

// 自機初期化
var chara = new Character();
chara.init(10);

// 自機ショット初期化
var charaShot = new Array(CHARA_SHOT_MAX_COUNT);
for(i = 0; i < CHARA_SHOT_MAX_COUNT; i++){
charaShot[i] = new CharacterShot();
}

// エネミー初期化
var enemy = new Array(ENEMY_MAX_COUNT);
for(i = 0; i < ENEMY_MAX_COUNT; i++){
enemy[i] = new Enemy();
}

// エネミーショット初期化
var enemyShot = new Array(ENEMY_SHOT_MAX_COUNT);
for(i = 0; i < ENEMY_SHOT_MAX_COUNT; i++){
enemyShot[i] = new EnemyShot();
}

// レンダリング処理を呼び出す
(function(){
// カウンタをインクリメント
counter++;

// HTMLを更新
info.innerHTML = mouse.x + ' : ' + mouse.y;

// screenクリア
ctx.clearRect(0, 0, screenCanvas.width, screenCanvas.height);

// 自機 ---------------------------------------------------------------
// パスの設定を開始
ctx.beginPath();

// 自機の位置を設定
chara.position.x = mouse.x;
chara.position.y = mouse.y;

// 自機を描くパスを設定
ctx.arc(
chara.position.x,
chara.position.y,
chara.size,
0, Math.PI * 2, false
);

// 自機の色を設定する
ctx.fillStyle = CHARA_COLOR;

// 自機を描く
ctx.fill();

// fireフラグの値により分岐
if(fire){
// すべての自機ショットを調査する
for(i = 0; i < CHARA_SHOT_MAX_COUNT; i++){
// 自機ショットが既に発射されているかチェック
if(!charaShot[i].alive){
// 自機ショットを新規にセット
charaShot[i].set(chara.position, 3, 5);

// ループを抜ける
break;
}
}
// フラグを降ろしておく
fire = false;
}

// 自機ショット -------------------------------------------------------
// パスの設定を開始
ctx.beginPath();

// すべての自機ショットを調査する
for(i = 0; i < CHARA_SHOT_MAX_COUNT; i++){
// 自機ショットが既に発射されているかチェック
if(charaShot[i].alive){
// 自機ショットを動かす
charaShot[i].move();

// 自機ショットを描くパスを設定
ctx.arc(
charaShot[i].position.x,
charaShot[i].position.y,
charaShot[i].size,
0, Math.PI * 2, false
);

// パスをいったん閉じる
ctx.closePath();
}
}

// 自機ショットの色を設定する
ctx.fillStyle = CHARA_SHOT_COLOR;

// 自機ショットを描く
ctx.fill();

// エネミーの出現管理 -------------------------------------------------
// 100 フレームに一度出現させる
if(counter % 100 === 0){
// すべてのエネミーを調査する
for(i = 0; i < ENEMY_MAX_COUNT; i++){
// エネミーの生存フラグをチェック
if(!enemy[i].alive){
// タイプを決定するパラメータを算出
j = (counter % 200) / 100;

// タイプに応じて初期位置を決める
var enemySize = 15;
p.x = -enemySize + (screenCanvas.width + enemySize * 2) * j
p.y = screenCanvas.height / 2;

// エネミーを新規にセット
enemy[i].set(p, enemySize, j);

// 1体出現させたのでループを抜ける
break;
}
}
}

// エネミー -----------------------------------------------------------
// パスの設定を開始
ctx.beginPath();

// すべてのエネミーを調査する
for(i = 0; i < ENEMY_MAX_COUNT; i++){
// エネミーの生存フラグをチェック
if(enemy[i].alive){
// エネミーを動かす
enemy[i].move();

// エネミーを描くパスを設定
ctx.arc(
enemy[i].position.x,
enemy[i].position.y,
enemy[i].size,
0, Math.PI * 2, false
);

// ショットを打つかどうかパラメータの値からチェック
if(enemy[i].param % 30 === 0){
// エネミーショットを調査する
for(j = 0; j < ENEMY_SHOT_MAX_COUNT; j++){
if(!enemyShot[j].alive){
// エネミーショットを新規にセットする
p = enemy[i].position.distance(chara.position);
p.normalize();
enemyShot[j].set(enemy[i].position, p, 5, 5);

// 1個出現させたのでループを抜ける
break;
}
}
}

// パスをいったん閉じる
ctx.closePath();
}
}

// エネミーの色を設定する
ctx.fillStyle = ENEMY_COLOR;

// エネミーを描く
ctx.fill();

// エネミーショット ---------------------------------------------------
// パスの設定を開始
ctx.beginPath();

// すべてのエネミーショットを調査する
for(i = 0; i < ENEMY_SHOT_MAX_COUNT; i++){
// エネミーショットが既に発射されているかチェック
if(enemyShot[i].alive){
// エネミーショットを動かす
enemyShot[i].move();

// エネミーショットを描くパスを設定
ctx.arc(
enemyShot[i].position.x,
enemyShot[i].position.y,
enemyShot[i].size,
0, Math.PI * 2, false
);

// パスをいったん閉じる
ctx.closePath();
}
}

// エネミーショットの色を設定する
ctx.fillStyle = ENEMY_SHOT_COLOR;

// エネミーショットを描く
ctx.fill();

// 衝突判定 -----------------------------------------------------------
// すべての自機ショットを調査する
for(i = 0; i < CHARA_SHOT_MAX_COUNT; i++){
// 自機ショットの生存フラグをチェック
if(charaShot[i].alive){
// 自機ショットとエネミーとの衝突判定
for(j = 0; j < ENEMY_MAX_COUNT; j++){
// エネミーの生存フラグをチェック
if(enemy[j].alive){
// エネミーと自機ショットとの距離を計測
p = enemy[j].position.distance(charaShot[i].position);
if(p.length() < enemy[j].size){
// 衝突していたら生存フラグを降ろす
enemy[j].alive = false;
charaShot[i].alive = false;

// 衝突があったのでループを抜ける
break;
}
}
}
}
}

// フラグにより再帰呼び出し
if(run){setTimeout(arguments.callee, fps);}
})();
};

// - event --------------------------------------------------------------------
function mouseMove(event){
// マウスカーソル座標の更新
mouse.x = event.clientX - screenCanvas.offsetLeft;
mouse.y = event.clientY - screenCanvas.offsetTop;
}

function mouseDown(event){
// フラグを立てる
fire = true;
}

function keyDown(event){
// キーコードを取得
var ck = event.keyCode;

// Escキーが押されていたらフラグを降ろす
if(ck === 27){run = false;}
}


ここまでの全体の構成を、しっかり把握しておきましょう。

上から順に書いていくと、次のように処理が流れていきます。


  • グローバルな変数の宣言

  • 定数としてあつかう変数の宣言

  • onload に割り当てるメインプログラム

  • イベント関連の処理

そして、メインプログラムのなかだけに限定すると、次のように処理が進んでいきます。


  • 変数の初期化やエレメントへの参照取得

  • 各クラスのインスタンスを取得

  • 無名関数によるループ処理開始

無名関数によるループ処理のなかでは、次のように処理が進んできます。


  • カウンタをインクリメントする

  • スクリーンをクリアしてリセットする

  • 自機を動かし必要に応じてショットを撃つ

  • 自機ショットを動かす

  • 敵キャラクターの出現をチェック

  • 敵キャラクターを動かし必要に応じてショットを撃たせる

  • 敵ショットを動かす

  • 衝突判定

いろいろな処理が一度に登場するので紛らわしいですが、よく見てみれば理解できると思います。

基本的には、キャラクターを動かし、ショットを動かし、一番最後に衝突判定する、という感じの流れになっています。


まとめ

今回はコードの修正自体はわずかなものでしたが、衝突判定を実装したことによって、自機のショットで敵キャラクターを破壊することができるようになりました。

次回は敵のショットと自機キャラクターの衝突判定を実装するほか、ゲーム中のシーン構成やスコアについても考えてみます。

オンラインサンプル.07で実際のサンプルの動作を確認できます。