【Godot】Godotノードのライフサイクル入門:_readyが呼ばれる順番を確かめて、初期値が消えない渡し方にする

作成: 2026-09-22最終更新: 2026-10-10

ノードの_init・_enter_tree・_ready・_exit_treeが呼ばれる順番を出力で確かめ、外から渡した初期値が消えない書き方を解説します。

ノードに初期化の処理を書くときは、その関数がいつ呼ばれるかを知っておく必要があります。Godot のノードは、生まれる・ツリーに入る・準備が終わる・ツリーから出る、という4つの節目を決まった順番で通ります。呼ばれる関数は _init()・_enter_tree()・_ready()・_exit_tree() です。

順番を知らないまま書くと、食い違いが起きます。たとえば、instantiate() でHPを30にしてから add_child() したのに、画面には100と出ます。エラーは出ないので、原因が分かりにくい問題です。

この記事では、4つの節目がいつ呼ばれるかを出力パネルで確かめ、外から渡した初期値が消えない書き方までを解説します。

1つのノードが生まれ、ツリーに入り、準備を終え、ツリーから出るまでが、横一列の時間軸に並んでいる

この記事でわかること

  • 4つの節目(_init()・_enter_tree()・_ready()・_exit_tree())と呼ばれる順番
  • 親と子のどちらが先に _ready() を終えるか
  • @onready の変数に値が入るタイミング
  • add_child() で足したノードの準備が進むタイミングと、初期値が消えない渡し方
  • 実践:3層のノードの順番を出力で確かめ、消えるHPを直す

動作確認環境: Godot 4.7

Sponsored

4つの節目

ノードは作った瞬間から動き出すわけではありません。ここでいうツリーは、実行中に並んでいるノードの階層(シーンツリー)のことです。ノードはそこへ入り、出ていくまでに、次の4つの節目をこの順に通ります。

生まれた瞬間の_init、ツリーに入った_enter_tree、子がそろった_ready、毎フレームの_process、出る_exit_treeが並ぶ
  • _init(): オブジェクトが生まれた瞬間。まだツリーの外にいる
  • _enter_tree(): ツリーに入った直後。子はぶら下がっているが、子自身の番はまだ
  • _ready(): 自分より下のノードが全員そろったところ。通常は一度きり
  • _exit_tree(): ツリーから出るとき

図の _process() は、準備が終わってから毎フレーム呼ばれる処理です。この記事では節目とは分けて扱います。

_init() だけは、ツリーとは関係なく呼ばれます。instantiate() した時点で、まだどこにもぶら下がっていなくても走ります。このとき、インスペクタで設定した値はまだ入っていません。@export(変数をインスペクタに出す書き方)を付けた変数を読んでも、宣言に書いた初期値のままです。

他のエンジンでいうと: Godot の _ready() は、開始時の準備を書くという点で Unity の Start() や UE の BeginPlay に近い役割です。呼び出される条件と順序は違うので、Unity の Awake() と一対一に対応させないでください。

親と子の順番

ここがいちばん直感と食い違います。ツリーの上にいる親のほうが、準備を終えるのは後です。

3層ツリーでの順番の図。_enter_treeは親から子、子から孫へと上から下に降りる矢印、_readyは孫から子、子から親へと下から上に昇る矢印で描かれている

_enter_tree() は上から降りてきます。親が入り、次に子、その次に孫です。_ready() は逆に下から昇ります。孫、子、親の順です。

この向きには理由があります。_ready() は、自分より下のノードが全部そろったときに呼ばれます。孫の準備が終わるまで、子の _ready() は呼ばれません。だから親の _ready() の中では、子も孫もすでに使える状態になっています。

_exit_tree() も子が先です。子が出てから、親が出ます。

@onready に値が入るタイミング

@onready は、変数の代入をそのノードの _ready() の直前まで遅らせる書き方です。$HpLabel は、自分の子を名前で取る書き方です。

extends Node2D

@onready var label: Label = $HpLabel   # 代入は _ready() の直前
@onreadyの変数は、_enter_treeの中では空のままで、_readyの直前に値が入り、_readyの中から使える

だから最初の _enter_tree() の中で label を読むと、まだ何も入っていません。同じノードを _enter_tree() で使いたいときは $HpLabel と直接書けば取れます。

親子をまたぐときも同じです。子の _ready() は親より先なので、そこから親の @onready 変数を読んでも、まだ空です。

add_child() で足したときの順番

instantiate() は、シーンからノードの実体を作る関数です。この時点ではまだツリーの外にいます。すでにツリーにいる親へ、まだ一度も準備していないノードを足すと、add_child() を呼んだその行で準備まで進みます。

