【Cocos Creator 3.8】LedgeGrab(崖掴まり)の実装:アタッチするだけで「足場の角に手が届いたら自動で引き上がる」キャラクター挙動を実現する汎用スクリプト

このコンポーネントは、2Dアクションゲームでよく見る「崖掴まり → 引き上がり」挙動を、1つのスクリプトをキャラクターノードにアタッチするだけで実現するためのものです。
Rigidbody2D + Collider2D を持つキャラクターに付けると、足場の角にギリギリ届く位置で自動的に崖を掴み、レイで検出した角の位置まで体を引き上げてくれます

他の GameManager や入力管理スクリプトには一切依存せず、インスペクタで調整可能なプロパティのみで動作します。


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

1. 機能要件の整理

  • キャラクターがジャンプ中などで足場の端に「ギリギリ届く」位置に来たときに、RayCast で足場の角を検出する。
  • 角が見つかったら、一旦その場でキャラクターを固定(崖掴まり状態)にする。
  • その後、角の少し上(立てる位置)までキャラクターをスムーズに移動させる(引き上がり)。
  • ジャンプ中かどうか、落下中かどうかなど、外部の状態管理には依存しない
    • 代わりに、「上昇中は掴まない」「落下速度が一定以上のときだけ掴む」などをプロパティで制御できるようにする。
  • 足場は Collider2D を持つオブジェクトを前提とし、物理 RayCast で検出する。
  • キャラクターには Rigidbody2D + Collider2D が必須。
  • コンポーネント単体で完結させるため、入力処理は「自動引き上げ」オンリーとする(キー入力不要)。

2. 崖掴まり検出のアプローチ

キャラクターの 頭の少し上から前方へ Ray を飛ばして足場の角の「上側」を検出し、
同時に 胸あたりから前方へ Ray を飛ばして「側面」を検出することで、角の位置を推定します。

  • 頭レイ(head ray): キャラの中心から headOffsetY 上にずらした位置から、forward 方向に発射。
  • 胸レイ(chest ray): キャラの中心から chestOffsetY 上にずらした位置から、同じく forward 方向に発射。
  • 頭レイが「上面」に当たり、胸レイが「側面」に当たる & その距離が一定範囲内なら「崖掴まり可能」と判定。

検出に成功したら、角の座標 + オフセットを「引き上がり先」として決め、
キャラクターを 一定時間をかけてそのポイントまで補間移動させます。

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

以下のようなプロパティを用意し、ゲームごとに細かく調整できるようにします。

  • enabledLedgeGrab (boolean)
    • 崖掴まり機能のオン/オフ。
    • デバッグや特定シーンで無効化したいときに使用。
  • forwardIsRight (boolean)
    • true: 右方向を「前方」とみなしてレイを飛ばす。
    • false: 左方向を「前方」とみなす。
    • キャラの向きをこのコンポーネント内で管理しない代わりに、向きが固定のゲームやテスト用として用意。
    • もしゲーム内で向きが頻繁に変わる場合は、他のスクリプトからこの値を変更してもよい。
  • headOffsetY (number)
    • キャラクター中心から見た「頭レイの発射位置」の高さ(ローカルY座標)。
    • 例: キャラ身長が 2 の場合、1.0〜1.2 くらい。
  • chestOffsetY (number)
    • 「胸レイの発射位置」の高さ。
    • 例: 0.4〜0.7 くらい。
  • rayDistance (number)
    • 頭・胸レイの長さ。
    • キャラの半分くらい先の足場まで届くように、0.5〜1.0 くらいが目安。
  • maxGrabVerticalGap (number)
    • キャラの現在位置と「掴める角」の高さ差の最大値。
    • これより高い位置の角は「届かない」とみなす。
    • 例: 1.0〜1.5
  • minFallSpeedToGrab (number)
    • 崖掴まりを許可するために必要な 落下速度(マイナスY速度)の閾値
    • 例: -1.0 とすると、ある程度落下しているときだけ掴む。
    • 0 にすると「上昇中も含めて常に判定」になるため、通常は -1.0 など負の値を推奨。
  • pullUpDuration (number)
    • 崖掴まり状態から「角の上まで」引き上がるのにかける時間(秒)。
    • 例: 0.2〜0.4 くらいで素早い引き上がり。
  • hangTimeBeforePull (number)
    • 崖を掴んでから、引き上がりを開始するまでの待機時間(秒)。
    • 0 にすると掴んだ瞬間にすぐ引き上がる。
  • ledgeOffsetX (number)
    • 引き上がり完了後の「崖からの水平オフセット」。
    • 正の値で「崖の少し内側」に立たせるイメージ。
  • ledgeOffsetY (number)
    • 引き上がり完了後の「崖からの垂直オフセット」。
    • 少し足場の上に浮かせる/沈める調整用。
  • raycastMask (number)
    • 物理レイキャストの グループマスク
    • 足場に設定している PhysicsGroup に合わせることで、キャラ自身のコライダーなどを無視できる。
  • debugDraw (boolean)
    • レイの向きや検出位置を 画面上に描画するかどうか。
    • デバッグ時のみ true にするのがおすすめ。

