【Cocos Creator 3.8】WeaponPivot(武器持ち手)の実装:アタッチするだけで「肩など任意の支点を中心に武器スプライトを回転」させる汎用スクリプト

2Dアクションやシューティングで、剣や銃などの武器スプライトを「親ノードの中心」ではなく「肩」「グリップ」など任意の位置を支点にして回転させたい場面はよくあります。
本記事では、任意の支点を指定して武器スプライトを回転させられる汎用コンポーネント WeaponPivot を実装します。

このコンポーネントは、武器スプライトのノードにアタッチするだけで使え、支点オフセット・回転速度・自動回転のON/OFF・角度制限・ローカル/ワールド基準などをインスペクタから調整できます。
他のカスタムスクリプトへの依存は一切なく、1ファイルだけで完結する設計です。


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

目的と要件整理

WeaponPivot の目的:

  • 武器スプライトの見た目上の支点(例: 肩・グリップ)を中心に回転させる。
  • 支点位置はインスペクタから柔軟に調整できる。
  • 自動で回転させる機能も持ち、デモやテスト、ギミックに使える。
  • 他スクリプトに依存せず、このコンポーネント単体で完結する。

実現アプローチ(Cocos Creator 2D座標系ベース):

  • 武器ノード(このコンポーネントをアタッチしたノード)を「見た目上の支点」からのオフセット位置に配置し、
  • 計算上の支点座標を基準に、回転後の武器の位置を毎フレーム更新する。
  • 回転角度はローカル回転(親ノード基準)を使い、支点オフセットを回転行列で回すことで「支点を中心に回る」挙動を再現する。

イメージ:

  • 親ノード: キャラクターの上半身など。
  • 武器ノード: 親の子として配置。WeaponPivot をアタッチ。
  • pivotOffset: 親の中心から見た「肩」や「グリップ」の位置。
  • weaponOffset: 支点から見た武器スプライトの相対位置(距離・向き)。

支点座標 = 親の位置 + pivotOffset
武器座標 = 支点座標 + 回転後の weaponOffset

このように毎フレーム位置を更新することで、支点を中心に武器が回転しているように見せます。

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

すべての設定は @property で公開し、外部スクリプトに依存しないようにします。

  • pivotOffsetX / pivotOffsetY(number)
    • ツールチップ例: 親ノードの中心から見た「支点(肩など)」までのオフセットX/Y(ローカル座標)
    • 親ノードのローカル座標系での支点位置。
    • 例: キャラの中心が (0,0) で、肩が右上にあるなら pivotOffsetX = 20, pivotOffsetY = 40 など。
  • weaponOffsetX / weaponOffsetY(number)
    • ツールチップ例: 支点(肩など)から見た武器スプライト原点までのオフセットX/Y(ローカル座標)
    • 支点を中心に回転したい場合、武器の「グリップ位置」が支点になるように調整する。
    • 例: 武器の原点が中央で、グリップが左端にある場合、支点がグリップなら weaponOffsetX をマイナス方向に設定。
  • initialAngle(number)
    • ツールチップ例: 初期角度(度)。0度は右向き、反時計回りが正方向
    • コンポーネント開始時の武器の角度。
    • 例: 上向きから開始したい場合は 90、下向きなら -90。
  • autoRotate(boolean)
    • ツールチップ例: 有効にすると毎フレーム自動で回転します
    • ON: rotationSpeed に従って自動回転。
    • OFF: 角度は currentAngle を直接変更することで制御(将来的に他スクリプトから操作する想定)。
  • rotationSpeed(number)
    • ツールチップ例: 自動回転時の角速度(度/秒)。正で反時計回り、負で時計回り
    • autoRotate が true のときに有効。
  • useAngleLimit(boolean)
    • ツールチップ例: 有効にすると回転角度を minAngle ~ maxAngle に制限します
    • ON: 角度が指定範囲外に出ないようクランプ。
  • minAngle / maxAngle(number)
    • ツールチップ例: 許可する最小/最大角度(度)。0度は右向き、反時計回りが正方向
    • 例: キャラの前方(右側)だけを向かせたいなら minAngle = -60, maxAngle = 60 など。
  • debugDrawGizmos(boolean)
    • ツールチップ例: エディタ・実行中に支点位置をログ出力します(簡易デバッグ用)
    • エディタ上での調整を助けるためのデバッグ出力(Gizmos API はないためログベース)。

なお、本コンポーネントは 特定のコンポーネント(Sprite など)には依存しません
任意のノード(Sprite, Node, UIノードなど)にアタッチして使用できます。


TypeScriptコードの実装

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


import { _decorator, Component, Node, Vec2, Vec3, math } from 'cc';
const { ccclass, property } = _decorator;

