【Godot】GDScriptで使うOOPデザインパターン

作成: 2026-02-08最終更新: 2026-07-07

Godotでゲームを大きくしていくときに役立つコンポジション、デコレーター、ファクトリーを、継承だけで設計したときのつらさから実装例まで噛み砕いて解説します。

敵を増やすたびに FlyingEnemyFastEnemyArmoredEnemyFlyingFastEnemy のようなクラスが増えていく。装備やバフを足すたびに攻撃力の計算場所が分からなくなる。敵を生成する処理がステージ、ウェーブ、イベント会話の中に散らばってしまう。

こうした問題は、GDScriptの書き方だけではなく 設計の分け方 の問題です。デザインパターンは難しい名前がついていますが、ゲーム制作では「増えたときに壊れにくくする整理術」と考えると使いやすくなります。

継承で増えすぎた敵クラスを、移動・攻撃・体力・AIの部品へ分ける図

この記事でわかること

  • 継承だけで設計すると、どこがつらくなるのか
  • Godotで自然に使いやすい コンポジション の考え方
  • 装備やバフを重ねる デコレーター の考え方
  • 敵やアイテム生成を集約する ファクトリー の作り方
  • 3つのパターンを、実際のゲーム機能へどうつなげるか

Sponsored

デザインパターンは「増えたとき」のための道具

最初に大事なのは、デザインパターンを「かっこいい設計名」として覚えないことです。ゲームを作っていると、最初は動いていたコードが次のように苦しくなります。

起きる問題具体例困ること
クラスが増えすぎる飛ぶ敵、速い敵、硬い敵、その組み合わせ似たコードを何度も書く
値の計算場所が散る装備、バフ、スキル、難易度補正最終的な攻撃力の理由が追えない
生成処理が散るステージ開始、イベント、ウェーブ管理で敵を作る新しい敵を追加すると修正箇所が多い

この記事で扱う3つのパターンは、それぞれこの痛みに対応します。

パターン一言で言うとゲームでの使いどころ
コンポジション機能を部品として組み合わせるプレイヤーと敵で移動・体力・攻撃処理を使い回す
デコレーター元の値や処理に効果を重ねる装備、バフ、デバフでステータスを変える
ファクトリー生成方法を専用クラスに集める敵、弾、アイテム、NPCを決まったルールで作る

小さいプロトタイプなら、これらを無理に入れる必要はありません。1ファイルで済む処理にパターンを入れると、逆に読むファイルが増えて分かりにくくなります。目安は「同じ種類の処理を3回書き始めた」「変更のたびに複数ファイルを直している」と感じたタイミングです。

まず継承とコンポジションを分けて考える

オブジェクト指向では 継承 がよく出てきます。Godotでも、スクリプトの先頭に extends CharacterBody2Dextends Node と書きます。これは「このスクリプトはCharacterBody2Dとして振る舞う」という土台を決めるものです。

# player.gd
extends CharacterBody2D

このような継承は自然です。プレイヤーは2D物理キャラクターとして移動したいので、CharacterBody2D を継承します。

問題は、ゲームごとの細かい違いまで継承で表そうとする場合です。

Enemy
├─ FlyingEnemy
├─ FastEnemy
├─ ArmoredEnemy
├─ FlyingFastEnemy
├─ FlyingArmoredEnemy
└─ FastArmoredEnemy

敵の特徴が「飛ぶ」「速い」「硬い」だけならまだ読めます。しかしここに「毒攻撃」「遠距離攻撃」「夜だけ強い」「分裂する」が増えると、組み合わせ用のクラスが一気に増えます。

そこで使うのが コンポジション です。「敵は移動機能を持つ」「敵は攻撃機能を持つ」「敵は体力機能を持つ」のように、変わりやすい機能を部品として持たせます。

考え方使う場面
継承土台の種類を決めるPlayer extends CharacterBody2D
コンポジション機能を組み合わせるPlayerMovementComponent を持つ

