弾幕、それは漢の浪漫。この実装を追い求める事がプログラミング技術の向上であると言っても過言で…かもしれません。さて、C# でリストを管理する場合、システムには便利なその名もズバリの List という命令があります。座標を Vector2 で管理するとして、List をとります。使えば…

    List<Vector2> bullets = new List<Vector2>();
たったこれだけです。弾を撃ったら

    bullets.Add(new Vector2(pos));
等とすれば良いですし、弾が当たったり画面外に消えたら

    bullets.RemoveAt(idxRemove);
とするだけです。めちゃくちゃ簡単ですが、実はコレ、ゲーム制作には大変問題があります。かなりの高頻度で追加と削除を List に対して繰り返しますので、ガーベージコレクションは起きまくると思われますし、なにより List の Remove は悲しいほどに遅いのです。あまりに遅いので以前こんな記事を書いたぐらいです。総じて、遅くてゲームに使うのは厳しいと言わざるを得ません。


  • 配列を事前に確保する
では、最初にかなり大きい数で、弾の管理を行う配列を作ってしまえば、少なくとも確保と破棄というガーベージコレクションが起きる要素はなくなります。とりあえずままよと 3000個ぐらい作ってしまいましょうか。こういう時は、後から簡単にサイズ変更出来るように、要素数も別途定数定義しておくと良いです。

    const int maxBullet = 3000;
    Vector2[] bullets = new Vector2[maxBullet];
おそらく使用しないであろう new Vector2(int.MinValue, int.MinValue) なら使っていないとして、使ったときはここに Vector2 オブジェクトを入れれば良いですよね。…あれ?どうやって動かすんだっけ?こちらで解説したとおり、弾には最低限でも「現在位置」と「移動ベクトル」が必要です。そのため、これを要素とした Bullet 構造体を定義して配列化してしまいます。

    struct Bullet
    {
        public Vector2 vecPos;  // 現在の位置
        public Vector2 vecAdd;  // 移動ベクトル
    }
    Bullet[] bullets = new Bullet[maxBullet];

    readonly Vector2 vecEmpty = new Vector2(int.MinValue, int.MinValue);
    for (int i = 0; i < bullets.Length; ++i)
    {
        bullets[i] = vecEmpty;
    }
※実際には、リストで使用中か空きかを判別していますので、配列の確保だけで問題ありません。

  • 弾を発射する
弾を撃つ場合は、発射位置と発射方向と速度が必要です。発射方向と速度は、最終的にはひっくるめて移動ベクトルにしてしまいますが、この移動ベクトルはどう算出すれば良いでしょうか。これも以前説明した通り、三角関数を使います。sinθで X成分、cosθで Y成分、このそれぞれの成分に速度を掛ければ出来上がりです。

    const double DegToRad = 2 * Math.PI / 360;
    Bullet blt = new Bullet()
    {
        vecPos = pos,
        vecAdd = new Vector2(
            (float)(Math.Sin(angle * DegToRad) * speed),
            (float)(Math.Cos(angle * DegToRad) * speed))
    }
さて、この出来た弾のオブジェクトを、空いている弾配列に入れるとしましょう。配列を検索して vecEmpty の場所が空いているので、そこに格納します。

    for (int i = 0; i < bullets.Length; ++i)
    {
        if (bullets[i] != vecEmpty) continue;
        bullets[i] = blt;
        break;
    }
…ちょっと待ってください。弾を一発撃つ毎に、配列の使用状況を検索する?最初の100や200ならまだしも、数千とか毎回検索するんです?これまた重そうですよね…。


  • リスト構造で検索不要に
そこで解説するのが今回の主目的であるリスト構造です。これは簡単に言うと、使っていない配列と使用中の配列を、それぞれ数珠つなぎでリスト化して使おうという事です。片方向リストと双方向リストがあります。
片方向リスト
片方向リストは「次の位置」だけを保持しています。現在の処理が終わったら、次の位置に移動していきます。次の位置がデータが無い値になれば終了となります。
双方向リスト
双方向リストは「次の位置」「以前の位置」を保持しています。何かあれば一つ前に戻って処理をする事が出来ます。ただ、片方向と比べて若干ですがデータサイズが大きくなります。

弾幕はかなりの数を処理しますので、小さなサイズアップも大きな差になる事があります。そのため、ここでは片方向リストを採用する事にします。配列の位置はインデックスで管理していますので、先に提示した弾構造体に次の位置を追加しましょう。

    struct Bullet
    {
        public int idxNext;     // 次の弾インデックス
        public Vector2 vecPos;  // 現在の位置
        public Vector2 vecAdd;  // 移動ベクトル
    }