また、内部的には以下のような状態を管理します。

  • _state:
    • 'idle': 通常状態(崖掴まりなし)
    • 'hanging': 崖を掴んで静止している状態
    • 'pulling': 引き上がり中
  • _hangTimer: 掴んでからどれくらい経ったか。
  • _pullTimer: 引き上がり開始からの経過時間。
  • _startPos, _targetPos: 引き上がりの開始位置と目標位置。

TypeScriptコードの実装

以下が、LedgeGrab.ts コンポーネントの完全な実装コードです。


import { _decorator, Component, Node, Vec2, Vec3, RigidBody2D, PhysicsSystem2D, EPhysics2DDrawFlags, ERaycast2DType, Contact2DType, IPhysics2DContact, Collider2D, debug, geometry } from 'cc';
const { ccclass, property } = _decorator;

/**
 * LedgeGrab
 * 2D用 崖掴まりコンポーネント
 * - Rigidbody2D + Collider2D を持つキャラクターにアタッチして使用
 * - 足場(Collider2D)に対して RayCast を行い、角が見つかれば自動で引き上げる
 */
@ccclass('LedgeGrab')
export class LedgeGrab extends Component {

    @property({
        tooltip: '崖掴まり機能の有効/無効。false にすると一切判定を行いません。'
    })
    public enabledLedgeGrab: boolean = true;

    @property({
        tooltip: 'true: 右方向を前方としてレイを飛ばします。\nfalse: 左方向を前方とします。\nキャラクターの向きが変わるゲームでは、別スクリプトからこの値を更新してください。'
    })
    public forwardIsRight: boolean = true;

    @property({
        tooltip: '頭レイの発射位置(ローカルY)。キャラクター中心からのオフセットです。'
    })
    public headOffsetY: number = 0.9;

    @property({
        tooltip: '胸レイの発射位置(ローカルY)。キャラクター中心からのオフセットです。'
    })
    public chestOffsetY: number = 0.4;

    @property({
        tooltip: 'レイキャストの長さ。キャラクターの前方何mまで足場を検出するかを指定します。'
    })
    public rayDistance: number = 0.7;

    @property({
        tooltip: 'キャラクター位置から掴める角までの最大垂直距離。これより高い角は届かないとみなします。'
    })
    public maxGrabVerticalGap: number = 1.2;

    @property({
        tooltip: '崖掴まりを許可する最低落下速度(マイナスY)。\n例: -1.0 なら、ある程度落下しているときだけ掴みます。'
    })
    public minFallSpeedToGrab: number = -1.0;

    @property({
        tooltip: '崖を掴んでから角の上まで引き上がるのにかかる時間(秒)。'
    })
    public pullUpDuration: number = 0.25;