判断のコツは「これは種類か、部品か」です。PlayerCharacterBody2D の一種です。一方、移動処理や攻撃処理はプレイヤーの一種ではなく、プレイヤーが持っている機能です。

Sponsored

コンポジション: 機能を部品として持たせる

Godotのシーンツリーは、もともとコンポジションと相性が良い作りです。Player シーンの下に Sprite2DCollisionShape2DAnimationPlayer を置くのも、複数の部品で1つのキャラクターを作っているからです。

スクリプトでも同じように考えます。ここではトップダウンアクションを例に、移動処理を部品化します。

MovementInput、MovementStats、MovementComponentがActorを動かすコード構造の図

例: プレイヤーと敵で移動処理を使い回す

プレイヤーと敵はどちらも動きますが、入力の取り方が違います。

  • プレイヤー: キーボードやゲームパッドから方向を取る
  • 敵: AIやターゲット位置から方向を決める

このとき、速度計算や move_and_slide() の呼び出しは共通化できます。違うのは「どちらへ動きたいか」を決める部分です。

まず、移動パラメータを Resource にします。Resource にしておくと、エディタ上で「速い敵用」「重い敵用」のデータを作りやすくなります。

# movement_stats.gd
class_name MovementStats
extends Resource

@export var max_speed: float = 200.0
@export var acceleration: float = 800.0
@export var friction: float = 600.0

次に、入力方向を返す基底クラスを作ります。プレイヤー入力でも敵AIでも、最終的に Vector2 の方向を返せば、移動コンポーネント側は同じように扱えます。

# movement_input.gd
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")

移動コンポーネントは、入力方向と移動データを受け取って、実際に CharacterBody2D を動かします。

# movement_component.gd
class_name MovementComponent
extends Node

@export var stats: MovementStats
@export var input_path: NodePath

@onready var input: MovementInput = get_node(input_path) as MovementInput

func update_movement(actor: CharacterBody2D, delta: float) -> void:
    if stats == null or input == null:
        return

    # 入力の種類を知らずに、方向だけを受け取る
    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
        )

    # 実際に動かす責務はMovementComponentへ集約する
    actor.move_and_slide()

最後に、プレイヤー本体から移動コンポーネントを呼びます。

# player.gd
extends CharacterBody2D

@onready var movement: MovementComponent = $MovementComponent

func _physics_process(delta: float) -> void:
    # プレイヤー本体は「移動部品を使う」だけにする
    movement.update_movement(self, delta)

シーン構成は次のようになります。

Player (CharacterBody2D)
├─ CollisionShape2D
├─ Sprite2D
├─ PlayerInput
└─ MovementComponent

MovementComponentinput_path../PlayerInput を設定しておけば、移動コンポーネントはプレイヤー入力を使います。敵用に EnemyInput を作って差し替えれば、同じ MovementComponent を敵にも使えます。

実装レシピ: アクションゲームのプレイヤーと敵

アクションゲームで「プレイヤーも敵も同じ加速感で動かしたい」ときは、次の順で作ると整理しやすいです。

  1. MovementStats で速度、加速度、摩擦をデータ化する
  2. MovementInput を基底にして、PlayerInputEnemyChaseInput を分ける
  3. MovementComponent は入力方向を受け取り、速度計算だけを担当する
  4. プレイヤーと敵のシーンに同じ MovementComponent を置く
  5. キャラクターごとの違いは MovementStatsMovementInput の差し替えで表現する

この形にすると、「敵だけ滑りやすくしたい」「プレイヤーだけ最高速度を上げたい」といった調整が、移動処理の書き換えではなくデータ差し替えで済みます。バグが出たときも、速度計算の問題なのか、入力方向の問題なのかを切り分けやすくなります。

デコレーター: 装備やバフを重ねて計算する

次は デコレーター です。デコレーターは、元の値や処理を包んで、後から効果を足す考え方です。

