【Godot】弾幕2DSTGの基本

今回はGodot Engineで弾幕2DSTGを作るときに知っておきたい基本をまとめておきます。

数学

ベクトル

これは2Dゲームを作るときに共通することですが、座標や速度などはベクトルを使います。 ベクトルとは「向きと大きさの2つを同時に持つ量」のことで、物体の位置や移動を表す際に用いられます。

上記の2Dベクトルであれば、(X, Y) = (6, -5) というベクトルです。2Dゲームにおけるベクトルは数学的なベクトルと異なり、上が "-Y" となるのが特徴です。

なおGodot Engineには、2Dベクトルを扱うためのクラスが用意されています。2Dベクトルを扱うときに知っておくと便利な関数は以下のページにまとめています。

2dgames.jp

極座標

弾幕を扱うときに便利なのが極座標系です。ベクトルのような直交座標系は X/Yの要素を持ちますが、極座標系は r (距離) と θ (角度) の要素となります。

例えば (X, Y) = (4, -4) の方向であれば、θは atan2(-4, 4) = -0.785...≒45度、rは √(42 + (-4)2) = 5.656.... となります。

具体的には、角度と速度を指定して敵弾を発射できると、3way弾のパターンで弾の広がり具合(角度)と弾の発射速度を直感的に変更しやすくなります。

このように中心を基準に左右の広がり角度と弾の速度を決めると、3way弾が作れます。

Godot の Vector2での角度の扱い

2Dゲーム (スクリーン座標系) では、上を -Y、下を+Yとして扱いますが、数学的には上が+Yなので座標軸の扱いに差異があります。 そして Godot の Vector2 などには Vector2.angle() などベクトルを角度 (ラジアン) に変換する関数が用意されていますが、これらは 上を+Yとして扱っているため、スクリーン座標系と逆の扱いとなります。

このことについて、どのように扱うかは人によります。

  1. 数学的な正しさを優先して Godotのangle()をそのまま使う
  2. 扱いやすさを考えて、Y軸をマイナスで反転させる

1は余計な変換をせずに扱えますが、弾幕のパターンを作るときにY軸を逆にした角度を与える必要があります。 2はベクトルと角度を変換する際に変換処理を書く必要がありますが、直感的に角度を扱えるメリットがあります。

この記事では「2. 扱いやすさを考えて、Y軸をマイナスで反転させる」という実装方法で進めていきます。

狙い撃ち弾

2DSTGの基本パターンとして狙い撃ち弾があります。 これは文字通り、敵からプレイヤーに向けて発射する弾です。

(※自機画像は 有限会社エムツー様よりお借りしました)

ベクトルのみで処理するのであれば、敵の位置を A、プレイヤーの位置をBとした場合、ベクトルの差 (B-A) を求めて正規化した後、適切な速度をかけることで移動ベクトルが求められます。

ただ弾幕ゲームでは、狙い撃ち弾を中心に3way弾を作りたいことが多いので「狙い撃ち角度」を計算したほうが都合が良いです。 計算式はベクトルの差(B-A)=Vを求めて、atan2(v.y, v.x) で角度(ラジアン)が求められます。

ただ Godot Engineの場合は、Vector2に angle_to_point() という指定の位置に向けた角度を簡単に取得できるのでこれを使うのが便利です。

# 狙い撃ちの角度.
static func get_aim(pos:Vector2) -> float:
    # プレイヤーの位置.
    var target = _player.position
    # 狙い撃ち角度 (ラジアン)の取得.
    var rad = pos.angle_to_point(target)
    # ラジアンからディグリーに変換.
    return rad_to_deg(-rad) # 逆回転にしたいので -1

補足として、この解説ではスクリーン座標系で角度を扱いたいのでベクトルと角度の変換が入るところで "-1" をかけ合わせ逆回転しています。

なお敵が狙い撃ちするためにはプレイヤーの座標が必要なので、static変数にプレイヤーのインスタンスを保持しておくのがおすすめです。

extends Node

class_name Common

# プレイヤー.
static var _player:Player
# 敵弾.
static var _bullets:CanvasLayer

# セットアップ (インスタンスを保持しておく).
static func setup(player:Player, bullets:CanvasLayer) -> void:
    _player = player
    _bullets = bullets

ラジアン (Radian) とディグリー (Degree)

角度を扱う上で知っておきたいのが、ラジアンとディグリーの違いです。 Godotを始め、数学ライブラリでは角度はラジアン単位で扱われます。

ただ弾幕パターンを作るときは度数法のディグリーを使うと扱いやすいです。

ラジアンとディグリーの相互変換は以下の計算式となります。

ただGodotには、専用の変換関数が用意されているのでこれらを使うのがおすすめです。

  • rad_to_deg(): Radian → Degree
  • deg_to_rad(): Degree → Radian

N-way弾

扇状に広がる 3way弾や4way弾などの作り方です。

関数のパラメータとしては、以下のパラメータを渡せると良いです。

  • 発射数: 発射する弾の数。多くなるほど弾の密度が上がる
  • 中心角度: 扇状に広がる弾の中心角度
  • 範囲: 扇の広さを表す角度。大きくなるほど密度が小さくなります
  • 速度: 弾の速度

Godotでの実装例は以下のとおりです。

## N-wayを撃つ
## @param n 発射数.
## @param center 中心角度.
## @param wide 範囲.
## @param speed 速度.
func _nway(n, center, wide, speed) -> void:
    if n < 1:
        return
    
    var d = wide / n # 弾の間隔
    var a = center - (d * 0.5 * (n - 1)) # 開始角度
    for i in range(n):
        _bullet(a, speed)
        a += d

N-way弾の特徴として、発射数が "奇数" である「奇数弾」、"偶数" である「偶数弾」によって扇の中心の扱いが変化します。これによって「奇数弾」と「偶数弾」を交互に繰り返すことに、弾をジグザグにすり抜ける遊びが作れます。

遅延発射

2DSTGを作るときに是非とも実装しておきたいのが「遅延発射」です。

例えば「1秒後に 220度の方向に10の速さで発射する」という情報を配列などに保持しておき、1秒経過後に弾を発射する仕組みです。 これを作っておくと、上記の画像のような Whip弾 (しなる弾幕) が実装しやすくなります。

Godotでのサンプルコードは以下のとおりです。

class DelayedBatteryInfo:
    """ 遅延発射弾の情報 """
    var deg:float = 0 # 角度.
    var speed:float = 0 # 速さ.
    var delay:float = 0 # 遅延時間(秒).
    var ax:float = 0 # 加速度(X)
    var ay:float = 0 # 加速度(Y)
    func _init(_deg:float, _speed:float, _delay:float, _ax:float=0.0, _ay:float=0.0) -> void:
        """ コンストラクタ """
        deg = _deg
        speed = _speed
        delay = _delay
        ax = _ax
        ay = _ay
    func elapse(delta:float) -> bool:
        """ 時間経過 trueで発射可能. """
        delay -= delta
        if delay <= 0:
            return true # 発射できる.
        return false

# 弾のディレイ発射用配列.
var _batteries: Array[DelayedBatteryInfo] = []

## 弾を撃つ.
## @param deg 角度
## @param speed 速さ
## @param delay 発射遅延 (秒)
## @param ax 加速度(X)
## @param ay 加速度(Y)
func _bullet(deg:float, speed:float, delay:float=0, ax:float=0, ay:float=0):
    if delay > 0.0:
        # 遅延発射なのでリストに追加するだけ.
        _add_battery(deg, speed, delay, ax, ay)
        return null
    
    # 発射する.
    var b = BULLET_OBJ.instantiate()
    b.position = position
    b.set_velocity(deg, speed)
    b.set_accel(ax, ay)
    var bullets = Common.get_layer("bullet")
    bullets.add_child(b)
    return b

## 遅延発射リストに追加する.
func _add_battery(deg:float, speed:float, delay:float, ax:float, ay:float) -> void:
    var b = DelayedBatteryInfo.new(deg, speed, delay, ax, ay)
    _batteries.append(b)

## 更新時間を固定化するため "_physics_process" で更新
func _physics_process(delta: float) -> void:
    _timer += delta
    # 遅延発射更新.
    _update_batteies(delta)

## 遅延発射リストを更新する.
func _update_batteies(delta:float) -> void:
    var tmp:Array[DelayedBatteryInfo] = []
    for battery in _batteries:
        var b:DelayedBatteryInfo = battery
        if b.elapse(delta):
            # 発射する.
            _bullet(b.deg, b.speed, 0, b.ax, b.ay)
            continue
        
        # 発射できないのでリストに追加.
        tmp.append(b)
    
    # 発射できない弾は次回に持ち越し.
    _batteries = tmp

ポイントとしては、更新関数を _physics_process() にして更新時間を固定にしているところです。通常の _process() の更新頻度には偏りがあるためこれを使うと弾幕の発射タイミングにムラが発生します。ですが、_physics_process() を使うことで発射タイミングがズレにくくなります。

遅延発射にコルーチンやスレッドを使う方法もありますが、個人的に非同期処理をあまり入れたくないのでこのようにしています。ただ処理をマルチスレッドで最適化したい場合や、非同期処理に抵抗がない場合は、コルーチンやスレッドにしても良いと思います。

なお発射遅延を実装することで、先程のN-way弾にWhip弾を組み合わせられます。

## N-Wayを撃つ
## @param n 発射数.
## @param center 中心角度.
## @param wide 範囲.
## @param speed 速度.
## @param delay 発射遅延時間 (秒).
func _nway(n:int, center:float, wide:float, speed:float, delay:float=0.0) -> void:
    if n < 1:
        return
    
    var d = wide / n # 弾の間隔
    var a = center - (d * 0.5 * (n - 1)) # 開始角度
    for i in range(n):
        _bullet(a, speed, delay)
        a += d

針弾

針弾とは、針のように長細い弾のことです。

2DSTGをあまりやらない人には "針弾" は「単に見た目が長細いだけの弾では?」という印象を受けますが、実は弾幕STGを作る上で重要な効果を持ちます。

効果 説明
1. 進行方向の可視化 長細いことで、弾の進む方向 (角度) がわかりやすい
2.塊としての認識 通常はつながって発射されることで、弾の塊を認識しやすい
3. 当たり判定の分かりやすさ ・見た目の形と実際の判定がほぼ一致しやすいため、プレイヤーが「避けやすい」と感じる
・丸弾に比べて「横幅が狭い」ため、判定が小さく感じられ、スレスレを抜ける緊張感を演出できる
4. 弾速との相性 ・針弾は「速い弾」に使われやすい: → 長細さが速度感を強調し、プレイヤーに迫ってくる怖さや緊張感を与える
・同じ速度の丸弾よりも「速く見える」心理効果がある

他にも針弾を連ねることで「レーザーの簡易版」のように壁を作るので、弾幕パターンの区切りとなる効果などが考えられます。

具体的な発射方法の実装例としては、"発射角度" と "速度" を固定して先程の「発射遅延」で連続発射すると実装できます。

for i in range(5):
    var aim = _aim() # 狙い撃ち.
    var delay = (0.05 * i) # ディレイ時間.
    _bullet(aim, 500, delay) # 発射.

画像は丸弾ですが、このように連結させることで針弾のように見せられます。

全方位弾

全方向に弾を発射する全方位弾は 360度の均等な間隔で弾を生成すると実装できます。

for i in range(36):
  _bullet(i * 10, 100) # 10x36=360度.

回転弾

全方位弾に遅延発射を適用すると回転弾となります。

これは回転弾に限りませんが、高速弾を発射する場合は、あえて後ろから発射を開始することで予兆の演出としても使えます。

var aim = _aim()
var deg1 = aim - 155 # 斜め後ろから開始
var deg2 = aim + 155 # 斜め後ろから開始
for i in range(36):
  var speed = 100 + 20 * i
  var delay = 0.05 * i
  _bullet(deg1, speed, delay)
  _bullet(deg2, speed, delay)
  deg1 += i * 0.5
  deg2 -= i * 0.5

重力 (加速) 弾

下方向に加速する弾です。

等速移動でないため、やや回避に慣れのいる弾です。 弾の発射に加速度を持たせることで実装できます。

# 敵弾クラス.
class_name Bullet

var _velocity:Vector2
var _acceleration:Vector2

func _process(delta: float) -> void:
    # 加速度を加算.
    _velocity += _acceleration * delta
    # 速度を加算
    position += _velocity * delta

なおY軸だけでなく、X軸方向に加速させるとウェーブ弾になります。

Godotサンプル

Godotで弾幕パターンを実装したサンプルをHTML5として出力しました。以下のページから確認できます。 syun777.sakura.ne.jp

ここのドロップダウンリストから弾幕パターンが選べます。

またソースコードGithubにアップロードしています。

github.com