    @property({
        tooltip: '崖を掴んでから引き上がりを開始するまでの待機時間(秒)。\n0 にすると即座に引き上がります。'
    })
    public hangTimeBeforePull: number = 0.05;

    @property({
        tooltip: '引き上がり完了後、崖の角からどれだけ内側(X)に寄せるか。正の値で足場の内側に移動します。'
    })
    public ledgeOffsetX: number = 0.2;

    @property({
        tooltip: '引き上がり完了後、崖の角からどれだけ上下(Y)にずらすか。正の値で少し上に立たせます。'
    })
    public ledgeOffsetY: number = 0.0;

    @property({
        tooltip: 'レイキャストで検出する物理グループのマスク。\n足場のCollider2Dが属するグループを指定してください。'
    })
    public raycastMask: number = 0xffffffff;

    @property({
        tooltip: 'true にするとレイキャストの可視化を行います(エディタ/デバッグ用)。'
    })
    public debugDraw: boolean = false;

    // 内部状態管理
    private _rb: RigidBody2D | null = null;
    private _col: Collider2D | null = null;

    private _state: 'idle' | 'hanging' | 'pulling' = 'idle';
    private _hangTimer: number = 0;
    private _pullTimer: number = 0;

    private _startPos: Vec3 = new Vec3();
    private _targetPos: Vec3 = new Vec3();

    // 崖掴まり中に一時的に保存しておく速度
    private _savedLinearVelocity: Vec2 = new Vec2();

    onLoad() {
        this._rb = this.getComponent(RigidBody2D);
        this._col = this.getComponent(Collider2D);

        if (!this._rb) {
            console.error('[LedgeGrab] このノードには RigidBody2D が必要です。Add Component から追加してください。');
        }
        if (!this._col) {
            console.error('[LedgeGrab] このノードには Collider2D が必要です。Add Component から追加してください。');
        }

        // デバッグ描画設定
        if (this.debugDraw) {
            PhysicsSystem2D.instance.debugDrawFlags =
                EPhysics2DDrawFlags.Aabb |
                EPhysics2DDrawFlags.Pair |
                EPhysics2DDrawFlags.CenterOfMass |
                EPhysics2DDrawFlags.Joint |
                EPhysics2DDrawFlags.Shape;
        }
    }

    start() {
        // 特に初期化は不要だが、将来の拡張用に分けておく
    }

    update(deltaTime: number) {
        if (!this.enabledLedgeGrab) {
            return;
        }
        if (!this._rb || !this._col) {
            return;
        }

        switch (this._state) {
            case 'idle':
                this.updateIdleState(deltaTime);
                break;
            case 'hanging':
                this.updateHangingState(deltaTime);
                break;
            case 'pulling':
                this.updatePullingState(deltaTime);
                break;
        }
    }

