【Cocos Creator 3.8】SlopeSlider の実装:アタッチするだけで「急な坂道では自動で滑り落ちる」挙動を実現する汎用スクリプト

横スクロールアクションや3Dランゲームなどで「キャラクターが急な坂道に立つと、そのままズルズル滑り落ちてほしい」という場面はよくあります。
本記事では、ノードにアタッチするだけで「一定以上の傾きの坂にいると強い重力で滑り落ちる」挙動を付与できる汎用コンポーネント SlopeSlider を TypeScript で実装します。

外部の GameManager やシングルトンに一切依存せず、必要なパラメータはすべてインスペクタで調整可能 な設計とします。
2D 物理(Rigidbody2D)を使っているプレイヤーや敵ノードにアタッチするだけで動作します。


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

想定する利用シーンと前提

  • 2Dゲーム(Rigidbody2D + Collider2D)で動くプレイヤーキャラやオブジェクト。
  • 地面や坂道は Collider2D(Box/Polygon など)で作られている。
  • オブジェクトが一定以上の傾きの地面に接地しているとき重力を強くして滑り落ちる
  • 傾きが緩い坂や平地では、通常通りの重力で挙動する。

Cocos Creator 3.8 の 2D 物理では、接触しているコライダーの法線ベクトルから、坂の角度を計算できます。
この法線を利用して「接地している面の傾き」を求め、設定したしきい値以上なら「急な坂」と判定し、Rigidbody2D の gravityScale を一時的に増加させます。

外部依存をなくすためのアプローチ

  • 必須コンポーネント
    • Rigidbody2D(物理挙動と重力制御に使用)
    • 何らかの Collider2D(接地判定に使用)
  • 他のスクリプトへの依存は一切禁止
    • プレイヤー制御やゲーム管理のスクリプトには依存しない。
    • 必要な値はすべて @property でインスペクタから設定。
  • 防御的実装
    • onLoadRigidbody2D を取得し、存在しなければエラーログを出して機能を停止。
    • コライダーがない場合も警告ログを出し、物理イベントが来ないため滑り判定は行わない。

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

以下のようなプロパティを用意します。

  • enabledSliding: boolean
    • 坂道滑り機能のオン/オフ。
    • デバッグ時に簡単に無効化したいときに使用。
  • slopeAngleThreshold: number
    • 「これ以上の傾きなら滑り始める」という角度(度数法)。
    • 例:30度なら、30度以上の坂で滑り落ちる。
  • gravityScaleOnSlope: number
    • 急な坂道にいるときに適用する Rigidbody2D.gravityScale の値。
    • 通常の重力スケールより大きい値を推奨(例:通常 1.0、坂では 3.0)。
  • restoreOriginalGravity: boolean
    • 急な坂から離れたときに、元の gravityScale に戻すかどうか
    • オンの場合:平地や空中では初期値に戻る。
  • groundNormalSmoothing: number
    • 接地面の法線ベクトルをどれくらい平滑化するか(0〜1)。
    • 0:平滑化なし(接触ごとに即座に角度が変化)。
    • 0.1〜0.5:フレーム間でなめらかに変化し、角度判定が安定。
  • debugLog: boolean
    • 坂の角度や状態変化をコンソールにログ出力するかどうか。

内部的には、現在接触している「足元の」コライダーの法線ベクトルを追跡し、その角度を毎フレーム計算してしきい値を超えているかどうかを判定します。
急な坂にいる間は gravityScaleOnSlope を適用し、そうでないときは(オプションで)元の値に戻します。


TypeScriptコードの実装


