【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
RigidBody2DとCollider2DをgetComponentで取得し、存在しなければ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. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
LedgeGrab.tsにします。 - 作成された
LedgeGrab.tsをダブルクリックしてエディタで開き、
既存のコードをすべて削除して、前述のコードをそのまま貼り付けて保存します。
2. テスト用キャラクターノードの準備
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選び、Player という名前のノードを作成します。
- Player ノードを選択し、Inspector で以下のコンポーネントを追加します。
- Add Component → Physics 2D → RigidBody2D
- Body Type: Dynamic
- Gravity Scale: 1(デフォルトのままでOK)
- Add Component → Physics 2D → BoxCollider2D
- キャラクターの見た目に合うようにサイズを調整してください。
- Add Component → Physics 2D → RigidBody2D
- 同じく Player ノードを選択した状態で、
Add Component → Custom → LedgeGrab を選択してアタッチします。
3. 足場(崖)オブジェクトの準備
- Hierarchy で右クリック → Create → 2D Object → Sprite などを選び、Ground という名前のノードを作成します。
- Ground ノードを選択し、Inspector で:
- Add Component → Physics 2D → BoxCollider2D を追加します。
- 必要に応じて RigidBody2D(Static)を追加しても構いませんが、
Cocos Creator では Collider2D のみでも RayCast で検出可能です。
- 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. シーンの再生と挙動確認
- シーンを保存して、上部の ▶(Play)ボタン を押して実行します。
- 簡単な操作確認のため、Player に「右方向へ一定速度で移動する」ような仮スクリプトを付けてもよいですし、
Editor の Scene ビューで初期位置を調整して、落下しながら足場の角に近づくように配置しても構いません。 - Player が足場の角の「ギリギリ届く位置」を通過すると、以下のような挙動になるはずです。
- 落下中に頭と胸のレイが足場の角を検出 → 崖掴まり状態へ。
- 一瞬その場で静止し(
hangTimeBeforePull)、自動的に足場の上へスッと引き上がります。
- もしうまく掴まらない場合は、次の点を調整してください。
- Head Offset Y / Chest Offset Y:
- キャラのコライダーの高さに合わせるように調整。
- Ray Distance:
- 足場の角まで届く長さにする(短すぎると検出できない)。
- Max Grab Vertical Gap:
- 足場が高すぎる場合は値を大きくする。
- Min Fall Speed To Grab:
- 落下速度が閾値を超えていないと掴まないので、
-0.1など緩めの値にしてみる。
- 落下速度が閾値を超えていないと掴まないので、
- Head Offset Y / Chest Offset Y:
まとめ
この LedgeGrab コンポーネントは、
- Rigidbody2D + Collider2D を持つ任意のキャラクターにアタッチするだけで、
- 足場の角を RayCast で検出し、
- 自動で「崖掴まり → 引き上がり」までを完結
させることができます。外部の GameManager や入力システムに依存しないため、
- プロトタイプ段階で「とりあえず崖掴まりを入れてみたい」
- 既存プロジェクトのキャラクターに 最小限の変更で崖掴まりを追加したい
- 複数のキャラ(プレイヤー・NPC)に同じロジックを再利用したい
といった場面で、そのまま流用できます。
また、状態管理(idle/hanging/pulling)や補間移動の実装はすべてこのコンポーネント内に閉じているため、
「掴まり中は入力を無視する」「掴まり開始・終了時にアニメーションを再生する」などの拡張も、
別コンポーネントから enabledLedgeGrab や cancelLedgeGrab() を操作するだけで簡単に実現できます。
まずはこの記事のコードをそのまま動かし、Head/Chest のオフセットや Ray 距離を調整しながら、
自分のゲームに合った「気持ちいい崖掴まり」の感覚を探ってみてください。
