【Godot】循環参照を避ける依存関係管理

作成: 2025-12-10最終更新: 2026-07-08

GodotでPlayer、HUD、Inventory、ItemDataなどの参照が絡まる理由を整理し、シグナル、Autoload、WeakRef、引数渡しで依存方向をきれいに保つ方法を解説します。

プレイヤーがHPを持ち、HUDがHPバーを表示し、インベントリがアイテムを使い、アイテムがプレイヤーを回復する。ここまでは自然です。

しかし実装しているうちに、PlayerHUD を直接触り、HUDPlayer を参照し、ItemDataPlayer を覚え、InventoryItemData を持つ、という状態になりがちです。動いている間は問題に見えませんが、UIを差し替えた瞬間に落ちる、別シーンでは参照が見つからない、どこでHPが変わったか追えない、という形で苦しくなります。

Player・HUD・Inventoryが双方向に絡み合った参照を、Player→HUD→Inventoryの一方向の依存へ整理する前後比較図

この記事では、循環参照依存関係の絡まり を分けて整理します。メモリ管理の話だけでなく、初心者〜中級者が実際のゲーム制作で詰まりやすい「どのノードがどのノードを知っていてよいのか」を中心に扱います。

この記事でわかること

  • 循環参照と循環依存の違い
  • NodeRefCounted で問題の出方が違う理由
  • 直接呼び出し、シグナル、Autoload、WeakRefの使い分け
  • アイテム、HUD、ステータス効果を安全に設計する考え方

Sponsored

まず「参照」と「依存」を分ける

参照 は、あるオブジェクトが別のオブジェクトを変数として持っている状態です。

var player: Player

依存 は、あるコードが別のコードの存在やメソッド名を知っている状態です。

player.heal(20)

循環が問題になるのは、単に変数があるからではありません。AがBを知り、BもAを知り、さらに互いのメソッドを呼び合うようになると、変更に弱くなります。

Player -> HUD
HUD    -> Player

この形だと、PlayerHUD の存在を前提にします。HUDがないテストシーンや、ボス戦専用UIに差し替えたシーンで壊れやすくなります。さらにHUD側もPlayerを前提にしているため、「どちらを先に用意するか」という初期化順の問題も出ます。

依存関係を整理するときは、まず次の問いを置きます。

このオブジェクトは、本当に相手の存在を知る必要があるのか?

NodeとRefCountedで何が違うのか

循環参照の話で混乱しやすいのは、Godotのオブジェクトがすべて同じ仕組みで解放されるわけではない点です。

種類解放の考え方循環で起きやすい問題
NodeCharacterBody2D, Control, Node2Dシーンツリーから外す、queue_free() する参照切れ、初期化順、強い結合
RefCountedResource, 独自の計算用クラス参照されなくなると解放される相互参照で解放されにくくなる

言葉だけだとイメージしにくいので、2つの「解放のされ方」と「循環で起きる問題」を並べてみます。

NodeとRefCountedの解放の違いの比較図。Node系はqueue_free()で解放され、残った参照が参照切れになる。RefCounted系は参照カウントが0で解放されるが、AとBが互いを参照し合うとカウントが2のまま解放されない

左のNode系では、HUDqueue_free() で消すと、Player が握っていた参照は「もう存在しないノードを指したまま」になります(参照切れ)。参照カウントの輪は関係ありません。右のRefCounted系では、AB が互いを参照し合うと、外部から誰も見ていなくても参照カウントが0にならず、いつまでも解放されません。 同じ「循環」でも、Node系は“壊れた参照”、RefCounted系は“消えないメモリ” と、問題の出方がまったく違うわけです。

NodeRefCounted ではありません。PlayerHUD が互いを参照していても、それだけでRefCounted同士のような参照カウントの輪になるわけではありません。

ただし、Node同士の相互参照も安全という意味ではありません。片方が queue_free() されたあとに古い参照を使うと、無効なインスタンスを触ってしまいます。別シーンで片方だけ存在しない場合も壊れます。

一方、ResourceRefCounted を継承した独自クラスでは、参照カウントの輪が問題になります。AがBを持ち、BがAを持っていると、外部から見えなくなっても互いに参照し続けるため、解放されにくくなります。

基本ルール: 参照の向きを一方向にする

まずは難しい技術より、参照の向きを整理します。悪い例と良い例を並べてみましょう。

参照の向きの悪い例と良い例の比較図。悪い例ではPlayer・HUD・Inventory・ItemDataが双方向矢印で絡まっている。良い例ではHUDがPlayerを見る、InventoryがItemDataを持つ、ItemDataはPlayerを覚えない、Playerは通知するだけ、と一方向に整理されている

悪い例では、すべてのオブジェクトが互いを参照し合い、どこから手をつければいいか分からない網になっています。良い例のように「上位が下位を知る」「表示側がデータ元を見る」「データは利用者を覚えない」「通知したい側はシグナルを出す」と方向を決めるだけで、かなり読みやすくなります。

たとえばHP表示なら、PlayerHUD を直接更新するより、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などで壊れやすくなります。

Sponsored

解決策1: 通知はシグナルにする

「プレイヤーがダメージを受けた」ことを、HUD、効果音、クエスト管理など複数の仕組みが知りたい場合は シグナル が向いています。

