【Cocos Creator 3.8】WobbleEffect(ぷるぷる)の実装:アタッチするだけでスプライトをゼリーのように揺らして「ぷるぷる」させる汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで、そのノードのスプライトをノイズ関数を使って歪ませ、ゼリーのような「ぷるぷる」質感を表現する汎用コンポーネント WobbleEffect を実装します。
攻撃待機中のキャラクター、UIボタンの待機アニメーション、スライム系モンスターの質感表現など、「何もしていない時間をちょっとリッチに見せたい」ときにアタッチするだけで使えるのが特徴です。
コンポーネントの設計方針
機能要件の整理
- このコンポーネントをアタッチしたノード(または指定したターゲットノード)の Sprite を「ぷるぷる」させる。
- 揺れは ノイズ関数(疑似乱数ベース) を時間で変化させて生成し、スケール・回転・位置を微妙に歪ませてゼリー感を出す。
- 完全に独立して動作し、他のカスタムスクリプトやシングルトンに依存しない。
- 必要なコンポーネント(
Sprite)は自動取得を試み、存在しない場合は エラーログを出して安全に無効化する。 - インスペクタから「揺れの強さ・速さ・方向・有効/無効」などを細かく調整できる。
- 実行中に一時停止/再開できるプロパティを用意する。
ノイズによる「ぷるぷる」のアプローチ
本格的な Perlin Noise 実装は長くなるため、ここでは以下の方針で「ノイズっぽい」動きを作ります:
- 時間
tに対して、いくつかの 異なる周波数のサイン波 を合成し、簡易的なノイズ関数として使う。 - 各成分に シード値 を与え、ノードごとにパターンが変わるようにする。
- ノイズ値を 位置・回転・スケール にマッピングすることで、ゼリーっぽい「ぷるぷる」感を出す。
@property で調整可能な項目
インスペクタで設定可能なプロパティと役割は以下の通りです。
enabledEffect: boolean
・エフェクトの有効/無効を切り替えるフラグ。
・false の場合、元の Transform(位置・回転・スケール)を維持し、処理をスキップ。targetNode: Node | null
・ぷるぷるさせたいノード。
・未指定(null)の場合は、このコンポーネントが付いているノード自身を対象とする。positionAmplitude: Vec2
・位置の揺れの強さ(ピクセル単位)。
・x: 左右の揺れ幅、y: 上下の揺れ幅。
・例: (3, 5) で、最大 ±3px 横揺れ、±5px 縦揺れ。rotationAmplitude: number
・回転の揺れの強さ(度数)。
・例: 5 で、最大 ±5° ほどぷるぷる回転する。scaleAmplitude: Vec2
・スケール変化量の最大値。
・x: 横方向、y: 縦方向の伸縮量(1.0 を基準とした加算)。
・例: (0.1, 0.15) で、横は 0.9〜1.1、縦は 0.85〜1.15 程度でぷるぷる。baseScale: Vec2
・「揺れの基準」となるスケール値。
・ここで指定した値を中心に揺れる。
・null にしておくと 開始時のスケールを自動的に基準として使用。frequency: number
・揺れの基本速度(周波数)。
・値が大きいほど、ぷるぷるが速くなる。
・例: 1.0〜3.0 あたりが扱いやすい。noiseIntensity: number
・ノイズの乱れ具合。
・0 に近いほど「規則的な揺れ」、1.0 以上で「ランダム感のある揺れ」。randomSeed: number
・ノイズパターンを決めるシード値。
・同じ値なら同じ揺れ方、値を変えると揺れのパターンが変わる。
・0 の場合は 自動的にランダムシードを生成。useUnscaledTime: boolean
・director.getDeltaTime()を使うか、game.deltaTimeを使うか、などの「タイムスケール」の影響を受けるかどうかを切り替える想定ですが、
・Cocos Creator 3.8 ではdirector.getDeltaTime()が標準的なため、ここでは 単純に on/off で時間進行を制御する用途に使用します。
・true の場合、内部的にtimeを 固定速度で進める(タイムスケールの影響を受けにくい)実装例を示します。
TypeScriptコードの実装
import { _decorator, Component, Node, Vec2, Vec3, Sprite, math, director, game } from 'cc';
const { ccclass, property } = _decorator;
/**
* WobbleEffect
* 対象ノードの Transform をノイズベースで揺らし、
* ゼリーのような「ぷるぷる」表現を行う汎用コンポーネント。
*/
@ccclass('WobbleEffect')
export class WobbleEffect extends Component {
@property({
tooltip: 'エフェクトの有効/無効を切り替えます。false の場合は Transform を変更しません。'
})
public enabledEffect: boolean = true;
@property({
type: Node,
tooltip: 'ぷるぷるさせたいターゲットノード。未指定の場合、このコンポーネントが付いているノード自身を対象とします。'
})
public targetNode: Node | null = null;
@property({
tooltip: '位置の揺れ幅(ピクセル単位)。x: 左右、y: 上下。'
})
public positionAmplitude: Vec2 = new Vec2(3, 5);
@property({
tooltip: '回転の揺れ幅(度数)。値が大きいほど大きく回転します。'
})
public rotationAmplitude: number = 5;
@property({
tooltip: 'スケール変化量の最大値。x: 横方向、y: 縦方向。例: (0.1, 0.15) で 0.9〜1.1 程度に揺れます。'
})
public scaleAmplitude: Vec2 = new Vec2(0.1, 0.15);
@property({
tooltip: '基準スケール。null の場合は開始時のスケールを基準として使用します。'
})
public baseScale: Vec2 | null = null;
@property({
tooltip: '揺れの基本速度(周波数)。値が大きいほど高速にぷるぷるします。'
})
public frequency: number = 1.5;
@property({
tooltip: 'ノイズの乱れ具合。0 に近いほど規則的、1.0 以上でランダム感が強くなります。'
})
public noiseIntensity: number = 1.0;
@property({
tooltip: 'ノイズパターンを決めるシード値。0 の場合は自動でランダムシードを生成します。'
})
public randomSeed: number = 0;
@property({
tooltip: 'true の場合、内部時間を一定速度で進め、タイムスケールの影響を受けにくくします。'
})
public useUnscaledTime: boolean = false;
// 内部状態
private _target: Node | null = null;
private _sprite: Sprite | null = null;
private _originalPosition: Vec3 = new Vec3();
private _originalRotation: Vec3 = new Vec3();
private _originalScale: Vec3 = new Vec3();
private _baseScaleInternal: Vec2 = new Vec2(1, 1);
private _time: number = 0;
private _seed: number = 1;
onLoad() {
// ターゲットノードの決定
this._target = this.targetNode ? this.targetNode : this.node;
if (!this._target) {
console.error('[WobbleEffect] ターゲットノードが取得できません。コンポーネントを正しくアタッチしてください。');
this.enabledEffect = false;
return;
}
// Sprite コンポーネントの取得(ぷるぷるさせる対象として推奨)
this._sprite = this._target.getComponent(Sprite);
if (!this._sprite) {
console.warn('[WobbleEffect] ターゲットノードに Sprite コンポーネントが見つかりません。' +
'スプライトでの視覚的なぷるぷる表現を行う場合は、Sprite を追加してください。' +
'(Transform の揺れ自体は動作します)');
}
// 元の Transform を保存
this._originalPosition = this._target.position.clone();
this._originalRotation = this._target.eulerAngles.clone();
this._originalScale = this._target.scale.clone();
// 基準スケールの決定
if (this.baseScale) {
this._baseScaleInternal.set(this.baseScale.x, this.baseScale.y);
} else {
this._baseScaleInternal.set(this._originalScale.x, this._originalScale.y);
}
// シード値の初期化
if (this.randomSeed !== 0) {
this._seed = this.randomSeed;
} else {
// ランダムシード(簡易)
this._seed = Math.floor(Math.random() * 100000) + 1;
}
this._time = 0;
}
start() {
// 特に処理は不要だが、将来の拡張用に空実装を残す
}
update(deltaTime: number) {
if (!this.enabledEffect || !this._target) {
return;
}
// 時間の進め方を決定
if (this.useUnscaledTime) {
// 固定速度で進める(deltaTime を使わず、一定値で加算)
this._time += 0.016; // 約 60FPS 相当
} else {
// 通常の deltaTime ベース
const dt = director.getDeltaTime ? director.getDeltaTime() : deltaTime;
this._time += dt;
}
const t = this._time * this.frequency;
// ノイズ値の計算(擬似的なノイズ関数)
const n1 = this._noise1D(t + this._seed * 0.13);
const n2 = this._noise1D(t * 0.9 + this._seed * 0.37);
const n3 = this._noise1D(t * 1.7 + this._seed * 0.73);
// 位置の揺れ
const offsetX = (n1 * 2 - 1) * this.positionAmplitude.x * this.noiseIntensity;
const offsetY = (n2 * 2 - 1) * this.positionAmplitude.y * this.noiseIntensity;
const newPos = new Vec3(
this._originalPosition.x + offsetX,
this._originalPosition.y + offsetY,
this._originalPosition.z
);
// 回転の揺れ(Z軸回転のみ)
const rotOffset = (n3 * 2 - 1) * this.rotationAmplitude * this.noiseIntensity;
const newRot = new Vec3(
this._originalRotation.x,
this._originalRotation.y,
this._originalRotation.z + rotOffset
);
// スケールの揺れ(x と y を別ノイズで揺らす)
const n4 = this._noise1D(t * 1.3 + this._seed * 1.11);
const n5 = this._noise1D(t * 1.9 + this._seed * 2.17);
const scaleX = this._baseScaleInternal.x + (n4 * 2 - 1) * this.scaleAmplitude.x * this.noiseIntensity;
const scaleY = this._baseScaleInternal.y + (n5 * 2 - 1) * this.scaleAmplitude.y * this.noiseIntensity;
const newScale = new Vec3(
scaleX,
scaleY,
this._originalScale.z
);
// Transform の適用
this._target.setPosition(newPos);
this._target.setRotationFromEuler(newRot);
this._target.setScale(newScale);
}
onDisable() {
// 無効化時に Transform を元に戻す
if (this._target) {
this._target.setPosition(this._originalPosition);
this._target.setRotationFromEuler(this._originalRotation);
this._target.setScale(this._originalScale);
}
}
onDestroy() {
// 破棄時にも Transform を元に戻しておく(エディタ上の意図しない変形を避ける)
if (this._target) {
this._target.setPosition(this._originalPosition);
this._target.setRotationFromEuler(this._originalRotation);
this._target.setScale(this._originalScale);
}
}
/**
* 1次元の簡易ノイズ関数
* - いくつかの周波数のサイン波を合成して 0〜1 の値を返す。
* - Perlin Noise ほど本格的ではないが、揺れ表現には十分。
*/
private _noise1D(x: number): number {
// 周波数と重みを変えたサイン波を合成
const v1 = Math.sin(x);
const v2 = Math.sin(x * 2.3 + 1.7);
const v3 = Math.sin(x * 3.7 + 0.5);
// 正規化して 0〜1 に
const sum = v1 * 0.5 + v2 * 0.3 + v3 * 0.2;
const normalized = (sum + 1) * 0.5; // -1〜1 を 0〜1 に変換
return normalized;
}
}
主要メソッドの解説
onLoad()targetNodeが指定されていればそれを、なければthis.nodeをターゲットとします。- ターゲットから
Spriteを取得し、見つからなければconsole.warnで警告を出します(Transform の揺れ自体は動作)。 - ターゲットの 元の位置・回転・スケール を保存します。
baseScaleが指定されていればそれを、なければ元のスケールを基準スケールとして保持します。randomSeedが 0 の場合はランダムなシード値を生成します。
update(deltaTime: number)enabledEffectが false またはターゲットが無効な場合は何もしません。useUnscaledTimeに応じて_timeを進めます。- 内部ノイズ関数
_noise1D()を使って、時間に応じた ノイズ値 n1〜n5 を生成します。 - それぞれのノイズ値を 位置・回転・スケール にマッピングし、新しい Transform を計算してターゲットに適用します。
onDisable(),onDestroy()- コンポーネントが無効化/破棄される際に、ターゲットの Transform を 元の状態に戻すことで、エディタ上での「変な位置・スケールで固定されてしまう」問題を防ぎます。
_noise1D(x: number)- 1次元の簡易ノイズ関数です。
- 複数のサイン波を合成し、結果を 0〜1 の範囲に正規化した値を返します。
- Perlin Noise などに比べて実装が軽く、ぷるぷるのような用途には十分なランダム感があります。
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリックし、Create → TypeScript を選択します。
- ファイル名を
WobbleEffect.tsとして作成します。 - 自動生成されたテンプレートコードをすべて削除し、前述の TypeScript コードを丸ごと貼り付けて保存します。
2. テスト用ノードの作成
ここでは、単純なスプライトをぷるぷるさせる例を説明します。
- Hierarchy パネルで右クリックし、Create → 2D Object → Sprite を選択します。
- 作成された Sprite ノードを選択し、Inspector の Sprite コンポーネントで任意の画像(例: スライムの画像やボタン画像)を設定します。
- シーン上で見やすい位置に移動・スケールを調整しておきます。
3. WobbleEffect のアタッチ
- ぷるぷるさせたいノード(先ほど作成した Sprite ノード)を選択します。
- Inspector 下部の Add Component ボタンをクリックします。
- メニューから Custom → WobbleEffect を選択して追加します。
- 追加された WobbleEffect コンポーネントの各プロパティを設定します。
4. プロパティ設定の例
まずは分かりやすい「ぷるぷる」設定例を紹介します。
- enabledEffect: チェックを入れる(true)。
- targetNode: 空欄のまま(自分自身をターゲットにする)。
- positionAmplitude:
(3, 5) - rotationAmplitude:
4 - scaleAmplitude:
(0.08, 0.12) - baseScale: 空欄(null のまま。現在のスケールを基準にする)。
- frequency:
1.8 - noiseIntensity:
1.0 - randomSeed:
0(ランダムで OK。特定パターンを再現したい場合のみ数値を入れる)。 - useUnscaledTime: チェックなし(false)。
5. 再生して動作確認
- エディタ上部の ▶(Play)ボタン をクリックしてゲームを再生します。
- シーン上の Sprite が、その場で ぷるぷると揺れているのを確認します。
- もし揺れが弱い/強すぎる場合は、ゲームを一旦停止してから以下を微調整します:
- もっと大きく揺らしたい →
positionAmplitudeとscaleAmplitude、rotationAmplitudeを少しずつ増やす。 - 動きが速すぎる/遅すぎる →
frequencyを調整(1.0〜3.0 の範囲で試す)。 - もっとランダム感がほしい →
noiseIntensityを 1.2〜1.5 くらいまで上げる。 - 規則正しく揺れてほしい →
noiseIntensityを 0.5〜0.8 くらいまで下げる。
- もっと大きく揺らしたい →
6. 複数ノードへの適用とパターンの変化
- 同じ
WobbleEffectを 複数のノードにアタッチすると、それぞれが独立したシード値で揺れるため、同じタイミングで同じ動きになりにくいようになっています。 - もし「全員まったく同じ揺れ方をしてほしい」場合は、
randomSeedに同じ値(例: 12345)を設定してください。 - 逆に「特定のキャラだけ揺れ方を変えたい」場合は、そのキャラの
randomSeedに別の数値を入れると、揺れパターンが変わります。
7. 別ノードをターゲットにする例
UI ボタンの「親ノード」に WobbleEffect を付けて、中のアイコンだけをぷるぷるさせたい場合などは、以下のように設定します。
- 親ノードに WobbleEffect をアタッチします。
targetNodeプロパティに、ぷるぷるさせたい 子ノード(アイコンの Sprite ノード) をドラッグ&ドロップします。- これにより、親ノードは固定されたまま、子ノードだけがぷるぷる揺れるようになります。
まとめ
この WobbleEffect コンポーネントは、
- アタッチするだけで ゼリーのような「ぷるぷる」表現を付与できる。
- 位置・回転・スケールの揺れを ノイズベースで生成するため、単純な往復アニメーションよりも 自然でランダム感のある動きを実現できる。
- 外部スクリプトに一切依存せず、必要な設定はすべてインスペクタから行える。
- Sprite が無い場合も Transform の揺れだけは動作し、エラーログ/警告で状況が分かるようになっている。
キャラクターの待機モーション、スライム系オブジェクト、UI ボタンのホバー演出など、「とりあえず動かしておきたい」ノードにポン付けできるのが最大の利点です。
このコンポーネント単体で完結しているため、プロジェクト内のどのシーン・どのノードにも コピペ&アタッチで簡単に再利用できます。
必要に応じて、
- 特定の入力(クリック/タップ)時だけ揺らす
- ダメージを受けた瞬間だけ揺れを強くする
- 時間経過で揺れが弱まるフェードアウト機能を追加する
といった拡張も、同じ設計方針(外部依存なし・@property ベース)で実装しやすいはずです。
まずは今回の WobbleEffect をプロジェクトに組み込んで、「待機中のぷるぷる」を量産してみてください。




