プレイヤーがHPを持ち、HUDがHPバーを表示し、インベントリがアイテムを使い、アイテムがプレイヤーを回復する。ここまでは自然です。
しかし実装しているうちに、Player が HUD を直接触り、HUD も Player を参照し、ItemData が Player を覚え、Inventory も ItemData を持つ、という状態になりがちです。動いている間は問題に見えま せんが、UIを差し替えた瞬間に落ちる、別シーンでは参照が見つからない、どこでHPが変わったか追えない、という形で苦しくなります。
この記事では、循環参照 と 依存関係の絡まり を分けて整理します。メモリ管理の話だけでなく、初心者〜中級者が実際のゲーム制作で詰まりやすい「どのノードがどのノードを知っていてよいのか」を中心に扱います。
この記事でわかること
- 循環参照と循環依存の違い
NodeとRefCountedで問題の出方が違う理由- 直接呼び出し、シグナル、Autoload、WeakRefの使い分け
- アイテム、HUD、ステータス効果を安全に設計する考え方
まず「参照」と「依存」を分ける
参照 は、あるオブジェクトが別のオブジェクトを変数として持っている状態です。
var player: Player
依存 は、あるコードが別のコードの存在やメソッド名を知っている状態です。
player.heal(20)
循環が問題になるのは、単に変数があるからではありません。AがBを知り、BもAを知り、さらに互いのメソッドを呼び合うようになると、変更に弱くなります。
Player -> HUD
HUD -> Player
この形だと、Player は HUD の存在を前提にします。HUDがないテストシーンや、ボス戦専用UIに差し替えたシーンで壊れやすくなります。さらにHUD側もPlayerを前提にしているため、「どちらを先に用意するか」という初期化順の問題も出ます。
依存関係を整理するときは、まず次の問いを置きます。
このオブジェクトは、本当に相手の存在を知る必要があるのか?
NodeとRefCountedで何が違うのか
循環参照の話で混乱しやすいのは、Godotのオブジェクトがすべて同じ仕組みで解放されるわけではない点です。
| 種類 | 例 | 解放の考え方 | 循環で起きやすい問題 |
|---|---|---|---|
Node 系 | CharacterBody2D, Control, Node2D | シーンツリーから外す、queue_free() する | 参照切れ、初期化順、強い結合 |
RefCounted 系 | Resource, 独自の計算用クラス | 参照されなくなると解放される | 相互参照で解放されにくくなる |
言葉だけだとイメージしにくいので、2つの「解放のされ方」と「循環で起きる問題」を並べてみます。

左のNode系では、HUD を queue_free() で消すと、Player が握っていた参照は「もう存在しないノードを指したまま」になります(参照切れ)。参照カウントの輪は関係ありません。右のRefCounted系では、A と B が互いを参照し合うと、外部から誰も見ていなくても参照カウントが0にならず、いつまでも解放されません。 同じ「循環」でも、Node系は“壊れた参照”、RefCounted系は“消えないメモリ” と、問題の出方がまったく違うわけです。
Node は RefCounted ではありません。Player と HUD が互いを参照していても、それだけでRefCounted同士のような参照カウントの輪になるわけではありません。
ただし、Node同士の相互参照も安全という意味ではありません。片方が queue_free() されたあとに古い参照を使うと、無効なインスタンスを触ってしまいます。別シーンで片方だけ存在しない場合も壊れます。
一方、Resource や RefCounted を継承した独自クラスでは、参照カウントの輪が問題になります。AがBを持ち、BがAを持っていると、外部から見えなくなっても互いに参照し続けるため、解放されにくくなります。
基本ルール: 参照の向きを一方向にする
まずは難しい技術より、参照の向きを整理します。悪い例と良い例を並べてみましょう。

