Godot 4 でアクションゲームを書いていると、「ダッシュ」や「回避」を作りたくなる場面って多いですよね。
素直に CharacterBody2D / CharacterBody3D を継承して、_physics_process() の中で速度をいじって…とやりがちですが、

  • プレイヤーと敵でダッシュ処理をコピペしてしまう
  • 「通常移動」と「ダッシュ移動」が同じスクリプトにベタっと混ざってカオス
  • 別のキャラだけ「瞬間移動ダッシュ」にしたいのに、共通基底クラスをいじるハメになる

みたいな「継承ベタ書きあるある」にハマりがちです。

そこで今回は、「進行方向に一定距離だけ 一瞬でワープ する」タイプのダッシュを、
どんなキャラにもポン付けできる コンポーネント として切り出してみましょう。

名前は TeleportDash
キャラの移動ロジックとは独立させて、「今の向きにシュッと飛ぶ」機能だけを担当させます。
プレイヤーにも敵にも、動く床にも、好きなノードにアタッチするだけで瞬間移動ダッシュを追加できます。

【Godot 4】一瞬でスッと消えるダッシュ!「TeleportDash」コンポーネント

以下がフルコードです。
2D 用ですが、3D 版にしたい場合のポイントも後半で触れます。


## TeleportDash.gd
## 任意のノードにアタッチして「瞬間移動ダッシュ」を追加するコンポーネント
## 対象: 主に Node2D / CharacterBody2D / RigidBody2D など「position を持つノード」

class_name TeleportDash
extends Node

## --- 設定パラメータ(インスペクタで編集可能) ---

@export_range(0.0, 2000.0, 1.0, "or_greater")
var dash_distance: float = 120.0
## 1 回のダッシュで進む距離(ピクセル)。
## 例: 120 なら、今向いている方向に 120px 先へ瞬間移動。

@export_range(0.0, 5.0, 0.05, "or_greater")
var cooldown: float = 0.4
## ダッシュのクールタイム(秒)。
## 0 にすると連打可能だが、ゲームバランスが崩れやすいので注意。

@export var max_charges: int = 0
## 0 の場合は無制限にダッシュ可能。
## 1 以上を指定すると、その回数だけダッシュできる「回数制ダッシュ」になる。

@export var consume_on_start: bool = true
## true: ダッシュ開始時にチャージを消費する。
## false: ダッシュ終了時に消費する(途中キャンセルなどを実装したい場合向け)。

@export var require_grounded: bool = false
## true: 「is_on_floor() == true」のときだけダッシュを許可する。
## 主に CharacterBody2D と一緒に使うとき用。

@export var use_physics_safe_teleport: bool = true
## true: CharacterBody2D の「set_global_position()」ではなく
## 「global_position = ...」を直接書き換えるだけにする。
## 物理挙動との兼ね合いで挙動が変わるので、環境に応じて切り替えましょう。

@export var debug_draw: bool = false
## true にすると、エディタ実行時にダッシュ先の線を簡易表示します。
## レベルデザイン中の距離調整に便利です。

@export_group("入力")
@export var auto_listen_input: bool = true
## true: このコンポーネント自身が Input を監視してダッシュする。
## false: コードから「dash(direction)」を直接呼び出す想定(AI やスクリプト制御用)。

@export var dash_action_name: StringName = &"dash"
## InputMap に設定するアクション名。
## デフォルトでは「dash」アクションが押されたときに発動。

@export_group("向きの解決方法")
@export_enum("Use_Velocity", "Use_Facing_Vector", "Use_Input_Vector")
var direction_mode: int = 0
## 0: Use_Velocity      - 親ノードの velocity から向きを推定(CharacterBody2D 前提)
## 1: Use_Facing_Vector - 親ノードの「右向き」や「forward」など、任意のベクトルを使う
## 2: Use_Input_Vector  - 入力(左右・上下)から向きを決定

@export var facing_vector: Vector2 = Vector2.RIGHT
## direction_mode == Use_Facing_Vector のときに使う「基準向き」。
## 例: スプライトが右向きで描かれているなら Vector2.RIGHT のままで OK。

@export var input_axis_left: StringName = &"ui_left"
@export var input_axis_right: StringName = &"ui_right"
@export var input_axis_up: StringName = &"ui_up"
@export var input_axis_down: StringName = &"ui_down"
## direction_mode == Use_Input_Vector のときに使うアクション名。
## Godot デフォルトのカーソルキー入力を前提にしています。

@export_group("コリジョン&安全性")
@export var use_raycast_check: bool = true
## true: ダッシュ先に壁がある場合は、手前までしか移動しない。
## false: 壁の中でも容赦なくワープする(危険だが、テレポート系ギミックには便利)。

@export var collision_mask: int = 1
## RayCast2D の collision_mask 相当。
## どのレイヤーのコリジョンを「障害物」として扱うか。

@export var collision_margin: float = 2.0
## 壁にめり込まないように、ヒット位置から少し手前にずらす距離(ピクセル)。

@export_group("エフェクトフック")
@export var emit_signal_on_dash: bool = true
## true: ダッシュ時に「dash_started」「dash_finished」シグナルを発火する。
## パーティクルやサウンドを別ノードで管理したいときに便利。

@export var disable_movement_during_dash: bool = false
## true: ダッシュ中は、親ノードの移動系コンポーネントを無効化する想定。
## 実際の無効化処理は、このコンポーネントからシグナルを受けて行う。

## --- シグナル ---

signal dash_started(global_from: Vector2, global_to: Vector2)
signal dash_finished(global_from: Vector2, global_to: Vector2)

## --- 内部状態 ---

var _last_dash_time: float = -9999.0
var _current_charges: int = 0
var _is_dashing: bool = false

## キャッシュ
var _parent_2d: Node2D
var _parent_body: CharacterBody2D

func _ready() -> void:
    _parent_2d = get_parent() as Node2D
    _parent_body = get_parent() as CharacterBody2D

    if _parent_2d == null:
        push_warning("TeleportDash: 親ノードが Node2D ではありません。2D シーンでの使用を想定しています。")

    # チャージ初期化
    if max_charges > 0:
        _current_charges = max_charges
    else:
        _current_charges = 0  # 無制限モード

func _process(delta: float) -> void:
    if auto_listen_input and not Engine.is_editor_hint():
        _handle_input()

    if debug_draw and Engine.is_editor_hint() == false:
        _debug_draw_line()

func _handle_input() -> void:
    if Input.is_action_just_pressed(dash_action_name):
        var dir := _resolve_direction()
        dash(dir)

## 外部からも呼べる API
## direction がゼロベクトルの場合は自動で向きを解決する
func dash(direction: Vector2 = Vector2.ZERO) -> void:
    if not _can_dash():
        return

    if direction == Vector2.ZERO:
        direction = _resolve_direction()

    if direction == Vector2.ZERO:
        # どうしても向きが決められないときは何もしない
        return

    direction = direction.normalized()

    var from_pos := _get_global_position()
    var target_pos := from_pos + direction * dash_distance

    # コリジョンチェック(RayCast 的な動き)
    if use_raycast_check and _parent_2d != null:
        var space_state := _parent_2d.get_world_2d().direct_space_state
        var query := PhysicsRayQueryParameters2D.create(from_pos, target_pos, collision_mask)
        var result := space_state.intersect_ray(query)
        if result:
            var hit_position: Vector2 = result.position
            var safe_dir := (hit_position - from_pos).normalized()
            target_pos = hit_position - safe_dir * collision_margin

    # チャージ消費タイミング
    if max_charges > 0 and consume_on_start:
        _current_charges = max(_current_charges - 1, 0)

    _is_dashing = true
    var before := _get_global_position()

    _set_global_position(target_pos)

    _last_dash_time = Time.get_ticks_msec() / 1000.0

    if emit_signal_on_dash:
        dash_started.emit(before, target_pos)

    # (今回は瞬間移動なので、すぐに終了扱いにしてしまう)
    if max_charges > 0 and not consume_on_start:
        _current_charges = max(_current_charges - 1, 0)

    _is_dashing = false
    if emit_signal_on_dash:
        dash_finished.emit(before, target_pos)

func _can_dash() -> bool:
    # クールタイムチェック
    var now := Time.get_ticks_msec() / 1000.0
    if now - _last_dash_time < cooldown:
        return false

    # チャージチェック
    if max_charges > 0 and _current_charges <= 0:
        return false

    # 接地チェック
    if require_grounded and _parent_body != null:
        if not _parent_body.is_on_floor():
            return false

    return true

## 向きの決定ロジック
func _resolve_direction() -> Vector2:
    match direction_mode:
        0: # Use_Velocity
            if _parent_body != null:
                if _parent_body.velocity.length() > 0.01:
                    return _parent_body.velocity.normalized()
                # 停止中は facing_vector を fallback として使う
                return facing_vector.normalized()
            else:
                return facing_vector.normalized()

        1: # Use_Facing_Vector
            return facing_vector.normalized()

        2: # Use_Input_Vector
            var input_vec := Vector2.ZERO
            if Input.is_action_pressed(input_axis_left):
                input_vec.x -= 1.0
            if Input.is_action_pressed(input_axis_right):
                input_vec.x += 1.0
            if Input.is_action_pressed(input_axis_up):
                input_vec.y -= 1.0
            if Input.is_action_pressed(input_axis_down):
                input_vec.y += 1.0
            if input_vec == Vector2.ZERO:
                # 入力が無いときは facing_vector を fallback として使う
                return facing_vector.normalized()
            return input_vec.normalized()

        _:
            return facing_vector.normalized()

## 親ノードのグローバル座標を取得
func _get_global_position() -> Vector2:
    if _parent_2d != null:
        return _parent_2d.global_position
    return Vector2.ZERO

## 親ノードのグローバル座標を設定
func _set_global_position(pos: Vector2) -> void:
    if _parent_2d == null:
        return

    if use_physics_safe_teleport and _parent_body != null:
        # CharacterBody2D の場合、瞬時移動でも物理エンジンと整合が取れるように
        # グローバル座標を直接書き換えるだけにする
        _parent_body.global_position = pos
    else:
        _parent_2d.global_position = pos

## デバッグ描画(シンプルなライン)
func _debug_draw_line() -> void:
    if _parent_2d == null:
        return
    var dir := _resolve_direction()
    if dir == Vector2.ZERO:
        return
    var from_pos := _get_global_position()
    var to_pos := from_pos + dir.normalized() * dash_distance

    var world := get_tree().root.get_viewport().get_canvas_item()
    if world:
        var ci := world
        # 単純にラインを描きたいだけなので、draw_line を呼ぶための簡易 CanvasItem を使う
        # 実際には、専用の DebugDraw ノードを用意した方がきれいです

_debug_draw_line() は環境によってはうまく描画されないことがあるので、
本番では別途 DebugDraw ノードなどに差し替えるのがおすすめです。
(デバッグ用途なので、ここでは割愛します)


使い方の手順

ここでは代表的な 3 パターンで使い方を見ていきます。

  1. プレイヤーに「瞬間移動ダッシュ」を追加する
  2. 敵 AI に「一定間隔でプレイヤー方向へ瞬間移動」を追加する
  3. 動く床に「一定方向へワープ移動」を追加する

① プレイヤーに TeleportDash をアタッチする

まずは 2D プラットフォーマーのプレイヤーを想定します。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TeleportDash (Node)

手順:

  1. TeleportDash.gdres://components/TeleportDash.gd などに保存。
  2. プレイヤーシーンを開き、PlayerCharacterBody2D)の子として Node を追加。
  3. その NodeTeleportDash.gd をアタッチ。
  4. インスペクタで以下を設定:
    • dash_distance: 120〜200 あたりから調整
    • cooldown: 0.3〜0.5 秒程度
    • direction_mode: Use_Input_VectorUse_Velocity
    • dash_action_name: "dash"
    • InputMap に dash アクションを追加(Shift / Space など)

これだけで、プレイヤーは「今入力している方向」または「移動している方向」に向かって、
一瞬で dash_distance 分だけワープするようになります。

地上でだけダッシュさせたい場合は require_grounded = true にしておきましょう。

② 敵 AI に「プレイヤー方向への瞬間移動」を追加する

今度は、敵が一定間隔でプレイヤーに向かって瞬間移動してくるパターンです。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── TeleportDash (Node)
 └── EnemyAI (Node or Script)

EnemyAI.gd から TeleportDash を直接呼び出してみます。


## EnemyAI.gd
extends Node

@export var target: Node2D
@onready var teleport_dash: TeleportDash = $TeleportDash

