ボールとブロックの衝突判定
前章まででパドルを操作してボールを動かすことができました。 次に、ボールとブロックの衝突判定を追加してみましょう。
ブロックの登録
まずはこれらのブロックの画像を登録しましょう。 上記からダウンロードした breakout_block_a.png
、 breakout_block_a.png
を assets/images
に配置してください。
assets
└── images
├── breakout_ball.png
├── breakout_block_a.png
├── breakout_block_b.png
└── breakout_paddle.png
画像を配置後、 akashic scan
コマンドを実行します。
akashic scan asset
INFO: Added the declaration for 'breakout_block_a.png' (assets/images/breakout_block_a.png)
INFO: Added the declaration for 'breakout_block_b.png' (assets/images/breakout_block_b.png)
INFO: Done!
それはそうとして、一つの画像を追加するたびに assetPaths
を書き換えるのは少々骨が折れるかと思います。 次のように *
をファイル名に指名するとことで、対象のシーンを表示する際に自動的にそのディレクトリに存在するアセットを読み込むことができます。
const scene = new g.Scene({
game,
assetPaths: [
"/assets/images/breakout_paddle.png",
"/assets/images/breakout_ball.png",
"/assets/images/*"
]
});
*
によるファイル名の指定は、いわゆる glob と呼ばれるパターンマッチ です。 /assets/images/*.png
で特定の拡張子のみを指定するといったこともできます。 ただし Akashic Engine では glob のすべての機能は提供しておらず、一部機能のみをサポートしています。 詳細は Akashic Engine のリファレンス を参照してください。
TIP
一般的に assetPaths
に指定する画像が増えるほど対象のシーンを読み込む時間が増加します。 したがって、シーンで利用しない画像まで glob で読み込むことは避けるべきです。
ブロックの表示
読み込んだ画像を実際に表示しましょう。
ブロックのサイズは 128 x 48 です。 ゲーム全体の画面サイズは 1280 x 720 なので、横に 10 個、縦に 15 個のブロックを画面全体に敷き詰めることができます。
左上から右方向に (0, 0)
, (1, 0)
, ... というように、この分割した座標にブロックを配置していく要領で進めてみます。
ブロックの作成ですが、今までは g.Sprite
を直接 scene
に追加していました。 今回はブロックという複数の g.Sprite
をより管理しやすくするため、コンテナ用のエンティティとして g.E
を作成し、そこにブロックを追加します。
TIP
g.E
というのは何も描画しないエンティティです。 このように複数のエンティティをまとめるために利用できます。
パドル生成部分の直前で g.E
を作成し、変数 blockContainer
に代入します。
scene.onLoad.addOnce(() => {
// 背景を作成
const background = new g.FilledRect({
...
});
scene.append(background); // 背景をシーンに追加
// ブロックのコンテナを作成
const blockContainer = new g.E({
scene,
});
scene.append(blockContainer);
const paddle = new g.Sprite({
...
});
次にブロックを描画するためのデータを用意します。 ここではブロックのデータを2次元のマップ情報として定義しましょう。
マップの数値との対応は次のようにします。
数値 | 内容 | 画像ファイル | 画像ファイル名 |
---|---|---|---|
0 | 空白 | - | - |
1 | 破壊できないブロック | breakout_block_a.png | |
2 | 破壊できるブロック | breakout_block_b.png |
以上をコード化してみます。 次のコードを intersect()
の定義前に追加してください。
// ブロックのマップデータ
const blocksMap = [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 2, 2, 2, 2, 2, 2, 0, 1],
[1, 0, 2, 2, 2, 2, 2, 2, 0, 1],
[1, 0, 2, 2, 2, 2, 2, 2, 0, 1],
[1, 0, 2, 2, 2, 2, 2, 2, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
];
// ブロックの作成
for (let col = 0; col < blocksMap.length; col++) {
for (let row = 0; row < blocksMap[col].length; row++) {
const mapNumber = blocksMap[col][row];
if (mapNumber === 0) continue; // 0 は空白として扱う
let blockAsset;
if (mapNumber === 1) {
blockAsset = scene.asset.getImage("/assets/images/breakout_block_a.png");
} else if (mapNumber === 2) {
blockAsset = scene.asset.getImage("/assets/images/breakout_block_b.png");
} else {
throw new Error("対象の mapNumber は定義されていません");
}
const block = new g.Sprite({
scene,
x: row * blockAsset.width + blockAsset.width / 2,
y: col * blockAsset.height + blockAsset.height / 2,
src: blockAsset,
width: blockAsset.width,
height: blockAsset.height,
anchorX: 0.5,
anchorY: 0.5,
});
// ブロックを blockContainer の子として追加
blockContainer.append(block);
}
}
function intersect() {
...
}
blocksMap
に上で定義したマップ情報を二次元配列として保持しておきます。
1つ目の for (let col = 0; col < blocksMap.length; col++)
はマップの列情報を順番に参照しています。 2つ目の for (let row = 0; row < blocksMap[col].length; row++)
でマップの各列の行を順番に参照しています。 2つ目のループ内ではマップの番号を blocksMap[col][row]
という形式で参照できるので、それを変数 mapNumber
に代入しています。
TIP
for
は、処理を指定された条件で繰り返す構文です。
for (初期化; 条件; 更新) {
// ループ内で実行される処理
}
最初に初期化が行われ、条件が true
である間ループが続行されます。 各反復処理 (イテレーション) の終わりには更新が行われ、これが繰り返されます。
その他詳細な仕様は MDN のドキュメント などを参照してください。
そして、 mapNumber
に応じてアセットを切り替えつつブロックを生成しています。
ここでゲームを開始してみます。 ブロックが表示されていれば成功です。
TIP
"col" と "row" は、表や行列などのデータ構造に関連する用語です。
- 列 (col): 表や行列の垂直方向のセルを指します。列は通常、上から下に向かって数えられます。
- 行 (row): 表や行列の水平方向のセルを指します。行は通常、左から右に向かって数えられます。
これらの用語は表形式のデータや行列の操作に関連しており、プログラミングやデータ処理などの文脈で頻繁に使用されています。
ブロックとの衝突判定
このままではボールとブロックが通過してしまうので、衝突判定を加えましょう。
さて、ブロックには 破壊できないブロック (mapNumber === 1
) と 破壊できるブロック (mapNumber === 2
) の2種類があります。 これらの情報をどこに持たせるとコードを簡潔に保てるでしょうか。 おそらく一番単純なのは、 ブロックのエンティティ自体 にその情報を持たせることです。
g.Sprite
などエンティティには tag
という任意のデータを保持できる領域が提供されています。 この中に自身の mapNumber
を持たせておきましょう。 ブロックの生成部分を以下のように修正します。
const block = new g.Sprite({
scene,
x: row * blockAsset.width + blockAsset.width / 2,
y: col * blockAsset.height + blockAsset.height / 2,
src: blockAsset,
width: blockAsset.width,
height: blockAsset.height,
anchorX: 0.5,
anchorY: 0.5,
tag: {
//[!code ++]
mapNumber //[!code ++]
} //[!code ++]
});
ブロック自体に mapNumber
を持たせることで、後述するボールとの衝突判定のコードが非常に書きやすくなります。
次にボールがブロックに衝突したときの反射方向を考えます。 今回は、ボールの 上端
下端
右端
左端
の4つの座標についてブロックの衝突判定を定義してみます。 ボールがブロックと衝突したとき、もしボールの上端または下端がブロックと接触していたら vy
の値を、 ボールの右端または左端がブロックと接触していたら vx
の値を反転します。 これによりボールが反射する挙動を簡易的に再現することができます。
ブロックの端の各座標は以下のようになります。
場所 | x 座標 | y 座標 |
---|---|---|
上 | ball.x | ball.y - ball.height / 2 |
下 | ball.x | ball.y + ball.height / 2 |
右 | ball.x + ball.width / 2 | ball.y |
左 | ball.x - ball.width / 2 | ball.y |
これらの座標がブロックと接しているかを判定するため、点と矩形の判定を定義する関数 pointInRect()
を作成します。 以下を intersect()
が定義されている箇所の直後に追加しましょう。
function intersect(e1, e2) {
...
}
// ある座標が矩形と衝突しているかを判定する関数//[!code ++]
function pointInRect(x, y, e) {//[!code ++]
return (//[!code ++]
x >= e.x - e.width / 2 &&//[!code ++]
x <= e.x + e.width / 2 &&//[!code ++]
y >= e.y - e.height / 2 &&//[!code ++]
y <= e.y + e.height / 2//[!code ++]
);//[!code ++]
}//[!code ++]
pointInRect()
に引数に座標と矩形のエンティティを渡すと、それらが衝突しているかの真理値を返してくれます。 上記のコードはアンカーポイントが (0.5, 0.5)
のエンティティでないと動作しない点に注意してください。
続いて scene.onUpdate
にボールとブロックとの衝突判定を追加します。
// ボールとパドルが衝突したとき
if (intersect(ball, paddle)) {
vy = -vy;
}
for (const block of blockContainer.children) {
//[!code ++]
let isCollided = false; // 衝突しているかの情報を保持する変数//[!code ++]
//[!code ++]
// 上下//[!code ++]
if (
//[!code ++]
pointInRect(ball.x, ball.y - ball.height / 2, block) || //[!code ++]
pointInRect(ball.x, ball.y + ball.height / 2, block) //[!code ++]
) {
//[!code ++]
vy = -vy; //[!code ++]
isCollided = true; //[!code ++]
} //[!code ++]
//[!code ++]
// 左右//[!code ++]
else if (
//[!code ++]
pointInRect(ball.x + ball.width / 2, ball.y, block) || //[!code ++]
pointInRect(ball.x - ball.width / 2, ball.y, block) //[!code ++]
) {
//[!code ++]
vx = -vx; //[!code ++]
isCollided = true; //[!code ++]
} //[!code ++]
//[!code ++]
if (isCollided) {
//[!code ++]
// 破壊可能のボールと衝突していたら//[!code ++]
if (block.tag.mapNumber !== 1) {
//[!code ++]
block.destroy(); // ブロックを破壊//[!code ++]
break; // 一度のフレームで一つのブロックのみを削除//[!code ++]
} //[!code ++]
} //[!code ++]
} //[!code ++]
blockContainer.children
には blockContainer
に追加した子要素が格納されています。 for (const block of blockContainer.children)
はその子要素を一つずつ block
という変数に代入してループ処理をする構文です。 ここでは、すべてのブロックエンティティについて、ボールと衝突しているかどうか順番に判定しています。
ボールの上下がブロックに衝突していたら vy
の値を反転します。 一方でボールの左右がブロックに衝突していたら vx
の値を反転します。
またブロックとボールが接触したときに isCollided
を true
にしています。 isCollided
が true
の際にブロックの mapNumber
を比較し、破壊可能であれば対象のブロックを破壊します。 エンティティの削除には destroy()
を実行します。
TIP
この判定は非常に簡易的なロジックとなっています。 例えばボールの速度を大きくしすぎると、同時に複数の箇所がブロックと衝突したり、またはブロックを貫通したりと意図しない動作を引き起こし、ゲームとしての体験が大きく損なわれる可能性があります。 より厳密な衝突判定を制御したい場合は akashic-box2d や marble2d などの2D物理エンジンライブラリの導入も検討してください。
ゲームを実行してみましょう。 緑色のブロックがボールの衝突によって破壊されれば成功です。