Skip to content

ボールとブロックの衝突判定

前章まででパドルを操作してボールを動かすことができました。 次に、ボールとブロックの衝突判定を追加してみましょう。

ブロックの登録

まずはこれらのブロックの画像を登録しましょう。 上記からダウンロードした breakout_block_a.pngbreakout_block_a.pngassets/images に配置してください。

assets
└── images
    ├── breakout_ball.png
    ├── breakout_block_a.png
    ├── breakout_block_b.png
    └── breakout_paddle.png

画像を配置後、 akashic scan コマンドを実行します。

sh
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 を書き換えるのは少々骨が折れるかと思います。 次のように * をファイル名に指名するとことで、対象のシーンを表示する際に自動的にそのディレクトリに存在するアセットを読み込むことができます。

javascript
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 に代入します。

javascript
    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() の定義前に追加してください。

javascript
        // ブロックのマップデータ
        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 は、処理を指定された条件で繰り返す構文です。

javascript
for (初期化; 条件; 更新) {
  // ループ内で実行される処理
}

最初に初期化が行われ、条件が true である間ループが続行されます。 各反復処理 (イテレーション) の終わりには更新が行われ、これが繰り返されます。

その他詳細な仕様は MDN のドキュメント などを参照してください。

そして、 mapNumber に応じてアセットを切り替えつつブロックを生成しています。

ここでゲームを開始してみます。 ブロックが表示されていれば成功です。

TIP

"col" と "row" は、表や行列などのデータ構造に関連する用語です。

  • 列 (col): 表や行列の垂直方向のセルを指します。列は通常、上から下に向かって数えられます。
  • 行 (row): 表や行列の水平方向のセルを指します。行は通常、左から右に向かって数えられます。

これらの用語は表形式のデータや行列の操作に関連しており、プログラミングやデータ処理などの文脈で頻繁に使用されています。

ブロックとの衝突判定

このままではボールとブロックが通過してしまうので、衝突判定を加えましょう。

さて、ブロックには 破壊できないブロック (mapNumber === 1) と 破壊できるブロック (mapNumber === 2) の2種類があります。 これらの情報をどこに持たせるとコードを簡潔に保てるでしょうか。 おそらく一番単純なのは、 ブロックのエンティティ自体 にその情報を持たせることです。

g.Sprite などエンティティには tag という任意のデータを保持できる領域が提供されています。 この中に自身の mapNumber を持たせておきましょう。 ブロックの生成部分を以下のように修正します。

javascript
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.xball.y - ball.height / 2
ball.xball.y + ball.height / 2
ball.x + ball.width / 2ball.y
ball.x - ball.width / 2ball.y

これらの座標がブロックと接しているかを判定するため、点と矩形の判定を定義する関数 pointInRect() を作成します。 以下を intersect() が定義されている箇所の直後に追加しましょう。

javascript
        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 にボールとブロックとの衝突判定を追加します。

javascript
// ボールとパドルが衝突したとき
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 の値を反転します。

またブロックとボールが接触したときに isCollidedtrue にしています。 isCollidedtrue の際にブロックの mapNumber を比較し、破壊可能であれば対象のブロックを破壊します。 エンティティの削除には destroy() を実行します。

TIP

この判定は非常に簡易的なロジックとなっています。 例えばボールの速度を大きくしすぎると、同時に複数の箇所がブロックと衝突したり、またはブロックを貫通したりと意図しない動作を引き起こし、ゲームとしての体験が大きく損なわれる可能性があります。 より厳密な衝突判定を制御したい場合は akashic-box2dmarble2d などの2D物理エンジンライブラリの導入も検討してください。

ゲームを実行してみましょう。 緑色のブロックがボールの衝突によって破壊されれば成功です。

実行例とソースコード

Playground で実行