【Cocos Creator 3.8】FocusTrapの実装:UIを開いた瞬間に自動で「最初のボタン」にフォーカスを当てる汎用スクリプト
ゲームパッドやキーボードでUIを操作するゲームでは、「メニューを開いたのに、どのボタンにもフォーカスが当たっていない」という状態はとても扱いづらくなります。
この記事では、UIノードにアタッチするだけで、そのUIが有効化された瞬間に指定したボタンへ自動でフォーカスを移す汎用コンポーネント FocusTrap を TypeScript で実装します。
外部の GameManager やシングルトンには一切依存せず、インスペクタで設定したボタンや遅延時間だけで完結するように設計します。
コンポーネントの設計方針
実現したいこと
- ゲームパッド(またはキーボード)で UI を操作する際、UIが開いたら自動で特定のボタンにフォーカスを当てる。
- UI ノード(例:ポーズメニュー、設定画面など)にアタッチしておくと、そのノードが active = true になったタイミングで自動発火する。
- 外部スクリプトやグローバル状態に依存せず、このコンポーネント単体で動く。
- フォーカス対象のボタンはインスペクタで指定可能。指定しなかった場合は、子階層から自動で最初のボタンを探す。
- UI が開いてからフォーカスを当てるまでに、任意のフレーム/秒数の遅延を入れられる(レイアウト更新後にフォーカスしたいケースに対応)。
- UI が再度開かれたとき(非アクティブ→アクティブに戻ったとき)も、再びフォーカスを当てる。
フォーカス制御の前提
Cocos Creator 3.8 では、UI のボタンフォーカスは主に Button コンポーネントと EventSystem(または UITransform + Button)を通して扱いますが、標準 API では「フォーカス中のボタン」を直接管理する専用クラスはありません。
多くのプロジェクトでは独自の UI ナビゲーション管理クラスを用意しますが、本記事では外部依存禁止という条件があるため、次のようなシンプルな方針を取ります。
- 「フォーカスする」とは:対象ボタンの
clickEventsを触るのではなく、視覚的な「選択状態」や「ハイライト状態」を切り替えることで表現する。 - 具体的には、
Buttonコンポーネントの normal / pressed / hover / disabled とは別に、ハイライト用の状態を用意しておき、FocusTrapが「選択中」フラグを立てる。 - このサンプルでは、ボタンにアタッチした任意のコンポーネント(例:Outline や ColorTween)を on/off することでフォーカス感を出す設計にします。
ただし、「何を光らせるか」はプロジェクト依存になるため、FocusTrap では以下のように汎用化します。
- フォーカス時に有効化したいコンポーネントを Node 単位で指定できる。
- フォーカスされたらその Node を
active = true、フォーカスされていない間はactive = falseにする。 - この Node には、任意のエフェクト(Outline, Sprite, アニメーションなど)を仕込んでおけばよい。
インスペクタで設定可能なプロパティ
以下のプロパティを @property で公開し、インスペクタから調整できるようにします。
targetButtonNode: Node | null- フォーカスを当てたいボタンの Node。
- 未指定(null)の場合は、自分の子階層から最初に見つかった
Buttonコンポーネント付き Node を自動で使用する。
autoFindInChildren: boolean- true の場合、
targetButtonNodeが null なら子階層から自動検索する。 - false の場合、自動検索は行わず、
targetButtonNodeが未設定なら警告ログを出して処理を行わない。
- true の場合、
focusHighlightNode: Node | null- フォーカス中に
active = trueにするハイライト用 Node。 - 指定しなかった場合でも動作はするが、見た目上の変化は起こらないので、視覚フィードバックが欲しい場合は設定推奨。
- フォーカス中に
delayFrames: number- UI が有効化されてからフォーカスを当てるまでのフレーム遅延。
- 0 の場合は即時(次のフレーム)に実行。
- レイアウト更新やアニメーションの都合で、1〜2 フレーム待ってからフォーカスしたい場合に使用。
delaySeconds: number- UI が有効化されてからフォーカスを当てるまでの時間遅延(秒)。
delayFramesと併用可能。両方指定した場合は、フレーム遅延 → 時間遅延の順で待機してからフォーカスする。
focusOnLoad: boolean- true の場合、ノードが最初に有効になるタイミング(シーンロード直後など)でもフォーカスを行う。
- false の場合、「非アクティブ → アクティブになったとき」だけフォーカスする。
logDebug: boolean- フォーカス処理の開始・完了・エラーを
console.log / warn / errorで出力するかどうか。 - デバッグ時は true、本番では false 推奨。
- フォーカス処理の開始・完了・エラーを
この設計により、「どの UI を開いたときに、どのボタンを初期フォーカスにするか」をインスペクタだけで柔軟にコントロールできます。
TypeScriptコードの実装
以下が完成した FocusTrap.ts の全コードです。
import { _decorator, Component, Node, Button, game, director } from 'cc';
const { ccclass, property } = _decorator;
/**
* FocusTrap
*
* 指定した UI ノードが active = true になったタイミングで、
* 特定のボタンに「初期フォーカス」を当てるための汎用コンポーネント。
*
* - ゲームパッドやキーボード操作時に、UI を開いた瞬間にどのボタンが選択されるかを統一できる。
* - 外部の GameManager やシングルトンには一切依存せず、インスペクタ設定だけで完結。
*/
@ccclass('FocusTrap')
export class FocusTrap extends Component {
@property({
tooltip: 'フォーカスを当てたいボタンの Node。\n未設定の場合、autoFindInChildren が true なら子階層から自動検索します。'
})
public targetButtonNode: Node | null = null;
@property({
tooltip: 'targetButtonNode が未設定のとき、子階層から Button コンポーネント付きの Node を自動検索するかどうか。'
})
public autoFindInChildren: boolean = true;
@property({
tooltip: 'フォーカス中に active = true にするハイライト用 Node。\n任意のエフェクト(Outline, Sprite, アニメーションなど)をこの子ノードに仕込んでください。'
})
public focusHighlightNode: Node | null = null;
@property({
tooltip: 'UI が有効化されてからフォーカスを当てるまでのフレーム遅延。\n0 の場合はフレーム遅延なし(ただし内部的には次フレームで実行)。'
})
public delayFrames: number = 0;
@property({
tooltip: 'UI が有効化されてからフォーカスを当てるまでの時間遅延(秒)。\n0 の場合は時間遅延なし。'
})
public delaySeconds: number = 0;
@property({
tooltip: 'ノードが最初に有効になったタイミング(シーン開始時など)でもフォーカスを行うかどうか。'
})
public focusOnLoad: boolean = true;
@property({
tooltip: 'デバッグログを出力するかどうか。開発中は true、本番では false を推奨。'
})
public logDebug: boolean = false;
// 内部状態フラグ
private _isFocusing: boolean = false;
private _hasFocusedOnce: boolean = false;
onLoad() {
// ハイライトノードは初期状態では無効にしておく
if (this.focusHighlightNode) {
this.focusHighlightNode.active = false;
}
}
start() {
// シーン開始時点で自分のノードがアクティブかつ focusOnLoad が true ならフォーカスを試みる
if (this.node.activeInHierarchy && this.focusOnLoad) {
this._scheduleFocus();
}
}
/**
* onEnable は node.active = true になったタイミングで呼ばれる。
* 非アクティブ → アクティブに戻ってきたときも再度呼ばれるため、
* UI を再度開いたときの初期フォーカスにも対応できる。
*/
onEnable() {
// start() より後に呼ばれる可能性もあるが、どちらから呼ばれても問題ないように
// フォーカス処理は _scheduleFocus() に集約する。
this._scheduleFocus();
}
/**
* 非アクティブ時にはハイライトを消しておく。
*/
onDisable() {
if (this.focusHighlightNode) {
this.focusHighlightNode.active = false;
}
this._isFocusing = false;
}
/**
* フォーカス処理のスケジューリング。
* delayFrames と delaySeconds の設定に応じて、適切なタイミングで _doFocus() を呼び出す。
*/
private _scheduleFocus() {
// すでにフォーカス処理中なら二重実行を防ぐ
if (this._isFocusing) {
return;
}
this._isFocusing = true;
if (this.logDebug) {
console.log(`[FocusTrap] schedule focus for node="${this.node.name}"`);
}
// いったん全ての既存スケジュールを解除
this.unscheduleAllCallbacks();
// 実際の処理を行うコールバック
const focusCallback = () => {
this._doFocus();
};
// フレーム遅延がある場合
if (this.delayFrames > 0) {
let frames = 0;
const frameFunc = () => {
frames++;
if (frames >= this.delayFrames) {
this.unschedule(frameFunc);
if (this.delaySeconds > 0) {
// 秒単位の遅延もある場合、さらに scheduleOnce
this.scheduleOnce(focusCallback, this.delaySeconds);
} else {
focusCallback();
}
}
};
// 1 フレームごとに frameFunc を呼び出す
this.schedule(frameFunc, 0);
} else {
// フレーム遅延なしの場合
if (this.delaySeconds > 0) {
this.scheduleOnce(focusCallback, this.delaySeconds);
} else {
// 完全即時実行は UI レイアウトが整っていない可能性があるため、
// 次フレームに回す(0 秒 scheduleOnce)。
this.scheduleOnce(focusCallback, 0);
}
}
}
/**
* 実際にフォーカスを当てる処理。
* - targetButtonNode が設定されていなければ必要に応じて自動検索。
* - Button コンポーネントが存在するかをチェックし、なければエラーログを出す。
* - ハイライトノードが設定されていれば active = true にする。
*/
private _doFocus() {
this._isFocusing = false;
// 自分自身または親がすでに非アクティブになっていたら何もしない
if (!this.node.activeInHierarchy) {
if (this.logDebug) {
console.log(`[FocusTrap] node="${this.node.name}" is not active anymore. Skip focus.`);
}
return;
}
// 対象ボタンノードの決定
let targetNode = this.targetButtonNode;
if (!targetNode) {
if (this.autoFindInChildren) {
targetNode = this._findFirstButtonNodeInChildren();
if (targetNode && this.logDebug) {
console.log(`[FocusTrap] auto found button node="${targetNode.name}" under "${this.node.name}"`);
}
}
}
if (!targetNode) {
console.warn(`[FocusTrap] No target button node found for "${this.node.name}". Please assign "targetButtonNode" or enable "autoFindInChildren" with a Button component in children.`);
return;
}
// Button コンポーネントの存在チェック
const button = targetNode.getComponent(Button);
if (!button) {
console.error(`[FocusTrap] targetButtonNode="${targetNode.name}" does not have a Button component. Please add a Button component or select another node.`);
return;
}
// ここから「フォーカスされた状態」の表現を行う。
// Cocos Creator 標準ではフォーカス管理の専用 API はないため、
// このコンポーネントでは「ハイライトノードの有効化」でフォーカスを表現する。
if (this.focusHighlightNode) {
this.focusHighlightNode.active = true;
}
// ここで「現在フォーカス中のボタン」をどこかに記録したい場合は、
// 本来は外部の UI ナビゲーションマネージャーなどに通知するが、
// 本コンポーネントは外部依存禁止のため、ログ出力に留める。
if (this.logDebug) {
console.log(`[FocusTrap] focused button node="${targetNode.name}" for ui="${this.node.name}"`);
}
this._hasFocusedOnce = true;
}
/**
* 子階層から最初に見つかった Button コンポーネント付き Node を返す。
* なければ null。
*/
private _findFirstButtonNodeInChildren(): Node | null {
const stack: Node[] = [];
for (const child of this.node.children) {
stack.push(child);
}
while (stack.length > 0) {
const current = stack.shift()!;
if (current.getComponent(Button)) {
return current;
}
for (const child of current.children) {
stack.push(child);
}
}
return null;
}
}
コードの要点解説
onLoad- ハイライトノード(
focusHighlightNode)が設定されている場合、初期状態では非表示(active = false)にしておきます。
- ハイライトノード(
start- シーン開始時にノードがすでにアクティブで、かつ
focusOnLoadが true なら、最初のフォーカスを予約します。 - 実際のフォーカス処理は
_scheduleFocus()に集約しているため、start/onEnableのどちらから呼ばれても挙動が統一されます。
- シーン開始時にノードがすでにアクティブで、かつ
onEnable- UI ノードが非アクティブ → アクティブになったときにも呼ばれるため、UI を閉じて再度開いたときの初期フォーカスにも対応できます。
- 内部では
_scheduleFocus()を呼び、遅延設定に応じてフォーカスを行います。
onDisable- UI が閉じられたときに、ハイライトノードを非表示に戻します。
- 同時に
_isFocusingフラグを false にして、次回有効化時に再度フォーカスできるようにします。
_scheduleFocusdelayFramesとdelaySecondsの組み合わせに応じて、_doFocus()を呼び出すタイミングを決めます。delayFrames > 0の場合はschedule()でフレーム単位のカウントを行い、その後delaySecondsがあればscheduleOnce()で秒単位の待機を行います。- 完全に遅延なし(どちらも 0)の場合でも、UI レイアウトが整っていない可能性を考慮して次フレームに回すようにしています。
_doFocus- 実際のフォーカス対象ノードを決定し、
Buttonコンポーネントがあるかを防御的にチェックします。 targetButtonNodeが未設定でautoFindInChildrenが true の場合は、_findFirstButtonNodeInChildren()で自動検索を行います。- ハイライトノードが設定されていれば
active = trueにし、「ここが現在のフォーカス対象だ」と視覚的に示すことができます。
- 実際のフォーカス対象ノードを決定し、
_findFirstButtonNodeInChildren- 自分の子階層を幅優先探索して、最初に見つかった Button コンポーネント付き Node を返します。
- 見つからなかった場合は null を返し、
_doFocus側で警告ログを出します。
使用手順と動作確認
ここからは、Cocos Creator 3.8.7 のエディタ上で FocusTrap を実際に使う手順を説明します。
1. スクリプトファイルの作成
- エディタ下部の Assets パネルで、任意のフォルダ(例:
assets/scripts/ui)を選択します。 - そのフォルダ上で右クリック → Create → TypeScript を選択します。
- 新しく作成されたスクリプトの名前を
FocusTrap.tsに変更します。 - ダブルクリックして開き、本文をすべて削除し、前章で示した TypeScript コード全体 をそのまま貼り付けて保存します。
2. テスト用 UI ノードとボタンの作成
簡単なポーズメニューを例に、UI ノードとボタンを用意してみます。
- Canvas の確認
- Hierarchy パネルで
Canvasノードが存在するか確認します。 - ない場合は、右クリック → Create → UI → Canvas で作成します。
- Hierarchy パネルで
- ポーズメニュー用の親ノード作成
- Canvas を右クリック → Create → UI → Node を選択し、名前を
PauseMenuに変更します。 PauseMenuノードのactiveを チェックオン(true)にしておきます。(後でテストのために切り替えます)
- Canvas を右クリック → Create → UI → Node を選択し、名前を
- ボタンの作成
PauseMenuノードを右クリック → Create → UI → Button を選択します。- 生成された Button ノードの名前を
ResumeButtonに変更します。 - 同様に、
OptionsButton、QuitButtonなど、複数のボタンを下に並べても構いません。
- ハイライト用ノードの作成(任意だが推奨)
ResumeButtonノードを右クリック → Create → UI → Sprite を選択し、名前をHighlightに変更します。HighlightノードのSpriteコンポーネントで、半透明の枠や光る画像などを設定します。Highlightノードのactiveは チェックを外して false にしておきます(FocusTrap が制御します)。
3. FocusTrap コンポーネントのアタッチ
- Hierarchy パネルで
PauseMenuノードを選択します。 - Inspector パネル下部の Add Component ボタンをクリックします。
- Custom Component セクション(または検索欄)から
FocusTrapを選択して追加します。
4. インスペクタでプロパティを設定
PauseMenu ノードにアタッチされた FocusTrap コンポーネントを、次のように設定してみましょう。
- targetButtonNode
- 右側の小さな丸いピッカーアイコンをクリックし、Hierarchy から
ResumeButtonを選択します。
- 右側の小さな丸いピッカーアイコンをクリックし、Hierarchy から
- autoFindInChildren
- 今回は
targetButtonNodeを明示的に指定するので、チェックを外して false にしておいても構いません。 - 「とりあえず一番上のボタンに自動でフォーカスしてほしい」場合は true のままでもOKです。
- 今回は
- focusHighlightNode
- ピッカーアイコンから
ResumeButton/Highlightを指定します。 - これにより、フォーカス時に Highlight ノードが active = true になり、選択中ボタンが視覚的に分かるようになります。
- ピッカーアイコンから
- delayFrames
- まずは 0 にしておきます。
- もし UI レイアウトの都合でフォーカスがうまく効かない場合は、1〜2 に増やしてみてください。
- delaySeconds
- とりあえず 0 にしておきます。
- UI オープンアニメーションが 0.3 秒などある場合は、その時間分だけ遅らせると、アニメーション完了後にフォーカスが当たるようになります。
- focusOnLoad
- シーン開始時から PauseMenu を表示してテストしたい場合は チェックオン(true) のままでOKです。
- 「ゲーム中は非表示、ポーズボタンを押したときだけ表示するUI」の場合でも、非アクティブ → アクティブ時に onEnable が呼ばれるので、自動的にフォーカスされます。
- logDebug
- 挙動を確認したい間は true にして、Console でログを見ましょう。
- 問題なく動作するようになったら false に戻すと、ログが静かになります。
5. 動作確認
まずはシンプルに動作を確認してみます。
- シーンを保存(Ctrl+S または Cmd+S)します。
- エディタ右上の Play ボタンを押してゲームを実行します。
- ゲームウィンドウが開いたら、PauseMenu が表示されている状態で、次の点を確認します。
- ゲーム開始直後に、
ResumeButtonのHighlightノードが 自動で表示されていること。 - Console に
[FocusTrap] focused button node="ResumeButton" ...のログが出ていること(logDebug = true の場合)。
- ゲーム開始直後に、
- 次に、PauseMenu の active を切り替えるテストを行います。
- 簡単なテストとして、別のスクリプトで
PauseMenu.active = false / trueを切り替えても良いですし、 - エディタの Preview 機能で一時停止・再開を行い、
onEnableが再度呼ばれることを確認しても構いません。 - いずれの場合も、PauseMenu が再びアクティブになったタイミングで ResumeButton にフォーカス(ハイライト)が戻ることを確認してください。
- 簡単なテストとして、別のスクリプトで
6. 自動検索モードの確認(autoFindInChildren)
targetButtonNode を指定しないで使うケースも試してみましょう。
PauseMenuのFocusTrapコンポーネントから、targetButtonNode の参照をクリアします(右側の x ボタンを押す)。- autoFindInChildren を true にします。
- Hierarchy 上で、
ResumeButtonがPauseMenuの子階層の中で最上位にあることを確認します。 - シーンを実行すると、自動的に一番最初に見つかった Button(この場合 ResumeButton)にフォーカスされます。
このモードを使えば、「とにかく最初のボタンにフォーカスしてくれればいい」というメニューを素早く量産できます。
まとめ
この FocusTrap コンポーネントを使うことで、
- UI を開いた瞬間にどのボタンが選択されるかを統一できる。
- ゲームパッドやキーボード操作で、いきなりカーソルがどこにもない状態を避けられる。
- 外部の管理クラスやシングルトンに依存せず、各 UI ノードにアタッチしてインスペクタで設定するだけで完結する。
- 自動検索機能(
autoFindInChildren)を使えば、大量のメニュー画面にも簡単に適用できる。 - 遅延設定(
delayFrames/delaySeconds)により、アニメーションやレイアウト更新後のタイミングに合わせてフォーカスできる。
本記事の実装はあくまで「初期フォーカスの自動設定」に特化したシンプルなものですが、
- ハイライトノードにアニメーションやエフェクトを追加する
- フォーカスされたボタンに対してサウンドを鳴らす
- 将来的に独自の UI ナビゲーションマネージャーと連携させる
といった拡張も容易です。
まずはこの FocusTrap をベースに、「UI を開いたときの気持ちよさ」を高める仕組みを自分のプロジェクトに組み込んでみてください。




