RTSやシミュレーション系のゲームで、ユニットをまとめて選択したいとき、「ドラッグで範囲選択」はほぼ必須のUIですね。
Godotで素直に実装しようとすると、

  • プレイヤーシーンに直接ロジックを書き始めてしまう
  • ユニットごとに選択状態の処理をバラバラに書いてしまう
  • 最終的に「選択ロジック」があちこちに散らばる

…という「あるある」な構造になりがちです。さらに、Control系ノードで矩形を描くか、Node2Dで線を引くか、カメラとの座標変換はどうするか…と、毎回同じような悩みが出てきます。

そこで今回は、どのシーンにも後付けでポンと刺せる、コンポーネント指向な「範囲選択」コンポーネントを用意しました。
プレイヤーコントローラーやゲームルートノードにアタッチするだけで、ドラッグで矩形を描き、その中にあるユニットを一括選択できるようにしていきましょう。

【Godot 4】RTS風ドラッグ範囲選択をコンポーネントで!「DragSelect」コンポーネント

今回のコンポーネントの思想はシンプルです。

  • 「範囲選択」の責務だけを持つコンポーネント
  • 選択される側(ユニット)はインターフェースだけを守る(継承不要)
  • 描画はControlノード上の矩形で行い、ゲーム空間への変換はコンポーネントが面倒を見る

つまり、「選択ロジック」は DragSelect に集約し、ユニット側は select() / deselect() 的なメソッドを用意するだけ、という構成にします。


フルソースコード:DragSelect.gd


extends Control
class_name DragSelect
##
## マウスドラッグで矩形を描き、その中にあるユニットを選択状態にするコンポーネント
##
## 想定ユース:
## - RTS / シミュレーションゲームのユニット一括選択
## - エディタ風ツールでのオブジェクト範囲選択
##
## 使い方概要:
## - 画面上に常駐させるControlとして配置する(CanvasLayer配下推奨)
## - 対象ユニットは「グループ」か「配列」で登録
## - ユニット側は `set_selected(is_selected: bool)` を実装しておくと便利
##

@export_group("入力設定")
## 範囲選択に使うマウスボタン
@export var mouse_button: MouseButton = MOUSE_BUTTON_LEFT

## Shiftを押しながらドラッグで「追加選択」を許可するか
@export var enable_additive_selection: bool = true

@export_group("対象ユニット")
## 範囲選択の対象となるユニットが所属するグループ名
## 例: "selectable_unit"
@export var target_group: StringName = &"selectable_unit"

## 任意で、個別に対象ノードを登録するための配列
## (グループを使いたくない場合や、限定的なシーンで使いたい場合用)
@export var explicit_targets: Array[Node2D] = []

@export_group("描画設定")
## 選択矩形の線の色
@export var rect_border_color: Color = Color(0.2, 0.7, 1.0, 1.0)

## 選択矩形の塗りつぶし色
@export var rect_fill_color: Color = Color(0.2, 0.7, 1.0, 0.15)

## 線の太さ
@export var rect_border_width: float = 2.0

## 最低ドラッグ距離(ピクセル)。これ未満なら「クリック扱い」にしたい場合などに利用
@export var min_drag_distance: float = 4.0

@export_group("カメラ設定")
## 画面座標 → ワールド座標への変換に使うカメラ
## 未設定の場合は `get_viewport().get_camera_2d()` を自動で探す
@export var camera: Camera2D

## 内部状態
var _dragging: bool = false
var _drag_start: Vector2
var _drag_end: Vector2
var _current_selection: Array[Node2D] = []

func _ready() -> void:
	# マウス入力を受け取るために必要
	mouse_filter = Control.MOUSE_FILTER_PASS
	# フルスクリーンで矩形を描きたいので、レイアウトを画面全体に広げることを推奨
	# (エディタ上で「レイアウト」→「フルレクト」を選ぶと自動で設定されます)
	if not camera:
		camera = get_viewport().get_camera_2d()

func _unhandled_input(event: InputEvent) -> void:
	# 画面上どこでもドラッグを拾いたいので _unhandled_input を使用
	if event is InputEventMouseButton:
		_handle_mouse_button(event)
	elif event is InputEventMouseMotion:
		_handle_mouse_motion(event)

func _handle_mouse_button(event: InputEventMouseButton) -> void:
	if event.button_index != mouse_button:
		return

	if event.pressed:
		# ドラッグ開始
		_dragging = true
		_drag_start = event.position
		_drag_end = event.position
		update() # 描画更新
	else:
		# ドラッグ終了
		if _dragging:
			_dragging = false
			_drag_end = event.position
			update() # 描画更新(矩形を消す)
			# 実際の選択処理
			_process_selection()

func _handle_mouse_motion(event: InputEventMouseMotion) -> void:
	if not _dragging:
		return
	_drag_end = event.position
	update() # 矩形の再描画

func _draw() -> void:
	if not _dragging:
		return

	var rect := _get_screen_rect()
	# 塗りつぶし
	draw_rect(rect, rect_fill_color, true)
	# 枠線
	draw_rect(rect, rect_border_color, false, rect_border_width)

