【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を更新し、_currentOffsetをlerpで追従させます。
– 最後に_initialLocalPos + _currentOffsetをノードの位置として適用します。 - マウスイベント
–input.on(Input.EventType.MOUSE_MOVE, ...)でマウス移動イベントを購読します。
–EventMouse.getDeltaX()/getDeltaY()でフレームごとの移動量を取得し、_mouseDeltaに蓄積します。 - 追従と減衰
– マウスが動いている間は、感度と反転フラグを考慮して_targetOffsetを更新し、最大オフセットでクランプします。
– マウスが止まったフレームでは、_targetOffsetをVec3.ZEROに向かってreturnSpeedで補間し、自然に中央へ戻します。 - useDeltaTime
–followLerpとreturnSpeedをdtと掛け合わせることで、フレームレートに依存しにくい動きになります。
– 好みによってfalseにして「固定 60fps 前提」のような感覚にすることもできます。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ上部メニュー、または Assets パネルで任意のフォルダを選択します。
例:assets/scriptsフォルダ。 - 選択したフォルダ上で右クリック → Create → TypeScript を選択します。
- 作成されたスクリプトの名前を
WeaponSway.tsに変更します。 - ダブルクリックしてエディタ(VS Code など)で開き、先ほどの TypeScript コードを丸ごと貼り付けて保存します。
2. テスト用の武器ノードを用意する
ここでは簡単のため、2D プロジェクトの Canvas の子に配置した Sprite を「武器」と見立ててテストします。3D プロジェクトでも同じ手順で、任意の 3D モデルノードにアタッチすれば動きます。
- Hierarchy パネルで右クリック → Create → UI → Sprite を選択します。
(3D なら Create → 3D Object → Cube などでも可) - 作成されたノードの名前を
Weaponなどに変更しておきます。 - Scene ビュー上で、画面右下あたりに見えるように位置を調整しておきます。
例: X = 200, Y = -150 など。
3. WeaponSway コンポーネントをアタッチする
- Hierarchy パネルで先ほどの
Weaponノードを選択します。 - Inspector パネルの下部にある Add Component ボタンをクリックします。
- 表示されたメニューから Custom → WeaponSway を選択します。
(Custom カテゴリの中に@ccclass('WeaponSway')として表示されます)
4. プロパティの設定例
Inspector で、WeaponSway コンポーネントに以下のような値を設定してみてください。
Enabled Sway: チェック ONSensitivity:0.003(まずはデフォルトのまま)Max Offset X:0.3Max Offset Y:0.2Follow Lerp:10Return Speed:4Invert X: 好みに応じて ON/OFF(FPSらしいのは ON に感じるケースが多いです)Invert Y: 好みに応じて ON/OFFUse Delta Time: チェック ON
5. 再生して動作を確認する
- エディタ上部の Play ボタン(▶)をクリックしてゲームを実行します。
- Game ビュー上で、マウスカーソルを左右・上下にゆっくり動かしてみてください。
- 武器ノードが、マウスの移動方向に対して少し遅れてフワッと追従するはずです。
- 動きが弱すぎる場合は
Sensitivityを 0.005 〜 0.01 などに上げてみてください。 - 動きが速すぎて「ビクビク」する場合は
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 演出のベースが一気に整います。
