SevenAtOneDebug

プログラミングとネットワーク、セキュリティについて書くつもりです

2Dシューティングゲームの弾幕要素について

はじめに

詳細な経緯は失念してしまったのですが,友人(@Ririn_main)と弾幕2Dゲームを作ることになりました.しかし,開発者両名は弾幕ゲームにはあまり親しみがなく,どのように弾幕を作っていくか,という段階で詰まってしまいました.そこで,基本的な弾幕要素を調べて実装し,それをもとに応用的な(複合的な)弾幕を作ることを今回の目標とします.

弾幕ゲームの概要

 弾幕ゲームは,2Dシューティングゲームのジャンルの一つです.自機をうまく操作して,大量の敵弾を回避する,という部分に重きがあるのが特徴です.この特徴に合わせて,敵弾の速度は低めで,自機の当たり判定は小さめになることが多いようです.

代表的な弾幕ゲーム

怒首領蜂

1997年にCAVEによって開発されたシューティングゲームです.おそらくこれが弾幕ゲームの元祖的な作品で,画面を覆い尽くす弾幕と,小さな当たり判定が特徴的です.これに続く作品も作り続けられており,プレイングにおいては2024年3月,怒首領蜂最大往生での,裏ボスのノーコンティニュークリア達成が話題となりました.

Youtubeに上がっていた怒首領蜂のゲームプレイ映像

www.youtube.com

↓ノーコンティニュークリアのニュース記事

www.gamespark.jp

ノーコンティニュークリアの達成動画

www.youtube.com

東方Project

ニコニコに入り浸っている身なので,馴染み深いのはやはり東方Projectです.シューティングゲームではあるのですが,キャラクターやBGMも魅力的で,その方面でのファンも多いです.二次創作も盛んです.

 弾幕ゲームとしては,スペルカードという,名前のついた特徴的な弾幕が参考になります.

東方Project作品一覧

www16.big.or.jp

東方Project作品 プレイ動画

www.youtube.com

基本的な弾幕要素

基本的な弾幕要素を考えます.弾幕要素を,弾丸が依存する位置に基づいて大まかに分類し,その上で色々な弾丸の種類を考えてみます.

 

  1. 敵機の位置のみに依存している弾幕
  2. 自機の位置と敵機の位置に依存している弾幕
  3. 敵機の位置にも自機の位置にも依存していない弾幕

 

  1. 敵機の位置のみに依存している弾幕

敵機の位置を基準として発射される弾幕要素が多く存在します.具体的には以下のようなものが考えられます.

a. ノーマルショット

敵機の位置から,ある方向に対して弾丸を発射します.最も基本的な要素です.

ノーマルショット図解

b. 全方位ショット

敵機の位置から,全方向に向けて弾丸を発射します.発射する方向数を増やすと,密度の高い,環状を弾幕になります.

全方位ショット図解

c. うずまきショット

敵機の位置から時計回り,あるいは反時計回りに遅延させながら全方位に弾丸を発射します.発射する方向数を増やすと密度の高い弾幕になります.模様もはっきりとします.渦巻き模様にするためには,次弾を撃つのを待つ時間が必要となるため,カウンタを使って実装することになるでしょう.

うずまきショット図解

d. ばらまきショット

敵機位置から弾丸をランダムな方向に発射するような弾幕要素です.乱数を用いて,発射する弾幕の進行方向をばらつかせます.拡散する幅をある程度固定したようなばらまきも考えられます.

ばらまきショット図解
  1. 自機の位置と敵機の位置に依存している弾幕

敵機から発射され,時期に目掛けて弾が飛んでくる弾幕要素です.具体的には次のような弾幕が考えられます.

a. 自機狙いショット

この分類における最も基本的な要素です.敵機と自機の位置をもとに弾丸の出現位置と進行方向を決定します.

自機狙いショット図解

b. n-way

自機の方向に対して,複数方向の弾丸を飛ばすような弾幕要素です.自機を狙う弾丸を含むn-wayと,含まないn-wayがあります.前者の場合,自機が回避行動を取らなければ被弾してしまいます.後者は単純に自機の行動を制限するのみです.図で示しているのは,自機を狙う弾丸を含んだn–wayということになります.

n-way図解

c. 追尾ショット