RPGで考えると分かりやすいです。

  • 素の攻撃力: 10
  • 剣を装備: +5
  • 攻撃力アップのバフ: +3
  • 最終攻撃力: 18

このとき、「剣装備プレイヤー」「剣装備かつバフ中プレイヤー」のようなクラスを作るとすぐ破綻します。装備、バフ、デバフ、料理効果、難易度補正が増えるたびに組み合わせが増えるからです。

BaseStatsにSwordとBuffを重ねてAttack 18へ変化するデコレーター図

例: ステータス計算を重ねる

まず、ステータスを取得する共通の型を用意します。ここではシーン上に置くNodeではなく、軽い計算用オブジェクトとして RefCounted を使います。

# 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

次に、別の PlayerStats を包む基底デコレーターを作ります。

# stats_decorator.gd
class_name StatsDecorator
extends PlayerStats

var wrapped_stats: PlayerStats

func _init(stats: PlayerStats) -> void:
    # 元のステータス計算を内側に保持する
    wrapped_stats = stats

func get_attack() -> int:
    return wrapped_stats.get_attack()

func get_defense() -> int:
    return wrapped_stats.get_defense()

攻撃力を上げるデコレーターを作ります。

# attack_boost_stats.gd
class_name AttackBoostStats
extends StatsDecorator

var bonus_attack: int

func _init(stats: PlayerStats, bonus: int) -> void:
    super(stats)
    bonus_attack = bonus

func get_attack() -> int:
    # 内側の計算結果に、今回の補正を上乗せする
    return wrapped_stats.get_attack() + bonus_attack

使う側では、元のステータスに効果を重ねていきます。

var stats: PlayerStats = BasePlayerStats.new()
print(stats.get_attack()) # 10

# 剣を装備して +5
stats = AttackBoostStats.new(stats, 5)
print(stats.get_attack()) # 15

# 攻撃力アップのバフでさらに +3
stats = AttackBoostStats.new(stats, 3)
print(stats.get_attack()) # 18

ここで大事なのは、プレイヤー本体が「攻撃力はどう計算されているか」を細かく知らなくてよいことです。プレイヤーは stats.get_attack() を呼ぶだけで、装備やバフを含んだ最終値を受け取れます。

実装レシピ: RPGの装備と一時バフ

RPGで装備とバフを扱うなら、次のように考えます。

  1. プレイヤーの素の値を BasePlayerStats に置く
  2. 剣、指輪、料理効果、スキル効果などを「ステータスを変える効果」として分ける
  3. 攻撃時やUI表示時に、現在有効な効果を反映した stats.get_attack() を読む
  4. バフの効果時間が切れたら、その効果を外して再計算する

ただし、デコレーターは「最後に足した効果から外す」だけなら簡単ですが、「真ん中の効果だけ外す」「同じ種類のバフを更新する」ようになると扱いが難しくなります。

その場合は、厳密なデコレーターにこだわらず、StatModifier の配列を持って合計する設計の方が実装しやすいこともあります。

# 装備やバフが多いゲームでは、配列方式の方が扱いやすい場合もある
var attack_modifiers: Array[int] = [5, 3, -2]

func get_total_attack(base_attack: int) -> int:
    var total := base_attack

    for modifier in attack_modifiers:
        # 有効な補正を順番に合計する
        total += modifier

    return total

この記事で覚えてほしいのは「デコレーターパターンの厳密な形」よりも、素の値に効果を重ねて最終値を作る という考え方です。装備、バフ、難易度補正、ステージ効果などが増えるゲームでは、この考え方を持っているだけで設計がかなり整理されます。

Sponsored

ファクトリー: 生成ルールを1箇所に集める

最後は ファクトリー です。ファクトリーは、敵やアイテムなどの生成方法を専用の場所に集めるパターンです。

たとえば、ステージ開始時、宝箱を開けたとき、タワーディフェンスのウェーブ開始時に、それぞれ次のようなコードを書いているとします。

