RTSやMOBAっぽい「クリックした場所にユニットがスーッと移動していく」挙動、Godot 4でももちろん作れます。ただ、素直にやろうとすると:

  • プレイヤーごとに _unhandled_input() を書く
  • RayCast2D / RayCast3D を毎回個別に設定する
  • NavigationAgent2D / 3D をシーンごとにカスタマイズ
  • 「このユニットはクリック移動」「あのユニットはしない」みたいな条件分岐がスクリプトに増殖

…と、結局「プレイヤー用ベースクラス」を作って継承地獄になりがちなんですよね。
そこで今回は、どんなユニットにもポン付けできる「ClickToMove」コンポーネントとして切り出してみましょう。

NavigationAgent2D(または3D)さえアタッチしておけば、クリックした地点を自動で目的地にセットしてくれるコンポーネントです。
「継承より合成」の哲学で、クリック移動のロジックを1か所に閉じ込めてしまいましょう。

【Godot 4】RTS風クリック移動をポン付け!「ClickToMove」コンポーネント

今回は2D版を例にしますが、3D版もほぼ同じ構造で書けます。
NavigationAgent2D と組み合わせて、クリック位置をそのまま目的地にするだけの、シンプルだけど汎用性の高いコンポーネントです。

フルコード(GDScript / Godot 4)


extends Node
class_name ClickToMove
##
## ClickToMove.gd
## - マウスクリック位置を NavigationAgent2D の目的地に設定するコンポーネント
## - RTS / MOBA 風の「地面クリックで移動」を簡単に実装できます
##
## 使い方(概要)
## - 親ノードに CharacterBody2D などの移動主体を置く
## - 同じ親に NavigationAgent2D を置く
## - この ClickToMove コンポーネントを同じ親にアタッチ
## - 入力は「マウス左ボタン」や「アクション名」で制御可能
##

@export var input_action: StringName = "click_move"
## クリック移動に使う InputMap のアクション名
## - 例: InputMap で「click_move」にマウス左ボタンを割り当てる
## - 空文字にするとアクションではなく「生の左クリック」を監視します

@export var use_camera_viewport: bool = true
## カメラを使ってワールド座標に変換するかどうか
## - true: 現在のアクティブ Camera2D の viewport_to_world() で変換
## - false: get_global_mouse_position() をそのまま使用(2D の場合はこれで十分なことが多い)

@export var click_only_on_layer: bool = false
## 特定の物理レイヤー上だけを目的地にしたい場合 true
## - 例えば「地面」コリジョンだけを有効にしたいときに使います

@export_flags_2d_physics var ground_layer: int = 1
## click_only_on_layer = true のときに使用する 2D 物理レイヤー
## - 例: 地面 TileMap を layer 1 に置き、ユニットやUIは別レイヤーにするなど

@export var max_ray_distance: float = 10_000.0
## レイキャストの最大距離(カメラからマウス方向)
## - 2D では通常かなり大きめで問題ありません

@export var debug_draw_target: bool = true
## クリックした目的地をデバッグ表示するかどうか
## - true にすると、一瞬だけ小さな円を描画します

@export var debug_marker_radius: float = 6.0
@export var debug_marker_time: float = 0.2

@export var navigation_agent_path: NodePath = ^"NavigationAgent2D"
## 同じ親にある NavigationAgent2D へのパス
## - デフォルトでは兄弟に NavigationAgent2D ノードがある前提
## - 別名ならインスペクタから変更してください

var _agent: NavigationAgent2D
var _debug_marker_pos: Vector2
var _debug_marker_timer: float = 0.0


func _ready() -> void:
    # 親に NavigationAgent2D があることを確認して取得
    if navigation_agent_path != NodePath():
        _agent = get_node_or_null(navigation_agent_path)
    else:
        # NodePath が空なら、親から自動探索してみる
        _agent = _find_navigation_agent()

    if _agent == null:
        push_warning(
            "ClickToMove: NavigationAgent2D が見つかりませんでした。"
            + " navigation_agent_path を設定するか、兄弟に NavigationAgent2D を置いてください。"
        )

    # デバッグ描画を有効にするために process と draw を使う
    set_process(debug_draw_target)