発射された弾丸が自機を追いかける弾幕要素です.定期的に弾丸の進行方向を自機の方向に向けて修正することで実現できます.修正の頻度を高くすることで,追尾の精度を高められます.また,修正の回数を多くすることで,自機をより執拗に追いかける弾丸を作れます.

追尾ショット図解
3. 自機の位置にも敵機の位置にも依存しない弾幕

発射位置や,発射方向が常に一定あるいはランダムな弾幕要素をも考えられます.具体的には次のようなものが考えられるでしょう.

 

a. 固定弾

毎回固定の軌道を描くような弾幕です.弾幕の軌道は自由に作成します.

固定弾図解

b. ランダム弾

敵機の位置にも自機の位置にも依存せず,ランダムな位置に弾丸を配置し,ランダムな進行方向が設定される弾幕要素です.

ランダム弾図解

雛形の実装

 ここでは,様々な弾幕要素のデモを作るための雛形を作っていきます.

 

作成環境

 実装と実行の環境としては,Processingを用います.Processingは図形などを動かしてアートを作るVisual Artsのための環境です.図形を動かすためのプログラムを気軽に書けて,実行環境と開発環境が一緒になっているため,環境構築も簡単です.

以下のリンクからダウンロードできます.

processing.org

まずは,ウィンドウを作成するとこから始めます.

Processingでは,setup()という組込み関数があります.この関数は,初期設定を行うための関数で,Processingのプログラムを実行した際に,最初に一度だけ実行されます.ここでは,ウィドウのサイズの設定と,フレームレートの設定をしてみます.

また,draw()という画面を書き換えるための関数も使います.この関数の中身は,毎フレーム実行されます.今回は,フレームレートを60に設定していますので,draw()の中身の処理は秒間60回実行されることになります.とりあえず,ウィンドウの中身を黒くしてみましょう.

以上を踏まえて以下のようなプログラムにします.

void setup(){
    size(1200,900);
    frameRate(60);
}

void draw(){
    background(0,0,0);
}

黒いウィンドウを作成することができました.

黒いウィンドウ
自機の実装

今度は,自機を表示して,キーボードの矢印キーを使って動かせるようにしてみます.

まずは,自機をクラスで表現します.

とりあえず画面に自機を描いて動かせるようにするための,必要最小限の要素は次の通りです.

・位置

・サイズ(横幅と縦幅)

・移動用のメソッド

・描画用のメソッド

位置は,(x,y)という2要素のベクトルで表すことにします.ProcessingにはPVectorというベクトルを扱うためのクラスがありますので,PVectorのオブジェクトで位置情報を持っておきます.移動をするときには,移動量を表すベクトルを作成し,これを位置情報のベクトルに足すことで,座標をずらすことにします.

ここでは,とりあえずプレイヤーのサイズを,横幅20,縦幅25とします.

描画は,今回は自機を長方形で表すことします.Processingの関数であるrect()を用いて,位置情報とサイズをもとに長方形を描画します.

移動については,キーボードの矢印キーが押されている間,自機がその方向に移動できるようにします.Processingでは,キーの操作の際に呼び出される組み込み関数が用意されています.キーが押された際にはKeyPressed(),キーが離された際にはKeyRelease()がそれぞれ呼び出されます.上下左右の移動をするかどうか,それぞれ方向ごとにフラグをもっておき,フラグをチェックして,マイフレームの移動量を計算するようにします.

この際の注意点として,移動ベクトルを正規化する必要がある,という点があります.キーを押した時の1フレームの移動量を10とする場合は,当然1フレームで10だけ移動することを期待します.しかし,ここで,移動の実装を,押された方向に対して,座標を10足し引きするという方針にしていると斜め方向で移動する際に,通常よりも多く移動してしまうという問題が起きます.例えば,上矢印と右矢印を同時に押すケースでは,y座標を-10して,x座標を+10することになりますが,このように直接座標を足し引きすると,斜め方向では14移動することになります.これを避けるため,移動量を表すベクトルを別途作成し,これを座標用のベクトルに足しこむことで移動をするようにします.移動量を表すベクトルは,すべての方向をチェックした後に正規化し,移動したい量でスカラ倍することで作成します.

以上をふまえて作成したプログラムが以下です.Playerクラスのインスタンス化は冒頭です.

Player player = new Player(250, 400);

void setup(){
    size(1200,900);
    frameRate(60);
}

void draw(){
    background(0,0,0);


    /*------------- calc phase --------------------*/
    // move to next positions
    player.move_player();

    /*------------- draw phase --------------------*/ 

    player.draw_player();


}

void keyPressed(){
    if(keyCode == UP)
        player.up_move_flag = true;

    if(keyCode == DOWN)
        player.down_move_flag = true;

    if(keyCode == LEFT)
        player.left_move_flag = true;

    if(keyCode == RIGHT)
        player.right_move_flag = true;
}

void keyReleased(){
    if(keyCode == UP)
        player.up_move_flag = false;

    if(keyCode == DOWN)
        player.down_move_flag = false;

    if(keyCode == LEFT)
        player.left_move_flag = false; 

    if(keyCode == RIGHT)
        player.right_move_flag = false;

}


class Player{

    PVector position;

    int player_width = 20;
    int player_height = 25;

    color player_color = color(0,255,0);

    boolean left_move_flag;
    boolean right_move_flag;  
    boolean up_move_flag;
    boolean down_move_flag;


    Player(float x, float y){
        position = new PVector(x, y);
    }

    void move_player(){
        PVector move_vector = new PVector(0,0);
        if(up_move_flag && position.y > 0 )
            move_vector.y = -1;
          
        if(down_move_flag && position.y < 900 - player_height)
            move_vector.y = 1;

        if(left_move_flag && position.x > 0)
            move_vector.x = -1;

        if(right_move_flag && position.x < 1200 - player_width)   
            move_vector.x = 1;
    
        move_vector.normalize();
        move_vector.mult(10);

        position.add(move_vector);
    }


    void draw_player(){
        fill(player_color);
        rect(position.x, position.y, player_width, player_height); 
    }

}
敵機の実装

次に,敵機を実装していきます.敵機の挙動についてですが,今回は,左右を往復するだけの単純なものとします.実装内容は自機の場合とほとんど同じです.

・位置

・サイズ(横幅と縦幅)

・移動用のメソッド

・描画用のメソッド

サイズは,横30,縦30にしました.適当に決めてます.

移動用メソッドについては,進む方向を右か左か決めるフラグを一つ以て置き,画面端にたどりついたら方向を切り返すようにしています.移動量を表すベクトルを使って実装することもできますが,今回は挙動が単純なので,座標をインクリメント(またはデクリメント)しているだけです.

作成した敵機用のクラスは以下.

class Enemy{
  PVector position;

  int enemy_width = 30;
  int enemy_height = 30;

  boolean direction_right;
  
  color enemy_color = color(255,255,0);
  int hit_count = 0;
  
  Enemy(float x, float y){
    position = new PVector(x, y);
    direction_right = true;
  }
  
  void move(){
    if(direction_right){
      
      if(position.x < 1200)
        position.x++;
      else
        direction_right = false;  
      
    }else{
      
      if(position.x > 0)
        position.x--;
      else
        direction_right = true;
    }
  }
}

自機と同様に,インスタンス化は冒頭で行い,draw()の中では,座標更新用のメソッドと再描画用のメソッドを呼んでおきます.

Player player = new Player(250, 400);
Enemy enemy = new Enemy(250,100);

void setup(){
    size(1200,900);
    frameRate(60);
}

void draw(){
    background(0,0,0);


    /*------------- calc phase --------------------*/
    // move to next positions
    player.move_player();
    enemy.move();

    /*------------- draw phase --------------------*/ 

    player.draw_player();
    enemy.draw_enemy();

}
弾丸の実装

自機や敵機によって発射されるクラスを書いていきます.ただし,弾丸の最も基本的な部分は自機や敵機と同じような実装で,

・弾丸の座標

・移動用のメソッド

を備えておけばとりあえずは良いでしょう.当たり判定の追加などは,後の単発ショットの部分で実装したいと思います.

移動用ベクトルは,弾丸オブジェクト生成時に,コンストラクタ内でセットし,移動用メソッドが呼ばれるたびに,これを座標を表すベクトルに足しこんでいきます.

以上を踏まえて作成した弾丸用クラスが以下.

class Bullet{
  PVector position;
  PVector move_vector;
  
  Bullet(float px, float py, float speed, float angle){
    position = new PVector(px,py);
    move_vector = new PVector(0, speed);
    move_vector.rotate(angle);
  }
  
  void move_bullet(){
    position.add(move_vector);
  }
  
  void draw_bullet(){
    fill(255,255,255);
    ellipse(position.x, position.y, 5, 5);
  }

}  
弾丸の管理の実装,単発ショットの実装

弾丸ができたら,今度は自機や敵機から,弾丸を発射できるようにしましょう.発射された弾丸はリストで管理し,必要がなくなった弾丸については順次削除していくようにします.進み続けた結果,画面外に出ていった弾丸は不要ですので,削除します.

弾丸管理用のリストを作成し,新たに弾丸が作成されたら,このリストに追加されるようにします.弾丸の位置を更新するときは,リストに入っている弾丸オブジェクトとの移動用関数を順次コールすることによって,全弾丸の位置が更新できます.Processingでは,ArrayListを用いることで,任意の型(クラス)の配列を作ることができます.冒頭で,敵弾管理用のbulletsというリストを作成しましょう.

ArrayList<Bullet> bullets = new ArrayList<Bullet>();

move_bullets()という関数を作り,この中で,座標の更新処理をすることにします.move_bulletsの中身は以下の通りです.

void move_bullets(){
    for(int i=0; i<bullets.size(); i++){
      bullets.get(i).move_bullet();
    }
}

また,弾丸の描画についても,リストの弾丸を順次描画することで,全弾丸を描画できるようにします.draw_bullets()という関数を作り,この中で以下のように,弾丸の描画メソッドをコールしていきます.

void draw_bullets(){
    for(int i=0; i<bullets.size(); i++){
        bullets.get(i).draw_bullet();
    }
}

そして,これらを組み込み関数のdraw()の中で呼び出すようにします.

void draw(){
    background(0,0,0);


    /*------------- calc phase --------------------*/
    // move to next positions
    player.move_player();
    enemy.move();
    move_bullets();


    /*------------- draw phase --------------------*/ 

    player.draw_player();
    enemy.draw_enemy();
    draw_bullets();
}

敵機からの単発ショットを実装します.まずは,敵機のクラスに単発ショット用のメソッドを追加します.追加するのは以下のnormarl_shot()というメソッドです.

  void normal_shot(){
    Bullet bullet = new Bullet(position.x, position.y, 5, 0);
    bullets.add(bullet);  
  }

これによって,敵機の位置から真下に向かって飛んでいく弾丸オブジェクトが,リストに追加されます. この弾丸発射のメソッドが定期的に呼び出されるようにしましょう.draw()の中で,以下のように書きます.

void draw(){
    background(0,0,0);


    /*------------- calc phase --------------------*/
    // move to next positions
    player.move_player();
    enemy.move();
    move_bullets();


    /*------------- draw phase --------------------*/ 

    if(shot_timer > SHOT_COUNT){
        enemy.normal_shot();    
        shot_timer = 0;
    }else{
        shot_timer ++;  
    }

    player.draw_player();
    enemy.draw_enemy();

}

SHOT_COUNTで弾丸の発射を待つフレーム数を決めておき,shot_timerがそのフレーム数に達したら,弾丸を発射します.弾丸発射後はshot_timerが0に初期化され,また,次の発射タイミングまで,弾丸の発射を待つことになります.

shot_timerとSHOT_COUNTについては,冒頭で以下のように書いて初期化しました.

int shot_timer = 0;
int SHOT_COUNT = 30;

弾丸を破棄する

以上で,敵機から弾丸が発射されるようになりました.しかし,発射された弾丸が画面外に出た後も残り続けてしまうという問題があります.不必要にメモリを消費してしまうことにつながるため,この問題に対処したいと思います.弾丸が画面外にでているかどうかを判定し,出ているようであれば,弾丸管理用のリストから,その弾丸を削除します.

まずは,弾丸本体のクラスに,その弾丸が削除対象であるかどうかを判定するメソッドをつけます.

    boolean destroy_bullet_decidion(){
        if(position.x < 0 || 1200 < position.x)
            return true;
        if(position.y < 0 || 900 < position.y)
            return true;
          
        return false;
    }

このメソッドは,弾丸が画面外に出ていたら,trueを,そうでなかったらfalseを返します.この結果を見て、弾丸を削除するメソッドとしてremove_unused_bullets()を作成します.中身は以下の通りです.

void remove_unused_bullets(){
    for(int i=bullets.size()-1; i>=0; i--){
        if(bullets.get(i).destroy_bullet_decidion())
            bullets.remove(i);      
    }
  
}