# あちこちに散らばりやすい生成コード
var enemy = preload("res://enemies/goblin.tscn").instantiate()
enemy.global_position = spawn_position
enemy.health = 50
enemy.speed = 100
add_child(enemy)

最初は問題ありません。しかしゴブリンのHPを変えたい、新しい敵を追加したい、生成時にドロップ率も設定したい、となると修正箇所が増えていきます。

生成処理を使う側は「ゴブリンをこの位置に出したい」だけを伝え、細かいシーンや初期値はファクトリーに任せます。

EnemyTypeとSpawnPointからEnemyFactoryがGoblin、Orc、Dragonを生成する流れ

例: 敵生成をEnemyFactoryへ集める

# 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": 120, "speed": 75 },
    EnemyType.DRAGON: { "health": 500, "speed": 45 },
}

func create_enemy(type: EnemyType, position: Vector2) -> Node2D:
    if not ENEMY_SCENES.has(type):
        push_error("Unknown enemy type: %s" % type)
        return null

    # 生成するシーンと初期値をファクトリー側で決める
    var enemy: Node2D = ENEMY_SCENES[type].instantiate()
    var config: Dictionary = ENEMY_CONFIGS[type]

    enemy.global_position = position
    enemy.health = config["health"]
    enemy.speed = config["speed"]

    return enemy

使う側はかなり短くなります。

# wave_spawner.gd
extends Node2D

@onready var factory: EnemyFactory = $EnemyFactory

func spawn_wave() -> void:
    var spawn_points := [
        Vector2(100, 120),
        Vector2(180, 120),
        Vector2(260, 120),
    ]

    for point in spawn_points:
        # どの敵をどこに出すかだけを書く
        var enemy := factory.create_enemy(EnemyFactory.EnemyType.GOBLIN, point)

        if enemy != null:
            add_child(enemy)

実装レシピ: タワーディフェンスのウェーブ

タワーディフェンスでは、敵生成のルールが増えやすいです。ウェーブ番号、敵の種類、出現間隔、出現位置、難易度補正などが絡むからです。

この場合、役割を次のように分けると読みやすくなります。

役割持たせる内容
WaveSpawnerいつ、何体、どの順番で出すか
EnemyFactory敵タイプからシーンと初期値を決める
Enemy シーン実際の移動、攻撃、死亡処理

WaveSpawner がすべてを知っていると、ウェーブ演出を直したいだけなのに敵ステータスのコードまで読む必要が出ます。EnemyFactory に生成ルールを集めておけば、「出す順番」と「敵の作り方」を分けて考えられます。

ファクトリーは、RPGの村人生成、ローグライクのアイテム生成、シューティングの弾生成にも使えます。ただし弾を大量に出すゲームでは、生成コストを避けるために オブジェクトプール を使うことも多いです。その話は別テーマなので、ここでは「生成ルールを1箇所に集める」考え方だけ押さえれば十分です。

3つを組み合わせるとどうなるか

実際のゲームでは、3つのパターンを単独で使うより、役割ごとに組み合わせることが多いです。

FactoryがEnemyを生成し、Enemy内部でMove・Attack・Health・AIを持ち、EquipmentとBuffでFinal Statsを作る全体図

たとえばアクションRPGの敵なら、次のように分けられます。

処理使う考え方具体例
敵を出すファクトリーEnemyFactory.create_enemy(EnemyType.ORC, position)
敵の機能コンポジションMovementComponentHealthComponentAttackComponent
一時強化デコレーター怒り状態で攻撃力+20%、毒で防御力低下

この分け方のメリットは、変更理由が分かれることです。

  • 新しい敵タイプを追加するなら EnemyFactory
  • 移動の手触りを変えるなら MovementComponentMovementStats
  • 装備やバフの計算を変えるならステータス計算側

