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 ツリー)に置きましょう。

② スクリプトをアタッチしてエクスポート変数を設定

  1. ObjectiveMarker.gd を新規スクリプトとして作成し、上記コードをコピペ。
  2. UI シーンの ObjectiveMarker ノードにこのスクリプトをアタッチ。
  3. インスペクタから以下を設定します。
    • target : 目的地となる Node2D(例: ゴール地点)
    • camera : プレイヤーを追従している Camera2D
    • arrow_texture : 矢印アイコンのテクスチャ
    • arrow_size : アイコンの大きさ(デフォルト 64×64)
    • edge_margin : 画面端からどれだけ内側に表示するか

例:ゴール地点を指し示す場合のシーン構成図はこんな感じです。

Main (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Camera2D
 │    ├── Sprite2D
 │    └── CollisionShape2D
 ├── Goal (Node2D)              <-- 目的地
 │    └── Sprite2D
 └── UIRoot (CanvasLayer)
      └── ObjectiveMarker (Control)

このとき、ObjectiveMarkertargetGoal を、cameraPlayer/Camera2D を指定します。

③ 実行して挙動を確認する

  • ゲームを実行し、プレイヤーから遠く離れた位置に Goal を配置しておきます。
  • ターゲットが画面外にあるとき、画面端に矢印が表示され、ターゲット方向を向くはずです。
  • プレイヤーが近づいてターゲットが画面内に入ると、hide_when_on_screen = true の場合は矢印が非表示になります。

もし挙動がおかしい場合は、debug_draw_linetrue にして、
カメラ中心からターゲットへの赤いラインが正しいか確認してみましょう。

④ 応用例:敵やクエストマーカーにも再利用する

コンポーネント指向の美味しいところは「どのターゲットにも同じ ObjectiveMarker を再利用できる」点です。

例えば、次のようなシーン構成で:

Main (Node2D)
 ├── Player (CharacterBody2D)
 │    └── Camera2D
 ├── Boss (Node2D)
 │    └── Sprite2D
 ├── QuestItem (Node2D)
 │    └── Sprite2D
 └── UIRoot (CanvasLayer)
      ├── BossMarker (ObjectiveMarker)
      └── QuestMarker (ObjectiveMarker)
  • BossMarker.target = Boss
  • QuestMarker.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 でも気持ちいいやり方ですね。