【Godot】Godotで学ぶ依存関係管理:循環参照を避けるための設計パターン

作成: 2025-12-10最終更新: 2025-12-16

循環参照によるメモリリークを防ぐ設計パターン。WeakRef、シグナル、Autoloadの活用法を解説。

概要

Godot Engineでゲーム開発を進める上で、スクリプトやシーン間の依存関係の管理は避けて通れないテーマです。特に、参照カウントの仕組みを理解せずに設計を進めると、「循環参照」によるメモリリークに直面します。

この記事では、循環参照の問題を解説し、WeakRefシグナルAutoloadを活用した設計パターンを紹介します。


Godotのメモリ管理と循環参照

参照カウントシステム

Godotのオブジェクトは主に2種類に分類されます:

種類メモリ管理
Node派生クラスCharacterBody2D等シーンツリーから削除で解放(queue_free()
RefCounted派生クラスResource, Array, Dictionary参照カウントが0で自動解放

循環参照によるメモリリークは、主にRefCounted派生クラスで発生します。

循環参照がメモリリークを起こす仕組み

オブジェクトA  --->  オブジェクトB
    ^               |
    |_______________|

AとBが互いを参照していると、外部からの参照がなくなっても両者の参照カウントは1以上のまま。結果、メモリから解放されません。


よくある間違いとベストプラクティス

よくある間違いベストプラクティス
親が子を、子も親を直接参照する子から親へはget_parent()かシグナルで通知
ItemリソースがPlayerを、PlayerもItemを参照リソースはデータのみ、Playerが使用時に引数で渡す
SEやUI等が直接Autoload内部を変更シングルトンは変更用メソッドを提供、他は呼び出すだけ
一時的な効果で互いを参照WeakRefで片方を弱参照、または効果終了をシグナル通知

解決策1: WeakRef(弱参照)

循環参照を断ち切る最も直接的な方法です。参照カウントを増やさない弱参照を使用します。

実践例: 装備品とキャラクター

# Equipment.gd (RefCounted)
class_name Equipment
extends RefCounted

var owner_ref: WeakRef # 弱い参照

func get_owner() -> Character:
    if owner_ref:
        var ref = owner_ref.get_ref()
        if ref:
            return ref
    return null
# Character.gd (Node)
class_name Character
extends CharacterBody3D

var equipped_item: Equipment # 強い参照

func equip(item: Equipment):
    if equipped_item:
        unequip()
    equipped_item = item
    equipped_item.owner_ref = weakref(self)

func unequip():
    if equipped_item:
        equipped_item.owner_ref = null
        equipped_item = null

Characterが解放されると、Equipmentowner_refは自動的に無効(null)になります。


解決策2: シグナルによる疎結合化

シグナルは依存関係を管理する最もGodotらしい方法です。

実践例: プレイヤーHPとUI

# Player.gd (シグナル発行者)
extends CharacterBody3D

signal hp_changed(current_hp: int, max_hp: int)

var max_hp: int = 100
var hp: int = max_hp:
    set(value):
        hp = clamp(value, 0, max_hp)
        hp_changed.emit(hp, max_hp)

func take_damage(amount: int):
    self.hp -= amount
# HUD.gd (シグナル受信者)
extends Control

@onready var hp_bar: ProgressBar = $HPBar

func _ready():
    var player = get_tree().get_first_node_in_group("players")
    if player:
        player.hp_changed.connect(_on_player_hp_changed)

func _on_player_hp_changed(current_hp: int, max_hp: int):
    hp_bar.max_value = max_hp
    hp_bar.value = current_hp

PlayerHUDの存在を知らない - これが疎結合の美点です。依存関係は常に「HUD → Player」の一方向。


解決策3: Autoload(シングルトン)

ゲーム全体で共有したい機能は、Autoloadでシングルトン化します。

イベントバスパターン

Autoloadをグローバルなイベントバスとして活用すると、シーンをまたいだ通信が可能になります。

# EventBus.gd (Autoload)
extends Node

signal enemy_defeated(position: Vector3, score_value: int)
signal item_collected(item_id: String)
# Enemy.gd (シグナル発行)
func die():
    EventBus.enemy_defeated.emit(self.global_position, 100)
    queue_free()
# ScoreManager.gd (シグナル受信)
func _ready():
    EventBus.enemy_defeated.connect(_on_enemy_defeated)

func _on_enemy_defeated(position: Vector3, score_value: int):
    Global.score += score_value

EnemyScoreManagerの存在を知らない。ただ「掲示板」に書き込むだけです。


まとめ

パターン主な用途メリット注意点
WeakRef相互参照が必要な場合循環を直接断ち切れるnullチェック必須
シグナルオブジェクト間の疎結合化依存関係が一方向に接続先が多いと追跡が難しい
Autoloadグローバル状態管理、イベントバスどこからでもアクセス可能「なんでも屋」化に注意

次のステップ:

  1. オブザーバーパターン: シグナルの背景にある古典的デザインパターン
  2. ResourceLoaderの活用: 必要な時だけリソースを読み込み、メモリを最適化