敵AIを作るとき、つい「プレイヤーを追いかけるNavMeshのエージェントを継承して…」「敵ごとに専用のスクリプトを書いて…」という方向に行きがちですよね。
でもそうすると、

  • 敵ごとにスクリプトが肥大化して、挙動を追加するたびに継承ツリーが伸びる
  • 「ワープする敵」「追いかける敵」「飛ぶ敵」などを組み合わせたいのに、継承の都合でうまくミックスできない
  • シーンツリーの階層が深くなり、どこに何のロジックがあるのか分かりづらい

といった「Godotあるある」にハマりがちです。

そこで今回は、「歩かずに、数秒ごとにプレイヤーの背後や死角へワープする」挙動を
1つの独立したコンポーネントとして切り出した TeleportAI を用意しました。

敵はただの CharacterBody2D / CharacterBody3D としてシンプルに保ち、
「ワープ行動」という機能だけを TeleportAI コンポーネントとしてアタッチしてあげるイメージですね。
継承ツリーをいじらずに、「この敵はワープ」「この敵はワープ+射撃」といった合成が簡単になります。


【Godot 4】背後からヌッと現れる敵AI!「TeleportAI」コンポーネント

ここでは 2D を前提にした TeleportAI の実装を紹介します。
(3Dに拡張する場合のポイントも後半で触れます。)

コンポーネントの仕様

  • ターゲット(通常はプレイヤー)となるノードを指定
  • 一定間隔ごとにワープを試みる
  • ターゲットの背後 or 死角(視野外)にワープ位置を決定
  • 地形などにめり込まないよう、PhysicsDirectSpaceState2D を使って簡易チェック
  • ワープ時にエフェクトやサウンドを鳴らせるよう、シグナルを用意

ソースコード (Full Code)


extends Node2D
class_name TeleportAI
"""
TeleportAI コンポーネント (2D 用)

- 一定間隔でターゲットの背後や視界外にワープする。
- 親ノードは「ワープさせたいキャラクター」(例: CharacterBody2D)を想定。
"""

## --- エディタから設定できるパラメータ群 ---

@export var enabled: bool = true:
	set(value):
		enabled = value
		set_process(value)

@export var target: NodePath:
	## ワープ先の基準となるターゲット (通常はプレイヤー)
	## 例: "../Player" や、シーンインスタンス後にコードから代入
	get:
		return target
	set(value):
		target = value
		_cached_target = null  # 再解決させる

@export_range(0.5, 30.0, 0.1)
var teleport_interval: float = 3.0
## 何秒ごとにワープを試みるか

@export_range(0.0, 10.0, 0.1)
var random_interval_jitter: float = 1.0
## ワープ間隔にランダムな揺らぎを足す (0 なら一定間隔)

@export_range(32.0, 1024.0, 8.0)
var teleport_distance: float = 160.0
## ターゲットからどのくらい離れた位置にワープするか

@export_range(0.0, 360.0, 1.0)
var behind_angle_deg: float = 30.0
## 「背後」とみなす角度の幅 (ターゲットの後ろ方向を中心に、±この角度の範囲でランダム)

@export_range(0.0, 180.0, 1.0)
var side_angle_offset_deg: float = 60.0
## 「死角」用の角度オフセット。
## 例: 60° にすると、ターゲットの向きから ±60° 付近にも候補を作る。

@export_range(0.0, 1.0, 0.01)
var line_of_sight_margin: float = 0.2
## 視線チェックのマージン。0 に近いほどシビアに「視界外」を判定。

@export var require_out_of_sight: bool = true
## true の場合、ターゲットから見えていない(視線が通っていない)位置のみ採用する

@export var max_teleport_attempts: int = 6
## ワープ位置のサンプリングを何回まで試すか

@export var collision_radius: float = 8.0
## ワープ先の「空きスペース」をチェックするための半径 (小さめ推奨)

@export_flags_2d_physics
var collision_mask: int = 1
## ワープ先にめり込んではいけないレイヤー (例: 地形)