変更理由ごとにファイルが分かれると、「どこを直せばいいか」が見つけやすくなります。これは初心者のうちは地味に見えますが、ゲームが大きくなるほど効いてきます。

Sponsored

よくあるつまずき

パターンを先に決めすぎる

「最初から完璧な設計にしよう」とすると、まだ必要ないクラスが増えます。プロトタイプでは、まず素直に作って動かして構いません。

ただし、次の兆候が出たら整理を考えるタイミングです。

  • 似たコードを3箇所以上にコピーしている
  • if enemy_type == ... が複数ファイルに出てくる
  • クラス名が組み合わせ名だらけになる
  • 攻撃力やHPの最終値が、どこで決まったのか追えない

コンポーネント同士を強く結びすぎる

コンポジションにしても、各コンポーネントが get_node("../../SomeNode") のように遠いノードを直接探し始めると、結局壊れやすくなります。

まずは親ノードから必要な情報を渡す、@export で参照を設定する、シグナルで通知する、といった形を検討してください。部品化の目的はファイルを増やすことではなく、依存関係を見えやすくすることです。

デコレーターで解除処理が複雑になる

デコレーターは効果を重ねるには分かりやすいですが、任意の効果を途中から外す処理は複雑になりがちです。

たとえば「剣 + バフ + 毒」の順で重ねたあと、真ん中のバフだけ消したい場合、単純に一番外側を外すだけでは対応できません。バフやデバフの数が多いゲームでは、StatModifier を配列で管理し、有効期限や優先度を持たせる設計の方が向いています。

ファクトリーが何でも屋になる

ファクトリーは生成を担当しますが、敵AIの判断、ウェーブ進行、スコア加算、演出再生まで入れる場所ではありません。

ファクトリーの仕事は「何を作り、初期値をどう入れるか」までに留めると扱いやすいです。作った後にどう動くかは敵自身やコンポーネント、いつ出すかはスポナーに任せます。

おまけ: 先に知っておくと良いこと

GodotではResourceをうまく使うと設計が軽くなる

コンポジションというとNodeを増やすイメージがありますが、すべてをNodeにする必要はありません。

  • 速度、HP、攻撃力のような調整データ: Resource
  • 毎フレーム処理したい機能: Node
  • シーン上に存在するキャラクター: CharacterBody2DNode2D

このように分けると、シーンツリーが必要以上に深くなりません。特に敵やアイテムのデータは、後続のカスタムResource記事と相性が良いテーマです。

継承を完全に避ける必要はない

この記事では継承のつらさを強調しましたが、継承が悪いわけではありません。

CharacterBody2DControlResourceRefCounted のようなGodotの土台を継承するのは自然です。また、敵全体に共通する最低限の処理を BaseEnemy にまとめる程度なら読みやすいことも多いです。

避けたいのは、「飛ぶ」「速い」「硬い」「毒を持つ」のような組み合わせを、全部クラス階層で表現しようとすることです。

迷ったら「変更理由」で分ける

設計で迷ったときは、次の問いが役立ちます。

このコードは、何が変わったときに修正されるのか?

敵の種類が増えたらファクトリー、移動の手触りが変わったら移動コンポーネント、装備効果が増えたらステータス計算。変更理由が違うものを同じファイルに詰め込むほど、後から読みづらくなります。

まとめ

  • デザインパターン は、増えたときに壊れにくくするための整理術
  • 継承 は土台を決めるときに使い、変わりやすい機能は コンポジション で部品化する
  • デコレーター は、装備やバフのように効果を重ねて最終値を作る考え方
  • ファクトリー は、敵やアイテムの生成ルールを1箇所に集める考え方
  • 小さいうちは素直に作り、コピペ・組み合わせ爆発・生成処理の散らばりが見えてから導入する

デザインパターンを覚える目的は、名前を暗記することではありません。「この処理はどこに置くと、後から変更しやすいか」を考えられるようになることです。

さらに学ぶために