    /**
     * 通常状態(崖掴まりしていない)での更新処理。
     * ここでレイキャストを行い、崖掴まり可能かどうかを判定します。
     */
    private updateIdleState(deltaTime: number) {
        // 一定以上の落下中のみ判定(上昇中に掴まるのを防ぐ)
        const vel = this._rb!.linearVelocity;
        if (vel.y > this.minFallSpeedToGrab) {
            return;
        }

        const node = this.node;
        const worldPos = node.worldPosition;

        const forward = this.forwardIsRight ? 1 : -1;

        // レイ発射位置(ワールド座標)
        const headOrigin = new Vec2(worldPos.x, worldPos.y + this.headOffsetY);
        const chestOrigin = new Vec2(worldPos.x, worldPos.y + this.chestOffsetY);
        const dir = new Vec2(forward, 0);

        const headEnd = new Vec2(
            headOrigin.x + dir.x * this.rayDistance,
            headOrigin.y + dir.y * this.rayDistance
        );
        const chestEnd = new Vec2(
            chestOrigin.x + dir.x * this.rayDistance,
            chestOrigin.y + dir.y * this.rayDistance
        );

        const physics = PhysicsSystem2D.instance;

        const headResults = physics.raycast(headOrigin, headEnd, ERaycast2DType.Closest, this.raycastMask);
        const chestResults = physics.raycast(chestOrigin, chestEnd, ERaycast2DType.Closest, this.raycastMask);

        if (this.debugDraw) {
            // レイ自体は PhysicsSystem2D のデバッグ描画に任せてもよいが、
            // ここではログのみ簡易出力。
            // console.log('[LedgeGrab] headResults:', headResults.length, 'chestResults:', chestResults.length);
        }

        if (headResults.length === 0 || chestResults.length === 0) {
            return;
        }

        const headHit = headResults[0];
        const chestHit = chestResults[0];

        // 同じコライダーに当たっているかどうかは必須ではないが、
        // 角の判定としては同じ足場である方が自然。
        if (headHit.collider !== chestHit.collider) {
            return;
        }

        // 角の位置を推定
        // - headHit.point: 上面に当たっていることを期待
        // - chestHit.point: 側面に当たっていることを期待
        const headPoint = headHit.point;
        const chestPoint = chestHit.point;

        // 高さ差が大きすぎる場合は届かないと判断
        const verticalGap = headPoint.y - worldPos.y;
        if (verticalGap > this.maxGrabVerticalGap) {
            return;
        }

        // 崖の角の推定位置:
        // - forward が 1 の場合: (chestPoint.x, headPoint.y)
        // - forward が -1 の場合も同様だが、念のため forward を使って計算
        const ledgeCorner = new Vec3(
            chestPoint.x,
            headPoint.y,
            worldPos.z
        );

        // 現在位置からの距離が近すぎる/遠すぎる場合など、追加のチェックを入れてもよい

        // 崖掴まり開始
        this.beginHang(ledgeCorner);
    }

    /**
     * 崖掴まり状態の更新。
     * 一定時間待機した後、自動的に引き上がりを開始します。
     */
    private updateHangingState(deltaTime: number) {
        this._hangTimer += deltaTime;
        if (this._hangTimer >= this.hangTimeBeforePull) {
            this.beginPullUp();
        }
    }

    /**
     * 引き上がり中の更新。
     * 開始位置から目標位置までを補間移動します。
     */
    private updatePullingState(deltaTime: number) {
        this._pullTimer += deltaTime;
        const t = Math.min(1.0, this._pullTimer / this.pullUpDuration);

        // イージング(好みに応じて変更可能)
        const easedT = t * t * (3 - 2 * t); // smoothstep

        const newPos = new Vec3();
        Vec3.lerp(newPos, this._startPos, this._targetPos, easedT);
        this.node.worldPosition = newPos;

        if (t >= 1.0) {
            this.endPullUp();
        }
    }

    /**
     * 崖掴まり開始処理。
     * - Rigidbody2D を一時的に停止
     * - 現在位置を保持し、目標位置を計算
     */
    private beginHang(ledgeCorner: Vec3) {
        if (!this._rb) return;

        this._state = 'hanging';
        this._hangTimer = 0;
        this._pullTimer = 0;

        // 現在の速度を保存し、停止
        this._savedLinearVelocity.set(this._rb.linearVelocity);
        this._rb.linearVelocity = new Vec2(0, 0);
        this._rb.angularVelocity = 0;
        this._rb.gravityScale = 0; // 重力も一時的に無効化

        // 掴んだ瞬間の位置を開始位置とする
        this._startPos.set(this.node.worldPosition);

        const forward = this.forwardIsRight ? 1 : -1;

        // 目標位置(角の少し内側・少し上)
        this._targetPos.set(
            ledgeCorner.x + this.ledgeOffsetX * forward,
            ledgeCorner.y + this.ledgeOffsetY,
            this._startPos.z
        );
    }