@export var use_parent_rotation_as_facing: bool = false
## true の場合、ターゲットの「向き」を rotation から取得する。
## false の場合、ターゲットの velocity (CharacterBody2D など) から向きを推定し、
## それも無ければ parent → target のベクトルから推定する。

@export var debug_draw: bool = false
## ワープ候補位置や視線チェックをデバッグ表示するか


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

signal will_teleport(old_global_position: Vector2, new_global_position: Vector2)
## これからワープする直前に発火。エフェクト再生などに。

signal did_teleport(old_global_position: Vector2, new_global_position: Vector2)
## ワープ完了後に発火。


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

var _timer: float = 0.0
var _next_interval: float = 0.0
var _cached_target: Node2D

func _ready() -> void:
	# process の ON/OFF を enabled に合わせる
	set_process(enabled)
	_reset_interval()


func _process(delta: float) -> void:
	if not enabled:
		return

	if _resolve_target() == null:
		return

	_timer -= delta
	if _timer <= 0.0:
		_try_teleport()
		_reset_interval()


func _reset_interval() -> void:
	# ベース間隔 + ランダム揺らぎ
	var jitter := 0.0
	if random_interval_jitter > 0.0:
		jitter = randf_range(-random_interval_jitter, random_interval_jitter)
	_next_interval = max(0.1, teleport_interval + jitter)
	_timer = _next_interval


func _resolve_target() -> Node2D:
	if _cached_target and is_instance_valid(_cached_target):
		return _cached_target

	if target == NodePath():
		return null

	var node := get_node_or_null(target)
	if node and node is Node2D:
		_cached_target = node
	else:
		_cached_target = null
	return _cached_target


func _try_teleport() -> void:
	var t := _resolve_target()
	if t == null:
		return

	var space_state := get_world_2d().direct_space_state

	# ターゲットの向きをベクトルとして取得
	var facing := _get_target_facing_direction(t)
	if facing == Vector2.ZERO:
		# 向きが決められない場合は、親からターゲットへのベクトルを使う
		facing = (t.global_position - get_parent().global_position).normalized()

	var origin := t.global_position

	# 複数回サンプリングして、最初に条件を満たした位置を採用
	for i in max_teleport_attempts:
		var candidate := _sample_candidate_position(origin, facing, i)
		if not _is_position_free(candidate, space_state):
			continue
		if require_out_of_sight and _is_in_line_of_sight(origin, candidate, space_state):
			continue

		# ワープ実行
		var old_pos := get_parent().global_position
		emit_signal("will_teleport", old_pos, candidate)
		get_parent().global_position = candidate
		emit_signal("did_teleport", old_pos, candidate)

		if debug_draw:
			_debug_draw_line(origin, candidate, Color.GREEN)
		return

	# デバッグ用: 失敗したとき
	if debug_draw:
		_debug_draw_line(origin, get_parent().global_position, Color.RED)


func _get_target_facing_direction(t: Node2D) -> Vector2:
	if use_parent_rotation_as_facing:
		# rotation はラジアン。右向きが 0 で、反時計回りが正。
		return Vector2.RIGHT.rotated(t.global_rotation)

	# velocity プロパティがある場合はそれを使う (CharacterBody2D, RigidBody2D など)
	if "velocity" in t and t.velocity.length() > 0.1:
		return t.velocity.normalized()

	# それも無ければ 0 ベクトルを返す (呼び出し側でフォールバックする)
	return Vector2.ZERO


func _sample_candidate_position(origin: Vector2, facing: Vector2, attempt_index: int) -> Vector2:
	# 0〜2回目くらいは「背後」を優先、それ以降は「側面(死角)」も混ぜる、などの簡単なロジック
	var use_side := attempt_index >= 2

	var base_angle: float
	if use_side:
		# 左右どちらかの側面方向
		var sign := randf() > 0.5 ? 1.0 : -1.0
		base_angle = sign * deg_to_rad(side_angle_offset_deg)
	else:
		# 完全な背後方向 = 180°
		base_angle = PI

	# base_angle を中心にランダムな揺らぎを加える
	var jitter_angle := deg_to_rad(behind_angle_deg)
	var random_offset := randf_range(-jitter_angle, jitter_angle)
	var final_angle := base_angle + random_offset

	var dir := facing.rotated(final_angle).normalized()
	return origin + dir * teleport_distance


