Godotで「ワープポータル」を実装しようとすると、ついこういう構成にしがちですよね。

  • Playerシーンの中に「PortalArea」を子ノードとしてベタ書き
  • プレイヤー側のスクリプトに「ポータル判定」ロジックを直書き
  • 敵や動く床など、他のオブジェクトにもワープを持たせたくなってコピペ地獄

さらに、

  • 「このポータルはどこに飛ぶんだっけ?」がシーンを開かないと分からない
  • ペア関係をコードでベタ書きしてしまい、レベルデザイナーが触りにくい
  • プレイヤーだけワープできる、敵だけワープできる…など条件が複雑化

こういう「継承+深いノード階層+スクリプト肥大化」を避けるために、今回はどのノードにもポン付けできる「TeleportPortal コンポーネント」として実装してみましょう。
合成(Composition)で「ワープ機能」を後付けできるようにしておくと、プレイヤーでも敵でも動く床でも、同じ仕組みを使い回せてスッキリします。

【Godot 4】ペアをつなぐ瞬間移動ゲート!「TeleportPortal」コンポーネント

このコンポーネントは、Area2D をベースにしたポータルです。
コンポーネントとして TeleportPortal をシーンに追加し、ペア先のポータルをインスペクターで紐づけるだけで、入ったオブジェクトをペアの位置へ瞬時に移動させます。

  • プレイヤー
  • 敵キャラ
  • 動く床やギミック

など、「位置を持っているノード」ならなんでもワープ対象にできます。


フルコード:TeleportPortal.gd


extends Area2D
class_name TeleportPortal
##
## TeleportPortal コンポーネント
## - この Area2D に入ったノードを、ペアになっている別の TeleportPortal の位置へ瞬時に移動させる
## - 「どのノードがワープ対象か」はグループまたはカスタム条件で制御可能
##

@export_group("Portal Settings")
## ペア先のポータル。インスペクターでドラッグ&ドロップして設定します。
@export var paired_portal: TeleportPortal : set = set_paired_portal

## ワープ対象にしたいノードが所属しているグループ名。
## 例: "player", "enemy", "movable" など。
## 空文字の場合は「すべてのボディ/エリア」を対象とします。
@export var target_group: StringName = &"player"

## ワープさせるときに、ペア先ポータルの位置から少しだけオフセットする距離(ピクセル)。
## キャラクターがポータルと完全に重ならないようにするためのものです。
@export var exit_offset: Vector2 = Vector2(16, 0)

## ワープ後に「どの方向にオフセットするか」。
## 例: Vector2.RIGHT, Vector2.LEFT, Vector2.UP など。
## ペアポータルの向きに応じて変えたい場合は、回転を見て調整するのもありです。
@export var exit_direction: Vector2 = Vector2.RIGHT

@export_group("Cooldown / Safety")
## 同じポータルに連続で入ったときに「行ったり来たり無限ループ」にならないように、
## ワープ後に再度ワープ可能になるまでのクールダウン時間(秒)。
@export_range(0.0, 10.0, 0.05, "or_greater") var cooldown_time: float = 0.4

## true の場合、ワープ対象のノードに「_last_teleport_time」メタデータを付与して、
## 連続ワープを防ぎます(ポータルをまたいだ無限往復対策)。
@export var use_metadata_cooldown: bool = true

## このポータル全体に対するクールダウン。
## 直前に誰かをワープさせた場合、一定時間は誰もワープさせないようにします。
@export var portal_cooldown_time: float = 0.0

@export_group("Debug")
## デバッグ用。true にすると、ワープ時にログを出力します。
@export var debug_log: bool = false

## 内部状態:最後にこのポータルが誰かをワープさせた時間(秒)
var _last_portal_teleport_time: float = -1000.0

func _ready() -> void:
    ## Area2D のシグナルを接続しておきます。
    body_entered.connect(_on_body_entered)
    area_entered.connect(_on_area_entered)

    if debug_log:
        if paired_portal == null:
            push_warning("[TeleportPortal] paired_portal が設定されていません。ワープは行われません。")
        else:
            print("[TeleportPortal] Ready. Paired with: ", paired_portal.name)

