【Cocos Creator 3.8】PushableObject の実装:アタッチするだけで「プレイヤーがぶつかった方向と逆向きに押せる物体」を実現する汎用スクリプト
このガイドでは、PushableObject というコンポーネントを実装します。
任意のノードにこのスクリプトをアタッチし、RigidBody(2D/3D)や CharacterController を持つ「プレイヤー」がぶつかったときに、プレイヤーから見て反対側へ押される力(または速度) を与える仕組みを作ります。
外部の GameManager やプレイヤー管理スクリプトには一切依存せず、インスペクタから設定するだけで動く 汎用コンポーネントとして設計します。
コンポーネントの設計方針
1. 機能要件の整理
- このコンポーネントをアタッチしたノードが「押せる物体」となる。
- プレイヤーがこの物体に衝突した瞬間に、物体の Rigidbody に対して「プレイヤーから離れる方向」の力(または速度)を与える。
- プレイヤー側には特別なスクリプトは不要。Rigidbody(2D/3D)または CharacterController を持つノードを「プレイヤー」とみなして処理する。
- エディタのインスペクタで、押す強さ・押す方向の制限・対象の種類などを柔軟に設定できる。
- 物理次元(2D/3D)に依存しないようにしつつ、どちらにも対応できるが、実行時に存在するコンポーネントだけ使う防御的実装とする。
2. 想定する物理コンポーネント
PushableObject 自身のノードが持つ可能性のあるコンポーネント:
RigidBody2D(2D 物理)RigidBody(3D 物理)
「プレイヤー」とみなす相手側のコンポーネント:
RigidBody2DRigidBodyCharacterController(3D キャラクター移動)
どの組み合わせでも、とにかく相手の「位置」から押し出し方向を計算し、自分の Rigidbody に力や速度を与えるようにします。
3. インスペクタで設定可能なプロパティ設計
PushableObject に定義する @property 一覧と役割:
- use2DPhysics: boolean
– ツールチップ:2D 物理を使用する場合はオン。RigidBody2D + Collider2D を利用します。オフの場合は 3D 物理(RigidBody + Collider)を前提とします。
– 2D/3D どちらの物理をメインに扱うかをインスペクタで切り替える。 - pushForce: number
– ツールチップ:押す力の大きさ。Impulse モードの場合はインパルス量、Velocity モードの場合は設定する速度の大きさになります。
– 押し出しの強さ。例: 5 ~ 50 程度を目安。 - pushMode: PushMode (enum)
–IMPULSE: Rigidbody にインパルス(瞬間的な力)を加える。
–SET_VELOCITY: Rigidbody の速度ベクトルを上書きする。 - limitToHorizontal: boolean
– ツールチップ:押し出し方向を水平成分のみに制限します。オンの場合、Y(2D)/Y(3D)成分を 0 にしてから正規化します。
– 横方向だけ押したい場合に便利(上方向に飛ばないようにする)。 - minPushInterval: number
– ツールチップ:同じ相手から連続して押される間隔(秒)。この時間より短い間隔の衝突では再度押し出しを行いません。
– 連続衝突による「ガクガク押され続ける」現象を抑えるためのクールタイム。 - debugLog: boolean
– ツールチップ:押し出し処理のデバッグログを有効にします。
– 衝突時のログ出力 ON/OFF。 - playerTag: string
– ツールチップ:プレイヤーとして扱うノード名の一部。空文字の場合は、Rigidbody/CharacterController を持つ全てのノードをプレイヤー候補とします。
– 「Player」など、プレイヤーノード名の一部を指定してフィルタリングしたいときに使用。空なら無条件。
これらのプロパティにより、2D/3D、力の与え方、方向制限、プレイヤー判定の緩さをインスペクタから調整できます。
TypeScriptコードの実装
以下が完成した PushableObject コンポーネントの全コードです。
import {
_decorator,
Component,
Node,
Vec2,
Vec3,
RigidBody2D,
RigidBody,
Collider2D,
IPhysics2DContact,
PhysicsSystem2D,
Contact2DType,
Collider,
ICollisionEvent,
CharacterController,
game,
macro,
} from 'cc';
const { ccclass, property } = _decorator;
enum PushMode {
IMPULSE = 0,
SET_VELOCITY = 1,
}
@ccclass('PushableObject')
export class PushableObject extends Component {
@property({
tooltip: '2D 物理を使用する場合はオン。RigidBody2D + Collider2D を利用します。オフの場合は 3D 物理(RigidBody + Collider)を前提とします。',
})
public use2DPhysics: boolean = true;
@property({
tooltip: '押す力の大きさ。IMPULSE モードではインパルス量、SET_VELOCITY モードでは設定する速度の大きさになります。',
min: 0,
})
public pushForce: number = 10;
@property({
tooltip: '押し方のモードを選択します。IMPULSE: インパルスを加える / SET_VELOCITY: 速度ベクトルを上書きします。',
type: macro.ENUM(PushMode),
})
public pushMode: PushMode = PushMode.IMPULSE;
@property({
tooltip: '押し出し方向を水平成分のみに制限します。オンの場合、Y(2D)/Y(3D)成分を 0 にしてから正規化します。',
})
public limitToHorizontal: boolean = true;
@property({
tooltip: '同じ相手から連続して押される間隔(秒)。この時間より短い間隔の衝突では再度押し出しを行いません。',
min: 0,
})
public minPushInterval: number = 0.1;
@property({
tooltip: 'プレイヤーとして扱うノード名の一部。空文字の場合は、Rigidbody/CharacterController を持つ全てのノードをプレイヤー候補とします。',
})
public playerTag: string = '';
@property({
tooltip: '押し出し処理のデバッグログを有効にします。',
})
public debugLog: boolean = false;
private _rb2d: RigidBody2D | null = null;
private _rb3d: RigidBody | null = null;
private _lastPushTimeMap2D: Map<Node, number> = new Map();
private _lastPushTimeMap3D: Map<Node, number> = new Map();
onLoad() {
// 自身の Rigidbody を取得(2D/3D 両方試す)
this._rb2d = this.node.getComponent(RigidBody2D);
this._rb3d = this.node.getComponent(RigidBody);
if (!this._rb2d && !this._rb3d) {
console.error(
'[PushableObject] このノードには RigidBody2D も RigidBody もアタッチされていません。' +
'押し出しを物理的に反映するには、どちらか一方の Rigidbody コンポーネントを追加してください。',
this.node.name
);
}
// 2D 衝突コールバック登録
const col2d = this.node.getComponent(Collider2D);
if (col2d) {
col2d.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact2D, this);
} else if (this.use2DPhysics) {
console.warn(
'[PushableObject] use2DPhysics が true ですが Collider2D が見つかりません。' +
'2D 衝突で押し出しを行うには Collider2D を追加してください。',
this.node.name
);
}
// 3D 衝突コールバック登録
const col3d = this.node.getComponent(Collider);
if (col3d) {
col3d.on('onCollisionEnter', this._onCollisionEnter3D, this);
} else if (!this.use2DPhysics) {
console.warn(
'[PushableObject] use2DPhysics が false ですが Collider(3D) が見つかりません。' +
'3D 衝突で押し出しを行うには Collider を追加してください。',
this.node.name
);
}
}
onDestroy() {
// リスナー解除(安全のため)
const col2d = this.node.getComponent(Collider2D);
if (col2d) {
col2d.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact2D, this);
}
const col3d = this.node.getComponent(Collider);
if (col3d) {
col3d.off('onCollisionEnter', this._onCollisionEnter3D, this);
}
}
/**
* 2D 衝突開始時のコールバック
*/
private _onBeginContact2D(selfCol: Collider2D, otherCol: Collider2D, contact: IPhysics2DContact | null) {
if (!this.use2DPhysics) {
return;
}
const otherNode = otherCol.node;
if (!this._isPlayerCandidate(otherNode)) {
return;
}
const now = game.totalTime / 1000;
const lastTime = this._lastPushTimeMap2D.get(otherNode) || 0;
if (now - lastTime < this.minPushInterval) {
return;
}
this._lastPushTimeMap2D.set(otherNode, now);
const from = otherNode.worldPosition;
const to = this.node.worldPosition;
const dir3 = new Vec3();
Vec3.subtract(dir3, to, from); // プレイヤー → 物体 方向
this._applyPush2D(dir3, otherNode);
}
/**
* 3D 衝突開始時のコールバック
*/
private _onCollisionEnter3D(event: ICollisionEvent) {
if (this.use2DPhysics) {
return;
}
const otherNode = event.otherCollider.node;
if (!this._isPlayerCandidate(otherNode)) {
return;
}
const now = game.totalTime / 1000;
const lastTime = this._lastPushTimeMap3D.get(otherNode) || 0;
if (now - lastTime < this.minPushInterval) {
return;
}
this._lastPushTimeMap3D.set(otherNode, now);
const from = otherNode.worldPosition;
const to = this.node.worldPosition;
const dir = new Vec3();
Vec3.subtract(dir, to, from); // プレイヤー → 物体 方向
this._applyPush3D(dir, otherNode);
}
/**
* プレイヤー候補かどうかの判定:
* - Rigidbody2D / Rigidbody / CharacterController を持つ
* - かつ playerTag が空でない場合は node.name に playerTag が含まれている
*/
private _isPlayerCandidate(node: Node): boolean {
const hasBody2D = !!node.getComponent(RigidBody2D);
const hasBody3D = !!node.getComponent(RigidBody);
const hasCharCtrl = !!node.getComponent(CharacterController);
if (!hasBody2D && !hasBody3D && !hasCharCtrl) {
return false;
}
if (!this.playerTag) {
return true;
}
return node.name.includes(this.playerTag);
}
/**
* 2D 物理で押し出しを適用
*/
private _applyPush2D(directionWorld: Vec3, otherNode: Node) {
if (!this._rb2d) {
console.warn(
'[PushableObject] 2D 押し出しを行おうとしましたが RigidBody2D が見つかりません。',
this.node.name
);
return;
}
// 3D ベクトル → 2D ベクトル (x, y) を使用
const dir2 = new Vec2(directionWorld.x, directionWorld.y);
if (this.limitToHorizontal) {
// Y 成分を 0 にして水平方向だけにする
dir2.y = 0;
}
if (dir2.lengthSqr() === 0) {
return;
}
dir2.normalize();
switch (this.pushMode) {
case PushMode.IMPULSE: {
const impulse = new Vec2(dir2.x * this.pushForce, dir2.y * this.pushForce);
this._rb2d.applyLinearImpulse(impulse, this._rb2d.getWorldCenter(), true);
if (this.debugLog) {
console.log(
`[PushableObject][2D][IMPULSE] ${this.node.name} が ${otherNode.name} から押されました。impulse=(${impulse.x.toFixed(2)}, ${impulse.y.toFixed(2)})`
);
}
break;
}
case PushMode.SET_VELOCITY: {
const vel = new Vec2(dir2.x * this.pushForce, dir2.y * this.pushForce);
this._rb2d.linearVelocity = vel;
if (this.debugLog) {
console.log(
`[PushableObject][2D][SET_VELOCITY] ${this.node.name} が ${otherNode.name} から押されました。velocity=(${vel.x.toFixed(2)}, ${vel.y.toFixed(2)})`
);
}
break;
}
}
}
/**
* 3D 物理で押し出しを適用
*/
private _applyPush3D(directionWorld: Vec3, otherNode: Node) {
if (!this._rb3d) {
console.warn(
'[PushableObject] 3D 押し出しを行おうとしましたが RigidBody(3D) が見つかりません。',
this.node.name
);
return;
}
const dir = directionWorld.clone();
if (this.limitToHorizontal) {
// Y 成分を 0 にして水平方向だけにする
dir.y = 0;
}
if (dir.lengthSqr() === 0) {
return;
}
dir.normalize();
switch (this.pushMode) {
case PushMode.IMPULSE: {
const impulse = new Vec3(dir.x * this.pushForce, dir.y * this.pushForce, dir.z * this.pushForce);
this._rb3d.applyImpulse(impulse);
if (this.debugLog) {
console.log(
`[PushableObject][3D][IMPULSE] ${this.node.name} が ${otherNode.name} から押されました。impulse=(${impulse.x.toFixed(2)}, ${impulse.y.toFixed(2)}, ${impulse.z.toFixed(2)})`
);
}
break;
}
case PushMode.SET_VELOCITY: {
const vel = new Vec3(dir.x * this.pushForce, dir.y * this.pushForce, dir.z * this.pushForce);
this._rb3d.setLinearVelocity(vel);
if (this.debugLog) {
console.log(
`[PushableObject][3D][SET_VELOCITY] ${this.node.name} が ${otherNode.name} から押されました。velocity=(${vel.x.toFixed(2)}, ${vel.y.toFixed(2)}, ${vel.z.toFixed(2)})`
);
}
break;
}
}
}
}
コードのポイント解説
- onLoad()
– 自身のノードからRigidBody2D/RigidBodyを取得し、存在しない場合はconsole.errorで警告。
–Collider2D/Colliderにそれぞれ衝突開始イベントを登録。
–use2DPhysicsの設定と実際の Collider の有無が矛盾する場合はconsole.warnで通知。 - _onBeginContact2D()
– 2D 衝突開始時に呼ばれる。
– 衝突相手が「プレイヤー候補」かどうかを_isPlayerCandidate()で判定。
–minPushIntervalを使って、同じ相手からの連続押し出しを制限。
– プレイヤー位置 → 自分の位置のベクトルを計算し、_applyPush2D()で力を加える。 - _onCollisionEnter3D()
– 3D 衝突開始時に呼ばれる。ロジックは 2D 版とほぼ同じだが、Vec3を使い、_applyPush3D()に委譲する。 - _isPlayerCandidate()
– 相手ノードにRigidBody2D/RigidBody/CharacterControllerのいずれかが付いているか確認。
–playerTagが空でなければ、ノード名にその文字列が含まれているかもチェック。 - _applyPush2D() / _applyPush3D()
– 与えられたワールド方向ベクトルを、limitToHorizontalに応じて Y 成分を 0 にするかどうか決め、正規化。
–pushModeに応じて、applyLinearImpulse / applyImpulseもしくはlinearVelocity / setLinearVelocityを使って押し出しを適用。
–debugLogが有効な場合は、実際に与えたインパルス/速度をログ出力。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタの Assets パネルで任意のフォルダを右クリックします。
- Create > TypeScript を選択し、ファイル名を
PushableObject.tsとします。 - 自動生成された中身をすべて削除し、このガイドで示した TypeScript コード全体 を貼り付けて保存します。
2. 押せる物体ノードの準備
ここでは 2D の例と 3D の例をそれぞれ示します。
2D でのセットアップ例
- Hierarchy パネルで右クリック → Create > 2D Object > Sprite を選択し、名前を
Box2Dなどに変更します。 - Box2D ノードを選択し、Inspector で次を追加します:
- Add Component > Physics 2D > RigidBody2D
- Add Component > Physics 2D > BoxCollider2D(形状は任意の Collider2D)
- 同じく Inspector で:
- Add Component > Custom > PushableObject を追加。
- PushableObject コンポーネントのプロパティを設定:
- use2DPhysics:
ON - pushForce: 例として
15 - pushMode:
IMPULSE(最初はインパルスがおすすめ) - limitToHorizontal:
ON(横方向だけ押されるように) - minPushInterval:
0.1~0.2くらい - playerTag: 空のまま、もしくは
Playerなど - debugLog: 動作確認時は
ONにしておくとログが見やすいです。
- use2DPhysics:
2D プレイヤーノードの簡易作成
- Hierarchy パネルで右クリック → Create > 2D Object > Sprite を選択し、名前を
Player2Dにします。 - Inspector で:
- Add Component > Physics 2D > RigidBody2D
- Add Component > Physics 2D > BoxCollider2D
Player2Dをシーン上でBox2Dの近くに配置します。
この状態で、プレイ中に Player2D をキーボード移動させるスクリプトなどがあれば、Box2D にぶつかった瞬間に Box2D がプレイヤーから離れる方向に押されるのが確認できます。
(移動スクリプトは任意ですが、もし無い場合は、Scene ビューでドラッグして動かしながらテストしても衝突は確認できます。)
3D でのセットアップ例
- Hierarchy パネルで右クリック → Create > 3D Object > Cube を選択し、名前を
Box3Dにします。 - Inspector で:
- Add Component > Physics > RigidBody
- Add Component > Physics > BoxCollider
- 同じく Inspector で:
- Add Component > Custom > PushableObject を追加。
- PushableObject のプロパティ:
- use2DPhysics:
OFF - pushForce: 例として
5~20 - pushMode:
IMPULSEかSET_VELOCITYを好みで選択 - limitToHorizontal:
ON(Y 方向に飛びすぎないように) - minPushInterval:
0.1 - playerTag:
Playerなど(プレイヤーノード名に合わせる) - debugLog:
ON
- use2DPhysics:
3D プレイヤーノードの簡易作成
- Hierarchy パネルで右クリック → Create > 3D Object > Capsule などを選び、名前を
Player3Dにします。 - Inspector で:
- Add Component > Physics > RigidBody(もしくは CharacterController を使っている場合はそれでも可)
- Add Component > Physics > CapsuleCollider などの 3D Collider
Player3DをBox3Dの近くに配置します。
プレイヤー移動スクリプトで Player3D を動かし、Box3D にぶつかると、ぶつかった方向の反対側へ Box3D が押される動作が確認できます。
3. デバッグと調整のコツ
- 押され方が弱い/強すぎる
→pushForceを上下させて調整します。2D と 3D で適切な値は異なるので、シーンごとに調整してください。 - 連続でガクガク押されてしまう
→minPushIntervalを0.2 ~ 0.3程度に上げると安定しやすくなります。 - 上方向に飛んでしまう
→limitToHorizontalをONにして、水平成分だけに制限します。 - 特定のプレイヤーだけに反応させたい
→playerTagにPlayerなど、対象プレイヤーノード名に含まれる文字列を設定します。 - そもそも押されていないように見える
→debugLogをONにしてコンソールを確認し、衝突イベントやプレイヤー判定が正しく行われているかをチェックします。
まとめ
この PushableObject コンポーネントを使うことで、
- 任意のノードにアタッチするだけで「プレイヤーに押されるオブジェクト」を簡単に実装できる。
- 2D/3D のどちらにも対応し、押す強さ・方向・頻度・対象プレイヤーをすべてインスペクタから調整できる。
- 外部の GameManager やプレイヤースクリプトには依存せず、このスクリプト単体で完結するため、プロジェクトをまたいだ再利用性が高い。
応用例としては、
- パズルゲームの「押せる箱」や「スイッチ付きブロック」
- アクションゲームの「押して動かす足場」
- NPC がぶつかると動くオブジェクト(プレイヤー以外でも Rigidbody/CharacterController を持てば押せる)
など、多くのシーンでそのまま利用できます。
プロジェクトの共通ユーティリティとして PushableObject.ts を 1 つ置いておけば、「押せるギミック」を作るたびにコードを書く必要がなくなり、ゲーム開発のスピードと保守性が大きく向上します。
必要に応じて、今後は「押された量に応じてスイッチを ON にする」「一定距離だけ押されたらロックする」など、追加機能を別コンポーネントとして重ねていくと、さらに柔軟なギミック構成が作れるようになります。




