【Cocos Creator 3.8】SniperAim(狙撃AI)の実装:アタッチするだけで「プレイヤーをロックオンし、レーザー照射後に高速弾を撃つ」敵スナイパー挙動を実現する汎用スクリプト
このコンポーネントは、任意のノードにアタッチするだけで「遠距離からプレイヤーをロックオン → レーザー照射 → 高速弾発射」という一連のスナイパーAI挙動を実現します。
プレイヤー側のノードをインスペクタから指定し、レーザー用の Line(線)と弾丸プレハブを設定するだけで、外部のGameManagerやシングルトンに依存せずに完結して動作します。
コンポーネントの設計方針
1. 機能要件の整理
- 指定した「ターゲットノード(プレイヤー)」の位置を常に追尾し、狙いを付ける。
- 一定の「照準待機時間(ロックオン時間)」の後、レーザーを表示して「狙われている」演出を行う。
- レーザー照射時間が経過したら、高速弾をターゲット方向へ発射する。
- この一連のサイクル(ロックオン → レーザー → 発射)を一定間隔で繰り返す。
- ターゲットが射程外にいる場合は発射せず待機する。
- ターゲットが設定されていない場合や必須コンポーネントが無い場合は、ログで警告し、安全に何もしない。
2. 外部依存をなくすためのアプローチ
- ターゲット参照は
@property(Node)でインスペクタから指定。 - 弾丸は Prefab を
@property(Prefab)で受け取り、自身の親ノードにinstantiateして発射。 - レーザーは以下の2パターンに対応:
- (推奨)レーザー表示用の子ノードを用意し、そこに Sprite などを付けておき、SniperAim 側からスケールと角度を制御。
- レーザー用ノードを指定しない場合は、コンポーネント内でログを出してレーザー演出をスキップ(弾だけ撃つ)。
- 弾丸の移動は、弾丸プレハブに Rigidbody2D などを要求せず、このコンポーネント自身が「簡易弾丸スクリプト」を内部で付与して移動処理(update)を行う。
- 時計管理(タイマー)やゲーム状態管理は一切外部に依存せず、コンポーネント内部のカウンタで完結。
3. インスペクタで設定可能なプロパティ設計
SniperAim コンポーネントに定義する主なプロパティと役割は以下の通りです。
- target :
Node | null- 狙う対象(プレイヤーなど)のノード。
- ここが未設定の場合、スナイパーは何もしません(警告ログのみ)。
- attackRange :
number- この距離以内にターゲットが入ったときだけロックオン&攻撃する。
- 例:800 など。
- lockOnTime :
number(秒)- ターゲットを認識してからレーザーを照射するまでの「狙いを定める時間」。
- この間はまだレーザーも弾も出ません。
- laserDuration :
number(秒)- レーザーを表示している時間。
- この時間が経過すると弾丸を発射します。
- cooldownTime :
number(秒)- 1発撃ったあと、次のロックオンを開始するまでの待機時間。
- 0 にすると連続でロックオンを開始します。
- bulletSpeed :
number- 弾丸の移動速度(単位:unit/秒)。
- 例:1200 など。大きいほど速い。
- bulletLifeTime :
number(秒)- 弾丸が自動で破棄されるまでの時間。
- 弾が画面外に出ても永遠に残らないようにするための安全装置。
- bulletPrefab :
Prefab | null- 発射する弾丸プレハブ。
- ここが未設定の場合、弾は撃たれず、警告ログを出します。
- 弾丸プレハブには特別なスクリプトは不要です(見た目用の Sprite などだけでOK)。
- laserNode :
Node | null- レーザー表示用ノード。
- スナイパーの子ノードとして細長いSpriteなどを用意し、ここに割り当てます。
- 設定しない場合は、レーザー演出をスキップして弾だけ発射します。
- laserWidth :
number- レーザーの太さ(Y方向スケール)を調整するための値。
- Spriteを横方向(X軸)に伸ばす場合は、ここを 1 のままにしておき、レーザー用ノード側のデザインで調整しても構いません。
- predictiveAim :
boolean- 将来的な拡張用フラグ。この記事の実装では「オンでもオフでも同じく現在位置に向けて撃つ」挙動ですが、後で「移動先を予測して撃つ」などに拡張可能です。
- debugLog :
boolean- オンにすると、ロックオン開始・レーザー開始・弾発射などのログをコンソールに出力します。
このように、すべての設定値や参照はインスペクタから渡す形にしているため、他のカスタムスクリプトへの依存は一切ありません。
TypeScriptコードの実装
以下が SniperAim コンポーネントの完全な実装例です。
import { _decorator, Component, Node, Vec3, math, Prefab, instantiate, director, Quat } from 'cc';
const { ccclass, property } = _decorator;
/**
* 内部用:弾丸の移動と寿命を管理する簡易コンポーネント
* ※外部スクリプトに依存しないため、このファイル内で定義します。
*/
@ccclass('SniperBullet')
class SniperBullet extends Component {
@property({ tooltip: '弾丸の移動速度(単位:unit/秒)' })
public speed: number = 1200;
@property({ tooltip: '弾丸の寿命(秒)。この時間が経過すると自動的に破棄されます。' })
public lifeTime: number = 3;
private _direction: Vec3 = new Vec3(1, 0, 0);
private _elapsed: number = 0;
public init(direction: Vec3, speed: number, lifeTime: number) {
this._direction = direction.clone().normalize();
this.speed = speed;
this.lifeTime = lifeTime;
this._elapsed = 0;
}
update(dt: number) {
const node = this.node;
const delta = new Vec3(
this._direction.x * this.speed * dt,
this._direction.y * this.speed * dt,
this._direction.z * this.speed * dt,
);
node.position = node.position.add(delta);
this._elapsed += dt;
if (this._elapsed >= this.lifeTime) {
this.node.destroy();
}
}
}
enum SniperState {
Idle,
LockingOn,
Laser,
Cooldown,
}
@ccclass('SniperAim')
export class SniperAim extends Component {
@property({ type: Node, tooltip: '狙うターゲット(プレイヤーなど)のノード' })
public target: Node | null = null;
@property({ tooltip: 'この距離以内にターゲットが入ったときだけロックオン&攻撃します。' })
public attackRange: number = 800;
@property({ tooltip: 'ロックオン開始からレーザー照射開始までの時間(秒)' })
public lockOnTime: number = 0.7;
@property({ tooltip: 'レーザー照射時間(秒)。経過後に弾丸を発射します。' })
public laserDuration: number = 0.4;
@property({ tooltip: '1発撃ったあと、次のロックオンを開始するまでの待機時間(秒)' })
public cooldownTime: number = 1.5;
@property({ tooltip: '弾丸の速度(unit/秒)。値が大きいほど速く飛びます。' })
public bulletSpeed: number = 1200;
@property({ tooltip: '弾丸の寿命(秒)。この時間が経過すると自動的に破棄されます。' })
public bulletLifeTime: number = 3;
@property({ type: Prefab, tooltip: '発射する弾丸のプレハブ。Spriteなど見た目だけあればOKです。' })
public bulletPrefab: Prefab | null = null;
@property({ type: Node, tooltip: 'レーザー表示用ノード。スナイパーの子ノードに細長いSpriteなどを用意して指定します。未指定の場合はレーザー演出をスキップします。' })
public laserNode: Node | null = null;
@property({ tooltip: 'レーザーの太さ(Y方向スケール)。レーザー用ノードを伸ばす際の幅調整用です。' })
public laserWidth: number = 1;
@property({ tooltip: '将来的な拡張用フラグ。オンでもオフでも現在位置に向けて撃ちます。' })
public predictiveAim: boolean = false;
@property({ tooltip: 'オンにするとロックオンや発射タイミングをログ出力します。' })
public debugLog: boolean = false;
private _state: SniperState = SniperState.Idle;
private _stateTimer: number = 0;
private _hasFiredInThisCycle: boolean = false;
// キャッシュ用ベクトル
private _tmpVecA: Vec3 = new Vec3();
private _tmpVecB: Vec3 = new Vec3();
onLoad() {
// レーザー用ノードがある場合は最初は非表示にしておく
if (this.laserNode) {
this.laserNode.active = false;
}
}
start() {
// 特に初期化は不要だが、ターゲット未設定時は警告を出しておく
if (!this.target) {
console.warn('[SniperAim] target が設定されていません。このスナイパーは何も行いません。', this.node.name);
}
if (!this.bulletPrefab) {
console.warn('[SniperAim] bulletPrefab が設定されていません。弾丸を発射できません。', this.node.name);
}
}
update(dt: number) {
// ターゲットがいない、もしくはゲームが停止している場合は何もしない
if (!this.target || !this.target.isValid) {
this._setLaserActive(false);
this._state = SniperState.Idle;
this._stateTimer = 0;
return;
}
// ターゲットとの距離を測る(ワールド座標ベース)
const selfWorldPos = this.node.worldPosition;
const targetWorldPos = this.target.worldPosition;
const distance = Vec3.distance(selfWorldPos, targetWorldPos);
// 射程外の場合は常にIdleに戻す
if (distance > this.attackRange) {
if (this._state !== SniperState.Idle) {
if (this.debugLog) {
console.log('[SniperAim] ターゲットが射程外に出たためIdleに戻ります。');
}
}
this._setLaserActive(false);
this._state = SniperState.Idle;
this._stateTimer = 0;
this._hasFiredInThisCycle = false;
return;
}
// 射程内にいる場合はステートマシンを進める
this._stateTimer += dt;
switch (this._state) {
case SniperState.Idle:
this._enterLockOn();
break;
case SniperState.LockingOn:
// ロックオン中は常にターゲット方向を向く(レーザーはまだ出さない)
this._aimAtTarget(selfWorldPos, targetWorldPos);
if (this._stateTimer >= this.lockOnTime) {
this._enterLaser();
}
break;
case SniperState.Laser:
// レーザーをターゲットに向けて伸ばす
this._aimAtTarget(selfWorldPos, targetWorldPos);
this._updateLaser(selfWorldPos, targetWorldPos);
if (this._stateTimer >= this.laserDuration) {
// まだ撃っていなければ弾丸を発射
if (!this._hasFiredInThisCycle) {
this._fireBullet(selfWorldPos, targetWorldPos);
this._hasFiredInThisCycle = true;
}
this._enterCooldown();
}
break;
case SniperState.Cooldown:
// クールダウン中は何もしない
if (this._stateTimer >= this.cooldownTime) {
this._enterLockOn();
}
break;
}
}
/**
* ロックオン状態へ遷移
*/
private _enterLockOn() {
this._state = SniperState.LockingOn;
this._stateTimer = 0;
this._hasFiredInThisCycle = false;
this._setLaserActive(false);
if (this.debugLog) {
console.log('[SniperAim] ロックオン開始', this.node.name);
}
}
/**
* レーザー状態へ遷移
*/
private _enterLaser() {
this._state = SniperState.Laser;
this._stateTimer = 0;
this._setLaserActive(true);
if (this.debugLog) {
console.log('[SniperAim] レーザー照射開始', this.node.name);
}
}
/**
* クールダウン状態へ遷移
*/
private _enterCooldown() {
this._state = SniperState.Cooldown;
this._stateTimer = 0;
this._setLaserActive(false);
if (this.debugLog) {
console.log('[SniperAim] クールダウン開始', this.node.name);
}
}
/**
* ターゲット方向に自分自身を向ける(Z回転)
*/
private _aimAtTarget(selfWorldPos: Vec3, targetWorldPos: Vec3) {
const dir = this._tmpVecA;
Vec3.subtract(dir, targetWorldPos, selfWorldPos);
dir.z = 0; // 2D前提
// 方向から角度(ラジアン)を計算し、Z回転に反映
const angleRad = Math.atan2(dir.y, dir.x);
const angleDeg = math.toDegree(angleRad);
// ノードの回転を設定(Z軸回転)
const q = new Quat();
Quat.fromEuler(q, 0, 0, angleDeg);
this.node.setWorldRotation(q);
}
/**
* レーザーの長さ・角度をターゲットに合わせて更新
*/
private _updateLaser(selfWorldPos: Vec3, targetWorldPos: Vec3) {
if (!this.laserNode || !this.laserNode.isValid) {
return;
}
const dir = this._tmpVecA;
Vec3.subtract(dir, targetWorldPos, selfWorldPos);
dir.z = 0;
const distance = dir.length();
// レーザー用ノードはスナイパーの子ノードを想定
// X軸方向に伸びるSpriteなどを前提として、スケールで長さを調整
const localScale = this.laserNode.scale;
// X方向スケールで長さを表現、Y方向は太さ
this.laserNode.setScale(distance, this.laserWidth, localScale.z);
// レーザーの原点をスナイパー位置に合わせる
this.laserNode.setWorldPosition(selfWorldPos);
// レーザーの回転もターゲット方向に合わせる
const angleRad = Math.atan2(dir.y, dir.x);
const angleDeg = math.toDegree(angleRad);
const q = new Quat();
Quat.fromEuler(q, 0, 0, angleDeg);
this.laserNode.setWorldRotation(q);
}
/**
* レーザーの表示ON/OFF
*/
private _setLaserActive(active: boolean) {
if (this.laserNode && this.laserNode.isValid) {
this.laserNode.active = active;
}
}
/**
* 弾丸を発射
*/
private _fireBullet(selfWorldPos: Vec3, targetWorldPos: Vec3) {
if (!this.bulletPrefab) {
console.warn('[SniperAim] bulletPrefab が設定されていないため弾丸を発射できません。', this.node.name);
return;
}
const bulletNode = instantiate(this.bulletPrefab);
if (!bulletNode) {
console.error('[SniperAim] bulletPrefab の instantiate に失敗しました。');
return;
}
// 弾丸の親はスナイパーの親ノードにする(同じレイヤーに出したい想定)
const parent = this.node.parent ?? director.getScene();
bulletNode.parent = parent;
// 弾丸の初期位置はスナイパーの位置
bulletNode.setWorldPosition(selfWorldPos);
// 発射方向を計算
const dir = this._tmpVecB;
Vec3.subtract(dir, targetWorldPos, selfWorldPos);
dir.normalize();
// 弾丸の見た目も進行方向に向ける
const angleRad = Math.atan2(dir.y, dir.x);
const angleDeg = math.toDegree(angleRad);
const q = new Quat();
Quat.fromEuler(q, 0, 0, angleDeg);
bulletNode.setWorldRotation(q);
// 弾丸に SniperBullet コンポーネントを付与し、移動と寿命を管理
let bulletComp = bulletNode.getComponent(SniperBullet);
if (!bulletComp) {
bulletComp = bulletNode.addComponent(SniperBullet);
}
bulletComp.init(dir, this.bulletSpeed, this.bulletLifeTime);
if (this.debugLog) {
console.log('[SniperAim] 弾丸を発射しました。', this.node.name);
}
}
}
主要メソッドの解説
- onLoad
- レーザー用ノードが設定されている場合、起動時に非表示にしておきます。
- start
- ターゲットや弾丸プレハブが未設定の場合に警告ログを出します。
- これにより、ゲーム開始後に「何も起きない」状態をデバッグしやすくします。
- update
- 毎フレーム、ターゲットが存在するか・射程内かをチェックします。
- 射程外なら
Idle状態に戻し、レーザーも非表示にします。 - 射程内なら、状態マシン(ステートマシン)で挙動を制御します。
Idle: ロックオンを開始。LockingOn: 一定時間ターゲットを追尾し続ける。Laser: レーザーを表示し、照射時間が経過したら弾を発射。Cooldown: 一定時間待ってから再度ロックオンへ。
- _aimAtTarget
- 自分のノードをターゲット方向へZ回転させます。
- 2Dゲームでよくある「右向きが0度」の前提で、
atan2から角度を計算しています。
- _updateLaser
- レーザー用ノードがあれば、ターゲットとの距離に応じて X スケールを変更し、長さを表現します。
- レーザーの位置と回転もターゲット方向に合わせています。
- _fireBullet
- 弾丸プレハブを
instantiateし、自身の親ノード配下に生成します。 - ターゲット方向を計算し、
SniperBulletコンポーネントに移動処理を任せます。 SniperBulletはこのファイル内に定義されているため、外部スクリプトへの依存はありません。
- 弾丸プレハブを
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
SniperAim.tsにします。 - 自動生成されたコードをすべて削除し、本記事の
SniperAimコード全文を貼り付けて保存します。
2. 弾丸プレハブの用意
- Hierarchy で右クリック → Create → 2D Object → Sprite などで弾丸用ノードを作成します。
- 名前の例:
Bullet - 見た目が分かりやすいように細長い画像を設定すると良いです。
- 名前の例:
- 弾丸ノードを選択し、Inspector で位置・スケールなどを調整します。
- Assets パネルの任意のフォルダに、Hierarchy の弾丸ノードをドラッグ&ドロップして Prefab 化 します。
- 例:
Bullet.prefab
- 例:
- Prefab 化が終わったら、Hierarchy 上の元の弾丸ノードは削除して構いません。
3. レーザー用ノードの用意(任意だが推奨)
- Hierarchy で右クリック → Create → 2D Object → Sprite でレーザー表示用ノードを作成します。
- 名前の例:
Laser - 横に細長い画像(赤いラインなど)を設定すると分かりやすいです。
- 名前の例:
- この
Laserノードを、後で作るスナイパー本体ノードの子ノードにしておきます。 - スケールは
X=1, Y=1くらいで構いません。長さは SniperAim が自動で調整します。 - レーザーを常に上書きされるため、最初の位置や角度は特に気にしなくて大丈夫です。
4. スナイパー本体ノードの作成
- Hierarchy で右クリック → Create → 2D Object → Sprite などでスナイパー本体ノードを作成します。
- 名前の例:
SniperEnemy
- 名前の例:
- このノードに敵キャラの見た目用 Sprite を設定します。
- レーザー用ノード(
Laser)をSniperEnemyの子ノードにドラッグして親子関係を作ります。
5. プレイヤーノード(ターゲット)の準備
- すでにプレイヤーノードがある場合はそれを使います。
- 無い場合は Hierarchy で右クリック → Create → 2D Object → Sprite などでプレイヤー用ノードを作成します。
- 名前の例:
Player
- 名前の例:
6. SniperAim コンポーネントのアタッチ
- Hierarchy で
SniperEnemyノードを選択します。 - Inspector の Add Component ボタンをクリックします。
- Custom カテゴリ内から
SniperAimを選択して追加します。
7. インスペクタでプロパティを設定
SniperEnemy にアタッチされた SniperAim のプロパティを、以下のように設定してみましょう。
- Target : Hierarchy から
Playerノードをドラッグ&ドロップ - Attack Range :
800 - Lock On Time :
0.7 - Laser Duration :
0.4 - Cooldown Time :
1.5 - Bullet Speed :
1200 - Bullet Life Time :
3 - Bullet Prefab : Assets パネルから
Bullet.prefabをドラッグ&ドロップ - Laser Node : 子ノードの
Laserをドラッグ&ドロップ - Laser Width :
1(必要に応じて調整) - Predictive Aim : とりあえず
OFFのままでOK - Debug Log : 動作確認時は
ONにするとログが見えて便利
8. 動作確認
- Scene ビューで
SniperEnemyとPlayerの位置を適当に離して配置します。- 例:
SniperEnemyを左側、Playerを右側に置く。
- 例:
- 上部メニューの Play ボタン(▶)を押してゲームを再生します。
- 以下のような挙動になっていれば成功です:
- プレイヤーが射程内に入ると、スナイパーがプレイヤー方向に向きを変える。
- ロックオン時間後、レーザーがプレイヤーに向かって表示される。
- レーザー照射時間後、レーザーが消え、高速弾がプレイヤーに向かって飛んでいく。
- 一定時間後、同じサイクルが繰り返される。
- もし何も起きない場合:
- Console パネルに出ている警告ログを確認します(
targetやbulletPrefabの未設定など)。 - プレイヤーが
attackRangeの外側にいないか確認します。 - SniperAim コンポーネントが正しいノードにアタッチされているか確認します。
- Console パネルに出ている警告ログを確認します(
まとめ
この SniperAim コンポーネントは、
- ターゲットノード
- 弾丸プレハブ
- (任意)レーザー用ノード
をインスペクタから設定するだけで、「遠距離からのロックオン → レーザー照射 → 高速弾発射」という一連の狙撃AI挙動を実現します。
外部の GameManager やシングルトンに依存せず、スクリプト単体で完結しているため、任意のシーン・任意のプロジェクトに簡単に持ち込んで再利用できます。
応用例としては、
- ボス戦で複数のスナイパータレットを配置して弾幕を演出する。
- プレイヤーに「狙われている」緊張感を与えるギミックとして、レーザーのみを強調したトラップにする。
- 弾丸プレハブ側に当たり判定やエフェクトを追加して、よりリッチな演出に拡張する。
といった使い方が可能です。
パラメータをインスペクタから調整できるようにしてあるので、ゲームバランス調整もコードを書き換えずに行えます。
このコンポーネントをベースに、将来的には predictiveAim を活かした「移動先予測射撃」や、レーザーのチャージ演出なども簡単に追加できるはずです。