悪い例では、すべてのオブジェクトが互いを参照し合い、どこから手をつければいいか分からない網になっています。良い例のように「上位が下位を知る」「表示側がデータ元を見る」「データは利用者を覚えない」「通知したい側はシグナルを出す」と方向を決めるだけで、かなり読みやすくなります。
たとえばHP表示なら、Player が HUD を直接更新するより、Player はHP変更を通知し、HUD がそれを聞く形の方が壊れにくいです。
# 悪い例: PlayerがHUDの具体的なノード名まで知っている
@export var hud: HUD
func take_damage(amount: int) -> void:
health -= amount
hud.update_hp_bar(health, max_health)
このコードは短いですが、PlayerがHUDの存在を前提にしています。タイトル画面用のテストシーン、HUDが別名のシーン、オンライン用の別UIなどで壊れやすくなります。
解決策1: 通知はシグナルにする
「プレイヤーがダメージを受けた」ことを、HUD、効果音、クエスト管理など複数の仕組みが知りたい場合は シグナル が向いています。
シグナルは「相手に命令する」のではなく、「こういうことが起きた」と通知します。通知する側は、誰が聞いているかを知りません。シグナルの定義と接続の基本は シグナルによるノード間の連携 で詳しく扱っています。

# player.gd
class_name Player
extends CharacterBody2D
signal hp_changed(current_hp: int, max_hp: int)
@export var max_hp: int = 100
var hp: int = max_hp
func take_damage(amount: int) -> void:
hp = clamp(hp - amount, 0, max_hp)
# Playerは「HPが変わった」と通知するだけ
hp_changed.emit(hp, max_hp)
HUD側は、Playerのシグナルに接続して表示だけを担当します。
# hud.gd
class_name HUD
extends Control
@export var player: Player
@onready var hp_bar: ProgressBar = $HPBar
func _ready() -> void:
if player == null:
return
# HUD側がPlayerを観察する。PlayerはHUDを知らない
player.hp_changed.connect(_on_player_hp_changed)
_on_player_hp_changed(player.hp, player.max_hp)
func _on_player_hp_changed(current_hp: int, max_hp: int) -> void:
hp_bar.max_value = max_hp
hp_bar.value = current_hp
この形なら、Player は HUD のノード名、HPバーの種類、効果音の有無を知りません。HUDを別デザインに差し替えても、Player側のコードは変えずに済みます。
実装レシピ: アクションRPGのHP更新
アクションRPGでプレイヤーのHPを扱うなら、次のように分けると管理しやすいです。
Playerはtake_damage()とheal()でHPを変更する- HPが変わったら
hp_changedをemit()する HUDはHPバーを更新するためにシグナルを聞くSoundManagerは瀕死音や被弾音のために同じシグナルを聞くQuestManagerは「HP1で生き残る」実績のために同じシグナルを聞く
ポイントは、Playerが「HUDを更新する」「音を鳴らす」「実績を解除する」を全部背負わないことです。Playerの仕事はHPを正しく変えること、周辺システムの仕事はその通知を聞いて自分の表示や演出を変えることです。
解決策2: ItemDataはPlayerを覚えない
循環参照は、インベントリやアイテムでよく起きます。
Player -> Inventory -> ItemData
ItemData -> Player
たとえば「回復薬のItemDataが、使う相手としてPlayerを変数に持つ」設計にすると、ItemDataが特定のPlayerに結びついてしまいます。別のキャラクターが同じ回復薬を使う、敵がアイテムを使う、ショップでプレビューする、といった場面で使い回しにくくなります。

悪い例です。
# potion_data.gd
class_name PotionData
extends Resource
@export var heal_amount: int = 30
var owner: Player
func use() -> void:
# ItemDataが特定のPlayerを覚えている
owner.heal(heal_amount)
この形では、ItemDataが誰に使われるかを内部に持ってし まいます。アイテムデータは本来、「回復量はいくつか」「アイコンは何か」「説明文は何か」のような再利用できる情報に寄せた方が扱いやすいです。
良い例では、使う瞬間に利用者を引数で渡します。
# item_data.gd
class_name ItemData
extends Resource
@export var display_name: String
@export var heal_amount: int = 0
func use(user: Player) -> void:
if user == null:
return
# 使う相手は保存せず、その場で受け取る
user.heal(heal_amount)
# inventory.gd
class_name Inventory
extends Node
var items: Array[ItemData] = []
func use_item(index: int, user: Player) -> void:
if index < 0 or index >= items.size():
return
# Inventoryは「誰が使うか」をその場で渡す
items[index].use(user)
この形なら、ItemData は特定のPlayerを覚えません。プレイヤー、仲間、敵、テスト用キャラクターなど、使う相手をその場で渡せます。
実装レシピ: RPGのインベントリ
RPGのインベントリでは、次のように分けると循環しにくくなります。
| 役割 | 持つもの | 持たないもの |
|---|---|---|
ItemData | 名前、説明、回復量、価格 | 特定のPlayer参照 |
Inventory | 所持しているItemData一覧 | PlayerのHP処理の中身 |
Player | HP、MP、状態異常 | UIの具体的なノード |
InventoryUI | 選択表示、ボタン入力 | アイテム効果の計算 |
UIでアイテムを選んだら、Inventory.use_item(index, player) のように「使う相手」を渡します。ItemDataがPlayerを保持しないため、セーブデータ、ショップ、図鑑、戦闘中UIでも同じデータを使い回しやすくなります。
解決策3: RefCountedの輪はWeakRefで断つ
ここからが、メモリ管理としての循環参照です。
RefCounted は、参照されなくなると自動的に解放されます。便利ですが、RefCounted同士が互いを強く参照すると、外部から見えなくなっても参照カウントが0にならない状態が起きます。

たとえば、ステータス管理を RefCounted のクラスで作り、状態異常も RefCounted として持つ場合を考えます。
# character_stats.gd
class_name CharacterStats
extends RefCounted
var hp: int = 100
var effects: Array[StatusEffect] = []
func add_effect(effect: StatusEffect) -> void:
effects.append(effect)
# StatsはEffectを強く参照する
effect.owner_ref = weakref(self)
# status_effect.gd
class_name StatusEffect
extends RefCounted
var owner_ref: WeakRef
var damage_per_tick: int = 3
func apply_tick() -> void:
if owner_ref == null:
return
# 弱参照は使 うたびに取り出して、生存確認する
var owner := owner_ref.get_ref() as CharacterStats
if owner == null:
return
owner.hp -= damage_per_tick
weakref(self) で作った WeakRef は、参照カウントを増やしません。StatusEffect は「持ち主がまだ存在するなら触る」ことはできますが、持ち主を解放できなくするほど強く握りません。
WeakRefを使うときの注意
WeakRefは便利ですが、最初に選ぶ手段ではありません。多くの場合は、設計を一方向にする、シグナルにする、引数で渡す、という方法の方が読みやすいです。
WeakRefを使う場面は、次のように考えるとよいです。
RefCounted同士でどうしても相手を参照したい- 効果やタスクが、所有者の存在を確認しながら動く
- 相手が消えても、自分が安全に
nullとして扱いたい
WeakRefを使ったら、get_ref() の戻り値チェックを忘れないでください。弱参照は「相手が必ずいる」保証ではなく、「いたら取り出せる」仕組みです。
解決策4: シーンをまたぐ通知はAutoloadへ逃がす
シーンをまたぐ通知では、直接参照が特に絡まりやすくなります。
たとえば敵が倒れたときに、スコア、クエスト、効果音、実績が反応する場合を考えます。
Enemy -> ScoreManager
Enemy -> QuestManager
Enemy -> SoundManager
Enemy -> AchievementManager
この形でも動きますが、Enemyがゲーム全体の管理システムを知りすぎています。敵を別プロジェクトやテストシーンで使い回しにくくなります。
こうした「ゲーム全体のイベント」は、Autoloadのイベントバスへ寄せると整理できます。Autoload自体の作り方や注意点は Autoloadによるシーンをまたぐデータ管理 を参照してください。
# event_bus.gd (Autoload)
extends Node
signal enemy_defeated(enemy_id: String, position: Vector2, score: int)
# enemy.gd
extends CharacterBody2D
@export var enemy_id: String = "goblin"
@export var score_value: int = 100
func die() -> void:
# Enemyは「倒された」という事実だけを共有する
EventBus.enemy_defeated.emit(enemy_id, global_position, score_value)
queue_free()
# score_manager.gd
extends Node
var score: int = 0
func _ready() -> void:
EventBus.enemy_defeated.connect(_on_enemy_defeated)
func _on_enemy_defeated(enemy_id: String, position: Vector2, score_value: int) -> void:
# スコア側は、自分に必要な情報だけを使う
score += score_value
Autoloadを使うと、敵はScoreManagerの場所や名前を知りません。クエスト管理や実績管理も、同じ enemy_defeated を聞けば反応できます。
ただし、Autoloadを何でも屋にすると別の問題が起きます。EventBus は通知の通り道に留め、実際のスコア計算、クエスト進行、セーブ処理はそれぞれの担当クラスへ分ける方が安全です。
判断フロー: どの方法を選ぶか
参照が絡まりそうになったら、次の順で考えます。

