【Cocos Creator 3.8】Jetpack の実装:アタッチするだけで「ボタン長押しで上昇し続けるジェットパック挙動」を実現する汎用スクリプト

このガイドでは、2D/3Dどちらのプロジェクトでも、任意のキャラクターノードにアタッチするだけで「ボタン長押し → 燃料を消費しながら上昇力を加え続ける」挙動を実現できる汎用コンポーネント Jetpack を実装します。

入力は キーボード画面タッチ に対応し、燃料量・消費速度・推進力などはすべてインスペクタから調整可能です。外部の GameManager や入力管理スクリプトに一切依存しないため、どのプロジェクトにもそのまま持ち込んで利用できます。


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

1. 機能要件の整理

  • ボタン(キー/タッチ)を押している間、ノードに上向きの力(または速度変更)を加え続ける。
  • 推進中は燃料を消費し、燃料が 0 になると推進できない。
  • 燃料は時間経過で自動回復(任意で無効化可能)。
  • 2D/3D のどちらでも動作させたいので、Rigidbody2D / RigidBody の両方に対応する。
  • 物理ボディが無い場合は、Transform(position)を直接変更するフォールバックも用意する。
  • 入力は以下をサポートする:
    • キーボード:指定キー(デフォルト Space)
    • タッチ / マウス:画面押下中に推進(オプションで有効化)
  • 外部スクリプトに依存せず、すべての設定は @property で公開する。

2. インスペクタで設定可能なプロパティ設計

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

  • 物理挙動関連
    • use2DPhysics: boolean
      – 2D 物理(RigidBody2D)を使うかどうか。
      – true: RigidBody2D を探して力を加える。
      – false: RigidBody(3D)を探して力を加える。
    • usePhysicsForce: boolean
      – 物理ボディがある場合に、力を加える速度を直接変更するかを切り替える。
      – true: applyForceToCenter / applyForce を使用。
      – false: linearVelocity / setLinearVelocity を直接上書き。
    • thrustPower: number
      – 推進力の大きさ(上向きの力または速度の加算量)。
      – 値が大きいほど強く上昇する。
      – 例: 2D なら 5〜20 程度から調整。
    • maxUpwardSpeed: number
      – 上方向の最大速度(速度直接変更モードのときに適用)。
      – 0 以下で無制限。
    • fallbackMoveSpeed: number
      – 物理ボディが見つからなかった場合に、position を直接変更する速度(単位: unit / 秒)。
  • 燃料関連
    • maxFuel: number
      – 最大燃料量。
    • fuelConsumptionPerSecond: number
      – 推進中に 1 秒あたり消費する燃料量。
    • fuelRegenPerSecond: number
      – 非推進時に 1 秒あたり回復する燃料量(0 で回復なし)。
    • startWithFullFuel: boolean
      – 開始時に燃料を最大値でスタートさせるか。
  • 入力関連
    • enableKeyboard: boolean
      – キーボード入力を有効にするか。
    • keyCode: KeyCode
      – 推進に使うキー(デフォルト: Space)。
    • enablePointer: boolean
      – タッチ / マウス押下で推進を有効にするか。
    • pointerOnlyOnNode: boolean
      – true: このノードをタップしている間のみ推進。
      – false: 画面のどこを押しても推進。
  • デバッグ / 状態確認
    • debugLogMissingBody: boolean
      – 物理ボディが見つからない場合に警告ログを出すか。
    • currentFuel: number (readonly)
      – 現在の燃料量(インスペクタで確認用、コード内で更新)。
    • isThrusting: boolean (readonly)
      – 現在推進中かどうかのフラグ(インスペクタ確認用)。

これらのプロパティによって、2D/3D・物理/非物理・入力方法・燃料仕様まで、すべてインスペクタから完結して調整できるようにします。


TypeScriptコードの実装

以下が完成した Jetpack.ts の全コードです。


import {
    _decorator,
    Component,
    Node,
    Vec3,
    input,
    Input,
    EventKeyboard,
    EventTouch,
    EventMouse,
    KeyCode,
    systemEvent,
    SystemEvent,
    PhysicsSystem2D,
    RigidBody2D,
    RigidBody,
    macro,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * Jetpack
 * - キーまたはタッチを押している間、燃料を消費しつつ上昇力を加え続ける汎用コンポーネント
 * - 2D: RigidBody2D / 3D: RigidBody に対応
 * - 物理ボディがない場合は position を直接変更してフォールバック
 */
@ccclass('Jetpack')
export class Jetpack extends Component {

    // =========================
    // 物理挙動関連
    // =========================

    @property({
        tooltip: '2D物理(RigidBody2D)を使用するかどうか。\nON: RigidBody2D を使用\nOFF: 3D物理 RigidBody を使用',
    })
    public use2DPhysics: boolean = true;

    @property({
        tooltip: '物理ボディがある場合に「力を加える」か「速度を直接変更する」かを切り替えます。\nON: 力を加える\nOFF: 速度を直接変更',
    })
    public usePhysicsForce: boolean = true;

    @property({
        tooltip: '推進力の大きさ。\n力モード: 上方向に加える力の大きさ\n速度モード: 1秒あたりの上方向速度の加算量',
    })
    public thrustPower: number = 10;

    @property({
        tooltip: '上方向の最大速度。\n0以下で無制限。\n速度直接変更モードのときのみ有効。',
    })
    public maxUpwardSpeed: number = 0;

    @property({
        tooltip: '物理ボディが見つからなかった場合に position を直接変更する移動速度(unit/秒)。',
    })
    public fallbackMoveSpeed: number = 5;

    // =========================
    // 燃料関連
    // =========================

    @property({
        tooltip: '最大燃料量。',
    })
    public maxFuel: number = 3;

    @property({
        tooltip: '推進中に1秒あたり消費する燃料量。',
    })
    public fuelConsumptionPerSecond: number = 1;

    @property({
        tooltip: '非推進時に1秒あたり回復する燃料量。\n0で自動回復なし。',
    })
    public fuelRegenPerSecond: number = 0.5;

    @property({
        tooltip: '開始時に燃料を最大値でスタートさせるかどうか。',
    })
    public startWithFullFuel: boolean = true;

    @property({
        tooltip: '現在の燃料量(実行中のみ更新)。\nインスペクタ確認用の読み取り専用値。',
        readonly: true,
    })
    public currentFuel: number = 0;

    // =========================
    // 入力関連
    // =========================

    @property({
        tooltip: 'キーボード入力でジェットパックを起動できるようにするかどうか。',
    })
    public enableKeyboard: boolean = true;

    @property({
        tooltip: 'ジェットパック起動に使用するキー。\nデフォルトは Space。',
    })
    public keyCode: KeyCode = KeyCode.SPACE;

    @property({
        tooltip: 'タッチ / マウス押下でジェットパックを起動できるようにするかどうか。',
    })
    public enablePointer: boolean = true;

    @property({
        tooltip: 'true: このノード上をタップしている間のみ推進\nfalse: 画面のどこを押しても推進',
    })
    public pointerOnlyOnNode: boolean = false;

    // =========================
    // デバッグ関連
    // =========================

    @property({
        tooltip: '物理ボディが見つからなかった場合に警告ログを出すかどうか。',
    })
    public debugLogMissingBody: boolean = true;

    @property({
        tooltip: '現在推進中かどうか(インスペクタ確認用)。',
        readonly: true,
    })
    public isThrusting: boolean = false;

    // =========================
    // 内部状態
    // =========================

    private _rb2D: RigidBody2D | null = null;
    private _rb3D: RigidBody | null = null;

    private _keyPressed: boolean = false;
    private _pointerPressed: boolean = false;

    // pointerOnlyOnNode 用の簡易フラグ
    private _pointerOverThisNode: boolean = false;

    // 一時ベクトル(GC削減)
    private static _tempVec3: Vec3 = new Vec3();

    // -------------------------
    // ライフサイクル
    // -------------------------

    onLoad() {
        // 燃料初期化
        this.currentFuel = this.startWithFullFuel ? this.maxFuel : this.currentFuel;

        // 物理ボディ取得
        if (this.use2DPhysics) {
            this._rb2D = this.getComponent(RigidBody2D);
            if (!this._rb2D && this.debugLogMissingBody) {
                console.warn(
                    '[Jetpack] RigidBody2D が見つかりませんでした。' +
                    '2D物理での推進には、このノードに RigidBody2D コンポーネントを追加してください。' +
                    '見つからない場合は position 直接変更でフォールバックします。'
                );
            }
        } else {
            this._rb3D = this.getComponent(RigidBody);
            if (!this._rb3D && this.debugLogMissingBody) {
                console.warn(
                    '[Jetpack] RigidBody (3D) が見つかりませんでした。' +
                    '3D物理での推進には、このノードに RigidBody コンポーネントを追加してください。' +
                    '見つからない場合は position 直接変更でフォールバックします。'
                );
            }
        }

        // 入力イベント登録
        this._registerInput();
    }

    onDestroy() {
        this._unregisterInput();
    }

    // -------------------------
    // 入力登録 / 解除
    // -------------------------

    private _registerInput() {
        if (this.enableKeyboard) {
            input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
            input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
        }

        if (this.enablePointer) {
            if (this.pointerOnlyOnNode) {
                // このノード上でのタッチ / マウスイベントのみ
                this.node.on(Node.EventType.TOUCH_START, this._onNodeTouchStart, this);
                this.node.on(Node.EventType.TOUCH_END, this._onNodeTouchEnd, this);
                this.node.on(Node.EventType.TOUCH_CANCEL, this._onNodeTouchEnd, this);

                this.node.on(Node.EventType.MOUSE_DOWN, this._onNodeMouseDown, this);
                this.node.on(Node.EventType.MOUSE_UP, this._onNodeMouseUp, this);
            } else {
                // 画面全体のタッチ / マウスイベント
                input.on(Input.EventType.TOUCH_START, this._onGlobalPointerDown, this);
                input.on(Input.EventType.TOUCH_END, this._onGlobalPointerUp, this);
                input.on(Input.EventType.TOUCH_CANCEL, this._onGlobalPointerUp, this);

                input.on(Input.EventType.MOUSE_DOWN, this._onGlobalPointerDown, this);
                input.on(Input.EventType.MOUSE_UP, this._onGlobalPointerUp, this);
            }
        }
    }

    private _unregisterInput() {
        if (this.enableKeyboard) {
            input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
            input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
        }

        if (this.enablePointer) {
            if (this.pointerOnlyOnNode) {
                this.node.off(Node.EventType.TOUCH_START, this._onNodeTouchStart, this);
                this.node.off(Node.EventType.TOUCH_END, this._onNodeTouchEnd, this);
                this.node.off(Node.EventType.TOUCH_CANCEL, this._onNodeTouchEnd, this);

                this.node.off(Node.EventType.MOUSE_DOWN, this._onNodeMouseDown, this);
                this.node.off(Node.EventType.MOUSE_UP, this._onNodeMouseUp, this);
            } else {
                input.off(Input.EventType.TOUCH_START, this._onGlobalPointerDown, this);
                input.off(Input.EventType.TOUCH_END, this._onGlobalPointerUp, this);
                input.off(Input.EventType.TOUCH_CANCEL, this._onGlobalPointerUp, this);

                input.off(Input.EventType.MOUSE_DOWN, this._onGlobalPointerDown, this);
                input.off(Input.EventType.MOUSE_UP, this._onGlobalPointerUp, this);
            }
        }
    }

    // -------------------------
    // キーボード入力ハンドラ
    // -------------------------

    private _onKeyDown(event: EventKeyboard) {
        if (event.keyCode === this.keyCode) {
            this._keyPressed = true;
        }
    }

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

    // -------------------------
    // ポインタ入力ハンドラ(画面全体)
    // -------------------------

    private _onGlobalPointerDown(event: EventTouch | EventMouse) {
        this._pointerPressed = true;
    }

    private _onGlobalPointerUp(event: EventTouch | EventMouse) {
        this._pointerPressed = false;
    }

    // -------------------------
    // ポインタ入力ハンドラ(このノード限定)
    // -------------------------

    private _onNodeTouchStart(event: EventTouch) {
        this._pointerPressed = true;
    }

    private _onNodeTouchEnd(event: EventTouch) {
        this._pointerPressed = false;
    }

    private _onNodeMouseDown(event: EventMouse) {
        this._pointerPressed = true;
    }

    private _onNodeMouseUp(event: EventMouse) {
        this._pointerPressed = false;
    }

    // -------------------------
    // メインループ
    // -------------------------

    update(deltaTime: number) {
        const wantThrust = this._keyPressed || this._pointerPressed;

        // 燃料更新
        if (wantThrust && this.currentFuel > 0) {
            // 消費
            const consume = this.fuelConsumptionPerSecond * deltaTime;
            this.currentFuel = Math.max(0, this.currentFuel - consume);
        } else {
            // 回復
            if (this.fuelRegenPerSecond > 0 && this.currentFuel < this.maxFuel) {
                const regen = this.fuelRegenPerSecond * deltaTime;
                this.currentFuel = Math.min(this.maxFuel, this.currentFuel + regen);
            }
        }

        // 実際に推進できるか
        const canThrust = wantThrust && this.currentFuel > 0;

        this.isThrusting = canThrust;

        if (!canThrust) {
            return;
        }

        // 上向きの力 / 速度を適用
        if (this.use2DPhysics && this._rb2D) {
            this._applyThrust2D(deltaTime);
        } else if (!this.use2DPhysics && this._rb3D) {
            this._applyThrust3D(deltaTime);
        } else {
            // 物理ボディがない場合は position を直接変更
            this._applyFallbackMove(deltaTime);
        }
    }

    // -------------------------
    // 推進ロジック(2D)
    // -------------------------

    private _applyThrust2D(deltaTime: number) {
        const rb = this._rb2D!;
        if (this.usePhysicsForce) {
            // 力モード: 上方向に力を加える(世界座標のY+を上と仮定)
            rb.applyForceToCenter(new Vec3(0, this.thrustPower, 0), true);
        } else {
            // 速度直接変更モード
            const v = rb.linearVelocity;
            let newVy = v.y + this.thrustPower * deltaTime;

            if (this.maxUpwardSpeed > 0) {
                newVy = Math.min(newVy, this.maxUpwardSpeed);
            }

            rb.linearVelocity = new Vec3(v.x, newVy, 0);
        }
    }

    // -------------------------
    // 推進ロジック(3D)
    // -------------------------

    private _applyThrust3D(deltaTime: number) {
        const rb = this._rb3D!;
        if (this.usePhysicsForce) {
            // 力モード: 上方向に力を加える(世界座標のY+を上と仮定)
            const force = Jetpack._tempVec3;
            force.set(0, this.thrustPower, 0);
            rb.applyForce(force);
        } else {
            // 速度直接変更モード
            const v = rb.linearVelocity;
            let newVy = v.y + this.thrustPower * deltaTime;

            if (this.maxUpwardSpeed > 0) {
                newVy = Math.min(newVy, this.maxUpwardSpeed);
            }

            const newV = Jetpack._tempVec3;
            newV.set(v.x, newVy, v.z);
            rb.setLinearVelocity(newV);
        }
    }

    // -------------------------
    // フォールバック: position 直接変更
    // -------------------------

    private _applyFallbackMove(deltaTime: number) {
        const move = this.fallbackMoveSpeed * deltaTime;
        const pos = Jetpack._tempVec3;
        this.node.getPosition(pos);
        pos.y += move;
        this.node.setPosition(pos);
    }
}

コードの要点解説

  • onLoad
    • 燃料初期値を設定(startWithFullFuel が true なら maxFuel)。
    • use2DPhysics に応じて RigidBody2D または RigidBodygetComponent で取得。
      見つからない場合は console.warn で警告し、後で position 直接変更にフォールバックします。
    • キーボード・タッチ・マウスの入力イベントを登録。
  • 入力ハンドラ群
    • キーボード:KEY_DOWN / KEY_UP_keyPressed を更新。
    • ポインタ:
      • pointerOnlyOnNode = false の場合:input に対してグローバルな TOUCH/MOUSE イベントを登録。
      • pointerOnlyOnNode = true の場合:このノードに対して TOUCH/MOUSE イベントを登録。
    • 押下中かどうかは _pointerPressed に集約。
  • update(deltaTime)
    • wantThrust = _keyPressed || _pointerPressed で「プレイヤーが推進したいか」を判定。
    • 推進したい & 燃料 > 0 のとき、fuelConsumptionPerSecond に応じて燃料を減算。
    • 推進していないとき、fuelRegenPerSecond に応じて燃料を回復(上限 maxFuel)。
    • canThrust = wantThrust && currentFuel > 0 を満たすときだけ実際に推進処理を行う。
    • 2D/3D の物理ボディの有無に応じて、_applyThrust2D / _applyThrust3D / _applyFallbackMove を呼び分け。
  • _applyThrust2D / _applyThrust3D
    • usePhysicsForce = true のとき:
      • 2D: RigidBody2D.applyForceToCenter(0, thrustPower)
      • 3D: RigidBody.applyForce(0, thrustPower, 0)
    • usePhysicsForce = false のとき:
      • 毎フレーム thrustPower * deltaTime だけ Y 速度を加算。
      • maxUpwardSpeed > 0 の場合、上向き速度をその値でクランプ。
  • _applyFallbackMove
    • 物理ボディがない場合のフォールバック処理。
    • fallbackMoveSpeed * deltaTime だけ Y 方向に position を加算。
    • これにより、RigidBody を追加していなくても「とりあえず上昇するジェットパック」が機能します。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create → TypeScript を選択し、ファイル名を Jetpack.ts にします。
  3. 作成された Jetpack.ts をダブルクリックして開き、内容をすべて削除して、前述のコードを丸ごと貼り付けて保存します。

2. テスト用ノードの作成(2D の例)

ここでは 2D プロジェクトを例に説明しますが、3D でも同様に使えます。

  1. 上部メニューから File → New Scene でテスト用シーンを作成し、保存します。
  2. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、プレイヤー用の Player ノードを作成します。
  3. Player ノードを選択し、Inspector の Add Component ボタンをクリックします。
  4. Physics 2D → RigidBody2D を追加します。
    • Body Type: Dynamic に設定。
    • 必要に応じて BoxCollider2D なども追加してください(地面との衝突判定など)。
  5. 再度 Add Component をクリックし、Custom → Jetpack を選択してアタッチします。

3. Jetpack プロパティの設定例(2D)

Player ノードの Inspector に表示される Jetpack コンポーネントを次のように設定してみましょう。

  • 物理挙動関連
    • Use 2D Physics: ON(チェック)
    • Use Physics Force: ON(まずは力モードで試す)
    • Thrust Power: 15
    • Max Upward Speed: 0(無制限)
    • Fallback Move Speed: 5(今回は RigidBody2D があるので使用されません)
  • 燃料関連
    • Max Fuel: 3
    • Fuel Consumption Per Second: 1
    • Fuel Regen Per Second: 0.5
    • Start With Full Fuel: ON
  • 入力関連
    • Enable Keyboard: ON
    • Key Code: SPACE(デフォルトのままでOK)
    • Enable Pointer: ON(スマホでもテストする場合)
    • Pointer Only On Node: OFF(画面のどこでも押せば推進)
  • デバッグ関連
    • Debug Log Missing Body: ON(RigidBody2D を付け忘れたときに警告してくれます)
    • Current Fuel: 実行中に自動で更新されます。
    • Is Thrusting: 実行中に Space やタッチを押すと true になります。

4. シーンの簡易セットアップ(2D)

ジェットパックの動きを確認するために、簡単な地面を用意しておくとわかりやすいです。

  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、Ground ノードを作成します。
  2. GroundTransform を調整:
    • Position: (0, -200, 0)
    • Scale: (5, 1, 1) など、横に広い地面にします。
  3. GroundBoxCollider2DRigidBody2D を追加し、RigidBody2D.BodyTypeStatic に設定します。
  4. Player を地面の少し上(例: Position Y = -150)に配置します。

5. 再生して動作確認

  1. 上部の Play ボタン(▶)を押してゲームを実行します。
  2. ゲームビューで、Space キー を押し続けてみてください。
    • 押している間、Player が上昇し続けます。
    • 燃料が減少し、Current Fuel が 0 になると上昇しなくなります。
    • キーを離すと、少しずつ燃料が回復していきます。
  3. スマホビルドやエディタ内でタッチ入力を試す場合:
    • ゲームビュー内をマウスで押しっぱなし(またはタッチ)にすると、同様に上昇します。
  4. Inspector の Jetpack コンポーネントを見ながら、
    • Current Fuel が減ったり増えたりする様子
    • Is Thrusting が true/false に切り替わる様子

    をリアルタイムで確認できます。

6. 3D プロジェクトでの利用

3D の場合も基本的な手順は同じです。

  1. Hierarchy で Create → 3D Object → Cube などを作成し、キャラクターノードとします。
  2. そのノードに RigidBodyCollider(BoxCollider など)を追加し、RigidBody.TypeDynamic に設定します。
  3. 同じノードに Jetpack コンポーネントを追加し、次のように設定します:
    • Use 2D Physics: OFF
    • 他の値は 2D と同様に調整
  4. Play して Space キーを押し続けると、3D 空間でも上方向にジェットパック推進が行われます。

まとめ

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

  • 単一スクリプトで完結しており、外部の GameManager や入力管理クラスに依存しません。
  • 2D/3D・物理/非物理に対応し、どんなプロジェクトにもアタッチするだけで導入可能です。
  • 燃料システム・入力方法・推進力などをすべてインスペクタから調整できるため、
    • アクションゲームの二段ジャンプ+ホバリング
    • フライトゲームのブースト機能
    • パズルゲームでの一時的な浮遊ギミック

    など、さまざまなシーンで再利用できます。

ゲーム全体の設計に関わらず、「とりあえずキャラにジェットパックを付けてみる」ことが一瞬でできるようになるのが、この種の汎用コンポーネントの強みです。
今後は、同じ設計方針で「ダッシュ」「二段ジャンプ」「ホバリング」などの移動系コンポーネントを分離しておけば、プロジェクトをまたいだ再利用性が大きく向上します。

必要に応じて、

  • 推進中にパーティクルやエフェクトを再生するプロパティ
  • 燃料残量を UI に表示するためのイベント発行

などを追加して、自分のゲームに最適化した Jetpack に発展させてみてください。