    /**
     * 引き上がり開始処理。
     */
    private beginPullUp() {
        this._state = 'pulling';
        this._pullTimer = 0;
        // すでに beginHang で _startPos/_targetPos は設定済み
    }

    /**
     * 引き上がり完了処理。
     * - Rigidbody2D の重力を元に戻し、速度もある程度復元(もしくはゼロにする)します。
     */
    private endPullUp() {
        if (!this._rb) return;

        this._state = 'idle';
        this._hangTimer = 0;
        this._pullTimer = 0;

        // 位置はすでに _targetPos にある前提
        this.node.worldPosition = this._targetPos;

        // 重力を元に戻す
        this._rb.gravityScale = 1;

        // 速度はゼロから再開する方が制御しやすいが、
        // 好みに応じて _savedLinearVelocity を少しだけ戻してもよい。
        this._rb.linearVelocity = new Vec2(0, 0);
    }

    /**
     * 外部から強制的に崖掴まり状態を解除したい場合に呼び出すユーティリティ。
     */
    public cancelLedgeGrab() {
        if (!this._rb) return;

        this._state = 'idle';
        this._hangTimer = 0;
        this._pullTimer = 0;
        this._rb.gravityScale = 1;
        // 保存していた速度を戻すかどうかはゲーム仕様に応じて変更
        this._rb.linearVelocity = this._savedLinearVelocity.clone();
    }
}

コードのポイント解説

  • onLoad
    • RigidBody2DCollider2DgetComponent で取得し、存在しなければ console.error で警告。
    • debugDraw が true の場合は PhysicsSystem2D.instance.debugDrawFlags を設定して、物理デバッグ描画を有効にします。
  • update
    • 状態マシン(_state)に応じて updateIdleState / updateHangingState / updatePullingState を呼び分けます。
    • enabledLedgeGrab が false のときは何もしません。
  • updateIdleState
    • 落下速度が minFallSpeedToGrab より遅い(= 上昇中 or ほぼ停止)ときは処理をスキップ。
    • 頭・胸の 2本のレイを 前方方向(右 or 左) に飛ばして、足場の角を推定。
    • 高さ差が maxGrabVerticalGap を超える場合は届かないと判断。
    • 条件を満たしたら beginHang で崖掴まり状態へ遷移。
  • beginHang
    • 現在の速度を保存し、Rigidbody2D の速度・角速度を 0 にして重力も 0 にすることで、その場に固定。
    • 現在位置を _startPos に、
      崖角 + オフセットを _targetPos に保存。
    • 状態を 'hanging' に変更。
  • updateHangingState
    • hangTimeBeforePull 秒経過したら beginPullUp を呼び、'pulling' 状態へ。
  • updatePullingState
    • pullUpDuration に渡って _startPos_targetPosスムーズに補間移動
    • イージングには簡易 smoothstep(t * t * (3 - 2 * t))を使用。
    • 完了したら endPullUp を呼び、重力を元に戻して 'idle' 状態へ。
  • cancelLedgeGrab
    • 任意に崖掴まりをキャンセルしたい場合に使える public メソッド。
    • 今回の記事ではキーバインドなどは行っていませんが、別コンポーネントから呼び出せるように用意しています。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を LedgeGrab.ts にします。
  3. 作成された LedgeGrab.ts をダブルクリックしてエディタで開き、
    既存のコードをすべて削除して、前述のコードをそのまま貼り付けて保存します。

2. テスト用キャラクターノードの準備

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選び、Player という名前のノードを作成します。
  2. Player ノードを選択し、Inspector で以下のコンポーネントを追加します。
    1. Add Component → Physics 2D → RigidBody2D
      • Body Type: Dynamic
      • Gravity Scale: 1(デフォルトのままでOK)
    2. Add Component → Physics 2D → BoxCollider2D
      • キャラクターの見た目に合うようにサイズを調整してください。
  3. 同じく Player ノードを選択した状態で、
    Add Component → Custom → LedgeGrab を選択してアタッチします。