func _unhandled_input(event: InputEvent) -> void:
    # 入力監視のメイン処理
    # - InputMap ベースのアクション
    # - または生のマウス左クリック
    if _agent == null:
        return

    # 1) アクションベースのクリック
    if input_action != StringName(""):
        if event.is_action_pressed(input_action):
            var target_pos := _get_click_world_position()
            if target_pos != null:
                _set_agent_target(target_pos)
        return

    # 2) アクション名を使わない場合:生の左クリック
    if event is InputEventMouseButton:
        var mb := event as InputEventMouseButton
        if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
            var target_pos := _get_click_world_position()
            if target_pos != null:
                _set_agent_target(target_pos)


func _process(delta: float) -> void:
    # デバッグマーカーの寿命管理
    if not debug_draw_target:
        return

    if _debug_marker_timer > 0.0:
        _debug_marker_timer -= delta
        if _debug_marker_timer <= 0.0:
            _debug_marker_timer = 0.0
            update() # 描画を消す
        else:
            update() # 位置更新


func _draw() -> void:
    # 目的地のデバッグ表示
    if not debug_draw_target:
        return

    if _debug_marker_timer > 0.0:
        draw_circle(
            to_local(_debug_marker_pos),
            debug_marker_radius,
            Color(0.2, 0.8, 1.0, 0.8)
        )
        draw_circle(
            to_local(_debug_marker_pos),
            debug_marker_radius * 0.5,
            Color(1.0, 1.0, 1.0, 0.9)
        )


func _get_click_world_position() -> Vector2:
    ## マウスクリック位置をワールド座標に変換し、
    ## 必要に応じて物理レイヤー上の地点にスナップして返す
    var world_pos: Vector2

    if use_camera_viewport:
        # アクティブな Camera2D から変換
        var viewport := get_viewport()
        if viewport == null:
            return null

        var cam := viewport.get_camera_2d()
        if cam == null:
            # カメラがない場合はフォールバックとして get_global_mouse_position()
            world_pos = get_global_mouse_position()
        else:
            var mouse_pos := viewport.get_mouse_position()
            world_pos = cam.get_global_transform().affine_inverse() * mouse_pos
    else:
        # シンプルに 2D のグローバルマウス座標を使用
        world_pos = get_global_mouse_position()

    if not click_only_on_layer:
        return world_pos

    # 特定レイヤー上に限定したい場合はレイキャストでヒット位置を取得
    var space := get_world_2d().direct_space_state
    var query := PhysicsPointQueryParameters2D.new()
    query.position = world_pos
    query.collide_with_areas = true
    query.collide_with_bodies = true
    query.collision_mask = ground_layer

    var result := space.intersect_point(query, 1)
    if result.size() > 0:
        # 最初にヒットした地点を目的地にする
        var hit := result[0]
        return hit.position

    # 何もヒットしなければ null を返して目的地更新しない
    return null


func _set_agent_target(target_pos: Vector2) -> void:
    ## NavigationAgent2D に目的地をセットし、必要ならデバッグ表示も更新
    if _agent == null:
        return

    _agent.target_position = target_pos

    if debug_draw_target:
        _debug_marker_pos = target_pos
        _debug_marker_timer = debug_marker_time
        update()


func _find_navigation_agent() -> NavigationAgent2D:
    ## 親ノードから NavigationAgent2D を自動探索するユーティリティ
    var parent := get_parent()
    if parent == null:
        return null

    for child in parent.get_children():
        if child is NavigationAgent2D:
            return child

    return null

使い方の手順

ここでは 2D のキャラクターを「地面クリックで移動」させる例で説明します。

手順①: 入力アクションを設定する

  1. プロジェクト設定 > Input Map を開く
  2. click_move というアクションを追加
  3. 「追加」ボタンからマウス左ボタン(Button Left Mouse)を割り当て

もちろん、別名のアクションでもOKです。その場合はインスペクタで input_action を変更しましょう。

手順②: シーン構成をつくる

例:プレイヤーキャラクターをクリック移動させる構成

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── NavigationAgent2D
 └── ClickToMove (Node)
  • Player (CharacterBody2D)
    実際に動く本体。移動処理(速度ベクトルをセットするなど)はここに書きます。
  • NavigationAgent2D
    経路探索を担当。navigation_mesh を持つ NavigationRegion2D がシーン内に必要です。
  • ClickToMove (Node)
    今回のコンポーネント。上記のスクリプトをアタッチします。

3Dの場合は CharacterBody3D / NavigationAgent3D / Camera3D に読み替えればOKです(3D用に若干コードを変える必要はあります)。

手順③: NavigationAgent2D と移動処理をつなぐ

クリック位置を目的地にセットするのは ClickToMove の仕事ですが、
実際にキャラクターを動かす処理CharacterBody2D 側に書きます。