func _get_screen_rect() -> Rect2:
	# 開始点と終了点から、左上原点のRect2を作成
	var start := _drag_start
	var end := _drag_end
	var position := Vector2(
		min(start.x, end.x),
		min(start.y, end.y)
	)
	var size := Vector2(
		abs(start.x - end.x),
		abs(start.y - end.y)
	)
	return Rect2(position, size)

func _process_selection() -> void:
	# ほとんどドラッグしていない場合は、クリック扱いにしたいケースもあるので
	# ここでは単純に「小さい矩形」として扱う。必要なら条件分岐を追加してください。
	var rect_screen := _get_screen_rect()
	if rect_screen.size.length() < min_drag_distance:
		# ほぼドラッグしていない → 今回は何もしない
		return

	# 既存の選択をクリアするかどうか
	var additive := enable_additive_selection and Input.is_key_pressed(KEY_SHIFT)
	if not additive:
		_clear_selection()

	# 画面座標の矩形をワールド座標の矩形に変換
	var rect_world := _screen_rect_to_world_rect(rect_screen)

	# 対象ユニットを列挙
	var targets: Array[Node2D] = []
	if target_group != StringName():
		targets.append_array(_get_group_targets())
	targets.append_array(explicit_targets)

	# 重複を削除
	targets = targets.duplicate()
	targets = targets.filter(func(t): return t != null)

	for unit in targets:
		if not is_instance_valid(unit):
			continue
		if _is_unit_in_world_rect(unit, rect_world):
			_select_unit(unit)

func _screen_rect_to_world_rect(rect_screen: Rect2) -> Rect2:
	# Camera2D が設定されていればそれを使う。なければ画面座標をそのままワールドとみなす
	if camera and is_instance_valid(camera):
		var top_left_world := camera.screen_to_world(rect_screen.position)
		var bottom_right_world := camera.screen_to_world(rect_screen.position + rect_screen.size)
		var position := Vector2(
			min(top_left_world.x, bottom_right_world.x),
			min(top_left_world.y, bottom_right_world.y)
		)
		var size := Vector2(
			abs(top_left_world.x - bottom_right_world.x),
			abs(top_left_world.y - bottom_right_world.y)
		)
		return Rect2(position, size)
	else:
		# カメラがない場合は「画面座標 = ワールド座標」とみなす
		return rect_screen

func _get_group_targets() -> Array[Node2D]:
	var result: Array[Node2D] = []
	if target_group == StringName():
		return result
	for node in get_tree().get_nodes_in_group(target_group):
		if node is Node2D:
			result.append(node)
	return result

func _is_unit_in_world_rect(unit: Node2D, rect_world: Rect2) -> bool:
	# 一番シンプルな判定:ユニットのグローバル位置が矩形内かどうか
	var pos := unit.global_position
	return rect_world.has_point(pos)

func _select_unit(unit: Node2D) -> void:
	if unit in _current_selection:
		# すでに選択済み
		return
	_current_selection.append(unit)

	# ユニット側に「選択されたよ」と伝える
	# 推奨インターフェース: `func set_selected(selected: bool) -> void`
	if unit.has_method("set_selected"):
		unit.call_deferred("set_selected", true)
	elif unit.has_method("select"):
		unit.call_deferred("select")

func _clear_selection() -> void:
	for unit in _current_selection:
		if not is_instance_valid(unit):
			continue
		if unit.has_method("set_selected"):
			unit.call_deferred("set_selected", false)
		elif unit.has_method("deselect"):
			unit.call_deferred("deselect")
	_current_selection.clear()

## 外部から現在の選択を取得したい場合用のヘルパー
func get_selected_units() -> Array[Node2D]:
	return _current_selection.duplicate()

使い方の手順

ここからは、実際に「RTS風ユニット選択」を例にして、DragSelectコンポーネントの使い方を見ていきましょう。

手順①:ユニットシーンを用意し、選択インターフェースを実装する

まずは、選択される側の「ユニット」を作ります。
最低限、Node2D(または派生クラス)で、set_selected(selected: bool) を実装しておきましょう。


# Unit.gd
extends Node2D

@export var selected_color: Color = Color(0.2, 1.0, 0.4)
@export var normal_color: Color = Color.WHITE

var _selected: bool = false

func _ready() -> void:
	add_to_group("selectable_unit") # DragSelect の target_group と合わせる
	_update_visual()

func set_selected(selected: bool) -> void:
	_selected = selected
	_update_visual()

func _update_visual() -> void:
	# ここでは Sprite2D が子にいる前提で色を変えてみる
	var sprite := $Sprite2D if has_node("Sprite2D") else null
	if sprite:
		sprite.modulate = selected_color if _selected else normal_color

シーン構成例:

Unit (Node2D)
 ├── Sprite2D
 └── CollisionShape2D (任意)

手順②:ゲームのルート(またはUIレイヤー)に DragSelect を配置する

次に、画面全体を覆う Control を作り、そこに DragSelect.gd をアタッチします。
CanvasLayer の子にすると、カメラが動いてもUIが安定して表示されるのでオススメです。