import { _decorator, Component, Node, Vec2, math, RigidBody2D, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
const { ccclass, property } = _decorator;

/**
 * SlopeSlider
 * 急な坂道にいるときに Rigidbody2D の gravityScale を増やし、
 * 自然に滑り落ちる挙動を与える汎用コンポーネント。
 *
 * - 必須: 同じノードに Rigidbody2D がアタッチされていること
 * - 推奨: 同じノードに Collider2D (Box/Polygon など) がアタッチされていること
 */
@ccclass('SlopeSlider')
export class SlopeSlider extends Component {

    @property({
        tooltip: '坂道滑り機能のオン/オフ。false の場合、このコンポーネントは何もしません。'
    })
    public enabledSliding: boolean = true;

    @property({
        tooltip: 'この角度(度)以上に傾いた地面を「急な坂」とみなし、滑り落ちる挙動を適用します。\n例: 30 にすると 30 度以上の坂で滑ります。',
        min: 0,
        max: 89,
        step: 1
    })
    public slopeAngleThreshold: number = 30;

    @property({
        tooltip: '急な坂道にいるときに適用する Rigidbody2D.gravityScale の値。\n通常の重力スケールより大きい値を設定してください。',
        min: 0,
        step: 0.1
    })
    public gravityScaleOnSlope: number = 3.0;

    @property({
        tooltip: '急な坂道から離れたときに、Rigidbody2D.gravityScale を元の値に戻すかどうか。'
    })
    public restoreOriginalGravity: boolean = true;

    @property({
        tooltip: '接地面の法線ベクトルをどれくらい平滑化するか (0〜1)。\n0: 平滑化なし / 0.1〜0.5: 角度変化をなめらかにして判定を安定化。',
        min: 0,
        max: 1,
        step: 0.05
    })
    public groundNormalSmoothing: number = 0.2;

    @property({
        tooltip: 'true にすると、計算された坂の角度や状態変化をコンソールにログ出力します。'
    })
    public debugLog: boolean = false;

    // --- 内部状態 ---

    /** 監視対象の Rigidbody2D */
    private _rb2d: RigidBody2D | null = null;

    /** 初期の gravityScale を保存しておく(復元用) */
    private _originalGravityScale: number = 1.0;

    /** 現在接地しているかどうか(少なくとも 1 つのコライダーと接触中) */
    private _isGrounded: boolean = false;

    /** 接地面の「平均的な」法線ベクトル(平滑化後) */
    private _smoothedGroundNormal: Vec2 = new Vec2(0, 1);

    /** 現在接触している地面コライダーの数 */
    private _groundContactCount: number = 0;

    /** 現在「急な坂」と判定されているかどうか */
    private _onSteepSlope: boolean = false;

    onLoad() {
        // Rigidbody2D を取得
        this._rb2d = this.getComponent(RigidBody2D);
        if (!this._rb2d) {
            console.error('[SlopeSlider] Rigidbody2D が見つかりません。このコンポーネントを使用するには、同じノードに Rigidbody2D を追加してください。');
            return;
        }

        // 初期の gravityScale を保存
        this._originalGravityScale = this._rb2d.gravityScale;

        // Collider2D を取得して接触イベントを購読
        const collider = this.getComponent(Collider2D);
        if (!collider) {
            console.warn('[SlopeSlider] Collider2D が見つかりません。接地判定が行えないため、坂道滑りは動作しません。同じノードに Collider2D を追加してください。');
        } else {
            collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
            collider.on(Contact2DType.END_CONTACT, this._onEndContact, this);
            collider.on(Contact2DType.PRE_SOLVE, this._onPreSolve, this);
        }
    }

    onDestroy() {
        // イベント購読解除(Collider2D が存在する場合のみ)
        const collider = this.getComponent(Collider2D);
        if (collider) {
            collider.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
            collider.off(Contact2DType.END_CONTACT, this._onEndContact, this);
            collider.off(Contact2DType.PRE_SOLVE, this._onPreSolve, this);
        }
    }

    /**
     * 接触開始イベント
     * 地面との接触数をカウントし、接地状態を更新する。
     */
    private _onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        this._groundContactCount++;
        this._isGrounded = this._groundContactCount > 0;
    }

    /**
     * 接触終了イベント
     * 地面との接触数を減らし、0 になったら非接地とみなす。
     */
    private _onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        this._groundContactCount = Math.max(0, this._groundContactCount - 1);
        this._isGrounded = this._groundContactCount > 0;

        if (!this._isGrounded) {
            // 空中に出たら坂ではないとみなす
            this._onSteepSlope = false;
            if (this.restoreOriginalGravity && this._rb2d) {
                this._rb2d.gravityScale = this._originalGravityScale;
            }
        }
    }

    /**
     * PRE_SOLVE イベント
     * 接触している面の法線ベクトルを取得し、坂の角度を計算する。
     */
    private _onPreSolve(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        if (!contact) {
            return;
        }

        // 法線ベクトル(selfCollider 側から見た法線)
        const normal = contact.getWorldManifold().normal;
        if (!normal) {
            return;
        }

        // 法線ベクトルを Vec2 として扱う
        const n = new Vec2(normal.x, normal.y);

        // 上向きベクトル (0,1) と法線ベクトルとの角度差を計算
        const up = new Vec2(0, 1);
        const angleRad = Vec2.angle(up, n); // 0〜PI
        const angleDeg = math.toDegree(angleRad); // 0〜180

        // 法線が「上向きに近い」ほど角度は小さく、垂直に近いほど角度は大きい。
        // ここでは、単純に angleDeg を「坂の傾き」として扱う。
        // 例: 平地 (0度)、45度坂、ほぼ垂直 (90度)。

        // 法線ベクトルの平滑化
        if (this.groundNormalSmoothing > 0) {
            const t = this.groundNormalSmoothing;
            this._smoothedGroundNormal.x = math.lerp(this._smoothedGroundNormal.x, n.x, t);
            this._smoothedGroundNormal.y = math.lerp(this._smoothedGroundNormal.y, n.y, t);
            this._smoothedGroundNormal.normalize();
        } else {
            this._smoothedGroundNormal.set(n);
        }

        // しきい値と比較
        const isSteep = angleDeg >= this.slopeAngleThreshold;

        if (this.debugLog) {
            console.log(`[SlopeSlider] angleDeg=${angleDeg.toFixed(1)} / threshold=${this.slopeAngleThreshold} / isSteep=${isSteep}`);
        }

        this._onSteepSlope = isSteep;
    }

    update(deltaTime: number) {
        if (!this.enabledSliding) {
            // 明示的に無効化されている場合、元の重力を復元して何もしない
            if (this.restoreOriginalGravity && this._rb2d) {
                this._rb2d.gravityScale = this._originalGravityScale;
            }
            return;
        }

        if (!this._rb2d) {
            return;
        }

        // 接地していて、かつ急な坂の上にいる場合のみ重力を強化
        if (this._isGrounded && this._onSteepSlope) {
            if (this._rb2d.gravityScale !== this.gravityScaleOnSlope) {
                this._rb2d.gravityScale = this.gravityScaleOnSlope;
                if (this.debugLog) {
                    console.log(`[SlopeSlider] 急な坂を検出。gravityScale を ${this.gravityScaleOnSlope} に変更しました。`);
                }
            }
        } else {
            // 急な坂でない、または空中の場合
            if (this.restoreOriginalGravity && this._rb2d.gravityScale !== this._originalGravityScale) {
                this._rb2d.gravityScale = this._originalGravityScale;
                if (this.debugLog) {
                    console.log(`[SlopeSlider] 坂から離れたため、gravityScale を元の値 ${this._originalGravityScale} に戻しました。`);
                }
            }
        }
    }
}

