このチュートリアルでは、2Dトップダウンシューティングゲームの「弾幕」を作っていく。弾幕というのは、敵キャラクターから放たれる大量の弾(またはそれに類似する遠距離攻撃)のことで、弾が幕のように隙間なく飛んでくるので「弾幕」という。プレイヤーはその隙間を縫うようにうまくかわしながら、敵キャラクターを射撃して倒していくゲームを弾幕シューティングゲームという。単に弾幕をかわすことに特化したゲームもある。イメージに合う宇宙船や戦闘機をモチーフにしたゲームが多いのも特徴だ(今回は魔法使いとモンスターの地上戦だが)。

このチュートリアルでは、弾幕の作成のみにフォーカスする。また、弾幕も様々な形状があるが、今回取り扱うのは回転式の弾幕だ。

Environment
このチュートリアルは以下の環境で作成しました。

Godot のバージョン: 3.4.2
コンピュータのOS: macOS 11.6.5


このチュートリアルのプロジェクトファイルは GitHubリポジトリ に置いている。.zipファイルをダウンロードしていただき、「Start」フォルダの中の「project.godot」ファイルを Godot Engine にインポートしていただければ、下準備だけ完了したプロジェクトから開始できる。また、取り急ぎ完成形を確認されたい場合は「End」フォルダの方の「project.godot」ファイルをインポートしていただければOKだ。

なお、今回プロジェクトにインポートしてあるアセットは全て KENNEY(ケニー) のサイトからダウンロードさせていただいた。今回利用したのは 1-Bit Pack というアセットパックだ。この素晴らしすぎる無料の素材に感謝せずにはいられない。


さて、Godot Engine で「Start」フォルダの方のプロジェクトを開くと、あらかじめ以下は作成済みの状態になっている。

  1. プレイヤーキャラクター(魔法使い)
  2. プレイヤーキャラクターの遠距離攻撃(魔法)
  3. 敵キャラクター(モンスター、今回はこれに弾幕を放たせる)
  4. ゲームワールド(上記のオブジェクトが存在するゲームの世界)

まずは一度、下準備だけ完了している「Start」フォルダの方のプロジェクトを実行してどのような状態か確認してみてほしい。
プロジェクトを実行

ゲームの設定は以下のようになっている。

  • プレイヤーキャラクターは以下のキーで移動操作と魔法の遠距離攻撃が可能。
    • move_up: W キー・・・プレイヤーキャラクターが上に移動する
    • move_down: S キー・・・プレイヤーキャラクターが下に移動する
    • move_right: D キー・・・プレイヤーキャラクターが右に移動する
    • move_left: A キー・・・プレイヤーキャラクターが左に移動する
    • magic: スペースキー、またはマウス左ボタン・・・魔法を放つ
  • プレイヤーキャラクターは敵キャラクターの弾が10回ヒットしたら死ぬ。
  • プレイヤーキャラクターが死んだらゲームオーバー(自動的にデバッグパネルが閉じる)。
  • 敵キャラクターは現時点では移動のみ可能。
  • 敵キャラクターは2秒おきにプレイヤーキャラクターの方へ向かってくる。
  • 敵キャラクターは魔法が5回ヒットすると死ぬ。
  • 敵キャラクターに魔法がヒットして赤白点滅している間は魔法が効かない。
  • 敵キャラクターが死ぬと、次の敵キャラクターがプレイヤーキャラクターから半径50px以内に出現する。
  • 敵キャラクターの見た目は毎回ランダムで変わる
  • プレイヤーキャラクターの魔法は放たれてから1秒後に消える。
  • ゲームの世界は無制限に移動できる。


弾を作る

弾幕を作るにはそれを構成する一つひとつの弾が必要だ。ということで、まずは弾のシーンから作成していく。


シーンを作成する

以下の手順で弾のシーンを新規で作成する。

  1. 「シーン」メニュー>「新規シーン」を選択する。
  2. 「ルートノードを生成」にて「その他のノード」を選択する。
  3. 「Area2D」クラスのノードをルートノードとして選択。
  4. ルートノード「Area2D」の名前を「Bullet」に変更する。
  5. 一旦ここでシーンを保存しておく。ファイルパスを「res://Enemy/Bullet.tscn」とする。