/**
 * WeaponPivot
 * 親ノードの中心ではなく、肩など任意の支点を中心に武器ノードを回転させる汎用コンポーネント。
 *
 * 使い方(概要):
 * - このコンポーネントを「武器ノード」にアタッチする。
 * - 親ノードの中心から見た「支点(肩など)」までのオフセットを pivotOffsetX/Y に設定。
 * - 支点から見た武器ノード原点までのオフセットを weaponOffsetX/Y に設定。
 * - initialAngle / autoRotate / rotationSpeed / 角度制限などを必要に応じて調整。
 */
@ccclass('WeaponPivot')
export class WeaponPivot extends Component {

    @property({
        tooltip: '親ノードの中心から見た「支点(肩など)」までのオフセットX(ローカル座標)。\n単位: px。右が正、左が負。'
    })
    public pivotOffsetX: number = 0;

    @property({
        tooltip: '親ノードの中心から見た「支点(肩など)」までのオフセットY(ローカル座標)。\n単位: px。上が正、下が負。'
    })
    public pivotOffsetY: number = 0;

    @property({
        tooltip: '支点(肩など)から見た武器ノード原点までのオフセットX(ローカル座標)。\n単位: px。右が正、左が負。'
    })
    public weaponOffsetX: number = 50;

    @property({
        tooltip: '支点(肩など)から見た武器ノード原点までのオフセットY(ローカル座標)。\n単位: px。上が正、下が負。'
    })
    public weaponOffsetY: number = 0;

    @property({
        tooltip: '初期角度(度)。0度は右向き、反時計回りが正方向です。'
    })
    public initialAngle: number = 0;

    @property({
        tooltip: '有効にすると毎フレーム自動で回転します。'
    })
    public autoRotate: boolean = false;

    @property({
        tooltip: '自動回転時の角速度(度/秒)。正で反時計回り、負で時計回りに回転します。'
    })
    public rotationSpeed: number = 90;

    @property({
        tooltip: '有効にすると回転角度を minAngle ~ maxAngle に制限します。'
    })
    public useAngleLimit: boolean = false;

    @property({
        tooltip: '許可する最小角度(度)。0度は右向き、反時計回りが正方向です。'
    })
    public minAngle: number = -90;

    @property({
        tooltip: '許可する最大角度(度)。0度は右向き、反時計回りが正方向です。'
    })
    public maxAngle: number = 90;

    @property({
        tooltip: 'エディタ・実行中に支点位置などをログ出力します(簡易デバッグ用)。'
    })
    public debugDrawGizmos: boolean = false;

    /** 現在の角度(度)。インスペクタからは直接編集しない想定。 */
    private _currentAngle: number = 0;

    // 作業用ベクトル(GC削減のために使い回す)
    private _tempPivotLocal: Vec3 = new Vec3();
    private _tempWeaponOffsetLocal: Vec3 = new Vec3();
    private _tempRotatedOffset: Vec3 = new Vec3();

    onLoad() {
        // 親がいないと「親の中心からのオフセット」が定義できないため、警告を出す
        const parent = this.node.parent;
        if (!parent) {
            console.warn(
                '[WeaponPivot] 親ノードが存在しません。このコンポーネントは「親ノードの中心からのオフセット」で支点を定義するため、' +
                '必ず何らかの親ノード(キャラクターなど)の子として配置してください。',
                this.node.name
            );
        }

        // 初期角度を設定
        this._currentAngle = this.initialAngle;

        // 初期配置を反映
        this._updateTransform(0);

        if (this.debugDrawGizmos) {
            this._logDebugInfo('onLoad');
        }
    }

    start() {
        // start 時点でもう一度位置を確定させる(エディタ上で値変更後の反映用)
        this._updateTransform(0);
    }

    update(deltaTime: number) {
        // 自動回転が有効な場合のみ角度を進める
        if (this.autoRotate) {
            this._currentAngle += this.rotationSpeed * deltaTime;
        }

        // 角度制限
        if (this.useAngleLimit) {
            if (this.minAngle > this.maxAngle) {
                // ユーザー設定ミスに対する防御的実装
                const temp = this.minAngle;
                this.minAngle = this.maxAngle;
                this.maxAngle = temp;
                console.warn(
                    '[WeaponPivot] minAngle が maxAngle より大きかったため入れ替えました。',
                    `minAngle=${this.minAngle}, maxAngle=${this.maxAngle}`
                );
            }
            this._currentAngle = math.clamp(this._currentAngle, this.minAngle, this.maxAngle);
        }

        // 位置・回転を更新
        this._updateTransform(deltaTime);

        if (this.debugDrawGizmos) {
            // 頻繁に出すとうるさいので、必要に応じてコメントアウトして使う
            // this._logDebugInfo('update');
        }
    }

