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"に設定cameraにMain/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 をベースにした「選択コンポーネント」を育ててみてください。
