gdscript-advanced について
GDScriptの基礎を習得し、製品版ゲームを開発するGodot開発者向けの応用スキルです。性能、非同期処理、エディタースクリプト、動的呼び出しと、見落としやすい問題を解説します。
主な機能
- 01性能の慣用表現とProfilerによる調査
- 02Callableの動的利用と安全上の注意
- 03@toolエディタースクリプトのライフサイクル
- 04awaitとシグナルの非同期処理の落とし穴
- 05SignalとCallableの使い分け
活用シーン
- 01毎フレーム実行されるGDScriptの最適化
- 02エディタープレビューやリソース生成スクリプトの作成
- 03シグナル待ちで止まるコルーチンの調査
- 04動的呼び出しや解放済みオブジェクトのエラー回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jame581/ godotprompter --skill gdscript-advancedインストール後、Claude Code で/gdscript-advancedを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/metaprogramming-recipes.mdprofiler-recipes.mdtool-script-recipes.md詳細
GDScript 上級編
製品レベルの GDScript の深掘り — 言語を学ぶためではなく、ゲームをリリースするための内容です。基本を学ぶには gdscript-patterns と併せてご覧ください。
関連スキル: 言語の基本については gdscript-patterns、エンジン側のパフォーマンス改善については godot-optimization、実行時の問題診断については godot-debugging、C# の代替手段については csharp-godot をご覧ください。
意図: このスキルは設計上 GDScript のみを対象としています(許可リスト方式)。C# ユーザーは
csharp-godotをご覧ください。ここに C# 対応を加えると、対象ユーザーの区分が損なわれます。
1. 上級 GDScript を使う場面
次のような状況にあるなら、gdscript-patterns の基本を卒業しています。
- プロファイラーでボトルネックを見つけ、どの書き方が高速なのか把握する必要がある
- エディターツールを作成しており、
@toolのライフサイクルを正しく扱う必要がある - コルーチンが再開されない、または
Callableのライフタイムに関するバグが発生している - 落とし穴を避けながら、メタプログラミング(名前による関数呼び出し、動的ディスパッチ)を行う必要がある
- 実際にゲームをリリースするにあたり、一見問題なさそうでも負荷がかかると破綻するパターンを避けたい
このスキルでは、型付きパラメーター、@onready、await、match、ラムダ(gdscript-patterns で解説)をすでに理解していることを前提としています。
2. パフォーマンス向上のための書き方
static 変数とメソッド(static 変数は Godot 4.1 以降)を使うと、インスタンスごとのオーバーヘッドを回避できます。
class_name Tally extends Node
static var _global_score: int = 0
static func add_score(amount: int) -> void:
_global_score += amount
static func get_score() -> int:
return _global_score
クラスの static メソッドで済む場合は、シングルトンをオートロードとして使うのは避けてください。
Vector2i と Vector2、Vector3i と Vector3 の使い分け — 整数ベクトルを使うと、タイル座標やグリッド計算で誤差が生じません(浮動小数点の誤差がなく、Dictionary のキーとして使用できます)。速度向上を期待する前に、計測してください。描画の境界でのみ浮動小数点に変換します。
var grid_pos: Vector2i = Vector2i(8, 12) # cheap
var world_pos: Vector2 = Vector2(grid_pos) * TILE_SIZE # convert at boundary
汎用の Array より PackedArray* — PackedInt32Array、PackedFloat32Array、PackedVector2Array などは、連続したメモリを確保し、Variant のボックス化を省略します。バッファー、頂点配列、ホットループの累積値に使用してください。
var positions: PackedVector3Array = PackedVector3Array()
positions.resize(1000) # one allocation
for i in 1000:
positions[i] = Vector3(i, 0, 0)
型付き Dictionary へのアクセス — 型付き辞書(Godot 4.4 以降)は、書き込み時にキーと値の型を検証し、読み取り時にはアナライザーが静的型を適用します(値は引き続き Variant として格納されます)。
var stats: Dictionary[String, int] = {}
stats["hp"] = 100 # type-checked; stats["hp"] is statically an int
is_instance_valid と null チェックの使い分け — is_instance_valid() はエンジン側の検索を行いますが、!= null はポインターを比較します。@onready による割り当ての後は != null を優先し、参照を保持したままノードが queue_free される可能性がある場合に限り is_instance_valid() を使ってください。
よくある落とし穴:毎フレーム、多数の対象に対して
_process内でif is_instance_valid(target)を実行すること。呼び出しごとにオブジェクトデータベースを検索するため、1 回あたりの負荷は小さくても、回数が増えると負荷が積み重なります。
3. メタプログラミング
Callable.bind、Callable.call、Callable.call_deferred を使うと、Object.call(name) のセキュリティリスクを避けながら動的ディスパッチを行えます。
引数のバインド:
var greeter: Callable = print_named.bind("Player")
greeter.call() # prints "Hello, Player"
func print_named(name: String) -> void:
print("Hello, %s" % name)
遅延呼び出し — 現在のフレームの終わり(アイドル時間)に実行されます。スレッド間の処理やシグナルの大量発生への対策に役立ちます。
heavy_recompute.call_deferred()
Object.set / Object.get / Object.has_method — 真に動的なコード(スクリプトの再読み込み、MOD 対応)に使用します。
if obj.has_method("on_damaged"):
obj.call("on_damaged", 25)
セキュリティ上の注意点:
user_stringがセーブファイル、ネットワーク、または MOD のコンテンツに由来する場合、許可リストなしにobj.call(user_string, ...)を渡してはいけません。データからオブジェクトを削除できるcall("free")のような呼び出しが可能になります。既知の集合と照合してください。
const ALLOWED_RPCS: PackedStringArray = ["take_damage", "apply_buff", "set_position"]
if user_method in ALLOWED_RPCS and obj.has_method(user_method):
obj.call(user_method, args)
Callable のパターンと MOD のセキュリティモデルの詳細は、references/metaprogramming-recipes.md をご覧ください。
4. @tool のライフサイクル
@tool スクリプトはエディター内だけでなく、ゲーム実行中にも動作します。主な失敗パターンは次の2つです。
- エディター専用のロジックが誤ってゲーム実行時に動作する
- ゲーム実行時のロジックが誤ってエディター内で動作し、エディターがクラッシュする
ガード:
@tool
extends Node
func _ready() -> void:
if Engine.is_editor_hint():
_setup_editor_preview()
else:
_setup_game_runtime()
エディター通知 — エディターのライフサイクルイベント(NOTIFICATION_EDITOR_PRE_SAVE、NOTIFICATION_EDITOR_POST_SAVE)には _notification を使用します。
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
_bake_preview()
よくある落とし穴:シーンの読み込み中に
get_tree()を呼び出す@toolセッターです。エクスポートされた値はノードがツリーに入る前に設定されるため、get_tree()はエラーとともにnullを返します。is_inside_tree()でガードしてください。
エディタープレビュー、ベイキング、プロシージャルメッシュ生成など、
@toolのパターン全般については references/tool-script-recipes.md を参照してください。
5. 非同期処理の落とし穴
await は関数を一時停止し、シグナルが発火するまで制御を呼び出し元に戻します。落とし穴は3つのパターンに分かれます。
落とし穴 1 — _ready 内の await は早期に復帰するため、ノードの初期化が完了する前に準備完了として扱われます。
# BAD: the first await returns control, so `ready` is emitted and the parent's
# _ready() runs while `inventory` is still empty
func _ready() -> void:
await get_tree().create_timer(1.0).timeout
inventory = load_inventory()
対処法:最初の await より前に、ほかのノードが準備完了時に参照するすべての処理を完了させます。セットアップの一部で実際に待機が必要な場合は、is_initialized フラグを設定し、完了時に initialized シグナルを発火します。すでに発火したシグナルを待っても再開されないため(落とし穴 2)、依存する側は待機する前にフラグを確認します。
落とし穴 2 — 発火しないシグナルを待つ と、コルーチンは永久に一時停止します。
# BAD if `health_changed` never fires (e.g., entity already at full HP)
await health.health_changed
対処法:待機する前に前提条件を確認します。待機が必要な場合は、シグナルとタイムアウトを競合させます。リリース済みの Godot には Signal.any() も、複数のシグナルを同時に待つほかの組み込み方法もありません(godot-proposals#13597 ではグローバルな any()/all() が提案されています)。そのため、両方のシグナルを自分で用意した1つのシグナルに集約します。
signal _health_wait_finished(changed: bool)
func wait_for_health_change(timeout_sec: float) -> bool:
var timer := get_tree().create_timer(timeout_sec)
var on_changed := func(_hp: int) -> void: _health_wait_finished.emit(true)
var on_timeout := func() -> void: _health_wait_finished.emit(false)
health.health_changed.connect(on_changed)
timer.timeout.connect(on_timeout)
var changed: bool = await _health_wait_finished
if is_instance_valid(health): # freed while we waited (see Trap 3)
health.health_changed.disconnect(on_changed)
timer.timeout.disconnect(on_timeout) # or a stale timer ends the next wait early
return changed
ラムダがローカルフラグを設定するのではなくシグナルを発火するのは、GDScript のラムダがローカル変数を値渡しでキャプチャするためです。ラムダ内で changed = true としても、外側の変数には反映されません。_health_wait_finished を待つすべての待機処理は、最初の発火時に再開されるため、ノードごとに同時に実行する待機処理は1つにしてください。
落とし穴 3 — 待機中にオブジェクトが解放される。 コルーチンを実行しているノードが解放されると、コルーチンは通知なく破棄されます。エラーは発生せず、await の後の処理(クリーンアップや finished の発火)も実行されません。ノードが残っていても、参照先が解放された場合は、再開後にその参照に触れると「previously freed」エラーが発生します。await のたびに参照を再検証してください。
func flash(target: Node2D) -> void:
target.modulate = Color.RED
await get_tree().create_timer(0.2).timeout
if not is_instance_valid(target): # freed while we waited
return
target.modulate = Color.WHITE
6. Signal と Callable の設計上の選択
Signal — 多対多、疎結合、エッジトリガー型です。接続リストの検索により、発火ごとにわずかなオーバーヘッドが発生します。
Callable — 1対1、明示的、レベルトリガー型です。呼び出しごとのコストは低いものの、結合度は高くなります。
次の用途にはシグナルを使用します。
- システム間イベント(player_died、item_collected、level_complete)
- ゲームプレイからの UI 更新
- リスナーが0からN個まで変動するのが一般的なもの
次の用途には Callable を使用します。
- 戦略の注入(ソート比較関数、述語関数)
- 遅延作業のスケジューリング(
call_deferred) - Tween メソッド(
tween_methodは Callable を受け取ります)
よくある落とし穴:
selfを使用しないラムダはノードではなくスクリプトに属するため、ノードを解放しても切断されません。キャプチャした値を保持したまま、次の発火時にも実行されます。_exit_treeで明示的に切断するか、代わりにバインド済みメソッドを使用してください。
7. プロファイラー主導のイディオム
デバッガー → プロファイラーパネルを開きます。よく見られるパターンは次のとおりです。
| プロファイラーのホットスポット | 考えられる原因 | 対処法 |
|---|---|---|
_process 内での String の割り当て | フレームごとの print() / "%s" % var | ループの外で事前にフォーマットするか、循環バッファーでログをまとめます |
Node.get_node のセルフタイムが長い | フレームごとの $Path/Sub/Node の繰り返し | @onready var にキャッシュします |
Signal.emit の呼び出し回数が多い | フレームごとにシグナルが大量に発生する(例:位置の更新) | 10 Hz に制限するか、ポーリング方式を使います |
CharacterBody2D/3D.move_and_slide のセルフタイム | 1 フレームに多くのキャラクターボディがある | カメラからの距離に応じて負荷を調整し、軽量な検出には Area を使います |
| 割り当てによるフレーム時間の急増(GDScript に GC はなく、参照カウントによって解放されます) | 一時的な Array/Strings によるアロケーターの負荷 | 配列をプールするか、起動時に事前割り当てします |
各行の変更前と変更後を注釈付きで示した例については、references/profiler-recipes.md を参照してください。
8. よくある落とし穴
ラムダはローカル変数を値でキャプチャします — ラムダの作成時に一度だけキャプチャします。そのため、ループ内のラムダはそれぞれ独自の i を保持し、bind は不要です。反対に、キャプチャしたローカル変数を代入すると、そのラムダのコピーだけが変更され、次の呼び出しではキャプチャした値から再び始まります。
var count := 0
var bump := func() -> int:
count += 1 # CONFUSABLE_CAPTURE_REASSIGNMENT warning
return count
bump.call() # 1
bump.call() # 1 again, and `count` out here is still 0
対処法:共有状態はメンバー変数か参照型に保持します。Array、Dictionary、オブジェクトは同じインスタンスとしてキャプチャされます。
var state := {"count": 0}
var bump := func() -> int:
state.count += 1
return state.count # 1, then 2
@onready の実行順序 — @onready 変数は _init の後、_ready の前に設定されます。子の _ready は親の _ready より先に実行されます。そのため、次の点に注意してください。
- 親が初期化済みであることを確信できない場合、子の
_readyで親の状態を参照しないでください。 - ノード間のセットアップでは、親自身の
_readyからchild.setup_with(self)を呼び出す方法を推奨します。
シーンの再読み込み時の static var のライフサイクル — クラスの static var は、スクリプトがロードされている間(通常は実行中ずっと)保持されます。参照されていないスクリプトの変数は @static_unload で解放できます。シーン単位で保持されるわけではありません。シーンを再読み込みしても static var はリセットされません。シーンごとのシングルトンが必要な場合は、static var ではなくオートロードを使ってください。
リソース共有による予期しない動作 — 2 つのシーンで同じ Resource アセットを使う @export var item: ItemData は、参照によって状態を共有します。一方を変更すると、もう一方も変更されます。インスタンスごとに独自の状態が必要な場合は、item.duplicate() を使ってください。
Packed 配列のプロパティセッターは要素への書き込みでは呼び出されません
⚠️ Godot 4.7 での変更点: Packed 配列プロパティの要素(例:
obj.packed_prop[i] = x)を設定しても、Packed 配列プロパティ全体のセッターは呼び出されなくなりました。要素ごとの書き込みでセッターが呼び出されることに依存していたコードは、気付かないまま動作しなくなります。セッターを呼び出すには、配列全体を再代入してください。詳しくは4.7 移行ガイドを参照してください。
var points: PackedVector2Array:
set(value):
points = value
_rebuild_mesh()
func move_point() -> void:
points[0] = Vector2.ONE # 4.6: setter (and _rebuild_mesh) ran; 4.7+: it does NOT
var updated := points # fix: modify a copy...
updated[0] = Vector2.ONE
points = updated # ...then reassign — the setter fires
Godot 4.7 以降: 新しい
CONFUSABLE_TEMPORARY_MODIFICATION警告は、一時値(破棄される値)の変更に対して警告します。たとえば、組み込みのPacked*Arrayプロパティを複雑な代入チェーンやconstではないメソッド呼び出しで変更した場合、変更されるのは一時コピーだけで、プロパティの値は古いままになることがあります。この警告はdebug/gdscript/warnings/confusable_temporary_modificationで制御します(デフォルトは1、警告)。
実装チェックリスト
- 適用するパフォーマンス手法(型付きベクター、PackedArray、静的メソッド)を特定します
- メタプログラミングを使用する場合は、動的メソッド名をすべて許可リストに登録します
-
@toolを使用する場合は、Engine.is_editor_hint()でエディターと実行時の分岐をガードします - 発火しない可能性のあるシグナル、セットアップが完了する前に戻る
_ready、待機中に解放される参照について、await呼び出しを監査します - トレードオフ表に従ってシグナルと Callable のどちらを使うか選び、ラムダは
_exit_treeで切断します - 最適化の前にプロファイリングを行い、ホットスポットをセクション 7 の表に照らし合わせます
- キャプチャしたローカル変数に代入するラムダ(変更がラムダの外に反映されません)、
@onreadyの順序、静的変数のライフサイクル、Resource の共有、PackedArray のプロパティセッター(Godot 4.7)について、記載された落とし穴を監査します