Godot 4で「目的地マーカー」を作ろうとすると、ありがちな実装はこんな感じですよね。
- プレイヤーシーンに直接ロジックを書きまくる(
_process()がどんどん肥大化…) - UIシーンに「この矢印はあのターゲットを指す」みたいな依存を書いてしまう
- シーンごとに微妙に違う実装をコピペして、あとで全部直すハメになる
Godot標準のノード階層でも実現はできますが、
- プレイヤーが変わるたびにコードを修正
- UI構成が変わるたびに座標計算をやり直し
- シーンごとに「どのノードを参照するか」がバラバラ
…と、「継承&巨大シーン」に寄せるほどメンテがつらくなっていきます。
そこで今回は、どのシーンにもポン付けできる「ObjectiveMarker コンポーネント」を用意して、
- プレイヤー(またはカメラ)を基準に
- 画面外にある目的地の方向を
- 画面端の矢印アイコンで指し示す
という機能を「合成(Composition)」で実現していきましょう。
【Godot 4】画面外の目的地をスマートに案内!「ObjectiveMarker」コンポーネント
このコンポーネントは、ざっくり言うと次のようなことをやってくれます。
- ターゲット(目的地)ノードを指定
- 参照カメラ(またはプレイヤーに追従するカメラ)を指定
- ターゲットが画面外にあるときだけ、画面端に矢印を表示
- 矢印の回転・位置を自動で更新
そして、UIシーン側にがっつりロジックを書かずに、「ObjectiveMarker」コンポーネントを UI ノードにアタッチするだけで使えるようにします。
フルコード:ObjectiveMarker.gd
extends Control
class_name ObjectiveMarker
## 画面外にある目的地の方角を、画面端の矢印アイコンで指し示すコンポーネント。
##
## このノード自体は UI (CanvasLayer/Control) の子に置き、
## ターゲット(目的地)と参照カメラを指定して使います。
@export_category("必須参照")
@export var target: Node2D:
## 矢印が指し示す「目的地」ノード
## 例: ゴール地点、ボス、クエストマーカーなど
set(value):
target = value
_update_visibility()
@export var camera: Camera2D:
## 画面基準を決めるカメラ
## 通常はプレイヤーを追従している Camera2D を指定します
set(value):
camera = value
_update_visibility()
@export_category("見た目")
@export var arrow_texture: Texture2D:
## 矢印アイコンのテクスチャ
## 未指定の場合は、自分の子ノードの TextureRect / Sprite2D を利用してもOK
set(value):
arrow_texture = value
_apply_arrow_texture()
@export var arrow_size: Vector2 = Vector2(64, 64):
## 矢印アイコンの表示サイズ(Controlベースの場合に有効)
set(value):
arrow_size = value
_apply_arrow_size()
@export var arrow_color: Color = Color.WHITE:
## 矢印アイコンに乗せる Modulate 色
set(value):
arrow_color = value
_apply_arrow_color()
@export_category("表示設定")
@export var edge_margin: float = 32.0:
## 画面端からどれだけ内側に矢印を配置するか(ピクセル)
## 0 にすると完全な端に張り付きます
set(value):
edge_margin = value
@export var hide_when_on_screen: bool = true:
## ターゲットが画面内に入ったとき、矢印を非表示にするか
set(value):
hide_when_on_screen = value
_update_visibility()
@export var always_face_target: bool = true:
## 矢印をターゲット方向に回転させるか
set(value):
always_face_target = value
@export_category("デバッグ")
@export var debug_draw_line: bool = false:
## エディタや実行中に、カメラ中心からターゲット方向への線を描画(デバッグ用)
set(value):
debug_draw_line = value
queue_redraw()
## 内部参照
var _arrow_control: Control
var _arrow_texture_rect: TextureRect
func _ready() -> void:
## 内部UIノードを準備します
_setup_internal_nodes()
_apply_arrow_texture()
_apply_arrow_size()
_apply_arrow_color()
_update_visibility()
set_process(true)
func _process(delta: float) -> void:
if not is_instance_valid(target) or not is_instance_valid(camera):
visible = false
return
# カメラのビューポート矩形を取得
var viewport := get_viewport()
if viewport == null:
visible = false
return
var viewport_size: Vector2 = viewport.get_visible_rect().size
# ターゲットのワールド座標をスクリーン座標に変換
var target_screen_pos: Vector2 = camera.get_screen_position_of_target(target.global_position)
# ターゲットが画面内にいるかどうか
var is_on_screen := _is_point_inside_rect(
target_screen_pos,
Rect2(Vector2.ZERO, viewport_size)
)
if hide_when_on_screen and is_on_screen:
visible = false
return
visible = true
# カメラ中心からターゲットへの方向ベクトル
var screen_center := viewport_size * 0.5
var dir: Vector2 = (target_screen_pos - screen_center)
if dir == Vector2.ZERO:
dir = Vector2.UP # 同一座標ならとりあえず上向き
# 画面端にクランプした位置を計算
var clamped_pos := _clamp_to_screen_edge(screen_center, target_screen_pos, viewport_size, edge_margin)
global_position = clamped_pos
# 矢印の回転
if always_face_target:
rotation = dir.angle()
if debug_draw_line:
queue_redraw()
func _draw() -> void:
if not debug_draw_line:
return
if not is_instance_valid(camera) or not is_instance_valid(target):
return
var viewport := get_viewport()
if viewport == null:
return
var viewport_size: Vector2 = viewport.get_visible_rect().size
var screen_center := viewport_size * 0.5
var target_screen_pos: Vector2 = camera.get_screen_position_of_target(target.global_position)
# このノードは UI 空間にいるので、そのまま draw_line してOK
draw_line(
screen_center - global_position,
target_screen_pos - global_position,
Color(1, 0, 0, 0.5),
2.0
)
# --- 内部セットアップ -------------------------------------------------------
func _setup_internal_nodes() -> void:
## 子に TextureRect を自前で作るか、既存のものを流用する
_arrow_control = self
# 既存の TextureRect を探す(ユーザーが自分でレイアウトしている場合を考慮)
_arrow_texture_rect = get_node_or_null("Arrow") as TextureRect
if _arrow_texture_rect == null:
_arrow_texture_rect = TextureRect.new()
_arrow_texture_rect.name = "Arrow"
_arrow_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_arrow_texture_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_arrow_texture_rect.size_flags_vertical = Control.SIZE_SHRINK_CENTER
add_child(_arrow_texture_rect)
func _apply_arrow_texture() -> void:
if _arrow_texture_rect == null:
return
if arrow_texture:
_arrow_texture_rect.texture = arrow_texture
func _apply_arrow_size() -> void:
if _arrow_texture_rect == null:
return
_arrow_texture_rect.custom_minimum_size = arrow_size
_arrow_texture_rect.size = arrow_size
func _apply_arrow_color() -> void:
if _arrow_texture_rect == null:
return
_arrow_texture_rect.modulate = arrow_color
func _update_visibility() -> void:
visible = is_instance_valid(target) and is_instance_valid(camera)
if _arrow_texture_rect:
_arrow_texture_rect.visible = visible
# --- 位置計算系 ------------------------------------------------------------
func _is_point_inside_rect(point: Vector2, rect: Rect2) -> bool:
return rect.has_point(point)
func _clamp_to_screen_edge(center: Vector2, point: Vector2, screen_size: Vector2, margin: float) -> Vector2:
## 画面中心から point 方向に伸びるベクトルを、
## 「画面内(margin 分だけ内側)の矩形」との交点にクランプする
var half_size := screen_size * 0.5
var inner_rect := Rect2(
Vector2(margin, margin),
screen_size - Vector2(margin * 2.0, margin * 2.0)
)
var dir := (point - center)
if dir == Vector2.ZERO:
dir = Vector2.UP
# 非正規化方向ベクトルを正規化
var dir_norm := dir.normalized()
# 画面中心から十分遠くへ線分を伸ばして、その線分と inner_rect の交点を求める
var far_point := center + dir_norm * 10000.0
# 線分と矩形の交差判定を行う
var result := _line_rect_intersection(center, far_point, inner_rect)
if result == null:
# 念のため、画面内にクランプ
return Vector2(
clamp(point.x, inner_rect.position.x, inner_rect.position.x + inner_rect.size.x),
clamp(point.y, inner_rect.position.y, inner_rect.position.y + inner_rect.size.y)
)
return result
func _line_rect_intersection(p1: Vector2, p2: Vector2, rect: Rect2) -> Vector2:
## 線分 p1-p2 と矩形 rect の交点のうち、
## p1 に最も近いものを返す。交差しなければ null。
var points: Array[Vector2] = []
var r_min := rect.position
var r_max := rect.position + rect.size
# 矩形の4辺
var edges := [
[Vector2(r_min.x, r_min.y), Vector2(r_max.x, r_min.y)], # 上辺
[Vector2(r_max.x, r_min.y), Vector2(r_max.x, r_max.y)], # 右辺
[Vector2(r_max.x, r_max.y), Vector2(r_min.x, r_max.y)], # 下辺
[Vector2(r_min.x, r_max.y), Vector2(r_min.x, r_min.y)] # 左辺
]
for edge in edges:
var ip := Geometry2D.segment_intersects_segment(p1, p2, edge[0], edge[1])
if ip is Vector2:
points.append(ip)
if points.is_empty():
return null
# p1 から最も近い交点を採用
points.sort_custom(func(a: Vector2, b: Vector2) -> bool:
return a.distance_squared_to(p1) < b.distance_squared_to(p1)
)
return points[0]
# --- ユーティリティ ---------------------------------------------------------
func Camera2D.get_screen_position_of_target(self, world_pos: Vector2) -> Vector2:
## Camera2D を拡張するヘルパー関数。
## ワールド座標をスクリーン座標(UI空間)に変換します。
##
## Godot 4 では、CanvasItem の global_position と
## Camera2D の transform を組み合わせて計算できますが、
## ここではシンプルに viewport の CanvasTransform を利用します。
var viewport := get_viewport()
if viewport == null:
return world_pos
# world_pos をビューポート座標に変換
var xform := viewport.get_canvas_transform()
return xform * world_pos
※ Camera2D.get_screen_position_of_target() は「疑似拡張メソッド」的な書き方をしていますが、
Godot 4 の GDScript ではクラス名を前置した func Camera2D.xxx 形式でヘルパーを定義できます。
使い方の手順
① UI シーンに ObjectiveMarker を配置する
まずは UI 用のシーン、またはプレイヤーシーンにぶら下がっている CanvasLayer に ObjectiveMarker を追加します。
UIRoot (CanvasLayer) ├── Control │ └── ObjectiveMarker (Control) <-- このノードにスクリプトをアタッチ └── 他のUIいろいろ…
あるいはプレイヤーシーンに直接 UI を持たせているなら:
Player (CharacterBody2D)
├── Camera2D
├── Sprite2D
├── CollisionShape2D
└── CanvasLayer
└── ObjectiveMarker (Control)
ポイント: ObjectiveMarker は Control 派生なので、UI 空間(CanvasLayer / Control ツリー)に置きましょう。
② スクリプトをアタッチしてエクスポート変数を設定
ObjectiveMarker.gdを新規スクリプトとして作成し、上記コードをコピペ。- UI シーンの
ObjectiveMarkerノードにこのスクリプトをアタッチ。 - インスペクタから以下を設定します。
- target : 目的地となる
Node2D(例: ゴール地点) - camera : プレイヤーを追従している
Camera2D - arrow_texture : 矢印アイコンのテクスチャ
- arrow_size : アイコンの大きさ(デフォルト 64×64)
- edge_margin : 画面端からどれだけ内側に表示するか
- target : 目的地となる
例:ゴール地点を指し示す場合のシーン構成図はこんな感じです。
Main (Node2D)
├── Player (CharacterBody2D)
│ ├── Camera2D
│ ├── Sprite2D
│ └── CollisionShape2D
├── Goal (Node2D) <-- 目的地
│ └── Sprite2D
└── UIRoot (CanvasLayer)
└── ObjectiveMarker (Control)
このとき、ObjectiveMarker の target に Goal を、camera に Player/Camera2D を指定します。
③ 実行して挙動を確認する
- ゲームを実行し、プレイヤーから遠く離れた位置に
Goalを配置しておきます。 - ターゲットが画面外にあるとき、画面端に矢印が表示され、ターゲット方向を向くはずです。
- プレイヤーが近づいてターゲットが画面内に入ると、
hide_when_on_screen = trueの場合は矢印が非表示になります。
もし挙動がおかしい場合は、debug_draw_line を true にして、
カメラ中心からターゲットへの赤いラインが正しいか確認してみましょう。
④ 応用例:敵やクエストマーカーにも再利用する
コンポーネント指向の美味しいところは「どのターゲットにも同じ ObjectiveMarker を再利用できる」点です。
例えば、次のようなシーン構成で:
Main (Node2D)
├── Player (CharacterBody2D)
│ └── Camera2D
├── Boss (Node2D)
│ └── Sprite2D
├── QuestItem (Node2D)
│ └── Sprite2D
└── UIRoot (CanvasLayer)
├── BossMarker (ObjectiveMarker)
└── QuestMarker (ObjectiveMarker)
BossMarker.target = BossQuestMarker.target = QuestItem- 両方とも
camera = Player/Camera2D
とするだけで、「ボスへの矢印」「クエストアイテムへの矢印」をそれぞれ別アイコンで表示できます。
プレイヤーやカメラの実装には一切手を入れず、ObjectiveMarker コンポーネントの合成だけで機能追加できるのがポイントですね。
メリットと応用
この ObjectiveMarker コンポーネントを導入することで、次のようなメリットがあります。
- UI ロジックの分離
プレイヤーや敵のスクリプトから「目的地マーカー処理」を追い出せるので、_process()がすっきりします。 - シーン構造がフラットになる
「プレイヤーの子にマーカーをぶら下げて、その下に矢印…」といった深い階層を作らずに済みます。 - 使い回しが簡単
ボス、クエスト、NPC、アイテム…どれに対してもtargetを差し替えるだけで再利用可能です。 - レベルデザインが楽
レベルデザイナーは「どのノードを指すか」だけをインスペクタで指定すればよく、スクリプトには触らなくて済みます。
さらに、ObjectiveMarker 自体もコンポーネント的に小さくまとまっているので、
「別タイプのマーカー」を作りたくなったときも継承に頼らず、設定と薄い拡張で対応しやすいです。
改造案:距離に応じて矢印の透明度を変える
例えば、「目的地が近いときは矢印を薄くする」みたいな演出を入れたい場合は、
_process() の最後あたりに、次のような処理を追加できます。
func _update_alpha_by_distance() -> void:
if not is_instance_valid(target) or not is_instance_valid(camera):
return
# カメラ位置とターゲット位置の距離(ワールド座標)
var dist := camera.global_position.distance_to(target.global_position)
# 0 ~ max_dist の範囲でアルファを 1.0 ~ 0.2 にマッピング
var max_dist := 2000.0
var t := clamp(dist / max_dist, 0.0, 1.0)
var alpha := lerp(1.0, 0.2, 1.0 - t)
modulate.a = alpha
そして _process() の最後で _update_alpha_by_distance() を呼べば、
距離に応じて自然にフェードするマーカーが完成します。
このように、「矢印の見た目のルール」は別関数に切り出しておくと、
あとから「スケールも変えよう」「色も変えよう」といった拡張がしやすくなります。
継承クラスを増やすのではなく、小さな関数の合成でロジックを組み立てていくのが、Godot 4 でも気持ちいいやり方ですね。