シーンにノードを追加する

次にルートノード「Bullet」に子ノードを追加していく。

  1. ルートノード「Bullet」に「Sprite」クラスのノードを追加する。これは弾の見た目だ。
  2. ルートノード「Bullet」に「CollisionShape2D」クラスのノードを追加する。これは弾が物理ボディと衝突したことを検知するために利用する。
  3. ルートノード「Bullet」に「VisibilityNotifier2D」クラスのノードを追加する。これは弾が画面外に出たことを検知するために利用する。

公式オンラインドキュメント:
VisibilityNotifier2D

シーンツリーは以下のようになったはずだ。
Bullet Scene Tree


ノードのプロパティを編集する

続けてそれぞれのノードのプロパティを編集していく。

Bullet(Area2D)ノード

このルートノードのプロパティの編集は不要。

Sprite ノード

今回は、たくさんのテクスチャをまとめた1枚のスプライトシートから使いたいテクスチャの範囲を指定してスプライトのテクスチャを設定する方法を採用する。

  1. インスペクターにて、「Sprite」ノードの「Texture」プロパティにリソースファイル「res://colored-transparent_packed.png」を適用する。
    Texture property of Sprite
  2. 「Region」>「Enabled」をオンにする。
    Region>Enabled property of Sprite
  3. エディタ下部の「テクスチャ領域」パネルを開き、スプライトシートの中の利用したいテクスチャの領域を指定する。
    Region pannel
    1. まず、見やすいようにパネルをエディタ最上部まで広げたら、スプライトシートを見やすい大きさまで拡大する。
    2. 「テクスチャ領域」パネル上部の「snapモード」で「グリッドスナップ」を選択する。
      Region pannel > choose grid snap
    3. 同じくパネル上部の「ステップ」を 16px 16px にする。これでグリッドのサイズがスプライトシートのスプライトと同じサイズになる。
      Region pannel > input grid step
    4. スプライトシート上で弾に適用したいテクスチャを選択する。このチュートリアルではドクロのテクスチャを選択した。モンスターがドクロの弾幕を放ってくるというのが、なんとも恐怖である。
      Region pannel Select region
  4. インスペクターに戻り、「Visibility」>「Modulate」プロパティで色をお好みの弾の色に変更する。ここではサンプルとしてやや不気味な紫系の色 #9da4d8 にした。
    Visibility>Modulate property

CollisionShape2D ノード

このノードで弾のコリジョン形状を設定する。弾とプレイヤーキャラクターとの衝突判定のために必須であり、システム上「Area2D」クラスのノードにコリジョン形状を設定する子ノードが追加されていないとアラートが表示される。

  1. インスペクターにて、「Shape」プロパティに「新規CircleShape2D」リソースを適用する。
    Shape property
  2. 2Dワークスペースにて、コリジョン形状を「Sprite」ノードのテクスチャのサイズに合わせる。
    CollisionShape in 2D workspace
    インスペクターで直接入力する場合は、「CircleShape2D」リソースの「Radius」プロパティを 8 にする。
    Visibility>Modulate property

VisibilityNotifier2D ノード

このノードのプロパティの編集は不要。


スクリプトで弾を制御する

次はスクリプトをプログラミングして、弾を制御する。ルートノード「Bullet」に新しいスクリプトをアタッチしよう。ファイルパスを「res://Enemy/Bullet.gd」として作成する。

まずは以下のようにスクリプトを編集しよう。

###Bullet.gd###
extends Node2D

# 弾の秒速
export var speed = 150

# 物理プロセス(60回/秒呼ばれるメソッド)
func _physics_process(delta):
	# 現在の弾の位置に現在の弾の方向×秒速×1フレームの時間..
	# を加算して毎フレーム弾を移動させる
	position += transform.x * speed * delta

次に、ルートノード「Bullet」は Area2D クラスなので、このシグナルを利用して、物理ボディに衝突したら弾が消えるようにする。さっそくインスペクターで「Bullet」を選択し、ノードドック>シグナルタブでシグナル「body_entered(body)」をこの「Bullet.gd」スクリプトに接続しよう。
connect signal body_entered

シグナルを接続して生成されたメソッド_on_Bullet_body_entered内でメソッドqueue_freeを実行する。

###Bullet.gd###
# 物理ボディが衝突したら発信されるシグナルで呼ばれるメソッド
func _on_Bullet_body_entered(body):
	# Bullet を解放する
	queue_free()

同様に、「VisibilityNotifier2D」ノードのシグナルを利用して、画面外に出たら弾が消えるようにする。「VisibilityNotifier2D」のシグナル「screen_exited()」をこの「Bullet.gd」スクリプトに接続しよう。
connect signal screen_exited

シグナルを接続して生成されたメソッド_on_VisibilityNotifier2D_screen_exitedの中にメソッドqueue_freeを実行する。

###Bullet.gd###
# 画面外に出たら発信されるシグナルで呼ばれるメソッド
func _on_VisibilityNotifier2D_screen_exited():
	# Bullet を解放する
	queue_free()

これで弾の完成だ。このあとは、作成した弾シーンのインスタンスを敵キャラクターのシーンツリーに追加する形で、敵キャラクターが弾を放てるようにしていく。



弾幕を作る

スクリプトで弾幕を制御する

ここからは、敵キャラクターの「Enemy.tscn」シーンのルートノード「Enemy」にアタッチしているスクリプト「Enemy.gd」を編集して、弾幕を制御していく。スクリプトエディタで「Enemy.gd」を開いたら、まずは必要なプロパティを定義しよう。以下のコードの中の「# 追加」とコメントしている箇所のコードを追加してほしい。

###Enemy.gd###
extends KinematicBody2D

signal died

# 追加:プリロードしたBullet.tscnシーンファイルの参照
const bullet_scn = preload("res://Enemy/Bullet.tscn")

var enemy_life = 5
var enemy_speed = 800
var delta_count = 0

# 追加:Enemyの中心から弾の発射位置までの距離
var radius = 20
# 追加:Enemyを中心とした弾の発射位置の回転速度
export var rotate_speed = 40
# 追加:弾を発射する間隔(秒)
export var interval = 0.4
# 追加:一度に発射する弾の数
export var spawning_count = 4

コード内のコメントだけでは少しイメージしにくいかもしれないので、図をつけておく。
figure

ご覧の通り、「Enemy」を中心とし、プロパティradiusの値を半径とした円をイメージするとわかりやすい。プロパティspawning_countで指定した一度に発射される弾のうち、1つ目の発射位置を必ず(x: radius, y: 0)として、2つ目以降の弾はそこからこの円の円周上に等間隔(角度差)で配置されるようにする。それらの弾の発射位置をプロパティintervalで指定した秒数ごとに、時計回りにプロパティrotate_speed分だけずらしては発射、ずらしては発射することで弾幕が生成される仕組みだ。


続いて、シーンが読み込まれた時に最初に呼ばれる_readyメソッドを編集する。メソッド内に、弾幕生成に必要な初期化を行うためのコードを追加する。上の図をイメージしながら確認いただくとわかりやすいはずだ。

###Enemy.gd###
func _ready():
	anim_player.play("spawn")
	
	randomize()
	sprite.frame = randi() % sprite.hframes
	
	# 以下全て追加:
	# 一度に発射する弾の間隔(角度差)を step として定義する
	# step は 180° x 2 = 360° を プロパティSpawning_countの値で割った値
	var step = PI * 2 / spawning_count
	# spawning_count の値の数だけループ(spawning_count が 4 の場合は i に 0 ~ 3 が順に入る)
	for i in spawning_count:
		# 弾の発射位置の目印として使う Node2Dノードを新規作成し、それを spawn_point として定義する
		var spawn_point = Node2D.new()
		# 発射位置を pos として定義する
		# pos は基準位置(x: radius, y: 0)から(step x i)だけ回転した位置とする
		var pos = Vector2(radius, 0).rotated(step * i)
		# spawn_pointを弾の発射位置に配置する
		spawn_point.position = pos
		# spawn_pointの向きを正のx軸から発射位置までの角度に合わせる
		spawn_point.rotation = pos.angle()
		# spawn_point を回転するためのノードとして予め用意してある Rotater ノード(Node2D)の子にする
		rotater.add_child(spawn_point)

	# Timer ノードの wait_time プロパティを interval プロパティの値でセット
	timer.wait_time = interval
	# AnimationPlayerノードのアニメーションが終わるまで待機
	yield(anim_player, "animation_finished")
	# Timerノードのタイマーを開始する
	timer.start()

あとは「Timer」ノードがタイムアウトするたびに、先に作った「Bullet.tscn」シーンのインスタンスを「Rotater」ノードの子ノード(上のコードのspawn_point)と同じ位置に配置してあげれば、自動的に弾が飛んでいくようになるはずだ。では「Timer」ノードの「timeout」シグナルで呼ばれる_on_Timer_timeoutメソッドを編集しよう。下準備にてシグナルはすでに接続済みなので、メソッド内のpassを以下の内容で置き換えてほしい。

###Enemy.gd###
# Timer ノードの timeout シグナルで呼ばれるメソッド
func _on_Timer_timeout():
	# Rotaterノードの子ノードに対してループ処理
	for node2d in rotater.get_children():
		# Bullet.tscn のインスタンス
		var bullet = bullet_scn.instance()
		# Bullet インスタンスノードを Enemy ノードではなく、その親ノード(Worldノード)の子にする
		get_parent().add_child(bullet)
		# Bullet インスタンスの位置を Rotater の子ノードの位置と同じにする
		bullet.position = node2d.global_position
		# Bullet インスタンスの方向を Rotater の子ノードの方向と同じにする
		bullet.rotation = node2d.global_rotation

発射位置は変わらないが、ひとまずこれで指定した弾の数だけ指定した時間差で発射されるはずだ。プロジェクトを実行してみよう。
run project


次は弾の発射位置を少しずつ回転させて、より弾幕らしくしていこう。今度は_physics_processメソッドに少しコードを更新する。以下のコードの「# 追加」とコメントしている箇所を追加してほしい。

###Enemy.gd###
func _physics_process(delta):
	delta_count += delta
	if delta_count > 2:
		delta_count = 0
		if get_parent().has_node("Player"):
			anim_player.stop()
			anim_player.play("move")
			var direction = global_position.direction_to(get_parent().get_node("Player").global_position)
			var velocity = direction * enemy_speed
			velocity = move_and_slide(velocity)
			
	# 追加:次の方向(角度)を new_rotation として定義
	# new_rotation は Rotater ノードの現在の方向(角度)+ rotate_speed x 1フレームの時間とする
	var new_rotation = rotater.rotation + rotate_speed * delta
	# 追加:new_rotation を 360 で割った余り(角度)だけ Rotater ノードを回転する
	rotater.rotate(fmod(new_rotation, 360))

これで弾の発射位置がintervalプロパティに指定している0.4秒ごとに回転するはずだ。期待通りの挙動になるか、プロジェクトを再度実行して確認してみよう。
run project


弾幕をカスタマイズする

スクリプト内で定義したプロパティも、最初にexportキーワードをつけていればインスペクターで手軽に値を編集可能だ。値を変更して、さっきのとは異なる弾幕を作成してみよう。
run project


  • サンプル1

    • Rotate Speed: 45
    • Interval: 0.5
    • Spawning Count: 10
      run project
  • サンプル2

    • Rotate Speed: 10
    • Interval: 0.1
    • Spawning Count: 8
      run project

サンプル2の方は、かなり鬼畜なゲームになってしまった。しかし、なんともスリリングで楽しい。


ランダム性を追加する

下準備時点で、敵キャラクターの見た目(スプライトのテクスチャ)は、6種類からランダムで決まるようにコーディングしている。弾幕のプロパティもランダムで決まるようにすれば、毎回どんな弾幕が放たれるのか予想できないので、面白いかもしれない。実はここまでに記述したスクリプトに少しコードを足すだけで、意外と簡単に実装できるのだ。

ではスクリプトエディタで「Enemy.gd」スクリプトを開いてほしい。まずは、すでに用意している弾幕を形成するための各プロパティの上限値と下限値を別のプロパティで定義する。以下のコードの「# 追加」および「# 変更」とコメントしている行を更新しよう。

###Enemy.gd###
var radius = 20
export var rotate_speed: float # 変更:型だけ定義して値を未定義にする
export var max_rotate_speed = 80 # 追加
export var min_rotate_speed = 20 # 追加
export var interval: float # 変更:型だけ定義して値を未定義にする
export var max_interval = 0.8 # 追加
export var min_interval = 0.2 # 追加
export var spawning_count: int # 変更:型だけ定義して値を未定義にする
export var max_spawning_count = 10 # 追加
export var min_spawning_count = 2 # 追加

次に、シーンが読み込まれたタイミングで各プロパティにランダムな値を適用する必要があるので、_readyメソッドを編集する。「# 追加」のコメントがある箇所が更新箇所だ。

func _ready():
	anim_player.play("spawn")
	
	# ランダム値生成を有効にする
	randomize()
	sprite.frame = randi() % sprite.hframes # int, max: 5
	
	# 追加:min_rotate_speed を下限、max_rotate_speed を上限にしたランダム小数を rotate_speed に代入
	rotate_speed = rand_range(min_rotate_speed, max_rotate_speed)
	# 追加:min_interval を下限、max_interval を上限にしたランダム小数を interval に代入
	interval = rand_range(min_interval, max_interval)
	# 追加:この後の計算で上限値が指定した値より -1 されるので先に +1 して調整
	max_spawning_count += 1
	# 追加:min_spawning_count を下限、max_spawning_count を上限にしたランダム整数を spawning_count に代入
	spawning_count = randi() % max_spawning_count + min_spawning_count
	
	var step = PI * 2 / spawning_count
	for i in spawning_count:
		var spawn_point = Node2D.new()
		var pos = Vector2(radius, 0).rotated(step * i)
		spawn_point.position = pos
		spawn_point.rotation = pos.angle()
		rotater.add_child(spawn_point)

	timer.wait_time = interval
	yield(anim_player, "animation_finished")
	timer.start()

少しランダム値の生成について補足しておく。

まずrand_rangeはその第一引数を下限、第二引数を上限としたランダムな小数を返す。そのため、型を小数で定義しているプロパティrotate_speedintervalで利用した。

次にrandiはランダムな整数を返すメソッドだが、引数を取らないため、上限や下限を指定できない。そこで、返された値を上限値で割った時の余りが 0 以上かつ (上限値 -1) 以下 になることを利用する(最大値が指定した上限値 -1 なので、1行前のコードmax_spawning_count += 1で +1 している)。%(モジュロ) の記号を利用すれば a % bab で割った時の余りが求められる。さらにその値に c を足して a % b + c とすれば、c より小さな値にはならない。つまり上限と下限のあるランダムな整数を求める場合は randi() % 上限値 + 下限値 で表すことができる。ただし返される値の最大値が上限値 -1 であることを覚えておこう。

なお、今回は下準備にてrandomizeメソッドを先に記述しておいたが、これがないと毎回同じ結果になるので、ランダム値を返すメソッドを利用する場合は必ず_readyメソッドの冒頭に記述することも覚えておくと良いだろう。

では最後に、出現するモンスターごとに異なる弾幕になるか、プロジェクトを実行して確認してみよう。
run project



おわりに

今回のチュートリアルでは、2Dトップダウンシューティングゲームの弾幕を作った。今回のような回転式の弾幕の場合、以下がポイントになるだろう。

  • 円の半径、発射位置の回転速度、発射の時間差、同時発射弾数をプロパティでセット
  • 回転用のノードを用意しておく
  • 発射位置を円の半径と同時発射弾数から計算
  • 回転用ノードに発射位置の目印用の子ノードを追加する
  • タイマーの時間を発射の時間差にセット
  • 物理プロセスにて常に回転用ノードを回転速度に合わせて回転させる
  • タイマーがタイムアウトしたら弾のインスタンスを目印用ノードの位置に生成する

他にも実装方法はあるはずなので、いろいろ試して良いものを最終的に採用していただければと思う。

また、実際の弾幕シューティングゲームでは、今回のような回転式のものばかりではなく、波型、扇型など、多様だ。気になる場合はぜひ調べてみてほしい。



リンク


UPDATE
2022/05/25 キー操作を追加