var e := enemy_scene.instantiate()   # ここで _init。_ready はまだ
print("追加の直前")
add_child(e)                          # この行の中で _enter_tree と _ready が走る
print("追加の直後")
追加の直前と追加の直後の2枚のカードのあいだに、子の_enter_treeと_readyが挟まっている

出力は「追加の直前」「子の _enter_tree」「子の _ready」「追加の直後」の順です。add_child() が返ってきた時点で、その子はもう動き始めています。

起動したときからシーンに置いてあったノードは、シーンがツリーに入るときにまとめて準備が進みます。実行中に add_child() したノードだけが、その行の中で準備を終えます。エディタで組んだ敵のシーンでも、実行中に instantiate() して足せば後者です。

Sponsored

初期値が消えない渡し方

上の順番を知らないまま書くと、渡した値が消えます。instantiate() のあとで e.hp = 30 と渡し、それから add_child() したとします。子の _ready() は、add_child() の中で走ります。子の _ready() に hp = max_hp と書いてあれば、渡した30はそこで100に上書きされます。エラーは出ません。

hpに30を渡しても、add_childの中で走った子の_readyがmax_hpの100を書き込み、追加の直後は100になっている

直し方は、初期値を決める場所を1か所に寄せることです。宣言か @export で決め、_ready() はその値を読んで見た目に反映する側にします。

# enemy.gd
@export var hp: int = 100

func _ready() -> void:
	label.text = str(hp)   # 読むだけ。書き換えない

こうしておけば、add_child() の前に渡した30がそのまま残り、ラベルにも30と出ます。シーンに置いた子へ、子の _ready() より先に渡したいときは、親の _enter_tree() から $Child に書き込みます。この時点では、子はまだ自分の _enter_tree() を通っていません。

取り外し(remove_child)と削除(queue_free)

ツリーから外すことと、消してしまうことは違います。

remove_childはノードをツリーの横に置いて生かしたまま、queue_freeはフレームの終わりにノードそのものを消す

remove_child() はツリーから外すだけで、ノード自体は生きています。変数で持っていればあとで付け直せます。queue_free() はノードを破棄します。呼んだその場では消えず、そのフレームの終わりの片付けで消えます。

外して付け直したときに走るのは _enter_tree() だけで、_ready() と @onready の代入は走りません。

もう一度 _ready() を通したいときは、付け直す前に request_ready() を呼んでおきます。付け直すノードごとに呼ぶ必要があり、子孫には自動では効きません。

入退場のたびにやり直したい処理は、はじめから _enter_tree() に書いて、後始末を _exit_tree() に書きます。

Sponsored

実践:3層の順番を出力で確かめ、消えるHPを直す

タワーディフェンスの砲台とその子UI、RPGのパーティと各メンバー、シューティングの敵と装備——親が子を抱える形はジャンルを問わず出てきます。順番を一度自分の目で見ておくと、どこに初期化を書くかで迷わなくなります。

① 置くもの

新しいシーンを1つ作り、main.tscn として保存してメインシーンに指定します。

Main (Node2D)                 main.gd
└── Parent (Node2D)           lifecycle.gd   Who = 親
    └── Child (Node2D)        lifecycle.gd   Who = 子
        └── Grandchild (Node2D)  lifecycle.gd   Who = 孫

別ファイルで敵のシーンも作ります。ルートは Node2D で名前は Enemy、子に Label を1つ置いて HpLabel にし、res://enemy.tscn として保存します。

② 4つの節目に print を置く

lifecycle.gd は3つのノードで共用します。Who はインスペクタで「親」「子」「孫」と入れてください。

# lifecycle.gd
extends Node2D

@export var who: String = "?"

func _init() -> void:
	print("_init: who は ", who)   # インスペクタの値はまだ入っていない

func _enter_tree() -> void:
	print(who, " _enter_tree")

func _ready() -> void:
	print(who, " _ready")

func _exit_tree() -> void:
	print(who, " _exit_tree")
# enemy.gd(enemy.tscn のルートに付ける)
extends Node2D

@export var hp: int = 100
@export var max_hp: int = 100

@onready var label: Label = $HpLabel

func _ready() -> void:
	hp = max_hp            # この1行が、外から渡した値を消す
	label.text = str(hp)
# main.gd
extends Node2D

const EnemyScene := preload("res://enemy.tscn")

# ボタンなどの UI が使わなかった入力だけが、ここへ届く
func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_accept"):      # 最初から Space と Enter に割り当てられている
		_spawn_enemy()
	elif event.is_action_pressed("ui_cancel"):    # 最初から Esc に割り当てられている
		var p := get_node_or_null("Parent")   # 見つからなければ null。2回目の Esc でエラーにしない
		if p != null:
			p.queue_free()