func set_paired_portal(value: TeleportPortal) -> void:
    paired_portal = value
    if debug_log and paired_portal:
        print("[TeleportPortal] Paired portal set to: ", paired_portal.name)

## --- 共通処理: ボディ / エリアが入ったときに呼ばれる ---

func _on_body_entered(body: Node) -> void:
    _try_teleport(body)

func _on_area_entered(area: Area2D) -> void:
    _try_teleport(area)

## 実際のワープ処理
func _try_teleport(target: Node) -> void:
    if paired_portal == null:
        if debug_log:
            push_warning("[TeleportPortal] paired_portal が未設定のため、ワープをスキップしました。")
        return

    # ポータル側のクールダウンチェック
    var now := Time.get_ticks_msec() / 1000.0
    if portal_cooldown_time > 0.0 and now - _last_portal_teleport_time < portal_cooldown_time:
        if debug_log:
            print("[TeleportPortal] Portal cooldown active. Skip teleport for: ", target)
        return

    # 対象ノードがグループ条件を満たすかチェック
    if target_group != &"" and not target.is_in_group(target_group):
        # 対象グループが設定されている場合、それ以外は無視します。
        return

    # 位置を持っているノードだけを対象にしたいので、Node2D かそのサブクラスかを確認
    if not target is Node2D:
        if debug_log:
            push_warning("[TeleportPortal] Target is not Node2D. Cannot teleport: %s" % [target])
        return

    # メタデータによる個別クールダウンチェック
    if use_metadata_cooldown:
        var last_time := target.get_meta_or_default("_last_teleport_time", -1000.0)
        if typeof(last_time) == TYPE_FLOAT and now - float(last_time) < cooldown_time:
            if debug_log:
                print("[TeleportPortal] Target cooldown active. Skip teleport for: ", target)
            return

    # 実際のワープ位置を計算
    var node2d := target as Node2D
    var exit_dir := exit_direction.normalized()
    if exit_dir == Vector2.ZERO:
        exit_dir = Vector2.RIGHT

    var exit_pos := paired_portal.global_position + exit_dir * exit_offset.length()

    # ワープを実行
    node2d.global_position = exit_pos

    # キャラクター系(CharacterBody2D / RigidBody2D 等)の速度をリセットしたい場合は、
    # ここでカスタム処理を入れてもOKです。
    _reset_velocity_if_applicable(target)

    # クールダウン情報を更新
    _last_portal_teleport_time = now
    if use_metadata_cooldown:
        target.set_meta("_last_teleport_time", now)

    if debug_log:
        print("[TeleportPortal] Teleported ", target, " to ", exit_pos, " via ", paired_portal.name)

## CharacterBody2D や RigidBody2D をワープさせるとき、
## 直前の速度を引きずると不自然な場合があるので、必要に応じて速度をゼロにします。
func _reset_velocity_if_applicable(target: Node) -> void:
    if target is CharacterBody2D:
        var body := target as CharacterBody2D
        body.velocity = Vector2.ZERO
    elif target is RigidBody2D:
        var rigid := target as RigidBody2D
        rigid.linear_velocity = Vector2.ZERO
        rigid.angular_velocity = 0.0

## 便利関数: スクリプトから明示的にワープを発動したい場合に使えます。
## 例: ボタンを押したときだけワープさせたい、など。
func teleport_node(target: Node) -> void:
    _try_teleport(target)

使い方の手順

ここでは、プレイヤーがポータルに入ると別の場所にワープする例で説明します。

手順①:TeleportPortal コンポーネントをシーンに追加

  1. 新規シーンで Area2D を作成し、名前を TeleportPortal に変更。
  2. この Area2D に上記の TeleportPortal.gd をアタッチ。
  3. 子ノードとして CollisionShape2DSprite2D などを追加し、ポータルの見た目と当たり判定を作ります。
  4. このシーンを TeleportPortal.tscn として保存。

シーン構成例:

TeleportPortal (Area2D)
 ├── CollisionShape2D   # 円形などでポータルの範囲を定義
 └── Sprite2D           # ポータルの見た目(アニメーションでもOK)