    /**
     * 角度を外部から設定したい場合に呼び出すためのメソッド。
     * (他スクリプト依存はしないが、将来拡張用に public とする)
     */
    public setAngle(angleDeg: number) {
        this._currentAngle = angleDeg;
        if (this.useAngleLimit) {
            this._currentAngle = math.clamp(this._currentAngle, this.minAngle, this.maxAngle);
        }
        this._updateTransform(0);
    }

    /**
     * 現在の角度を取得します(度)。
     */
    public getAngle(): number {
        return this._currentAngle;
    }

    /**
     * 実際にノードの位置・回転を更新する中核処理。
     */
    private _updateTransform(_deltaTime: number) {
        const parent = this.node.parent;
        if (!parent) {
            // 親がいない場合は、単純に自身の回転だけを適用する
            this.node.setRotationFromEuler(0, 0, this._currentAngle);
            return;
        }

        // 1. 親ノードのローカル座標系で「支点」の位置を計算
        this._tempPivotLocal.set(this.pivotOffsetX, this.pivotOffsetY, 0);

        // 2. 支点から見た武器ノード原点までのオフセット
        this._tempWeaponOffsetLocal.set(this.weaponOffsetX, this.weaponOffsetY, 0);

        // 3. オフセットを現在角度だけ回転させる
        //    角度は度 → ラジアンに変換して回転行列を適用
        const rad = math.toRadian(this._currentAngle);
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);

        const ox = this._tempWeaponOffsetLocal.x;
        const oy = this._tempWeaponOffsetLocal.y;

        // 2D 回転: (x', y') = (x*cos - y*sin, x*sin + y*cos)
        this._tempRotatedOffset.x = ox * cos - oy * sin;
        this._tempRotatedOffset.y = ox * sin + oy * cos;
        this._tempRotatedOffset.z = 0;

        // 4. 親ローカル座標系での武器ノード位置 = 支点 + 回転後オフセット
        const finalLocalPos = new Vec3(
            this._tempPivotLocal.x + this._tempRotatedOffset.x,
            this._tempPivotLocal.y + this._tempRotatedOffset.y,
            0
        );

        // 5. 計算した位置をノードに適用
        this.node.setPosition(finalLocalPos);

        // 6. 回転も適用(Z軸回転のみ)
        this.node.setRotationFromEuler(0, 0, this._currentAngle);
    }

    /**
     * デバッグ用に支点・武器位置などをログ出力する。
     */
    private _logDebugInfo(tag: string) {
        const parent = this.node.parent;
        if (!parent) {
            console.log(`[WeaponPivot][${tag}] parent is null. node=${this.node.name}`);
            return;
        }

        const pivotLocal = new Vec3(this.pivotOffsetX, this.pivotOffsetY, 0);
        const parentWorldPos = parent.worldPosition;
        const pivotWorld = new Vec3(
            parentWorldPos.x + pivotLocal.x,
            parentWorldPos.y + pivotLocal.y,
            parentWorldPos.z
        );

        console.log(
            `[WeaponPivot][${tag}] node=${this.node.name}, ` +
            `angle=${this._currentAngle.toFixed(2)}deg, ` +
            `pivotLocal=(${pivotLocal.x.toFixed(1)}, ${pivotLocal.y.toFixed(1)}), ` +
            `pivotWorld=(${pivotWorld.x.toFixed(1)}, ${pivotWorld.y.toFixed(1)}), ` +
            `weaponLocalPos=(${this.node.position.x.toFixed(1)}, ${this.node.position.y.toFixed(1)})`
        );
    }
}

主要な処理のポイント解説

  • onLoad
    • 親ノードが存在しない場合は警告ログを出します(支点オフセットは親基準のため)。
    • initialAngle を内部の _currentAngle にコピーし、初期位置・回転を反映します。
  • update
    • autoRotate が true のとき、rotationSpeed(度/秒)に従って角度を進めます。
    • useAngleLimit が true の場合、minAnglemaxAngle にクランプします。
    • その後、_updateTransform で位置と回転を更新します。
  • _updateTransform
    • 親がない場合は、単純に自身の回転だけを適用(防御的実装)。
    • 親ローカル座標での支点位置(pivotOffsetX/Y)を計算。
    • 支点から見た武器のオフセット(weaponOffsetX/Y)を現在角度だけ回転。
    • 支点 + 回転済みオフセット = 武器ノードのローカル位置。
    • 最後にノードの位置と回転(Z軸)をまとめて適用。
  • デバッグ機能
    • debugDrawGizmos が true のとき、支点位置や角度などをログ出力し、エディタ上での調整をしやすくします。

使用手順と動作確認

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

  1. Assets パネルで右クリックします。
  2. Create → TypeScript を選択します。
  3. 作成されたスクリプトに WeaponPivot.ts という名前を付けます。
  4. ダブルクリックしてエディタ(VS Code など)で開き、前述のコードをそのまま貼り付けて保存します。

2. テスト用シーンとノードの準備

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、キャラ本体用のノードを作成します。
    • 名前の例: PlayerBody
    • 任意のキャラクター画像を Sprite に設定しておくと分かりやすいです。
  2. PlayerBody を選択した状態で、Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、武器ノードを子として作成します。
    • 名前の例: Weapon
    • 剣・銃などの画像を Sprite に設定します。
    • このノードに WeaponPivot をアタッチします。

3. WeaponPivot コンポーネントのアタッチ

  1. Hierarchy で Weapon ノードを選択します。
  2. Inspector パネル下部の Add Component ボタンをクリックします。
  3. Custom カテゴリから WeaponPivot を選択し、アタッチします。

4. プロパティの設定例(肩を支点にして剣を振る)

ここでは、キャラクターの右肩を支点にして剣を振るような配置を例に、数値設定の一例を示します。

  1. PlayerBody の中心がキャラクターの体の真ん中にあると仮定します。
  2. キャラクター画像を見ながら、右肩の位置をざっくり測ります。
    • 例: 体の中心から右に 20px、上に 40px くらい → pivotOffsetX = 20, pivotOffsetY = 40
  3. 剣画像の原点(Sprite のアンカー)が画像中央にあるとします。
    • グリップが左端にある場合、支点(肩)とグリップを合わせたいので、
    • 支点から見て剣の原点は「グリップから右へ半分」 くらいの位置になります。
    • 例: 剣の幅が 100px なら、weaponOffsetX = 50, weaponOffsetY = 0 が目安。
  4. Inspector で Weapon ノードに設定:
    • pivotOffsetX: 20
    • pivotOffsetY: 40
    • weaponOffsetX: 50
    • weaponOffsetY: 0
    • initialAngle: 0(右向きから開始)
    • autoRotate: チェック ON
    • rotationSpeed: 90(1秒で 90度回転)
    • useAngleLimit: チェック ON
    • minAngle: -60
    • maxAngle: 60
    • debugDrawGizmos: 必要に応じて ON(ログで位置を確認できます)

この設定でゲームを再生すると、

  • 剣はキャラクターの右肩付近から生えているように見え、
  • 肩を中心に左右 60度の範囲で自動的に回転する(振り子のような)動きになります。

5. オフセット調整のコツ

  • pivotOffsetX/Y(支点の位置)
    • キャラクター画像の上にガイド線をイメージし、
      「親ノードの中心(0,0)」から「肩」までの距離を目測で入力していきます。
    • 再生しながら少しずつ値を変えて、肩と剣の根本がぴったり合うように調整します。
  • weaponOffsetX/Y(支点から武器原点まで)
    • 武器画像の原点位置(アンカー)を意識し、「支点にしたい部分(グリップなど)」から原点までの距離をオフセットとして入力します。
    • 支点がグリップで、原点が画像中央なら「画像の半分の幅」を X 方向に設定することが多いです。
  • 調整が難しい場合は、debugDrawGizmos を ON にしてログを見ながら微調整してください。

6. 自動回転をオフにして手動制御する

将来的に、マウス方向やゲームパッド入力に応じて武器を向けたい場合は、

  • autoRotate を OFF にする。
  • 別スクリプト(例: PlayerController など)から setAngle() を呼び出して角度を制御する。

本記事のコンポーネントは他スクリプトに依存しない設計ですが、setAngle / getAngle を公開しているため、将来の拡張にも対応しやすい構造になっています。


まとめ

本記事では、Cocos Creator 3.8 / TypeScript で、

  • 親の中心ではなく、肩など任意の支点を中心に武器スプライトを回転させる汎用コンポーネント WeaponPivot

を実装しました。

WeaponPivot の特徴:

  • 親ノードの中心からのオフセット(pivotOffsetX/Y)で支点位置を自由に指定可能。
  • 支点から武器原点までのオフセット(weaponOffsetX/Y)で武器の付き方を調整可能。
  • initialAngle, autoRotate, rotationSpeed, minAngle/maxAngle回転挙動を柔軟に設定。
  • 他のカスタムスクリプトに依存せず、この 1 ファイルだけで完結。
  • 将来的に setAngle を外部から呼ぶことで、マウス方向を向く武器ゲームパッド連動のエイムなどにも簡単に発展可能。

このコンポーネントをプロジェクトの「武器ノード」に標準装備しておけば、

  • キャラクターの差し替えや武器の差し替え時にも、オフセット値を少し調整するだけで再利用でき、
  • 「支点を中心に回す」処理を毎回書き直す必要がなくなるため、開発効率が大きく向上します。

まずは本記事の設定例をそのまま試し、肩の位置や武器の長さが違うキャラクター・武器でも、Inspector の数値だけ変えて再利用してみてください。支点回転の挙動を一度コンポーネント化しておくと、2Dアクションの武器表現が格段に楽になります。