コードのポイント解説

  • onLoad
    • Rigidbody2D を取得し、存在しなければ console.error を出して機能停止。
    • 初期の gravityScale を保存しておき、後で復元可能にする。
    • Collider2D を取得し、BEGIN_CONTACT / END_CONTACT / PRE_SOLVE を購読。
  • _onBeginContact / _onEndContact
    • 接触している地面コライダーの数をカウントし、0より大きければ接地中とみなす。
    • 空中に出たとき(接触数が 0 になったとき)は、急な坂フラグをオフにし、必要なら重力を元に戻す。
  • _onPreSolve
    • contact.getWorldManifold().normal から法線ベクトルを取得。
    • 上向きベクトル (0,1) との角度を Vec2.angle で取得し、度数法に変換。
    • その角度が slopeAngleThreshold 以上なら「急な坂」と判定。
    • 法線ベクトルは groundNormalSmoothing に応じて線形補間し、角度変化をなめらかにする。
  • update
    • enabledSliding が false なら、復元オプションに従って重力を戻し、何もしない。
    • _isGrounded かつ _onSteepSlope のとき、gravityScaleOnSlope を適用。
    • それ以外(平地・緩い坂・空中)のとき、restoreOriginalGravity が true なら初期値に戻す。

使用手順と動作確認

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

  1. エディタの Assets パネルで任意のフォルダ(例:assets/scripts)を右クリックします。
  2. Create > TypeScript を選択し、ファイル名を SlopeSlider.ts にします。
  3. 自動生成されたコードをすべて削除し、本記事の TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用シーンとノードの用意

  1. 2D シーン を開くか、新規で作成します。
  2. Hierarchy パネルで右クリック → Create > 2D Object > Sprite を選択し、テスト用キャラクターノード(例:Player)を作成します。
  3. Player ノードを選択し、Inspector で以下のコンポーネントを追加します:
    1. Add Component > Physics 2D > RigidBody2D
      • Body Type: Dynamic(動的)
      • Gravity Scale: 1(お好みで)
    2. Add Component > Physics 2D > Collider > BoxCollider2D など
      • Sprite のサイズに合わせて Shape を調整してください。
  4. 地面・坂道を作成します:
    1. Hierarchy で右クリック → Create > 2D Object > NodeGround ノードを作成。
    2. Inspector で GroundBoxCollider2D または PolygonCollider2D を追加します。
    3. Transform を回転させて坂を作ります:
      • 例1:Rotation Z = 20 度(緩い坂)
      • 例2:Rotation Z = 40 度(急な坂)
    4. 必要に応じて StaticBody2D を追加し、地面が動かないようにします。

