【Cocos Creator 3.8】WeaponSway の実装:アタッチするだけで「マウス移動に応じて武器モデルが少し遅れて追従するFPS風揺れ演出」を実現する汎用スクリプト

FPSゲームでよく見る「マウスを動かすと、画面右下の武器モデルが少し遅れてフワッとついてくる」あの演出を、1つのコンポーネントをアタッチするだけで実現できるようにします。

この WeaponSway コンポーネントは、他のゲーム用マネージャやシングルトンに依存せず、武器モデルのノードに直接アタッチするだけで動きます。パラメータはすべてインスペクタから調整できるため、デザイナーやレベルデザイナーでも感覚的にチューニングできます。


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

実現したい挙動

  • マウスを左右・上下に動かしたときに、その動き量に応じて武器ノードを少しだけオフセットさせる。
  • 武器は遅れて追従するような「追従感(ラグ)」を持つ。
  • 動きすぎないようにオフセット量に上限を設ける。
  • マウスを止めたら、自然に元の位置へ戻る。
  • 外部スクリプト非依存(完全な独立コンポーネント)。

設計アプローチ

  • マウスの移動量(Δx, Δy)を毎フレーム取得し、それをもとに「目標オフセット」を計算する。
  • 武器ノードのローカル位置を「初期位置 + 現在のオフセット」として更新する。
  • 現在のオフセットは、目標オフセットへ向けて補間(lerp)することで「遅れて追従」させる。
  • オフセットには上限(最大距離)を設けて、極端なマウス速度でも破綻しないようにする。
  • 「マウスが止まったときに元に戻る」ように、目標オフセットを適度に減衰させる。
  • エディタ上で有効/無効切り替えや強さの調整ができるよう、すべてを @property で公開する。

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

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

  • enabledSway: boolean
    – 武器揺れの有効/無効を切り替えるフラグ。
    true: マウス移動に応じて揺れる。
    false: スクリプトはアタッチされているが揺れ処理は行わない。
  • sensitivity: number
    – マウス移動量をオフセット量に変換する係数(感度)。
    – 値が大きいほど、同じマウス移動でも武器の揺れが大きくなる。
    – 例: 0.001 ~ 0.01 くらいから調整開始。
  • maxOffsetX: number
    – X方向(左右)のオフセットの最大値(ローカル座標単位)。
    – 例: 0.1 ~ 0.5 程度。
  • maxOffsetY: number
    – Y方向(上下)のオフセットの最大値(ローカル座標単位)。
    – 例: 0.1 ~ 0.5 程度。
  • followLerp: number
    – 武器が「目標オフセット」に追従する速さ。
    – 0 ~ 1 の範囲で、1 に近いほどキビキビ、0.1 くらいだとヌルっと遅れてついてくる。
    – 実際は dt と組み合わせて使うため 5 ~ 20 程度の値を想定し、Math.min でクランプする。
  • returnSpeed: number
    – マウスが止まった際に、目標オフセットを 0(元の位置)に戻す速さ。
    – 値が大きいほどすぐ中央に戻る。
    – 例: 1 ~ 10 程度。
  • invertX: boolean
    – X方向揺れの反転フラグ。
    – FPSによって「マウスを右に動かすと武器が画面右に寄る/左に寄る」の好みが異なるため、簡単に切り替えられるようにする。
  • invertY: boolean
    – Y方向揺れの反転フラグ。
    – 「マウスを上に動かすと武器が上に寄る/下に寄る」を切り替えられる。
  • useDeltaTime: boolean
    – 補間計算に dt をかけるかどうか。
    – true: フレームレートに依存しにくい動きになる。
    – false: 単純な線形補間(テストや好みで切り替え)。

また、マウス入力は Cocos の標準 API(input.on)のみを使用し、他のカスタムクラスには依存しません。


TypeScriptコードの実装

以下が完成した WeaponSway コンポーネントの全コードです。


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

/**
 * WeaponSway
 * FPS武器モデルを、マウス移動に応じて少し遅れて追従させる汎用コンポーネント。
 * - 他スクリプト非依存
 * - このコンポーネントを武器ノードにアタッチするだけで動作
 */
@ccclass('WeaponSway')
export class WeaponSway extends Component {

    // ===== インスペクタ設定用プロパティ =====

    @property({
        tooltip: '武器揺れを有効にするかどうか。\nfalse にすると処理をスキップします。'
    })
    public enabledSway: boolean = true;

    @property({
        tooltip: 'マウス移動量を揺れ量に変換する感度。\n値が大きいほど大きく揺れます。'
    })
    public sensitivity: number = 0.003;

    @property({
        tooltip: 'X方向(左右)の最大オフセット量(ローカル座標単位)。'
    })
    public maxOffsetX: number = 0.3;

    @property({
        tooltip: 'Y方向(上下)の最大オフセット量(ローカル座標単位)。'
    })
    public maxOffsetY: number = 0.2;

    @property({
        tooltip: '武器が目標オフセットへ追従する速さ。\n大きいほどキビキビ動きます。'
    })
    public followLerp: number = 10.0;

    @property({
        tooltip: 'マウスが止まった時に、目標オフセットを 0 に戻す速さ。'
    })
    public returnSpeed: number = 4.0;

    @property({
        tooltip: 'X方向の揺れを反転するかどうか。'
    })
    public invertX: boolean = false;

    @property({
        tooltip: 'Y方向の揺れを反転するかどうか。'
    })
    public invertY: boolean = false;

    @property({
        tooltip: '補間計算に deltaTime を使用するかどうか。\ntrue 推奨。'
    })
    public useDeltaTime: boolean = true;

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

    /** 初期ローカル位置(武器の基準位置) */
    private _initialLocalPos: Vec3 = new Vec3();

    /** 現在のオフセット(ローカル座標) */
    private _currentOffset: Vec3 = new Vec3();

    /** 目標オフセット(ローカル座標) */
    private _targetOffset: Vec3 = new Vec3();

    /** フレームごとのマウス移動量 */
    private _mouseDelta: Vec2 = new Vec2();

    /** マウスイベントリスナー登録済みかどうか */
    private _mouseListening: boolean = false;

    // ===== ライフサイクル =====

    onLoad () {
        // 初期ローカル位置を保存
        this.node.getPosition(this._initialLocalPos);

        // 念のため maxOffset が負にならないように防御的に補正
        this.maxOffsetX = Math.abs(this.maxOffsetX);
        this.maxOffsetY = Math.abs(this.maxOffsetY);

        // マウスイベントの登録
        this._registerMouseEvents();
    }

    start () {
        // 特別な初期化は不要だが、ここで現在位置を再確認しても良い
        this.node.getPosition(this._initialLocalPos);
    }

    update (dt: number) {
        if (!this.enabledSway) {
            // 無効化されている場合は、元の位置に戻して何もしない
            this._resetPositionInstant();
            return;
        }

        // マウス移動量から目標オフセットを更新
        this._updateTargetOffsetFromMouse(dt);

        // 現在のオフセットを目標オフセットへ補間
        this._updateCurrentOffset(dt);

        // 実際のノード位置を更新
        this._applyOffsetToNode();
    }

    onEnable () {
        // 有効化されたときにマウスイベントを再登録
        this._registerMouseEvents();
    }

    onDisable () {
        // 無効化されたときはイベントを解除し、位置をリセット
        this._unregisterMouseEvents();
        this._resetPositionInstant();
    }

    onDestroy () {
        // ノード破棄時もイベントを解除
        this._unregisterMouseEvents();
    }

    // ===== マウスイベント関連 =====

    private _registerMouseEvents () {
        if (this._mouseListening) {
            return;
        }
        input.on(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
        this._mouseListening = true;
    }

    private _unregisterMouseEvents () {
        if (!this._mouseListening) {
            return;
        }
        input.off(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
        this._mouseListening = false;
    }

    private _onMouseMove (event: EventMouse) {
        // フレームごとのマウス移動量を取得
        const dx = event.getDeltaX();
        const dy = event.getDeltaY();
        this._mouseDelta.set(dx, dy);
    }

    // ===== オフセット計算 =====

    /**
     * マウス移動量から目標オフセットを更新する
     */
    private _updateTargetOffsetFromMouse (dt: number) {
        // マウス移動がない場合は、目標オフセットを 0 方向へ戻す
        if (this._mouseDelta.x === 0 && this._mouseDelta.y === 0) {
            // 徐々に (0,0,0) に近づける
            const factor = this.useDeltaTime ? math.clamp01(this.returnSpeed * dt) : math.clamp01(this.returnSpeed * 0.016);
            this._targetOffset = Vec3.lerp(this._targetOffset, this._targetOffset, Vec3.ZERO, factor);
            return;
        }

        // マウス移動量を元にオフセットを更新
        let offsetX = this._targetOffset.x;
        let offsetY = this._targetOffset.y;

        const signX = this.invertX ? -1 : 1;
        const signY = this.invertY ? -1 : 1;

        offsetX += this._mouseDelta.x * this.sensitivity * signX;
        offsetY += this._mouseDelta.y * this.sensitivity * signY;

        // クランプ(最大オフセットを超えないようにする)
        offsetX = math.clamp(offsetX, -this.maxOffsetX, this.maxOffsetX);
        offsetY = math.clamp(offsetY, -this.maxOffsetY, this.maxOffsetY);

        this._targetOffset.set(offsetX, offsetY, 0);

        // このフレームのマウス移動は使い切ったのでリセット
        this._mouseDelta.set(0, 0);
    }

    /**
     * 現在のオフセットを目標オフセットへ補間する
     */
    private _updateCurrentOffset (dt: number) {
        const factor = this.useDeltaTime ? math.clamp01(this.followLerp * dt) : math.clamp01(this.followLerp * 0.016);
        Vec3.lerp(this._currentOffset, this._currentOffset, this._targetOffset, factor);
    }

    /**
     * オフセットをノードのローカル位置に反映する
     */
    private _applyOffsetToNode () {
        const newPos = new Vec3(
            this._initialLocalPos.x + this._currentOffset.x,
            this._initialLocalPos.y + this._currentOffset.y,
            this._initialLocalPos.z + this._currentOffset.z
        );
        this.node.setPosition(newPos);
    }

    /**
     * 即座に位置を初期ローカル位置へ戻す
     */
    private _resetPositionInstant () {
        this._currentOffset.set(0, 0, 0);
        this._targetOffset.set(0, 0, 0);
        this.node.setPosition(this._initialLocalPos);
    }
}

コードのポイント解説

  • onLoad
    – 武器ノードの「初期ローカル位置」を _initialLocalPos に保存します。
    – 最大オフセット値を絶対値に補正し、マウスイベントを登録します。
  • update(dt)
    enabledSway が false のときは即座に初期位置へ戻して処理を終了。
    – マウス移動量から _targetOffset を更新し、_currentOffsetlerp で追従させます。
    – 最後に _initialLocalPos + _currentOffset をノードの位置として適用します。
  • マウスイベント
    input.on(Input.EventType.MOUSE_MOVE, ...) でマウス移動イベントを購読します。
    EventMouse.getDeltaX()/getDeltaY() でフレームごとの移動量を取得し、_mouseDelta に蓄積します。
  • 追従と減衰
    – マウスが動いている間は、感度と反転フラグを考慮して _targetOffset を更新し、最大オフセットでクランプします。
    – マウスが止まったフレームでは、_targetOffsetVec3.ZERO に向かって returnSpeed で補間し、自然に中央へ戻します。
  • useDeltaTime
    followLerpreturnSpeeddt と掛け合わせることで、フレームレートに依存しにくい動きになります。
    – 好みによって false にして「固定 60fps 前提」のような感覚にすることもできます。

使用手順と動作確認

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

  1. エディタ上部メニュー、または Assets パネルで任意のフォルダを選択します。
    例: assets/scripts フォルダ。
  2. 選択したフォルダ上で右クリック → CreateTypeScript を選択します。
  3. 作成されたスクリプトの名前を WeaponSway.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、先ほどの TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用の武器ノードを用意する

ここでは簡単のため、2D プロジェクトの Canvas の子に配置した Sprite を「武器」と見立ててテストします。3D プロジェクトでも同じ手順で、任意の 3D モデルノードにアタッチすれば動きます。

  1. Hierarchy パネルで右クリック → CreateUISprite を選択します。
    (3D なら Create3D ObjectCube などでも可)
  2. 作成されたノードの名前を Weapon などに変更しておきます。
  3. Scene ビュー上で、画面右下あたりに見えるように位置を調整しておきます。
    例: X = 200, Y = -150 など。

3. WeaponSway コンポーネントをアタッチする

  1. Hierarchy パネルで先ほどの Weapon ノードを選択します。
  2. Inspector パネルの下部にある Add Component ボタンをクリックします。
  3. 表示されたメニューから CustomWeaponSway を選択します。
    (Custom カテゴリの中に @ccclass('WeaponSway') として表示されます)

4. プロパティの設定例

Inspector で、WeaponSway コンポーネントに以下のような値を設定してみてください。

  • Enabled Sway: チェック ON
  • Sensitivity: 0.003(まずはデフォルトのまま)
  • Max Offset X: 0.3
  • Max Offset Y: 0.2
  • Follow Lerp: 10
  • Return Speed: 4
  • Invert X: 好みに応じて ON/OFF(FPSらしいのは ON に感じるケースが多いです)
  • Invert Y: 好みに応じて ON/OFF
  • Use Delta Time: チェック ON

5. 再生して動作を確認する

  1. エディタ上部の Play ボタン(▶)をクリックしてゲームを実行します。
  2. Game ビュー上で、マウスカーソルを左右・上下にゆっくり動かしてみてください。
  3. 武器ノードが、マウスの移動方向に対して少し遅れてフワッと追従するはずです。
  4. 動きが弱すぎる場合は Sensitivity を 0.005 〜 0.01 などに上げてみてください。
  5. 動きが速すぎて「ビクビク」する場合は Follow Lerp を 6〜8 程度に下げるか、Return Speed を少し下げてみてください。

6. よくある調整パターン

  • ふわっと重い武器感を出したい
    Sensitivity: やや小さめ(0.002〜0.004)
    Follow Lerp: 6〜8
    Return Speed: 3〜4
  • 軽くてキビキビした銃を表現したい
    Sensitivity: 標準〜やや大きめ(0.004〜0.007)
    Follow Lerp: 12〜18
    Return Speed: 6〜10

まとめ

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

  • マウス入力を直接購読して、
  • 武器ノードのローカル位置を「初期位置+オフセット」で制御し、
  • 追従速度や戻り速度、最大オフセット、反転などをすべてインスペクタから調整可能にした、
  • 完全に独立した汎用コンポーネント

として設計されています。

ゲーム全体の GameManager やプレイヤー制御スクリプトに一切依存しないため、

  • 任意のシーン・任意の武器ノードにポン付けで使える
  • プロジェクト間のコピペや再利用が非常に簡単
  • デザイナーがパラメータだけ触って演出を詰められる

というメリットがあります。

応用としては、

  • カメラの微妙な揺れ(ヘッドボブ)に転用する
  • UI 要素(照準やカーソル)をマウスに遅れて追従させる演出に使う
  • 3D モデルの「肩」や「頭」に適用して、視点移動に合わせた揺れを表現する

といった使い方もできます。
プロジェクトの assets/scripts/common などに置いておき、武器系のノードにはとりあえずアタッチしておく「標準装備コンポーネント」として運用すると、FPS 演出のベースが一気に整います。