| やりたいこと | 選びやすい方法 | 例 |
|---|---|---|
| 相手に具体的な命令をしたい | 直接呼び出し | ドアに open() を呼ぶ |
| 何かが起きたことを知らせたい | シグナル | hp_changed、died |
| シーンをまたいで複数システムへ知らせたい | Autoloadのイベントバス | enemy_defeated |
| RefCounted同士の輪を断ちたい | WeakRef | StatusEffect から CharacterStats を弱参照 |
| 一時的に相手が必要なだけ | 引数で渡す | item.use(player) |
持ち帰る判断基準はシンプルです。
「相手をずっと覚える必要があるのか? それとも、その瞬間だけ渡せばよいのか?」
この問いに答えるだけでも、循環参照の多くは避けられます。
よくあるつまずき
何でもシグナルにして追えなくなる
シグナルは便利ですが、すべてをシグナルにすると、どこで何が起きたか追いにくくなります。
ドアを開ける、ボタンを押す、特定の相手に攻撃する、のように相手が明確で命令に近い処理は、直接呼び出しでも問題ありません。シグナルが向いているのは、「誰が聞くかを発行側が知らなくてよい通知」です。
Autoloadに状態を詰め込みすぎる
Autoloadはどこからでも触れるため、便利な反面、依存関係を隠しやすいです。
Global.player_hp -= 10 のようなコードがあちこちに出ると、HPがどこで変わったか追えません。Autoloadに値を置く場合も、take_damage() や add_score() のような変更用メソッドを用意し、値変更の入口を絞ると原因調査が楽になります。
WeakRefで設計問題を隠す
WeakRefは循環を断つ道具ですが、依存方向そのものを整理してくれるわけではありません。
本当はItemDataがPlayerを覚えなくてよいのにWeakRefでPlayerを持たせる、という設計にすると、参照カウントの問題は避けられても、責務は絡まったままです。まず「そもそも相手を保持する必要があるか」を考えてください。
無効なNode参照をnullだと思い込む
Nodeが解放されたあと、変数が自動的に null になるとは限りません。古い参照を触る可能性がある場合は、is_instance_valid() で確認します。
if is_instance_valid(target):
# 参照先がまだ有効なときだけ触る
target.take_damage(10)
ただし、is_instance_valid() を大量に書く設計は、参照の持ち方が複雑になっているサインでもあります。必要以上に古いNode参照を保存しない設計を先に考えましょう。
実践:敵撃破からスコア・HUD・実績まで疎結合でつなぐ
最後に、ここまでの道具(シグナル と Autoloadのイベントバス)を、ひとつのゲームに組み込んでみます。題材は「敵を1体倒すと、いろいろなシステムが同時に反応する」という、どのジャンルにも出てくる場面です。
- ローグライク :撃破数がそのままスコアになり、HUDのスコア表示が増える
- ベルトスクロールアクション :連続撃破でコンボが伸び、効果音が派手になっていく
- タワーディフェンス :敵を倒すと報酬ゴールドが入り、次のタワー購入の原資になる
ジャンルは違っても、構造は同じです。「敵が倒れた」という 1つの事実 に、スコア・効果音・実績・HUDといった 複数のシステムがそれぞれ勝手に反応 します。ここで敵が各システムを直接呼び出すと、敵がゲーム全体を知りすぎて、テストシーンや別プロジェクトで使い回せなくなります。
そこで敵は、EventBus に「倒された」と一言つぶやくだけにします。