move_bullets()やdraw_bullets()と同じ要領で,draw()の中で呼び出すようにします.

ここまで出来たら,自機から発射される弾丸も同様に,実装しましょう.自機の弾丸管理用のリストである,player_bulletsを冒頭で作成します.

ArrayList<Bullet> player_bullets = new ArrayList<Bullet>();

次に,自機のクラスに,弾丸発射用のメソッドを追加します.

    void shot_bullet(ArrayList<Bullet> bullets){
        Bullet my_bullet = new Bullet(position.x + (player_width /2) ,
                                      position.y, 10, PI);
        bullets.add(my_bullet);
    }

弾丸管理用の関数である,move_bullets(), draw_bullets(), remove_unused_bulelts()の中で,player_bulletsを処理できるように,記述を追加します.

void move_bullets(){
    for(int i=0; i<bullets.size(); i++){
      bullets.get(i).move_bullet();
    }

    for(int i=0; i<player_bullets.size(); i++){
        player_bullets.get(i).move_bullet();  
    }
}

void draw_bullets(){
    for(int i=0; i<bullets.size(); i++){
        bullets.get(i).draw_bullet();
    }

    for(int i=0; i<player_bullets.size(); i++){
        player_bullets.get(i).draw_bullet();  
    }
}

void remove_unused_bullets(){
    for(int i=bullets.size()-1; i>=0; i--){
        if(bullets.get(i).destroy_bullet_decidion())
            bullets.remove(i);      
    }

    for(int i=player_bullets.size()-1; i>=0; i--){
        if(player_bullets.get(i).destroy_bullet_decidion())
            player_bullets.remove(i);      
    }
  
}

自機の弾丸発射用のメソッドが,発射ボタンが押されたときに,コールされるようにします.今回はスペースキーを発射ボタンに設定することにしますので,keyPressed()関数の中に,以下の記述を追加します.

void keyPressed(){
    if(keyCode == UP)
        player.up_move_flag = true;

    if(keyCode == DOWN)
        player.down_move_flag = true;

    if(keyCode == LEFT)
        player.left_move_flag = true;

    if(keyCode == RIGHT)
        player.right_move_flag = true;
  
    if(keyCode == ' ')
        player.shot_bullet(player_bullets);
}

当たり判定の追加

最後に,弾丸が自機に当たったら,弾丸が消えるようにしてみます.まずは,自機に弾丸がヒットしているかどうかを確認するメソッドを弾丸クラス(Bullet)に実装します.

    boolean hit_check_player(Player player){
        if(player.position.y <= this.position.y &&
            this.position.y <= player.position.y + player.player_height){

            if(player.position.x <= this.position.x &&
               this.position.x <= player.position.x + player.player_width){
                return true;  
            }
        }
        return false;
    }

このメソッドは,弾丸の座標が,自機が存在する範囲にあるかを判定し,入っていたらヒットしているとみなしてTrue,そうでない場合にはFalseを返します.

さらに,弾丸リストの各弾丸について,ヒットチェックの結果にもとづいて弾丸を削除するremove_hit_bullet()関数を作成します.

void remove_hit_bullet(){
    for(int i=bullets.size()-1; i>=0; i--){
        if(bullets.get(i).hit_check_player(player))
            bullets.remove(i);
    }
}

これをdraw()関数の中で毎回呼び出します.実際にゲームとして動かす場合には,ここで弾丸を消すだけでなく,ダメージ処理を入れたり,エフェクトを再生したりするとよいでしょう. これで雛型の完成ということにしたいと思います.実際の動作イメージとしては,下の動画の通りです.

www.youtube.com

基本的な弾幕要素の実装

雛型ができたら,これをベースにして,色々な弾幕要素を実装してみます.

全方位ショット

敵機のクラス(Enemy)に全方位ショットに対応するall_range_shot()メソッドを実装します.

    void all_range_shot(){
        int ways = 100;
        for(float d=0; d<2*PI; d+=2*PI/ways){
            Bullet bullet = new Bullet(position.x, position.y, 5, d);
            bullets.add(bullet);
        }
    }

弾丸の移動方向を,等間隔で増やしながら,弾丸を発射します.waysを増やすことで,弾丸の数を増やすことができます.これをnormal_shotと同様に,draw()関数の中で定期的にコールしましょう.

