Godot で「フックショット」系の挙動を作ろうとすると、ありがちな実装はこんな感じですよね。
- プレイヤー用の
Player.gdにロープの状態管理、レイキャスト、移動処理、アニメーション制御…全部詰め込む - 敵や動くオブジェクトにも同じような処理をコピペして、ちょっとだけ挙動を変える
- そのうち
Player.gdが 500 行超えて、どこを触ればいいのか怖くなる
Godot はノード継承が強力なので、つい「PlayerWithHook.gd」みたいな派生シーンを作りがちですが、フックのロジックは本来「プレイヤーの一部」ではなく「プレイヤーに付いている機能」ですよね。ここで「継承より合成(Composition)」の出番です。
この記事では、どんな Kinematic/CharacterBody にもポン付けできる、コンポーネント指向の「GrapplingHook」コンポーネントを作っていきます。クリックした地点にロープを伸ばし、ヒットしたら親ノードをその地点まで牽引します。プレイヤーにも敵にも、動く箱にも、同じコンポーネントをアタッチするだけでフックショット化できるようにしてみましょう。
【Godot 4】クリック一発でフックショット!「GrapplingHook」コンポーネント
今回のコンポーネントの役割はざっくり言うと:
- マウスクリック(または任意の入力)でフックを発射
- レイキャストで衝突点を検出し、その地点を「フック先」としてロック
- ロック中、親ノードをその地点へ牽引する
- ロープの Line2D を自動描画(任意でオフにできる)
親ノード側は「position を動かせる 2D ノード」であれば OK ですが、物理挙動を持つ CharacterBody2D / RigidBody2D を想定して設計します。
(3D 版に拡張するのも簡単にできるような構造にしてあります。)
フルコード:GrapplingHook.gd
extends Node2D
class_name GrapplingHook
## クリック位置にロープを飛ばし、ヒットしたら親をその地点へ牽引するコンポーネント。
## 親は CharacterBody2D / RigidBody2D / Node2D など position を持つノードを想定。
@export_group("Input")
## フックを発射するアクション名(InputMap で設定しておく)
@export var fire_action: StringName = &"fire_grapple"
## フックを解除するアクション名(同じでも可)
@export var release_action: StringName = &"fire_grapple"
@export_group("Grapple Settings")
## フックが届く最大距離(ピクセル)
@export var max_distance: float = 400.0
## 親を引っ張るスピード(ピクセル/秒)
@export var pull_speed: float = 600.0
## 親がフック地点にどれくらい近づいたら完了とみなすか
@export var stop_distance: float = 16.0
## フック中に重力を無効化したい場合に true(CharacterBody2D 想定)
@export var disable_gravity_while_pulling: bool = true
@export_group("Collision")
## 何にフックを刺せるかを制御するコリジョンマスク
@export var collision_mask: int = 1
## 自分の親など、無視したいノードを登録できる(オプション)
@export var exclude_parent_from_hit: bool = true
@export_group("Visual")
## ロープを描画するかどうか
@export var draw_rope: bool = true
## ロープの色
@export var rope_color: Color = Color(0.9, 0.9, 1.0)
## ロープの太さ
@export var rope_width: float = 2.0
## フック可能かどうか(外部から一時的に無効化したいとき用)
var enabled: bool = true
## 内部状態
var _is_pulling: bool = false
var _grapple_point: Vector2
var _original_gravity: float = 0.0
## ロープ描画用
var _line: Line2D
## RayCast2D を内部で持たせる方式
var _ray: RayCast2D
func _ready() -> void:
# 親ノードを取得(存在しないと意味がないので警告)
if get_parent() == null:
push_warning("GrapplingHook: 親ノードがありません。このコンポーネントは何かの子として使ってください。")
# RayCast2D を自動生成(シーンに既にある RayCast2D を使いたい場合は改造してもOK)
_ray = RayCast2D.new()
_ray.enabled = false
_ray.collision_mask = collision_mask
add_child(_ray)
# ロープ描画用 Line2D を生成(必要なら)
if draw_rope:
_line = Line2D.new()
_line.width = rope_width
_line.default_color = rope_color
_line.joint_mode = Line2D.LINE_JOINT_ROUND
_line.begin_cap_mode = Line2D.LINE_CAP_ROUND
_line.end_cap_mode = Line2D.LINE_CAP_ROUND
add_child(_line)
# 親が CharacterBody2D なら重力値を控えておく
var parent := get_parent()
if parent is CharacterBody2D:
# CharacterBody2D には重力プロパティはなく、通常は自前で velocity.y += gravity * delta している想定。
# ここでは「_gravity」みたいな変数を親が持っているケースに対応してみる。
if "_gravity" in parent:
_original_gravity = parent._gravity
func _unhandled_input(event: InputEvent) -> void:
if not enabled:
return
if event.is_action_pressed(fire_action):
_try_fire_grapple()
elif event.is_action_pressed(release_action):
release_grapple()
func _physics_process(delta: float) -> void:
if not _is_pulling:
_update_rope_visual()
return
var parent := get_parent()
if parent == null:
release_grapple()
return
# 親の現在位置
var current_pos: Vector2 = parent.global_position
var to_target: Vector2 = _grapple_point - current_pos
var distance: float = to_target.length()
if distance <= stop_distance:
# 目標地点に十分近づいたので終了
parent.global_position = _grapple_point
release_grapple()
return
var dir: Vector2 = to_target.normalized()
var move: Vector2 = dir * pull_speed * delta
# 行き過ぎ防止
if move.length() > distance:
move = dir * distance
# 親の種類によって移動の仕方を変える
if parent is CharacterBody2D:
# CharacterBody2D の場合、velocity を直接いじるのが自然
if "velocity" in parent:
parent.velocity = move / delta
parent.move_and_slide()
else:
# velocity プロパティが無い場合は、直接座標を動かす
parent.global_position += move
elif parent is RigidBody2D:
# RigidBody2D は直接 position を動かすと非推奨だが、
# ここではシンプルな実装として瞬間移動させる。
parent.global_position += move
parent.linear_velocity = move / delta
else:
# その他の Node2D 派生
parent.global_position += move
_update_rope_visual()
func _try_fire_grapple() -> void:
var parent := get_parent()
if parent == null:
return
# 既にフック中なら一度解除
if _is_pulling:
release_grapple()
# 画面上のマウス位置を取得(UI からの入力にも対応したい場合は別途調整)
var viewport := get_viewport()
if viewport == null:
return
var mouse_pos: Vector2 = viewport.get_mouse_position()
# カメラを考慮してワールド座標へ変換
var canvas := viewport.get_camera_2d()
var target_world_pos: Vector2 = mouse_pos
if canvas != null:
target_world_pos = canvas.get_screen_to_world(mouse_pos)
var origin: Vector2 = global_position
var to_target: Vector2 = target_world_pos - origin
# 最大距離制限
if to_target.length() > max_distance:
to_target = to_target.normalized() * max_distance
# RayCast 設定
_ray.global_position = origin
_ray.target_position = to_target
_ray.collision_mask = collision_mask
# 親をヒット対象から除外
_ray.clear_exceptions()
if exclude_parent_from_hit and parent is CollisionObject2D:
_ray.add_exception(parent)
_ray.enabled = true
_ray.force_raycast_update()
if _ray.is_colliding():
_grapple_point = _ray.get_collision_point()
_start_pulling()
else:
# 何もヒットしなかったら何もしない
_ray.enabled = false
_is_pulling = false
_update_rope_visual()
func _start_pulling() -> void:
_is_pulling = true
var parent := get_parent()
if parent is CharacterBody2D and disable_gravity_while_pulling:
if "_gravity" in parent:
parent._gravity = 0.0
_update_rope_visual()
func release_grapple() -> void:
if not _is_pulling:
_update_rope_visual()
return
_is_pulling = false
_ray.enabled = false
var parent := get_parent()
if parent is CharacterBody2D and disable_gravity_while_pulling:
if "_gravity" in parent:
parent._gravity = _original_gravity
_update_rope_visual()
func _update_rope_visual() -> void:
if not draw_rope or _line == null:
return
_line.clear_points()
if not _is_pulling:
return
# ロープは「コンポーネントの位置」から「フック地点」へ
_line.add_point(Vector2.ZERO)
_line.add_point(to_local(_grapple_point))
使い方の手順
ここからは、実際にプレイヤーにフックショットを付ける手順を見ていきましょう。
① InputMap にアクションを追加する
- Godot エディタ上で Project > Project Settings… > Input Map を開く
fire_grappleというアクションを追加- マウス左クリック(
Mouse Button Left)を割り当てる
これで、デフォルト設定の fire_action / release_action がそのまま使えます。別のキーを使いたい場合は、コンポーネントのインスペクタからアクション名を変えてください。
② プレイヤーシーンにコンポーネントを追加する
例として、典型的な 2D プレイヤーシーンはこんな感じだとします。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── GrapplingHook (Node2D) ← これを追加
- シーンツリーで
Player (CharacterBody2D)を開く - 右クリック > Add Child Node から
Node2Dを追加 - その
Node2DにGrapplingHook.gdをアタッチ - ノード名を
GrapplingHookにしておくとわかりやすいです
このとき、GrapplingHook ノードの位置が「ロープの出発点」になります。
プレイヤーの手元からロープを出したいなら、その位置に合わせて GrapplingHook ノードを移動しておきましょう。
③ パラメータを調整する
GrapplingHook ノードを選択すると、インスペクタにエクスポート変数が表示されます。
- max_distance … フックの射程。ステージの広さに合わせて 300~600 くらいが扱いやすいです。
- pull_speed … 引っ張るスピード。速すぎるとワープっぽくなるので、600~900 くらいから調整。
- stop_distance … どれくらい近づいたら停止するか。16~32 あたりで OK。
- collision_mask … フックを刺したい TileMap / StaticBody2D のレイヤーをチェック。
- draw_rope … ロープを描画するか。プロトタイプ中は ON にしておくとデバッグしやすいです。
特に collision_mask をちゃんと設定しないと、「空中で止まってしまう」「何にも刺さらない」という状態になるので注意してください。
④ 動作確認する
- ゲームを実行し、マウスカーソルを動かしながら左クリック
- 地形(TileMap や StaticBody2D)に向けてクリックすると、レイキャストがヒットしてロープが伸びる
- プレイヤーがフック地点へスーッと引っ張られていけば成功
- もう一度クリック(または別のキーにした場合は
release_action)でロープを解除
この時点で、プレイヤー側のスクリプトは一切変更していないはずです。
「フックショット機能」をまるごと GrapplingHook コンポーネントに閉じ込めているので、プレイヤーコードはスリムなまま保てます。
別の使用例:敵や動く床にもフックを付ける
コンポーネント指向の良さは、「どのノードにも同じ機能をアタッチできる」ところです。たとえば:
HookableEnemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── GrapplingHook (Node2D)
- 敵キャラに
GrapplingHookを付ければ、「プレイヤーに向かってフック移動してくる敵」が簡単に作れます。 - 入力処理だけ、プレイヤーと違って「AI からのトリガー」にすれば OK です。
あるいは、動くフックポイントとして:
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── GrapplingHook (Node2D)
- プレイヤーではなく、MovingPlatform 自身がどこかにフックして移動する、というギミックも作れます。
- この場合も、
GrapplingHookのロジックはそのまま使い回せます。
どのケースでも共通なのは、「GrapplingHook 自体は『親をどう動かすか』しか知らない」という点です。
「親がプレイヤーか敵か床か」は一切気にしていません。これがコンポーネント指向の気持ちよさですね。
メリットと応用
このコンポーネントを導入することで、次のようなメリットがあります。
- Player.gd が太らない
フックの状態管理、レイキャスト、描画などを全部コンポーネントに隔離しているので、プレイヤー本体のコードは「移動」「ジャンプ」などのコアな責務に集中できます。 - シーン構造がフラットで見通しが良い
「フック付きプレイヤー」のためだけに別シーンを作る必要がなく、GrapplingHookノードを 1 個足すだけで機能追加できます。 - 再利用性が高い
プレイヤー、敵、ギミック、どのノードにも同じコンポーネントを貼れるので、ゲーム全体でフックギミックを一貫した挙動で使い回せます。 - テストしやすい
GrapplingHook単体をテスト用シーンに貼って、挙動を確認・調整できます。他のロジックと絡みにくいのでバグの切り分けも楽です。
「継承で PlayerWithHook, PlayerWithDash, PlayerWithHookAndDash…」と増えていく地獄を避けて、必要な機能をコンポーネントとしてアタッチしていく、という方向に寄せていくと、Godot プロジェクトはかなり長生きしやすくなります。
改造案:フック成功/解除をシグナルで通知する
他のコンポーネントと連携させたい場合、「フック成功時に SE を鳴らす」「解除時にエフェクトを出す」などをシグナルで行うときれいです。
GrapplingHook に次のようなシグナルとメソッドを追加してみましょう。
signal grapple_started(target_point: Vector2)
signal grapple_released()
func _start_pulling() -> void:
_is_pulling = true
emit_signal("grapple_started", _grapple_point)
var parent := get_parent()
if parent is CharacterBody2D and disable_gravity_while_pulling:
if "_gravity" in parent:
parent._gravity = 0.0
_update_rope_visual()
func release_grapple() -> void:
if not _is_pulling:
_update_rope_visual()
return
_is_pulling = false
_ray.enabled = false
var parent := get_parent()
if parent is CharacterBody2D and disable_gravity_while_pulling:
if "_gravity" in parent:
parent._gravity = _original_gravity
emit_signal("grapple_released")
_update_rope_visual()
こうしておくと、別コンポーネント(GrappleSoundPlayer や GrappleVFX など)を同じ親ノードにぶら下げて、
GrapplingHook のシグナルにだけ反応する、という構成も簡単に作れます。
機能ごとにコンポーネントを分けていくと、シーンツリーは少し横に広がりますが、各ノードの責務が明確になり、長期的には圧倒的に楽になります。
ぜひ、自分のプロジェクトの「肥大化しがちなスクリプト」を見つけて、今回の GrapplingHook みたいに一つずつコンポーネントに切り出していってみてください。




