LoginSignup
24
17

More than 3 years have passed since last update.

【Godot】2Dシューティングゲームを作るときのポイントまとめ

Last updated at Posted at 2021-02-01

Godotで初めて2Dシューティングゲームを作ってみたので、少し悩んだポイントをまとめてみました
shot.gif

プロジェクトファイルは以下からダウンロードできます
https://github.com/syun77/godot_stg_test

ゲームオブジェクトとシーンの関係

Godotでは「ノード」という単位ですべて管理されているようです。
そしてノードは「シーン」という単位に配置されます。
さらに「シーンにシーンを配置できる」……という構造になっていて、プレイヤーや敵などは1つのシーンで作っておいて、それをメインゲームとなるシーンに配置する、という開発フローになっているようです。
名称未設定_key.png

Unityに例えるとオブジェクトを複製する場合は通常 Prefab を使いますが、Godotの場合はシーンが Prefab であり、シーンとしてそのまま使うこともできる、ということになります。

ゲームオブジェクトの構成

今回作ったゲームオブジェクトのノード構成は以下のようになりました。(例として プレイヤー[自機])
Godot_Engine_-_TestGame_-_Player_tscn.png

Player (Area2D)
 +-- Sprite
 +-- CollisionShape2D

ゲームオブジェクトのベースとなるノードでGodotのサンプルとしてよく使われるのが KinematicBody2D なのですが、今回は Area2D を使用しました。
理由としては、KinematicBody2D の場合、オブジェクト同士にめり込みが発生した場合、自動で衝突応答(重ならないように押し出し)が行われてしまうためです。
(※ひょっとしたら何か回避方法があるかもしれませんが……)
shot.gif
このように敵の中心から敵が出現したり、弾が発射されたりします。
そのため、今回のシューティングでは、衝突応答をせず衝突検知のみが必要なので Area2D を使うことにしました。

ちなみに KinematicBody2D を使用する場合は move_and_slide() を使うと、重なりが発生せずいい感じに移動させることができるようです。

KinematicBody2Dについては、以下の記事に詳しくまとめられていますね。

衝突検知には area_entered シグナルを使用する

シグナルをまだ完全に理解してはいないのですが、他のゲームエンジンでのイベントやコールバックに該当するのがシグナルのようです。
Area2D の場合、area_entered() というシグナルがあり、これを利用すると衝突検知が実装できます。

Godot_Engine_-_TestGame_-_Bullet_tscn.png

このシグナルを作成して、スクリプト側には以下のように記述します。

Bullet.gd
func _on_Bullet_area_entered(area):
  if area.name == "Player":
    area.hit(1) # プレイヤーに1ダメージ
    destroy() # 自分も消える

このシグナルの引数には Area2D が渡されるので、name プロパティ(ノード名)が "Player" の場合に衝突処理を行う……、という実装にしました。
おそらくArea2Dの「Layer」や「Mask」を使用することでより最適な書き方になる気がしますが、このあたり調査ができておらず今後の課題です。

なお、ノード名で判定する場合、敵キャラのように同時に複数のインスタンスが登場すると問題が発生します。理由は「同じシーンに同じノード名は存在できないため」です。
具体的には "Enemy" が複数生成されると、"@Enemy@2"、"@Enemy@3"、……というような名前となります。

これにより発生するコリジョン抜けを対処するには、以下のように記述します。

Shot.gd
func _on_Shot_area_entered(area):
  if "Enemy" in area.name or "Boss" in area.name:
    area.hit(1) # 敵に1ダメージ
    queue_free() # 自分も消える

"Enemy" in area.name という記述にすることで、文字列 "area.name" に "Enemy" という文字が含まれるかどうか、という判定をすることができます。

2021.2.22 追記:コリジョンレイヤーとマスクについて

コリジョンレイヤーとマスクについて調べました
【Godot】コリジョンレイヤーとマスクについて

この仕組みを使えば、ノード名で判定せずに衝突検知ができます。

インスタンスの生成

ゲームオブジェクトを生成するには、対象のシーンをpreload()で読み込み instance() を呼び出す、という記述となります。