実際の動作イメージは下の動画の通りです.

www.youtube.com

自機狙いショット

敵機クラス(Enemy)に自機狙いショットに対応するaim_shot()メソッドを実装します.自機を狙うためには,自機の座標を必要とするので,引数に自機のオブジェクトを受けることにします.

    void aim_shot(Player player){
        PVector target = player.position;
        int target_width = player.player_width;
        int target_height = player.player_height;
        float theta = atan2(target.y + target_width/2 - position.y - enemy_height,
                            target.x + target_height/2 - position.x - enemy_width/2);
        Bullet bullet = new Bullet(position.x + (enemy_width/2),
                                   position.y + (enemy_height),
                                   5, theta-HALF_PI);
        bullets.add(bullet);
    }

狙う角度は,自機の座標と敵機の座標からatan2()関数を用いて計算します.atan2は,直角三角形の底辺と高さから,角度θを計算する関数です. ただし,下図で示しているように,atan2のθを直接使おうとすると,自機の真下からθだけ,時計回りに回転した方向に弾丸を発射しようとしてしまうので,90°反時計回りに回転させて修正します.

発射方向の修正

ノーマルショットと同様に,draw()関数の中でコールして使います.

実際の動作イメージは下の動画の通りです.

www.youtube.com

n-way

敵機クラス(Enemy)にn-wayに対応するnway_shot()メソッドを実装します.

    void nway_shot(Player player){
        float ways = 5;
        float shot_degree = HALF_PI;
        PVector target = player.position;
        int target_width = player.player_width;
        int target_height = player.player_height;

        float pivot = atan2(target.y + target_width/2 - position.y - enemy_width/2,
                            target.x + target_height/2 - position.x - enemy_height) 
                            - HALF_PI;
   

        // pivot 
        Bullet bullet = new Bullet(position.x + (enemy_width/2),
                                   position.y + (enemy_height),
                                   5, pivot);
        bullets.add(bullet);


        // clockwise direction
        float degree = pivot; 
        for(float i=1; i<ways/2; i++){
            degree -=  shot_degree/ways;
            bullet = new Bullet(position.x + (enemy_width/2),
                                position.y + (enemy_height),
                                5, degree);
            bullets.add(bullet);
        }

        // counterclockwise direction
        degree = pivot; 
        for(float i=1; i<ways/2; i++){
            degree += shot_degree/ways;
            bullet = new Bullet(position.x + (enemy_width/2),
                                position.y + (enemy_height),
                                5, degree);
            bullets.add(bullet);
        }
    }

自機狙い弾と同様に,atan2()関数を用いて,自機を狙うための弾丸の角度を計算します.この角度を基準(pivot)として,時計回りと反時計回りに等間隔でそれぞれ弾丸を打ち出すようにしました.自機狙いではないn-wayを作る場合には,自機を狙う弾丸については発車しないようにすれば良いと思います. これもノーマルショットと同様に,draw()の中でコールして使います.

実際の動作イメージは下の動画の通りです.

www.youtube.com

うずまきショット

敵機クラス(Enemy)にうずまきショットに対応するspiral_shot()メソッドを実装します.

    void spiral_shot(int way){
        float ways = 30;
        float degree = (2*PI/ways)*way;
        Bullet bullet = new Bullet(position.x + (enemy_width/2),
                                   position.y + (enemy_height),
                                   5, degree);
        bullets.add(bullet);
    }

うずまきショットについては,弾丸を発射するタイミング自体を遅延させる必要があるため,draw()関数の中で,何回も呼び出すことで,弾幕要素が完成するようにします. メソッドの実装自体は,全方位ショットに似ていて,弾丸を等間隔に発射するようにしています.ただし,呼び出し時に与えられた引数によって,発射する方向が決定され,また,一度の呼び出しにつき一発しか弾丸を発射しません.

draw()関数では,

void draw(){
    background(0,0,0);


    /*------------- calc phase --------------------*/
    // move to next positions
    enemy.move();
    player.move_player();
    
    move_bullets();
    remove_unused_bullets();

    enemy.spiral_shot(spiral_counter);
    spiral_counter ++;

    /*------------- draw phase --------------------*/ 
    enemy.draw_enemy();
    player.draw_player();
    draw_bullets();

}

このように,spiral_shot()メソッドに与える引数を変化させながら,毎フレーム呼び出していきます.

