【Cocos Creator 3.8】Tooltipコンポーネントの実装:アタッチするだけでマウスホバー時にツールチップテキストを表示する汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで「マウスホバーしたときにツールチップを表示する」機能を実現する Tooltip コンポーネントを実装します。
専用のTooltipレイヤー(キャンバス上に自動生成されるノード)にテキストが表示され、マウス位置に追従しながら、マウスが離れたら自動的に非表示になります。UIボタンやアイコン、ステータス表示など、さまざまな場面で再利用できる汎用コンポーネントです。
コンポーネントの設計方針
実現したい機能
- ノードに
Tooltipをアタッチすると、そのノードにマウスカーソルを乗せたときだけツールチップテキストを表示する。 - ツールチップは専用の「Tooltipレイヤー」ノード(UI上の最前面)に表示される。
- マウス位置に追従し、画面端でははみ出さないように簡易的に位置調整する。
- テキスト内容や見た目(背景色・文字色・フォントサイズ・パディングなど)はインスペクタから調整可能。
- 外部のGameManagerやシングルトンに依存せず、このスクリプト単体で完結する。
外部依存をなくすためのアプローチ
- Tooltipレイヤー用のノード(背景+Label)は、必要になったタイミングでスクリプトが自動生成する。
- 既に同名のTooltipレイヤーノードが存在する場合は、それを利用する。
- Canvasが見つからない場合はエラーログを出し、処理を中断する(エディタでCanvasを用意してもらう)。
- イベント登録(マウス/タッチ)は、アタッチ先ノード自身に対して行う。
- PC/モバイル両対応のため、マウスイベント+タッチイベントをサポートする。
インスペクタで設定可能なプロパティ設計
Tooltipコンポーネントに用意する主な @property は以下の通りです。
- tooltipText: string
- 表示するツールチップの本文。
- 空文字の場合はツールチップを表示しない。
- followMouse: boolean
- オン: マウス位置に追従してツールチップを移動。
- オフ: 表示時の位置で固定。
- offsetX, offsetY: number
- マウス位置(またはターゲットノード中心)からの表示オフセット(ピクセル)。
- デフォルト: (16, -16) でマウスカーソル右下に表示されるイメージ。
- maxWidth: number
- ツールチップの最大幅(ピクセル)。
- 0以下の場合は制限なし。長文で改行させたい場合に使用。
- fontSize: number
- ツールチップテキストのフォントサイズ。
- paddingX, paddingY: number
- 背景とテキストの間の余白(ピクセル)。
- 背景サイズはテキストサイズ+パディングで自動調整。
- backgroundColor: Color
- 背景の色(半透明推奨)。
- textColor: Color
- テキストの色。
- useTouchAsHover: boolean
- モバイルなどマウスがない環境で、タッチ開始をホバー扱いにするか。
- オンにすると
TOUCH_STARTで表示、TOUCH_END/CANCELで非表示。
- showDelay: number
- ホバーしてからツールチップが表示されるまでの遅延時間(秒)。
- 0で即時表示。
- hideDelay: number
- ホバー解除してからツールチップを隠すまでの遅延時間(秒)。
- 0で即時非表示。
これらをすべてインスペクタから調整できるようにし、外部の設定ファイルやマネージャに依存しない設計にします。
TypeScriptコードの実装
import {
_decorator,
Component,
Node,
Label,
Color,
UITransform,
UIOpacity,
EventMouse,
EventTouch,
EventTarget,
input,
Input,
Vec2,
Vec3,
Canvas,
Widget,
director,
view,
macro,
} from 'cc';
const { ccclass, property } = _decorator;
/**
* Tooltip
* 任意のノードにアタッチすると、マウスホバー/タッチ時に
* 画面上部のTooltipレイヤーにテキストを表示する汎用コンポーネント。
*/
@ccclass('Tooltip')
export class Tooltip extends Component {
@property({
tooltip: 'ツールチップに表示するテキスト。空文字の場合は表示されません。',
})
public tooltipText: string = '';
@property({
tooltip: 'マウス位置に追従させるかどうか。',
})
public followMouse: boolean = true;
@property({
tooltip: 'マウス(またはターゲットノード中心)からのX方向オフセット(ピクセル)。',
})
public offsetX: number = 16;
@property({
tooltip: 'マウス(またはターゲットノード中心)からのY方向オフセット(ピクセル)。\n負の値でカーソルより上側に表示されます。',
})
public offsetY: number = -16;
@property({
tooltip: 'ツールチップの最大幅(ピクセル)。0以下で制限なし。',
})
public maxWidth: number = 300;
@property({
tooltip: 'ツールチップテキストのフォントサイズ。',
})
public fontSize: number = 18;
@property({
tooltip: '背景とテキストの左右パディング(ピクセル)。',
})
public paddingX: number = 8;
@property({
tooltip: '背景とテキストの上下パディング(ピクセル)。',
})
public paddingY: number = 4;
@property({
tooltip: '背景色(半透明推奨)。',
})
public backgroundColor: Color = new Color(0, 0, 0, 200);
@property({
tooltip: 'テキスト色。',
})
public textColor: Color = new Color(255, 255, 255, 255);
@property({
tooltip: 'モバイル環境などで、タッチ開始をホバー扱いにするかどうか。',
})
public useTouchAsHover: boolean = true;
@property({
tooltip: 'ホバーしてからツールチップが表示されるまでの遅延時間(秒)。',
min: 0,
})
public showDelay: number = 0.2;
@property({
tooltip: 'ホバー解除してからツールチップを隠すまでの遅延時間(秒)。',
min: 0,
})
public hideDelay: number = 0.1;
// 内部用: Tooltipレイヤー関連
private static _tooltipRoot: Node | null = null;
private static _tooltipLabel: Label | null = null;
private static _tooltipBg: Node | null = null;
private static _uiTransformRoot: UITransform | null = null;
private static _uiTransformBg: UITransform | null = null;
private static _uiOpacity: UIOpacity | null = null;
// 内部状態
private _hovered: boolean = false;
private _showTimer: number = 0;
private _hideTimer: number = 0;
private _currentPointerPos: Vec2 = new Vec2();
private _registeredGlobalMouseMove: boolean = false;
onLoad() {
// Tooltipレイヤーの初期化
this.ensureTooltipLayer();
// イベント登録
this.registerEvents();
}
onEnable() {
this.registerEvents();
}
onDisable() {
this.unregisterEvents();
// 自分が無効化されたらツールチップも隠す
if (Tooltip._tooltipRoot) {
this.hideTooltipImmediate();
}
}
onDestroy() {
this.unregisterEvents();
}
update(dt: number) {
if (!Tooltip._tooltipRoot || !Tooltip._tooltipLabel || !Tooltip._uiTransformRoot) {
return;
}
// 表示・非表示の遅延処理
if (this._hovered) {
if (this.showDelay > 0) {
this._showTimer += dt;
if (this._showTimer >= this.showDelay) {
this.showTooltip();
}
} else {
this.showTooltip();
}
// ホバー中は非表示タイマーをリセット
this._hideTimer = 0;
} else {
if (this.hideDelay > 0) {
if (this.isTooltipVisible()) {
this._hideTimer += dt;
if (this._hideTimer >= this.hideDelay) {
this.hideTooltipImmediate();
}
}
} else {
this.hideTooltipImmediate();
}
}
// マウス追従
if (this.followMouse && this.isTooltipVisible()) {
this.updateTooltipPosition();
}
}
/**
* Tooltipレイヤー(Canvas直下)を確保する。
* 既に存在すれば再利用し、なければ自動生成する。
*/
private ensureTooltipLayer() {
if (Tooltip._tooltipRoot && Tooltip._tooltipLabel && Tooltip._uiTransformRoot) {
return;
}
// Canvasの取得
const scene = director.getScene();
if (!scene) {
console.error('[Tooltip] シーンがロードされていません。Tooltipレイヤーを作成できません。');
return;
}
let canvasNode: Node | null = null;
for (const child of scene.children) {
const canvas = child.getComponent(Canvas);
if (canvas) {
canvasNode = child;
break;
}
}
if (!canvasNode) {
console.error('[Tooltip] シーン内にCanvasが見つかりません。Tooltipレイヤーを作成できません。Canvasを追加してください。');
return;
}
// 既存のTooltipRootを探す
let tooltipRoot = canvasNode.getChildByName('TooltipRoot');
if (!tooltipRoot) {
tooltipRoot = new Node('TooltipRoot');
canvasNode.addChild(tooltipRoot);
// UITransformを追加
const uiTrans = tooltipRoot.addComponent(UITransform);
uiTrans.setAnchorPoint(0, 1); // 左上
uiTrans.width = canvasNode.getComponent(UITransform)?.width || view.getVisibleSize().width;
uiTrans.height = canvasNode.getComponent(UITransform)?.height || view.getVisibleSize().height;
// 画面上部に張り付けるWidget(任意だが安定のため)
const widget = tooltipRoot.addComponent(Widget);
widget.isAlignTop = true;
widget.isAlignLeft = true;
widget.isAlignRight = true;
widget.isAlignBottom = true;
widget.top = 0;
widget.left = 0;
widget.right = 0;
widget.bottom = 0;
// 背景ノード
const bgNode = new Node('TooltipBg');
tooltipRoot.addChild(bgNode);
const bgTrans = bgNode.addComponent(UITransform);
bgTrans.setAnchorPoint(0, 1); // 左上
bgTrans.width = 10;
bgTrans.height = 10;
// 背景色を出すためにUIOpacity+Colorを利用
const bgOpacity = bgNode.addComponent(UIOpacity);
bgOpacity.opacity = 200;
// Labelノード
const labelNode = new Node('TooltipLabel');
tooltipRoot.addChild(labelNode);
const label = labelNode.addComponent(Label);
label.string = '';
label.fontSize = this.fontSize;
label.lineHeight = this.fontSize + 4;
label.overflow = Label.Overflow.SHRINK;
label.enableWrapText = true;
labelNode.addComponent(UITransform);
// 不透明度管理
const rootOpacity = tooltipRoot.addComponent(UIOpacity);
rootOpacity.opacity = 0; // 初期状態は非表示
Tooltip._tooltipRoot = tooltipRoot;
Tooltip._tooltipBg = bgNode;
Tooltip._tooltipLabel = label;
Tooltip._uiTransformRoot = uiTrans;
Tooltip._uiTransformBg = bgTrans;
Tooltip._uiOpacity = rootOpacity;
} else {
// 既存のTooltipRootから必要なコンポーネントを取得
const uiTrans = tooltipRoot.getComponent(UITransform);
const rootOpacity = tooltipRoot.getComponent(UIOpacity);
const bgNode = tooltipRoot.getChildByName('TooltipBg');
const labelNode = tooltipRoot.getChildByName('TooltipLabel');
const label = labelNode?.getComponent(Label) || null;
const bgTrans = bgNode?.getComponent(UITransform) || null;
if (!uiTrans || !rootOpacity || !bgNode || !labelNode || !label || !bgTrans) {
console.error('[Tooltip] 既存のTooltipRoot構成が不完全です。TooltipRootノードを削除して再生成させてください。');
return;
}
Tooltip._tooltipRoot = tooltipRoot;
Tooltip._tooltipBg = bgNode;
Tooltip._tooltipLabel = label;
Tooltip._uiTransformRoot = uiTrans;
Tooltip._uiTransformBg = bgTrans;
Tooltip._uiOpacity = rootOpacity;
}
}
/**
* アタッチ先ノードへのイベント登録
*/
private registerEvents() {
const node = this.node;
// すでに登録済みなら何もしない
// (Cocosのイベントは重複登録されるため、基本的には都度offしてからonするのが安全)
this.unregisterEvents();
node.on(Node.EventType.MOUSE_ENTER, this.onMouseEnter, this);
node.on(Node.EventType.MOUSE_LEAVE, this.onMouseLeave, this);
node.on(Node.EventType.MOUSE_MOVE, this.onMouseMove, this);
if (this.useTouchAsHover) {
node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
node.on(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
}
// グローバルマウス座標取得のため、inputにもリスナーを登録
if (!this._registeredGlobalMouseMove) {
input.on(Input.EventType.MOUSE_MOVE, this.onGlobalMouseMove, this);
this._registeredGlobalMouseMove = true;
}
}
/**
* イベント登録解除
*/
private unregisterEvents() {
const node = this.node;
node.off(Node.EventType.MOUSE_ENTER, this.onMouseEnter, this);
node.off(Node.EventType.MOUSE_LEAVE, this.onMouseLeave, this);
node.off(Node.EventType.MOUSE_MOVE, this.onMouseMove, this);
node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
if (this._registeredGlobalMouseMove) {
input.off(Input.EventType.MOUSE_MOVE, this.onGlobalMouseMove, this);
this._registeredGlobalMouseMove = false;
}
}
// ===== イベントハンドラ =====
private onMouseEnter(event: EventMouse) {
if (!this.tooltipText) {
return;
}
this._hovered = true;
this._showTimer = 0;
this._hideTimer = 0;
this.updatePointerPosFromMouse(event);
}
private onMouseLeave(event: EventMouse) {
this._hovered = false;
this._showTimer = 0;
}
private onMouseMove(event: EventMouse) {
this.updatePointerPosFromMouse(event);
}
private onTouchStart(event: EventTouch) {
if (!this.tooltipText) {
return;
}
this._hovered = true;
this._showTimer = 0;
this._hideTimer = 0;
this.updatePointerPosFromTouch(event);
}
private onTouchEnd(event: EventTouch) {
this._hovered = false;
this._showTimer = 0;
}
private onGlobalMouseMove(event: EventMouse) {
this.updatePointerPosFromMouse(event);
}
// ===== Tooltip表示制御 =====
private showTooltip() {
if (!Tooltip._tooltipRoot || !Tooltip._tooltipLabel || !Tooltip._uiOpacity || !Tooltip._uiTransformBg) {
return;
}
// 既に表示済みならテキストと位置だけ更新
if (!this.isTooltipVisible()) {
Tooltip._uiOpacity.opacity = 255;
}
// テキスト設定
Tooltip._tooltipLabel.string = this.tooltipText;
Tooltip._tooltipLabel.fontSize = this.fontSize;
Tooltip._tooltipLabel.lineHeight = this.fontSize + 4;
Tooltip._tooltipLabel.color = this.textColor;
// 最大幅設定
const labelTrans = Tooltip._tooltipLabel.node.getComponent(UITransform);
if (labelTrans) {
if (this.maxWidth > 0) {
labelTrans.width = this.maxWidth;
} else {
// 制限なしにしておく(適宜拡張可能)
labelTrans.width = 0;
}
}
// 一旦レイアウトを更新するために少し待つ必要がある場合もあるが、
// ここでは簡易的にUITransformのcontentSizeを利用
this.updateBackgroundSize();
this.updateTooltipPosition();
}
private hideTooltipImmediate() {
if (Tooltip._uiOpacity) {
Tooltip._uiOpacity.opacity = 0;
}
}
private isTooltipVisible(): boolean {
return !!Tooltip._uiOpacity && Tooltip._uiOpacity.opacity > 0;
}
/**
* 背景ノードのサイズをLabelのサイズ+パディングに合わせる
*/
private updateBackgroundSize() {
if (!Tooltip._tooltipLabel || !Tooltip._uiTransformBg) {
return;
}
const labelTrans = Tooltip._tooltipLabel.node.getComponent(UITransform);
if (!labelTrans) {
return;
}
// ラベルの実際のサイズを取得
const labelWidth = labelTrans.contentSize.width;
const labelHeight = labelTrans.contentSize.height;
Tooltip._uiTransformBg.width = labelWidth + this.paddingX * 2;
Tooltip._uiTransformBg.height = labelHeight + this.paddingY * 2;
// 背景とラベルの位置関係(左上基準)
// 背景の左上をTooltipRootの基準点とし、ラベルをその内側に配置
const bgNode = Tooltip._tooltipBg!;
const labelNode = Tooltip._tooltipLabel.node;
bgNode.setPosition(new Vec3(0, 0, 0));
// ラベルは背景の内側にオフセット
labelNode.setPosition(new Vec3(this.paddingX, -this.paddingY, 0));
}
/**
* ツールチップの表示位置を現在のポインタ位置に基づいて更新する。
* 画面端では簡易的にクランプする。
*/
private updateTooltipPosition() {
if (!Tooltip._tooltipRoot || !Tooltip._uiTransformRoot) {
return;
}
const visibleSize = view.getVisibleSize();
const canvasWidth = visibleSize.width;
const canvasHeight = visibleSize.height;
// ポインタのスクリーン座標をCanvas空間に変換
// CocosのUIは基本的に左下(0,0)~右上(canvasWidth, canvasHeight)を想定
let targetX = this._currentPointerPos.x + this.offsetX;
let targetY = this._currentPointerPos.y + this.offsetY;
const bgTrans = Tooltip._uiTransformBg;
if (bgTrans) {
const width = bgTrans.width;
const height = bgTrans.height;
// 右端にはみ出さないよう調整
if (targetX + width > canvasWidth) {
targetX = canvasWidth - width - 4;
}
// 左端
if (targetX canvasHeight - 4) {
targetY = canvasHeight - 4;
}
// 下端
if (targetY - height < 0) {
targetY = height + 4;
}
}
// TooltipRootのアンカーは左上(0,1)なので、Y座標を変換
const pos = new Vec3(
targetX,
targetY,
0
);
// 左下原点の座標を左上原点に変換
pos.y = canvasHeight - pos.y;
Tooltip._tooltipRoot.setPosition(pos);
}
// ===== 座標更新ユーティリティ =====
private updatePointerPosFromMouse(event: EventMouse) {
const loc = event.getLocation();
this._currentPointerPos.set(loc.x, loc.y);
}
private updatePointerPosFromTouch(event: EventTouch) {
const loc = event.getLocation();
this._currentPointerPos.set(loc.x, loc.y);
}
}
コードの主要部分の解説
- onLoad / ensureTooltipLayer()
- シーン内の
Canvasを自動で探し、その子としてTooltipRootノードを生成または再利用します。 TooltipRoot配下に背景ノード(TooltipBg)とテキストノード(TooltipLabel)を作成し、UITransformやLabel、UIOpacityを設定します。- Canvasが存在しない場合は
console.errorを出して処理を中断します(防御的実装)。
- シーン内の
- registerEvents / unregisterEvents
- アタッチ先ノードに対して、
MOUSE_ENTER / MOUSE_LEAVE / MOUSE_MOVEと、必要に応じてTOUCH_START / TOUCH_END / TOUCH_CANCELを登録します。 - グローバルなマウス位置を追跡するため、
input.on(MOUSE_MOVE)も登録します。 onDisable / onDestroyで必ず解除し、メモリリークや意図しない動作を防ぎます。
- アタッチ先ノードに対して、
- update(dt)
_hoveredフラグとshowDelay / hideDelayに基づいて、ツールチップの表示・非表示を制御します。- 表示中かつ
followMouseが有効な場合は、毎フレームupdateTooltipPosition()で位置更新します。
- showTooltip / hideTooltipImmediate / isTooltipVisible
- テキストやスタイル(フォントサイズ、色)を設定し、背景サイズを
updateBackgroundSize()で調整します。 - UIOpacityのopacityを 0/255 にすることで、表示・非表示を切り替えます。
- テキストやスタイル(フォントサイズ、色)を設定し、背景サイズを
- updateBackgroundSize()
- Labelの
UITransform.contentSizeを元に、背景ノードの幅・高さを計算します。 - パディング(
paddingX, paddingY)を考慮して背景を拡大し、ラベル位置を調整します。
- Labelの
- updateTooltipPosition()
- 最新のポインタ位置(
_currentPointerPos)とオフセット(offsetX, offsetY)から、ツールチップの表示位置を計算します。 - 画面端にはみ出さないように、キャンバスサイズで簡易的にクランプします。
- Canvasの左下原点とTooltipRootの左上アンカーの違いを考慮してY座標を変換しています。
- 最新のポインタ位置(
使用手順と動作確認
1. スクリプトファイルの作成
- Assetsパネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
Tooltip.tsにします。 - 生成された
Tooltip.tsをダブルクリックで開き、内容をすべて削除して、前述のコードをそのまま貼り付けて保存します。
2. Canvasの確認
- Hierarchyパネルで、シーン直下に Canvas ノードが存在するか確認します。
- もし存在しない場合は、+ ボタン → UI → Canvas でCanvasを作成してください。
- このコンポーネントはCanvas直下に
TooltipRootノードを自動生成するため、Canvasが必須です。
3. テスト用ノードの作成
- HierarchyパネルでCanvasノードを選択します。
- 右クリック → Create → UI → Sprite(またはButton、Imageなど任意のUI)を選択し、テスト用ノードを作成します。
- 作成したノードの名前を
TestIconなど分かりやすい名前に変更します。
4. Tooltipコンポーネントのアタッチ
- Hierarchyで
TestIconノードを選択します。 - Inspectorの一番下にある Add Component ボタンをクリックします。
- Custom カテゴリの中から Tooltip を選択して追加します。
5. プロパティの設定
Inspectorに表示される Tooltip コンポーネントの各プロパティを以下のように設定してみましょう。
- Tooltip Text(
tooltipText)- 例:
これはテスト用のツールチップです。
- 例:
- Follow Mouse(
followMouse)- チェックを入れる(オン)
- Offset X / Offset Y
offsetX = 16,offsetY = -16(デフォルトのままでOK)
- Max Width
300(長文を入れる場合はそのままでOK)
- Font Size
18など好みのサイズに調整。
- Padding X / Padding Y
paddingX = 8,paddingY = 4(背景との余白)
- Background Color
- RGBAで
(0, 0, 0, 200)など、少し透けた黒背景がおすすめ。
- RGBAで
- Text Color
- 白:
(255, 255, 255, 255)
- 白:
- Use Touch As Hover
- PCのみで確認するならオン/オフどちらでもOK。
- スマホ向けにビルドする場合はオン推奨。
- Show Delay / Hide Delay
showDelay = 0.2,hideDelay = 0.1など、好みで調整。
6. プレビューでの動作確認
- エディタ右上の Play ボタンを押してプレビューを開始します。
- ゲームビュー上で、先ほど作成した
TestIconノード(Spriteなど)の上にマウスカーソルを乗せます。 - 約0.2秒(showDelay)経過すると、画面上部にツールチップが表示され、カーソルに追従することを確認します。
- マウスをノードから外すと、少し遅れて(hideDelay)ツールチップが消えることを確認します。
- 長めのテキストを設定して、
maxWidthの効果(自動折り返し)も確認してみてください。
まとめ
この Tooltip コンポーネントは、
- Canvas以外の外部スクリプトやマネージャに依存せず、
- 任意のノードにアタッチしてテキストを設定するだけで、
- 共通のTooltipレイヤー上にホバー時の説明テキストを表示できる
という、再利用性の高いUIユーティリティです。
例えば、
- インベントリアイコンにアイテム名・説明文を表示する。
- スキルボタンにクールダウンや効果説明を表示する。
- ステータスバーの各項目に詳細情報を表示する。
といった用途で、同じコンポーネントをそのまま使い回せます。
スタイル(色・フォント・パディング)や挙動(ディレイ・追従の有無)もすべてインスペクタから調整できるため、デザイナーやレベルデザイナーがコードに触れずにUIの使い勝手を改善できる点も大きな利点です。
このコンポーネントをベースに、アイコン画像付きツールチップやリッチテキスト対応など、さらに高機能なTooltipシステムへ拡張していくことも容易です。まずはこの汎用版をプロジェクトに組み込んで、UIの説明表示を統一・自動化してみてください。




