【Cocos Creator 3.8】HomingMissile(誘導弾)の実装:アタッチするだけでターゲットに向かって徐々に旋回しながら追尾する汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで「指定したターゲットに向かって徐々に旋回して追いかける」挙動を実現する HomingMissile コンポーネントを実装します。
弾・ミサイル・敵キャラなどに付けるだけで、ターゲット追尾のロジックを再利用できるように設計します。
コンポーネントの設計方針
機能要件の整理
- ノードの 現在の向き と ターゲット方向 の角度差を計算し、毎フレーム少しずつ回転させる。
- 回転速度(旋回速度)はインスペクタから調整可能にする。
- 前方(ノードのローカルY軸 or X軸)へ一定速度で移動させることで「誘導弾」らしい動きを作る。
- ターゲットは
Node参照で指定するが、指定されていない場合や破棄された場合も安全に動作する。 - 2Dゲーム・3Dゲームどちらでも使えるように、2Dモード / 3Dモードを切り替え可能にする。
- 外部のゲームマネージャ等に依存せず、このコンポーネント単体で完結させる。
座標・回転の考え方
- 2Dモード:
- 回転は Z軸回転(
eulerAngles.z)を使用。 - 前進方向は「ノードのローカルY+方向(上向き)」を前方とする。
- スプライトの向きが違う場合は、インスペクタで「前方軸」を変更できるようにする。
- 回転は Z軸回転(
- 3Dモード:
- 回転は Y軸回転(
eulerAngles.y)を使用し、水平面内で旋回。 - 前進方向は「ノードのローカルZ+方向(前向き)」を前方とする。
- 回転は Y軸回転(
インスペクタで設定可能なプロパティ
@property で公開する設定項目とその役割は以下の通りです。
target: Node | null- 追尾するターゲットノード。
- 空の場合は「現在の向きのまま直進」する。
moveSpeed: number- 前進速度(単位: 単位距離/秒)。
- 例: 300 にすると 1秒で 300 ユニット進む。
turnSpeedDeg: number- 1秒あたりの最大旋回角度(度)。
- 例: 180 にすると 1秒で最大180度まで向きを変えられる(かなりキビキビ曲がる)。
is2DMode: booleantrue: 2Dゲーム(Z回転で旋回 / Y軸を無視)。false: 3Dゲーム(Y回転で旋回 / 水平面内で追尾)。
forwardAxis: number(enum風)- どのローカル軸を「前方」とみなすか。
- 0: Y+(上向き)、1: X+(右向き)、2: Z+(3D用の前向き)。
- 2Dでスプライトの向きが「右向き」の場合は X+ を選ぶなど、見た目に合わせて調整。
maxChaseAngleDeg: number- ターゲット方向との角度差がこの値より大きい場合、追尾をやめて直進(安全装置)。
- 0 以下にすると無制限で追尾し続ける。
stopWhenNoTarget: booleantrue: ターゲットがいない/失われたらその場で停止。false: ターゲットがいなくても直進し続ける。
destroyWhenReachedDistance: number- ターゲットとの距離がこの値以下になったらノードを
destroy()する。 - 0 以下にするとこの機能を無効化。
- 「命中したら自滅する弾」などに使う。
- ターゲットとの距離がこの値以下になったらノードを
debugDrawDirection: booleantrueにすると、console.logで向きや角度を簡易出力(軽いデバッグ用)。
TypeScriptコードの実装
以下が完成した HomingMissile.ts の全コードです。
import { _decorator, Component, Node, Vec3, math, Quat } from 'cc';
const { ccclass, property } = _decorator;
/**
* HomingMissile
* 任意のノードをターゲットに向かって徐々に旋回させながら前進させる汎用コンポーネント
*/
enum ForwardAxis {
Y_POSITIVE = 0,
X_POSITIVE = 1,
Z_POSITIVE = 2,
}
@ccclass('HomingMissile')
export class HomingMissile extends Component {
@property({
type: Node,
tooltip: '追尾するターゲットノード。\n未設定の場合は現在の向きのまま直進します。'
})
public target: Node | null = null;
@property({
tooltip: '前進速度(単位: ユニット/秒)。\n値を大きくするほど速く進みます。'
})
public moveSpeed: number = 300;
@property({
tooltip: '1秒あたりの最大旋回角度(度)。\n例: 180 → 1秒で最大180度まで向きを変えられます。'
})
public turnSpeedDeg: number = 180;
@property({
tooltip: 'true: 2Dモード(Z回転で旋回)\nfalse: 3Dモード(Y回転で旋回)'
})
public is2DMode: boolean = true;
@property({
type: ForwardAxis,
tooltip: 'このノードのどのローカル軸を「前方」とみなすか。\n' +
'2Dスプライトが上向きなら Y+、右向きなら X+ を選択します。\n' +
'3Dモデルは通常 Z+ が前方です。'
})
public forwardAxis: ForwardAxis = ForwardAxis.Y_POSITIVE;
@property({
tooltip: 'ターゲット方向との角度差がこの値より大きい場合は追尾を行いません(度)。\n' +
'0 以下で無制限に追尾します。'
})
public maxChaseAngleDeg: number = 0;
@property({
tooltip: 'true: ターゲットがいない/破棄された場合はその場で停止します。\n' +
'false: ターゲットがいなくても直進し続けます。'
})
public stopWhenNoTarget: boolean = false;
@property({
tooltip: 'ターゲットとの距離がこの値以下になったらこのノードを destroy() します(ユニット)。\n' +
'0 以下で無効化します。'
})
public destroyWhenReachedDistance: number = 0;
@property({
tooltip: 'true にすると、向きや角度情報を console に簡易出力します(デバッグ用)。'
})
public debugDrawDirection: boolean = false;
// 内部で使い回す一時ベクトル(GC削減)
private _tempVec3_1: Vec3 = new Vec3();
private _tempVec3_2: Vec3 = new Vec3();
onLoad() {
// 特に必須コンポーネントはないが、ノード参照の存在を確認しておく
if (!this.node) {
console.error('[HomingMissile] このコンポーネントは Node にアタッチされている必要があります。');
}
}
start() {
// 初期向きやパラメータの簡易ログ
if (this.debugDrawDirection) {
console.log('[HomingMissile] start',
'\n is2DMode:', this.is2DMode,
'\n moveSpeed:', this.moveSpeed,
'\n turnSpeedDeg:', this.turnSpeedDeg,
'\n forwardAxis:', ForwardAxis[this.forwardAxis]
);
}
}
update(deltaTime: number) {
if (!this.node) {
return;
}
// ターゲットがいない場合の挙動
if (!this.target || !this.target.isValid) {
if (this.stopWhenNoTarget) {
// その場で停止
return;
} else {
// ターゲットがいなくても直進だけは行う
this.moveForward(deltaTime);
return;
}
}
// ターゲット方向を計算
const myWorldPos = this.node.worldPosition;
const targetWorldPos = this.target.worldPosition;
// ターゲットとの距離チェック(destroyWhenReachedDistance が有効な場合)
if (this.destroyWhenReachedDistance > 0) {
const dist = Vec3.distance(myWorldPos, targetWorldPos);
if (dist <= this.destroyWhenReachedDistance) {
if (this.debugDrawDirection) {
console.log('[HomingMissile] Target reached. Destroying missile.');
}
this.node.destroy();
return;
}
}
// ターゲットへの方向ベクトル
const toTarget = this._tempVec3_1;
Vec3.subtract(toTarget, targetWorldPos, myWorldPos);
if (this.is2DMode) {
this.update2D(deltaTime, toTarget);
} else {
this.update3D(deltaTime, toTarget);
}
}
/**
* 2Dモード:Z軸回転で旋回
*/
private update2D(deltaTime: number, toTarget: Vec3) {
// Z平面上の方向に射影(高さは無視)
toTarget.z = 0;
if (toTarget.lengthSqr() === 0) {
// すでにターゲットと同じ位置にいる
return;
}
// 現在の向き(前方ベクトル)を取得
const forward = this.getForwardVector2D(this._tempVec3_2);
// 角度差を求める(度)
const angleToTargetDeg = this.angleBetweenVectors2D(forward, toTarget);
// 角度制限が設定されている場合のチェック
if (this.maxChaseAngleDeg > 0 && Math.abs(angleToTargetDeg) > this.maxChaseAngleDeg) {
// 追尾しないで直進
this.moveForward(deltaTime);
return;
}
// このフレームで回せる最大角度
const maxStep = this.turnSpeedDeg * deltaTime;
let rotateDeg = math.clamp(angleToTargetDeg, -maxStep, maxStep);
// 実際にZ回転を適用
const euler = this.node.eulerAngles;
euler.z += rotateDeg;
this.node.eulerAngles = euler;
if (this.debugDrawDirection) {
console.log('[HomingMissile-2D]',
'angleToTargetDeg:', angleToTargetDeg.toFixed(2),
'applied:', rotateDeg.toFixed(2),
'newZ:', euler.z.toFixed(2)
);
}
// 回転後に前進
this.moveForward(deltaTime);
}
/**
* 3Dモード:Y軸回転で水平面内旋回
*/
private update3D(deltaTime: number, toTarget: Vec3) {
// 水平面(XZ)上の方向に射影(高さは無視)
toTarget.y = 0;
if (toTarget.lengthSqr() === 0) {
return;
}
const forward = this.getForwardVector3D(this._tempVec3_2);
// 角度差(度)
const angleToTargetDeg = this.angleBetweenVectors2D(forward, toTarget); // XZ平面での2D角度として扱う
if (this.maxChaseAngleDeg > 0 && Math.abs(angleToTargetDeg) > this.maxChaseAngleDeg) {
this.moveForward(deltaTime);
return;
}
const maxStep = this.turnSpeedDeg * deltaTime;
let rotateDeg = math.clamp(angleToTargetDeg, -maxStep, maxStep);
// Y軸回転を適用
const euler = this.node.eulerAngles;
euler.y += rotateDeg;
this.node.eulerAngles = euler;
if (this.debugDrawDirection) {
console.log('[HomingMissile-3D]',
'angleToTargetDeg:', angleToTargetDeg.toFixed(2),
'applied:', rotateDeg.toFixed(2),
'newY:', euler.y.toFixed(2)
);
}
this.moveForward(deltaTime);
}
/**
* 現在の「前方ベクトル」を取得(2D用)
* forwardAxis の設定に応じて X+ または Y+ を使う
*/
private getForwardVector2D(out: Vec3): Vec3 {
// ローカル空間での前方
switch (this.forwardAxis) {
case ForwardAxis.X_POSITIVE:
out.set(1, 0, 0);
break;
case ForwardAxis.Y_POSITIVE:
default:
out.set(0, 1, 0);
break;
}
// ワールド空間に変換(回転のみ反映したいので方向ベクトルとして扱う)
const worldRot = this.node.worldRotation;
Vec3.transformQuat(out, out, worldRot);
// Zは無視
out.z = 0;
out.normalize();
return out;
}
/**
* 現在の「前方ベクトル」を取得(3D用)
* 通常は Z+ を前方とみなす
*/
private getForwardVector3D(out: Vec3): Vec3 {
switch (this.forwardAxis) {
case ForwardAxis.X_POSITIVE:
out.set(1, 0, 0);
break;
case ForwardAxis.Y_POSITIVE:
out.set(0, 1, 0);
break;
case ForwardAxis.Z_POSITIVE:
default:
out.set(0, 0, 1);
break;
}
const worldRot = this.node.worldRotation;
Vec3.transformQuat(out, out, worldRot);
// 水平面だけを見るため Y=0 に(XZ平面)
out.y = 0;
out.normalize();
return out;
}
/**
* 2Dベクトル(x,y)としての角度差を求める(度)。
* 正の値: toTarget は forward から見て反時計回り側にある。
* 負の値: 時計回り側にある。
*/
private angleBetweenVectors2D(forward: Vec3, toTarget: Vec3): number {
// 正規化
const f = forward;
const t = toTarget;
f.normalize();
t.normalize();
// 内積から角度の大きさ
const dot = math.clamp(Vec3.dot(f, t), -1, 1);
let angleRad = Math.acos(dot);
let angleDeg = math.toDegree(angleRad);
// 2Dクロス積(z成分)で符号を判定
const crossZ = f.x * t.y - f.y * t.x;
if (crossZ < 0) {
angleDeg = -angleDeg;
}
return angleDeg;
}
/**
* 現在の「前方方向」に対して moveSpeed で前進させる
*/
private moveForward(deltaTime: number) {
if (this.moveSpeed === 0) {
return;
}
const distance = this.moveSpeed * deltaTime;
const dir = this._tempVec3_1;
// 前方ベクトル(ワールド空間)を取得
if (this.is2DMode) {
this.getForwardVector2D(dir);
} else {
this.getForwardVector3D(dir);
}
// dir は正規化されているので、距離を掛けて移動
Vec3.multiplyScalar(dir, dir, distance);
const newPos = this._tempVec3_2;
Vec3.add(newPos, this.node.worldPosition, dir);
this.node.worldPosition = newPos;
}
}
コードのポイント解説
- onLoad()
- 必須コンポーネントはないため、最低限
this.nodeの存在確認のみ。 - RigidBody2D などには依存していないので、どのノードにもそのままアタッチ可能です。
- 必須コンポーネントはないため、最低限
- update(deltaTime)
- ターゲットがいない場合の挙動を
stopWhenNoTargetで分岐。 - ターゲットがいれば
toTarget(ターゲットへの方向ベクトル)を計算し、2D/3D モードごとに処理。 destroyWhenReachedDistanceが有効な場合は、距離が閾値以下でthis.node.destroy()。
- ターゲットがいない場合の挙動を
- update2D / update3D
- それぞれ 2D(Z回転)・3D(Y回転)に特化した追尾ロジック。
- 角度差を求め、
turnSpeedDeg * deltaTimeを上限として回転量をクランプ。 - 回転後に
moveForward()で前進。
- getForwardVector2D / getForwardVector3D
forwardAxisの設定に応じてローカル軸を選択し、ワールド空間に変換。- 2Dでは Z を無視、3Dでは Y を無視して水平面上のベクトルとして利用。
- angleBetweenVectors2D
- 内積から角度の大きさを求め、2Dクロス積の z 成分で符号(時計回り/反時計回り)を判定。
- これにより「左に何度」「右に何度」曲がるべきかが分かります。
- moveForward
- 現在の前方ベクトルを取得し、
moveSpeed * deltaTimeだけ前進。 - 物理を使わないので、RigidBody2D/3D なしでも問題なく動作します。
- 現在の前方ベクトルを取得し、
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ上部メニューまたは Assets パネルで操作します。
- Assets パネルで右クリック → Create → TypeScript を選択。
- 新しく作成されたファイル名を HomingMissile.ts に変更します。
- HomingMissile.ts をダブルクリックして開き、既存のテンプレートコードを全て削除し、
本記事の<code class="language-typescript">内のコードを丸ごと貼り付けて保存します。
2Dゲームでのテスト手順(スプライトを誘導弾にする)
- ターゲット用ノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択。
- 名前を Target に変更します。
- Scene ビューで適当な位置(例: X=200, Y=0)に移動させます。
- ミサイル用ノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択。
- 名前を Missile に変更します。
- Scene ビューで別の位置(例: X=-200, Y=0)に配置します。
- スプライト画像が「上向き」であればそのまま、「右向き」の場合は後で
forwardAxisを X+ にします。
- HomingMissile コンポーネントをアタッチ
- Hierarchy で Missile ノードを選択。
- Inspector の下部で Add Component → Custom → HomingMissile を選択。
- プロパティの設定(2D用)
- Target:
- Inspector の
Targetフィールドに、Hierarchy から Target ノードをドラッグ&ドロップ。
- Inspector の
- Move Speed:
- 例: 300 と入力(速さは後で調整)。
- Turn Speed Deg:
- 例: 180 に設定すると比較的キビキビ曲がります。
- ゆっくり曲がらせたい場合は 60 や 90 など小さめに。
- Is 2D Mode:
- チェックを入れる(2Dゲームなので true)。
- Forward Axis:
- スプライトが「上向き」なら Y_POSITIVE のままでOK。
- スプライトが「右向き」なら X_POSITIVE に変更。
- Max Chase Angle Deg:
- まずは 0(無制限)にして、どの角度からでも追尾するようにします。
- Stop When No Target:
- テストではどちらでも構いませんが、チェックを入れておくと挙動が分かりやすいです。
- Destroy When Reached Distance:
- 例: 20 と入力すると、ターゲットにかなり近づいた時点でミサイルが自滅します。
- Debug Draw Direction:
- 挙動を確認したければ チェックを入れる(Console に角度情報が出ます)。
- Target:
- プレビューで動作確認
- エディタ右上の ▶(Play) ボタンを押してプレビューを開始します。
- ゲーム画面で、Missile が Target に向かって徐々に旋回し、近づく挙動を確認します。
Destroy When Reached Distanceを設定していれば、接近したタイミングでミサイルが消えるはずです。
3Dゲームでのテスト手順(3Dモデルを誘導弾にする)
- ターゲット用ノードの作成
- Hierarchy パネルで右クリック → Create → 3D Object → Cube などを選択。
- 名前を Target3D に変更し、位置を (0, 0, 5) などに設定。
- ミサイル用ノードの作成
- Hierarchy パネルで右クリック → Create → 3D Object → Capsule などを選択。
- 名前を Missile3D に変更し、位置を (0, 0, -5) などに設定。
- モデルの「前向き」が Z+ でない場合は、ForwardAxis を後で調整します。
- HomingMissile コンポーネントをアタッチ
- Hierarchy で Missile3D を選択。
- Inspector → Add Component → Custom → HomingMissile。
- プロパティの設定(3D用)
- Target: Target3D をドラッグ&ドロップ。
- Move Speed: 例として 5〜10 程度に設定(3D空間のスケールに応じて調整)。
- Turn Speed Deg: 例として 90 程度から試す。
- Is 2D Mode: チェックを外す(false)。
- Forward Axis: モデルの前向きが Z+ なら Z_POSITIVE を選択。
- その他のプロパティは 2D と同様に設定します。
- プレビューで確認
- 再生して、Missile3D が水平面内で Target3D に向かって旋回しながら移動することを確認します。
よくある調整ポイント
- 旋回が遅すぎてターゲットに追いつかない
Turn Speed Degを大きくする(例: 60 → 180)。Move Speedを少し下げると追尾しやすくなります。
- カクカクせず、もっとなめらかに曲がってほしい
Turn Speed Degを少し小さめにして、Move Speedもそれに合わせて調整。
- スプライトの向きと進行方向が90度ずれている
Forward Axisを Y_POSITIVE / X_POSITIVE で切り替えてみてください。- それでも合わない場合は、ノード自体を回転させて見た目を合わせる方法もあります。
まとめ
この HomingMissile コンポーネントは、
- ターゲットへの角度差を計算して徐々に旋回するロジック
- 2D / 3D の両対応
- 前方軸・旋回速度・移動速度・破壊距離などをインスペクタから柔軟に調整できる設計
- 外部スクリプトや GameManager に一切依存しない完全な独立性
を備えた、汎用的な「誘導弾」挙動コンポーネントです。
弾・ミサイルだけでなく、
- プレイヤーを追いかける敵キャラ
- 特定のオブジェクトを追従するドローン
- 指定ポイントへ自動で帰還するオブジェクト
などにも、そのままアタッチして使い回すことができます。
パラメータを変えたプリセットプレハブをいくつか作っておけば、シーンにドラッグ&ドロップするだけで多様な追尾挙動を素早く配置できるようになり、ゲーム開発のスピードが大きく向上します。
このコンポーネントをベースに、
- 寿命タイマーを追加して一定時間で自滅
- 命中時にエフェクトを再生
- ターゲットを自動検出(一定範囲内の最も近い敵をロックオン)
といった機能を拡張していくことで、よりリッチな誘導システムを構築できます。
まずはこのシンプルな HomingMissile をプロジェクトに組み込んで、追尾挙動を自在にコントロールしてみてください。




