概要
動作確認環境: Godot 4.3+
Godotでゲームを作る際、「すべての機能を継承で実装する」と階層が深くなり、コードの再利用や拡張が難しくなります。デザインパターンを活用することで、柔軟で保守しやすい設計が実現できます。
この記事では、Godotで特に有用な3つのデザインパターンを紹介します。
3つのパターンの概要
継承ベースの設計では、機能を追加するたびにクラス階層が深くなり、「移動する敵」「飛ぶ敵」「移動して飛ぶ敵」のように組み合わせが爆発します。本記事で紹介する3つのパターンは、それぞれ異なる角度からこの問題を解決します。
| パターン | 一言で言うと | こんなときに使う |
|---|---|---|
| コンポジション | 機能を部品として組み合わせる | 移動・攻撃・体力などの機能を複数キャラで使い回したい |
| デコレーター | 既存オブジェクトに機能を重ねる | 装備 ・バフで能力値を動的に変えたい |
| ファクトリー | 生成処理を一元管理する | 敵・アイテムの種類ごとに異なる設定で生成したい |
コンポジション — 「has-a」の設計
「キャラクターは移動機能を持つ」という関係で設計します。移動・攻撃・体力などの機能をそれぞれ独立したNodeやResourceとして作り、必要なキャラクターに組み合わせて使います。Godotのシーンツリーと相性が良いパターンです。
デコレーター — 「ラップして拡張」
元のオブジェクトを包み込んで機能を上乗せします。剣で攻撃力+5、さらにバフで+3というように、装飾を何層でも重ねられます。装備解除時はラップを外すだけで元に戻せるのが特徴です。
ファクトリー — 「生成の一元化」
「ゴブリンならHP50・速度100、オークならHP100・速度80」といった生成ルールを1つのクラスにまとめます。生成処理が散らばらず、新しい敵タイプの追加も設定を1箇所に書くだけで済みます。
コンポジションパターン
それでは、各パターンの詳細を見ていきましょう。最初はGodotとの相性が抜群のコンポジションパターンです。
Godotのシーンツリーは、まさに「小さな部品を組み合わせて大きなものを作る」という設計思想でできています。コンポジションパターンはこの思想にそのまま乗るため、最も自然に導入できるパターンと言えます。
問題
プレイヤーと敵に移動機能を実装する場合、継承だと以下のような階層になります。
Character (基底クラス)
├─ Player
└─ Enemy
しかし「移動速度が異なる」「入力方法が違う」といった違いを継承で表現すると、クラスが増えすぎます。
解決: コンポーネント化
移動ロジックを独立したコンポーネントとして切り出すことで、プレイヤーにも敵にも使い回せる設計になります。
ポイントは「データ(どれくらい速いか)」と「処理(どう動くか)」を分離することです。以下のコードでは、MovementStatsリソースにデータを、MovementComponentノードに処理をそれぞれ分けています。
# movement_stats.gd (Resource)
class_name MovementStats
extends Resource
@export var max_speed: float = 200.0
@export var acceleration: float = 800.0
@export var friction: float = 600.0
# movement_input.gd (Node)
class_name MovementInput
extends Node
func get_input_direction() -> Vector2:
# 子クラスでオーバーライド
return Vector2.ZERO
# player_input.gd
class_name PlayerInput
extends MovementInput
func get_input_direction() -> Vector2:
return Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
# movement_component.gd
class_name MovementComponent
extends Node
@export var stats: MovementStats
@onready var input: MovementInput = $"../PlayerInput" # シーンツリーから取得
func update_movement(actor: CharacterBody2D, delta: float):
var direction = input.get_input_direction()
if direction != Vector2.ZERO:
actor.velocity = actor.velocity.move_toward(
direction * stats.max_speed,
stats.acceleration * delta
)
else:
actor.velocity = actor.velocity.move_toward(
Vector2.ZERO,
stats.friction * delta
)
actor.move_and_slide()
# player.gd
extends CharacterBody2D
@onready var movement = $MovementComponent
func _physics_process(delta):
movement.update_movement(self, delta)
tips: Nodeの参照を
@exportで設定することも可能ですが、@onreadyでシーンツリーから取得するほうがGodotの慣例に沿っています。Resourceの参照(MovementStatsなど)は@exportが適切です。
メリット:
- 移動ロジックの再利用が容易
MovementStatsリソースで調整可能inputを差し替えればAI移動も実装可能
デコレーターパターン
コンポジションが「部品を組み合わせる」パターンだったのに対し、デコレーターは「既存のものに包み紙を重ねていく」イメージです。RPGで剣を装備したら攻撃力+5、さらにバフ魔法で+3――こういった処理を、クラスを増やさずに柔軟に実装できます。
問題
プレイヤーのステータスに、装備品やバフによる補正を適用したい。継承で実装すると「素のプレイヤー」「剣装備プレイヤー」「剣+盾装備プレイヤー」とクラスが爆発します。
解決: デコレーター
デコレーターパターンでは、元のオブジェクト をラップして機能を追加します。まずインターフェースとなる基底クラスを作り、その上にデコレーター層を重ねていく構成です。
# player_stats.gd (インターフェース)
class_name PlayerStats
extends RefCounted
func get_attack() -> int:
return 0
func get_defense() -> int:
return 0
# base_player_stats.gd
class_name BasePlayerStats
extends PlayerStats
var base_attack: int = 10
var base_defense: int = 5
func get_attack() -> int:
return base_attack
func get_defense() -> int:
return base_defense
# stats_decorator.gd (デコレーター基底クラス)
class_name StatsDecorator
extends PlayerStats
var wrapped_stats: PlayerStats
func _init(stats: PlayerStats):
wrapped_stats = stats
func get_attack() -> int:
return wrapped_stats.get_attack()
func get_defense() -> int:
return wrapped_stats.get_defense()
# attack_boost_decorator.gd
class_name AttackBoostDecorator
extends StatsDecorator
var bonus_attack: int
func _init(stats: PlayerStats, bonus: int):
super(stats)
bonus_attack = bonus
func get_attack() -> int:
return wrapped_stats.get_attack() + bonus_attack
# 使用例
var stats = BasePlayerStats.new()
print(stats.get_attack()) # 10
# 剣を装備(攻撃力+5)
stats = AttackBoostDecorator.new(stats, 5)
print(stats.get_attack()) # 15
# さらにバフ(攻撃力+3)
stats = AttackBoostDecorator.new(stats, 3)
print(stats.get_attack()) # 18
デコレーターの除去(装備解除)
追加するだけでなく、外す処理も重要です。プレイヤーが装備を外したりバフの効果時間が切れたりしたとき、対応するデコレーターを取り除く必要があります。wrapped_statsを使って1段階戻すことで実現できます。
# 装備解除: 最後に適用したデコレーターを除去
func unequip_last(current_stats: PlayerStats) -> PlayerStats:
if current_stats is StatsDecorator:
return current_stats.wrapped_stats
return current_stats # デコレーターでなければそのまま
# 使用例
stats = unequip_last(stats)
print(stats.get_attack()) # 15(バフが除去され、剣のみ)
メリット:
- 実行時に機能を追加・削除可能
- 装備やバフの組み合わせが柔軟
- 基底クラスを変更せずに拡張
ファクトリーパターン
最後はファクトリーパターンです。「ゲームのあちこちで敵を生成する処理が書かれていて、新しい敵タイプを追加するたびに複数ファイルを修正しなければならない」――そんな経験はありませんか? ファクトリーパターンは生成ロジックを一箇所にまとめることで、この問題を解決します。
問題
敵の種類ごとに生成処理が異なる場合、生成コードがあちこちに散らばります。
# アンチパターン
if enemy_type == "goblin":
var enemy = load("res://enemies/goblin.tscn").instantiate()
enemy.health = 50
enemy.speed = 100
elif enemy_type == "orc":
var enemy = load("res://enemies/orc.tscn").instantiate()
enemy.health = 100
enemy.speed = 80
解決: ファクトリークラス
生成処理を専用のクラスにまとめましょ う。シーンのパスと初期設定値を辞書で一元管理するので、新しい敵タイプの追加は辞書にエントリを足すだけで済みます。
# enemy_factory.gd
class_name EnemyFactory
extends Node
enum EnemyType { GOBLIN, ORC, DRAGON }
const ENEMY_SCENES = {
EnemyType.GOBLIN: preload("res://enemies/goblin.tscn"),
EnemyType.ORC: preload("res://enemies/orc.tscn"),
EnemyType.DRAGON: preload("res://enemies/dragon.tscn"),
}
const ENEMY_CONFIGS = {
EnemyType.GOBLIN: { "health": 50, "speed": 100 },
EnemyType.ORC: { "health": 100, "speed": 80 },
EnemyType.DRAGON: { "health": 300, "speed": 50 },
}
func create_enemy(type: EnemyType, position: Vector2) -> Node2D:
var scene = ENEMY_SCENES.get(type)
if not scene:
push_error("Unknown enemy type: %s" % type)
return null
var enemy = scene.instantiate()
var config = ENEMY_CONFIGS[type]
enemy.global_position = position
enemy.health = config["health"]
enemy.speed = config["speed"]
return enemy
# 使用例
@onready var factory = $EnemyFactory
func spawn_enemies():
var goblin = factory.create_enemy(EnemyFactory.EnemyType.GOBLIN, Vector2(100, 100))
add_child(goblin)
var orc = factory.create_enemy(EnemyFactory.EnemyType.ORC, Vector2(200, 100))
add_child(orc)
メリット:
- 生成ロジックの一元管理
- 新しい敵タイプの追加が容易
- テストが書きやすい
tips:
const+preload()は起動時に全シーンをメモリに読み込みます。大量のシーンを登録する場合はload()で遅延読み込みするか、ResourceLoader.load_threaded_request()で非同期読み込みを検討してください。
パターン選択ガイド
3つのパターンを見てきましたが、実際の開発ではどれを使えばいいか迷うこともあるでしょう。以下の表を判断基準にしてみてください。
| パターン | 使用場面 | メリット |
|---|---|---|
| コンポジション | 機能を組み合わせたい | 柔軟な機能の組み合わせ、再利用性 |
| デコレーター | 実行時に機能を追加したい | 動的な機能追加、多段階の装飾 |
| ファクトリー | オブジェクト生成が複雑 | 生成ロジックの一元管理、拡張性 |
組み合わせ例:
- ファクトリーで敵を生成 → コンポジションで行動を組み立て → デコレーターでバフ適用
Godotと親和性の高い他のパターン
Godotの設計は、標準機能としていくつかのデザインパターンを組み 込んでいます。
| パターン | Godotでの実装 | 典型的な用途 |
|---|---|---|
| オブザーバー | signalが標準で提供 | イベント通知、UI更新 |
| ステート | match文 + Enum / State Machine | キャラクターの状態管理 |
| シングルトン | Autoload | ゲーム全体のデータ管理 |
Godotのシグナルシステムはオブザーバーパターンそのもので、signal の宣言と connect() で疎結合なイベント通知を実現します。ステートパターンの実装についてはステートマシンの記事も参照してください。
まとめ
- コンポジションは継承の代わりに部品を組み合わせて機能を構築
- デコレーターは既存オブジェクトに動的に機能を追加
- ファクトリーはオブジェクト生成ロジックを一元管理
- 各パターンは単独でも、組み合わせても有効
- Godotではノードとリソースの組み合わせで柔軟に実装可能
- 過度な設計は避け、必要になったときにリファクタリングする姿勢が重要