【Cocos Creator 3.8】RadialMenu(リングメニュー)の実装:アタッチするだけで「ボタンを押すとマウス周囲に円形メニューを展開」できる汎用スクリプト
本記事では、Cocos Creator 3.8.7 + TypeScript で、任意のノードにアタッチするだけで「ボタンを押した位置を中心に、円形にメニュー項目が並ぶリングメニュー」を実現できる汎用コンポーネント RadialMenu を実装します。
ゲーム中にポーズメニュー、スキル選択、ショートカット選択などを「聖剣伝説方式」のリングメニューで実装したいときに、そのまま流用できる設計です。外部の GameManager やシングルトンに一切依存せず、すべてインスペクタの設定のみで完結します。
コンポーネントの設計方針
1. 機能要件の整理
- 特定のキー(例:
Tab)またはマウスボタンを押すと、押した位置を中心にリングメニューを表示する。 - メニューは複数の「項目プレハブ(Prefab)」を円周上に等間隔で並べて表示する。
- キー/ボタンを離すとメニューを閉じる。
- マウスカーソルの角度に応じて「現在選択されている項目」を判定できるようにする(ハイライト用に現在インデックスを取得可能)。
- 表示/非表示のアニメーションは最小限(スケールやアルファ)だが、インスペクタでオン/オフや時間を調整できるようにする。
- 他のカスタムスクリプトに依存しない。必要な情報はすべて
@propertyから設定する。
2. 外部依存をなくすための設計アプローチ
- メニュー項目の見た目やクリック処理は Prefab を通じて差し替え可能にし、
RadialMenu自体は「並べる・位置決めする」ことに専念する。 - 表示位置は「画面上のマウス位置」をワールド座標に変換して、その位置にリングを生成する。
- 入力は Cocos Creator 標準の
input(マウス/キーボード)を直接利用する。 - メニューの親ノードは、このコンポーネントをアタッチしたノード自身とし、その子としてメニュー項目を生成する。
3. インスペクタで設定可能なプロパティ設計
RadialMenu が持つ主なプロパティと役割は以下の通りです。
- menuItemPrefab(
Prefab)- リングメニューの1項目に使用するプレハブ。
- ボタンやスプライトなど、任意の UI ノードをプレハブ化して指定します。
- itemCount(
number)- リングメニューに並べる項目数。
- 2 以上を推奨。1 の場合は単一ボタンとして中央に配置されます。
- radius(
number)- リングの半径(ピクセル)。
- メニュー中心から各項目までの距離です。
- startAngleDeg(
number)- 最初の項目を配置する角度(度数法)。
- 0度は「右方向」、90度は「上方向」として扱います。
- clockwise(
boolean)- 項目を時計回りに並べるかどうか。
- false の場合は反時計回りに配置します。
- openKey(
KeyCode)- リングメニューを開閉するキーボードキー。
- デフォルトは
KeyCode.TAB。
- useRightMouseButton(
boolean)- true の場合、右クリック(MouseButton.RIGHT)でもメニューを開閉します。
- キーボードと併用可能。
- closeOnRelease(
boolean)- true の場合、キー/ボタンを離したときにメニューを閉じます。
- false の場合はトグル(押すたびに開閉)動作にできます。
- alignToCamera(
boolean)- 2D UI カメラを使用している場合、メニューをカメラの前面に揃えるかどうか。
- 2D UI(Canvas)上の操作時には通常 true で問題ありません。
- animationDuration(
number)- リングメニューを開閉するときの簡易スケールアニメーション時間(秒)。
- 0 の場合はアニメーションなしで即座に表示/非表示します。
- scaleOnOpen(
number)- 開くときの最終スケール値。
- 通常は 1。0.8〜1.2 などで微調整可能。
- fadeItems(
boolean)- 開閉時に項目の不透明度(opacity)をフェードさせるかどうか。
- 項目ノードに
UIOpacityコンポーネントがない場合は自動追加します。
- debugLog(
boolean)- 内部の状態(開閉、選択インデックスなど)を
console.logに出力するかどうか。
- 内部の状態(開閉、選択インデックスなど)を
また、実行時に現在選択されている項目インデックスを取得するための 読み取り専用プロパティ(getter)も用意します。
TypeScriptコードの実装
以下が完成した RadialMenu.ts の全コードです。
import {
_decorator,
Component,
Node,
Prefab,
instantiate,
Vec3,
Vec2,
input,
Input,
EventMouse,
EventKeyboard,
KeyCode,
Camera,
UITransform,
view,
UIOpacity,
math,
} from 'cc';
const { ccclass, property } = _decorator;
/**
* RadialMenu
* 任意のノードにアタッチするだけで、
* キー/マウス入力に応じて「マウス位置を中心にリングメニュー」を表示する汎用コンポーネント。
*/
@ccclass('RadialMenu')
export class RadialMenu extends Component {
@property({
type: Prefab,
tooltip: 'リングメニューの1項目として使用するPrefab。\nボタンやスプライトなど、任意のUIノードを指定してください。'
})
public menuItemPrefab: Prefab | null = null;
@property({
tooltip: 'リングメニューに並べる項目数。\n2以上を推奨。1の場合は中央に1つだけ配置されます。'
})
public itemCount: number = 6;
@property({
tooltip: 'リングの半径(ピクセル)。\nメニュー中心から各項目までの距離です。'
})
public radius: number = 150;
@property({
tooltip: '最初の項目を配置する角度(度数法)。\n0度=右方向、90度=上方向。'
})
public startAngleDeg: number = 90;
@property({
tooltip: 'true: 項目を時計回りに配置します。\nfalse: 反時計回りに配置します。'
})
public clockwise: boolean = true;
@property({
type: KeyCode,
tooltip: 'リングメニューを開閉するキーボードキー。'
})
public openKey: KeyCode = KeyCode.TAB;
@property({
tooltip: 'true の場合、右クリックでもメニューを開閉します。'
})
public useRightMouseButton: boolean = true;
@property({
tooltip: 'true の場合、キー/ボタンを離したときにメニューを閉じます。\nfalse の場合、押すたびにトグル動作になります。'
})
public closeOnRelease: boolean = true;
@property({
type: Camera,
tooltip: 'UI用カメラ(任意)。\n指定すると、マウス位置からワールド座標への変換に使用します。\n未指定の場合は、このノードの親階層のUITransformを利用して計算します。'
})
public uiCamera: Camera | null = null;
@property({
tooltip: 'true の場合、メニューのZ座標をカメラに合わせて配置します。'
})
public alignToCamera: boolean = true;
@property({
tooltip: '開閉時の簡易スケールアニメーション時間(秒)。\n0 の場合はアニメーションなし。'
})
public animationDuration: number = 0.1;
@property({
tooltip: '開くときの最終スケール値。\n通常は 1。'
})
public scaleOnOpen: number = 1.0;
@property({
tooltip: '開閉時に項目の不透明度をフェードさせるかどうか。\n各項目にUIOpacityが自動で追加されます。'
})
public fadeItems: boolean = true;
@property({
tooltip: '内部状態をconsole.logに出力するデバッグモード。'
})
public debugLog: boolean = false;
// 内部状態
private _isOpen: boolean = false;
private _items: Node[] = [];
private _centerWorldPos: Vec3 = new Vec3();
private _elapsedAnim: number = 0;
private _animating: boolean = false;
private _opening: boolean = false;
// 選択判定用
private _currentSelectedIndex: number = -1;
// マウス位置キャッシュ
private _lastMousePos: Vec2 = new Vec2();
// 公開用 getter
public get isOpen(): boolean {
return this._isOpen;
}
public get currentSelectedIndex(): number {
return this._currentSelectedIndex;
}
onLoad() {
if (!this.menuItemPrefab) {
console.error('[RadialMenu] menuItemPrefab が設定されていません。InspectorでPrefabを指定してください。');
}
if (!this.node.getComponent(UITransform)) {
console.warn('[RadialMenu] アタッチ先ノードに UITransform がありません。UIキャンバス上で使用する場合は UITransform を追加してください。');
}
// 初期状態は閉じておく
this.node.active = false;
this.node.setScale(0, 0, 1);
// 入力イベント登録
input.on(Input.EventType.MOUSE_DOWN, this._onMouseDown, this);
input.on(Input.EventType.MOUSE_UP, this._onMouseUp, this);
input.on(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
}
onDestroy() {
// 入力イベント解除
input.off(Input.EventType.MOUSE_DOWN, this._onMouseDown, this);
input.off(Input.EventType.MOUSE_UP, this._onMouseUp, this);
input.off(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
}
update(dt: number) {
// アニメーション処理
if (this._animating) {
this._elapsedAnim += dt;
const t = this.animationDuration > 0 ? Math.min(this._elapsedAnim / this.animationDuration, 1) : 1;
// イージング(簡易:easeOutQuad)
const eased = 1 - (1 - t) * (1 - t);
if (this._opening) {
const scale = this.scaleOnOpen * eased;
this.node.setScale(scale, scale, 1);
if (this.fadeItems) {
this._setItemsOpacity(Math.floor(255 * eased));
}
} else {
const scale = this.scaleOnOpen * (1 - eased);
this.node.setScale(scale, scale, 1);
if (this.fadeItems) {
this._setItemsOpacity(Math.floor(255 * (1 - eased)));
}
}
if (t >= 1) {
this._animating = false;
if (!this._opening) {
this.node.active = false;
}
}
}
// 開いている間は選択インデックスを更新
if (this._isOpen) {
this._updateSelection();
}
}
// =============================
// 入力イベントハンドラ
// =============================
private _onMouseDown(event: EventMouse) {
if (this.useRightMouseButton && event.getButton() === EventMouse.BUTTON_RIGHT) {
const loc = event.getLocation();
this._lastMousePos.set(loc.x, loc.y);
if (this.closeOnRelease) {
// 押した瞬間に開く
this.openAtScreenPosition(this._lastMousePos);
} else {
// トグル動作
if (this._isOpen) {
this.close();
} else {
this.openAtScreenPosition(this._lastMousePos);
}
}
}
}
private _onMouseUp(event: EventMouse) {
if (this.useRightMouseButton && event.getButton() === EventMouse.BUTTON_RIGHT) {
const loc = event.getLocation();
this._lastMousePos.set(loc.x, loc.y);
if (this.closeOnRelease) {
this.close();
} else {
// トグルモードでは何もしない
}
}
}
private _onMouseMove(event: EventMouse) {
const loc = event.getLocation();
this._lastMousePos.set(loc.x, loc.y);
}
private _onKeyDown(event: EventKeyboard) {
if (event.keyCode === this.openKey) {
// キー押下
if (this.closeOnRelease) {
// 押した瞬間に開く
this.openAtScreenPosition(this._lastMousePos);
} else {
// トグル動作
if (this._isOpen) {
this.close();
} else {
this.openAtScreenPosition(this._lastMousePos);
}
}
}
}
private _onKeyUp(event: EventKeyboard) {
if (event.keyCode === this.openKey) {
if (this.closeOnRelease) {
this.close();
}
}
}
// =============================
// 公開 API
// =============================
/**
* 任意のスクリーン座標にリングメニューを開く。
* @param screenPos スクリーン座標(ピクセル)
*/
public openAtScreenPosition(screenPos: Vec2) {
if (!this.menuItemPrefab) {
console.error('[RadialMenu] menuItemPrefab が設定されていないため、メニューを開けません。');
return;
}
if (this.itemCount <= 0) {
console.warn('[RadialMenu] itemCount が 0 以下です。メニュー項目を生成しません。');
}
// スクリーン座標をワールド座標に変換
const worldPos = this._screenToWorld(screenPos);
this._centerWorldPos.set(worldPos);
// このノードを中心位置に移動
this.node.worldPosition = this._centerWorldPos;
// メニュー項目を生成・再配置
this._createOrUpdateItems();
// 表示状態を更新
this._isOpen = true;
this.node.active = true;
this._opening = true;
this._elapsedAnim = 0;
this._animating = this.animationDuration > 0;
if (!this._animating) {
// アニメーションなしの場合は即座に最終状態に
this.node.setScale(this.scaleOnOpen, this.scaleOnOpen, 1);
if (this.fadeItems) {
this._setItemsOpacity(255);
}
} else {
// アニメーション開始時はスケール0に
this.node.setScale(0, 0, 1);
if (this.fadeItems) {
this._setItemsOpacity(0);
}
}
if (this.debugLog) {
console.log('[RadialMenu] openAtScreenPosition', screenPos);
}
}
/**
* メニューを閉じる。
*/
public close() {
if (!this._isOpen) {
return;
}
this._isOpen = false;
this._opening = false;
this._elapsedAnim = 0;
this._animating = this.animationDuration > 0;
if (!this._animating) {
this.node.active = false;
}
if (this.debugLog) {
console.log('[RadialMenu] close');
}
}
// =============================
// 内部処理
// =============================
private _screenToWorld(screenPos: Vec2): Vec3 {
const out = new Vec3();
// UIカメラが指定されている場合はそれを利用
if (this.uiCamera) {
this.uiCamera.screenToWorld(new Vec3(screenPos.x, screenPos.y, 0), out);
if (this.alignToCamera) {
out.z = this.uiCamera.node.worldPosition.z;
}
return out;
}
// UITransform を辿ってローカル変換
const canvasTransform = this._findParentUITransform(this.node);
if (canvasTransform) {
const visibleSize = view.getVisibleSize();
// 画面中心を(0,0)としたローカル座標に変換
const x = screenPos.x - visibleSize.width / 2;
const y = screenPos.y - visibleSize.height / 2;
out.set(x, y, 0);
// Canvasローカル座標 → ワールド座標
canvasTransform.convertToWorldSpaceAR(out, out);
return out;
}
// フォールバック:そのまま使う
out.set(screenPos.x, screenPos.y, 0);
return out;
}
private _findParentUITransform(node: Node | null): UITransform | null {
while (node) {
const ui = node.getComponent(UITransform);
if (ui) {
return ui;
}
node = node.parent;
}
return null;
}
private _createOrUpdateItems() {
// 既存項目が itemCount と異なる場合は作り直す
if (this._items.length !== this.itemCount) {
// 既存ノードを破棄
for (const n of this._items) {
n.destroy();
}
this._items.length = 0;
for (let i = 0; i < this.itemCount; i++) {
if (!this.menuItemPrefab) {
break;
}
const item = instantiate(this.menuItemPrefab);
item.parent = this.node;
// フェード用UIOpacity
if (this.fadeItems && !item.getComponent(UIOpacity)) {
item.addComponent(UIOpacity);
}
this._items.push(item);
}
}
// 配置計算
if (this.itemCount <= 0) {
return;
}
const angleStep = this.itemCount > 1 ? 360 / this.itemCount : 0;
for (let i = 0; i < this._items.length; i++) {
const item = this._items[i];
const sign = this.clockwise ? -1 : 1;
const angleDeg = this.startAngleDeg + sign * angleStep * i;
const rad = math.toRadian(angleDeg);
const x = Math.cos(rad) * this.radius;
const y = Math.sin(rad) * this.radius;
item.setPosition(x, y, 0);
}
// 初期選択状態
this._currentSelectedIndex = this.itemCount > 0 ? 0 : -1;
}
private _setItemsOpacity(opacity: number) {
for (const item of this._items) {
const uiOpacity = item.getComponent(UIOpacity) || item.addComponent(UIOpacity);
uiOpacity.opacity = opacity;
}
}
/**
* マウス位置から現在選択中の項目インデックスを更新する。
*/
private _updateSelection() {
if (this.itemCount <= 0) {
this._currentSelectedIndex = -1;
return;
}
// 中心とマウス位置の角度を計算
const centerScreen = this._worldToScreen(this._centerWorldPos);
const dx = this._lastMousePos.x - centerScreen.x;
const dy = this._lastMousePos.y - centerScreen.y;
if (dx === 0 && dy === 0) {
return;
}
let angle = Math.atan2(dy, dx); // ラジアン(x軸正方向から反時計回り)
let angleDeg = math.toDegree(angle);
// 0〜360に正規化
if (angleDeg < 0) {
angleDeg += 360;
}
// startAngleDeg と clockwise 設定に合わせてインデックスを算出
const sign = this.clockwise ? -1 : 1;
let relativeDeg = angleDeg - this.startAngleDeg;
if (this.clockwise) {
relativeDeg = -relativeDeg;
}
// 0〜360に再正規化
relativeDeg = ((relativeDeg % 360) + 360) % 360;
const angleStep = this.itemCount > 0 ? 360 / this.itemCount : 360;
let index = Math.floor((relativeDeg + angleStep / 2) / angleStep);
index = Math.max(0, Math.min(this.itemCount - 1, index));
if (index !== this._currentSelectedIndex) {
this._currentSelectedIndex = index;
if (this.debugLog) {
console.log('[RadialMenu] selected index =', index);
}
}
}
private _worldToScreen(worldPos: Vec3): Vec2 {
const out = new Vec2();
if (this.uiCamera) {
const temp = new Vec3();
this.uiCamera.worldToScreen(worldPos, temp);
out.set(temp.x, temp.y);
return out;
}
const visibleSize = view.getVisibleSize();
const canvasTransform = this._findParentUITransform(this.node);
if (canvasTransform) {
const temp = new Vec3();
canvasTransform.convertToNodeSpaceAR(worldPos, temp);
out.set(temp.x + visibleSize.width / 2, temp.y + visibleSize.height / 2);
return out;
}
// フォールバック
out.set(worldPos.x, worldPos.y);
return out;
}
}
コードのポイント解説
- onLoad
menuItemPrefab未設定時にエラーログを出力。- アタッチ先に
UITransformがない場合は警告。 - ノードを非アクティブ&スケール0にして初期状態を「閉じたメニュー」にする。
inputに対してマウス・キーボードイベントを登録。
- update
animationDurationに応じて開閉アニメーション(スケール・フェード)を更新。- メニューが開いている間は常に
_updateSelection()を呼び出し、マウス角度から選択インデックスを更新。
- openAtScreenPosition
- スクリーン座標 → ワールド座標へ変換し、その位置にメニューのルートノード(このノード)を移動。
- 項目を生成・円形配置し、アニメーション状態を初期化して表示開始。
- close
_isOpenフラグを false にし、アニメーションを開始。- アニメーションがない場合は即座に
node.active = false。
- _updateSelection
- 中心とマウス位置のスクリーン座標差分から
atan2で角度を算出。 startAngleDeg・clockwiseに合わせて角度を変換し、1セクター(項目1つ分)の角度幅でインデックスを決定。- 選択インデックスが変わったときのみ
console.log(debugLog 有効時)。
- 中心とマウス位置のスクリーン座標差分から
使用手順と動作確認
1. 準備:メニュー項目用のPrefabを作る
- Hierarchy パネルで右クリック → Create → UI → Sprite を選択し、メニュー項目のベースとなるノードを作成します。
- 名前は例として
RadialMenuItemとします。
- 名前は例として
- Inspector で必要に応じて以下を設定します。
- Sprite の画像(アイコン)を設定。
- サイズ(Width / Height)を調整(例: 64 x 64)。
- クリック判定を付けたい場合は
Buttonコンポーネントを追加しておきます(任意)。
- Assets パネルに
RadialMenuItemノードをドラッグ&ドロップして Prefab 化します。- 例:
assets/prefabs/RadialMenuItem.prefab
- 例:
- Hierarchy 上の元ノード
RadialMenuItemは削除しても構いません(Prefab があればOK)。
2. RadialMenu.ts スクリプトを作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
RadialMenu.tsにします。 - 作成された
RadialMenu.tsをダブルクリックしてエディタで開き、先ほど掲載したコード全文を貼り付けて保存します。
3. テスト用のノードを作成し、RadialMenu をアタッチ
- Hierarchy パネルで右クリック → Create → UI → Empty Node(または Node)を選択し、リングメニューのルートノードを作成します。
- 名前は例として
RadialMenuRootとします。
- 名前は例として
RadialMenuRootが Canvas の子になるように配置します(UI として扱う場合)。- Hierarchy で
RadialMenuRootをCanvasノードの下にドラッグします。
- Hierarchy で
RadialMenuRootを選択し、Inspector で Add Component → Custom → RadialMenu を選択してコンポーネントを追加します。
4. Inspector で RadialMenu のプロパティを設定
- Menu Item Prefab に、先ほど作成した
RadialMenuItem.prefabをドラッグ&ドロップして設定します。 - その他のプロパティを好みで調整します。例:
- Item Count:
8 - Radius:
200 - Start Angle Deg:
90(上方向からスタート) - Clockwise:
true - Open Key:
TAB - Use Right Mouse Button:
true - Close On Release:
true - Animation Duration:
0.12 - Scale On Open:
1.0 - Fade Items:
true - Debug Log:
true(挙動確認のため)
- Item Count:
- 2D UI カメラを使用している場合は、Ui Camera に該当カメラを設定します。通常は Canvas の子にある
Cameraを指定します。
5. 動作確認
- エディタ右上の Play ボタンを押してゲームを実行します。
- ゲーム画面上でマウスを動かしながら以下を試します。
- キーボードの Tab キーを押し続けると、押したときのマウス位置を中心にリングメニューが表示されます。
- Tab キーを離すとメニューが閉じます(
Close On Release = trueの場合)。 - マウスカーソルをリングの周囲で動かすと、コンソールに
[RadialMenu] selected index = Xが表示され、現在選択されている項目インデックスが更新されていることが確認できます(Debug Log = trueの場合)。 - Use Right Mouse Button が true の場合は、右クリックでも同様にメニューが開閉します。
- もしメニューが画面外に出てしまう場合は、
radiusを小さくするか、ゲーム解像度に合わせて調整してください。
6. 実際のゲームへの組み込み例
- 各項目プレハブ(
RadialMenuItem)にButtonコンポーネントを追加し、clickEventsに任意のスクリプトのメソッドを割り当てれば、「項目クリックでスキル発動」などが簡単に実装できます。 - 選択インデックスに応じてハイライトしたい場合は、別スクリプトから
RadialMenuコンポーネントを取得し、currentSelectedIndexを参照して、各項目の色やスケールを変更することができます(このガイドでは外部スクリプトは必須ではないため、必要に応じて追加してください)。
まとめ
本記事では、Cocos Creator 3.8.7 + TypeScript で、
- 他のスクリプトに一切依存しない
- 任意のノードにアタッチするだけで使える
- 入力・座標変換・円形配置・選択判定までを1つにまとめた
汎用リングメニューコンポーネント RadialMenu を実装しました。
Prefab を差し替えるだけで見た目を自由に変更でき、itemCount や radius、startAngleDeg などをインスペクタから調整することで、
- スキル選択リング
- ポーズメニュー
- ショートカットホイール
- 武器切り替えホイール
といった UI を素早く試作できます。
ゲームごとに GameManager やシングルトンを用意する必要がなく、このコンポーネント単体で完結しているため、プロジェクト間のコピー&ペーストも容易です。
ここからさらに、
- 選択中項目の自動ハイライト
- ゲームパッドのスティック入力対応
- 開閉時のアニメーション強化(Tween や AnimationClip 利用)
などを追加して、自分のゲームに最適化したリングメニューへ発展させていくこともできます。
まずはこの記事のコードをそのまま貼り付けて、Tab キーや右クリックで「聖剣伝説方式」のリングメニューを動かしてみてください。