シグナルは「相手に命令する」のではなく、「こういうことが起きた」と通知します。通知する側は、誰が聞いているかを知りません。シグナルの定義と接続の基本は シグナルによるノード間の連携 で詳しく扱っています。

Playerのhp_changedシグナルをHUD、Sound、Questが受け取る一方向の通知図
# 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

この形なら、PlayerHUD のノード名、HPバーの種類、効果音の有無を知りません。HUDを別デザインに差し替えても、Player側のコードは変えずに済みます。

実装レシピ: アクションRPGのHP更新

アクションRPGでプレイヤーのHPを扱うなら、次のように分けると管理しやすいです。

  1. Playertake_damage()heal() でHPを変更する
  2. HPが変わったら hp_changedemit() する
  3. HUD はHPバーを更新するためにシグナルを聞く
  4. SoundManager は瀕死音や被弾音のために同じシグナルを聞く
  5. QuestManager は「HP1で生き残る」実績のために同じシグナルを聞く

ポイントは、Playerが「HUDを更新する」「音を鳴らす」「実績を解除する」を全部背負わないことです。Playerの仕事はHPを正しく変えること、周辺システムの仕事はその通知を聞いて自分の表示や演出を変えることです。

解決策2: ItemDataはPlayerを覚えない

循環参照は、インベントリやアイテムでよく起きます。

Player -> Inventory -> ItemData
ItemData -> Player

たとえば「回復薬のItemDataが、使う相手としてPlayerを変数に持つ」設計にすると、ItemDataが特定のPlayerに結びついてしまいます。別のキャラクターが同じ回復薬を使う、敵がアイテムを使う、ショップでプレビューする、といった場面で使い回しにくくなります。

ItemDataがPlayerを覚える悪い例と、use(user)で利用者を渡す良い例

悪い例です。

# 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処理の中身
PlayerHP、MP、状態異常UIの具体的なノード
InventoryUI選択表示、ボタン入力アイテム効果の計算

UIでアイテムを選んだら、Inventory.use_item(index, player) のように「使う相手」を渡します。ItemDataがPlayerを保持しないため、セーブデータ、ショップ、図鑑、戦闘中UIでも同じデータを使い回しやすくなります。

Sponsored

解決策3: RefCountedの輪はWeakRefで断つ

ここからが、メモリ管理としての循環参照です。

RefCounted は、参照されなくなると自動的に解放されます。便利ですが、RefCounted同士が互いを強く参照すると、外部から見えなくなっても参照カウントが0にならない状態が起きます。

EffectとOwnerが強参照で輪になる例と、WeakRefで片方を弱参照にする例

たとえば、ステータス管理を 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 は通知の通り道に留め、実際のスコア計算、クエスト進行、セーブ処理はそれぞれの担当クラスへ分ける方が安全です。

判断フロー: どの方法を選ぶか

参照が絡まりそうになったら、次の順で考えます。

直接呼び出し、シグナル、Autoload、WeakRefを選ぶ判断フロー
やりたいこと選びやすい方法
相手に具体的な命令をしたい直接呼び出しドアに open() を呼ぶ
何かが起きたことを知らせたいシグナルhp_changeddied
シーンをまたいで複数システムへ知らせたいAutoloadのイベントバスenemy_defeated
RefCounted同士の輪を断ちたいWeakRefStatusEffect から CharacterStats を弱参照
一時的に相手が必要なだけ引数で渡すitem.use(player)

持ち帰る判断基準はシンプルです。

「相手をずっと覚える必要があるのか? それとも、その瞬間だけ渡せばよいのか?」

この問いに答えるだけでも、循環参照の多くは避けられます。

Sponsored

よくあるつまずき

何でもシグナルにして追えなくなる

シグナルは便利ですが、すべてをシグナルにすると、どこで何が起きたか追いにくくなります。

ドアを開ける、ボタンを押す、特定の相手に攻撃する、のように相手が明確で命令に近い処理は、直接呼び出しでも問題ありません。シグナルが向いているのは、「誰が聞くかを発行側が知らなくてよい通知」です。

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 に「倒された」と一言つぶやくだけにします。

敵がEventBusのenemy_defeatedを発行し、スコア・効果音・実績がそれぞれ受け取る。さらにスコア担当がscore_changedを出してHUDが表示を更新する二段構えの図

まず、通知の通り道になる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("../../..") のような遠い参照が増えていないか

このチェックは、ゲームが小さいうちから役立ちます。参照方向が見えるコードは、後から機能を足すときに安心して触れます。

まとめ

  • 循環参照 は、参照の矢印が輪になる状態
  • NodeRefCounted では、循環で起きる問題が違う
  • 通知は シグナル にすると、発行側が受信側を知らずに済む
  • アイテムや効果は、相手を保持せず 引数で渡す だけで済むことが多い
  • RefCounted 同士の輪が避けられない場合は WeakRef を使う
  • シーンをまたぐ共通イベントは Autoload のイベントバスで整理できるが、何でも屋化には注意する

依存関係管理の目的は、メモリリークだけを避けることではありません。UIを差し替えても壊れない、テストシーンでも動く、どこで値が変わったか追える、そういう「後から直しやすいゲーム」にすることです。

さらに学ぶために

関連するノート記事:

公式ドキュメント: