敵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
)
使い方の手順
手順①: コンポーネントをプロジェクトに追加
- 上記の
TeleportAI.gdをプロジェクト内のres://scripts/components/TeleportAI.gdなどに保存します。 - エディタを再読み込みすると、ノード追加ダイアログで
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)
WarpPlatformはStaticBody2Dでも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設計をどんどん楽にしていきましょう。
