【Godot】Stomping Shooterで使った技術と得られた知見まとめ

Godot Engine Advent Celendar 2022 15日目

この記事はGodot Engine Advent Celendar 2022 15日目の記事となります。


Godot Engineでのゲームの試作として上に登るアクションゲームを作ったので、得られた知見と使った技術をまとめてみました。

ゲームの情報とプロジェクト

まずゲームの紹介です。ゲームは itch.io にアップロードしています。

ソースコードGitHubからダウンロードできます。


得られたもの

今回のゲームで使用したGodot の機能

一方通行コリジョン

一方通行床コリジョン」を使ってアクションゲームを作る…ということがこのゲームを作ったきっかけとなります。

ただ一方通行床コリジョンだけだと、横からの当たりが抜けてしまいます。

このコリジョン抜けがあると、理不尽な落下死が発生しやすくなってしまうので、コリジョンを2つ重ねて横からの衝突時には上にプレイヤーを押し上げるようにしました。

コリジョンは以下のようにStaticBody2D を外側に配置して、Area2Dを内側に入れています。できればArea2Dだけで判定したかったのですが、コリジョン抜けが発生したり接地判定がうまく取れなかったのでこのような実装となりました。

ホーミングレーザー

敵を攻撃するホーミングレーザーは以下の記事をもとに作りました。

バレットタイム(スロー再生)

青い玉を取得したときに、敵弾などがスロー再生されます。

これは _process() / _physics_process() の delta にバレットタイムの係数を掛けることで対処しました。

func _process(delta: float) -> void:
    # バレットタイムの係数を掛ける
    delta *= Common.get_bullet_time_rate()

    _velocity += _accel
    position += _velocity * delta;
    
    # 画像の回転.
    rotation = atan2(_velocity.y, _velocity.x)

スロー再生する他の方法として Engine.time_scale の値を変更することでスローにできますが、プレイヤーの速度はそのままとしたかったので、この方法は使用しませんでした。

 

ヒットストップの実装方法

敵に接触した際に発生するヒットストップは停止したいオブジェクトの set_process() / set_physics_process() を呼び出すことで実装しています。RigidBody2D 以外はこれですべて一時停止できます。

# 一時停止状態を切り替える
func _set_process_all_objects(b:bool) -> void:
    for obj in _main_layer.get_children():
        obj.set_process(b)
        obj.set_physics_process(b)
    for item in _item_layer.get_children():
        item.set_process(b)
        item.set_physics_process(b)
    for wall in _block_layer.get_children():
        wall.set_process(b)
        wall.set_physics_process(b)
    for bullet in _bullet_layer.get_children():
        bullet.set_process(b)
        bullet.set_physics_process(b)

 

 

リアルタイムのBGMの変化

BGMは新しい敵が出現したときにシームレスに切り替えています。曲のテンポは 148 BPM・4拍子・8小節で統一しているため、1小節単位で新しい曲に切り替えるようにしました。

    if _now_bgm != _next_bgm: # BGMのIDに変更があった
        var _can_change = true
        if _bgm.playing:
            var pos = _bgm.get_playback_position()
            var measure = 13.01 / 8 # 13.01秒 / 8小節.
            var d = fmod(pos, measure)
            if 0.1 < d and d < measure - 0.1:
                _can_change = false
        if _can_change:
            # BGM変更.
            _now_bgm = _next_bgm
            _bgm.stream = load("res://assets/sound/stage%d.mp3"%_now_bgm)
            _bgm.play()

またバレットタイムに入ったときに、"bitch_scale" を 0.75 に減らしてローパスフィルタを適用しています。

    if Common.is_slow_blocks(): # バレットタイム中.
        _bgm.pitch_scale = 0.75
        AudioServer.set_bus_effect_enabled(1, 0, true) # ローパスフィルタを有効にする.
    else:
        AudioServer.set_bus_effect_enabled(1, 0, false) # ローパスフィルタを無効にする.
        _bgm.pitch_scale = 1.0

ローパスフィルタとは「低音だけを通す(くぐもった音になる)」という効果を持つエフェクターで、活発でない印象を与えることができます。ゲームスピードが低下していることを感じさせるのに適していると思ってローパスフィルタを採用しました。

エフェクターの設定方法については以下の記事に書いています。

またBGMは「4小節がイントロ」+「4小節がループ区間」という作りになっていて、区間リピートの設定方法は以下の記事に書いています。

プログレスバー

画面の上部に表示されているプログレスバーは以下の記事を元に作りました。

BMPフォント

今回使用したフォントはBMPフォントを使っています。TTFだと固定幅でよい感じのものが見つけられなかったのでBMPフォントを使いました。

BMPフォントは ShoeBox を使用して作っています。

 

ただShoeBoxで固定幅の設定がうまく設定できず、以下の記事を見つけて対応しました。

  • Txt MonoSpace
    固定幅フォントにするかどうかの設定です。ヘルプにはそのようにできると解説がありますが、試してみた限り固定フォント書き出しにはなってくれませんでした。。File Format Loop設定のadvanceX設定を固定値にしてしまえば近い感じにではできます。
ShoeBox:ビットマップフォント書き出し設定の解説(#4詳細編)

ちなみにフォント画像は「おめが試作設計局」の「芝が生えるゲーム」からお借りしています。(他にも敵画像を「えぐぜりにゃ〜」からお借りしています)

その他・得られた知見

CanvasLayerにインスタンスをまとめておいて共通モジュールからアクセスできるようにする

アクションゲームを作る場合、特定のグループに所属するインスタンスを取得したり登録したりしたくなることが多いですが、共通モジュールに CanvasLayer をまとめて登録することで対応しました。

    # レイヤーテーブルを作っておく.
    var layers = {
        "wall": _block_layer,
        "item": _item_layer,
        "enemy": _enemy_layer,
        "shot": _shot_layer,
        "bullet": _bullet_layer,
        "effect": _effect_layer,
    }
    # 共通モジュールに登録する
    Common.setup(self, layers, _player, _camera)

Commonは常駐(自動読み込み)のスクリプトなので、これでどこからでも各オブジェクトにアクセスできるようになります。

        # _bullet_layerを取得.
        var bullets = Common.get_layer("bullet")

        # 爆発オブジェクトを生成.
        var blast = BlastObj.instance()
        blast.position = position

        # _bullet_layerに登録.
        bullets.add_child(blast)

AudioStreamPlayer は共通モジュールにまとめてプールしておくのが楽

AudioStreamPlayer は同時再生するSEの数だけ共通モジュールにまとめてプールしておくと、再生が簡単にできて使い勝手が良いです。

# サウンドデータのパステーブル.
var _snd_tbl = {
    "damage": "res://assets/sound/damage.wav",
    "explosion" : "res://assets/sound/explosion.wav",
    "coin": "res://assets/sound/coin.wav",
    "flash": "res://assets/sound/flash.wav",
}

func setup(root, layers, player:Player, camera:Camera2D) -> void:
    # ゲーム開始時に一度だけ呼び出される関数
    ...
    
    # MAX_SOUND の数だけ AudioStreamPlayer を作って登録しておく
    for i in range(MAX_SOUND):
        var snd = AudioStreamPlayer.new()
        snd.volume_db = -4
        root.add_child(snd)
        _snds.append(snd)

そして再生するときにあらかじめ確保していた AudioStreamPlayer を使います。

func play_se(name:String, id:int=0) -> void:
    if id < 0 or MAX_SOUND <= id:
        push_error("不正なサウンドID %d"%id)
        return
    
    if not name in _snd_tbl:
        push_error("存在しないサウンド %s"%name)
        return
    
    var snd = _snds[id] # idに対応する AudioStreamPlayerを取り出す
    snd.stream = load(_snd_tbl[name]) # SEをロードする
    snd.play() # 再生

カメラのスムージングとカメラ揺れの共存

カメラのスクロールは smoothing をオンにしておくと滑らかにスクロールしておすすめです。

問題点として、カメラを揺らすときにカメラの position の移動がゆっくりになって激しい揺れができなくなります。そこで、カメラを揺らす場合には smoothing_enabled無効にして揺らすようにしています。

## カメラ揺らしの開始.
func _start_camera_shake(type:int) -> void:
    # カメラ位置を保持.
    _camera_shake_type = type
    _camera_shake_position = _camera.position
    _camera_shake_timer = TIMER_SHAKE
        
    # スムージングを無効化.
    _camera.smoothing_enabled = false

RigidBody2D はアクションゲームに向いていないかもしれない

大根ミサイルは壁にぶつかると反射します。この挙動を楽に作りたかったので、最初は RigidBody2D を使って実装していました。

ただ今回のゲームでは、ヒットストップやスロー再生など特殊な挙動があったため RigidBody2D だと細かい制御ができず、結局は Area2D に置き換えました

func _process(delta: float) -> void:
    delta *= Common.get_bullet_time_rate()
    position += _velocity * delta
    
    # 横壁は固定なのでこれで反射が実装できる
    var left = Common.TILE_SIZE * 1.5
    var right = Common.SCREEN_W - Common.TILE_SIZE * 1.5
    if position.x < left:
        position.x = left
        _velocity.x *= -1
    if position.x > right:
        position.x = right
        _velocity.x *= -1
    
    _spr.rotation = atan2(_velocity.y, _velocity.x)

RigidBody2D は物理アクションなど、物理法則に準拠した挙動をするオブジェクト向きでアクションゲームにはあまり向いていないのかもしれません。

ゲームデザイン的な話

高さ・重力のあるプラットフォーマーでの弾避けは難しいので、そのあたりの落としどころを見つけるのに苦労しました。具体的には2体目の敵を作ったあたりで、弾幕シューティングとアクションの組み合わせをどのように広げていくかの方向性の考えがまとまらない状態となりました。

そこで、名作2Dアクションシューティングとして人気の高い ガンスターヒーローズ を少しだけ参考にしました。

セブンフォースのこの形態の攻撃方法(レティクルを移動させて攻撃する)は「使える!」と思って採用した次第です。

ということで以下が実装した攻撃方法です。

ガンスターヒーローズの場合はレティクルに向けて発射するだけですが、今回のゲームでは、この攻撃方法と別の攻撃やトゲ床が組み合わさることでアクションのバリエーションを感じさせるようなものとなりました。

やはり既存の似たジャンルのゲームを参考にするのは基本であり、得られるものが多い…ということを改めて理解した次第です。

また「最初のアイデア固執しない」ということも大切かなと思いました。1つのメカニクス固執して、それを複雑に拡張するのではなく、別の軸の新しいメカニクスを採用する(その敵固有の攻撃方法を追加する)のも時として良い効果を発揮することもある事例かな…と思いました。

それと敵弾を無効化するアイテムをいくつか用意して、「弾避け」よりもそれらのアイテムを使うタイミングを選ぶ自由度を組み込み、「足場に飛び移るタイミングを探るゲーム」という面白さも表現できたのでは…と思っています。