Godot 4で3Dゲームを書いていると、敵の頭上にHPバーを出したくなることって多いですよね。
でも、素直に実装しようとすると、こんな「あるある」にハマりがちです。
- 敵シーンの中に無理やり
CanvasLayerやControlをネストして、3DとUIがごちゃ混ぜになる - 敵ごとにHPバーのスクリプトを継承して増やしていき、気づいたら「HPバー付き敵ベースクラス」が肥大化
- 3D座標 → 画面座標への変換コードがあちこちにコピペされて、カメラを変えたら全部直す羽目になる
こういうときこそ「継承より合成(Composition)」の出番です。
敵本体は「HPを持つだけ」、HPバーの見た目や追従処理は「独立したコンポーネント」に切り出して、必要な敵にだけアタッチしていきましょう。
この記事では、3D空間上の敵の位置をUI座標に変換して追従する「頭上HPバー」コンポーネント、FloatingHealthBar を紹介します。
【Godot 4】3D座標をUIにマッピング!「FloatingHealthBar」コンポーネント
FloatingHealthBar は、以下のような責務を持つコンポーネントです。
- 指定した「ターゲットノード」(通常は敵の
Node3D)の頭上にHPバーを表示 - 3Dワールド座標をアクティブなカメラでスクリーン座標に変換し、UIに追従させる
- HPの現在値と最大値を受け取り、
ProgressBarで可視化 - UIは 2D シーン(例:
CanvasLayer配下)にまとめて置けるので、3Dシーンと綺麗に分離できる
つまり、敵シーンには「HPを持つコンポーネント」だけを付けておき、
画面上のHPバーは FloatingHealthBar が一括で面倒を見てくれる、という構造にできます。
フルコード: FloatingHealthBar.gd
このコンポーネント自体は Control を継承した UI ノードとして実装します。
シーンとしては「Control の子に ProgressBar を持つ」構成を想定しています。
extends Control
class_name FloatingHealthBar
"""
3D空間のターゲット(敵など)の頭上にHPバーを表示するコンポーネント。
・このノード自体は UI(CanvasLayer / Control)ツリーに置く
・ターゲットは 3D シーン側の Node3D を参照する(export でも、コードからセットでもOK)
・カメラは自動取得 or 明示的に指定できる
"""
@export_group("Target Settings")
@export var target: Node3D:
set(value):
target = value
# ターゲットが変わったら毎フレーム更新するようにする
_set_process_state()
@export var head_offset: Vector3 = Vector3(0, 2.0, 0)
# ↑ 3D空間上で、ターゲットの「頭の高さ」までオフセットする量
@export_group("Camera Settings")
@export var camera_3d: Camera3D
# ↑ 未指定の場合は、_ready で get_viewport().get_camera_3d() から取得を試みる
@export_group("HP Settings")
@export var max_health: float = 100.0:
set(value):
max_health = max(value, 1.0)
_update_health_ui()
@export var current_health: float = 100.0:
set(value):
current_health = clamp(value, 0.0, max_health)
_update_health_ui()
@export_group("Behavior")
@export var hide_when_full: bool = false
# ↑ HPが満タンのときはバーを非表示にするかどうか
@export var hide_when_offscreen: bool = true
# ↑ ターゲットが画面外に出たら非表示にするかどうか
@export var screen_offset: Vector2 = Vector2(0, -40)
# ↑ 2Dスクリーン座標上でのオフセット(ピクセル)。-Yで少し上にずらす
# 内部参照
var _progress_bar: ProgressBar
func _ready() -> void:
# ProgressBar を子ノードから探す
_progress_bar = _find_progress_bar()
if not _progress_bar:
push_warning("FloatingHealthBar: 子に ProgressBar が見つかりません。UIを表示できません。")
# カメラが指定されていなければ、ビューポートから自動取得
if not camera_3d:
camera_3d = get_viewport().get_camera_3d()
if not camera_3d:
push_warning("FloatingHealthBar: Camera3D が見つかりません。位置の更新ができません。")
# 初期HP表示を反映
_update_health_ui()
# 初期の処理状態を設定
_set_process_state()
func _process(delta: float) -> void:
if not is_instance_valid(target) or not is_instance_valid(camera_3d):
# ターゲットやカメラが無効なら非表示にして処理停止
visible = false
set_process(false)
return
_update_position()
_update_visibility()
func _set_process_state() -> void:
# ターゲットとカメラが揃っているときだけ処理を回す
var should_process := is_instance_valid(target) and is_instance_valid(camera_3d)
set_process(should_process)
if not should_process:
visible = false
func _update_position() -> void:
# 3D空間のターゲット位置 + 頭上オフセット
var world_pos: Vector3 = target.global_transform.origin + head_offset
# 3D → 2D スクリーン座標に変換
var viewport := get_viewport()
if not viewport:
return
# project_position でスクリーン座標を取得
var screen_pos: Vector2 = camera_3d.unproject_position(world_pos)
# 画面上のオフセットを適用
screen_pos += screen_offset
# この FloatingHealthBar は UI ツリー上にあるので、rect_position を直接いじる
# 親が CanvasLayer/Control のどこにいようと、スクリーン座標系で扱える
position = screen_pos
func _update_visibility() -> void:
# HP満タン時に非表示にするオプション
if hide_when_full and current_health >= max_health:
visible = false
return
# 画面外に出たら非表示にするオプション
if hide_when_offscreen:
var viewport_rect := Rect2(Vector2.ZERO, get_viewport_rect().size)
if not viewport_rect.has_point(position):
visible = false
return
visible = true
func _update_health_ui() -> void:
if not _progress_bar:
return
_progress_bar.max_value = max_health
_progress_bar.value = current_health
# HPが0のときは薄くするとか、色を変えるなどの表現もここでできる
if current_health <= 0.0:
modulate = Color(1, 1, 1, 0.3)
else:
modulate = Color(1, 1, 1, 1.0)
func _find_progress_bar() -> ProgressBar:
# 子孫ノードから最初に見つかった ProgressBar を返す
for child in get_children():
if child is ProgressBar:
return child
if child is Control:
var found := _find_progress_bar_recursive(child)
if found:
return found
return null
func _find_progress_bar_recursive(node: Node) -> ProgressBar:
for child in node.get_children():
if child is ProgressBar:
return child
if child is Control:
var found := _find_progress_bar_recursive(child)
if found:
return found
return null
# 外部からHPを更新するためのヘルパー関数
func set_health(value: float, max_value: float = -1.0) -> void:
if max_value > 0.0:
max_health = max_value
current_health = value
# 外部からターゲットを差し替えるとき用
func attach_to(target_node: Node3D) -> void:
target = target_node
_set_process_state()
使い方の手順
ここからは実際に「敵の頭上にHPバーを出す」までの手順を見ていきましょう。
手順①: HPバーUIシーンを作る
- 新しいシーンを作成し、ルートに
Controlを配置します。 - 子ノードとして
TextureProgressBarでもProgressBarでもOKなので配置します。 - ルートの
Controlに、上記のFloatingHealthBar.gdをアタッチします。 - 必要に応じて、
head_offsetやscreen_offsetをインスペクタで調整します。
シーン構成例:
FloatingHealthBar (Control) └── ProgressBar
このシーンを res://ui/FloatingHealthBar.tscn のようなパスで保存しておきます。
手順②: 3Dの敵シーンを用意する
敵側はシンプルに「3Dモデル+HPを持つコンポーネント」にしておきます。
ここでは例として、Enemy シーンを以下のように構成します。
Enemy (CharacterBody3D) ├── MeshInstance3D ├── CollisionShape3D └── Health (Node) ※HPを管理するコンポーネント(例)
Health コンポーネントは、例えばこんなシンプルなスクリプトにできます。
extends Node
class_name Health
@export var max_health: float = 100.0
var current_health: float
signal health_changed(current: float, max: float)
signal died
func _ready() -> void:
current_health = max_health
func apply_damage(amount: float) -> void:
current_health = clamp(current_health - amount, 0.0, max_health)
health_changed.emit(current_health, max_health)
if current_health <= 0.0:
died.emit()
手順③: ゲームUIシーンに FloatingHealthBar をまとめて置く
3DシーンとUIシーンを分離するために、UI用のシーン(例: MainUI)を作り、
そこに CanvasLayer を置いて、その子として FloatingHealthBar をインスタンス化します。
Main (Node3D) ※3Dのメインシーン
├── Player (CharacterBody3D)
├── EnemySpawner (Node)
├── Camera3D
└── MainUI (CanvasLayer)
└── EnemyHealthBarContainer (Control)
└── FloatingHealthBar (Control) ※プレイヤー用や敵用など複数置いてもOK
よりコンポーネント指向にするなら、Enemy がスポーンされたタイミングでFloatingHealthBar のインスタンスを生成し、ターゲットにアタッチするのがおすすめです。
手順④: 敵とHPバーをひもづける
例えば、敵を生成する EnemySpawner から、HPバーを紐づけるコードはこんな感じになります。
extends Node
@export var enemy_scene: PackedScene
@export var floating_health_bar_scene: PackedScene
@export var ui_health_bar_container: Control # MainUI の中のコンテナを指定
func spawn_enemy(at_position: Vector3) -> void:
var enemy := enemy_scene.instantiate() as CharacterBody3D
enemy.global_position = at_position
add_child(enemy)
# HPコンポーネントを取得
var health := enemy.get_node("Health") as Health
# HPバーUIを生成してUIツリーに追加
var bar := floating_health_bar_scene.instantiate() as FloatingHealthBar
ui_health_bar_container.add_child(bar)
# ターゲットと初期HPを設定
bar.attach_to(enemy)
bar.set_health(health.current_health, health.max_health)
# Healthコンポーネントのシグナルと連携
health.health_changed.connect(func(current: float, max: float) -> void:
if is_instance_valid(bar):
bar.set_health(current, max)
)
health.died.connect(func() -> void:
if is_instance_valid(bar):
bar.queue_free()
)
この形にしておくと、
- 敵は「HPを持つ」だけで、UIのことは一切知らなくてよい
- HPバーは「ターゲットの Node3D と HP 値」を知っていれば仕事ができる
- どの敵にどのHPバーを付けるかは
EnemySpawner側で自由に決められる
という、かなり気持ちの良いコンポーネント分離になります。
メリットと応用
FloatingHealthBar を導入することで得られるメリットを整理してみましょう。
- 3DシーンとUIシーンが完全に分離できる
敵シーンの中にCanvasLayerやControlを埋め込まなくて済むので、ノード階層がスッキリします。 - 「HPバー付き敵ベースクラス」が不要
敵のクラスにHPバーのロジックを抱え込まず、HealthとFloatingHealthBarという2つのコンポーネントに分離できます。 - 再利用性が高い
プレイヤーキャラや味方NPC、動くオブジェクト(壊せる箱など)にも、そのまま使い回せます。
3DのターゲットとHP値を渡すだけでOKなので、「HPバー付き何か」を量産できます。 - カメラの差し替えにも強い
camera_3dを差し替えるだけで、TPSカメラ・RTSカメラ・ミニマップ用カメラなど、
いろいろな視点に対応できます。
さらに、応用としてはこんなことも簡単にできます。
- HPが少なくなったらバーの色を赤くする
- ダメージを受けたときにバーを一瞬大きくして目立たせる
- ボスだけは「名前+HPバー」の複合UIにする
例えば「HPが30%以下になったらバーを赤くする」改造案は、FloatingHealthBar に以下のような関数を足すだけでOKです。
func _update_health_ui() -> void:
if not _progress_bar:
return
_progress_bar.max_value = max_health
_progress_bar.value = current_health
# HP割合に応じて色を変える例
var ratio := current_health / max_health
if ratio <= 0.3:
_progress_bar.modulate = Color(1, 0.2, 0.2) # 赤っぽく
elif ratio <= 0.6:
_progress_bar.modulate = Color(1, 0.8, 0.2) # 黄っぽく
else:
_progress_bar.modulate = Color(1, 1, 1) # 通常
# 0のときは半透明にする
if current_health <= 0.0:
modulate = Color(1, 1, 1, 0.3)
else:
modulate = Color(1, 1, 1, 1.0)
このように、見た目や表現は UI コンポーネント側で完結させておくと、
ゲームロジック(敵の行動やHP管理)と完全に分離できて、後からの調整もしやすくなります。
「3Dのロジックは3Dシーン」「見た目のUIはUIシーン」「橋渡しはコンポーネント」で分ける、
この構成に慣れてくると、Godot 4 の開発体験がぐっと快適になりますよ。
