【Cocos Creator 3.8】FlankTactic(回り込みAI)の実装:アタッチするだけで「プレイヤーを横から追い詰める」賢い移動を実現する汎用スクリプト
このコンポーネントは、ターゲット(多くの場合プレイヤー)に対して、真正面から直線的に突っ込むのではなく、「横方向にふくらみながら距離を詰める」いわゆる“回り込み”行動を簡単に実装するための汎用AI移動コンポーネントです。
ターゲットとなるノードをインスペクタで指定し、回り込みの強さや接近速度を調整するだけで、敵キャラクターなどに「少し賢く見える」動きを与えられます。外部のGameManagerやシングルトンに一切依存せず、このスクリプト単体をアタッチするだけで動作します。
コンポーネントの設計方針
1. 機能要件の整理
- 指定したターゲット(例:プレイヤー)に向かって移動するが、真正面ではなく「横方向(サイド)」にずれながら接近する。
- 2D/3Dどちらのプロジェクトでも使えるよう、「XZ平面の3D」「XY平面の2D」の両方をサポートする。
- ターゲットが動いていても、常に相対位置から「回り込み方向」を計算し続ける。
- インスペクタから調整可能なパラメータのみで挙動を制御し、他のカスタムスクリプトには依存しない。
- ターゲットが未設定・存在しない場合は安全に何もしないよう防御的に実装し、警告ログを出す。
2. 回り込みのロジック概要
フレームごとに以下のような方向ベクトルを作ります:
- toTarget = ターゲット位置 − 自身の位置(ターゲットへの直線方向)
- forwardDir = toTarget を正規化したベクトル
- sideDir = forwardDir に対して垂直なベクトル(左右どちらか)
- moveDir = normalize(forwardDir + sideDir × flankStrength)
この moveDir に沿って移動することで、「前に進みつつ横にずれる」動きになります。
flankStrength が大きいほど、横成分が強くなり、より大きく膨らんだ軌道になります。
3. インスペクタで設定可能なプロパティ設計
以下のようなプロパティを用意します。
- target: Node | null
- 説明:追いかけるターゲット(通常はプレイヤー)のノード。
- 役割:このノードの位置を基準に回り込み方向を計算する。
- 注意:未設定の場合は移動しない。警告ログを出力。
- moveSpeed: number
- 説明:1秒あたりの移動速度(ワールド単位)。
- 例:2.0〜10.0 くらいを想定。ゲームのスケールに応じて調整。
- flankStrength: number
- 説明:回り込み(横方向)成分の強さ。
- 0 なら真正面に向かうだけ、1 で「前+横」が同程度、2 以上で大きく円を描くような軌道。
- flankSide: number(enum風)
- 説明:どちら側から回り込むか。
- 値:-1 = 左側から回り込む、1 = 右側から回り込む。
- stopDistance: number
- 説明:ターゲットとの距離がこの値以下になったら停止する(近づきすぎ防止)。
- 例:0.5〜2.0 くらい。
- use2D: boolean
- 説明:2Dゲーム用のXY平面モードを使うかどうか。
- true:XY平面で計算(通常の2Dゲーム)。
- false:XZ平面で計算(3Dゲームでの平面移動)。
- enableAutoRotate: boolean
- 説明:移動方向に応じてノードの回転を自動で合わせるかどうか。
- 2Dの場合:Z回転(スプライトの向き)を調整。
- 3Dの場合:Y軸まわりに回転して前方を向ける想定。
- rotationLerp: number
- 説明:回転追従のなめらかさ。0〜1 の補間係数。
- 0.1 くらいで「ゆっくり追従」、1.0 で「即座に向き変更」。
- debugDraw: boolean
- 説明:エディタのプレビュー実行中に移動方向などのデバッグラインを描画するかどうか。
- 注意:Gizmos ではなく、簡易的にログや簡単な描画に留める(ここではログのみにします)。
TypeScriptコードの実装
import { _decorator, Component, Node, Vec3, math, Quat, CCFloat, CCBoolean } from 'cc';
const { ccclass, property } = _decorator;
/**
* FlankTactic
* ターゲットに対して真正面ではなく、横方向に膨らみながら接近するAI移動コンポーネント。
* 外部のゲームマネージャなどには一切依存せず、ターゲットNodeを指定するだけで動作します。
*/
@ccclass('FlankTactic')
export class FlankTactic extends Component {
@property({
type: Node,
tooltip: '追いかけるターゲット(例:プレイヤー)のノード。未設定の場合は移動しません。'
})
public target: Node | null = null;
@property({
type: CCFloat,
tooltip: '移動速度(1秒あたりのワールド単位)。数値が大きいほど速く移動します。'
})
public moveSpeed: number = 3.0;
@property({
type: CCFloat,
tooltip: '回り込み(横方向)成分の強さ。0で真正面のみ、1で前+横が同程度、2以上で大きく膨らみます。'
})
public flankStrength: number = 1.0;
@property({
tooltip: 'どちら側から回り込むか。-1で左側、1で右側。'
})
public flankSide: number = 1; // -1: left, 1: right
@property({
type: CCFloat,
tooltip: 'ターゲットとの距離がこの値以下になったら停止します。0以下なら常に接近し続けます。'
})
public stopDistance: number = 1.0;
@property({
type: CCBoolean,
tooltip: 'trueなら2Dゲーム用のXY平面として計算します。falseなら3DゲームのXZ平面として計算します。'
})
public use2D: boolean = true;
@property({
type: CCBoolean,
tooltip: '移動方向に応じてノードの回転を自動で合わせるかどうか。'
})
public enableAutoRotate: boolean = true;
@property({
type: CCFloat,
tooltip: '回転の追従速度(0〜1)。1で即座に向きを合わせ、0.1程度でゆっくり追従します。'
})
public rotationLerp: number = 0.2;
@property({
type: CCBoolean,
tooltip: 'trueにすると、実行中に移動方向ベクトルなどをログ出力します(デバッグ用)。'
})
public debugDraw: boolean = false;
// 内部で再利用する一時ベクトル(GC削減)
private _tempVecA: Vec3 = new Vec3();
private _tempVecB: Vec3 = new Vec3();
onLoad() {
// flankSide の値を -1 or 1 に正規化しておく
if (this.flankSide === 0) {
this.flankSide = 1;
} else {
this.flankSide = this.flankSide > 0 ? 1 : -1;
}
if (!this.target) {
console.warn('[FlankTactic] target が設定されていません。ターゲットが設定されるまで移動しません。');
}
}
start() {
// ここでは特に初期化は不要だが、将来の拡張に備えて残しておく
}
update(deltaTime: number) {
// ターゲットがいない場合は何もしない
if (!this.target) {
return;
}
const selfNode = this.node;
// 自身とターゲットのワールド座標を取得
const selfPos = selfNode.worldPosition;
const targetPos = this.target.worldPosition;
// ターゲットへのベクトルを計算
Vec3.subtract(this._tempVecA, targetPos, selfPos); // toTarget = target - self
// 2DモードならZを0に、3DモードならYを0にして平面上に投影
if (this.use2D) {
this._tempVecA.z = 0;
} else {
this._tempVecA.y = 0;
}
const distance = this._tempVecA.length();
// stopDistance が正で、かつ十分近づいている場合は停止
if (this.stopDistance > 0 && distance <= this.stopDistance) {
return;
}
// 距離がほぼ0の場合は方向が定まらないので何もしない
if (distance < 0.0001) {
return;
}
// forwardDir = ターゲットへの正規化ベクトル
const forwardDir = this._tempVecA;
forwardDir.normalize();
// sideDir を計算(forwardに対する垂直方向)
const sideDir = this._tempVecB;
if (this.use2D) {
// XY平面での垂直ベクトル(左or右)
// forward = (x, y, 0) に対して、left = (-y, x, 0)
sideDir.set(-forwardDir.y, forwardDir.x, 0);
} else {
// XZ平面での垂直ベクトル(左or右)
// forward = (x, 0, z) に対して、left = (-z, 0, x)
sideDir.set(-forwardDir.z, 0, forwardDir.x);
}
sideDir.normalize();
sideDir.multiplyScalar(this.flankStrength * this.flankSide);
// moveDir = forward + side を正規化
const moveDir = new Vec3();
Vec3.add(moveDir, forwardDir, sideDir);
// 万が一ゼロベクトルになったら、forwardのみで動く
if (moveDir.length() < 0.0001) {
moveDir.set(forwardDir);
} else {
moveDir.normalize();
}
// 実際の移動量 = moveDir * moveSpeed * deltaTime
const moveDelta = new Vec3();
Vec3.multiplyScalar(moveDelta, moveDir, this.moveSpeed * deltaTime);
// 現在位置に加算して新しい位置を設定
const newPos = new Vec3();
Vec3.add(newPos, selfPos, moveDelta);
selfNode.setWorldPosition(newPos);
// 自動回転
if (this.enableAutoRotate) {
this._applyAutoRotation(moveDir, deltaTime);
}
// デバッグログ
if (this.debugDraw) {
console.log('[FlankTactic] moveDir =', moveDir.toString(), 'distance =', distance.toFixed(3));
}
}
/**
* 移動方向に応じてノードの回転を自動で合わせる。
* 2D: Z回転を方向に合わせる
* 3D: Y軸回りに回転して前方を向ける(前方は +Z を想定)
*/
private _applyAutoRotation(moveDir: Vec3, deltaTime: number) {
const node = this.node;
if (this.use2D) {
// 2Dの場合:XY平面で方向ベクトルから角度を計算し、Z回転に反映
// atan2(y, x) を度数に変換
const angleRad = Math.atan2(moveDir.y, moveDir.x);
const angleDeg = math.toDegree(angleRad);
// Cocosの2Dスプライトは通常、右向きが0度なのでそのまま適用
const currentEuler = node.eulerAngles;
const targetEuler = new Vec3(currentEuler.x, currentEuler.y, angleDeg);
// 線形補間でなめらかに回転
const lerpFactor = math.clamp01(this.rotationLerp);
const newEuler = new Vec3(
math.lerp(currentEuler.x, targetEuler.x, lerpFactor),
math.lerp(currentEuler.y, targetEuler.y, lerpFactor),
math.lerp(currentEuler.z, targetEuler.z, lerpFactor)
);
node.setRotationFromEuler(newEuler);
} else {
// 3Dの場合:XZ平面の方向からY軸回転を計算
const angleRad = Math.atan2(moveDir.x, moveDir.z); // Zを前方とする
const angleDeg = math.toDegree(angleRad);
const currentEuler = node.eulerAngles;
const targetEuler = new Vec3(currentEuler.x, angleDeg, currentEuler.z);
const lerpFactor = math.clamp01(this.rotationLerp);
const newEuler = new Vec3(
math.lerp(currentEuler.x, targetEuler.x, lerpFactor),
math.lerp(currentEuler.y, targetEuler.y, lerpFactor),
math.lerp(currentEuler.z, targetEuler.z, lerpFactor)
);
node.setRotationFromEuler(newEuler);
}
}
}
コードの主要ポイント解説
- onLoad()
flankSideを -1 または 1 に正規化しています。targetが未設定の場合、警告ログを出しておきます(実行時に気づきやすくするため)。
- update(deltaTime)
- ターゲットが存在しない場合は即 return し、安全に何もしません。
- 自身とターゲットのワールド座標を取得し、差分から
toTargetベクトルを計算します。 use2Dに応じて、XY または XZ 平面に投影してから距離と方向を求めます。stopDistance以内に入ったら停止し、これ以上近づかないようにします。- forward(ターゲット方向)と side(垂直方向)を合成して
moveDirを作り、正規化して移動方向にします。 - 移動量 =
moveDir × moveSpeed × deltaTimeを現在位置に加算し、新しいワールド座標として設定します。 enableAutoRotateが true の場合は、_applyAutoRotation()で向きを合わせます。
- _applyAutoRotation(moveDir, deltaTime)
- 2Dモードでは、
atan2(y, x)からZ回転角を算出し、スプライトの向きを移動方向に合わせます。 - 3Dモードでは、XZ平面上の方向からY軸回転を算出し、
+Zを前方とするモデルが移動方向を向くように調整します。 rotationLerpを用いて現在のオイラー角との線形補間を行い、急激な向き変更を避けて自然な回転にします。
- 2Dモードでは、
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリックします。
Create > TypeScriptを選択します。- ファイル名を
FlankTactic.tsにします。 - 作成された
FlankTactic.tsをダブルクリックしてエディタ(VSCode など)で開き、本文のコードをすべて貼り付けて上書き保存します。
2. テスト用シーンの準備(2Dの場合の例)
- Hierarchy パネルで右クリックし、
Create > 2D Object > Spriteを選択して、プレイヤー用のノード(例:Player)を作成します。 - 同様にして、敵キャラクター用のノード(例:
Enemy)となる Sprite を作成します。 - シーン上で
PlayerとEnemyの位置を適当に離して配置します(例:Player を (0,0)、Enemy を (-300, 0) など)。
3. FlankTactic コンポーネントのアタッチ
- Hierarchy で
Enemyノードを選択します。 - Inspector の下部にある
Add Componentボタンをクリックします。 CustomカテゴリからFlankTacticを選択して追加します。
4. プロパティの設定
Enemy の Inspector に表示された FlankTactic の各プロパティを設定します。
- Target:
- Hierarchy から
Playerノードをドラッグ&ドロップして、このフィールドにセットします。
- Hierarchy から
- Move Speed:
- 例:
3〜6くらいに設定して、動きが見やすい速度にします。
- 例:
- Flank Strength:
- まずは
1.0に設定して、自然な回り込みを確認してみます。 - より大きく円を描くように回り込ませたい場合は
2.0〜3.0に上げてみてください。
- まずは
- Flank Side:
1: 右側から回り込む。-1: 左側から回り込む。- 左右の違いを見たい場合は、値を切り替えて再生してみましょう。
- Stop Distance:
- 例:
1.0に設定すると、ターゲットにほぼ接触したら停止します。 - 常にぐるぐる回り込ませたい場合は
0か、かなり小さい値にしておきます。
- 例:
- Use 2D:
- 2Dプロジェクトの場合は
trueのままでOKです。
- 2Dプロジェクトの場合は
- Enable Auto Rotate:
- スプライトの向きも移動方向に合わせたい場合は
trueにします。 - ビルボードなどで常に一定の向きにしたい場合は
falseにします。
- スプライトの向きも移動方向に合わせたい場合は
- Rotation Lerp:
- 例:
0.2〜0.3くらいにすると、なめらかに向きを変えます。 - 素早く向きを変えたい場合は
0.8〜1.0にします。
- 例:
- Debug Draw:
- 挙動を確認したいときは
trueにして、Console に出るログを見ながらチューニングします。
- 挙動を確認したいときは
5. 再生して動作確認
- エディタ上部の
Preview(再生ボタン)をクリックしてゲームを再生します。 - シーンビューまたはゲームビューで、
EnemyがPlayerに対して真正面ではなく、横方向に膨らみながら接近していく様子を確認します。 Flank Strengthを変えながら再生し直し、軌道の違いを確認してみてください。Flank Sideを1と-1で切り替えて、左右どちらから回り込むかを比較してみましょう。
6. 3Dプロジェクトでの利用例
3Dでキャラクターが地面上を移動するゲームでも、ほぼ同じ手順で使えます。
- 3Dシーンで
Player(例:Capsule、Characterモデルなど)とEnemyを配置します。 EnemyにFlankTacticをアタッチします。Use 2Dをfalseに設定します(XZ平面で計算するため)。- その他のプロパティ(
Move Speed,Flank Strengthなど)を2Dと同様に調整します。 - 再生すると、
Enemyが地面上を回り込みながらPlayerに接近していく挙動を確認できます。
まとめ
この FlankTactic コンポーネントは、
- ターゲットとなるノードをインスペクタで指定するだけ
- 外部のGameManagerやシングルトンに一切依存しない
- 2D/3Dどちらにも対応
- 回り込みの強さ・左右・停止距離・自動回転などをプロパティで柔軟に調整可能
という特徴を持つ、汎用的な「回り込みAI移動」スクリプトです。
敵キャラクターにこのコンポーネントをアタッチするだけで、「ただ真っ直ぐ突っ込んでくるだけ」の単調なAIから一歩進んだ、横から攻めてくるような賢い印象の挙動を簡単に実現できます。
複数の敵に対して FlankStrength や FlankSide をランダムに変えることで、それぞれ違う軌道でプレイヤーを包囲するような演出も、このコンポーネント単体で完結して行えます。
プロジェクトに組み込んだら、まずは1体の敵で挙動を確認し、その後はPrefab化して量産することで、ゲーム全体のAI実装を効率的に進められるはずです。




