【Cocos Creator】アタッチするだけ!SniperAim (狙撃AI)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【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. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を SniperAim.ts にします。
  3. 自動生成されたコードをすべて削除し、本記事の SniperAim コード全文を貼り付けて保存します。

2. 弾丸プレハブの用意

  1. Hierarchy で右クリック → Create → 2D Object → Sprite などで弾丸用ノードを作成します。
    • 名前の例:Bullet
    • 見た目が分かりやすいように細長い画像を設定すると良いです。
  2. 弾丸ノードを選択し、Inspector で位置・スケールなどを調整します。
  3. Assets パネルの任意のフォルダに、Hierarchy の弾丸ノードをドラッグ&ドロップして Prefab 化 します。
    • 例:Bullet.prefab
  4. Prefab 化が終わったら、Hierarchy 上の元の弾丸ノードは削除して構いません。

3. レーザー用ノードの用意(任意だが推奨)

  1. Hierarchy で右クリック → Create → 2D Object → Sprite でレーザー表示用ノードを作成します。
    • 名前の例:Laser
    • 横に細長い画像(赤いラインなど)を設定すると分かりやすいです。
  2. この Laser ノードを、後で作るスナイパー本体ノードの子ノードにしておきます。
  3. スケールは X=1, Y=1 くらいで構いません。長さは SniperAim が自動で調整します。
  4. レーザーを常に上書きされるため、最初の位置や角度は特に気にしなくて大丈夫です。

4. スナイパー本体ノードの作成

  1. Hierarchy で右クリック → Create → 2D Object → Sprite などでスナイパー本体ノードを作成します。
    • 名前の例:SniperEnemy
  2. このノードに敵キャラの見た目用 Sprite を設定します。
  3. レーザー用ノード(Laser)を SniperEnemy の子ノードにドラッグして親子関係を作ります。

5. プレイヤーノード(ターゲット)の準備

  1. すでにプレイヤーノードがある場合はそれを使います。
  2. 無い場合は Hierarchy で右クリック → Create → 2D Object → Sprite などでプレイヤー用ノードを作成します。
    • 名前の例:Player

6. SniperAim コンポーネントのアタッチ

  1. Hierarchy で SniperEnemy ノードを選択します。
  2. Inspector の Add Component ボタンをクリックします。
  3. 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. 動作確認

  1. Scene ビューで SniperEnemyPlayer の位置を適当に離して配置します。
    • 例:SniperEnemy を左側、Player を右側に置く。
  2. 上部メニューの Play ボタン(▶)を押してゲームを再生します。
  3. 以下のような挙動になっていれば成功です:
    • プレイヤーが射程内に入ると、スナイパーがプレイヤー方向に向きを変える。
    • ロックオン時間後、レーザーがプレイヤーに向かって表示される。
    • レーザー照射時間後、レーザーが消え、高速弾がプレイヤーに向かって飛んでいく。
    • 一定時間後、同じサイクルが繰り返される。
  4. もし何も起きない場合:
    • Console パネルに出ている警告ログを確認します(targetbulletPrefab の未設定など)。
    • プレイヤーが attackRange の外側にいないか確認します。
    • SniperAim コンポーネントが正しいノードにアタッチされているか確認します。

まとめ

この SniperAim コンポーネントは、

  • ターゲットノード
  • 弾丸プレハブ
  • (任意)レーザー用ノード

をインスペクタから設定するだけで、「遠距離からのロックオン → レーザー照射 → 高速弾発射」という一連の狙撃AI挙動を実現します。
外部の GameManager やシングルトンに依存せず、スクリプト単体で完結しているため、任意のシーン・任意のプロジェクトに簡単に持ち込んで再利用できます。

応用例としては、

  • ボス戦で複数のスナイパータレットを配置して弾幕を演出する。
  • プレイヤーに「狙われている」緊張感を与えるギミックとして、レーザーのみを強調したトラップにする。
  • 弾丸プレハブ側に当たり判定やエフェクトを追加して、よりリッチな演出に拡張する。

といった使い方が可能です。
パラメータをインスペクタから調整できるようにしてあるので、ゲームバランス調整もコードを書き換えずに行えます。
このコンポーネントをベースに、将来的には predictiveAim を活かした「移動先予測射撃」や、レーザーのチャージ演出なども簡単に追加できるはずです。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!