ボス戦って、つい「Boss.gd」を巨大クラスに育てがちですよね。
HP管理、攻撃パターン、アニメーション、SE、フェーズ管理…全部1つのスクリプトに詰め込むと、
- HPロジックと攻撃ロジックが密結合になってテストしづらい
- 「別のボスでも同じ形態変化を使いたい」のにコピペ地獄になる
- アニメーションやエフェクトを変えたいだけなのに、巨大なBoss.gdを開いてスクロール地獄
Godotだと、つい「ボス用のベースシーンを継承して、子シーンごとにフェーズ違いを作る」みたいな構成にもなりがちですが、継承ベースの設計は後からの仕様変更に弱いんですよね。
そこで今回は、「HPが半分を切ったら形態変化する」というよくあるボスギミックを、1つの独立コンポーネントに切り出してしまいましょう。
ボス本体は「HPを持っていること」さえ守れば、形態変化のロジックは全部コンポーネントにお任せできます。
【Godot 4】HP半分でド派手に変身!「BossPhase」コンポーネント
今回作る BossPhase コンポーネントは、ざっくり言うとこんなことをします。
- 親ノード(ボス)の「最大HP」と「現在HP」を監視する
- HPが一定割合(デフォルト50%)を下回った瞬間に「フェーズ2」に移行
- フェーズ2移行時に:
- アニメーション切り替え(例: Idle → Rage)
- 攻撃パターン用コンポーネントの有効/無効切り替え
- 見た目(Sprite2D / AnimatedSprite2D / AnimationTree など)の差し替え
- SE再生、画面揺れなどの演出
- 必要なら、スクリプト側でシグナルを受けて「自前のフェーズ2処理」を書ける
ボス本体は「HPをどう減らすか」に集中し、
「いつ形態変化するか・変化したときに何をするか」は BossPhase が担当する構成ですね。
フルコード:BossPhase.gd
extends Node
class_name BossPhase
## ボスのHPを監視して、一定割合を下回ったら形態変化(フェーズ2)を発火するコンポーネント
##
## 親ノードは「最大HP」と「現在HP」を提供している必要があります。
## - プロパティで提供する場合:
## var max_hp: int
## var current_hp: int
## - メソッドで提供する場合:
## func get_max_hp() -> int
## func get_current_hp() -> int
##
## 形態変化のタイミングは「HPが max_hp * phase_threshold_ratio を下回った瞬間」です。
signal phase_changed(new_phase: int)
## フェーズが変わったときに発火します。
## 例: 1 -> 2 に変わったら phase_changed.emit(2)
@export_range(0.1, 0.9, 0.05)
var phase_threshold_ratio: float = 0.5:
set(value):
phase_threshold_ratio = clampf(value, 0.1, 0.9)
## 形態変化するHP割合。
## 0.5 なら「HPが最大の50%を下回った瞬間」にフェーズ2へ。
@export var auto_connect_on_ready: bool = true
## true の場合、_ready() 時に自動で親のHP情報を取得し、監視を開始します。
@export_group("見た目の切り替え")
@export var phase1_sprite_node: NodePath
## フェーズ1で表示するスプライト(Sprite2D / AnimatedSprite2D など)
@export var phase2_sprite_node: NodePath
## フェーズ2で表示するスプライト。未指定なら切り替えを行わない。
@export var phase2_animation_player: NodePath
## フェーズ2移行時に再生する AnimationPlayer(ボス本体の演出用)
@export var phase2_animation_name: StringName = &"phase2_start"
## 上記 AnimationPlayer で再生するアニメーション名
@export_group("攻撃パターンの切り替え")
@export var phase1_attack_nodes: Array[NodePath] = []
## フェーズ1用の攻撃コンポーネント群。フェーズ2移行時に無効化したいノードを登録。
@export var phase2_attack_nodes: Array[NodePath] = []
## フェーズ2用の攻撃コンポーネント群。フェーズ2移行時に有効化したいノードを登録。
@export_group("演出オプション")
@export var play_sfx_on_phase2: bool = false
@export_file("*.wav", "*.ogg")
var phase2_sfx_path: String
## フェーズ2開始時に再生するSE。AudioStreamPlayer を自前で用意できないとき用の簡易機能。
@export var camera_shake_node: NodePath
## カメラ揺れ用のコンポーネント(例: CameraShake)を指定すると、フェーズ2時に揺らします。
@export var camera_shake_intensity: float = 1.0
var _current_phase: int = 1:
set(value):
if _current_phase == value:
return
_current_phase = value
phase_changed.emit(_current_phase)
var _boss: Node = null
var _audio_player: AudioStreamPlayer = null
func _ready() -> void:
if auto_connect_on_ready:
_setup_boss_reference()
_setup_audio_player()
_update_visual_for_phase(1) # 初期状態はフェーズ1想定
func _process(delta: float) -> void:
if not is_instance_valid(_boss):
return
# すでにフェーズ2以降なら監視をやめる(単純な2フェーズ制を想定)
if _current_phase >= 2:
return
var max_hp := _get_boss_max_hp()
var current_hp := _get_boss_current_hp()
if max_hp <= 0:
return
var threshold_hp := int(max_hp * phase_threshold_ratio)
# 「下回った瞬間」にフェーズ2へ
if current_hp < threshold_hp:
_enter_phase2()
# --- セットアップ系 ---------------------------------------------------------
func _setup_boss_reference() -> void:
## 親ノードをボスとして扱う前提。
_boss = get_parent()
if not is_instance_valid(_boss):
push_warning("BossPhase: 親ノードが見つかりません。HP監視ができません。")
return
# ボスが必要なHP情報を持っているか軽くチェック
if not _boss_has_hp_interface():
push_warning("BossPhase: 親ノードに HP 情報のインターフェースが見つかりません。" +
"max_hp/current_hp プロパティ、または get_max_hp()/get_current_hp() を実装してください。")
func _setup_audio_player() -> void:
if not play_sfx_on_phase2:
return
if phase2_sfx_path.is_empty():
push_warning("BossPhase: play_sfx_on_phase2 が true ですが、phase2_sfx_path が空です。")
return
_audio_player = AudioStreamPlayer.new()
add_child(_audio_player)
var stream := load(phase2_sfx_path)
if stream is AudioStream:
_audio_player.stream = stream
else:
push_warning("BossPhase: SE ファイルの読み込みに失敗しました: %s" % phase2_sfx_path)
_audio_player.queue_free()
_audio_player = null
# --- HP 情報の取得 ---------------------------------------------------------
func _boss_has_hp_interface() -> bool:
if not is_instance_valid(_boss):
return false
var has_props := _boss.has_variable("max_hp") and _boss.has_variable("current_hp")
var has_methods := _boss.has_method("get_max_hp") and _boss.has_method("get_current_hp")
return has_props or has_methods
func _get_boss_max_hp() -> int:
if not is_instance_valid(_boss):
return 0
if _boss.has_method("get_max_hp"):
return int(_boss.call("get_max_hp"))
elif _boss.has_variable("max_hp"):
return int(_boss.get("max_hp"))
return 0
func _get_boss_current_hp() -> int:
if not is_instance_valid(_boss):
return 0
if _boss.has_method("get_current_hp"):
return int(_boss.call("get_current_hp"))
elif _boss.has_variable("current_hp"):
return int(_boss.get("current_hp"))
return 0
# --- フェーズ遷移 ---------------------------------------------------------
func _enter_phase2() -> void:
_current_phase = 2
# 見た目の切り替え
_update_visual_for_phase(2)
# 攻撃パターンの切り替え
_switch_attack_nodes()
# 演出(アニメーション / SE / カメラ揺れ)
_play_phase2_animation()
_play_phase2_sfx()
_do_camera_shake()
# ここから先はユーザー側でシグナルを拾って自由に拡張できます
# 例: ボスの移動速度を上げる、フィールドギミックを起動する、など
func _update_visual_for_phase(phase: int) -> void:
var sprite1 := (phase1_sprite_node != NodePath()) ? get_node_or_null(phase1_sprite_node) : null
var sprite2 := (phase2_sprite_node != NodePath()) ? get_node_or_null(phase2_sprite_node) : null
match phase:
1:
if sprite1 and "visible" in sprite1:
sprite1.visible = true
if sprite2 and "visible" in sprite2:
sprite2.visible = false
2:
if sprite1 and "visible" in sprite1:
sprite1.visible = false
if sprite2 and "visible" in sprite2:
sprite2.visible = true
func _switch_attack_nodes() -> void:
# フェーズ1攻撃を無効化
for path in phase1_attack_nodes:
var node := get_node_or_null(path)
if node and "set_process" in node:
node.set_process(false)
if node and "set_physics_process" in node:
node.set_physics_process(false)
if node and "enabled" in node:
node.enabled = false
# フェーズ2攻撃を有効化
for path in phase2_attack_nodes:
var node := get_node_or_null(path)
if node and "set_process" in node:
node.set_process(true)
if node and "set_physics_process" in node:
node.set_physics_process(true)
if node and "enabled" in node:
node.enabled = true
func _play_phase2_animation() -> void:
if phase2_animation_player == NodePath():
return
var anim_player := get_node_or_null(phase2_animation_player)
if anim_player is AnimationPlayer and phase2_animation_name != StringName():
if anim_player.has_animation(phase2_animation_name):
anim_player.play(phase2_animation_name)
else:
push_warning("BossPhase: AnimationPlayer にアニメーション '%s' がありません。" % phase2_animation_name)
func _play_phase2_sfx() -> void:
if not _audio_player:
return
if _audio_player.stream:
_audio_player.play()
func _do_camera_shake() -> void:
if camera_shake_node == NodePath():
return
var shake := get_node_or_null(camera_shake_node)
if not shake:
return
# CameraShake コンポーネントを想定した汎用呼び出し。
# 例:
# func shake(intensity: float, duration: float = 0.3)
if shake.has_method("shake"):
shake.call("shake", camera_shake_intensity)
# --- 公開API ---------------------------------------------------------
func get_current_phase() -> int:
## 現在のフェーズ番号を返します(1 または 2)。
return _current_phase
func force_phase2() -> void:
## デバッグ用: HPに関係なく強制的にフェーズ2へ移行します。
_enter_phase2()
使い方の手順
ここでは、2Dアクションゲームの「ドラゴンボス」を例にします。
フェーズ1は火の玉をゆっくり撃つだけ、フェーズ2では連射+突進攻撃に変化するイメージです。
手順①:ボス本体にシンプルなHPインターフェースを用意する
まずはボス本体(例: DragonBoss.gd)に、max_hp と current_hp を用意します。
コンポーネントからは「プロパティ」か「getterメソッド」で読めればOKです。
extends CharacterBody2D
@export var max_hp: int = 200
var current_hp: int
func _ready() -> void:
current_hp = max_hp
func apply_damage(amount: int) -> void:
current_hp = max(current_hp - amount, 0)
if current_hp == 0:
_die()
func _die() -> void:
queue_free()
これだけで BossPhase から HP が読めるようになります。
手順②:シーンに BossPhase コンポーネントをアタッチする
シーン構成のイメージはこんな感じです。
DragonBoss (CharacterBody2D) ├── SpritePhase1 (Sprite2D) ├── SpritePhase2 (Sprite2D) ├── CollisionShape2D ├── AttackPatternPhase1 (Node / 自作コンポーネント) ├── AttackPatternPhase2 (Node / 自作コンポーネント) ├── AnimationPlayer ├── Camera2D ├── CameraShake (Node / カメラ揺れコンポーネント) └── BossPhase (Node)
BossPhase ノードを追加し、インスペクタで以下を設定します。
- phase_threshold_ratio: 0.5(HP50%で形態変化)
- phase1_sprite_node:
../SpritePhase1 - phase2_sprite_node:
../SpritePhase2 - phase2_animation_player:
../AnimationPlayer - phase2_animation_name:
"phase2_start"(演出用アニメ) - phase1_attack_nodes:
[ ../AttackPatternPhase1 ] - phase2_attack_nodes:
[ ../AttackPatternPhase2 ] - play_sfx_on_phase2: true
- phase2_sfx_path:
res://sounds/boss_roar.ogg - camera_shake_node:
../CameraShake - camera_shake_intensity: 1.5 など
SpritePhase2 は最初は非表示にしておくか、BossPhase が _ready() でフェーズ1状態にしてくれるので、どちらでもOKです。
手順③:攻撃パターンをコンポーネント化しておく
フェーズごとの攻撃は、ボス本体とは別ノード(コンポーネント)として分離しておくと綺麗です。
# AttackPatternPhase1.gd
extends Node
@export var fireball_scene: PackedScene
@export var interval: float = 2.0
var _timer: float = 0.0
func _process(delta: float) -> void:
_timer -= delta
if _timer <= 0.0:
_timer = interval
_shoot_fireball()
func _shoot_fireball() -> void:
if not fireball_scene:
return
var fireball = fireball_scene.instantiate()
get_tree().current_scene.add_child(fireball)
fireball.global_position = get_parent().global_position
フェーズ2用も同様に、連射や突進など別のロジックを別スクリプトに分けておきます。BossPhase は set_process() / enabled を切り替えるだけで、攻撃パターンをスイッチしてくれます。
手順④:シグナルで細かい調整をする(任意)
もっと細かい制御をしたい場合は、BossPhase の phase_changed シグナルをボス本体で拾いましょう。
# DragonBoss.gd の一部
func _ready() -> void:
var boss_phase: BossPhase = $BossPhase
boss_phase.phase_changed.connect(_on_boss_phase_changed)
func _on_boss_phase_changed(new_phase: int) -> void:
if new_phase == 2:
# 例: フェーズ2で移動速度を上げる
set("speed", 400.0)
こうしておくと、「形態変化時にだけ一部ステータスを変える」「フィールドギミックを起動する」といった処理も、ボス本体側で適度に分離された形で書けます。
メリットと応用
BossPhase コンポーネントを使うメリットはかなり多いです。
- ボスごとの巨大スクリプトからフェーズ管理を切り離せる
「HPが半分を切ったら〜」というロジックを、全ボスでコピペしなくて済みます。 - シーン構造がフラットで見通しが良くなる
「BossBase」シーンを継承して「Phase2Boss」「Phase3Boss」みたいな派生シーンを作らなくてよくなり、
単一シーン+コンポーネントの組み合わせで完結します。 - 攻撃パターンもコンポーネントとして再利用できる
「Phase1はゆっくり弾」「Phase2は弾幕」みたいな攻撃コンポーネントを、別のボスにも簡単に転用できます。 - 演出の差し替えが楽
Sprite2D / AnimationPlayer / カメラ揺れ / SE などは全部インスペクタで差し替え可能。
レベルデザイナーがスクリプトを触らずに調整しやすい構成ですね。
「継承でボス専用クラスを作る」のではなく、「HPを持った何かに BossPhase をアタッチする」だけで形態変化が手に入るので、合成(Composition)らしい設計になっています。
改造案:3フェーズ以上に拡張する
今の BossPhase はシンプルな2フェーズ制ですが、
「HP75%でフェーズ2、HP50%でフェーズ3、HP25%でフェーズ4」みたいにしたくなることもありますよね。
そんなときは、ざっくりこんな関数を追加して、Array でしきい値を持つようにしても良いでしょう。
@export_group("多段フェーズ(拡張用)")
@export var extra_phase_thresholds: Array[float] = []
## 例: [0.75, 0.5, 0.25] とすると、75%, 50%, 25% を切るたびにフェーズ+1
func _check_multi_phase(max_hp: int, current_hp: int) -> void:
# すでに全フェーズに到達しているなら何もしない
var max_phase := 1 + extra_phase_thresholds.size()
if _current_phase >= max_phase:
return
# 現在フェーズより先のしきい値をチェック
for i in range(extra_phase_thresholds.size()):
var phase_index := i + 2 # フェーズ2以降
if phase_index <= _current_phase:
continue
var threshold := int(max_hp * extra_phase_thresholds[i])
if current_hp < threshold:
_current_phase = phase_index
phase_changed.emit(_current_phase)
# ここでフェーズごとに攻撃や見た目を切り替える処理を追加
このように、BossPhase 自体も「コンポーネントとして後から拡張しやすい」構成になっています。
まずは今回の2フェーズ版をそのままコピペして使ってみて、
「うちのゲームのボス戦パターン」に合わせて少しずつ改造していくと良いですね。