まず、通知の通り道になるAutoloadを1つ用意します。
# event_bus.gd (Autoload)
extends Node
# ゲーム全体の「出来事」を流す通り道
signal enemy_defeated(enemy_id: String, position: Vector2, score: int)
敵は「倒された」という事実だけを流します。スコアの計算も、音を鳴らすことも、敵自身はやりません 。
# enemy.gd
extends CharacterBody2D
@export var enemy_id: String = "slime"
@export var score_value: int = 50
func die() -> void:
# 敵は「倒された」とだけ通知する。誰が聞くかは知らない
EventBus.enemy_defeated.emit(enemy_id, global_position, score_value)
queue_free()
スコア担当は、イベントを聞いてスコアを更新し、 さらに自分のスコア変更をシグナルで通知 します。
# score_manager.gd (Autoload)
extends Node
signal score_changed(total: int)
var score: int = 0
func _ready() -> void:
EventBus.enemy_defeated.connect(_on_enemy_defeated)
func _on_enemy_defeated(_enemy_id: String, _position: Vector2, score_value: int) -> void:
score += score_value
# 今度は自分が「スコアが変わった」と通知する
score_changed.emit(score)
HUDは、ScoreManager のスコア変更だけを聞きます。敵の存在も、EventBus の中身も知りません。
# hud.gd
extends Control
@onready var score_label: Label = $ScoreLabel
func _ready() -> void:
ScoreManager.score_changed.connect(_on_score_changed)
_on_score_changed(ScoreManager.score)
func _on_score_changed(total: int) -> void:
# HUDはスコアの計算方法を知らず、表示だけを担当する
score_label.text = "SCORE: %d" % total
ポイントは2つです。
- 敵は「倒された」しか言わない :スコア加算・効果音・実績解除は、それぞれの担当が同じ
enemy_defeatedを勝手に拾います。ジャンルを変えて「コンボ演出」や「報酬ゴールド」を足したくなっても、聞く側を1クラス追加するだけで、敵のコードは1行も変わりません。 - 通知は二段 構えにする :
EventBusはゲーム全体の出来事を流す通り道、ScoreManagerは「スコアが変わった」という自分の状態変化をscore_changedで伝える担当、と役割を分けます。こうするとHUDはスコアの計算方法を知らずに済み、スコア表示を別デザインへ差し替えてもScoreManager側は無傷です。
抽象的な「疎結合」という言葉が、ここでようやく手触りのある形になります。敵・スコア・HUDが互いの中身を知らないので、片方を作り替えても連鎖的に壊れません。この設計は Autoloadによるシーンをまたぐデータ管理 と シグナルによるノード間の連携 の組み合わせそのものです。
おまけ: 先に知っておくと良いこと
親子関係では「子が親を直接操作する」を減らす
子ノードが親を探して直接操作するコードは、最初は簡単です。
get_parent().take_damage(10)
ただし、この子ノードを別の親の下へ移動すると壊れます。再利用したいコンポーネントなら、親が子のシグナルを聞く形にすると、子は親の具体的なクラスを知らずに済みます。
# hitbox.gd
extends Area2D
signal hit(damage: int)
@export var damage: int = 10
func _on_body_entered(body: Node2D) -> void:
# HitBoxは親のHP処理を知らず、命中だけを通知する
hit.emit(damage)
参照方向はレビューしやすい形にする
依存関係は、バグが出てから探すと大変です。コードレビューや自分の見直しでは、次をチェックすると早いです。
- UIやサウ ンドがゲームロジックから直接触られていないか
Resourceがシーン上の特定Nodeを保持していないか- Autoloadの値をどこからでも直接変更していないか
get_node("../../..")のような遠い参照が増えていないか
このチェックは、ゲームが小さいうちから役立ちます。参照方向が見えるコードは、後から機能を足すときに安心して触れます。
まとめ
- 循環参照 は、参照の矢印が輪になる状態
NodeとRefCountedでは、循環で起きる問題が違う- 通知は シグナル にすると、発行側が受信側を知らずに済む
- アイテムや効果は、相手を保持せず 引数で渡す だけで済むことが多い
RefCounted同士の輪が避けられない場合は WeakRef を使う- シーンをまたぐ共通イベントは Autoload のイベントバスで整理できるが、何でも屋化には注意する
依存関係管理の目的は、メモリリークだけを避けることではありません。UIを差し替えても壊れない、テストシーンでも動く、どこで値が変わったか追える、そういう「後から直しやすいゲーム」にすることです。
さらに学ぶために
関連するノート記事:
- シグナルによるノード間の連携 ——通知を一方向にする土台
- Autoloadによるシーンをまたぐデータ管理 ——イベントバスの作り方
- ステートマシンで管理するAIとプレイヤーの状態 ——状態を1箇所に集めて絡まりを減らす
- GDScriptで使うOOPデザインパターン ——責務の分け方をさらに深掘り
公式ドキュメント: