【Cocos Creator 3.8】ChargeShot(溜め撃ち)の実装:アタッチするだけで「ボタン長押しで威力とサイズが変わる弾」を発射できる汎用スクリプト

このガイドでは、ボタン長押し時間を自動で計測し、ボタンを離したタイミングで威力やサイズが変化する弾を発射する「溜め撃ち」コンポーネントを実装します。
プレイヤーキャラや砲台のノードにこの ChargeShot をアタッチし、Inspector で弾プレハブとパラメータを設定するだけで利用できます。外部の GameManager などには一切依存しません。


コンポーネントの設計方針

機能要件の整理

  • ボタン(キーボード or マウス or 画面タッチ)を押している時間を計測する。
  • 押し始めた瞬間に「チャージ開始」状態になり、押している間はチャージ時間を加算し続ける。
  • ボタンを離した瞬間に、チャージ時間に応じて威力・サイズ・速度などが変化した弾を生成する。
  • チャージ時間には最小値・最大値を設定できるようにする。
  • 弾はプレハブ(Prefab)として設定し、生成位置・向き・親ノードを Inspector から指定できる。
  • 入力方式(キーボード、マウス左クリック、タッチ)を Inspector から切り替え可能にする。
  • 外部スクリプトへの依存は一切なし。必要な設定はすべて @property で受け取る。

前提と防御的な実装

  • 弾プレハブには任意のコンポーネント(SpriteRigidBody2D など)を自由に付けてよいが、このコンポーネント側からそれらに依存しないようにする。
  • 弾のサイズは NodesetScale で変更し、威力は damage というカスタムプロパティを持っていればそこに代入し、なければログを出す程度に留める(あくまで汎用)。
  • 必須の参照(弾プレハブなど)が未設定の場合は、error ログを出して発射処理をスキップする。

Inspector で設定可能なプロパティ設計

以下のようなプロパティを用意します。

  • 入力関連
    • inputMode: 'Keyboard' | 'MouseLeft' | 'Touch'
      入力方式の切り替え。キーボード、マウス左クリック、タッチのいずれか。
    • keyCode: KeyCode
      Keyboard のときに使用するキー。初期値は KeyCode.SPACE
  • チャージ関連
    • minChargeTime: number
      最小チャージ時間(秒)。これ未満でも撃てるが、スケール計算の下限値として使う。
    • maxChargeTime: number
      最大チャージ時間(秒)。これ以上はチャージ時間を増やさない。
    • autoFireAtMax: boolean
      最大チャージに到達した瞬間に自動で発射するかどうか。
  • 弾生成関連
    • bulletPrefab: Prefab
      生成する弾のプレハブ。必須。
    • spawnNode: Node | null
      弾の生成位置として使うノード。未指定の場合は、このコンポーネントが付いているノード自身を使用。
    • bulletParent: Node | null
      生成した弾の親ノード。未指定の場合は、scene のルートもしくは this.node.parent を使う。
    • baseBulletSpeed: number
      弾の基礎速度(任意の単位)。実際には bullet.node.forward 方向に Vec3 で速度を設定するなど、プロジェクト側で利用できるように velocity プロパティに代入を試みる。
    • direction: Vec3
      弾を飛ばす方向(ローカル座標系)。例えば (1, 0, 0) で右方向。
  • スケール・威力スケーリング
    • minScale: number
      最小スケール倍率(1 で等倍)。
    • maxScale: number
      最大スケール倍率。
    • minDamage: number
      最小ダメージ値。
    • maxDamage: number
      最大ダメージ値。
    • minSpeedMultiplier: number
      弾速の最小倍率。
    • maxSpeedMultiplier: number
      弾速の最大倍率。
  • デバッグ・UI 補助
    • showDebugLog: boolean
      チャージ開始・終了・発射時にログを出すかどうか。
    • chargeRatioOut: number (readonly)
      現在のチャージ割合(0〜1)。他の UI コンポーネントから参照しやすいように公開だけしておく。

TypeScriptコードの実装


import { _decorator, Component, Node, Prefab, instantiate, Vec3, input, Input, EventMouse, EventTouch, EventKeyboard, KeyCode, sys } from 'cc';
const { ccclass, property } = _decorator;

enum ChargeShotInputMode {
    Keyboard = 0,
    MouseLeft = 1,
    Touch = 2,
}

@ccclass('ChargeShot')
export class ChargeShot extends Component {

    // =========================
    // 入力設定
    // =========================
    @property({
        tooltip: '入力方式を選択します。\nKeyboard: 指定キーでチャージ\nMouseLeft: 左クリックでチャージ\nTouch: 画面タッチでチャージ',
        type: ChargeShotInputMode
    })
    public inputMode: ChargeShotInputMode = ChargeShotInputMode.Keyboard;

    @property({
        tooltip: 'Keyboard モードの時に使用するキーコードです。\n例: SPACE, ENTER など',
        type: KeyCode
    })
    public keyCode: KeyCode = KeyCode.SPACE;

    // =========================
    // チャージ関連設定
    // =========================
    @property({
        tooltip: '最小チャージ時間(秒)。\nこの時間未満でも発射はしますが、スケーリング計算の下限として扱われます。'
    })
    public minChargeTime: number = 0.0;

    @property({
        tooltip: '最大チャージ時間(秒)。\nこの時間を超えてチャージしても、威力やサイズはこれ以上は上昇しません。'
    })
    public maxChargeTime: number = 1.5;

    @property({
        tooltip: '最大チャージに達した瞬間に自動で発射するかどうか。'
    })
    public autoFireAtMax: boolean = false;

    // =========================
    // 弾生成関連
    // =========================
    @property({
        tooltip: '発射する弾のプレハブ。\n必ず設定してください。',
        type: Prefab
    })
    public bulletPrefab: Prefab | null = null;

    @property({
        tooltip: '弾の生成位置として使用するノード。\n未設定の場合、このコンポーネントが付いているノードの位置を使用します。',
        type: Node
    })
    public spawnNode: Node | null = null;

    @property({
        tooltip: '生成した弾の親ノード。\n未設定の場合、このノードの親かシーンルートに配置されます。',
        type: Node
    })
    public bulletParent: Node | null = null;

    @property({
        tooltip: '弾の基礎速度(任意の単位)。\n実際の移動は弾側のスクリプトで行ってください。'
    })
    public baseBulletSpeed: number = 10;

    @property({
        tooltip: '弾を飛ばす方向(ローカル座標系)。\n例: (1, 0, 0) で右方向に発射。',
        type: Vec3
    })
    public direction: Vec3 = new Vec3(1, 0, 0);

    // =========================
    // スケール・威力スケーリング
    // =========================
    @property({
        tooltip: '弾の最小スケール倍率(1 で等倍)。'
    })
    public minScale: number = 0.5;

    @property({
        tooltip: '弾の最大スケール倍率。'
    })
    public maxScale: number = 2.0;

    @property({
        tooltip: '最小ダメージ値。\n弾が damage プロパティを持つ場合に代入されます。'
    })
    public minDamage: number = 10;

    @property({
        tooltip: '最大ダメージ値。\n弾が damage プロパティを持つ場合に代入されます。'
    })
    public maxDamage: number = 50;

    @property({
        tooltip: '弾速の最小倍率。\n実際の速度は baseBulletSpeed * この倍率 で計算されます。'
    })
    public minSpeedMultiplier: number = 1.0;

    @property({
        tooltip: '弾速の最大倍率。\n実際の速度は baseBulletSpeed * この倍率 で計算されます。'
    })
    public maxSpeedMultiplier: number = 2.0;

    // =========================
    // デバッグ・状態確認用
    // =========================
    @property({
        tooltip: 'チャージ開始・終了・発射時にデバッグログを出力するかどうか。'
    })
    public showDebugLog: boolean = false;

    @property({
        tooltip: '現在のチャージ割合(0〜1)。\n他の UI から参照するための出力用プロパティです(手動で変更しないでください)。',
        readonly: true
    })
    public chargeRatioOut: number = 0;

    // 内部状態
    private _isCharging: boolean = false;
    private _currentChargeTime: number = 0;

    onEnable() {
        this._registerInput();
    }

    onDisable() {
        this._unregisterInput();
    }

    update(deltaTime: number) {
        if (this._isCharging) {
            this._currentChargeTime += deltaTime;

            // 最大チャージ時間でクランプ
            if (this._currentChargeTime > this.maxChargeTime) {
                this._currentChargeTime = this.maxChargeTime;

                if (this.autoFireAtMax) {
                    // 最大チャージ到達時に自動発射
                    this._stopChargingAndFire();
                }
            }

            // 現在のチャージ割合を更新(0〜1)
            this.chargeRatioOut = this._getChargeRatio();
        }
    }

    // =========================
    // 入力登録・解除
    // =========================
    private _registerInput() {
        switch (this.inputMode) {
            case ChargeShotInputMode.Keyboard:
                input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
                input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
                break;
            case ChargeShotInputMode.MouseLeft:
                input.on(Input.EventType.MOUSE_DOWN, this._onMouseDown, this);
                input.on(Input.EventType.MOUSE_UP, this._onMouseUp, this);
                break;
            case ChargeShotInputMode.Touch:
                input.on(Input.EventType.TOUCH_START, this._onTouchStart, this);
                input.on(Input.EventType.TOUCH_END, this._onTouchEnd, this);
                input.on(Input.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
                break;
        }
    }

    private _unregisterInput() {
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
        input.off(Input.EventType.MOUSE_DOWN, this._onMouseDown, this);
        input.off(Input.EventType.MOUSE_UP, this._onMouseUp, this);
        input.off(Input.EventType.TOUCH_START, this._onTouchStart, this);
        input.off(Input.EventType.TOUCH_END, this._onTouchEnd, this);
        input.off(Input.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
    }

    // =========================
    // 入力イベントハンドラ
    // =========================
    private _onKeyDown(event: EventKeyboard) {
        if (event.keyCode === this.keyCode) {
            this._startCharging();
        }
    }

    private _onKeyUp(event: EventKeyboard) {
        if (event.keyCode === this.keyCode) {
            this._stopChargingAndFire();
        }
    }

    private _onMouseDown(event: EventMouse) {
        if (event.getButton() === EventMouse.BUTTON_LEFT) {
            this._startCharging();
        }
    }

    private _onMouseUp(event: EventMouse) {
        if (event.getButton() === EventMouse.BUTTON_LEFT) {
            this._stopChargingAndFire();
        }
    }

    private _onTouchStart(event: EventTouch) {
        this._startCharging();
    }

    private _onTouchEnd(event: EventTouch) {
        this._stopChargingAndFire();
    }

    // =========================
    // チャージ制御
    // =========================
    private _startCharging() {
        if (this._isCharging) {
            return;
        }
        this._isCharging = true;
        this._currentChargeTime = 0;
        this.chargeRatioOut = 0;

        if (this.showDebugLog) {
            console.log('[ChargeShot] チャージ開始');
        }
    }

    private _stopChargingAndFire() {
        if (!this._isCharging) {
            return;
        }
        this._isCharging = false;

        // チャージ割合を最終確定
        this.chargeRatioOut = this._getChargeRatio();

        if (this.showDebugLog) {
            console.log(`[ChargeShot] チャージ終了: time=${this._currentChargeTime.toFixed(2)}s, ratio=${this.chargeRatioOut.toFixed(2)}`);
        }

        this._fire();
        this._currentChargeTime = 0;
    }

    private _getChargeRatio(): number {
        if (this.maxChargeTime <= this.minChargeTime) {
            // 不正設定の場合は 0〜1 にクランプせず、そのまま最大扱い
            return 1.0;
        }

        const clampedTime = Math.min(Math.max(this._currentChargeTime, this.minChargeTime), this.maxChargeTime);
        const ratio = (clampedTime - this.minChargeTime) / (this.maxChargeTime - this.minChargeTime);
        // 0〜1 にクランプ
        return Math.min(Math.max(ratio, 0), 1);
    }

    // =========================
    // 弾の生成とパラメータ適用
    // =========================
    private _fire() {
        if (!this.bulletPrefab) {
            console.error('[ChargeShot] bulletPrefab が設定されていません。Inspector で設定してください。');
            return;
        }

        const bulletNode = instantiate(this.bulletPrefab);

        // 親ノードの決定
        let parent: Node | null = this.bulletParent;
        if (!parent) {
            parent = this.node.parent;
        }
        if (!parent) {
            // 最後の手段としてシーンルートに追加
            bulletNode.setParent(this.node.scene);
        } else {
            bulletNode.setParent(parent);
        }

        // 生成位置の決定
        const spawnSource = this.spawnNode ? this.spawnNode : this.node;
        bulletNode.worldPosition = spawnSource.worldPosition;

        // 発射方向(ローカル方向をワールド方向に変換したい場合は、必要に応じてプロジェクト側で調整)
        const dir = this.direction.clone().normalize();
        if (dir.length() === 0) {
            dir.set(1, 0, 0); // デフォルトで右方向
        }

        const ratio = this.chargeRatioOut;

        // スケール計算
        const scale = this._lerp(this.minScale, this.maxScale, ratio);
        bulletNode.setScale(scale, scale, scale);

        // ダメージ計算
        const damage = this._lerp(this.minDamage, this.maxDamage, ratio);

        // 速度倍率計算
        const speedMultiplier = this._lerp(this.minSpeedMultiplier, this.maxSpeedMultiplier, ratio);
        const finalSpeed = this.baseBulletSpeed * speedMultiplier;

        // 汎用的な「damage」「velocity」プロパティに代入を試みる(存在しなくてもエラーにはしない)
        const anyBullet: any = bulletNode;
        try {
            if ('damage' in anyBullet) {
                anyBullet.damage = damage;
            } else if (this.showDebugLog) {
                console.log('[ChargeShot] 弾ノードに damage プロパティが存在しません。必要なら弾側に追加してください。');
            }

            if ('velocity' in anyBullet) {
                anyBullet.velocity = new Vec3(dir.x * finalSpeed, dir.y * finalSpeed, dir.z * finalSpeed);
            } else if (this.showDebugLog) {
                console.log('[ChargeShot] 弾ノードに velocity プロパティが存在しません。必要なら弾側に追加してください。');
            }
        } catch (e) {
            console.warn('[ChargeShot] 弾のカスタムプロパティ設定中に例外が発生しました:', e);
        }

        if (this.showDebugLog) {
            console.log(`[ChargeShot] 発射: ratio=${ratio.toFixed(2)}, scale=${scale.toFixed(2)}, damage=${damage.toFixed(1)}, speed=${finalSpeed.toFixed(2)}`);
        }
    }

    private _lerp(min: number, max: number, t: number): number {
        return min + (max - min) * t;
    }
}

主要部分の解説

  • update(deltaTime)
    チャージ中(_isCharging === true)であれば、フレームごとに _currentChargeTime を加算し、maxChargeTime を超えないようにクランプします。
    さらに、_getChargeRatio()0〜1 のチャージ割合を計算し、chargeRatioOut に反映します。
    autoFireAtMax が有効な場合、最大チャージ到達時点で _stopChargingAndFire() を呼び出して自動発射します。
  • 入力イベント登録(onEnable / onDisable
    inputMode に応じて、キーボード・マウス・タッチのイベントを登録/解除しています。
    例えば Keyboard の場合、KEY_DOWN_startCharging()KEY_UP_stopChargingAndFire() を呼び出します。
  • チャージ制御(_startCharging / _stopChargingAndFire
    _startCharging() ではフラグと時間を初期化し、_stopChargingAndFire() ではチャージ割合を確定させてから _fire() を呼び出します。
  • 弾の生成とパラメータ適用(_fire()
    • bulletPrefab から instantiate で弾ノードを生成。
    • bulletParent があればそこに、なければ this.node.parent かシーンルートに追加。
    • spawnNode があればその worldPosition に、なければこのコンポーネントのノード位置に配置。
    • chargeRatioOut を使って、min〜max の各種値を _lerp で補間し、スケール・ダメージ・速度倍率を決定。
    • 弾ノードに damage / velocity プロパティがあれば代入を試みる(なければログのみ)。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタの Assets パネルで空白部分を右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を ChargeShot.ts に変更します。
  4. 生成された ChargeShot.ts をダブルクリックして開き、内容をすべて削除して、上記のコードをそのまま貼り付けて保存します。

2. テスト用の弾プレハブを用意

  • 簡単な動作確認として、Sprite だけの弾プレハブを作ります(移動や当たり判定はなくても構いません)。
  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、名前を Bullet にします。
  2. Inspector の Sprite コンポーネントで、任意の画像を設定します(小さな丸など)。
  3. Assets パネルに Bullet ノードをドラッグ&ドロップしてプレハブ化します(例: Prefabs/Bullet.prefab)。
  4. Hierarchy 上の Bullet ノードは削除してもかまいません(プレハブだけ残ればOK)。

※ 弾に移動させるスクリプトを付けたい場合は、別途 velocity プロパティを持つコンポーネントを弾に付けることで、ChargeShot から速度を受け取れます。

3. 溜め撃ちをさせたいノードを作成

  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、名前を Player などにします。
  2. Inspector の Sprite コンポーネントで、任意のプレイヤー画像を設定します。

4. ChargeShot コンポーネントをアタッチ

  1. HierarchyPlayer ノードを選択します。
  2. Inspector 下部の Add Component ボタンをクリックします。
  3. Custom → ChargeShot を選択して追加します。

5. Inspector でプロパティを設定

  1. Bullet Prefab: 先ほど作成した Bullet プレハブをドラッグ&ドロップして設定します。
  2. Spawn Node: 未設定のままで構いません(プレイヤーの位置から発射されます)。
  3. Bullet Parent: 未設定のままでも動きますが、弾をまとめたい場合は BulletsRoot などのノードを作って指定してもよいです。
  4. Input Mode:
    • PC でテストするなら Keyboard を選択し、Key CodeSPACE にしておくと分かりやすいです。
    • マウスで試す場合は MouseLeft を選択します。
    • スマホ向けなら Touch を選択します。
  5. Min Charge Time: 0.0 のままでOK(押した瞬間でも撃てる)。
  6. Max Charge Time: 例として 1.5 秒に設定します。
  7. Auto Fire At Max:
    • ON にすると、1.5 秒押しっぱなしで自動発射します。
    • OFF の場合は、離すまでチャージし続けます。
  8. Direction: (1, 0, 0) のままで、右方向に発射されます。
  9. Min Scale / Max Scale: 例として 0.5 / 2.0 に設定し、チャージすると弾が大きくなるのを確認できます。
  10. Min Damage / Max Damage: 例として 10 / 50 に設定しておきます(ログで値を確認できます)。
  11. Min Speed Multiplier / Max Speed Multiplier: 例として 1.0 / 2.0 にしておきます。
  12. Show Debug Log: ON にすると、チャージ開始・終了・発射時に Console にログが表示されます。動作確認時は ON にしておくと便利です。

6. 動作確認

  1. エディタ右上の Play ボタンを押してプレビューを開始します。
  2. Input Mode を Keyboard にしている場合:
    • SPACE キーを押し続けてみてください。
    • 押している間は内部でチャージ時間が増え、離した瞬間に弾プレハブが生成されます。
    • 短く押すと小さな弾、長く押すと大きな弾が生成されるのを確認します。
  3. Input Mode を MouseLeft にしている場合:
    • ゲーム画面上で左クリックを押し続け、離したタイミングで弾が発射されます。
  4. Input Mode を Touch にしている場合:
    • モバイルビルドやシミュレータで、画面をタッチして離すと弾が発射されます。
  5. Console を開いている場合、Show Debug Log を ON にしていれば
    • [ChargeShot] チャージ開始
    • [ChargeShot] チャージ終了: time=..., ratio=...
    • [ChargeShot] 発射: ratio=..., scale=..., damage=..., speed=...

    といったログが出力され、チャージ時間とスケール・ダメージの関係を確認できます。


まとめ

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

  • ボタン長押し時間の計測
  • チャージ割合(0〜1)の算出
  • 弾プレハブの生成と、チャージに応じたサイズ・威力・速度のスケーリング
  • キーボード/マウス/タッチの汎用入力対応

を、単一スクリプトかつ Inspector 設定だけで完結させています。
プレイヤーキャラ、ボスのチャージ攻撃、チャージショット付きの砲台など、あらゆる「溜め撃ち」表現にそのまま再利用できます。

さらに、chargeRatioOut プロパティを利用すれば、

  • チャージゲージ UI の塗りつぶし割合に反映する
  • チャージ中にエフェクトの色や強さを変える
  • チャージ完了時に SE やエフェクトを再生する

といった拡張も簡単です。
外部の GameManager に依存しない汎用コンポーネントとしてプロジェクトに組み込んでおけば、「とりあえず溜め撃ちを付けたいキャラにアタッチするだけ」でゲーム開発のスピードを大きく向上させられます。