func _is_position_free(position: Vector2, space_state: PhysicsDirectSpaceState2D) -> bool:
	# 円形のスペースが空いているかをチェックする簡易版。
	var shape := CircleShape2D.new()
	shape.radius = collision_radius

	var params := PhysicsShapeQueryParameters2D.new()
	params.shape = shape
	params.transform = Transform2D(0.0, position)
	params.collision_mask = collision_mask

	var result := space_state.intersect_shape(params, 1)
	return result.is_empty()


func _is_in_line_of_sight(origin: Vector2, target_pos: Vector2, space_state: PhysicsDirectSpaceState2D) -> bool:
	# ターゲットから candidate までの間に「視線を遮る障害物」があるかをチェック。
	# ここでは collision_mask をそのまま利用。
	var query := PhysicsRayQueryParameters2D.create(origin, target_pos, collision_mask)
	var hit := space_state.intersect_ray(query)

	if hit.is_empty():
		# 何も当たらなければ「視線が通っている」= 視界内
		if debug_draw:
			_debug_draw_line(origin, target_pos, Color.YELLOW)
		return true

	# ヒット位置が candidate に十分近ければ「ほぼ見えている」とみなす
	var hit_pos: Vector2 = hit.position
	var total_dist := origin.distance_to(target_pos)
	var hit_dist := origin.distance_to(hit_pos)

	var ratio := hit_dist / total_dist
	if debug_draw:
		_debug_draw_line(origin, hit_pos, Color.ORANGE)
	# ratio が 1.0 に近いほど candidate に近い場所で遮られている。
	# margin より小さい = かなり手前で遮られている = 視界外 とみなす。
	return ratio >= (1.0 - line_of_sight_margin)


func _debug_draw_line(from: Vector2, to: Vector2, color: Color) -> void:
	# デバッグ描画は、親の CanvasItem に頼る形にしておく
	var parent := get_parent()
	if parent and parent is CanvasItem:
		(parent as CanvasItem).draw_line(
			parent.to_local(from),
			parent.to_local(to),
			color,
			1.5
		)

使い方の手順

手順①: コンポーネントをプロジェクトに追加

  1. 上記の TeleportAI.gd をプロジェクト内の res://scripts/components/TeleportAI.gd などに保存します。
  2. エディタを再読み込みすると、ノード追加ダイアログTeleportAI がクラスとして選べるようになります。

手順②: 敵シーンにアタッチする

例として、プレイヤーを追いかける「ワープ敵」を作ってみましょう。

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

EnemyWarper (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TeleportAI (Node2D)  ← このコンポーネントを追加
  • EnemyWarper は普通の CharacterBody2D でOK。移動ロジックが無くても構いません。
  • EnemyWarper の子として TeleportAI ノードを追加し、スクリプトに TeleportAI.gd を指定します。

手順③: ターゲット(プレイヤー)を指定する

シーンツリーでプレイヤーと敵が同じ親の下にあると仮定します。

Main (Node2D)
 ├── Player (CharacterBody2D)
 ├── EnemyWarper (CharacterBody2D)
 │    └── TeleportAI (Node2D)
 └── TileMap

TeleportAI を選択し、インスペクタで target../Player を設定します。

  • teleport_interval: 例えば 3.0 秒にすると、だいたい 3 秒ごとにワープします。
  • random_interval_jitter: 1.0 にすると、2〜4秒のあいだでランダムになります。
  • teleport_distance: プレイヤーからどれくらい離れた位置に出現するか。マップの広さに応じて調整しましょう。
  • collision_mask: 地形(TileMapなど)が属しているレイヤーを指定しておくと、壁の中にワープしなくなります。
  • require_out_of_sight: true にしておくと、「プレイヤーから見えていない位置」だけにワープしようとします。

手順④: エフェクトやサウンドをつなぐ(任意)

ワープ時にパーティクルやSEを鳴らしたい場合は、TeleportAI のシグナルを利用します。


# EnemyWarper.gd (親のスクリプト側で受け取る例)
extends CharacterBody2D

@onready var teleport_ai: TeleportAI = $TeleportAI

func _ready() -> void:
	teleport_ai.will_teleport.connect(_on_will_teleport)
	teleport_ai.did_teleport.connect(_on_did_teleport)

func _on_will_teleport(old_pos: Vector2, new_pos: Vector2) -> void:
	# 消えるエフェクトを old_pos で再生するなど
	print("ワープ準備: ", old_pos, " → ", new_pos)

func _on_did_teleport(old_pos: Vector2, new_pos: Vector2) -> void:
	# 現れるエフェクトを new_pos で再生するなど
	print("ワープ完了: ", old_pos, " → ", new_pos)

このように、ワープのロジックそのものは TeleportAI に閉じ込めておき
演出は親側で自由に差し替えられるようにしておくと、コンポーネントとしての再利用性がかなり高まります。


別の使用例

例1: 動く床の「ワープ版」

プレイヤーを載せると、一定時間ごとに背後にワープするトラップ床を作る場合:

WarpPlatform (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TeleportAI (Node2D)
  • WarpPlatformStaticBody2D でもOK。動かない床ですが、ワープで瞬間移動します。
  • target"../Player" にしておけば、プレイヤーの死角に床が現れたり消えたりするギミックになります。

例2: ボスの「フェーズ切り替え」専用ワープ

ボス戦で、HPが一定以下になったら「ワープフェーズ」に移行するケース:

Boss (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── TeleportAI (Node2D)
 └── BossLogic (Script)

BossLogic から TeleportAI.enabled をオン・オフすることで、
好きなタイミングだけワープAIを有効化できます。


# BossLogic.gd
@onready var teleport_ai: TeleportAI = $TeleportAI

var phase := 0

func _process(delta: float) -> void:
	if phase == 0 and health <= max_health * 0.5:
		phase = 1
		teleport_ai.enabled = true  # ここからワープフェーズ開始

メリットと応用

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

  • 敵本体のスクリプトが「移動」「攻撃」「ワープ」などで肥大化しない
  • 「ワープする敵」「ワープする動く床」「ワープするボス」など、
    ノードの種類に関わらずワープ挙動を簡単に再利用できる
  • シーン構造がシンプルになり、「どのノードがどんなAIを持っているか」が一目で分かる
  • ワープのパラメータ(間隔、距離、視界判定など)をレベルデザイナーがインスペクタから直接いじれる

といった恩恵があります。
特に「視界外にワープする」挙動は、ステルスゲームやホラーゲームの敵AIとしても流用しやすいですね。

応用・改造案: プレイヤーのHPに応じてワープ頻度を変える

例えば、プレイヤーのHPが減るほどワープ間隔が短くなるようにしたい場合、
TeleportAI をそのまま使いつつ、親スクリプトでパラメータを動的に変更するのがおすすめです。


# EnemyWarper.gd (改造例)
extends CharacterBody2D

@onready var teleport_ai: TeleportAI = $TeleportAI
@export var player_path: NodePath

var _player

func _ready() -> void:
	_player = get_node_or_null(player_path)

func _process(delta: float) -> void:
	if not _player:
		return

	# プレイヤーの HP 比率に応じてワープ間隔を変える (例)
	var hp_ratio := float(_player.health) / float(_player.max_health)
	# HP が減るほど間隔が短くなる (1.0〜4.0秒の範囲)
	teleport_ai.teleport_interval = lerp(1.0, 4.0, hp_ratio)

このように、「ワープそのもののロジック」は TeleportAI に閉じ込めたまま、
外側のスクリプトからパラメータを操作することで、色々なゲームデザインに対応できます。
継承に頼らず、コンポーネントを合成していくスタイルで、Godot 4 のAI設計をどんどん楽にしていきましょう。