3. 足場(崖)オブジェクトの準備

  1. Hierarchy で右クリック → Create → 2D Object → Sprite などを選び、Ground という名前のノードを作成します。
  2. Ground ノードを選択し、Inspector で:
    1. Add Component → Physics 2D → BoxCollider2D を追加します。
    2. 必要に応じて RigidBody2D(Static)を追加しても構いませんが、
      Cocos Creator では Collider2D のみでも RayCast で検出可能です。
  3. Ground の Transform を調整して、Player がジャンプすれば届きそうな高さに配置します。
    • 例えば:
      • Player: (0, 0)
      • Ground: (1.5, 1.0) 〜 (3.5, 1.0) くらいの位置に横長の足場を置くイメージ。

4. LedgeGrab プロパティの設定例

Player ノードの Inspector で、LedgeGrab コンポーネントのプロパティを次のように設定してみてください。

  • Enabled Ledge Grab: チェック(true)
  • Forward Is Right: チェック(true)
  • Head Offset Y: 0.9
  • Chest Offset Y: 0.4
  • Ray Distance: 0.7
  • Max Grab Vertical Gap: 1.2
  • Min Fall Speed To Grab: -1.0
  • Pull Up Duration: 0.25
  • Hang Time Before Pull: 0.05
  • Ledge Offset X: 0.2
  • Ledge Offset Y: 0.0
  • Raycast Mask: 0xffffffff(とりあえず全グループ対象)
  • Debug Draw: 必要に応じてチェック

※足場の PhysicsGroup をカスタマイズしている場合は、raycastMask をそれに合わせて変更してください。

5. シーンの再生と挙動確認

  1. シーンを保存して、上部の ▶(Play)ボタン を押して実行します。
  2. 簡単な操作確認のため、Player に「右方向へ一定速度で移動する」ような仮スクリプトを付けてもよいですし、
    Editor の Scene ビューで初期位置を調整して、落下しながら足場の角に近づくように配置しても構いません。
  3. Player が足場の角の「ギリギリ届く位置」を通過すると、以下のような挙動になるはずです。
    • 落下中に頭と胸のレイが足場の角を検出 → 崖掴まり状態へ。
    • 一瞬その場で静止し(hangTimeBeforePull)、自動的に足場の上へスッと引き上がります。
  4. もしうまく掴まらない場合は、次の点を調整してください。
    • Head Offset Y / Chest Offset Y:
      • キャラのコライダーの高さに合わせるように調整。
    • Ray Distance:
      • 足場の角まで届く長さにする(短すぎると検出できない)。
    • Max Grab Vertical Gap:
      • 足場が高すぎる場合は値を大きくする。
    • Min Fall Speed To Grab:
      • 落下速度が閾値を超えていないと掴まないので、-0.1 など緩めの値にしてみる。

まとめ

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

  • Rigidbody2D + Collider2D を持つ任意のキャラクターにアタッチするだけで、
  • 足場の角を RayCast で検出し、
  • 自動で「崖掴まり → 引き上がり」までを完結

させることができます。外部の GameManager や入力システムに依存しないため、

  • プロトタイプ段階で「とりあえず崖掴まりを入れてみたい」
  • 既存プロジェクトのキャラクターに 最小限の変更で崖掴まりを追加したい
  • 複数のキャラ(プレイヤー・NPC)に同じロジックを再利用したい

といった場面で、そのまま流用できます。

また、状態管理(idle/hanging/pulling)や補間移動の実装はすべてこのコンポーネント内に閉じているため、
「掴まり中は入力を無視する」「掴まり開始・終了時にアニメーションを再生する」などの拡張も、
別コンポーネントから enabledLedgeGrabcancelLedgeGrab() を操作するだけで簡単に実現できます。

まずはこの記事のコードをそのまま動かし、Head/Chest のオフセットや Ray 距離を調整しながら、
自分のゲームに合った「気持ちいい崖掴まり」の感覚
を探ってみてください。