【Cocos Creator】アタッチするだけ!WindFan (扇風機)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】WindFan(扇風機)の実装:アタッチするだけで「範囲内のRigidBody2Dを一定方向に押し続ける」汎用スクリプト

このガイドでは、2Dゲームでよくある「扇風機」や「風エリア」を簡単に実装できる汎用コンポーネント WindFan を作成します。
WindFan をノードにアタッチし、向き・強さ・範囲をインスペクタから設定するだけで、その範囲内にある RigidBody2D に対して継続的にエアフォース(力)を与え続けることができます。

風洞ステージ、上昇気流、横風トラップなど、物理挙動を活かしたギミックにそのまま使えます。


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

機能要件の整理

  • このコンポーネントをアタッチしたノードが「風を発生させるエリア」となる。
  • エリア内にいる RigidBody2D を検知し、毎フレーム一定方向の力を加える。
  • 風の向き・強さ・有効範囲・力の種類(Force / Impulse / LinearVelocity など)をインスペクタから調整可能。
  • 外部の GameManager やシングルトンには一切依存しない。
  • 必要な標準コンポーネント(RigidBody2D, Collider2D)がない場合は自動取得を試み、なければログで警告する。

物理挙動の方針

Cocos Creator 3.8 の 2D 物理(Box2D)を前提とした設計にします。

  • エリアの検知には Collider2DisTrigger = true(センサー) で利用。
  • 風を受けるオブジェクトは RigidBody2D を持っていることを前提とする。
  • 力の加え方は3種類をサポートする:
    • Force: 毎フレーム applyForceToCenter で継続的な力を加える(加速度的に速度が増える)。
    • Impulse: 毎フレーム applyLinearImpulseToCenter で瞬間的な衝撃を与える(強い風のイメージ)。
    • Velocity: linearVelocity を一定方向に上書きする(ベルトコンベアや強制スクロール風)。
  • 風向きは「ノードのローカルY+方向」を基準とし、ノードの回転に応じて自動で変わるようにする。
    • ノードを回転させるだけで風向きを変えられる。

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

WindFan コンポーネントに用意するプロパティと役割は以下の通りです。

  • enabledFan: boolean
    • この扇風機を動作させるかどうか。
    • チェックを外すと、エリアに入っていても力は加えられない。
  • strength: number
    • 風の強さ(スカラー値)。
    • 値が大きいほど強い力が加わる。
    • 単位は Force / Impulse / Velocity の種類によって解釈が変わるが、基本的には「大きいほど強い」と覚えればよい。
  • maxAffectedSpeed: number
    • 風によって到達させたい最大速度の目安。
    • 0 以下にすると「制限なし」。
    • Force / Impulse モードでは、対象の速度がこの値を超えたらそれ以上は力を加えない。
  • mode: WindMode(Enum)
    • 風の作用モードを選択:
      • FORCE – 毎フレーム applyForceToCenter
      • IMPULSE – 毎フレーム applyLinearImpulseToCenter
      • VELOCITYlinearVelocity を補間して目標速度へ近づける
  • useWorldDirection: boolean
    • true の場合:direction ベクトルをワールド座標系の向きとして使用。
    • false の場合:ノードのローカルY+(上方向)を基準にして、ノードの回転に応じて風向きが変化。
  • direction: Vec2
    • 風の向き(ワールド or ローカル基準は useWorldDirection による)。
    • 例: (0, 1) で上向き、(1, 0) で右向き。
    • ゼロベクトルの場合はログ警告を出し、何もしない。
  • onlyAffectTagged: boolean
    • true の場合:targetGroupMask によるフィルタリングを行う。
    • false の場合:エリア内すべての RigidBody2D に風を適用。
  • 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();
    }
}