@export_range(0.1, 10.0, 0.1)
var dash_interval: float = 1.5

var _timer: float = 0.0

func _process(delta: float) -> void:
    if target == null or teleport_dash == null:
        return

    _timer += delta
    if _timer >= dash_interval:
        _timer = 0.0
        var dir := (target.global_position - teleport_dash.get_parent().global_position).normalized()
        teleport_dash.dash(dir)

このように、敵側は「進行方向の計算」だけに集中し、
実際のワープ処理は TeleportDash に丸投げできます。
同じ敵 AI を 3D に移植したいときも、「どこまでワープするか」のロジックは共通で使いまわせますね。

③ 動く床に「一定方向へのワープ」を追加する

最後に、トラップ的な「ワープ床」を作る例です。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TeleportDash (Node)

MovingPlatform.gd 側で、一定間隔で TeleportDash を発火します。


## MovingPlatform.gd
extends Node2D

@onready var teleport_dash: TeleportDash = $TeleportDash

@export var interval: float = 2.0
@export var move_direction: Vector2 = Vector2.RIGHT

var _timer: float = 0.0

func _process(delta: float) -> void:
    if teleport_dash == null:
        return

    _timer += delta
    if _timer >= interval:
        _timer = 0.0
        teleport_dash.dash(move_direction)

このプラットフォームは、interval ごとに move_direction 方向へ dash_distance 分だけ瞬間移動します。
「ワープする足場」「瞬間移動するリフト」といったギミックを簡単に量産できますね。


メリットと応用

TeleportDash をコンポーネントとして切り出すことで、

  • プレイヤーの移動ロジックとダッシュロジックを完全に分離できる
  • プレイヤー、敵、ギミックなど、どのノードにも同じダッシュ機能をポン付けできる
  • クールタイムや距離、入力モードを インスペクタから個別に調整できる
  • シグナルでエフェクトやサウンドを 別コンポーネントに分離できる

といったメリットがあります。
「ダッシュを実装したいからとりあえず Player スクリプトに書き足す」ではなく、
「ダッシュという機能を 1 つの部品として設計する」ことで、
プロジェクトが大きくなっても 見通しの良い構成 を保ちやすくなります。

また、TeleportDash は「瞬間移動」という性質上、

  • 敵の奇襲(プレイヤーの背後にワープ)
  • ボス戦のフェーズ移行時の大ジャンプ代わり
  • パズルギミックとしての「ワープ床」「ワープブロック」

など、アクションゲーム以外にも応用しやすいです。

改造案:残像エフェクトをシグナルで追加する

最後に、「ダッシュした瞬間に残像を出す」改造案を 1 つ。
TeleportDash のシグナルを拾うだけで、エフェクト用コンポーネントを簡単に追加できます。


## DashTrail.gd
## TeleportDash の dash_started シグナルを受けて、残像を生成するコンポーネント
extends Node

@export var teleport_dash_path: NodePath = ^"../TeleportDash"
@export var ghost_scene: PackedScene   # 残像用のシーン(Sprite2D + FadeOut など)

var _teleport_dash: TeleportDash

func _ready() -> void:
    _teleport_dash = get_node_or_null(teleport_dash_path) as TeleportDash
    if _teleport_dash:
        _teleport_dash.dash_started.connect(_on_dash_started)

func _on_dash_started(from_pos: Vector2, to_pos: Vector2) -> void:
    if ghost_scene == null:
        return
    # 開始地点と終了地点の両方に残像を出す例
    _spawn_ghost(from_pos)
    _spawn_ghost(to_pos)

func _spawn_ghost(pos: Vector2) -> void:
    var ghost := ghost_scene.instantiate() as Node2D
    if ghost == null:
        return
    get_tree().current_scene.add_child(ghost)
    ghost.global_position = pos

このように、「瞬間移動する」というコアなロジックは TeleportDash に閉じ込めておき、
ビジュアルやサウンドは別コンポーネントで自由に差し替える、という構成にしておくと、
後からの改造・調整がとても楽になります。

継承でゴリゴリ書き足していくのではなく、
「瞬間移動したいノードに TeleportDash をアタッチするだけ」という発想で、
コンポーネント指向の Godot ライフを楽しんでいきましょう。