私が趣味で開発している主要開発ツールが Godot です。これはノードベースの開発ツールで、最初は少々クセを感じるかもしれないですが、一度慣れてしまうととんでもなく開発効率が良くて、現在の私のお気に入りツールとなっています。さて、本ブログでは漢の浪漫と称して、Z80 や C# で弾幕の実装を説明してきました。ならば!現在のお気に入りツールである Godot での解説はもはや人生のマスト事項であると考え、ここに記事を発表いたします。
- 基本的な考え方
今まではリスト構造を使用するため、それぞれに次のオブジェクトへ繋ぐためのポインタを付けてきました。ところが Godot ではそんな面倒なことは不要なのです。とはいえ、流石に弾を発射するたびにインスタンスして弾を毎回消していたのでは、実行速度が気になります。だから、未使用リストを用意する考え方は全く一緒です。ただ Godot ではリスト配列にする必要がないのです。
これは、考え方として一度画面内に生まれ落ちたノードは、自分自身で動いていき、自分で画面端まで到達したら、親に対して自分は役目は終わったよーと通知して、自分で勝手に終了する、そんな作り方にしてしまえば良いためです。だから動かすために、リストを追いかけて座標を更新するなどという手間は一切必要ありません。やろうと思えば C# でも出来たかもですが、親への通知の仕組みが Godot は秀逸なのと、Godot の配列は標準で追加と削除が簡単なので、本当にいろいろ手間いらずなのです。
今回のサンプルでは、画面範囲外に出たら親に対して終わったよーと通知するだけですが、参考として引数も受け渡すようにはしておきましたので、ここに例えば当たった相手の識別子とか受け渡すことで、攻撃があたったよー等と通知することも可能となります。それ以外の考え方は C# と同じにしてあります。
※ソースコードのバックアップ用には外付けUSB-HDDが最もお手軽。私も使っています。- 弾のシーンを用意する
弾はそれ一つ一つ独立したノード(オブジェクト)となります。そのため、シーンで弾自体を用意します。予めファイルシステムには、画像などを入れる assets、シーンを入れる scenes、スクリプトを入れる scripts フォルダを作っておきます。

Godot では、ゲーム内のあらゆる要素はノードで構成され、それらをまとめたものがシーンになります。ノードは「部品」、シーンは「組み立て済」だと考えてください。

Godot では、ゲーム内のあらゆる要素はノードで構成され、それらをまとめたものがシーンになります。ノードは「部品」、シーンは「組み立て済」だと考えてください。

scenes に bullet.tscn を Node2D で作成します。空っぽの Bullet がシーンとして作成されます。出来たシーンを右クリックして型を変更を選択します。検索で Spr と入れて表示される Sprite2D を選択して変更ボタンを押します。

これでシーンのノードはスプライトになりました。弾の画像を assets にコピーしてから、インスペクターの Texture にその画像を設定してください。

これでシーンのノードはスプライトになりました。弾の画像を assets にコピーしてから、インスペクターの Texture にその画像を設定してください。

続いてスクリプトです。Bullet シーンのインスペクター一番下にある Script <空> を左クリックして新規スクリプト...を選択します。そのまま作ると scenes フォルダにスクリプトファイルが作られます。折角スクリプトフォルダを作りましたから、パスをそちらに変更してください。最後に作成ボタンを押します。空っぽのスクリプトファイルが作成されます。

- 弾のスクリプトを作る
弾の実装にあたり、最初に簡単に設計を考えます。簡単に言えば、どんな風に使いたいかを考えます。先ほど空きリストに入れて、必要に応じて使っていくと説明しましたので、少なくとも待機と動作の状態があります。移動していきますから最初の位置指定と移動方向と速度は使う側から指定してほしいです。弾が画面外に出たら待機状態に変化して、終わったよーと、呼び出し元に通知します。
この動作を実現するためには内部に移動に関する情報が必要です。また、これから動くよーと指定されるための関数が必要です。start() が良いかなと。継続して動き続けるための仕組みと、画面外に出たときの待機とお知らせも必要です。お知らせは Godot ではシグナルという便利な機能が用意されているので、それを使いましょう(後述)。継続処理は Node2D 系に用意されている _process() を使うと良さそうです。これでなんとなく全体の設計イメージが掴めました。
さて最初に extends Sprite2D の下に class_name Bullet 書きます。これはこのスクリプトが Bullet というクラス名を持つという宣言です。これを書いておくと、後々便利です(後述)。
extends Sprite2D class_name Bullet
弾は当たり前ですが移動します。移動するには、現在の位置、移動する方向、移動速度が情報として必要です。Sprite2D ノードには現在の位置を示す global_position というプロパティがあります。これは画面上の絶対位置を示しています。position もありますが、今回は説明を端折るために global_position で指定します。
移動に必要な方向と速度は、移動ベクトルを用意すれば単純な足し算で移動が実現できます。そのため、変数としては Vector2 を一つだけ用意しておくだけです。
var dir:Vector2 = Vector2.ZERO #移動方向と速度
お知らせ用にシグナルを設定する変数も必要です。呼び出し元に自分のノードと汎用で使用できそうな value を引数として用意したいなと、こんな感じで記述してみます。
signal bullet_finished(node, value) #移動終了時のお知らせシグナル
画面範囲外の範囲のために画面全体のサイズ情報が必要ですね。これは定数で用意すればよいでしょう。そうそう大きさが変わるものでもないと思いますし…
const SCREEN_SIZE:Vector2i = Vector2i(640, 400) const SCR_RANGE:Rect2 = Rect2(-8, -8, SCREEN_SIZE.x + 16, SCREEN_SIZE.y + 16)
SCR_RANGE は画面サイズより上下左右に +8 ドット広げています。もし、ぴったりサイズで画面範囲内判定を行うと、弾がまだ半分見えている状態で画面外と判定されて、パッと消えてしまいます。この +8ドットで弾が完全に画面外に消えてから、初めて画面外として判定されるので、自然に画面から消えるようになります。
これでプロパティ系の準備は完了です。続いて関数の実装です。まずインスタンスした直後に、ノードを見えなくします。Node.PROCESS_MODE_DISABLED はこのノードの動きを止める指定です。待機中に動いてもらっては遅くなるだけなので止めてしまいます。
#初期化 func _init() -> void: _reset() #弾の状態をリセットする func _reset() -> void: hide() dir = Vector2.ZERO process_mode = Node.PROCESS_MODE_DISABLED
次に弾の発射です。start という関数を自分で追加します。引数は方向、速度、初期位置です。Godot には便利な rotated メソッドが用意されています。Vector2.RIGHT で右側を 0度として一周が2πのラジアン角です。2πは Godot では TAU 定数で定義済みなので利用すると良いです。Vector2.RIGHT.rotated(rad) * speed で移動ベクトルの初期化が完了します。最後に Node.PROCESS_MODE_INHERIT として動きを再開させます。
#弾を発射する func start(rad:float, speed:float, pos:Vector2) -> void: global_position = pos dir = Vector2.RIGHT.rotated(rad) * speed show() process_mode = Node.PROCESS_MODE_INHERIT
最後に実際に動かす処理です。
#経過処理 func _process(_delta: float) -> void: if dir == Vector2.ZERO: return global_position += dir * _delta if SCR_RANGE.has_point(global_position): return emit_signal("bullet_finished", self, -1) _reset()
移動ベクトルがゼロなら待機状態と分かるので動かさずに終了します。動作中なら座標を移動ベクトル方向に、経過時間分移動させます。この移動の結果、まだ画面範囲内にいるのであれば処理を終了します。画面範囲外なら、emit_signal で親に終了を通知してリセットします。emit_signal は、第1引数が呼び出し先情報が入ってる変数名、続く引数が自分で指定した内容となります。self は自分自身のノード、-1 は value に入ります。※ 今回は使っていません。
たったこれだけです。
※やっぱり最低でもベクトルの知識は欲しいので、理解が怪しい人はぜひこれで勉強してみてほしいなー- ステージを用意する
次のゲームのステージとなるシーンを追加します。シーンの追加方法は弾のシーン追加とほぼ同じです。但し、インスペクター/Sprite2D/Centered のチェックは外してください、背景画像は左上準拠のほうが理解しやすいためです。

スクリプトも追加します。私はなんとなくスクリプト名を main.gd に変えてしまいました。これに関しては任意で全く問題ありません。また、今回は説明用のサンプルなので、この main.gd 内で直接弾幕の発射制御を行います。
#定数 const BULLET = preload("res://scenes/Bullet.tscn") #弾シーンの読み込み const MAX_BULLETS = 1000 #同時存在最大数 const FIRE_SPEED:float = 250 #弾速 const FIRE_WAIT:float = 0.04 #弾幕発射の時間間隔 const FIRE_NEXT_ANGLE:float = 48 #次の弾発射の差分角度 const FIRE_SEGMENTS:int = 5 #同時発射弾幕数 const FIRE_POSITION:Vector2 = Vector2(320, 200) #発射位置
弾幕を発生させるのに必要な定数を定義しました。preload は指定のシーンをメモリに事前に読み込んでいます。これでインスタンスの実行速度が速くなります。MAX_BULLETS は画面内に同時に存在する最大数です。これで指定した数だけ、事前にインスタンスした実態を未使用配列に格納して待機させます。この 1000 という数が適切かどうかは最後に確認します。あとは、コメントの通りです。
#プロパティ var empty:Array = [] #空きリスト var bullets:Array = [] #使用中リスト var timer:float = 0 #発射間隔タイマー var fire_angle:float = 0 #発射初期方向 var max_bullet:int = 0
これが弾幕処理に必要なプロパティ群です。実は使用中のオブジェクトを登録する配列 bullets 配列がなくても動作します。ですが、これを用意しておくことで、途中で弾を全て消したい時や、現在発生している弾数を瞬時に判別できたりするので、使用中配列は用意しています。順番が前後しましたが、empty は未使用オブジェクトの格納配列です。max_bullet は弾幕同時最大数を保存します。弾幕処理に直接関係はないんですが、これを用意した理由は最後に説明します。
続いて初期化です。初期化は _init() ではなく _ready() に記述しました。理由は弾のオブジェクトを自分自身の子として接続するためには、自分が動作可能になっている _ready() で記述する必要があるためです。
※ GDScript の _init() はコンストラクタだと思ってください。
#初期化 func _ready() -> void: var win = get_window() win.content_scale_size = Vector2i(640, 400)
これは表示スケールを指定しています。詳しくは Godot 初期設定のページを参照してください。Godot の初期設定は必要です。表示がおかしい場合は、こちらを参照して設定してください。
for i in range(MAX_BULLETS): var blt = BULLET.instantiate() blt.connect("bullet_finished", Callable(self, "_on_bullet_signal")) add_child(blt) empty.append(blt)
これは弾を MAX_BULLETS の数だけ実体化して未使用配列に追加する処理です。BULLET.instantiate() で実体化しています。このタイミングで、bullet.gd の _init() が走ります。続いて connect でシグナルの接続を行っています。弾の利用が終わったことを親へ通知するために connect で文字通り情報を繋いでいます。この設定があるので、呼び出し側は弾の利用終了を知ることが出来ます。つまり弾の再利用(オブジェクトプール)を成立させる鍵になります。
シグナルはスクリプト間で情報のやり取りを行う重要な機能です。シグナル発信対象の signal 変数に対して、受信側のオブジェクトと関数名を指定します。これで、発信側が emit_signal することで、受信側に情報が届く仕組みになっています。最後に add_child して背景画像の子として登録の後に、未使用配列に append で追加しています。
Godot の配列は動的配列なので、宣言時に個数を指定する必要がありません。いつでも append で要素を追加できてしまうのです。いやはや便利ですよねー。
#弾終了シグナル受信 func _on_bullet_signal(_node, _value): bullets.erase(_node) #使用中リストから削除 empty.append(_node) #空きリストに追加
シグナル受信関数です。やってることは単純明快で、動作中配列から終了した弾を削除して、その弾を未使用配列に戻しているだけです。配列なのに、こうやって指定できるのが本当に便利なんです。
最後に弾幕を撃ちまくる処理です。これはだいたい指定の FPS で毎回呼び出されてくる _process に記述します。
#経過処理 func _process(_delta: float) -> void: if max_bullet < bullets.size(): max_bullet = bullets.size() print("同時存在最大数:", max_bullet)
さて、いきなり弾幕処理と直接関係ないコードがあります。やってることは現在発生している弾の数が記録より多かった時は、記録を塗り替えてエディターの出力に結果を表示しているだけです。これにより、実際にはどれくらい弾数を事前に用意すればよいかの確認ができます。これが例えば「同時存在最大数:150」と表示されるのであれば、念の為 200とか 300を用意すれば大丈夫だと判断できます。私は最初に MAX_BULLETS = 1000 と定数定義していましたが、全然多すぎました。これを必要最小限にすることで、メモリに余裕が出来て動作の安定性に繋がる…かもしれない期待…いや、たぶんコレくらいの差なら、あまり関係ないとは思うんだけどさ。こういうのってチリツモだろー(滝汗
timer += _delta if timer < FIRE_WAIT: return
経過時間測定です。_delta は前回の呼び出しからの経過時間が単位が秒で入っています。その値を timer に加算することで、前回弾を発射してからの経過時間が判別できるのです。この時間が弾の発射タイミングである FIRE_WAIT より小さかったら、まだ弾を撃つタイミングではないので終了しています。
timer -= FIRE_WAIT
経過時間からタイミング時間を引いてます。timer = 0.0 だと、もしかすると微妙にタイミングが揺らぐかもしれません。その揺らぎの可能性を吸収するために、このような減算をしています。減算処理の結果、FPS タイミングの揺らぎの超過時間分を、次の弾発射呼び出しに反映できるようになります。
for i in range(FIRE_SEGMENTS): var rad = fire_angle + i * TAU / FIRE_SEGMENTS var blt = empty.pop_back() as Bullet if not blt: break blt.start(rad, FIRE_SPEED, FIRE_POSITION) bullets.append(blt)
弾幕発射部分です。for により i の値は 0 から FIRE_SEGMENTS - 1 までループします。FIRE_SEGMENTS が 5 なら、そのまま 5回ループすることになります。rad は弾を発射する方向です。fire_angle は弾を発射する開始方向です。0 から TAU の間の角度が入っています。それを基本軸として、一周を FIRE_SEGMENTS 分割した方向になるよう rad を求めています。角度には一周を超えた値が入りますが、受け取り側の計算で使用している Vector2.RIGHT.rotated() はそれくらいは何の問題もなく受け取れます。
var blt = empty.pop_back() as Bullet で未使用の弾オブジェクトを受け取っています。受取時に as Bullet としているのは型指定です。class_name Bullet と bullet.gd に記述しましたが、それが型(クラス)の名前になります。これで、独自に追加した関数のコード補完が出来るようになります。簡単に言うと便利に使えるようになるのですw
さて、empty.pop_back() で未使用の弾オブジェクトを受け取りましたが、もしかするともう空きがなくなっているかもしれません。受け取った blt が null ならもう空きがないので、ループを中断して抜けています。取得した弾に発射角度、速度、位置情報を与えます。これでもう動き始めます。最後に使用中リストに発射した弾を登録して、同時発射の全弾を処理します。
fire_angle = fmod(fire_angle + TAU / FIRE_NEXT_ANGLE, TAU)
見た目を面白くするために、最初の発射角度である fire_angle を発射する度にズラしています。この角度に関してはドンドン積み重なるととんでもなく大きな値になってしまう可能性もあるので、浮動小数の剰余算計算 fmod で一周の範囲に収めるようにしています。
これでこんな弾幕が出来ます。

- 調整
この手の弾幕処理は、一発でカッコいい表示ができる事は珍しくて、だいたいは調整でカッコよく仕上げていきます。このサンプルでも次の数値を変更することで、いろんな弾幕を作り出すことが出来ます。
const FIRE_SPEED:float = 250
値を小さくするとトロトロと避けやすく、大きくするとめっちゃ速い動きになったりします。速すぎると避けられないですし遅すぎるとイライラしますが、まあ、ゲームバランス調整の一つではあります。
const FIRE_WAIT:float = 0.04
弾幕発射の時間間隔です。数値を小さくすると繋がって表示される…ように思うかもですが、実は弾幕の回転速度が上がります。理由は一発弾を撃つ事に発射基準角を更新しているので、タイミングが早くなればなるほど角度を変更する時間が短くなるためです。
const FIRE_NEXT_ANGLE:float = 48
その角度を調節する定数です。数値を大きくすると角度の変化が緩やかになります。1 にすると回転が止まります。マイナスの値にすると逆回転します。なお、この値を 0 にすると動作がおかしくなります。所謂ゼロ除算エラーが内部で発生するためです。
const FIRE_SEGMENTS:int = 5
この数を増やすと周囲に一気にまとめて弾を射出します。増やしすぎると大変なことになるかも汗
const FIRE_POSITION:Vector2 = Vector2(320, 200)
発射位置はサンプルでは固定なので定数定義としてありますが、実際には動いている敵から射出するので、この定数定義は最終的に不要になると思います。定数を変更するとどんな表現ができるのか、如何にいくつか試した結果もお見せします。
const MAX_BULLETS = 1000 #同時存在最大数 const FIRE_SPEED:float = 500 #弾速 const FIRE_WAIT:float = 0.01 #弾幕発射の時間間隔 const FIRE_NEXT_ANGLE:float = 100 #次の弾発射の差分角度 const FIRE_SEGMENTS:int = 5 #同時発射弾幕数

const MAX_BULLETS = 2000 #同時存在最大数 const FIRE_SPEED:float = 250 #弾速 const FIRE_WAIT:float = 0.04 #弾幕発射の時間間隔 const FIRE_NEXT_ANGLE:float = 1 #次の弾発射の差分角度 const FIRE_SEGMENTS:int = 64 #同時発射弾幕数

const MAX_BULLETS = 1000 #同時存在最大数 const FIRE_SPEED:float = 250 #弾速 const FIRE_WAIT:float = 0.25 #弾幕発射の時間間隔 const FIRE_NEXT_ANGLE:float = 1 #次の弾発射の差分角度 const FIRE_SEGMENTS:int = 180 #同時発射弾幕数
ロジテックダイレクト
2025.12.01 12:00




コメント