【Cocos Creator 3.8】TooltipTrigger の実装:アイコンにアタッチするだけでホバー時にツールチップをポップアップ表示する汎用スクリプト
本記事では、任意のアイテムアイコン(Sprite などのノード)にアタッチするだけで、マウスカーソルを乗せたときに詳細情報のツールチップを自動表示する TooltipTrigger コンポーネントを実装します。
ツールチップ用のノードや GameManager などの外部スクリプトに一切依存せず、このスクリプト単体でツールチップの生成・表示位置・スタイル・表示/非表示制御まで完結する構成にします。
コンポーネントの設計方針
要件整理
- 任意のノード(主に UI アイテムアイコン)にアタッチするだけで動く。
- マウスカーソルがノード上にある間だけツールチップを表示する。
- ツールチップは自動で生成される(別途プレハブや UI ノードを用意しなくてよい)。
- ツールチップの内容(タイトル・説明文)はインスペクタから設定可能。
- ツールチップの見た目(背景色・余白・フォントサイズ・最大幅など)もある程度インスペクタから調整可能。
- UI システム(Canvas・UITransform・Label・Sprite)に依存するが、足りない場合は自動追加 or エラーログで明示する。
- 外部スクリプト・シングルトンには依存しない。
動作イメージ
- マウスカーソルがアイテムアイコン上に入ると、カーソル付近にツールチップがふわっと表示される。
- カーソルがアイテムから離れるとツールチップが消える。
- ツールチップは常に画面内に収まるように位置を自動調整する。
インスペクタで設定可能なプロパティ
以下のプロパティを @property として公開し、インスペクタから調整できるようにします。
- タイトル・本文テキスト
title(string): ツールチップ上部のタイトルテキスト。description(string): ツールチップ本文テキスト。
- 表示タイミング・位置
showDelay(number): ホバーしてからツールチップを表示するまでの遅延時間(秒)。offsetX(number): カーソル位置からの X オフセット(ピクセル)。offsetY(number): カーソル位置からの Y オフセット(ピクセル)。followMouse(boolean): true の場合、ツールチップがマウスに追従して動く。
- ツールチップのスタイル
maxWidth(number): ツールチップの最大幅(ピクセル)。テキストが長い場合は自動で折り返し。padding(number): 背景とテキストの内側余白(ピクセル)。backgroundColor(Color): 背景色。backgroundOpacity(number): 背景の不透明度(0〜255)。titleFontSize(number): タイトルのフォントサイズ。descriptionFontSize(number): 説明文のフォントサイズ。textColor(Color): テキストカラー(タイトル/本文共通)。
- キャンバス参照
uiCanvas(Node | null): ツールチップを配置する Canvas ノード。未指定の場合はシーン内の最初の Canvas を自動検出して使用。
マウスホバー検出には UITransform と NodeEventType.MOUSE_ENTER / MOUSE_LEAVE / MOUSE_MOVE を利用します。ツールチップの UI は以下のように自動生成します。
- 背景ノード(Sprite)
- タイトル用 Label ノード
- 説明文用 Label ノード
これらを 1 つの親ノード(tooltipRoot)の子としてまとめ、Canvas 配下に動的に追加・削除します。
TypeScriptコードの実装
import {
_decorator,
Component,
Node,
Label,
Color,
UITransform,
NodeEventType,
EventMouse,
Vec2,
Vec3,
Canvas,
director,
Sprite,
SpriteFrame,
UIRenderer,
math,
} from 'cc';
const { ccclass, property } = _decorator;
/**
* TooltipTrigger
* 任意の UI ノードにアタッチするだけで、
* マウスホバー時にツールチップをポップアップ表示する汎用コンポーネント。
*/
@ccclass('TooltipTrigger')
export class TooltipTrigger extends Component {
// ===== ツールチップ内容 =====
@property({
tooltip: 'ツールチップのタイトルテキスト。',
})
public title: string = 'アイテム名';
@property({
tooltip: 'ツールチップの説明テキスト(長文可)。',
multiline: true,
})
public description: string = 'ここにアイテムの説明を表示します。';
// ===== 表示タイミング・位置 =====
@property({
tooltip: 'ホバーしてからツールチップを表示するまでの遅延時間(秒)。0 なら即時表示。',
min: 0,
})
public showDelay: number = 0.2;
@property({
tooltip: 'カーソル位置からの X オフセット(ピクセル)。正の値で右方向。',
})
public offsetX: number = 16;
@property({
tooltip: 'カーソル位置からの Y オフセット(ピクセル)。正の値で上方向。',
})
public offsetY: number = 16;
@property({
tooltip: 'true の場合、ツールチップがマウスに追従して動きます。',
})
public followMouse: boolean = true;
// ===== スタイル設定 =====
@property({
tooltip: 'ツールチップの最大幅(ピクセル)。0 なら制限なし。',
min: 0,
})
public maxWidth: number = 260;
@property({
tooltip: '背景とテキストの内側余白(ピクセル)。',
min: 0,
})
public padding: number = 8;
@property({
tooltip: '背景色(不透明度は backgroundOpacity で制御)。',
})
public backgroundColor: Color = new Color(0, 0, 0, 255);
@property({
tooltip: '背景の不透明度(0〜255)。0 で完全透明、255 で不透明。',
min: 0,
max: 255,
slide: true,
})
public backgroundOpacity: number = 180;
@property({
tooltip: 'タイトルテキストのフォントサイズ。',
min: 1,
})
public titleFontSize: number = 20;
@property({
tooltip: '説明文テキストのフォントサイズ。',
min: 1,
})
public descriptionFontSize: number = 16;
@property({
tooltip: 'テキストカラー(タイトル/説明共通)。',
})
public textColor: Color = new Color(255, 255, 255, 255);
// ===== Canvas 参照 =====
@property({
type: Node,
tooltip: 'ツールチップを配置する Canvas ノード。\n未指定の場合はシーン内の最初の Canvas を自動検出します。',
})
public uiCanvas: Node | null = null;
// ===== 内部用フィールド(インスペクタ非表示) =====
private _tooltipRoot: Node | null = null;
private _titleLabel: Label | null = null;
private _descLabel: Label | null = null;
private _bgSprite: Sprite | null = null;
private _isPointerInside: boolean = false;
private _hoverElapsed: number = 0;
private _currentMousePos: Vec2 = new Vec2();
private _canvasTransform: UITransform | null = null;
onLoad() {
// UITransform がなければ追加(ホバー検出に必要)
let uiTrans = this.node.getComponent(UITransform);
if (!uiTrans) {
uiTrans = this.node.addComponent(UITransform);
console.warn(
'[TooltipTrigger] アタッチ先ノードに UITransform が存在しなかったため、自動で追加しました。',
);
}
// マウスイベント登録
this.node.on(NodeEventType.MOUSE_ENTER, this._onMouseEnter, this);
this.node.on(NodeEventType.MOUSE_LEAVE, this._onMouseLeave, this);
this.node.on(NodeEventType.MOUSE_MOVE, this._onMouseMove, this);
}
start() {
// Canvas を確定
this._ensureCanvas();
}
onDestroy() {
// イベント解除
this.node.off(NodeEventType.MOUSE_ENTER, this._onMouseEnter, this);
this.node.off(NodeEventType.MOUSE_LEAVE, this._onMouseLeave, this);
this.node.off(NodeEventType.MOUSE_MOVE, this._onMouseMove, this);
// ツールチップノードを破棄
if (this._tooltipRoot && this._tooltipRoot.isValid) {
this._tooltipRoot.destroy();
}
this._tooltipRoot = null;
this._titleLabel = null;
this._descLabel = null;
this._bgSprite = null;
}
update(dt: number) {
if (!this._isPointerInside) {
this._hoverElapsed = 0;
return;
}
this._hoverElapsed += dt;
// 遅延時間を超えたらツールチップを表示
if (this._hoverElapsed >= this.showDelay) {
if (!this._tooltipRoot) {
this._createTooltipNode();
}
this._updateTooltipContent();
this._updateTooltipPosition();
this._tooltipRoot!.active = true;
} else {
// 遅延中は非表示
if (this._tooltipRoot) {
this._tooltipRoot.active = false;
}
}
// マウス追従
if (this.followMouse && this._tooltipRoot && this._tooltipRoot.active) {
this._updateTooltipPosition();
}
}
// ===== イベントハンドラ =====
private _onMouseEnter(event: EventMouse) {
this._isPointerInside = true;
this._hoverElapsed = 0;
this._updateMousePos(event);
}
private _onMouseLeave(event: EventMouse) {
this._isPointerInside = false;
this._hoverElapsed = 0;
if (this._tooltipRoot) {
this._tooltipRoot.active = false;
}
}
private _onMouseMove(event: EventMouse) {
this._updateMousePos(event);
}
private _updateMousePos(event: EventMouse) {
event.getUILocation(this._currentMousePos);
}
// ===== Canvas 準備 =====
private _ensureCanvas() {
if (this.uiCanvas) {
this._canvasTransform = this.uiCanvas.getComponent(UITransform);
if (!this._canvasTransform) {
console.error(
'[TooltipTrigger] 指定された uiCanvas に UITransform が存在しません。Canvas ノードを指定してください。',
);
}
return;
}
// 未指定の場合はシーンから Canvas を自動検索
const scenes = director.getScene();
if (!scenes) {
console.error('[TooltipTrigger] シーンがロードされていません。');
return;
}
const canvasNode = scenes.getComponentInChildren(Canvas)?.node;
if (!canvasNode) {
console.error(
'[TooltipTrigger] シーン内に Canvas が見つかりません。UI 用の Canvas を作成してください。',
);
return;
}
this.uiCanvas = canvasNode;
this._canvasTransform = canvasNode.getComponent(UITransform) || null;
if (!this._canvasTransform) {
console.error(
'[TooltipTrigger] 自動検出した Canvas ノードに UITransform がありません。',
);
}
}
// ===== ツールチップ生成・更新 =====
private _createTooltipNode() {
if (!this.uiCanvas || !this._canvasTransform) {
this._ensureCanvas();
if (!this.uiCanvas || !this._canvasTransform) {
console.error(
'[TooltipTrigger] Canvas が見つからないため、ツールチップを生成できません。',
);
return;
}
}
// ルートノード
const root = new Node('TooltipRoot');
const rootTrans = root.addComponent(UITransform);
root.layer = this.uiCanvas!.layer;
root.setParent(this.uiCanvas!);
// 背景ノード
const bgNode = new Node('Background');
const bgTrans = bgNode.addComponent(UITransform);
const bgSprite = bgNode.addComponent(Sprite);
bgNode.setParent(root);
// タイトルラベル
const titleNode = new Node('Title');
const titleTrans = titleNode.addComponent(UITransform);
const titleLabel = titleNode.addComponent(Label);
titleNode.setParent(root);
// 説明ラベル
const descNode = new Node('Description');
const descTrans = descNode.addComponent(UITransform);
const descLabel = descNode.addComponent(Label);
descNode.setParent(root);
// ラベル設定
titleLabel.string = this.title;
titleLabel.fontSize = this.titleFontSize;
titleLabel.color = this.textColor.clone();
titleLabel.overflow = Label.Overflow.SHRINK;
titleLabel.horizontalAlign = Label.HorizontalAlign.LEFT;
titleLabel.verticalAlign = Label.VerticalAlign.TOP;
descLabel.string = this.description;
descLabel.fontSize = this.descriptionFontSize;
descLabel.color = this.textColor.clone();
descLabel.overflow = Label.Overflow.RESIZE_HEIGHT;
descLabel.horizontalAlign = Label.HorizontalAlign.LEFT;
descLabel.verticalAlign = Label.VerticalAlign.TOP;
// 幅制限
if (this.maxWidth > 0) {
titleTrans.width = this.maxWidth;
descTrans.width = this.maxWidth;
}
// 背景色設定(単色スプライト)
bgSprite.color = new Color(
this.backgroundColor.r,
this.backgroundColor.g,
this.backgroundColor.b,
this.backgroundOpacity,
);
// SpriteFrame がないと描画されないので空の SpriteFrame を設定
bgSprite.spriteFrame = new SpriteFrame();
// 一旦サイズを仮決めしてレイアウト
const padding = this.padding;
titleTrans.anchorX = 0;
titleTrans.anchorY = 1;
descTrans.anchorX = 0;
descTrans.anchorY = 1;
bgTrans.anchorX = 0;
bgTrans.anchorY = 1;
rootTrans.anchorX = 0;
rootTrans.anchorY = 1;
// タイトル位置(ルートの左上から padding 分だけ内側)
titleNode.setPosition(padding, -padding);
// 説明文の位置(タイトルの下に配置)
const approxTitleHeight = this.titleFontSize + 4;
descNode.setPosition(padding, -padding - approxTitleHeight);
// 説明文の高さはテキストから自動計算されるので、update の中で最終サイズを調整
// ここでは仮のサイズを設定
const approxDescHeight = Math.max(this.descriptionFontSize * 2, this.descriptionFontSize + 4);
const totalWidth = (this.maxWidth > 0 ? this.maxWidth : Math.max(titleTrans.width, descTrans.width)) + padding * 2;
const totalHeight = padding + approxTitleHeight + approxDescHeight + padding;
bgTrans.setContentSize(totalWidth, totalHeight);
rootTrans.setContentSize(totalWidth, totalHeight);
bgNode.setPosition(0, 0);
// 内部参照を保存
this._tooltipRoot = root;
this._titleLabel = titleLabel;
this._descLabel = descLabel;
this._bgSprite = bgSprite;
// 初期状態は非表示
this._tooltipRoot.active = false;
}
private _updateTooltipContent() {
if (!this._titleLabel || !this._descLabel || !this._tooltipRoot) {
return;
}
// テキスト内容更新
this._titleLabel.string = this.title;
this._titleLabel.fontSize = this.titleFontSize;
this._titleLabel.color = this.textColor.clone();
this._descLabel.string = this.description;
this._descLabel.fontSize = this.descriptionFontSize;
this._descLabel.color = this.textColor.clone();
// 背景色更新
if (this._bgSprite) {
this._bgSprite.color = new Color(
this.backgroundColor.r,
this.backgroundColor.g,
this.backgroundColor.b,
this.backgroundOpacity,
);
}
// サイズ再計算
const rootTrans = this._tooltipRoot.getComponent(UITransform);
const bgTrans = this._bgSprite?.getComponent(UITransform);
const titleTrans = this._titleLabel.node.getComponent(UITransform);
const descTrans = this._descLabel.node.getComponent(UITransform);
if (!rootTrans || !bgTrans || !titleTrans || !descTrans) {
return;
}
// 幅制限再適用
if (this.maxWidth > 0) {
titleTrans.width = this.maxWidth;
descTrans.width = this.maxWidth;
}
// Label.Overflow.RESIZE_HEIGHT により高さが自動調整される想定
const padding = this.padding;
const titleHeight = this.titleFontSize + 4;
const descHeight = Math.max(descTrans.height, this.descriptionFontSize + 4);
const totalWidth = (this.maxWidth > 0 ? this.maxWidth : Math.max(titleTrans.width, descTrans.width)) + padding * 2;
const totalHeight = padding + titleHeight + descHeight + padding;
bgTrans.setContentSize(totalWidth, totalHeight);
rootTrans.setContentSize(totalWidth, totalHeight);
// ノード位置再調整
this._titleLabel.node.setPosition(padding, -padding);
this._descLabel.node.setPosition(padding, -padding - titleHeight);
}
// ===== 位置更新(画面内に収める) =====
private _updateTooltipPosition() {
if (!this._tooltipRoot || !this._canvasTransform) {
return;
}
// マウスの UI 座標を Canvas ローカルに変換
const uiPos = this._currentMousePos;
const canvasTrans = this._canvasTransform;
// Canvas は通常 (0,0) が中央なので、スクリーン座標からローカル座標に変換
const localPos = new Vec3(
uiPos.x - canvasTrans.width / 2,
uiPos.y - canvasTrans.height / 2,
0,
);
// オフセット適用
localPos.x += this.offsetX;
localPos.y += this.offsetY;
const rootTrans = this._tooltipRoot.getComponent(UITransform)!;
// 画面内に収めるためのクランプ
const halfW = canvasTrans.width / 2;
const halfH = canvasTrans.height / 2;
const tipHalfW = rootTrans.width / 2;
const tipHalfH = rootTrans.height / 2;
// 左右
const minX = -halfW + tipHalfW;
const maxX = halfW - tipHalfW;
localPos.x = math.clamp(localPos.x, minX, maxX);
// 上下
const minY = -halfH + tipHalfH;
const maxY = halfH - tipHalfH;
localPos.y = math.clamp(localPos.y, minY, maxY);
this._tooltipRoot.setPosition(localPos);
}
}
コードのポイント解説
- onLoad
- アタッチ先ノードに
UITransformが無い場合、自動で追加(ホバー検出のため)。 - マウスイベント(
MOUSE_ENTER,MOUSE_LEAVE,MOUSE_MOVE)を登録。
- アタッチ先ノードに
- start
- ツールチップを配置する Canvas を
_ensureCanvasで確定。 uiCanvas未指定ならシーン内の最初のCanvasを自動検出。
- ツールチップを配置する Canvas を
- update
- ホバー中 (
_isPointerInsidetrue) の経過時間を計測。 showDelayを超えたらツールチップノードを生成/更新し、表示。followMouseが true の場合、毎フレームマウス位置に追従。
- ホバー中 (
- _createTooltipNode
- Canvas 配下にツールチップ用のルートノードを生成。
- 背景 Sprite、タイトル Label、説明 Label を自動で作成し、基本レイアウトを組み立てる。
- 背景には単色の
SpriteFrameを割り当て、backgroundColorとbackgroundOpacityを反映。
- _updateTooltipContent
- インスペクタから設定した
title,description, フォントサイズ、色などを Label に反映。 - テキストの高さに合わせて背景とルートのサイズを再計算。
- インスペクタから設定した
- _updateTooltipPosition
- マウスの UI 座標を Canvas ローカル座標に変換し、
offsetX,offsetYを加算。 - ツールチップが画面外にはみ出さないように、Canvas のサイズ内で位置をクランプ。
- マウスの UI 座標を Canvas ローカル座標に変換し、
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリックします。
Create > TypeScriptを選択します。- 新規スクリプトの名前を
TooltipTrigger.tsに変更します。 - ダブルクリックしてエディタで開き、既存コードをすべて削除して、本記事の
TooltipTriggerコードを貼り付け、保存します。
2. Canvas(UI 親ノード)の確認
- Hierarchy パネルで、シーン内に
Canvasノードがあるか確認します。- もし無い場合は、
+ > UI > Canvasを選択して Canvas を作成してください。
- もし無い場合は、
uiCanvas プロパティを未設定にすると、この Canvas が自動的に使用されます。
3. テスト用アイテムアイコンノードの作成
- Hierarchy パネルで
Canvasを右クリックし、Create > UI > Spriteを選択します。 - 作成された Sprite ノードを
ItemIconなど分かりやすい名前に変更します。 - Inspector の
Spriteコンポーネントで、任意のアイコン画像をSpriteFrameに設定します。 - 必要に応じて
UITransformのContent Sizeを調整し、アイコンの大きさを決めます。
4. TooltipTrigger コンポーネントのアタッチ
- Hierarchy で
ItemIconノードを選択します。 - Inspector の下部にある
Add Componentボタンをクリックします。 Custom > TooltipTriggerを選択してアタッチします。
5. プロパティの設定例
ItemIcon ノードにアタッチした TooltipTrigger コンポーネントの各プロパティを、次のように設定してみてください。
- ツールチップ内容
Title:ヒールポーションDescription:使用すると HP を 50 回復する基本的な回復薬です。
- 表示タイミング・位置
Show Delay:0.2(0.2 秒ホバー後に表示)Offset X:16Offset Y:16Follow Mouse:ON(チェックを入れる)
- スタイル
Max Width:260Padding:8Background Color:#000000(黒)Background Opacity:180(少し透ける黒)Title Font Size:20Description Font Size:16Text Color:#FFFFFF(白)
- Canvas 参照
Ui Canvas: 空のままで OK(自動検出に任せる)
6. 動作確認
- エディタ右上の
Playボタン(プレビュー)をクリックします。 - Game ウィンドウで、マウスカーソルを
ItemIcon上に乗せます。 Show Delayで指定した時間(例: 0.2 秒)ホバーすると、カーソル付近に黒背景・白文字のツールチップが表示されます。- マウスをアイコンから外すと、ツールチップが消えます。
Follow Mouseを ON にしている場合、ホバー中にマウスを少し動かすと、ツールチップがマウスに追従して動きます。- 画面端に近づけても、ツールチップが画面外にはみ出さないように位置が自動調整されていることを確認してください。
7. 複数アイテムでの再利用
同じシーン内で複数のアイテムにツールチップを付けたい場合も、各アイテムノードに TooltipTrigger をアタッチし、それぞれの Title と Description を変えるだけで動作します。
- 別の Sprite ノード(例:
ItemIcon_Sword)を作成。 - 同様に
TooltipTriggerをアタッチ。 Title:ブロンズソードDescription:攻撃力 +5 の初心者向けの剣。
このように、スクリプト単体で完結しているため、追加の管理クラスやグローバルなシングルトンを用意する必要はありません。
まとめ
本記事では、Cocos Creator 3.8 / TypeScript で、任意のノードにアタッチするだけでマウスホバー時にツールチップをポップアップ表示できる汎用コンポーネント TooltipTrigger を実装しました。
- ツールチップの生成から表示/非表示、位置調整までをコンポーネント内部で完結。
- インスペクタからタイトル・説明文・スタイル・表示タイミングを柔軟に調整可能。
- Canvas の自動検出や UITransform の自動追加により、最低限の設定で動作。
- 他のカスタムスクリプトやシングルトンに一切依存しないため、どのプロジェクトにも簡単に持ち込んで再利用できる。
このコンポーネントをベースに、例えば以下のような拡張も容易です。
- キーボード/ゲームパッドフォーカス時のツールチップ表示。
- フェードイン/アウトのアニメーション追加。
- アイテムレアリティに応じて枠線色やアイコンを切り替え。
まずはこの記事のコードをそのままプロジェクトに組み込んで、アイテム一覧やスキルツリーなどにツールチップを付けてみてください。UI の分かりやすさが一段と向上し、実装効率も大きく上がるはずです。




