【Cocos Creator 3.8】WindFan(扇風機)の実装:アタッチするだけで「範囲内のRigidBody2Dを一定方向に押し続ける」汎用スクリプト
このガイドでは、2Dゲームでよくある「扇風機」や「風エリア」を簡単に実装できる汎用コンポーネント WindFan を作成します。
WindFan をノードにアタッチし、向き・強さ・範囲をインスペクタから設定するだけで、その範囲内にある RigidBody2D に対して継続的にエアフォース(力)を与え続けることができます。
風洞ステージ、上昇気流、横風トラップなど、物理挙動を活かしたギミックにそのまま使えます。
コンポーネントの設計方針
機能要件の整理
- このコンポーネントをアタッチしたノードが「風を発生させるエリア」となる。
- エリア内にいる
RigidBody2Dを検知し、毎フレーム一定方向の力を加える。 - 風の向き・強さ・有効範囲・力の種類(Force / Impulse / LinearVelocity など)をインスペクタから調整可能。
- 外部の
GameManagerやシングルトンには一切依存しない。 - 必要な標準コンポーネント(
RigidBody2D,Collider2D)がない場合は自動取得を試み、なければログで警告する。
物理挙動の方針
Cocos Creator 3.8 の 2D 物理(Box2D)を前提とした設計にします。
- エリアの検知には
Collider2Dを isTrigger = true(センサー) で利用。 - 風を受けるオブジェクトは
RigidBody2Dを持っていることを前提とする。 - 力の加え方は3種類をサポートする:
- Force: 毎フレーム
applyForceToCenterで継続的な力を加える(加速度的に速度が増える)。 - Impulse: 毎フレーム
applyLinearImpulseToCenterで瞬間的な衝撃を与える(強い風のイメージ)。 - Velocity:
linearVelocityを一定方向に上書きする(ベルトコンベアや強制スクロール風)。
- Force: 毎フレーム
- 風向きは「ノードのローカルY+方向」を基準とし、ノードの回転に応じて自動で変わるようにする。
- ノードを回転させるだけで風向きを変えられる。
インスペクタで設定可能なプロパティ
WindFan コンポーネントに用意するプロパティと役割は以下の通りです。
enabledFan: boolean- この扇風機を動作させるかどうか。
- チェックを外すと、エリアに入っていても力は加えられない。
strength: number- 風の強さ(スカラー値)。
- 値が大きいほど強い力が加わる。
- 単位は Force / Impulse / Velocity の種類によって解釈が変わるが、基本的には「大きいほど強い」と覚えればよい。
maxAffectedSpeed: number- 風によって到達させたい最大速度の目安。
- 0 以下にすると「制限なし」。
- Force / Impulse モードでは、対象の速度がこの値を超えたらそれ以上は力を加えない。
mode: WindMode(Enum)- 風の作用モードを選択:
FORCE– 毎フレームapplyForceToCenterIMPULSE– 毎フレームapplyLinearImpulseToCenterVELOCITY–linearVelocityを補間して目標速度へ近づける
- 風の作用モードを選択:
useWorldDirection: boolean- true の場合:
directionベクトルをワールド座標系の向きとして使用。 - false の場合:ノードのローカルY+(上方向)を基準にして、ノードの回転に応じて風向きが変化。
- true の場合:
direction: Vec2- 風の向き(ワールド or ローカル基準は
useWorldDirectionによる)。 - 例: (0, 1) で上向き、(1, 0) で右向き。
- ゼロベクトルの場合はログ警告を出し、何もしない。
- 風の向き(ワールド or ローカル基準は
onlyAffectTagged: boolean- true の場合:
targetGroupMaskによるフィルタリングを行う。 - false の場合:エリア内すべての
RigidBody2Dに風を適用。
- true の場合:
targetGroupMask: number- 対象とする
Node.layerのビットマスク。 - 例: デフォルトレイヤー(
1 << 0)だけに適用したい場合などに利用。 - 0 の場合は「全レイヤー許可」として扱う。
- 対象とする
debugDrawDirection: boolean- エディタ・ゲーム実行中に、風向きを示す矢印を
Graphicsで簡易表示するかどうか。
- エディタ・ゲーム実行中に、風向きを示す矢印を
debugColor: Color- デバッグ矢印の色。
また、内部的には以下の標準コンポーネントを利用します。
Collider2D- エリア検知用。isTrigger = true に設定することを推奨。
- 自動で追加はしないので、エディタでの追加を必須とし、なければ警告ログを出す。
RigidBody2D(対象側)- 風を受けるオブジェクト側に必要。
- 存在しない場合はそのオブジェクトには何もしない。
TypeScriptコードの実装
import { _decorator, Component, Node, Vec2, Vec3, RigidBody2D, Collider2D, IPhysics2DContact, Contact2DType, Enum, Color, Graphics, UITransform, math } from 'cc';
const { ccclass, property } = _decorator;
/**
* 風の作用モード
*/
enum WindMode {
FORCE = 0,
IMPULSE = 1,
VELOCITY = 2,
}
Enum(WindMode);
@ccclass('WindFan')
export class WindFan extends Component {
@property({
tooltip: 'この扇風機を動作させるかどうか。\nオフにするとエリア内でも力を加えません。'
})
public enabledFan: boolean = true;
@property({
tooltip: '風の強さ(スカラー値)。\n値が大きいほど強い力が加わります。'
})
public strength: number = 10;
@property({
tooltip: '風によって到達させたい最大速度(単位: m/s)。\n0 以下で無制限。\n対象の速度がこの値を超えると、それ以上風の力を加えません。'
})
public maxAffectedSpeed: number = 0;
@property({
type: Enum(WindMode),
tooltip: '風の作用モードを選択します。\nFORCE: 毎フレーム Force を加える(加速度的に速度上昇)。\nIMPULSE: 毎フレーム Impulse を加える(瞬間的に強く押す)。\nVELOCITY: 対象の線形速度を目標方向へ補間します。'
})
public mode: WindMode = WindMode.FORCE;
@property({
tooltip: 'true: direction ベクトルをワールド座標系の向きとして使用します。\nfalse: ノードのローカルY+方向を基準に、ノードの回転に応じて風向きが変わります。'
})
public useWorldDirection: boolean = false;
@property({
tooltip: '風の向きベクトル。\n(0,1) で上向き、(1,0) で右向きなど。\nゼロベクトルの場合は無効です。'
})
public direction: Vec2 = new Vec2(0, 1);
@property({
tooltip: 'true の場合、targetGroupMask で指定したレイヤーのノードのみ風の対象とします。'
})
public onlyAffectTagged: boolean = false;
@property({
tooltip: '対象とする Node.layer のビットマスク。\n0 の場合は全レイヤーを対象とします。\n例: デフォルトレイヤーだけにしたい場合は 1 << 0 = 1 を指定。'
})
public targetGroupMask: number = 0;
@property({
tooltip: '風向きをデバッグ表示するかどうか。\n有効にすると、このノード配下の Graphics を使って矢印を描画します。'
})
public debugDrawDirection: boolean = true;
@property({
tooltip: 'デバッグ矢印の色です。'
})
public debugColor: Color = new Color(0, 200, 255, 255);
// --- 内部用フィールド ---
private _collider: Collider2D | null = null;
private _graphics: Graphics | null = null;
// 風エリア内に存在する RigidBody2D を管理
private _bodiesInArea: Set<RigidBody2D> = new Set<RigidBody2D>();
onLoad() {
// Collider2D を取得
this._collider = this.getComponent(Collider2D);
if (!this._collider) {
console.warn('[WindFan] Collider2D が見つかりません。このノードに BoxCollider2D などの Collider2D を追加してください。ノード名:', this.node.name);
} else {
// トリガーとして扱うことを推奨
if (!this._collider.sensor) {
console.warn('[WindFan] Collider2D.sensor が false です。風エリアとして使用する場合は true に設定することを推奨します。ノード名:', this.node.name);
}
}
// デバッグ描画用 Graphics を子ノードから探す
this._graphics = this.getComponent(Graphics);
if (!this._graphics) {
// Graphics が無い場合は必須ではないので警告のみ
// デバッグ表示が不要なら無視して良い
// console.log('[WindFan] Graphics コンポーネントが見つかりません。debugDrawDirection を使用する場合は Graphics を追加してください。ノード名:', this.node.name);
}
// コリジョンコールバック登録
if (this._collider) {
this._collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
this._collider.on(Contact2DType.END_CONTACT, this._onEndContact, this);
}
}
start() {
// 初回のデバッグ描画
this._drawDebug();
}
update(dt: number) {
if (!this.enabledFan) {
return;
}
const dir = this._getNormalizedDirection();
if (!dir) {
// 無効な方向
return;
}
// 各モードで力を適用
this._bodiesInArea.forEach((body) => {
if (!body.node || !body.enabled) {
return;
}
if (this.onlyAffectTagged && this.targetGroupMask !== 0) {
const layer = body.node.layer;
if ((layer & this.targetGroupMask) === 0) {
// 対象レイヤーではない
return;
}
}
const vel = body.linearVelocity;
const currentSpeed = vel.length();
if (this.maxAffectedSpeed > 0 && currentSpeed >= this.maxAffectedSpeed) {
// 既に十分速いのでこれ以上押さない
return;
}
switch (this.mode) {
case WindMode.FORCE:
this._applyForce(body, dir);
break;
case WindMode.IMPULSE:
this._applyImpulse(body, dir);
break;
case WindMode.VELOCITY:
this._applyVelocity(body, dir, dt);
break;
}
});
// 実行中でもデバッグ矢印を更新
this._drawDebug();
}
onDisable() {
// 無効化されたらエリア内の管理をクリア
this._bodiesInArea.clear();
}
onDestroy() {
if (this._collider) {
this._collider.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
this._collider.off(Contact2DType.END_CONTACT, this._onEndContact, this);
}
}
// --- コリジョンコールバック ---
private _onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
const body = otherCollider.getComponent(RigidBody2D);
if (body) {
this._bodiesInArea.add(body);
}
}
private _onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
const body = otherCollider.getComponent(RigidBody2D);
if (body) {
this._bodiesInArea.delete(body);
}
}
// --- 力の適用ロジック ---
/** 風の方向ベクトル(正規化済み)を取得。無効な場合は null を返す。 */
private _getNormalizedDirection(): Vec2 | null {
let dir = new Vec2(this.direction.x, this.direction.y);
if (!this.useWorldDirection) {
// ノードのローカルY+ を基準に、ノードの回転を考慮したワールド方向に変換
// Node の up ベクトル(ワールド)を取得
const worldUp = new Vec3(0, 1, 0);
const worldDir = this.node.getWorldMatrix().transformVector(worldUp, worldUp);
dir.set(worldDir.x, worldDir.y);
}
if (dir.lengthSqr() === 0) {
console.warn('[WindFan] direction がゼロベクトルのため、風が発生しません。ノード名:', this.node.name);
return null;
}
dir.normalize();
return dir;
}
private _applyForce(body: RigidBody2D, dir: Vec2) {
// strength をそのまま Force の大きさとして扱う
const force = new Vec2(dir.x * this.strength, dir.y * this.strength);
body.applyForceToCenter(force, true);
}
private _applyImpulse(body: RigidBody2D, dir: Vec2) {
const impulse = new Vec2(dir.x * this.strength, dir.y * this.strength);
body.applyLinearImpulseToCenter(impulse, true);
}
private _applyVelocity(body: RigidBody2D, dir: Vec2, dt: number) {
// 目標速度の大きさ
const targetSpeed = (this.maxAffectedSpeed > 0) ? this.maxAffectedSpeed : this.strength;
const targetVel = new Vec2(dir.x * targetSpeed, dir.y * targetSpeed);
const currentVel = body.linearVelocity;
// 線形補間で徐々に目標速度へ近づける(0.1 は追従率の調整パラメータ)
const lerpFactor = math.clamp01(0.1 + dt); // dt も加味して少しなめらかに
const newVel = new Vec2(
math.lerp(currentVel.x, targetVel.x, lerpFactor),
math.lerp(currentVel.y, targetVel.y, lerpFactor),
);
body.linearVelocity = newVel;
}
// --- デバッグ描画 ---
private _drawDebug() {
if (!this.debugDrawDirection || !this._graphics) {
return;
}
const g = this._graphics;
g.clear();
const dir = this._getNormalizedDirection();
if (!dir) {
return;
}
g.strokeColor = this.debugColor;
g.lineWidth = 2;
// ノードのローカル座標系で矢印を描く(UITransform のサイズを考慮)
const ui = this.getComponent(UITransform);
const len = ui ? Math.max(ui.width, ui.height) * 0.5 : 50;
const startX = 0;
const startY = 0;
const endX = dir.x * len;
const endY = dir.y * len;
g.moveTo(startX, startY);
g.lineTo(endX, endY);
// 矢印の頭
const headSize = 10;
const left = new Vec2(
endX - dir.x * headSize - dir.y * headSize * 0.5,
endY - dir.y * headSize + dir.x * headSize * 0.5
);
const right = new Vec2(
endX - dir.x * headSize + dir.y * headSize * 0.5,
endY - dir.y * headSize - dir.x * headSize * 0.5
);
g.moveTo(endX, endY);
g.lineTo(left.x, left.y);
g.moveTo(endX, endY);
g.lineTo(right.x, right.y);
g.stroke();
}
}
コードのポイント解説
WindModeEnum- インスペクタでプルダウン選択できるように
Enum(WindMode)を呼び出しています。
- インスペクタでプルダウン選択できるように
onLoadCollider2Dを取得し、存在しない場合はconsole.warnで警告。sensorがfalseの場合も、風エリア用途にはtrueを推奨する警告を出します。- コリジョンコールバック(
BEGIN_CONTACT,END_CONTACT)を登録。
updateenabledFanがfalseなら何もしない。- 現在エリア内にいる
RigidBody2Dに対して、モード別に力を適用。 maxAffectedSpeedが 0 より大きい場合、対象の速度がそれ以上になったら風の適用を止める。- 毎フレーム
_drawDebug()を呼び出し、向き変更時にも矢印を更新。
- 接触管理(
_onBeginContact,_onEndContact)- エリアに入った
RigidBody2DをSetで管理し、エリアから出たら削除。 - これにより、毎フレーム物理ワールド全体を走査する必要がなく効率的。
- エリアに入った
- 方向計算(
_getNormalizedDirection)useWorldDirectionがfalseの場合、ノードのローカルY+をワールド方向に変換し、ノードの回転に追随。- ゼロベクトルの場合は警告を出して
nullを返し、風を発生させない。
- デバッグ描画(
_drawDebug)- 同じノードに
Graphicsがアタッチされていれば、風向きを示す矢印を描画。 UITransformのサイズがあれば、それに応じた長さで矢印を描く。
- 同じノードに
使用手順と動作確認
1. スクリプトファイルの作成
- エディタの Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - 右クリック → Create → TypeScript を選択します。
- 新規スクリプト名を
WindFan.tsに変更します。 - 作成された
WindFan.tsをダブルクリックして開き、既存コードをすべて削除して、前章の TypeScript コードを丸ごと貼り付けて保存します。
2. 風エリア用ノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Node を選択し、新しいノードを作成します。
- ノード名を分かりやすく
WindAreaなどに変更します。 - Inspector パネルで、このノードを選択した状態で Add Component → Physics 2D → BoxCollider2D(または CircleCollider2D)を追加します。
- これは「風が有効な範囲」を表します。
- 追加した Collider2D の設定:
- Sensor(
isTrigger)にチェックを入れます。 - Size(または Radius)を調整し、風を発生させたい範囲を決めます。
- Sensor(
3. WindFan コンポーネントのアタッチ
- WindArea ノードを選択した状態で、Inspector の Add Component ボタンをクリックします。
- Custom → WindFan を選択してアタッチします。
- Inspector に
WindFanの各種プロパティが表示されます。
4. デバッグ描画用 Graphics の追加(任意)
風向きの矢印を見たい場合は、以下の手順で Graphics を追加します。
- WindArea ノードを選択します。
- Add Component → Rendering → Graphics を追加します。
WindFanの debugDrawDirection をtrueにします。- 必要に応じて debugColor を変更します。
5. 風を受けるオブジェクトの準備
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite など、テスト用のオブジェクトを作成します。
- そのノードを選択し、Add Component → Physics 2D → RigidBody2D を追加します。
- 同じく Add Component → Physics 2D → BoxCollider2D を追加し、当たり判定を設定します。
- このオブジェクトを WindArea のコライダー範囲内に配置します。
6. WindFan プロパティの設定例
代表的な設定例をいくつか示します。
例1: 上向きの上昇気流(FORCE モード)
- enabledFan: チェックオン
- strength: 30
- maxAffectedSpeed: 5
- mode: FORCE
- useWorldDirection: true
- direction: (0, 1)
- onlyAffectTagged: false
- targetGroupMask: 0
- debugDrawDirection: true
この設定では、エリア内の RigidBody2D がゆっくりと上昇していきます。
例2: 右向きの強風(IMPULSE モード)
- enabledFan: チェックオン
- strength: 5
- maxAffectedSpeed: 15
- mode: IMPULSE
- useWorldDirection: true
- direction: (1, 0)
エリア内に入ったオブジェクトが、右方向に力強く押し出されます。
例3: ノード回転で向きが変わる扇風機(VELOCITY モード)
- enabledFan: チェックオン
- strength: 8
- maxAffectedSpeed: 8
- mode: VELOCITY
- useWorldDirection: false
- direction: (0, 1)(無視されるが、ゼロでなければOK)
この場合、WindArea ノード自体を回転させると、風向きもそれに追随して変化します。
ベルトコンベア的な挙動や、強制スクロールに近い制御が欲しい場合に便利です。
7. プレイして動作確認
- シーンを保存します。
- エディタ右上の Play ボタン(▶)をクリックしてゲームを実行します。
- テスト用オブジェクトを WindArea の範囲内に置いた状態で、風の方向・強さが期待通りか確認します。
- うまく動かない場合は、以下をチェックしてください:
- WindArea に
Collider2Dがアタッチされているか。 Collider2D.sensorがtrueになっているか。- テストオブジェクトに
RigidBody2DとCollider2Dがついているか。 enabledFanがtrueか。directionがゼロベクトルになっていないか。onlyAffectTaggedとtargetGroupMaskの組み合わせで、レイヤーが除外されていないか。
- WindArea に
まとめ
この WindFan コンポーネントは、
- エリア内の
RigidBody2Dを検知し、 - 指定方向に継続的な力(または速度)を与える、
- 完全に独立した汎用スクリプト
として設計しました。外部のマネージャークラスやシングルトンに依存せず、ノードにアタッチしてインスペクタで値を調整するだけで、さまざまな「風」ギミックを実現できます。
応用例としては:
- ステージ中の上昇気流やダウンバーストゾーン。
- 横風でプレイヤー操作を妨害するトラップ。
- ベルトコンベアや流水の表現(VELOCITY モード)。
- 特定レイヤーだけを押すことで、敵だけを流す風・プレイヤーだけを押す風などの差別化。
いずれも、この WindFan.ts 1ファイルだけで完結しており、他のプロジェクトにもそのままコピー&ペーストして再利用できます。
あとは、シーン内に複数の WindFan を配置して強さや向きを変えることで、風洞ステージやパズル要素を簡単に作り込んでいけます。