func _spawn_enemy() -> void:
	var e := EnemyScene.instantiate()
	e.position = Vector2(300, 200)
	e.hp = 30                      # add_child より前に渡す
	print("追加の直前 hp = ", e.hp)
	add_child(e)
	print("追加の直後 hp = ", e.hp)

③ 動かして確かめる

F5 で実行します。見るのは3つです。

1つ目は、起動した直後の出力パネルです。この順で並びます。

_init: who は ?
_init: who は ?
_init: who は ?
親 _enter_tree
子 _enter_tree
孫 _enter_tree
孫 _ready
子 _ready
親 _ready

_init の3行が全部 ? なのは、インスペクタで入れた「親」「子」「孫」がまだ届いていないからです。そして _enter_tree は上から、_ready は下から並んでいます。

2つ目は Esc です。押すと Parent が消え、出るのも子から順です。

孫 _exit_tree
子 _exit_tree
親 _exit_tree

3つ目が消える30です。スペースキーを押してください。

追加の直前 hp = 30
追加の直後 hp = 100
直す前は追加の直後にhpが100へ変わるが、_readyのhp = max_hpの1行を消すと、追加の直後も30のまま残る

渡した30が、add_child() の1行のあいだに100へ変わっています。enemy.gd の hp = max_hp を消して保存し、いったん止めてから F5 で実行し直してください。前の敵が残っていると同じ場所に重なって見分けが付きません。実行したらスペースを押します。

追加の直前 hp = 30
追加の直後 hp = 30

HpLabel にも30と出ます。渡し方は最初から合っていて、消えた原因は _ready() の1行でした。

ポイントは2つです。

  • 初期値を書くのは1か所だけ: 宣言か @export で決めて、_ready() はその値を読んで見た目に反映する側にします。両方から書くと、どちらが勝つかがノードの作り方で変わります
  • 起動時からいるノードと、あとから足したノードは、準備が終わる時期が違う: 起動時にシーンにいる子はまとめて _ready() まで進みますが、実行中に add_child() した子はその行の中で進みます。シーンの組み立て方は シーンとノード入門 にあります

うまくいかないときは

症状原因直し方
出力の行頭が「?」のままで、親・子・孫にならない3つのノードの Who に値を入れていないParent・Child・Grandchild を1つずつ選び、インスペクタの Who に「親」「子」「孫」と入れる
スペースを押しても、追加の直前・直後の行が出ないmain.gd が Main に付いていない。またはゲームの画面が選ばれていないMain に main.gd を付ける。ゲームの画面を一度クリックしてから押す
スペースを押すと「Node not found: "HpLabel"」と出るenemy.tscn の Label の名前が HpLabel になっていないenemy.tscn を開き、Label の名前を HpLabel に変えて保存する
hp = max_hp を消したのに、追加の直後が100のままenemy.gd の変更が、実行中のゲームに入っていないenemy.gd を保存し、止めてから F5 で実行し直す

おまけ:先に知っておくと良いこと

  • _init() に引数を渡せるのはコードで作るときだけ: シーン経由の instantiate() では引数を渡せません。渡したい値は @export にするか、add_child() の前にプロパティへ入れます
  • オートロードは先に準備が終わる: オートロード(どのシーンでも常駐させるノード)の _ready() はメインシーンより先に走ります。だからオートロードの _ready() から、まだ存在しない現在のシーンは取れません
  • 親クラスの _ready() は自動では呼ばれない: 継承して _ready() を書くと、親クラスの _ready() は走りません。要るときは super()(継承元の同じ関数を呼ぶ書き方)を呼びます。@onready の代入だけは、書かなくても行われます
  • 次に読むもの: インスペクタの値がいつ届くかは @export入門 、掴む相手の選び方は ノード参照入門 、_process と _physics_process の使い分けは 処理の使い分け 、待つ書き方は awaitとコルーチン にあります

まとめ

  • 節目は4つ: 生まれる・入る・準備が終わる・出る。_init() の時点では、インスペクタの値はまだ無い
  • 呼ばれる向き: _enter_tree() は親から子へ、_ready() と _exit_tree() は子から親へ
  • @onready は _ready() の直前: _enter_tree() ではまだ空
  • add_child() で _ready() まで走る: 初期値は1か所、_ready() は読む側
  • 付け直しても _ready() は走らない: 戻したいときは request_ready()

print を4行足して一度実行してみてください。出力パネルに並ぶ順番が、頭の中の順番と合っていますか。

次に読む学習パスの次の記事
GDScript

_processと_physics_processの正しい使い分け

この記事を読む
Godot このセクションのノート104