【Cocos Creator 3.8】InputRemapperの実装:アタッチするだけでゲーム中にキー/ボタン設定を変更できる汎用スクリプト
ゲーム中の「キーコンフィグ」画面や「ボタン割り当て変更」機能を、1つのコンポーネントをアタッチするだけで実現できるようにするのが本記事の目的です。
ここで作る InputRemapper は、キーボードやゲームパッドからの入力を検知し、任意の「アクション名」に対してキー/ボタンを割り当てるための汎用コンポーネントです。
外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結するように設計します。インスペクタでアクション名や保存キーなどを設定し、UIボタンなどから公開メソッドを呼び出すだけで運用できる構成にします。
コンポーネントの設計方針
1. 機能要件の整理
- キーボードやゲームパッドの入力を検知し、「次に押されたキー/ボタン」を取得できる。
- 取得したキー/ボタンを、任意の「アクション名」に紐づけて内部の InputMap に保存する。
- InputMap は メモリ上の辞書 として保持し、さらに ローカルストレージ(localStorage) に保存・読み込みできる。
- 他のスクリプトに依存しないため、InputRemapper 自身が入力の監視とマッピング管理を行う。
- UI ボタンなどから呼び出しやすいように、public メソッドをいくつか用意する。
- ゲーム中からも利用しやすいように、現在の割り当て状態をログで確認できる機能を持たせる。
2. 入力の検知方法
Cocos Creator 3.8 では、キーボード・マウス・ゲームパッドなどの入力は input モジュールからイベントとして受け取れます。
input.on(Input.EventType.KEY_DOWN, ...)でキーボード入力を取得input.on(Input.EventType.GAMEPAD_INPUT, ...)でゲームパッド入力を取得
本コンポーネントでは、「今は割り当て待ち状態か?」というフラグを持ち、割り当て待ち状態のときだけ次に押されたキー/ボタンを記録します。
3. InputMap の設計
InputMap は以下のような構造のオブジェクトとして扱います:
// 内部的なイメージ
interface InputBinding {
type: 'keyboard' | 'gamepad';
code: string; // キーボード: KeyCode名, ゲームパッド: ボタンや軸の識別子
index?: number; // ゲームパッドのインデックス (0,1,2...)
}
type InputMap = {
[actionName: string]: InputBinding | null;
};
この InputMap を JSON にシリアライズして localStorage に保存・読み込みします。
キー名はインスペクタから設定可能にし、プロジェクトごとに変えられるようにします。
4. インスペクタで設定可能なプロパティ
- storageKey: string
- ツールチップ:
localStorageに保存する際のキー名。プロジェクトごとに一意にしてください。 - 役割: InputMap を JSON で保存する際のキー名。例:
"myGame.inputMap"
- ツールチップ:
- defaultActions: string[]
- ツールチップ:
事前に用意しておくアクション名のリスト。起動時にInputMapに登録されます。 - 役割: 「Jump」「Attack」「Dash」など、割り当て対象となるアクション名の一覧。
- 備考: 起動時にまだ割り当てがないアクションは
nullとして初期化されます。
- ツールチップ:
- listenKeyboard: boolean
- ツールチップ:
キーボード入力を割り当てに使用するかどうか。
- ツールチップ:
- listenGamepad: boolean
- ツールチップ:
ゲームパッド入力を割り当てに使用するかどうか。
- ツールチップ:
- autoSaveOnChange: boolean
- ツールチップ:
割り当てが変更されたタイミングで自動的にlocalStorageへ保存します。
- ツールチップ:
- logDebug: boolean
- ツールチップ:
入力検知や割り当て変更時にログを出力してデバッグしやすくします。
- ツールチップ:
5. 公開メソッドの設計
- beginListenForAction(actionName: string)
- 指定したアクションに対して、次に押されたキー/ボタンを割り当てるモードに入る。
- UIボタンから
this.node.getComponent(InputRemapper).beginListenForAction('Jump')のように呼ぶ想定。
- cancelListening()
- 割り当て待ち状態をキャンセルする。
- clearBinding(actionName: string)
- 指定アクションの割り当てを解除する。
- saveToStorage()
- 現在の InputMap を
localStorageに保存する。
- 現在の InputMap を
- loadFromStorage()
localStorageから InputMap を読み込む。
- getBinding(actionName: string): string
- 指定アクションに現在割り当てられているキー/ボタンを文字列で返す。UI表示などに利用。
- logAllBindings()
- 全アクションの現在の割り当てをログに出力する。
TypeScriptコードの実装
import { _decorator, Component, input, Input, EventKeyboard, EventGamepad, KeyCode, game } from 'cc';
const { ccclass, property } = _decorator;
type BindingType = 'keyboard' | 'gamepad';
interface InputBinding {
type: BindingType;
code: string; // keyboard: KeyCode名, gamepad: ボタンor軸名
index?: number; // gamepad index
}
type InputMap = { [action: string]: InputBinding | null };
@ccclass('InputRemapper')
export class InputRemapper extends Component {
@property({
tooltip: 'localStorageに保存する際のキー名。プロジェクトごとに一意にしてください。',
})
public storageKey: string = 'myGame.inputMap';
@property({
tooltip: '事前に用意しておくアクション名のリスト。起動時にInputMapに登録されます。',
})
public defaultActions: string[] = ['Jump', 'Attack', 'Dash'];
@property({
tooltip: 'キーボード入力を割り当てに使用するかどうか。',
})
public listenKeyboard: boolean = true;
@property({
tooltip: 'ゲームパッド入力を割り当てに使用するかどうか。',
})
public listenGamepad: boolean = true;
@property({
tooltip: '割り当てが変更されたタイミングで自動的にlocalStorageへ保存します。',
})
public autoSaveOnChange: boolean = true;
@property({
tooltip: '入力検知や割り当て変更時にログを出力してデバッグしやすくします。',
})
public logDebug: boolean = true;
// 内部状態
private _inputMap: InputMap = {};
private _listeningAction: string | null = null;
private _isListening: boolean = false;
onLoad() {
// 起動時にInputMapを初期化
this._initInputMap();
// ローカルストレージから読み込み
this.loadFromStorage();
// 入力イベント登録
this._registerInputEvents();
}
onDestroy() {
this._unregisterInputEvents();
}
private _initInputMap() {
// defaultActionsのアクションをすべて登録し、未定義ならnullで初期化
for (const action of this.defaultActions) {
if (!this._inputMap[action]) {
this._inputMap[action] = null;
}
}
if (this.logDebug) {
console.log('[InputRemapper] Initialized actions:', Object.keys(this._inputMap));
}
}
private _registerInputEvents() {
if (this.listenKeyboard) {
input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
}
if (this.listenGamepad) {
input.on(Input.EventType.GAMEPAD_INPUT, this._onGamepadInput, this);
}
}
private _unregisterInputEvents() {
input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
input.off(Input.EventType.GAMEPAD_INPUT, this._onGamepadInput, this);
}
// --- 入力イベントハンドラ ---
private _onKeyDown(event: EventKeyboard) {
if (!this._isListening || !this._listeningAction) {
return;
}
const keyCode: KeyCode = event.keyCode;
const codeName = KeyCode[keyCode]; // enum名を文字列として取得
if (!codeName) {
if (this.logDebug) {
console.warn('[InputRemapper] Unknown KeyCode:', keyCode);
}
return;
}
const binding: InputBinding = {
type: 'keyboard',
code: codeName,
};
this._assignBinding(binding);
}
private _onGamepadInput(event: EventGamepad) {
if (!this._isListening || !this._listeningAction) {
return;
}
const gamepad = event.gamepad;
if (!gamepad) {
return;
}
// どのボタン/軸が入力されたかを判定する簡易ロジック
const index = gamepad.index;
// ボタン押下の検出
const buttons = gamepad.buttonValues;
for (let i = 0; i < buttons.length; i++) {
if (buttons[i] > 0.5) {
const binding: InputBinding = {
type: 'gamepad',
code: `button_${i}`,
index,
};
this._assignBinding(binding);
return;
}
}
// 軸の検出 (スティックの倒し)
const axes = gamepad.axisValues;
for (let i = 0; i < axes.length; i++) {
const v = axes[i];
if (Math.abs(v) > 0.5) {
const dir = v > 0 ? 'pos' : 'neg';
const binding: InputBinding = {
type: 'gamepad',
code: `axis_${i}_${dir}`,
index,
};
this._assignBinding(binding);
return;
}
}
}
// --- 割り当て処理 ---
private _assignBinding(binding: InputBinding) {
if (!this._listeningAction) {
return;
}
const action = this._listeningAction;
this._inputMap[action] = binding;
if (this.logDebug) {
console.log(
`[InputRemapper] Assigned ${action} =>`,
binding.type,
binding.code,
binding.index !== undefined ? `(pad:${binding.index})` : ''
);
}
if (this.autoSaveOnChange) {
this.saveToStorage();
}
// 割り当てが完了したのでリッスンを終了
this._isListening = false;
this._listeningAction = null;
}
// --- 公開メソッド群 ---
/**
* 指定したアクションに対して、次に押されたキー/ボタンを割り当てるモードに入ります。
* UIボタンなどから呼び出してください。
*/
public beginListenForAction(actionName: string) {
if (!actionName) {
console.warn('[InputRemapper] beginListenForAction: actionName is empty');
return;
}
// 未登録のアクション名であれば新規登録
if (!this._inputMap[actionName]) {
this._inputMap[actionName] = null;
}
this._listeningAction = actionName;
this._isListening = true;
if (this.logDebug) {
console.log(`[InputRemapper] Listening for next input to assign to action: ${actionName}`);
}
}
/**
* 現在の割り当て待ち状態をキャンセルします。
*/
public cancelListening() {
if (this._isListening && this.logDebug) {
console.log('[InputRemapper] Cancel listening');
}
this._isListening = false;
this._listeningAction = null;
}
/**
* 指定アクションの割り当てを解除します。
*/
public clearBinding(actionName: string) {
if (!this._inputMap[actionName]) {
if (this.logDebug) {
console.warn('[InputRemapper] clearBinding: action does not exist:', actionName);
}
return;
}
this._inputMap[actionName] = null;
if (this.logDebug) {
console.log('[InputRemapper] Cleared binding for action:', actionName);
}
if (this.autoSaveOnChange) {
this.saveToStorage();
}
}
/**
* 現在のInputMapをlocalStorageに保存します。
*/
public saveToStorage() {
if (!this.storageKey) {
console.warn('[InputRemapper] storageKey is empty. Cannot save.');
return;
}
try {
const json = JSON.stringify(this._inputMap);
// Cocosの環境はブラウザ or ネイティブなので、window.localStorageが使える環境を想定
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(this.storageKey, json);
if (this.logDebug) {
console.log('[InputRemapper] Saved InputMap to storage:', this.storageKey);
}
} else {
console.warn('[InputRemapper] localStorage is not available in this environment.');
}
} catch (e) {
console.error('[InputRemapper] Failed to save InputMap:', e);
}
}
/**
* localStorageからInputMapを読み込みます。
* 保存がなければdefaultActionsのみを維持します。
*/
public loadFromStorage() {
if (!this.storageKey) {
console.warn('[InputRemapper] storageKey is empty. Cannot load.');
return;
}
try {
if (typeof window !== 'undefined' && window.localStorage) {
const json = window.localStorage.getItem(this.storageKey);
if (json) {
const data = JSON.parse(json) as InputMap;
// 読み込んだマップをマージ(defaultActionsで定義したものは最低限残す)
this._inputMap = { ...this._inputMap, ...data };
if (this.logDebug) {
console.log('[InputRemapper] Loaded InputMap from storage:', this.storageKey, this._inputMap);
}
} else {
if (this.logDebug) {
console.log('[InputRemapper] No saved InputMap found for key:', this.storageKey);
}
}
}
} catch (e) {
console.error('[InputRemapper] Failed to load InputMap:', e);
}
}
/**
* 指定アクションに現在割り当てられているキー/ボタンを文字列として返します。
* UI表示などに利用してください。
*/
public getBinding(actionName: string): string {
const binding = this._inputMap[actionName];
if (!binding) {
return 'Unassigned';
}
if (binding.type === 'keyboard') {
return `Key:${binding.code}`;
} else {
const pad = binding.index ?? 0;
return `Pad${pad}:${binding.code}`;
}
}
/**
* 現在の全アクションと割り当てをログに出力します。
*/
public logAllBindings() {
console.log('--- [InputRemapper] Current Bindings ---');
for (const action in this._inputMap) {
const text = this.getBinding(action);
console.log(` ${action} => ${text}`);
}
console.log('----------------------------------------');
}
// --- 参考: 実行中にアクションが押されているかどうかをチェックする簡易メソッド ---
// ※本コンポーネントは「割り当て管理」が主目的のため、
// 実際のゲームロジックからのポーリングは別途Input処理側で行うことを推奨します。
// ただし、サンプルとして最低限の実装を示します。
/**
* 実行中にアクションが押されているかどうかをチェックするサンプルメソッド。
* ポーリング用にupdate等から呼び出すことを想定。
* 注意: 簡易実装のため、本格的な入力処理には別コンポーネントでの管理を推奨します。
*/
public isActionPressed(_actionName: string): boolean {
// ここでは実際のポーリング実装は行わず、設計上のサンプルとして残します。
// 本格的にやる場合は、KeyboardInput/GamepadInputの状態管理を別コンポーネントで行うか、
// このクラスを拡張して状態を保持する必要があります。
if (this.logDebug) {
console.warn('[InputRemapper] isActionPressed is not fully implemented. Use your own input polling.');
}
return false;
}
}
主要メソッドの解説
- onLoad()
_initInputMap()でdefaultActionsに基づいて InputMap を初期化。loadFromStorage()で保存済みの割り当てがあれば読み込む。_registerInputEvents()でキーボード・ゲームパッドの入力イベントを登録。
- _onKeyDown / _onGamepadInput()
_isListeningと_listeningActionを確認し、割り当て待ち状態でなければ無視。- 押されたキー/ボタン/軸から
InputBindingを生成し、_assignBinding()に渡す。
- _assignBinding()
_inputMapにバインディングを保存し、ログ出力。autoSaveOnChangeが true の場合はsaveToStorage()を呼び出す。- 割り当て完了後、
_isListeningを false にして待ち状態を解除。
- beginListenForAction()
- 指定アクション名に対して「次の入力を割り当てる」モードに入る。
- 未登録のアクション名なら新しく
nullで登録する。
- saveToStorage() / loadFromStorage()
storageKeyで指定されたキー名を使ってlocalStorageに保存/読み込み。- 読み込み時は、既存の
_inputMapとマージして defaultActions を維持。
- getBinding()
- UIに表示しやすいように、
"Key:Space"や"Pad0:button_0"のような文字列にして返す。
- UIに表示しやすいように、
使用手順と動作確認
1. スクリプトファイルの作成
- Editor の Assets パネルで空白部分を右クリックします。
- Create > TypeScript を選択します。
- ファイル名を
InputRemapper.tsにします。 - 作成された
InputRemapper.tsをダブルクリックし、エディタ(VSCodeなど)で開きます。 - 中身をすべて削除し、本記事の
TypeScriptコードの実装セクションのコードを丸ごと貼り付けて保存します。
2. テスト用シーンとノードの準備
- Scene を開きます(新規シーンでも既存シーンでもOK)。
- Hierarchy パネルで右クリックし、Create > Empty Node を選択して空ノードを作成します。
- 作成したノードの名前を
InputManagerNodeなど分かりやすい名前に変更します。
3. InputRemapper コンポーネントのアタッチ
- Hierarchy で先ほど作成した
InputManagerNodeを選択します。 - Inspector パネルの下部にある Add Component ボタンをクリックします。
- Custom カテゴリを開き、一覧から InputRemapper を選択します。
4. プロパティの設定
Inspector で、InputRemapper の各プロパティを設定します。
- Storage Key: 例として
mySampleGame.inputMapと入力。 - Default Actions: 例として
- Element 0:
Jump - Element 1:
Attack - Element 2:
Dash
- Element 0:
- Listen Keyboard: チェックを入れる(true)。
- Listen Gamepad: ゲームパッドを使う場合はチェックを入れる。
- Auto Save On Change: 基本的にはチェックを入れておくと便利。
- Log Debug: 動作確認中はチェックを入れてログを確認しやすくする。
5. UIボタンから割り当てを行う例
ここでは、「Jumpキーを変更する」ボタンを作り、そのボタンを押したら InputRemapper に「Jump に対する次の入力を聞き始めて」と指示する流れを示します。
- Hierarchy で Canvas を作成します(まだ無い場合)。
- Hierarchy 右クリック → Create > UI > Canvas
- Canvas の子としてボタンを作成します。
- Canvas を選択 → 右クリック → Create > UI > Button
- ボタンの名前を
BtnRemapJumpなどに変更。
- ボタンのラベルテキストを変更します(任意)。
- Button の子にある
Labelノードを選択し、Label コンポーネントの String をRemap Jumpに変更。
- Button の子にある
- ボタンのクリックイベントに InputRemapper を紐づけます。
BtnRemapJumpを選択。- Inspector の Button コンポーネントを確認し、Click Events セクションを探します。
- + ボタンを押して新しいイベントを追加。
- 追加されたイベントの Target に、先ほど InputRemapper をアタッチした
InputManagerNodeをドラッグ&ドロップ。 - その右側のドロップダウンから
InputRemapper→beginListenForActionを選択。
- 引数フィールド(CustomEventData)に
Jumpと入力。
こうすると、ゲーム実行中に Remap Jump ボタンをクリックすると beginListenForAction('Jump') が呼ばれ、次に押したキー/ボタンが Jump アクションに割り当てられます。
6. 実行して動作確認
- Editor 上部の Play ボタンを押してシーンを実行します。
- ゲーム画面に表示される Remap Jump ボタンをクリックします。
- コンソールログに
[InputRemapper] Listening for next input to assign to action: Jumpのようなメッセージが出ていることを確認します(logDebug が true の場合)。 - キーボードの任意のキー(例: スペースキー)を押します。
- コンソールに
[InputRemapper] Assigned Jump => keyboard Spaceのようなログが出れば成功です。 - ゲームを停止し、再度実行します。
- 起動時に
loadFromStorage()によって前回の設定が読み込まれ、logAllBindings()を呼べば同じ割り当てが維持されていることが確認できます。- 簡単な確認方法として、
InputManagerNodeのInputRemapperに一時的にstart()を追加し、その中でthis.logAllBindings();を呼んでみると良いです。
- 簡単な確認方法として、
7. 複数アクションの割り当て UI を作るヒント
- Attack 用、Dash 用など、アクションごとにボタンを作り、それぞれの Click Events から
beginListenForAction('Attack')などを呼ぶ。 - 現在の割り当てを表示するラベルを作り、
getBinding('Jump')の結果を定期的に更新するスクリプトを別途用意すると、ユーザーにとって分かりやすいUIになります。
まとめ
本記事では、InputRemapper コンポーネントを用いて、ゲームパッドやキーボードの入力を検知して任意のアクション名に割り当てる汎用スクリプトを実装しました。
- 外部の GameManager やシングルトンに依存せず、このコンポーネント単体で InputMap の管理と保存/読み込みを完結。
- インスペクタから アクション名リスト や 保存キー名 を設定するだけで、プロジェクトごとに柔軟に利用可能。
- UIボタンから
beginListenForActionを呼ぶだけで、ユーザーがゲーム中に自由にキーコンフィグを変更できる。 localStorageを使った永続化により、一度設定したキー/ボタン割り当てが次回起動時にも維持される。
このコンポーネントをベースに、例えば
- 「デフォルト設定に戻す」ボタンから
defaultActionsの初期状態を再構築して保存する。 - マウスボタンやホイール操作もサポートする。
- 実際のゲーム入力処理側で
InputRemapperの InputMap を参照し、「アクションベース」の入力判定を行う。
といった拡張も容易に行えます。
まずはこの記事のコードをそのまま貼り付けて試し、プロジェクトに合わせてアクション名やUIをカスタマイズしてみてください。