Main (Node2D)
 ├── Camera2D
 ├── UnitsRoot (Node2D)
 │    ├── Unit (Unit.tscn)
 │    ├── Unit (Unit.tscn)
 │    └── Unit (Unit.tscn)
 └── CanvasLayer
      └── DragSelect (Control)
  • DragSelect (Control)DragSelect.gd をアタッチ
  • レイアウトは「フルレクト」にして、画面全体を覆うようにする
  • target_group"selectable_unit" に設定
  • cameraMain/Camera2D をドラッグ&ドロップで指定

これで、ゲームを再生してマウス左ドラッグをすると、矩形が描かれ、その範囲に入ったユニットが一括で選択されるようになります。

手順③:Shiftキーで「追加選択」を使う

enable_additive_selection = true の場合、Shiftキーを押しながらドラッグすると、既存の選択に追加する形でユニットを選択できます。

  • 通常ドラッグ: 既存の選択をクリアしてから、新しい範囲を選択
  • Shift + ドラッグ: 既存の選択を保持したまま、新しい範囲を追加選択

RTSやレベルエディタっぽい操作感を簡単に再現できますね。

手順④:プレイヤーやゲームロジックから選択結果を使う

DragSelect は、現在選択されているユニットを get_selected_units() で返すようにしてあります。
例えば、右クリックで移動命令を出す「プレイヤーコントローラー」コンポーネントから、こういった形で利用できます。


# PlayerCommander.gd
extends Node

@export var drag_select: DragSelect

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
		var units := drag_select.get_selected_units()
		if units.is_empty():
			return

		var target_pos := event.position
		# ここではとりあえず全員同じ場所に向かわせる例
		for u in units:
			if u.has_method("move_to"):
				u.call("move_to", target_pos)

シーン構成例(プレイヤーコマンダーを追加した場合):

Main (Node2D)
 ├── Camera2D
 ├── UnitsRoot (Node2D)
 │    ├── Unit (Unit.tscn)
 │    ├── Unit (Unit.tscn)
 │    └── Unit (Unit.tscn)
 ├── CanvasLayer
 │    └── DragSelect (Control) [DragSelect.gd]
 └── PlayerCommander (Node) [PlayerCommander.gd]

こうしておくと、「選択ロジック」DragSelect に、「命令ロジック」PlayerCommander に分離されて、かなりスッキリした構成になります。


メリットと応用

この DragSelect コンポーネントを使うメリットを、コンポーネント指向の観点から整理してみましょう。

  • 責務がはっきり分離される
    範囲選択のロジックはすべて DragSelect に集約されるので、ユニット側は「選択されたらどう見た目を変えるか」だけに集中できます。
  • シーン構造がフラットになる
    「プレイヤー」シーンにカメラも選択ロジックも命令ロジックも全部詰め込む…といった、肥大化したノードを避けられます。
  • 再利用性が高い
    RTSでも、レベルエディタでも、マップエディタでも、「範囲選択」が必要になったらこのコンポーネントをポンと置くだけです。
  • 対象の切り替えが簡単
    グループ名を変えたり、explicit_targets に一時的にノードを詰めたりするだけで、選択対象を柔軟に切り替えられます。

応用としては、例えば:

  • ユニットの当たり判定(CollisionShape2D)と矩形の交差判定を行って、より厳密な範囲選択にする
  • クリック(短いドラッグ)時は「単体選択」、ドラッグ時は「範囲選択」という挙動に分ける
  • 選択矩形の見た目を、ゲームのUIテーマに合わせてカスタマイズする

などが考えられます。

改造案:クリックで単体選択、ドラッグで範囲選択にする

最後に、_process_selection() を少し改造して、ドラッグ距離が短い場合は「クリック扱い」で単体選択」にする例を載せておきます。


func _process_selection() -> void:
	var rect_screen := _get_screen_rect()
	var is_click := rect_screen.size.length() < min_drag_distance

	var additive := enable_additive_selection and Input.is_key_pressed(KEY_SHIFT)
	if not additive:
		_clear_selection()

	if is_click:
		# クリック位置に一番近いユニットを1体だけ選択
		var click_pos_world := camera.screen_to_world(_drag_start) if camera else _drag_start
		var nearest: Node2D = null
		var nearest_dist := INF

		for unit in _get_group_targets():
			if not is_instance_valid(unit):
				continue
			var d := unit.global_position.distance_to(click_pos_world)
			if d < nearest_dist:
				nearest_dist = d
				nearest = unit

		if nearest:
			_select_unit(nearest)
		return

	# 通常のドラッグ範囲選択
	var rect_world := _screen_rect_to_world_rect(rect_screen)
	for unit in _get_group_targets():
		if not is_instance_valid(unit):
			continue
		if _is_unit_in_world_rect(unit, rect_world):
			_select_unit(unit)

こうして、挙動のバリエーションはどんどん DragSelect 側に積み増していき、ユニット側は常にシンプルなまま維持する、というのがコンポーネント指向の美味しいところですね。
ぜひ自分のプロジェクト用に、DragSelect をベースにした「選択コンポーネント」を育ててみてください。