mcpField
G
gdscript-advanced

gdscript-advanced

github.com/jame581開発889181 回インストール4 日前に更新
リポジトリ

gdscript-advanced について

GDScriptの基礎を習得し、製品版ゲームを開発するGodot開発者向けの応用スキルです。性能、非同期処理、エディタースクリプト、動的呼び出しと、見落としやすい問題を解説します。

主な機能

  1. 01性能の慣用表現とProfilerによる調査
  2. 02Callableの動的利用と安全上の注意
  3. 03@toolエディタースクリプトのライフサイクル
  4. 04awaitとシグナルの非同期処理の落とし穴
  5. 05SignalとCallableの使い分け

活用シーン

  1. 01毎フレーム実行されるGDScriptの最適化
  2. 02エディタープレビューやリソース生成スクリプトの作成
  3. 03シグナル待ちで止まるコルーチンの調査
  4. 04動的呼び出しや解放済みオブジェクトのエラー回避

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jame581/godotprompter --skill gdscript-advanced

インストール後、Claude Code で/gdscript-advancedを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
metaprogramming-recipes.md
profiler-recipes.md
tool-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つです。

  1. エディター専用のロジックが誤ってゲーム実行時に動作する
  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)について、記載された落とし穴を監査します

コメント