3. SlopeSlider コンポーネントをアタッチ

  1. Hierarchy で Player ノードを選択します。
  2. Inspector の Add Component ボタンをクリックし、Custom > SlopeSlider を選択して追加します。
  3. Inspector 上で SlopeSlider のプロパティを設定します:
    • Enabled Sliding:チェックを入れておく(true)。
    • Slope Angle Threshold30 程度に設定。
      • 20度の坂:滑らない想定。
      • 40度の坂:滑る想定。
    • Gravity Scale On Slope3.05.0 など、通常より大きい値。
    • Restore Original Gravity:チェックを入れる(平地・空中で元の重力に戻したい場合)。
    • Ground Normal Smoothing0.20.3 で様子を見る。
    • Debug Log:挙動を確認したいときはチェック(true)。

4. 動作確認

  1. エディタ右上の Play ボタンでゲームプレビューを開始します。
  2. Player を急な坂の上に配置しておき、シーン再生時の挙動を確認します。
    • 20度程度の緩い坂では、ほとんど滑らない(または通常の重力で少しだけ下る)。
    • 40度程度の急な坂では、明らかに強い重力で滑り落ちるように見えるはずです。
  3. Debug Log をオンにしている場合、コンソールに次のようなログが出力されます:
    • [SlopeSlider] angleDeg=42.3 / threshold=30 / isSteep=true
    • [SlopeSlider] 急な坂を検出。gravityScale を 3 に変更しました。
    • 平地や空中に戻ると:
      • [SlopeSlider] 坂から離れたため、gravityScale を元の値 1 に戻しました。
  4. 挙動が強すぎる/弱すぎると感じた場合は、以下のパラメータを調整します:
    • Slope Angle Threshold:値を上げると「より急な坂でのみ」滑るようになります。
    • Gravity Scale On Slope:値を上げると滑りが速くなります。
    • Ground Normal Smoothing:値を上げると角度判定が安定し、ガクつきが減ります。

まとめ

本記事では、Cocos Creator 3.8 / TypeScript で、急な坂道にいるときだけ自動的に滑り落ちる挙動を付与する汎用コンポーネント SlopeSlider を実装しました。

  • 他のカスタムスクリプトやシングルトンに一切依存せず、Rigidbody2D + Collider2D を持つ任意のノードにアタッチするだけで利用可能。
  • インスペクタから
    • 滑り始める坂の角度(Slope Angle Threshold
    • 坂道上での重力スケール(Gravity Scale On Slope
    • 元の重力への復元有無(Restore Original Gravity
    • 法線の平滑化(Ground Normal Smoothing

    を調整でき、さまざまなゲームに合わせてチューニングできます。

  • 物理接触イベントから法線ベクトルを取得して角度を算出するため、地形の形状に依存せず、どんな坂でも自動で判定できます。

プレイヤーキャラクターだけでなく、転がる岩・落ちる箱・敵キャラなど、「坂では勝手に滑ってほしい」すべての 2D 物理オブジェクトにそのまま再利用できます。
別のゲームでも SlopeSlider.ts をコピーしてアタッチするだけで同じ挙動を再現できるので、プロジェクト間の再利用性も高いコンポーネントになっています。

このコンポーネントをベースに、坂道での最大速度制限プレイヤー入力との合成などを追加していけば、よりリッチなキャラクターコントローラへ発展させることも可能です。ぜひ自分のゲームに合わせてカスタマイズしてみてください。