手順②:ポータルを2つ配置してペアにする

  1. メインステージ(例: Level01.tscn)を開く。
  2. シーンツリーに TeleportPortal.tscn を2つドラッグ&ドロップして配置。
    • 例: Portal_APortal_B という名前にする。
  3. Portal_A を選択し、インスペクターの paired_portalPortal_B をドラッグ。
  4. 同様に、Portal_Bpaired_portalPortal_A をドラッグ。

ステージ全体のシーン構成図例:

Level01 (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── PlayerController (Script)   # 既存のプレイヤー制御スクリプト
 ├── Portal_A (TeleportPortal)        # TeleportPortal.tscn のインスタンス
 │    ├── CollisionShape2D
 │    └── Sprite2D
 └── Portal_B (TeleportPortal)
      ├── CollisionShape2D
      └── Sprite2D

プレイヤー側のスクリプトは一切いじらずに、ポータル側のコンポーネントだけでワープ機能を完結させているのがポイントです。

手順③:プレイヤーを「ワープ対象グループ」に入れる

  1. Player ノードを選択。
  2. インスペクター上部の「Node」タブ → 「Groups」タブを開く。
  3. player というグループ名を追加。
  4. TeleportPortal の target_groupplayer を設定(デフォルトが player なので変えなくてもOK)。

これで、Player だけがポータルに反応してワープするようになります。
敵もワープさせたい場合は enemy グループを作って敵を所属させ、ポータル側の target_groupenemy に変えた別インスタンスを置けばOKです。

手順④:実行してテスト

  • ゲームを実行し、プレイヤーを Portal_A に重ねてみましょう。
  • プレイヤーが Portal_B の近くに瞬間移動していれば成功です。
  • もし「行ったり来たり」を繰り返す場合は、cooldown_timeportal_cooldown_time を少し大きめにしてみてください。

敵キャラでも同じように使えます。例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── EnemyAI (Script)
 └── TeleportPortal (Area2D)  # 敵専用のポータルを子として持たせる例
      ├── CollisionShape2D
      └── Sprite2D

この場合は、Enemyenemy グループに入れて、ポータルの target_groupenemy にしておくと、敵専用ワープゲートとして動かせます。


メリットと応用

この TeleportPortal コンポーネントを使うと、

  • プレイヤーや敵のスクリプトに「ポータル処理」を書かなくてよい
    • それぞれの役割(移動ロジック、AI、ワープ)は別コンポーネントで管理できます。
  • レベルデザイナーがインスペクターからペア関係を直感的に編集できる
    • コードを触らずに「このポータルはあっちへ飛ぶ」を変えられます。
  • どのノードにも後付けできる
    • プレイヤー、敵、動く床、ギミックなど、位置を持つものなら何でもワープ可能。
  • シーン構造がフラットになる
    • 「Playerの中にPortalArea、その中にさらに…」のような深いネストを避けられます。

特に「継承ベースで PlayerWithPortal, EnemyWithPortal みたいな派生シーンを増やす」パターンと比べると、TeleportPortal をポン付けするだけで済むのはかなり快適です。

応用アイデア:方向を引き継ぐワープ

例えば、「ポータルに入った方向をそのまま出口側に反映したい」場合、
以下のような関数で exit_direction を自動計算するのもアリです。


## 改造案:入口に入ってきた向きから出口方向を決める
func compute_exit_direction_from_entry(target: Node2D) -> Vector2:
    # ターゲットとこのポータルの位置関係から「入ってきた方向」を推定
    var dir := (global_position - target.global_position).normalized()
    # そのまま使うと逆向きなので、反転させて出口方向にする
    return -dir

この関数を _try_teleport() 内で使えば、
「左から入ったら右へ抜ける」「下から入ったら上へ抜ける」といった、よりゲームっぽいポータル挙動も簡単に実現できます。

こんな感じで、ワープという「機能」をコンポーネントとして切り出しておくと、
あとから「速度を引き継ぐ」「向きを変える」「エフェクトを出す」などの拡張も、
継承地獄に陥らずにサクッと追加していけますね。Composition でいきましょう。