実際の動作イメージは下の動画の通りです.

www.youtube.com

ばらまきショット

敵機のクラス(Enemy)に,ばらまきショットに対応するrandom_spread_shot()メソッドを実装します.

    void random_spread_shot(){
        int number_of_bullets = 5;
        for(int i =0; i < number_of_bullets; i++){

            float random_direction = random(-(HALF_PI/2), HALF_PI/2);

            Bullet bullet = new Bullet(position.x + (enemy_width/2),
                                       position.y + (enemy_height),
                                       5, random_direction);
            bullets.add(bullet);
        }

    }

random(low, high)で,指定した範囲の乱数を取得できます.ここでは,弾丸を発射する角度に乱数を使っています.今回は下方向に-45° ~ 45°の範囲で,ランダムな方向に弾丸を発射します. ノーマルショットと同様にdraw()関数の中で定期的に呼び出して使用します.

実際の動作イメージは下の動画の通りです.

www.youtube.com

追尾ショット

追尾については,弾丸側で機能が必要です.弾丸クラス(Bullet)に,自機の位置に合わせて弾丸の移動方向を修正するtrack_player()メソッドを実装します.

    void track_player(Player player){
        PVector target = player.position;
        int target_width = player.player_width;
        int target_height = player.player_height;
        float theta = atan2(target.y + (target_height / 2) - position.y,
                            target.x + (target_width / 2) - position.x);
        move_vector = new PVector(0, 5);
        move_vector.rotate(theta-HALF_PI);
    }

これにより,弾丸の移動方向を自機のいる方向に修正します.この関数が呼ばれるたびに,弾丸の起動が修正されるので,この関数をたくさん呼ぶようにすることで,弾丸は正確に自機を追尾するようになります.

追尾ショットで発射される弾丸は,追尾用のtrack_playerを定期的に呼んで座標を修正したりする必要があり,専用のリストに分けておいたほうが都合が良いと思います.ですので,追尾ショットの弾丸用のリストである,homing_bulletsをプログラムの冒頭で定義しておくことにします.

ArrayList<Bullet> homing_bullets = new ArrayList<Bullet>();

敵機クラス(Enemy)には,追尾ショットに対応するhoming_shot()メソッドを実装します.

    void homing_shot(){
        Bullet bullet = new Bullet(position.x + (enemy_width/2),
                                   position.y + (enemy_height),
                                   5, 0);
        homing_bullets.add(bullet);
    }

弾丸のリストが増えたので,弾丸リストに関する関数である,move_bullets(),remove_unused_bullets(),remove_hit_bullet(),draw_bullets()の中に記述を追加して,homing_bulletsにおいても更新がされるようにします.

void move_bullets(){
    for(int i=0; i<bullets.size(); i++){
        bullets.get(i).move_bullet();
    }
    for(int i=0; i<player_bullets.size(); i++){
        player_bullets.get(i).move_bullet();  
    }
    for(int i=0; i<player_bullets.size(); i++){
        player_bullets.get(i).move_bullet();  
    }
}


void remove_unused_bullets(){
    for(int i=bullets.size()-1; i>=0; i--){
        if(bullets.get(i).destroy_bullet_decidion())
            bullets.remove(i);      
    }
    for(int i=player_bullets.size()-1; i>=0; i--){
        if(player_bullets.get(i).destroy_bullet_decidion())
            player_bullets.remove(i);      
    }
  
    for(int i=player_bullets.size()-1; i>=0; i--){
        if(player_bullets.get(i).destroy_bullet_decidion())
            player_bullets.remove(i);      
    }
}

void remove_hit_bullet(){
    for(int i=bullets.size()-1; i>=0; i--){
        if(bullets.get(i).hit_check_player(player)){
            bullets.remove(i);
        }
    }
    for(int i=homing_bullets.size()-1; i>=0; i--){
        if(homing_bullets.get(i).hit_check_player(player))
            homing_bullets.remove(i);      
    }
}


void draw_bullets(){
    for(int i=0; i<bullets.size(); i++){
        bullets.get(i).draw_bullet();
    }
    for(int i=0; i<player_bullets.size(); i++){
        player_bullets.get(i).draw_bullet();  
    }
    for(int i=0; i<player_bullets.size(); i++){
        player_bullets.get(i).draw_bullet();  
    }
}

ここまでできたら,draw()関数の中で,追尾ショットを発射するhoming_shot()メソッドを呼んで,弾丸を発射させます.また,弾丸の軌道を修正するtrack_playerも定期的に呼びます.

    if(shot_timer > SHOT_COUNT){

        enemy.homing_shot();
        shot_timer = 0;
    }else{
        shot_timer ++;  
    }

    if(track_timer > TRACK_COUNT){
        for(int i=0; i<homing_bullets.size(); i++){
            homing_bullets.get(i).track_player(player);
        }
        track_timer = 0;
    }else{
        track_timer ++; 
    }

track_timerとTRACK_COUNTについては,プログラムの冒頭であらかじめ定義しておきます.

int track_timer = 0;
int TRACK_COUNT = 5;
実際の動作イメージは下の動画の通りです.

www.youtube.com

固定弾

固定の軌道を描く弾丸の例として,格子状に発射される固定弾幕を作ってみます. 敵機のクラス(Enemy)に格子状に弾丸を発射するgrid_shot()メソッドを実装します.

    void grid_shot(){
        int gap = 100;

        // horizontal 
        for(int i=0; i<1200; i+=gap){
            Bullet bullet = new Bullet(i, 0, 5, 0);
            bullets.add(bullet);
        }
        // vertical 
        for(int i=0; i<800; i+=gap){
            Bullet bullet = new Bullet(0, i, 5, -HALF_PI);
            bullets.add(bullet);
        }

    }
実際の動作イメージは下の動画の通りです.

www.youtube.com

ランダム弾

敵機クラス(Enemy)に,ランダムな位置に,ランダムな方向で弾丸を発射するrandom_shot()メソッドを実装します.

    void random_shot(){
        int number_of_bullets = 10;
        for(int i=0; i < number_of_bullets; i++){
            float random_vertical_position = random(0,1200);
            float random_horizontal_position = random(0,800);
            float random_direction = random(0, PI*2);
            Bullet bullet = new Bullet(random_vertical_position,
                                       random_horizontal_position,
                                       5,
                                       random_direction);
            bullets.add(bullet);
        }
    }
実際の動作イメージは下の動画のとおりです.

www.youtube.com

発展的な弾幕

全方位ショット*2 + n-way

全方位ショットの発射ポイントを2箇所作って,n-wayも撃つようにしてみました. 全方位ショットの発射ポイントを変えるために,all_range_shot()メソッドの内容を少し変えています.

    void all_range_shot(PVector relative_position){
        int ways = 50;
        for(float d=0; d<2*PI; d+=2*PI/ways){
            Bullet bullet = new Bullet(position.x + (enemy_width/2) + relative_position.x,
                                       position.y + (enemy_height) + relative_position.y,
                                       5, d);
            bullets.add(bullet);
        }
    }

引数に相対的な座標を受け,敵機の座標と足し合わせて弾丸の発射地点を決めるようにしました.これを使って,敵機の左右に発射地点をそれぞれ設けています.

www.youtube.com

固定弾+ばらまきショット

格子状の固定弾幕に,ばらまき弾を合わせてみます.

www.youtube.com

弾丸の組み合わせや出現位置で無数にパターンを作ることができます.また,今回はあまり注意を向けられませんでしたが,出現タイミングをタイマーなどで細かく制御することで,より,多彩な軌道を作ることができると思います.このあたりのパターンについては今後さらに研究していきたいと思います.

そのほかのトピックと参考文献

弾幕にまつわるそのほかのトピックとしては,弾幕言語などがあります.

弾幕言語について

今回は純粋にProcessingだけを利用しましたが,弾幕記述用に言語を作成し,それを利用して,弾幕を作るという方法もあるようです.以下のようなページを見つけました.

www.asahi-net.or.jp

BulletMLというのがあるらしいです.

rkx1209.hatenablog.com

“低レイヤーの歩き方”の人が書いた弾幕記述言語の話.

参考サイト

gmdisc.com

シューティングゲーム全般で見られる弾幕要素を説明した記事です.本ページではでは紹介しなかった,先読み弾などが紹介されています.

ch-random.net

東方シリーズの弾幕について説明している記事です.弾幕構造が図解付きでわかりやすく説明されています.おおまかな分類についても説明してあり,参考になりました.

qiita.com

p5.jsという言語を使って,弾幕要素を実装している記事です.実装部分の解説が充実しています.