【Cocos Creator 3.8】FloatingText の実装:アタッチするだけで「ダメージ数値が頭上にふわっと浮かぶ」ポップアップを実現する汎用スクリプト
この記事では、キャラクターや敵のノードにアタッチするだけで、ダメージ数値などのテキストが頭上にポップアップして、ふわっと浮かびながらフェードアウトする汎用コンポーネント FloatingText を実装します。
外部の GameManager などには一切依存せず、このスクリプト 1 本だけで完結します。Inspector からフォントサイズ・色・浮き上がる距離・再生時間などを調整できるため、さまざまなゲームで再利用できます。
コンポーネントの設計方針
機能要件の整理
- ダメージなどの数値を受け取って、親ノードの頭上に数値テキストを生成する。
- テキストは
- 指定した開始位置(親ノードのローカル座標+オフセット)から
- 指定した距離だけ上方向へ移動しながら
- 指定した時間でフェードアウトし
- アニメーション終了後に自動で破棄される
- どのノードにアタッチしても動作するよう、外部スクリプトには一切依存しない。
- Canvas / UI シーン内での使用を想定し、
LabelとUIOpacityを利用して実装する。 - 攻撃側のスクリプトなどから簡単に呼べるように、public メソッドを用意する:
showFloatingText(value: number | string, color?: Color): void
- 防御的実装として、必要な標準コンポーネントが無い場合は自動追加 or エラーログ出力を行う。
インスペクタで設定可能なプロパティ
FloatingText は、以下のようなプロパティを Inspector から調整できるようにします。
- 見た目関連
fontSize: number
テキストのフォントサイズ(ピクセル)。例:24。defaultColor: Color
テキストの基本色。ダメージなら赤系、回復なら緑系など。bold: boolean
太字にするかどうか。outlineWidth: number
アウトライン(縁取り)の太さ。0 でアウトライン無し。outlineColor: Color
アウトラインの色。
- 動き・アニメーション関連
startOffset: Vec3
親ノードのローカル座標からの開始オフセット。例:(0, 50, 0)で頭上に表示。floatDistance: number
上方向にどれだけ移動するか(ローカル Y 座標の距離)。例:50。duration: number
浮遊&フェードアウトにかける総時間(秒)。例:0.8。useScalePop: boolean
最初に少し大きくなってから縮む「ポップ」演出を行うか。scalePopFactor: number
ポップ時の最大スケール倍率。例:1.2。
- 生成位置・親子関係関連
useWorldSpace: boolean
親ノードのワールド座標に追従して UI 上に表示するかどうか。
– true: 3D キャラの頭上に UI として表示したい場合など(Canvas 内に作成)。
– false: 親ノードのローカル空間内にそのまま Label を作成。uiRoot: Node | null
useWorldSpace = trueの場合に、生成する Label をぶら下げる Canvas(UI)ノード。未設定なら自動で親を使うかログを出す。
- 挙動制御
autoZOffset: number
各ポップアップごとに少し z をずらして、重なりを軽減するためのオフセット値。maxConcurrent: number
同時に存在できる最大ポップアップ数。超えた場合は一番古いものから強制削除。
これらのプロパティをすべて Inspector で設定できるようにし、このコンポーネントをアタッチしたノードに対して showFloatingText() を呼ぶだけで、ポップアップ演出が動作するようにします。
TypeScriptコードの実装
以下が、完成した FloatingText.ts の全コードです。
import { _decorator, Component, Node, Label, Color, Vec3, UITransform, UIOpacity, tween, Tween, v3, math, LabelOutline, director, Canvas } from 'cc';
const { ccclass, property } = _decorator;
/**
* FloatingText
* 親ノードの頭上に数値テキストをポップアップ表示する汎用コンポーネント。
*
* 使用例:
* // 攻撃スクリプトなどから
* const ft = this.node.getComponent(FloatingText);
* ft?.showFloatingText(120); // デフォルト色で表示
* ft?.showFloatingText(-30, new Color(0,255,0)); // 色を指定して表示
*/
@ccclass('FloatingText')
export class FloatingText extends Component {
// =========================
// 見た目関連
// =========================
@property({
tooltip: 'ポップアップテキストのフォントサイズ(ピクセル)。'
})
public fontSize: number = 24;
@property({
tooltip: 'ポップアップテキストの基本色。'
})
public defaultColor: Color = new Color(255, 80, 80, 255);
@property({
tooltip: 'テキストを太字で表示するかどうか。'
})
public bold: boolean = true;
@property({
tooltip: 'アウトライン(縁取り)の太さ。0 でアウトライン無し。'
})
public outlineWidth: number = 2;
@property({
tooltip: 'アウトライン(縁取り)の色。'
})
public outlineColor: Color = new Color(0, 0, 0, 255);
// =========================
// 動き・アニメーション関連
// =========================
@property({
tooltip: '親ノードのローカル座標からの開始オフセット。頭上に表示したい場合は Y を正の値にします。'
})
public startOffset: Vec3 = new Vec3(0, 50, 0);
@property({
tooltip: '上方向へ浮き上がる距離(ローカル Y 座標の距離)。'
})
public floatDistance: number = 50;
@property({
tooltip: '浮遊&フェードアウトにかける総時間(秒)。'
})
public duration: number = 0.8;
@property({
tooltip: '最初に少し大きくなってから縮む「ポップ」アニメーションを行うかどうか。'
})
public useScalePop: boolean = true;
@property({
tooltip: 'ポップ時の最大スケール倍率(1.0 で通常サイズ)。'
})
public scalePopFactor: number = 1.2;
// =========================
// 生成位置・親子関係関連
// =========================
@property({
tooltip: 'ワールド座標を UI 空間に変換して Canvas 上に表示するかどうか。',
})
public useWorldSpace: boolean = false;
@property({
type: Node,
tooltip: 'useWorldSpace = true の場合に、ポップアップをぶら下げる UI ルート(通常は Canvas ノード)。未設定の場合は自動でシーン内の Canvas を検索します。'
})
public uiRoot: Node | null = null;
// =========================
// 挙動制御
// =========================
@property({
tooltip: '各ポップアップごとに z 座標へ加算するオフセット。重なり軽減用。'
})
public autoZOffset: number = 0.01;
@property({
tooltip: '同時に存在できる最大ポップアップ数。超えた場合は一番古いものから削除します。0 以下なら無制限。'
})
public maxConcurrent: number = 10;
// 内部管理用
private _activeTexts: Node[] = [];
private _zCounter: number = 0;
onLoad() {
// useWorldSpace = true で uiRoot が未設定なら、シーン内の Canvas を探す
if (this.useWorldSpace && !this.uiRoot) {
const scene = director.getScene();
if (scene) {
const canvas = scene.getComponentInChildren(Canvas);
if (canvas) {
this.uiRoot = canvas.node;
console.log('[FloatingText] uiRoot が未設定のため、シーン内の Canvas を自動で使用します。');
} else {
console.warn('[FloatingText] useWorldSpace が true ですが、シーン内に Canvas が見つかりません。uiRoot を Inspector で指定してください。');
}
}
}
}
/**
* ダメージなどの数値をポップアップ表示する。
* @param value 表示する数値または文字列
* @param color 任意のテキストカラー(省略時は defaultColor)
*/
public showFloatingText(value: number | string, color?: Color): void {
const text = String(value);
// ポップアップ数の上限管理
if (this.maxConcurrent > 0 && this._activeTexts.length >= this.maxConcurrent) {
const oldest = this._activeTexts.shift();
if (oldest && oldest.isValid) {
oldest.destroy();
}
}
// Label ノード生成
const labelNode = new Node('FloatingTextLabel');
let parentForLabel: Node | null = null;
if (this.useWorldSpace) {
// UI 空間に表示
if (!this.uiRoot) {
console.error('[FloatingText] useWorldSpace が true ですが uiRoot が設定されていません。ポップアップを生成できません。');
return;
}
parentForLabel = this.uiRoot;
} else {
// 親ノードのローカル空間に表示
parentForLabel = this.node;
}
parentForLabel.addChild(labelNode);
// 必要なコンポーネントを追加
const uiTrans = labelNode.addComponent(UITransform);
uiTrans.setContentSize(100, 40); // 適当なデフォルトサイズ
const label = labelNode.addComponent(Label);
label.string = text;
label.fontSize = this.fontSize;
label.color = color ? color.clone() : this.defaultColor.clone();
label.enableBold = this.bold;
label.horizontalAlign = Label.HorizontalAlign.CENTER;
label.verticalAlign = Label.VerticalAlign.CENTER;
// アウトライン
if (this.outlineWidth > 0) {
const outline = labelNode.addComponent(LabelOutline);
outline.width = this.outlineWidth;
outline.color = this.outlineColor.clone();
}
// 透明度管理
const opacity = labelNode.addComponent(UIOpacity);
opacity.opacity = 255;
// 位置の設定
let startPos = new Vec3();
if (this.useWorldSpace) {
// 親ノードのワールド座標を Canvas(UI) 空間に変換して配置
const worldPos = this.node.worldPosition.clone();
// UITransform を通してローカル座標に変換
const uiRootTransform = this.uiRoot!.getComponent(UITransform);
if (!uiRootTransform) {
console.warn('[FloatingText] uiRoot に UITransform がありません。UI 空間での位置計算が正しく行えない可能性があります。');
// とりあえず worldPosition をそのまま使う
startPos = worldPos.add(this.startOffset);
} else {
// ワールド座標 → ローカル座標変換
uiRootTransform.convertToNodeSpaceAR(worldPos, startPos);
startPos.add(this.startOffset);
}
} else {
// 親ノードのローカル空間で、そのまま startOffset を使う
startPos.set(this.startOffset);
}
// z 方向の自動オフセット
if (this.autoZOffset !== 0) {
this._zCounter++;
startPos.z += this.autoZOffset * this._zCounter;
}
labelNode.setPosition(startPos);
// スケール初期値
labelNode.setScale(v3(1, 1, 1));
// 管理リストに追加
this._activeTexts.push(labelNode);
// アニメーション設定
const endPos = new Vec3(startPos.x, startPos.y + this.floatDistance, startPos.z);
const totalDuration = math.clamp(this.duration, 0.1, 10); // 過度な値を防ぐ
// ポップアニメーション用のスケール
const startScale = 1.0;
const peakScale = this.useScalePop ? this.scalePopFactor : 1.0;
const halfDuration = totalDuration * 0.3;
const remainDuration = totalDuration - halfDuration;
// 位置と透明度の tween
const moveAndFade: Tween<any> = tween(labelNode)
.parallel(
tween(labelNode)
.to(totalDuration, { position: endPos }, { easing: 'quadOut' }),
tween(opacity)
.to(totalDuration, { opacity: 0 }, { easing: 'quadIn' })
)
.call(() => {
// 終了後にノードを破棄
const idx = this._activeTexts.indexOf(labelNode);
if (idx !== -1) {
this._activeTexts.splice(idx, 1);
}
labelNode.destroy();
});
// スケールポップの tween
let scaleTween: Tween<any> | null = null;
if (this.useScalePop && peakScale > 1.0) {
const startScaleVec = v3(startScale, startScale, startScale);
const peakScaleVec = v3(peakScale, peakScale, peakScale);
const endScaleVec = v3(1, 1, 1);
labelNode.setScale(startScaleVec);
scaleTween = tween(labelNode)
.to(halfDuration, { scale: peakScaleVec }, { easing: 'backOut' })
.to(remainDuration, { scale: endScaleVec }, { easing: 'quadIn' });
}
// 並列再生
if (scaleTween) {
tween(labelNode)
.parallel(moveAndFade, scaleTween)
.start();
} else {
moveAndFade.start();
}
}
}
コードのポイント解説
onLoaduseWorldSpaceが true かつuiRoot未設定の場合、シーン内のCanvasを自動で検索し、見つかればそれをuiRootに設定します。- Canvas が見つからない場合は
console.warnを出して、Inspector での設定を促します。
showFloatingText- 外部から呼ぶための public メソッドです。ダメージ値(number or string)と任意の色を受け取ります。
maxConcurrentを超える場合は、一番古いポップアップを破棄してから新しいものを生成します。useWorldSpaceに応じて、uiRoot(Canvas)か自ノードに子ノードとして Label を追加します。- 必要なコンポーネント(
UITransform,Label,LabelOutline,UIOpacity)を自動で追加します。 - 開始位置は
useWorldSpace = false: 親ノードのローカル座標にstartOffsetを足した位置。useWorldSpace = true: 親ノードのワールド座標をuiRootのローカル座標に変換し、そこにstartOffsetを足した位置。
autoZOffsetにより、連続で生成した場合でも少しずつ z をずらして重なりを軽減します。tweenを使って- 位置を上方向へ移動
- 透明度を 255 → 0 に変化
- (オプションで)スケールをポップさせる
を並列に再生し、終了後にノードを破棄します。
使用手順と動作確認
1. スクリプトファイルを作成
- エディタ下部の Assets パネルで、スクリプトを置きたいフォルダを右クリックします。
- Create → TypeScript を選択し、ファイル名を
FloatingText.tsにします。 - 自動生成されたファイルをダブルクリックして開き、内容をすべて削除して、前章のコードをそのまま貼り付けて保存します。
2. テスト用ノードを作成
UI シーン(Canvas 内)での利用を例に説明します。
- Hierarchy パネルで右クリック → Create → UI → Canvas を選択し、Canvas がなければ作成します。
- Canvas の子として、Create → UI → Sprite などでテスト用のキャラクターノードを作成し、名前を
Enemyなどに変更します。
3. FloatingText コンポーネントをアタッチ
- Hierarchy で
Enemyノードを選択します。 - Inspector の下部にある Add Component ボタンをクリックします。
- Custom カテゴリから FloatingText を選択して追加します。
4. Inspector プロパティを設定
Enemy ノードの Inspector に表示される FloatingText コンポーネントのプロパティを、例えば次のように設定します。
- 見た目
- Font Size:
28 - Default Color:
(255, 80, 80, 255)(赤系) - Bold: チェック ON
- Outline Width:
2 - Outline Color: 黒
- Font Size:
- 動き
- Start Offset:
(0, 60, 0)(頭上に少し離して表示) - Float Distance:
60 - Duration:
0.8 - Use Scale Pop: チェック ON
- Scale Pop Factor:
1.2
- Start Offset:
- 位置・親子関係
- Use World Space:
- Canvas 内の 2D UI だけで完結するなら OFF(false)
- 3D キャラの頭上に Canvas 上の UI として表示したいなら ON(true)
- UI Root:
- Use World Space を ON にした場合、Canvas ノードをドラッグ&ドロップして設定
- OFF の場合は空のままでOK
- Use World Space:
- 挙動制御
- Auto Z Offset:
0.01 - Max Concurrent:
10
- Auto Z Offset:
5. テスト用の呼び出しコードを書く
FloatingText 自体は「アタッチするだけ」で準備は完了ですが、実際にポップアップを表示するには、どこかのタイミングで showFloatingText() を呼ぶ必要があります。
簡単なテストとして、Enemy ノードに「クリックしたらダメージポップアップを出す」スクリプトを追加してみます。
- Assets パネルで右クリック → Create → TypeScript で
EnemyTest.tsを作成します。 - 以下のコードを貼り付けます。
import { _decorator, Component, Node, input, Input, EventMouse, Color } from 'cc';
import { FloatingText } from './FloatingText';
const { ccclass, property } = _decorator;
@ccclass('EnemyTest')
export class EnemyTest extends Component {
private _floatingText: FloatingText | null = null;
onLoad() {
this._floatingText = this.node.getComponent(FloatingText);
if (!this._floatingText) {
console.warn('[EnemyTest] FloatingText コンポーネントが同じノードに見つかりません。Inspector で追加してください。');
}
}
start() {
// マウスクリックイベントを登録(エディタプレビュー用)
input.on(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
}
onDestroy() {
input.off(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
}
private onMouseDown(event: EventMouse) {
if (!this._floatingText) {
return;
}
// 仮のダメージ値をランダムに生成
const damage = Math.floor(50 + Math.random() * 50); // 50〜99
// たまにクリティカル(黄色)を出す例
if (Math.random() < 0.2) {
this._floatingText.showFloatingText(damage, new Color(255, 230, 80, 255));
} else {
this._floatingText.showFloatingText(damage);
}
}
}
- Hierarchy で
Enemyノードを選択し、Add Component → Custom → EnemyTest を追加します。 - シーンを保存し、再生ボタン(Play) を押してゲームを実行します。
- Game ビュー上でクリックすると、
EnemyノードにアタッチされたFloatingTextが呼び出され、頭上にダメージ値がポップアップ表示されることを確認します。
実際のゲームでは、攻撃処理やダメージ計算を行っているスクリプトから、以下のように呼び出すだけで利用できます。
// 例: ダメージ処理の中で
const ft = this.node.getComponent(FloatingText);
if (ft) {
ft.showFloatingText(actualDamage); // 色指定なし → defaultColor
}
まとめ
FloatingTextコンポーネントは、ノードにアタッチしてshowFloatingText()を呼ぶだけで、- 頭上に数値テキストをポップアップ表示
- 上方向への浮遊
- フェードアウト
- (オプション)ポップするスケール演出
- 終了後の自動破棄
を一括で行ってくれます。
- Inspector から
- フォントサイズ・色・アウトライン
- 開始位置・浮遊距離・時間
- スケールポップの有無
- UI 空間での表示(Canvas 利用)の有無
- 同時表示数の制限
を柔軟に調整できるため、RPG・アクション・タワーディフェンスなど、さまざまなゲームでそのまま再利用できます。
- 外部の GameManager やシングルトンに一切依存せず、この 1 スクリプトだけをプロジェクトに追加すれば完結する設計なので、他プロジェクトへの移植も簡単です。
ダメージ表示以外にも、回復量・経験値獲得・スコア加算・コンボ数など、数値や短いメッセージをポップアップさせたい場面で幅広く活用できます。
必要に応じて、フォントや色のプリセットを増やしたり、ランダムな左右揺れを加えるなど、カスタマイズしてみてください。