コードのポイント解説

  • WindMode Enum
    • インスペクタでプルダウン選択できるように Enum(WindMode) を呼び出しています。
  • onLoad
    • Collider2D を取得し、存在しない場合は console.warn で警告。
    • sensorfalse の場合も、風エリア用途には true を推奨する警告を出します。
    • コリジョンコールバック(BEGIN_CONTACT, END_CONTACT)を登録。
  • update
    • enabledFanfalse なら何もしない。
    • 現在エリア内にいる RigidBody2D に対して、モード別に力を適用。
    • maxAffectedSpeed が 0 より大きい場合、対象の速度がそれ以上になったら風の適用を止める。
    • 毎フレーム _drawDebug() を呼び出し、向き変更時にも矢印を更新。
  • 接触管理(_onBeginContact, _onEndContact
    • エリアに入った RigidBody2DSet で管理し、エリアから出たら削除。
    • これにより、毎フレーム物理ワールド全体を走査する必要がなく効率的。
  • 方向計算(_getNormalizedDirection
    • useWorldDirectionfalse の場合、ノードのローカルY+をワールド方向に変換し、ノードの回転に追随。
    • ゼロベクトルの場合は警告を出して null を返し、風を発生させない。
  • デバッグ描画(_drawDebug
    • 同じノードに Graphics がアタッチされていれば、風向きを示す矢印を描画。
    • UITransform のサイズがあれば、それに応じた長さで矢印を描く。

使用手順と動作確認

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

  1. エディタの Assets パネルで、スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. 新規スクリプト名を WindFan.ts に変更します。
  4. 作成された WindFan.ts をダブルクリックして開き、既存コードをすべて削除して、前章の TypeScript コードを丸ごと貼り付けて保存します。

2. 風エリア用ノードの作成

  1. Hierarchy パネルで右クリック → Create2D ObjectNode を選択し、新しいノードを作成します。
  2. ノード名を分かりやすく WindArea などに変更します。
  3. Inspector パネルで、このノードを選択した状態で Add ComponentPhysics 2DBoxCollider2D(または CircleCollider2D)を追加します。
    • これは「風が有効な範囲」を表します。
  4. 追加した Collider2D の設定:
    • SensorisTrigger)にチェックを入れます。
    • Size(または Radius)を調整し、風を発生させたい範囲を決めます。

3. WindFan コンポーネントのアタッチ

  1. WindArea ノードを選択した状態で、InspectorAdd Component ボタンをクリックします。
  2. CustomWindFan を選択してアタッチします。
  3. Inspector に WindFan の各種プロパティが表示されます。

4. デバッグ描画用 Graphics の追加(任意)

風向きの矢印を見たい場合は、以下の手順で Graphics を追加します。

  1. WindArea ノードを選択します。
  2. Add ComponentRenderingGraphics を追加します。
  3. WindFandebugDrawDirectiontrue にします。
  4. 必要に応じて debugColor を変更します。

5. 風を受けるオブジェクトの準備

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite など、テスト用のオブジェクトを作成します。
  2. そのノードを選択し、Add ComponentPhysics 2DRigidBody2D を追加します。
  3. 同じく Add ComponentPhysics 2DBoxCollider2D を追加し、当たり判定を設定します。
  4. このオブジェクトを 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. プレイして動作確認

  1. シーンを保存します。
  2. エディタ右上の Play ボタン(▶)をクリックしてゲームを実行します。
  3. テスト用オブジェクトを WindArea の範囲内に置いた状態で、風の方向・強さが期待通りか確認します。
  4. うまく動かない場合は、以下をチェックしてください:
    • WindAreaCollider2D がアタッチされているか。
    • Collider2D.sensortrue になっているか。
    • テストオブジェクトに RigidBody2DCollider2D がついているか。
    • enabledFantrue か。
    • direction がゼロベクトルになっていないか。
    • onlyAffectTaggedtargetGroupMask の組み合わせで、レイヤーが除外されていないか。

まとめ

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

  • エリア内の RigidBody2D を検知し、
  • 指定方向に継続的な力(または速度)を与える、
  • 完全に独立した汎用スクリプト

として設計しました。外部のマネージャークラスやシングルトンに依存せず、ノードにアタッチしてインスペクタで値を調整するだけで、さまざまな「風」ギミックを実現できます。

応用例としては:

  • ステージ中の上昇気流やダウンバーストゾーン。
  • 横風でプレイヤー操作を妨害するトラップ。
  • ベルトコンベアや流水の表現(VELOCITY モード)。
  • 特定レイヤーだけを押すことで、敵だけを流す風・プレイヤーだけを押す風などの差別化。

いずれも、この WindFan.ts 1ファイルだけで完結しており、他のプロジェクトにもそのままコピー&ペーストして再利用できます。
あとは、シーン内に複数の WindFan を配置して強さや向きを変えることで、風洞ステージやパズル要素を簡単に作り込んでいけます。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!