Godot 4で3Dゲームを書いていると、敵の頭上にHPバーを出したくなることって多いですよね。
でも、素直に実装しようとすると、こんな「あるある」にハマりがちです。

  • 敵シーンの中に無理やり CanvasLayerControl をネストして、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シーンを作る

  1. 新しいシーンを作成し、ルートに Control を配置します。
  2. 子ノードとして TextureProgressBar でも ProgressBar でもOKなので配置します。
  3. ルートの Control に、上記の FloatingHealthBar.gd をアタッチします。
  4. 必要に応じて、head_offsetscreen_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シーンが完全に分離できる
    敵シーンの中に CanvasLayerControl を埋め込まなくて済むので、ノード階層がスッキリします。
  • 「HPバー付き敵ベースクラス」が不要
    敵のクラスにHPバーのロジックを抱え込まず、HealthFloatingHealthBar という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 の開発体験がぐっと快適になりますよ。