【Cocos Creator 3.8】Jetpack の実装:アタッチするだけで「ボタン長押しで上昇し続けるジェットパック挙動」を実現する汎用スクリプト
このガイドでは、2D/3Dどちらのプロジェクトでも、任意のキャラクターノードにアタッチするだけで「ボタン長押し → 燃料を消費しながら上昇力を加え続ける」挙動を実現できる汎用コンポーネント Jetpack を実装します。
入力は キーボード と 画面タッチ に対応し、燃料量・消費速度・推進力などはすべてインスペクタから調整可能です。外部の GameManager や入力管理スクリプトに一切依存しないため、どのプロジェクトにもそのまま持ち込んで利用できます。
コンポーネントの設計方針
1. 機能要件の整理
- ボタン(キー/タッチ)を押している間、ノードに上向きの力(または速度変更)を加え続ける。
- 推進中は燃料を消費し、燃料が 0 になると推進できない。
- 燃料は時間経過で自動回復(任意で無効化可能)。
- 2D/3D のどちらでも動作させたいので、Rigidbody2D / RigidBody の両方に対応する。
- 物理ボディが無い場合は、Transform(position)を直接変更するフォールバックも用意する。
- 入力は以下をサポートする:
- キーボード:指定キー(デフォルト Space)
- タッチ / マウス:画面押下中に推進(オプションで有効化)
- 外部スクリプトに依存せず、すべての設定は @property で公開する。
2. インスペクタで設定可能なプロパティ設計
以下のプロパティを用意します。
- 物理挙動関連
use2DPhysics: boolean
– 2D 物理(RigidBody2D)を使うかどうか。
– true:RigidBody2Dを探して力を加える。
– false:RigidBody(3D)を探して力を加える。usePhysicsForce: boolean
– 物理ボディがある場合に、力を加えるか 速度を直接変更するかを切り替える。
– true:applyForceToCenter / applyForceを使用。
– false:linearVelocity / setLinearVelocityを直接上書き。thrustPower: number
– 推進力の大きさ(上向きの力または速度の加算量)。
– 値が大きいほど強く上昇する。
– 例: 2D なら 5〜20 程度から調整。maxUpwardSpeed: number
– 上方向の最大速度(速度直接変更モードのときに適用)。
– 0 以下で無制限。fallbackMoveSpeed: number
– 物理ボディが見つからなかった場合に、position を直接変更する速度(単位: unit / 秒)。
- 燃料関連
maxFuel: number
– 最大燃料量。fuelConsumptionPerSecond: number
– 推進中に 1 秒あたり消費する燃料量。fuelRegenPerSecond: number
– 非推進時に 1 秒あたり回復する燃料量(0 で回復なし)。startWithFullFuel: boolean
– 開始時に燃料を最大値でスタートさせるか。
- 入力関連
enableKeyboard: boolean
– キーボード入力を有効にするか。keyCode: KeyCode
– 推進に使うキー(デフォルト: Space)。enablePointer: boolean
– タッチ / マウス押下で推進を有効にするか。pointerOnlyOnNode: boolean
– true: このノードをタップしている間のみ推進。
– false: 画面のどこを押しても推進。
- デバッグ / 状態確認
debugLogMissingBody: boolean
– 物理ボディが見つからない場合に警告ログを出すか。currentFuel: number (readonly)
– 現在の燃料量(インスペクタで確認用、コード内で更新)。isThrusting: boolean (readonly)
– 現在推進中かどうかのフラグ(インスペクタ確認用)。
これらのプロパティによって、2D/3D・物理/非物理・入力方法・燃料仕様まで、すべてインスペクタから完結して調整できるようにします。
TypeScriptコードの実装
以下が完成した Jetpack.ts の全コードです。
import {
_decorator,
Component,
Node,
Vec3,
input,
Input,
EventKeyboard,
EventTouch,
EventMouse,
KeyCode,
systemEvent,
SystemEvent,
PhysicsSystem2D,
RigidBody2D,
RigidBody,
macro,
} from 'cc';
const { ccclass, property } = _decorator;
/**
* Jetpack
* - キーまたはタッチを押している間、燃料を消費しつつ上昇力を加え続ける汎用コンポーネント
* - 2D: RigidBody2D / 3D: RigidBody に対応
* - 物理ボディがない場合は position を直接変更してフォールバック
*/
@ccclass('Jetpack')
export class Jetpack extends Component {
// =========================
// 物理挙動関連
// =========================
@property({
tooltip: '2D物理(RigidBody2D)を使用するかどうか。\nON: RigidBody2D を使用\nOFF: 3D物理 RigidBody を使用',
})
public use2DPhysics: boolean = true;
@property({
tooltip: '物理ボディがある場合に「力を加える」か「速度を直接変更する」かを切り替えます。\nON: 力を加える\nOFF: 速度を直接変更',
})
public usePhysicsForce: boolean = true;
@property({
tooltip: '推進力の大きさ。\n力モード: 上方向に加える力の大きさ\n速度モード: 1秒あたりの上方向速度の加算量',
})
public thrustPower: number = 10;
@property({
tooltip: '上方向の最大速度。\n0以下で無制限。\n速度直接変更モードのときのみ有効。',
})
public maxUpwardSpeed: number = 0;
@property({
tooltip: '物理ボディが見つからなかった場合に position を直接変更する移動速度(unit/秒)。',
})
public fallbackMoveSpeed: number = 5;
// =========================
// 燃料関連
// =========================
@property({
tooltip: '最大燃料量。',
})
public maxFuel: number = 3;
@property({
tooltip: '推進中に1秒あたり消費する燃料量。',
})
public fuelConsumptionPerSecond: number = 1;
@property({
tooltip: '非推進時に1秒あたり回復する燃料量。\n0で自動回復なし。',
})
public fuelRegenPerSecond: number = 0.5;
@property({
tooltip: '開始時に燃料を最大値でスタートさせるかどうか。',
})
public startWithFullFuel: boolean = true;
@property({
tooltip: '現在の燃料量(実行中のみ更新)。\nインスペクタ確認用の読み取り専用値。',
readonly: true,
})
public currentFuel: number = 0;
// =========================
// 入力関連
// =========================
@property({
tooltip: 'キーボード入力でジェットパックを起動できるようにするかどうか。',
})
public enableKeyboard: boolean = true;
@property({
tooltip: 'ジェットパック起動に使用するキー。\nデフォルトは Space。',
})
public keyCode: KeyCode = KeyCode.SPACE;
@property({
tooltip: 'タッチ / マウス押下でジェットパックを起動できるようにするかどうか。',
})
public enablePointer: boolean = true;
@property({
tooltip: 'true: このノード上をタップしている間のみ推進\nfalse: 画面のどこを押しても推進',
})
public pointerOnlyOnNode: boolean = false;
// =========================
// デバッグ関連
// =========================
@property({
tooltip: '物理ボディが見つからなかった場合に警告ログを出すかどうか。',
})
public debugLogMissingBody: boolean = true;
@property({
tooltip: '現在推進中かどうか(インスペクタ確認用)。',
readonly: true,
})
public isThrusting: boolean = false;
// =========================
// 内部状態
// =========================
private _rb2D: RigidBody2D | null = null;
private _rb3D: RigidBody | null = null;
private _keyPressed: boolean = false;
private _pointerPressed: boolean = false;
// pointerOnlyOnNode 用の簡易フラグ
private _pointerOverThisNode: boolean = false;
// 一時ベクトル(GC削減)
private static _tempVec3: Vec3 = new Vec3();
// -------------------------
// ライフサイクル
// -------------------------
onLoad() {
// 燃料初期化
this.currentFuel = this.startWithFullFuel ? this.maxFuel : this.currentFuel;
// 物理ボディ取得
if (this.use2DPhysics) {
this._rb2D = this.getComponent(RigidBody2D);
if (!this._rb2D && this.debugLogMissingBody) {
console.warn(
'[Jetpack] RigidBody2D が見つかりませんでした。' +
'2D物理での推進には、このノードに RigidBody2D コンポーネントを追加してください。' +
'見つからない場合は position 直接変更でフォールバックします。'
);
}
} else {
this._rb3D = this.getComponent(RigidBody);
if (!this._rb3D && this.debugLogMissingBody) {
console.warn(
'[Jetpack] RigidBody (3D) が見つかりませんでした。' +
'3D物理での推進には、このノードに RigidBody コンポーネントを追加してください。' +
'見つからない場合は position 直接変更でフォールバックします。'
);
}
}
// 入力イベント登録
this._registerInput();
}
onDestroy() {
this._unregisterInput();
}
// -------------------------
// 入力登録 / 解除
// -------------------------
private _registerInput() {
if (this.enableKeyboard) {
input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
}
if (this.enablePointer) {
if (this.pointerOnlyOnNode) {
// このノード上でのタッチ / マウスイベントのみ
this.node.on(Node.EventType.TOUCH_START, this._onNodeTouchStart, this);
this.node.on(Node.EventType.TOUCH_END, this._onNodeTouchEnd, this);
this.node.on(Node.EventType.TOUCH_CANCEL, this._onNodeTouchEnd, this);
this.node.on(Node.EventType.MOUSE_DOWN, this._onNodeMouseDown, this);
this.node.on(Node.EventType.MOUSE_UP, this._onNodeMouseUp, this);
} else {
// 画面全体のタッチ / マウスイベント
input.on(Input.EventType.TOUCH_START, this._onGlobalPointerDown, this);
input.on(Input.EventType.TOUCH_END, this._onGlobalPointerUp, this);
input.on(Input.EventType.TOUCH_CANCEL, this._onGlobalPointerUp, this);
input.on(Input.EventType.MOUSE_DOWN, this._onGlobalPointerDown, this);
input.on(Input.EventType.MOUSE_UP, this._onGlobalPointerUp, this);
}
}
}
private _unregisterInput() {
if (this.enableKeyboard) {
input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
}
if (this.enablePointer) {
if (this.pointerOnlyOnNode) {
this.node.off(Node.EventType.TOUCH_START, this._onNodeTouchStart, this);
this.node.off(Node.EventType.TOUCH_END, this._onNodeTouchEnd, this);
this.node.off(Node.EventType.TOUCH_CANCEL, this._onNodeTouchEnd, this);
this.node.off(Node.EventType.MOUSE_DOWN, this._onNodeMouseDown, this);
this.node.off(Node.EventType.MOUSE_UP, this._onNodeMouseUp, this);
} else {
input.off(Input.EventType.TOUCH_START, this._onGlobalPointerDown, this);
input.off(Input.EventType.TOUCH_END, this._onGlobalPointerUp, this);
input.off(Input.EventType.TOUCH_CANCEL, this._onGlobalPointerUp, this);
input.off(Input.EventType.MOUSE_DOWN, this._onGlobalPointerDown, this);
input.off(Input.EventType.MOUSE_UP, this._onGlobalPointerUp, this);
}
}
}
// -------------------------
// キーボード入力ハンドラ
// -------------------------
private _onKeyDown(event: EventKeyboard) {
if (event.keyCode === this.keyCode) {
this._keyPressed = true;
}
}
private _onKeyUp(event: EventKeyboard) {
if (event.keyCode === this.keyCode) {
this._keyPressed = false;
}
}
// -------------------------
// ポインタ入力ハンドラ(画面全体)
// -------------------------
private _onGlobalPointerDown(event: EventTouch | EventMouse) {
this._pointerPressed = true;
}
private _onGlobalPointerUp(event: EventTouch | EventMouse) {
this._pointerPressed = false;
}
// -------------------------
// ポインタ入力ハンドラ(このノード限定)
// -------------------------
private _onNodeTouchStart(event: EventTouch) {
this._pointerPressed = true;
}
private _onNodeTouchEnd(event: EventTouch) {
this._pointerPressed = false;
}
private _onNodeMouseDown(event: EventMouse) {
this._pointerPressed = true;
}
private _onNodeMouseUp(event: EventMouse) {
this._pointerPressed = false;
}
// -------------------------
// メインループ
// -------------------------
update(deltaTime: number) {
const wantThrust = this._keyPressed || this._pointerPressed;
// 燃料更新
if (wantThrust && this.currentFuel > 0) {
// 消費
const consume = this.fuelConsumptionPerSecond * deltaTime;
this.currentFuel = Math.max(0, this.currentFuel - consume);
} else {
// 回復
if (this.fuelRegenPerSecond > 0 && this.currentFuel < this.maxFuel) {
const regen = this.fuelRegenPerSecond * deltaTime;
this.currentFuel = Math.min(this.maxFuel, this.currentFuel + regen);
}
}
// 実際に推進できるか
const canThrust = wantThrust && this.currentFuel > 0;
this.isThrusting = canThrust;
if (!canThrust) {
return;
}
// 上向きの力 / 速度を適用
if (this.use2DPhysics && this._rb2D) {
this._applyThrust2D(deltaTime);
} else if (!this.use2DPhysics && this._rb3D) {
this._applyThrust3D(deltaTime);
} else {
// 物理ボディがない場合は position を直接変更
this._applyFallbackMove(deltaTime);
}
}
// -------------------------
// 推進ロジック(2D)
// -------------------------
private _applyThrust2D(deltaTime: number) {
const rb = this._rb2D!;
if (this.usePhysicsForce) {
// 力モード: 上方向に力を加える(世界座標のY+を上と仮定)
rb.applyForceToCenter(new Vec3(0, this.thrustPower, 0), true);
} else {
// 速度直接変更モード
const v = rb.linearVelocity;
let newVy = v.y + this.thrustPower * deltaTime;
if (this.maxUpwardSpeed > 0) {
newVy = Math.min(newVy, this.maxUpwardSpeed);
}
rb.linearVelocity = new Vec3(v.x, newVy, 0);
}
}
// -------------------------
// 推進ロジック(3D)
// -------------------------
private _applyThrust3D(deltaTime: number) {
const rb = this._rb3D!;
if (this.usePhysicsForce) {
// 力モード: 上方向に力を加える(世界座標のY+を上と仮定)
const force = Jetpack._tempVec3;
force.set(0, this.thrustPower, 0);
rb.applyForce(force);
} else {
// 速度直接変更モード
const v = rb.linearVelocity;
let newVy = v.y + this.thrustPower * deltaTime;
if (this.maxUpwardSpeed > 0) {
newVy = Math.min(newVy, this.maxUpwardSpeed);
}
const newV = Jetpack._tempVec3;
newV.set(v.x, newVy, v.z);
rb.setLinearVelocity(newV);
}
}
// -------------------------
// フォールバック: position 直接変更
// -------------------------
private _applyFallbackMove(deltaTime: number) {
const move = this.fallbackMoveSpeed * deltaTime;
const pos = Jetpack._tempVec3;
this.node.getPosition(pos);
pos.y += move;
this.node.setPosition(pos);
}
}
コードの要点解説
- onLoad
- 燃料初期値を設定(
startWithFullFuelが true ならmaxFuel)。 use2DPhysicsに応じてRigidBody2DまたはRigidBodyをgetComponentで取得。
見つからない場合はconsole.warnで警告し、後で position 直接変更にフォールバックします。- キーボード・タッチ・マウスの入力イベントを登録。
- 燃料初期値を設定(
- 入力ハンドラ群
- キーボード:
KEY_DOWN / KEY_UPで_keyPressedを更新。 - ポインタ:
pointerOnlyOnNode = falseの場合:inputに対してグローバルな TOUCH/MOUSE イベントを登録。pointerOnlyOnNode = trueの場合:このノードに対して TOUCH/MOUSE イベントを登録。
- 押下中かどうかは
_pointerPressedに集約。
- キーボード:
- update(deltaTime)
wantThrust = _keyPressed || _pointerPressedで「プレイヤーが推進したいか」を判定。- 推進したい & 燃料 > 0 のとき、
fuelConsumptionPerSecondに応じて燃料を減算。 - 推進していないとき、
fuelRegenPerSecondに応じて燃料を回復(上限maxFuel)。 canThrust = wantThrust && currentFuel > 0を満たすときだけ実際に推進処理を行う。- 2D/3D の物理ボディの有無に応じて、
_applyThrust2D/_applyThrust3D/_applyFallbackMoveを呼び分け。
- _applyThrust2D / _applyThrust3D
usePhysicsForce = trueのとき:- 2D:
RigidBody2D.applyForceToCenter(0, thrustPower) - 3D:
RigidBody.applyForce(0, thrustPower, 0)
- 2D:
usePhysicsForce = falseのとき:- 毎フレーム
thrustPower * deltaTimeだけ Y 速度を加算。 maxUpwardSpeed > 0の場合、上向き速度をその値でクランプ。
- 毎フレーム
- _applyFallbackMove
- 物理ボディがない場合のフォールバック処理。
fallbackMoveSpeed * deltaTimeだけ Y 方向に position を加算。- これにより、RigidBody を追加していなくても「とりあえず上昇するジェットパック」が機能します。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、任意のフォルダ(例:
assets/scripts)を右クリックします。 - Create → TypeScript を選択し、ファイル名を
Jetpack.tsにします。 - 作成された
Jetpack.tsをダブルクリックして開き、内容をすべて削除して、前述のコードを丸ごと貼り付けて保存します。
2. テスト用ノードの作成(2D の例)
ここでは 2D プロジェクトを例に説明しますが、3D でも同様に使えます。
- 上部メニューから File → New Scene でテスト用シーンを作成し、保存します。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、プレイヤー用の
Playerノードを作成します。 Playerノードを選択し、Inspector の Add Component ボタンをクリックします。- Physics 2D → RigidBody2D を追加します。
- Body Type:
Dynamicに設定。 - 必要に応じて
BoxCollider2Dなども追加してください(地面との衝突判定など)。
- Body Type:
- 再度 Add Component をクリックし、Custom → Jetpack を選択してアタッチします。
3. Jetpack プロパティの設定例(2D)
Player ノードの Inspector に表示される Jetpack コンポーネントを次のように設定してみましょう。
- 物理挙動関連
Use 2D Physics: ON(チェック)Use Physics Force: ON(まずは力モードで試す)Thrust Power: 15Max Upward Speed: 0(無制限)Fallback Move Speed: 5(今回は RigidBody2D があるので使用されません)
- 燃料関連
Max Fuel: 3Fuel Consumption Per Second: 1Fuel Regen Per Second: 0.5Start With Full Fuel: ON
- 入力関連
Enable Keyboard: ONKey Code: SPACE(デフォルトのままでOK)Enable Pointer: ON(スマホでもテストする場合)Pointer Only On Node: OFF(画面のどこでも押せば推進)
- デバッグ関連
Debug Log Missing Body: ON(RigidBody2D を付け忘れたときに警告してくれます)Current Fuel: 実行中に自動で更新されます。Is Thrusting: 実行中に Space やタッチを押すと true になります。
4. シーンの簡易セットアップ(2D)
ジェットパックの動きを確認するために、簡単な地面を用意しておくとわかりやすいです。
- Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、
Groundノードを作成します。 Groundの Transform を調整:- Position: (0, -200, 0)
- Scale: (5, 1, 1) など、横に広い地面にします。
Groundに BoxCollider2D と RigidBody2D を追加し、RigidBody2D.BodyTypeをStaticに設定します。Playerを地面の少し上(例: Position Y = -150)に配置します。
5. 再生して動作確認
- 上部の Play ボタン(▶)を押してゲームを実行します。
- ゲームビューで、Space キー を押し続けてみてください。
- 押している間、
Playerが上昇し続けます。 - 燃料が減少し、
Current Fuelが 0 になると上昇しなくなります。 - キーを離すと、少しずつ燃料が回復していきます。
- 押している間、
- スマホビルドやエディタ内でタッチ入力を試す場合:
- ゲームビュー内をマウスで押しっぱなし(またはタッチ)にすると、同様に上昇します。
- Inspector の Jetpack コンポーネントを見ながら、
Current Fuelが減ったり増えたりする様子Is Thrustingが true/false に切り替わる様子
をリアルタイムで確認できます。
6. 3D プロジェクトでの利用
3D の場合も基本的な手順は同じです。
- Hierarchy で Create → 3D Object → Cube などを作成し、キャラクターノードとします。
- そのノードに RigidBody と Collider(BoxCollider など)を追加し、
RigidBody.TypeをDynamicに設定します。 - 同じノードに Jetpack コンポーネントを追加し、次のように設定します:
Use 2D Physics: OFF- 他の値は 2D と同様に調整
- Play して Space キーを押し続けると、3D 空間でも上方向にジェットパック推進が行われます。
まとめ
この Jetpack コンポーネントは、
- 単一スクリプトで完結しており、外部の GameManager や入力管理クラスに依存しません。
- 2D/3D・物理/非物理に対応し、どんなプロジェクトにもアタッチするだけで導入可能です。
- 燃料システム・入力方法・推進力などをすべてインスペクタから調整できるため、
- アクションゲームの二段ジャンプ+ホバリング
- フライトゲームのブースト機能
- パズルゲームでの一時的な浮遊ギミック
など、さまざまなシーンで再利用できます。
ゲーム全体の設計に関わらず、「とりあえずキャラにジェットパックを付けてみる」ことが一瞬でできるようになるのが、この種の汎用コンポーネントの強みです。
今後は、同じ設計方針で「ダッシュ」「二段ジャンプ」「ホバリング」などの移動系コンポーネントを分離しておけば、プロジェクトをまたいだ再利用性が大きく向上します。
必要に応じて、
- 推進中にパーティクルやエフェクトを再生するプロパティ
- 燃料残量を UI に表示するためのイベント発行
などを追加して、自分のゲームに最適化した Jetpack に発展させてみてください。
