概要
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が解放されると、Equipmentのowner_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
PlayerはHUDの存在を知らない - これが疎結合の美点です。依存関係は常に「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
EnemyはScoreManagerの存在を知らない。ただ「掲示板」に書き込むだけです。
まとめ
| パターン | 主な用途 | メリット | 注意点 |
|---|---|---|---|
| WeakRef | 相互参照が必要な場合 | 循環を直接断ち切れる | nullチェック必須 |
| シグナル | オブジェクト間の疎結合化 | 依存関係が一方向に | 接続先が多いと追跡が難しい |
| Autoload | グローバル状態管理、イベントバス | どこからでもアクセス可能 | 「なんでも屋」化に注意 |
次のステップ:
- オブザーバーパターン: シグナルの背景にある古典的デザインパターン
- ResourceLoaderの活用: 必要な時だけリソースを読み込み、メモリを最適化