Player
extends Area2D

# ショットシーンを読み込み
const Shot = preload("res://Scenes/Shot.tscn")
……

func _process(delta):
  ……

  # Spaceキーを押したらショットを発射
  if Input.is_action_pressed(("ui_select")):
    # ショット生成
    var shot = Shot.instance()

    # 位置と移動量(90度方向に1000の速さ)を設定
    shot.start(position.x, position.y, 90, 1000)

    # ルートノードを取得
    var main_node = get_owner()
    main_node.add_child(shot)

instance() を呼び出すだけではメモリ上に配置されるだけなので、どこかのノードに add_child() でぶら下げることで表示されるようになります。ここでは get_owner() でノードの所有者を取得してそれにぶら下げていますが、しっかりと作る場合はオブジェクトプールを管理するノードにぶら下げるのが良いのかもしれません。

インスタンスの破棄

インスタンスの破棄は queue_free() を呼び出します。

func destroy():
    # 消滅
    queue_free()

これによりノードツリーから削除され、メモリ上からも消えるようです

Mainノードの構成

今回はメインとなるシーンを以下のようなノード構成としました。

Main (Node2D): メインシーン
 +-- Player (Area2D): プレイヤー
 +-- Boss (Area2D): ボス
 +-- HPBar (TextureProgress): ボスのHP
 +-- Label (Label): キャプション

Godot_Engine_-_TestGame_-_Main_tscn____.png

Playerは Shot(自機が発射する弾) を生成し、BossはEnemyを生成します。
BossのHPに連動して、HPBarのゲージの幅が変化し、ゲームオーバーやゲームクリア時に、Labelのテキストが変化する……という実装となっています。

すべてのノードは Mainノードに直接ぶら下がるようにしました。

プレイヤーの移動

Area2Dを使用する場合、物理エンジンを使わず移動するので、直接 position の値を変化させて移動させるようにしました。

Player.gd
func _process(delta):
    # 移動速度
    var spd = 500 * delta
    if Input.is_action_pressed("ui_select"):
        # Spaceキーを押しているとスロー
        spd = 200 * delta

    var v = Vector2()
    if Input.is_action_pressed("ui_up"):
        v.y -= 1 # 上に移動
    if Input.is_action_pressed("ui_down"):
        v.y += 1 # 下に移動
    if Input.is_action_pressed("ui_left"):
        v.x -= 1 # 左に移動
    if Input.is_action_pressed("ui_right"):
        v.x += 1 # 右に移動

    position += v.normalized() * spd

KinematicBody2D を使用する場合には、移動処理は move_and_collide() などを使用することになりそうですが、Area2D を使用する場合には position を直接変更して問題なさそうです。

入力ボタン設定

デバイスからの入力を判定するボタン(キー)は、メニューの「プロジェクト > プロジェクト設定」から、インプットマップのタブを選ぶことで確認することができます。
Godot_Engine_-_TestGame_-_HPBar_tscn.png

スクリプトとシーンの切り替え

エディタの上にある「2D」と「Script」をクリックすることで切り替えられます。
shot.gif

「出力」ウィンドウを非表示にする

スクリプトを編集するときに非表示にしておきたい「出力」ウィンドウ。これを非表示にするには、左下の「出力」の文字をクリックします。
shot.gif

HPバーの作り方

HPバーは、TextureProgress を使うと簡単に実装できます。
Textureの Over と Under に画像を登録して……
Godot_Engine_-_TestGame_-_HPBar_tscn.png

※画像サイズは実際に表示するサイズに合わせます。例えば横幅 400px にしたい場合は、幅を 400px にした画像を用意します。

そうすると、valueの値を書き換えることで幅が変化します。
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f38323238326164382d383962372d623361652d306661642d6632373032336330656233372e676966.gif

以下はボスからHPバーの値を書き換えるコードです

Boss.gd
func _physics_process(delta):
    ……

    # HPバー更新
    var hpbar:TextureProgress = $"../HPBar"
    var hpratio = _hpratio() # 残りHPの割合を 0.0〜1.0 で取得
    # valueに 0〜100 を設定することで幅が変化する
    hpbar.value = 100 * hpratio

    # HPバーの色を更新
    hpbar.tint_progress = lerp(Color.red, Color.limegreen, hpratio)

シーンを単体で確認する方法

Godotの便利な機能として、シーン単体を簡単に確認することができます。
まずは確認したいシーンを開きます。ここでは "Enemy.tscn" シーンを開きます。
Godot_Engine_-_TestGame_-_Enemy_tscn.png

そして、右上の「編集したシーンを実行」のボタンをクリックします
Godot_Engine_-_TestGame_-_Enemy_tscn.png

すると、シーン単体を動作確認できます。
shot.gif

エディタで設定した値を反映することもできるので、敵の弾幕パターンを確認するなどのときにはとても便利なのではないかと思います。

なお、実行中のアプリケーションに対してエディタの値をリアルタイムで反映するには、変数の前に export をつける必要があります

Enemy.gd
# 変数 "id" を実行時に編集できるようにする
export var id = 0

常にロードされるシーンを設定する

Godotでは preload() で外部のシーンを読み込むことができますが、C++のような staticクラス・static変数・シングルトンを利用するには、常にロードする設定にしておくと便利です。

設定方法としては、メニューから「プロジェクト > プロジェクト設定」を選択して……
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f65373761366336662d323232372d633736352d643866382d6239353139366338656331352e706e67.png
「自動読み込み」タブを選び、対象のパスとノード名を指定して、「追加」ボタンをクリックします。

シーンの遷移

シーンの遷移は get_tree().change_scene() で対象のシーンを指定することで行えるようです。

Main.gd
func _process(delta):
  ……    

  if is_instance_valid(_player) == false:
    # プレイヤーが消滅したら ”GAME OVER” のキャプションテキストを設定
    _caption.text = "GAME OVER\n\nPRESS SPACE KEY"
    if Input.is_action_just_pressed("ui_select"):
      # リスタート (Mainシーンの読み込み)
      get_tree().change_scene("res://Scenes/Main.tscn")

コルーチンの利用方法

指定秒数後に弾を発射する、という処理をしたい場合は以下の記述で実装できます

Enemy.gd
# 弾を撃つ
func bullet(deg, speed, delay=0):
  if delay > 0:
    # 遅延して発射する
    yield(get_tree().create_timer(delay), "timeout")

  var bullet = Bullet.instance()
  bullet.start(position.x, position.y, deg, speed)
  # ルートノードを取得
  var main_node = get_parent()
  main_node.add_child(bullet)

yield(get_tree().create_timer([遅延秒数]), "timeout")

ただこれを使うと実行しているコルーチンを停止して、新しくコルーチンを作る方法がわからなかったので、敵のAI処理は以下のように記述しました。

Enemy.gd
export var id = 0
var id_previous = 0
var _wait = 0
# 初期化フラグ
var is_init = false
var func_ai = null

func wait(t):
    _wait += t

func ai_1():
    # 高速狙い撃ち弾を撃つコルーチン
    while true:
        wait(1)
        yield()
        while true:
            for i in range(5):
                aim(700, i * 0.05)
            wait(0.8)
            yield()

# その他様々な AIのコルーチン
func ai_2():
func ai_3():
func ai_4():
……

func _physics_process(delta):

    if id != id_previous:
        # idが切り替わった
        id_previous = id
        is_init = false # 初期化し直す
        _wait = 0

    if is_init == false:
        # 初期化
        is_init = true
        # コルーチンを生成
        func_ai = ai_1() # ここを "id" の値で呼び出しを切り替える

    _wait -= delta
    if _wait <= 0:
        if func_ai:
            func_ai = func_ai.resume()

コルーチンを変数 func_ai に格納して resume() を呼び出すことでコルーチンの切り替えをできるようにしました。ただこの方法だと _process() の呼び出しに遅延が出てしまうので、弾幕が不揃いになってあまりキレイにならないという問題があります。
見た目の問題なので今回はそのままとしましたが、ひょっとしたら何かもっと良い方法があるのかもしれません。

プロジェクトの構成

今回のプロジェクトは以下のような構成としました。

godot_stg_test
  +-- *.png: 各種画像リソース
  +-- Scene
  |    +-- *.tscn: 各種シーンファイル
  |
  +-- Scripts
       +-- *.gd: 各種スクリプトファイル

Unityにならって、"Scenes"、"Scripts" フォルダを作成しましたが、Godotの場合は シーンとスクリプトは同一フォルダにした方が良さそうです。

ウィンドウサイズの変更

メニューから プロジェクト > プロジェクト設定 を選択します。
一般 > Display > Window から WidthとHeightの値を変更すると画面サイズが変更できます。
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f34373732626436322d393135642d323565642d653935632d3730363137373233353465652e706e67.png

フォントサイズの変更

フォントサイズを変更する場合は、Scaleの値を変更することになりますが、線形補間処理となるため文字がぼやけてしまいます。
そのままサイズを変更する方法がわからなかったので、Custom Fonts にフォント (DynamicFont) を追加することで対応しました。
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f62613638643134392d376163372d626663662d646264652d3231396432366239303032662e706e67.png

インスペクタから Custom Fonts > Font に "新規 DynamicFont" を設定して
……

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f61383366663435392d656130362d613230332d333166352d6232366462356331386261642e706e67.png
任意のフォントリソースを追加して……

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f64316663383962662d363633302d663430332d383436632d3230373031313831366537632e706e67.png

Font > Font Data にフォントリソースを登録します。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f31396137363439652d313964302d666136352d313366642d3931646466363535356335362e706e67.png
すると、Font > Settings > Size からフォントサイズを変更できます。

パーティクルの実装

色々実装方法はありそうですが、今回は CPUParticle2D で実装してみました
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f33323038313035342d356462342d396338612d653438362d6136353264666136386332312e706e67.png

Drawing の Texture に画像を設定するとアニメーションが再生されるようになります。
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f38623335393537632d336239362d626631392d353462662d3339326331323966666330382e676966.gif

パーティクルのパラメータ設定例

  • Direction > Spread の値を「180」(発射範囲を±180度)
  • Initial Velocity > Velocityを「200」(初期速度)
  • Initial Velocity > Velocity Randomを「1」(完全にランダム)

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f63353331393638362d623133392d343864652d643662322d6239363438353235303634322e706e67.png

  • Amount: 16
  • Time > Lifetime: 2
  • Time > One Shot: チェックを入れる
  • Time > Speed Scale: 2
  • Time > Explosiveness: 1
  • Time > Lifetime Random: 0.5

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f33323064623166322d666135342d373666632d303336632d3139323934363861346232332e706e67.png

パーティクルにアタッチするスクリプト

アタッチするスクリプト Particle.gd には以下のように記述しました

Particle.gd
extends CPUParticles2D

func start(x, y, c):
    # 位置を設定
    position = Vector2(x, y)

    # 色を設定
    color = c

    # 放出開始
    emitting = true

func _ready():
    pass # Replace with function body.

func _process(delta):
    if emitting == false:
        # 放出が終了
        queue_free()

パーティクルを呼び出すスクリプト

Enemy.gd からのパーティクル呼び出しは、以下のようにしました

  • Particle.tscn をプリロードする
  • destroy() 関数を作成する
  • 敵破壊時に destroy() を呼び出す
Enemy.gd
extends Area2D

# パーティクルをプリロード
var Particle = preload("res://Scenes/Particle.tscn")

func destroy():
    var p = Particle.instance()
    p.start(position.x, position.y, Color.green)
    var main_node = get_parent()
    main_node.add_child(p)
    queue_free()

敵が消滅するときに、このパーティクルが生成されます
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f34333737322f39653032636532342d613239322d663534322d623739382d3461646461633830396161362e676966.gif

2021.2.22 追記

2Dパーティクルについてさらに調べてみました
【Godot】2Dパーティクルの使い方

リンク

さらに調査したことをまとめてみました


24
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
17