【Cocos Creator 3.8】KeepDistance の実装:アタッチするだけで「ターゲットとの距離を一定に保ちながら射撃戦を行う」汎用スクリプト
このガイドでは、任意の敵キャラやドローンなどにアタッチするだけで、プレイヤー(ターゲット)に近づきすぎず、離れすぎず、一定距離を保ちつつ射撃を行う汎用コンポーネント KeepDistance を実装します。
ターゲットノードをインスペクタで指定するだけで動作し、GameManager や別スクリプトへの依存は一切ありません。距離のしきい値・移動速度・射撃間隔・弾のプレハブなどもすべてインスペクタから調整可能です。
コンポーネントの設計方針
1. 機能要件の整理
- 指定したターゲットノード(通常はプレイヤー)との距離を監視する。
- 近すぎる場合: ターゲットから離れる方向に移動する。
- 遠すぎる場合: ターゲットに近づく方向に移動する。
- 適正距離の範囲内(ミニマムとマキシマムの間)では、基本的に停止またはゆっくり調整する。
- ターゲットの方向を向き、一定間隔で弾を発射する。
- 発射する弾は
Prefabとしてインスペクタから指定し、弾の初速・寿命・発射位置オフセットも調整可能にする。 - 2D/3D どちらでも使えるよう、移動は
Vec3ベースで行い、Y 軸を固定するオプションも用意する。
2. 外部依存をなくすためのアプローチ
- ターゲット参照:
@property(Node)でインスペクタから直接指定。 - 移動制御:
RigidBodyなどには依存せず、this.node.positionをupdate()内で直接更新。 - 射撃制御: 弾のプレハブ(
Prefab)をインスペクタから指定し、instantiateで生成。生成先の親ノードもプロパティで指定できるようにする(未指定なら自ノードの親にぶら下げる)。 - 防御的実装:
- ターゲット未設定時は警告を出し、移動と射撃を無効化。
- 弾プレハブ未設定時は射撃機能のみ無効化し、ログで通知。
- 親ノードが存在しない(ルートノード)場合の射出先は
this.node.sceneを利用。
3. インスペクタで設定可能なプロパティ設計
以下のプロパティを用意します。
- target:
Node | null- 追従&距離維持の対象となるノード(通常はプレイヤー)。
- 未設定の場合はログ警告を出し、コンポーネントは何もしない。
- minDistance:
number(例: 3.0)- ターゲットからこれより近いと「離れる」挙動になる距離。
- maxDistance:
number(例: 6.0)- ターゲットからこれより遠いと「近づく」挙動になる距離。
minDistanceより常に大きくなるように防御的チェックを行う。
- moveSpeed:
number(例: 4.0)- ターゲットに近づく/離れるときの移動速度(ユニット/秒)。
- stopOnComfortZone:
boolean(例: true)- 距離が
[minDistance, maxDistance]の範囲にあるときに完全停止するかどうか。 - false の場合は、ちょうど中央の距離を目指して微調整移動する。
- 距離が
- faceTarget:
boolean(例: true)- ターゲットの方向を常に向くかどうか。
- 2D 横スクロールの場合は X 方向の反転のみを行うオプションも用意。
- use2DFlipX:
boolean(例: false)- 2D スプライト用の簡易向き制御。
- true の場合、ターゲットが右にいれば
scale.x = |scale.x|、左にいればscale.x = -|scale.x|にして向きを変える。
- lockYMovement:
boolean(例: true)- 移動時に Y 座標を固定するかどうか(2D 横スクロールや平面上の移動向け)。
- shootEnabled:
boolean(例: true)- 射撃機能を有効/無効にする。
- projectilePrefab:
Prefab | null- 発射する弾のプレハブ。
- 未設定の場合、射撃処理はスキップし警告ログを出す。
- shootInterval:
number(例: 1.0)- 弾を発射する間隔(秒)。
- projectileSpeed:
number(例: 10.0)- 弾の初速度(ターゲット方向)。
- 発射方向ベクトルにこのスカラーを掛けて
velocityとして設定。
- projectileLifetime:
number(例: 5.0)- 弾が自動的に破棄されるまでの時間(秒)。
- shootOnlyInComfortZone:
boolean(例: true)- 距離が適正範囲内のときだけ射撃するかどうか。
- false の場合、距離に関係なく常に射撃間隔で撃ち続ける。
- shootOriginOffset:
Vec3(例: (0, 0.5, 0))- 自ノードのローカル座標から見た発射位置オフセット。
- 敵の手元や銃口の位置に合わせるために使用。
- projectileParent:
Node | null- 生成した弾をぶら下げる親ノード。
- 未指定の場合は
this.node.parent(なければシーンルート)を使用。
TypeScriptコードの実装
import { _decorator, Component, Node, Vec3, Prefab, instantiate, math, log, warn, director } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('KeepDistance')
export class KeepDistance extends Component {
// === ターゲット関連 ===
@property({
type: Node,
tooltip: '距離を維持する対象ノード(通常はプレイヤー)。\n未設定の場合、このコンポーネントは移動・射撃を行いません。'
})
public target: Node | null = null;
@property({
tooltip: 'ターゲットに対してこれより近いと離れる挙動になる距離。'
})
public minDistance: number = 3.0;
@property({
tooltip: 'ターゲットに対してこれより遠いと近づく挙動になる距離。\nminDistance より大きい値にしてください。'
})
public maxDistance: number = 6.0;
@property({
tooltip: 'ターゲットに近づく/離れるときの移動速度(ユニット/秒)。'
})
public moveSpeed: number = 4.0;
@property({
tooltip: 'ターゲットとの距離が [minDistance, maxDistance] の範囲内のとき完全に停止するかどうか。\nOFF の場合は中央距離を目指して微調整移動します。'
})
public stopOnComfortZone: boolean = true;
@property({
tooltip: 'ターゲットの方向を常に向くかどうか。'
})
public faceTarget: boolean = true;
@property({
tooltip: '2D スプライト用の簡易向き制御。\nON の場合、ターゲットが右にいれば scale.x を正、左にいれば負にします。'
})
public use2DFlipX: boolean = false;
@property({
tooltip: '移動時に Y 座標を固定するかどうか。\n2D 横スクロールや平面上の移動に便利です。'
})
public lockYMovement: boolean = true;
// === 射撃関連 ===
@property({
tooltip: '射撃機能を有効にするかどうか。'
})
public shootEnabled: boolean = true;
@property({
type: Prefab,
tooltip: '発射する弾のプレハブ。\n未設定の場合、射撃は行われません。'
})
public projectilePrefab: Prefab | null = null;
@property({
tooltip: '弾を発射する間隔(秒)。'
})
public shootInterval: number = 1.0;
@property({
tooltip: '弾の初速度(ターゲット方向)。'
})
public projectileSpeed: number = 10.0;
@property({
tooltip: '弾が自動的に破棄されるまでの時間(秒)。'
})
public projectileLifetime: number = 5.0;
@property({
tooltip: 'ターゲットとの距離が [minDistance, maxDistance] の範囲内のときのみ射撃するかどうか。'
})
public shootOnlyInComfortZone: boolean = true;
@property({
type: Vec3,
tooltip: '自ノードのローカル座標から見た発射位置オフセット。\n敵の手元や銃口の位置に合わせて調整してください。'
})
public shootOriginOffset: Vec3 = new Vec3(0, 0.5, 0);
@property({
type: Node,
tooltip: '生成した弾をぶら下げる親ノード。\n未設定の場合、自ノードの親(なければシーンルート)に追加します。'
})
public projectileParent: Node | null = null;
// === 内部状態 ===
private _shootTimer: number = 0;
private _initialY: number = 0;
private _hasLoggedNoTarget: boolean = false;
private _hasLoggedNoProjectile: boolean = false;
onLoad() {
// min/max 距離の防御的な補正
if (this.maxDistance <= this.minDistance) {
warn(`[KeepDistance] maxDistance (${this.maxDistance}) が minDistance (${this.minDistance}) 以下です。minDistance + 1 に自動調整します。`);
this.maxDistance = this.minDistance + 1.0;
}
// 初期 Y 座標を記録(lockYMovement 用)
this._initialY = this.node.position.y;
// タイマー初期化
this._shootTimer = 0;
}
start() {
if (!this.target) {
warn('[KeepDistance] target が設定されていません。このコンポーネントは動作しません。');
this._hasLoggedNoTarget = true;
}
if (!this.projectilePrefab && this.shootEnabled) {
warn('[KeepDistance] projectilePrefab が設定されていないため、射撃は行われません。');
this._hasLoggedNoProjectile = true;
}
}
update(deltaTime: number) {
// ターゲットがいない場合は何もしない
if (!this.target) {
// 一度だけ警告ログを出す
if (!this._hasLoggedNoTarget) {
warn('[KeepDistance] target が設定されていません。');
this._hasLoggedNoTarget = true;
}
return;
}
// 現在位置とターゲット位置を取得
const selfPos = this.node.worldPosition;
const targetPos = this.target.worldPosition;
// 高さを無視して距離計算したい場合は Y を揃える
const tempSelf = new Vec3(selfPos.x, selfPos.y, selfPos.z);
const tempTarget = new Vec3(targetPos.x, targetPos.y, targetPos.z);
if (this.lockYMovement) {
tempSelf.y = 0;
tempTarget.y = 0;
}
const toTarget = new Vec3();
Vec3.subtract(toTarget, tempTarget, tempSelf);
const distance = toTarget.length();
// 移動方向ベクトル
let moveDir = new Vec3(0, 0, 0);
if (distance < this.minDistance) {
// 近すぎるのでターゲットから離れる方向へ
if (distance > 0.0001) {
Vec3.normalize(moveDir, toTarget);
moveDir.multiplyScalar(-1); // 反対方向
}
} else if (distance > this.maxDistance) {
// 遠すぎるのでターゲットに近づく方向へ
if (distance > 0.0001) {
Vec3.normalize(moveDir, toTarget);
}
} else {
// 適正距離範囲内
if (!this.stopOnComfortZone) {
// 中央の距離を目指して微調整
const centerDist = (this.minDistance + this.maxDistance) * 0.5;
const diff = distance - centerDist;
if (Math.abs(diff) > 0.05 && distance > 0.0001) {
Vec3.normalize(moveDir, toTarget);
// diff > 0 なら少し離れる、< 0 なら少し近づく
moveDir.multiplyScalar(diff > 0 ? 1 : -1);
}
}
}
// 実際の移動
if (!moveDir.equals(Vec3.ZERO)) {
Vec3.normalize(moveDir, moveDir);
const moveDelta = new Vec3();
Vec3.multiplyScalar(moveDelta, moveDir, this.moveSpeed * deltaTime);
let newPos = this.node.position.clone();
newPos.add(moveDelta);
if (this.lockYMovement) {
newPos.y = this._initialY;
}
this.node.setPosition(newPos);
}
// ターゲットの方向を向く
if (this.faceTarget) {
this._updateFacing(tempSelf, tempTarget);
}
// 射撃処理
if (this.shootEnabled && this.projectilePrefab) {
const inComfortZone = (distance >= this.minDistance && distance <= this.maxDistance);
if (!this.shootOnlyInComfortZone || inComfortZone) {
this._shootTimer += deltaTime;
if (this._shootTimer >= this.shootInterval) {
this._shootTimer = 0;
this._shootProjectile();
}
} else {
// 射撃条件を満たさないときはタイマーだけ進める or リセットする設計もあり
// ここではリセットして「範囲に入ったときからカウント開始」とする
this._shootTimer = 0;
}
} else if (this.shootEnabled && !this.projectilePrefab && !this._hasLoggedNoProjectile) {
warn('[KeepDistance] projectilePrefab が設定されていないため、射撃は行われません。');
this._hasLoggedNoProjectile = true;
}
}
/**
* ターゲットの方向を向く処理
* 2D 用の FlipX と 3D 用の LookAt を両方サポート
*/
private _updateFacing(selfPos: Vec3, targetPos: Vec3) {
const dir = new Vec3();
Vec3.subtract(dir, targetPos, selfPos);
// 2D FlipX モード
if (this.use2DFlipX) {
const scale = this.node.scale.clone();
if (dir.x > 0) {
scale.x = Math.abs(scale.x);
} else if (dir.x < 0) {
scale.x = -Math.abs(scale.x);
}
this.node.setScale(scale);
return;
}
// 3D の場合は Y 軸を上にした LookAt を簡易実装
// dir がほぼゼロの場合は何もしない
if (dir.lengthSqr() < 0.0001) {
return;
}
// LookAt の簡易版(カスタム実装)
// forward を dir の正規化、up を (0,1,0) として回転を作る
Vec3.normalize(dir, dir);
const up = Vec3.UP.clone();
const right = new Vec3();
Vec3.cross(right, up, dir);
if (right.lengthSqr() < 0.0001) {
// up と dir が平行に近い場合は回転を変更しない
return;
}
Vec3.normalize(right, right);
const newUp = new Vec3();
Vec3.cross(newUp, dir, right);
// 回転行列からクォータニオンを作る
const m00 = right.x, m01 = right.y, m02 = right.z;
const m10 = newUp.x, m11 = newUp.y, m12 = newUp.z;
const m20 = dir.x, m21 = dir.y, m22 = dir.z;
const trace = m00 + m11 + m22;
const quat = this.node.rotation.clone();
if (trace > 0) {
const s = Math.sqrt(trace + 1.0) * 2;
quat.w = 0.25 * s;
quat.x = (m21 - m12) / s;
quat.y = (m02 - m20) / s;
quat.z = (m10 - m01) / s;
} else if ((m00 > m11) && (m00 > m22)) {
const s = Math.sqrt(1.0 + m00 - m11 - m22) * 2;
quat.w = (m21 - m12) / s;
quat.x = 0.25 * s;
quat.y = (m01 + m10) / s;
quat.z = (m02 + m20) / s;
} else if (m11 > m22) {
const s = Math.sqrt(1.0 + m11 - m00 - m22) * 2;
quat.w = (m02 - m20) / s;
quat.x = (m01 + m10) / s;
quat.y = 0.25 * s;
quat.z = (m12 + m21) / s;
} else {
const s = Math.sqrt(1.0 + m22 - m00 - m11) * 2;
quat.w = (m10 - m01) / s;
quat.x = (m02 + m20) / s;
quat.y = (m12 + m21) / s;
quat.z = 0.25 * s;
}
this.node.setRotation(quat);
}
/**
* 弾を生成してターゲット方向に発射する
*/
private _shootProjectile() {
if (!this.projectilePrefab) {
return;
}
// 発射位置(ワールド座標)を計算
const localOrigin = this.shootOriginOffset.clone();
const worldOrigin = new Vec3();
this.node.getWorldMatrix().transformPoint(localOrigin, worldOrigin);
// インスタンス生成
const projectile = instantiate(this.projectilePrefab);
// 親ノードを決定
let parent: Node | null = this.projectileParent;
if (!parent) {
parent = this.node.parent;
}
if (!parent) {
// 親がいない場合はシーンルートに追加
const scene = director.getScene();
if (!scene) {
warn('[KeepDistance] シーンが取得できなかったため、弾を生成できませんでした。');
return;
}
parent = scene;
}
projectile.setParent(parent);
projectile.setWorldPosition(worldOrigin);
// ターゲット方向に初速度を与える
if (this.target) {
const targetPos = this.target.worldPosition;
const dir = new Vec3();
Vec3.subtract(dir, targetPos, worldOrigin);
if (this.lockYMovement) {
dir.y = 0;
}
if (dir.lengthSqr() > 0.0001) {
Vec3.normalize(dir, dir);
} else {
// ターゲットがほぼ同じ位置なら、前方方向(Z+)に撃つ
dir.set(0, 0, 1);
}
const velocity = new Vec3();
Vec3.multiplyScalar(velocity, dir, this.projectileSpeed);
// 弾側に velocity プロパティがあれば設定する(任意の実装と相性が良いように)
const anyProjectile = projectile as any;
if ('velocity' in anyProjectile) {
anyProjectile.velocity = velocity;
}
// RigidBody 系を使わない独立実装のため、ここでは Transform ベースの簡易移動コンポーネントを
// 弾プレハブ側に用意しておくことを想定しています。
// ただしこの KeepDistance 自体は一切依存しません。
}
// 一定時間後に自動破棄
if (this.projectileLifetime > 0) {
this.scheduleOnce(() => {
if (projectile && projectile.isValid) {
projectile.destroy();
}
}, this.projectileLifetime);
}
}
}
コードの要点解説
- onLoad
minDistanceとmaxDistanceの関係をチェックし、maxDistance <= minDistanceの場合は自動で補正します。- Y 座標固定用に初期 Y を記録します。
- start
- ターゲット未設定・弾プレハブ未設定の場合に警告ログを出します。
- update
- ターゲットがない場合は何もせず早期 return。
- ターゲットとの距離を計算し、
minDistance・maxDistanceに応じて「近づく/離れる/停止 or 微調整」を行います。 lockYMovementが true の場合、移動後も Y 座標を初期値に固定します。faceTargetが true の場合、ターゲット方向に向くように回転または FlipX を行います。- 射撃タイマーを進め、条件を満たしたときに
_shootProjectile()を呼び出します。
- _updateFacing
use2DFlipXが true の場合、X 方向の位置関係に応じてscale.xを正負反転します。- 3D 向けには、ターゲット方向を forward として簡易的にクォータニオンを構築し、
this.node.setRotation()で適用します。
- _shootProjectile
shootOriginOffsetをローカル座標としてワールド座標に変換し、その位置に弾を生成します。- 弾の親ノードは
projectileParent→this.node.parent→ シーンルート の優先順で決定します。 - ターゲット方向に正規化したベクトルに
projectileSpeedを掛けてvelocityを算出し、弾インスタンスにvelocityプロパティがあればそこに代入します(弾側の実装と疎結合にするため)。 scheduleOnceを使ってprojectileLifetime秒後に自動でdestroy()します。
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
KeepDistance.tsにします。 - 自動生成された中身をすべて削除し、本記事の
KeepDistanceコードを貼り付けて保存します。
2. テスト用シーンの準備
プレイヤーノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite(または 3D Object → Cube など)を選択し、Player という名前に変更します。
- 位置を分かりやすくするため、Inspector の Position を
(0, 0, 0)に設定します。
敵(距離維持するキャラ)ノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite(または 3D Object → Cube)を選択し、Enemy という名前に変更します。
- Inspector の Position を
(5, 0, 0)など、プレイヤーから少し離れた位置に設定します。
3. KeepDistance コンポーネントのアタッチ
- Hierarchy で Enemy ノードを選択します。
- Inspector の下部にある Add Component ボタンをクリックします。
- Custom Component → KeepDistance を選択して追加します。
4. プロパティの設定
Enemy ノードの Inspector に表示される KeepDistance の各プロパティを以下のように設定してみましょう(2D 横スクロール想定)。
- target: Hierarchy から Player ノードをドラッグ&ドロップ。
- minDistance:
3 - maxDistance:
6 - moveSpeed:
4 - stopOnComfortZone:
ON - faceTarget:
ON - use2DFlipX:
ON(2D Sprite の場合) - lockYMovement:
ON - shootEnabled:
ON - projectilePrefab: 後述の手順で作成する弾プレハブを設定
- shootInterval:
1.0(1秒ごとに発射) - projectileSpeed:
10 - projectileLifetime:
5 - shootOnlyInComfortZone:
ON - shootOriginOffset:
(0, 0.5, 0)(敵の少し上から撃つイメージ) - projectileParent: 未設定のままで OK(自動的に Enemy の親ノード or シーンルートに追加されます)
5. 弾プレハブの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、Bullet という名前にします。
- 見た目が分かるように小さな画像を設定するか、単色スプライトなどを割り当てます。
- Assets パネルの任意のフォルダに Bullet ノードをドラッグ&ドロップして Prefab 化します。
- Hierarchy 上の Bullet ノードはもう不要なので削除して構いません。
- Enemy ノードの Inspector で、KeepDistance > projectilePrefab に先ほど作成した Bullet プレハブをドラッグ&ドロップします。
※ このガイドでは KeepDistance 自体は弾の移動ロジックを持ちません。
単純に「velocity プロパティ」を弾インスタンスに渡しているだけなので、必要であれば弾プレハブ側に「velocity を使って毎フレーム移動する簡単なスクリプト」を追加するとよいです。(ただし、KeepDistance はそのスクリプトに依存していないため、完全に任意です。)
6. 再生して動作確認
- ツールバーの Play ボタン(▶)を押してゲームを実行します。
- シーン上で Player を移動させるために、簡単な移動スクリプトを別途用意しても良いですし、エディタの Scene ビュー 上で Player をドラッグして動かしてみても挙動を確認できます(エディタ上のドラッグは再生中の Transform を直接動かせます)。
- Enemy が Player に対して
- 近づきすぎると後退し、
- 離れすぎると前進し、
[minDistance, maxDistance]の範囲で距離を保とうとする
様子を確認できます。
- 距離が適正範囲に入っているときに、一定間隔で弾が Player 方向に発射されることを確認します。
もし動かない、あるいはログに警告が出ている場合は、以下をチェックしてください。
- Enemy の target に Player が正しく設定されているか。
- projectilePrefab に Bullet プレハブが設定されているか。
- shootEnabled が ON になっているか。
- minDistance < maxDistance になっているか(自動補正されますが、値も確認)。
まとめ
KeepDistance コンポーネントは、
- ターゲットとの距離を 近すぎず・遠すぎずに保つ AI 的な挙動
- 距離条件に応じて 射撃を行う戦闘キャラクター の実装
を、単一スクリプトをアタッチするだけで実現できる汎用コンポーネントです。外部の GameManager やシングルトンに一切依存せず、必要なパラメータはすべてインスペクタから設定可能なため、
- 敵 AI のバリエーション作成(距離や速度、射撃間隔を変えるだけで別キャラに)
- テスト用のターゲット追従ドローン・護衛キャラの実装
- チュートリアル用の簡易敵キャラ
など、さまざまなシーンで再利用できます。
本記事の設計方針をベースに、
- HP やダメージ判定を持つ弾プレハブ
- 回り込みやランダムなストレイフ移動
- 複数ターゲットから最も近いものを自動で選ぶ拡張
といった機能を追加していくことで、より高度な敵 AI を構築していくことも可能です。まずはこの KeepDistance をベースに、あなたのゲームに合った距離維持型の射撃キャラクターを量産してみてください。