さて、最初は全て使っていないので、全ての配列を使っていないリストに繋げてしまいます。使用中リストは最初は何もないので、データなしとして初期化します。

    int idxEmpty = 0;           // 未使用リスト
    int idxInvalid = -1;        // 使用中リスト

    for (int i = 0; i < maxBullet - 1; i++)
    {
        bullets[i].idxNext = i + 1;
    }
    bullets[maxBullet - 1].idxNext = -1;
初期化で、一番最後にはデータが無い事を示す -1 を入れるのがキモです。このような終端を示す数値を入れる事を「番兵法」と呼びます。これで未使用配列インデックスは idxEmpty に書いてありますし、使用中配列のインデックスは idxInvalid を見れば分かるようになりました。
究極タイガーヘリ - PS4

エムツー
2021-10-28


  • 改めて弾を発射する
空リストから空きワークを取得して弾を発射します。どこの配列が空かは idxEmpty をみれば明らかです。そのため、このインデックスが示す配列に新しい弾情報を書き込んでしまいます。そして、その配列に入っていた次の空インデックスを新しい最初の空インデックスとして、idxEmpty に書き込みます。なお、配列を使い切っている時は、いきなり -1 が入っていますので、弾は発射できません。

    if (idxEmpty < 0) return;           // 空きがない
    int idx = idxEmpty;                 // 発射するIDX
    idxEmpty = bullets[idx].idxNext;    // 次の空ワークを最初のIDXの変更
そして、この弾は使用中リストに接続するため、次のリストに現在使用中のインデックスを書き込んで、自分自身をリストの先頭に入れてしまいます。

    bullets[idx].idxNext = idxInvalid;  // 現在の使用中IDXを次に入れる
    idxInvalid = idx;                   // 新しい発射IDXを使用中の先頭に
あとは発射位置と移動ベクトルを設定すれば、発射準備完了となります。


  • 弾を削除する
弾の削除は、使用中のリストを前方から順番に処理していくしかありません。これは、一つ前のインデックスが順次走査しないと分からないためです。幸いな事に、弾の処理は順番に処理する事になります。つまりは弾の移動処理で、以前のインデックスを覚えておく必要があります。このリストのつなぎ替えは少しややこしいです。

    int idxPrev = -1;           // 一つ前のIDX
    int idxNow = idxInvalid;    // IDXを存在開始位置に
    while (idxNow >= 0)
    {
        // 移動する
        bullets[idxNow].vecPos += bullets[idxNow].vecAdd;

        // 画面内チェック
        if (rcCanvas.Contains(new Point(
            (int)(bullets[idxNow].vecPos.X + 0.5f),
            (int)(bullets[idxNow].vecPos.Y + 0.5f)))
        ){
            idxPrev = idxNow;                   // 一つ前は現在のIDX
            idxNow = bullets[idxNow].idxNext;   // 次のIDXに遷移
            continue;
        }

        // 画面範囲外なので消去する
        int idxNext = bullets[idxNow].idxNext;  // 次のIDXを保存
        if (idxPrev < 0)                        // 以前かない?
        {
            // 先頭IDXを書き換える
            idxInvalid = bullets[idxNow].idxNext;
        }
        else
        {
            // 一つ前の次のIDXを現在の次のIDXに書き換える
            bullets[idxPrev].idxNext = bullets[idxNow].idxNext;
        }
        bullets[idxNow].idxNext = idxEmpty;     // 現在のIDXの次を空のIDXにして
        idxEmpty = idxNow;                      // 空になったIDXを先頭とする
        idxNow = idxNext;                       // 次のIDXに遷移
    }
削除の際、以前のインデックスが -1 なら先頭のデータなので、idxInvalid を次のインデックスに更新します。-1 以外であれば、以前のデータの次のインデックスに、現在のデータに保存されている次のインデックスを設定します。これで、削除しようとしているデータは飛ばされるようになりますので、現在のデータは空になりました。空のデータですので、idxEmpty に接続するため、次のインデックスに現在の idxEmpty の内容を入れて、自分自身は空の先頭位置にするため idxEmpty に格納します。


  • 確実に相手を狙う
X,Yの差分をアークタンジェントに渡すと、方向が取れます。そのため、狙う位置のXYから、発射位置のXYを引く事で、発射位置を原点としたベクトルが得られますので、それを移動ベクトルとすれば、そちらの方向に飛んでいく事になります。

    var angle = Math.Atan2(vecTo.X - vecFrom.X, vecTo.Y - vecFrom.Y);
以前に指摘してますが、Atan2 の引数が場合により逆転している事があります。各自の開発環境上で Atan2 の仕様を確認するようにしてください。
弾幕
ということで、上記で解説した技術で弾幕テストプログラムを作成してみました。TestVector2.zip となります。参考になれば幸いです。