Player(CharacterBody2D)側のシンプルな例:


extends CharacterBody2D

@export var move_speed: float = 200.0

var agent: NavigationAgent2D


func _ready() -> void:
    agent = $NavigationAgent2D
    # NavigationAgent2D の path_changed シグナルなどを使ってもOK


func _physics_process(delta: float) -> void:
    if agent == null:
        return

    # agent.get_next_path_position() は現在位置から見た次の経路ポイントを返す
    var next_pos: Vector2 = agent.get_next_path_position()
    var direction: Vector2 = (next_pos - global_position)

    if direction.length() > 1.0:
        direction = direction.normalized()
        velocity = direction * move_speed
    else:
        velocity = Vector2.ZERO

    move_and_slide()

これで、ClickToMoveagent.target_position を更新すると、
Player は NavigationAgent2D が示す経路に沿って移動するようになります。

手順④: ClickToMove のパラメータ調整

Player の子にある ClickToMove ノードを選択し、インスペクタから:

  • input_action: click_move(InputMap に合わせる)
  • use_camera_viewport: 2D なら false でもOKですが、カメラを動かす場合は true 推奨
  • click_only_on_layer:
    – 地面だけをクリック対象にしたいなら true
    – どこでもクリックしたいなら false
  • ground_layer: 地面のコリジョンレイヤーに合わせる
  • navigation_agent_path: デフォルトで NavigationAgent2D を指しているので、そのままでOK

これで、ゲームを実行して地面をクリックすると、
NavigationAgent2D の目的地が更新され、キャラがスーッと移動するはずです。


メリットと応用

ClickToMove をコンポーネントとして切り出すと、かなり嬉しいポイントがあります。

  • シーン構造がスッキリ
    「クリック移動キャラ用のベースクラス」を作らなくてよくなり、
    どのキャラも NavigationAgent2D + ClickToMove を付けるだけでOKになります。
  • プレイヤー / 敵 / 中立ユニットを同じ仕組みで動かせる
    例えば RTS で、プレイヤーユニットも中立モンスターも「クリックで命令を受け付ける」ようにしたい場合、
    どちらにも ClickToMove を付けるだけで共通化できます。
  • UIや入力制御と分離できる
    将来的に「ミニマップからもクリック移動」「選択中ユニットだけ動く」などの要件が出ても、
    クリック入力の部分だけ別コンポーネントに差し替えたり、UI側から _set_agent_target() 相当を呼ぶだけで対応できます。
  • テストがしやすい
    クリック移動のロジックが 1 スクリプトにまとまっているので、
    「クリック座標の変換」「レイヤーフィルタ」「デバッグ表示」などを個別に検証しやすくなります。

特に RTS / MOBA 系は、ユニットの種類が増えるにつれて「入力まわりの継承」が爆発しがちなので、
入力 → コンポーネント、移動 → 本体、経路探索 → NavigationAgent としっかり責務分離しておくと後々かなり楽になります。

改造案:Shift+クリックで「追従モード」にする

例えば、Shift+クリックしたときは敵ユニットを追いかける
通常クリックのときは地面を目的地にする、という拡張も簡単にできます。

ClickToMove にこんな関数を追加して、_unhandled_input() から呼び出すイメージです:


func _handle_shift_click_follow(event: InputEventMouseButton) -> void:
    if not event.pressed:
        return
    if not event.shift_pressed:
        return

    # マウス位置にいる敵ユニットを探して追いかける例
    var world_pos := _get_click_world_position()
    if world_pos == null:
        return

    var space := get_world_2d().direct_space_state
    var query := PhysicsPointQueryParameters2D.new()
    query.position = world_pos
    query.collide_with_bodies = true
    query.collision_mask = ground_layer  # 敵用のレイヤーに変更してもOK

    var result := space.intersect_point(query, 8)
    for hit in result:
        var collider := hit.collider
        if collider and collider.is_in_group("enemy"):
            # 敵の位置を常に追いかけるような処理に切り替えるなど
            # ここでは単純に現在位置を目的地にする例
            _set_agent_target(collider.global_position)
            return

こういう「特別なクリック挙動」も、ClickToMove 側にだけ追加すれば済むので、
ユニット本体のスクリプトを汚さずに機能を盛っていけるのがコンポーネント指向の良いところですね。

必要に応じて、3D版の ClickToMove3D を用意したり、
「選択コンポーネント」「編隊移動コンポーネント」などと組み合わせて、
RTS / MOBA の入力周りを全部コンポーネント化していくのもおすすめです。