【Cocos Creator 3.8】WindTunnel の実装:アタッチするだけで「指定エリア内の Rigidbody2D に一定方向の風力を与え続ける」汎用スクリプト
このガイドでは、指定したエリア(Collider2D)内に入っている物体に対して、一定方向の力をフレームごとに加え続ける「風の通り道」コンポーネントを実装します。
アタッチ先のノードに Collider2D(例: BoxCollider2D) を付けておけば、WindTunnel を追加するだけで、風エリアとして機能します。
コンポーネントの設計方針
実現したい機能の要件
- ノードにアタッチされた Collider2D の範囲を「風の通り道」として扱う。
- その範囲内に入っている Rigidbody2D を持つノードに対し、毎フレーム一定方向の力(風力)を加え続ける。
- 風の方向・強さ・適用方法などは、すべて インスペクタから調整可能にする。
- 外部の GameManager やシングルトンには依存せず、このスクリプト単体で完結させる。
- 必要なコンポーネント(Collider2D)が無い場合は、エラーログで知らせる防御的実装にする。
設計アプローチ
- Collider2D のトリガーイベントを使い、エリアに入っている Rigidbody2D を追跡する。
- エリア内の Rigidbody2D を Set(集合)で管理し、
update()で毎フレーム全てに力を加える。 - 風の方向は ノードのローカル回転に依存させるか、任意のベクトルで指定できるようにする。
- 力の加え方は、Force / Impulse / VelocityChange など
RigidBody2DのapplyForceToCenter/applyLinearImpulseToCenterを使って切り替え可能にする。
インスペクタで設定可能なプロパティ
WindTunnel コンポーネントのプロパティ設計は以下の通りです。
- enabledWind: boolean
・風の有効 / 無効を切り替えます。
・trueのときだけ力を加えます。 - useNodeDirection: boolean
・true: ノードの向き(ローカルの +X 方向)を風向きとして使用。
・false:customDirectionプロパティで指定したベクトルを風向きとして使用。 - customDirection: Vec2
・useNodeDirection = falseのときに使われる任意の方向ベクトル。
・例: (1, 0) で右方向、(0, 1) で上方向。
・内部で正規化されるため、長さは気にせず「向き」だけ指定してOK。 - strength: number
・風力の強さ。数値が大きいほど強い風になります。
・単位は適用モードに依存しますが、目安として10 ~ 500程度から調整していくとよいです。 - mode: WindForceMode(enum)
・風の加え方を選びます。
・ContinuousForce: 毎フレームapplyForceToCenterで力を加える(質量に応じた加速)。
・Impulse: 毎フレームapplyLinearImpulseToCenterを呼ぶ(瞬間的な衝撃の連続)。
・VelocityChange: Rigidbody2D のlinearVelocityを直接変更し、一定速度に近づける(空気抵抗風)。 - maxAffectedBodies: number
・同時に影響を与える Rigidbody2D の最大数。
・0 以下の場合は無制限。
・大量のオブジェクトが入る可能性がある場合の負荷制御用。 - debugDrawDirection: boolean
・trueの場合、Scene ビューで風の向きを示す簡易的な矢印をGizmos風に描画します(editorOnly的な用途)。
・ゲーム実行には影響しません。 - debugColor: Color
・デバッグ矢印の色。
また、内部的に以下の標準コンポーネントを利用します。
- Collider2D
・このノードにアタッチされている必要があります。
・isTrigger = true推奨(物理的な衝突ではなく、エリア検出のみ)。
・存在しない場合はonLoad()でエラーログを出力します。
TypeScriptコードの実装
以下が WindTunnel コンポーネントの完全な実装コードです。
import { _decorator, Component, Node, Vec2, Vec3, RigidBody2D, Collider2D, IPhysics2DContact, Contact2DType, Color, Graphics, director } from 'cc';
const { ccclass, property } = _decorator;
/**
* 風の力の適用モード
*/
enum WindForceMode {
ContinuousForce = 0,
Impulse = 1,
VelocityChange = 2,
}
@ccclass('WindTunnel')
export class WindTunnel extends Component {
@property({
tooltip: '風の効果を有効にするかどうか。\nチェックを外すと一時的に風を止められます。',
})
public enabledWind: boolean = true;
@property({
tooltip: 'ノードの向き(ローカル+X方向)を風向きとして使用するか。\nオン: ノードの回転で風向きを調整。\nオフ: customDirection プロパティの値を使用。',
})
public useNodeDirection: boolean = true;
@property({
tooltip: 'useNodeDirection がオフのときに使用される任意の風向きベクトル。\n例: (1,0)=右, (0,1)=上。内部で正規化されます。',
})
public customDirection: Vec2 = new Vec2(1, 0);
@property({
tooltip: '風力の強さ。\n数値が大きいほど強い風になります。\nContinuousForce: 力の大きさ\nImpulse: 衝撃の大きさ\nVelocityChange: 目標速度の大きさ',
min: 0,
})
public strength: number = 50;
@property({
tooltip: '風の力の適用モードを選択します。\nContinuousForce: 毎フレーム力を加える(加速度的)。\nImpulse: 毎フレーム衝撃を加える(跳ね飛ばすような風)。\nVelocityChange: 一定速度に近づける(空気抵抗やベルトコンベア風)。',
type: Number,
})
public mode: WindForceMode = WindForceMode.ContinuousForce;
@property({
tooltip: '同時に影響を与える Rigidbody2D の最大数。\n0 以下の場合は無制限。\n大量のオブジェクトがエリアに入る可能性がある場合に負荷を抑えたいときに使用します。',
min: 0,
})
public maxAffectedBodies: number = 0;
@property({
tooltip: 'Scene ビュー上で風向きを示す矢印を描画するかどうか(デバッグ用)。\nゲーム実行には影響しません。',
})
public debugDrawDirection: boolean = true;
@property({
tooltip: 'デバッグ用の矢印の色。',
})
public debugColor: Color = new Color(0, 200, 255, 255);
/** エリア内に存在する Rigidbody2D の集合 */
private _bodiesInArea: Set<RigidBody2D> = new Set<RigidBody2D>();
/** このノードに付いている Collider2D */
private _collider: Collider2D | null = null;
/** デバッグ描画用 Graphics(エディタ再生時のみ) */
private _debugGraphics: Graphics | null = null;
onLoad() {
// Collider2D を取得
this._collider = this.getComponent(Collider2D);
if (!this._collider) {
console.error('[WindTunnel] Collider2D が見つかりません。このノードに BoxCollider2D などの Collider2D を追加してください。', this.node);
return;
}
// トリガーイベントを購読
this._collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
this._collider.on(Contact2DType.END_CONTACT, this._onEndContact, this);
// 可能なら isTrigger を true にして、物理的な衝突ではなくエリア判定だけにする
if (!this._collider.sensor) {
// sensor が false なら、風エリアとして使う場合は true にするのが一般的
// ただし、ここでは自動変更はせず、警告だけ出す。
console.warn('[WindTunnel] Collider2D.sensor が false です。風エリアとして使う場合は、Inspector で「Sensor (Trigger)」をオンにすることを推奨します。', this.node);
}
// デバッグ描画用 Graphics の準備(再生時のみ)
if (this.debugDrawDirection && director.getScene()) {
const debugNode = new Node('WindTunnelDebug');
debugNode.layer = this.node.layer;
debugNode.parent = this.node;
this._debugGraphics = debugNode.addComponent(Graphics);
this._debugGraphics.lineWidth = 2;
this._debugGraphics.strokeColor = this.debugColor;
}
}
onDestroy() {
if (this._collider) {
this._collider.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
this._collider.off(Contact2DType.END_CONTACT, this._onEndContact, this);
}
this._bodiesInArea.clear();
}
update(deltaTime: number) {
if (!this.enabledWind) {
return;
}
if (!this._collider) {
return;
}
// 風向きベクトルを計算(正規化)
const dir = this._getWindDirection();
if (dir.lengthSqr() === 0) {
// 方向がゼロの場合は何もしない
return;
}
// エリア内の Rigidbody2D へ力を適用
let processed = 0;
for (const body of this._bodiesInArea) {
if (!body || !body.isValid) {
continue;
}
// 最大数制限
if (this.maxAffectedBodies > 0 && processed >= this.maxAffectedBodies) {
break;
}
this._applyWindToBody(body, dir, deltaTime);
processed++;
}
// デバッグ描画
this._drawDebug(dir);
}
/**
* 風向きベクトルを取得(正規化済み)
*/
private _getWindDirection(): Vec2 {
if (this.useNodeDirection) {
// ノードのローカル +X 方向をワールド空間で取得し、XY 平面に投影
const worldDir3D = new Vec3(1, 0, 0);
this.node.getWorldRotation().transformVector(worldDir3D, worldDir3D);
const dir2D = new Vec2(worldDir3D.x, worldDir3D.y);
if (dir2D.lengthSqr() > 0) {
dir2D.normalize();
}
return dir2D;
} else {
const dir2D = new Vec2(this.customDirection.x, this.customDirection.y);
if (dir2D.lengthSqr() > 0) {
dir2D.normalize();
}
return dir2D;
}
}
/**
* 指定された Rigidbody2D に風を適用
*/
private _applyWindToBody(body: RigidBody2D, dir: Vec2, deltaTime: number) {
switch (this.mode) {
case WindForceMode.ContinuousForce: {
// 毎フレーム力を加える。力の大きさは strength。
// deltaTime を掛けることでフレームレートに依存しにくくする。
const force = new Vec2(dir.x * this.strength * deltaTime, dir.y * this.strength * deltaTime);
body.applyForceToCenter(force, true);
break;
}
case WindForceMode.Impulse: {
// 毎フレーム衝撃を加える。非常に強い風や爆風のような表現に向く。
const impulse = new Vec2(dir.x * this.strength * deltaTime, dir.y * this.strength * deltaTime);
body.applyLinearImpulseToCenter(impulse, true);
break;
}
case WindForceMode.VelocityChange: {
// Rigidbody2D の速度を一定方向・一定大きさに近づける。
// 単純に線形補間で目標速度へ寄せる。
const targetVel = new Vec2(dir.x * this.strength, dir.y * this.strength);
const currentVel = body.linearVelocity.clone();
// 補間係数(0〜1)。小さいほどゆっくり変化。
const lerpFactor = 0.1;
currentVel.x = currentVel.x + (targetVel.x - currentVel.x) * lerpFactor;
currentVel.y = currentVel.y + (targetVel.y - currentVel.y) * lerpFactor;
body.linearVelocity = currentVel;
break;
}
}
}
/**
* Collider2D の BEGIN_CONTACT ハンドラ
*/
private _onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
const body = otherCollider.getComponent(RigidBody2D);
if (body && body.isValid) {
this._bodiesInArea.add(body);
}
}
/**
* Collider2D の END_CONTACT ハンドラ
*/
private _onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
const body = otherCollider.getComponent(RigidBody2D);
if (body && body.isValid) {
this._bodiesInArea.delete(body);
}
}
/**
* デバッグ用に風向き矢印を描画
*/
private _drawDebug(dir: Vec2) {
if (!this.debugDrawDirection || !this._debugGraphics) {
return;
}
const g = this._debugGraphics;
g.clear();
g.strokeColor = this.debugColor;
// 矢印の長さ(ローカル空間)
const length = 100;
const start = new Vec3(0, 0, 0);
const end = new Vec3(dir.x * length, dir.y * length, 0);
// 矢印本体
g.moveTo(start.x, start.y);
g.lineTo(end.x, end.y);
g.stroke();
// 矢印の頭
const headSize = 15;
const left = new Vec3(
end.x - dir.x * headSize - dir.y * headSize * 0.5,
end.y - dir.y * headSize + dir.x * headSize * 0.5,
0
);
const right = new Vec3(
end.x - dir.x * headSize + dir.y * headSize * 0.5,
end.y - dir.y * headSize - dir.x * headSize * 0.5,
0
);
g.moveTo(end.x, end.y);
g.lineTo(left.x, left.y);
g.moveTo(end.x, end.y);
g.lineTo(right.x, right.y);
g.stroke();
}
}
コードのポイント解説
- onLoad()
・アタッチされたノードからCollider2Dを取得し、存在しない場合はconsole.errorで通知。
・BEGIN_CONTACT/END_CONTACTイベントを購読し、エリア内のRigidBody2DをSetで管理。
・デバッグ描画用にGraphicsコンポーネントを子ノードに追加(debugDrawDirectionが true の場合)。 - update(deltaTime)
・風が有効でなければ何もしません。
・_getWindDirection()で現在の風向きベクトルを取得(ノードの回転 or customDirection)。
・エリア内の Rigidbody2D をループし、_applyWindToBody()でモードに応じた力を適用。
・maxAffectedBodiesで負荷を制御。
・最後に_drawDebug()で矢印を描画。 - _applyWindToBody()
・ContinuousForce:applyForceToCenter()にstrength * deltaTimeを掛けた力を渡す。
・Impulse:applyLinearImpulseToCenter()を使い、毎フレーム衝撃を加える。
・VelocityChange:linearVelocityを補間して目標速度に近づける。 - _onBeginContact / _onEndContact()
・接触開始時にotherColliderからRigidBody2Dを取得し、Setに追加。
・接触終了時に同じくSetから削除。
・これにより、「今エリア内にいるボディ」のみを常に追跡。 - _drawDebug()
・ローカル空間で矢印を描画し、風向きの可視化に利用。
・ゲームプレイには影響せず、Scene ビューでの確認用。
使用手順と動作確認
1. スクリプトファイルの作成
- Unity ではなく Cocos Creator 3.8.7 を起動します。
- 左下の Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - フォルダ上で右クリック → Create → TypeScript を選択します。
- 新しく作成されたファイル名を WindTunnel.ts に変更します。
- ダブルクリックしてエディタ(VS Code など)で開き、既存のコードをすべて削除して、前節の TypeScript コード をそのまま貼り付けて保存します。
2. テスト用シーンとノードの準備
- シーンを開く / 作成する
・Assets パネルで任意のシーン(例:Main.scene)をダブルクリックして開きます。
・まだシーンがない場合は、File → New Scene で新規シーンを作成し、保存します。 - 物理世界の有効化を確認
・メインメニューから Project → Project Settings を開きます。
・左メニューから Physics 2D を選択し、Enable にチェックが入っていることを確認します。
・重力などはデフォルトのままで構いません。 - 風エリア用ノードの作成
1. Hierarchy パネルで右クリック → Create → Empty Node を選択し、ノード名を WindArea に変更します。
2. Inspector で Add Component → Physics 2D → BoxCollider2D を追加します。
3. BoxCollider2D の Size を (例)width: 400, height: 150など、風エリアとして使いたい大きさに調整します。
4. BoxCollider2D の Sensor (Trigger) にチェックを入れて、衝突せずにエリア判定のみ行うようにします。 - WindTunnel コンポーネントのアタッチ
1. WindArea ノードを選択した状態で、Inspector の Add Component ボタンをクリックします。
2. 一覧から Custom → WindTunnel を選択します。
3. これで WindArea ノードは「風の通り道」になりました。 - 風エリアのプロパティ設定
WindArea にアタッチされた WindTunnel コンポーネントの Inspector を次のように設定してみます(例):- Enabled Wind: チェック ON
- Use Node Direction: チェック ON(ノードの向きで風向きを決める)
- Custom Direction: (1, 0) のままでOK(Use Node Direction が ON のため無視されます)
- Strength: 100
- Mode:
ContinuousForce - Max Affected Bodies: 0(無制限)
- Debug Draw Direction: チェック ON
- Debug Color: 好きな色(例: シアン)
ノードの Rotation を 0 度にしておけば、風は「右方向」に吹きます。
風を上向きにしたい場合は、WindArea ノードを 90 度回転させてください。
3. 風に影響を受けるオブジェクトの作成
- Sprite ノードの作成
1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、ノード名を Box などに変更します。
2. Inspector の Sprite コンポーネントで、適当な画像(白い四角など)を指定します。 - Rigidbody2D の追加
1. Box ノードを選択した状態で、Add Component → Physics 2D → RigidBody2D を追加します。
2. Rigidbody2D の Body Type を Dynamic に設定します。 - Collider2D の追加
1. 同じく Box ノードに BoxCollider2D を追加します。
2. Sprite のサイズに合わせて Size を調整します。 - 位置の調整
・Box ノードを WindArea の左側に配置し、重力で落ちるか、またはプレイヤー操作などで WindArea 内に入るような位置に移動させます。
・テストとしては、WindArea を画面中央に、Box をその左側に少し離して置き、重力を弱めるか 0 にしておくと動きが確認しやすいです。
4. 実行して動作を確認
- 上部ツールバーの Play(▶) ボタンをクリックしてゲームを再生します。
- Box ノードが WindArea のエリア内に入ると、右方向(または設定した方向)に加速し始めるのを確認できます。
- WindTunnel コンポーネントの以下の値を変えながら、挙動の違いを試してみましょう。
- Strength を 20, 100, 300 などに変えて、風の強さの違いを確認。
- Mode を
Impulseにして、「バンッ」と弾き飛ばされるような風を再現。 - Mode を
VelocityChangeにして、一定速度で流れるベルトコンベアのような動きに。 - Use Node Direction を OFF にし、Custom Direction を (0, 1) にして上向きの風に。
まとめ
この WindTunnel コンポーネントは、
- Collider2D を持つ任意のノードにアタッチするだけで「風の通り道」として機能し、
- 外部の管理クラスやシングルトンに依存せず、このスクリプト単体で完結し、
- 風向き・強さ・モードを Inspector から柔軟に調整できる
という特徴を持っています。
応用例としては、
- ステージ内の 上昇気流エリア(上向きの風)
- プレイヤーやオブジェクトを一定方向に流す 川やコンベアベルト
- 特定のタイミングで強く吹く ギミック用の風(
enabledWindをスクリプトやアニメーションから切り替え) - 敵やギミックをまとめて押し流す トラップゾーン
など、2D 物理ゲーム全般で幅広く利用できます。
プロジェクト内で再利用しやすいように、外部依存を排した汎用コンポーネントとして設計してあるため、シーンをまたいでドラッグ&ドロップするだけで、どこでも同じ挙動を再現できます。
この WindTunnel をベースに、例えば「ランダムで向きが変わる風」「時間経過で強さが変わる風」などを拡張していけば、より多彩なステージギミックを簡単に実装できるようになります。




