【Cocos Creator 3.8】DamagePopup の実装:アタッチするだけで「ダメージ数字がふわっと跳ねて消える」演出を実現する汎用スクリプト
敵やプレイヤーにダメージを与えたとき、頭上にダメージ数値がふわっと表示されて消える演出は、ゲームの手触りを一気に良くします。この記事では、任意のノードにアタッチするだけで、そのノードの位置からダメージ数字をポップアップ表示できる汎用コンポーネント DamagePopup を実装します。
外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結するように設計します。インスペクタからフォントや色、移動距離、時間などを調整できるため、様々なゲームにそのまま流用できます。
コンポーネントの設計方針
全体像
DamagePopup は、アタッチされたノードの位置にダメージ数字用の Label ノードを一時的に生成し、
- 指定時間かけて上方向に移動(ふわっと浮く)
- スケールやアルファ値をアニメーション(縮む・フェードアウト)
- アニメーション後に自動で削除
という動きを行います。
ダメージ発生時には、DamagePopup コンポーネントの showDamage(value: number) メソッドを呼ぶだけで済みます。
このメソッドは public として公開するので、攻撃処理側のスクリプトから簡単に呼び出せます。
外部依存をなくすための設計
- フォントや色などの見た目は、インスペクタのプロパティで設定可能にする。
- ポップアップ用ノードは 動的に生成し、Prefab を前提にしない。
- Canvas や他ノードへの参照は 任意とし、未設定時は「自分と同じ親ノード」に生成する。
- 必須コンポーネント(Label など)はコード側で
addComponentするため、エディタで事前に追加する必要はない。 - 設定ミスや未設定に対しては
console.warn/console.errorでログを出し、防御的に動作する。
インスペクタで設定可能なプロパティ
以下のプロパティを用意し、全て @property でインスペクタから調整できるようにします。
- 表示系
font: BitmapFont | TrueTypeFont | null
表示に使用するフォントアセット。未指定の場合は Label のデフォルトフォントを使用。fontSize: number
ダメージ数字のフォントサイズ。例: 32。textColor: Color
ダメージ数字の色。例: 赤っぽい色。outlineColor: Color
アウトラインの色。アウトラインを使わない場合でも設定しておく。useOutline: boolean
アウトライン(LabelOutline コンポーネント)を付与するかどうか。outlineWidth: number
アウトラインの太さ(ピクセル)。
- アニメーション系
duration: number
ポップアップの寿命(秒)。この時間をかけて移動&フェードし、最後に削除。moveDistance: number
上方向への移動距離(ローカル座標の Y)。例: 50。startScale: number
表示開始時のスケール。例: 1.0。endScale: number
表示終了時のスケール。例: 0.5。startOpacity: number
開始時の不透明度(0〜255)。例: 255。endOpacity: number
終了時の不透明度(0〜255)。例: 0。useRandomOffset: boolean
表示位置にランダムなオフセットを加えるかどうか。randomOffsetRadius: number
ランダムオフセットの半径。例: 10。
- 生成位置系
parentForPopup: Node | null
生成したダメージ数字ノードをぶら下げる親ノード。未設定の場合は「この DamagePopup がアタッチされたノードの親」を使用。localOffset: Vec3
アタッチノードからのローカルオフセット。例: (0, 50, 0) で少し頭上に表示。
- 数値フォーマット系
prefix: string
ダメージ値の前に付ける文字列。例:"-"で「-100」のように表示。suffix: string
ダメージ値の後ろに付ける文字列。例:" HP"。useInteger: boolean
小数を四捨五入して整数表示するかどうか。
TypeScriptコードの実装
以下が完成した DamagePopup.ts のコードです。
import { _decorator, Component, Node, Label, Color, Vec3, tween, UIOpacity, LabelOutline, BitmapFont, TrueTypeFont, isValid } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('DamagePopup')
export class DamagePopup extends Component {
// =========================
// 表示系
// =========================
@property({
type: BitmapFont,
tooltip: 'ダメージ数字に使用する BitmapFont。未指定の場合はデフォルトフォントを使用します。'
})
public bitmapFont: BitmapFont | null = null;
@property({
type: TrueTypeFont,
tooltip: 'ダメージ数字に使用する TrueTypeFont。BitmapFont が指定されている場合はそちらが優先されます。'
})
public ttfFont: TrueTypeFont | null = null;
@property({
tooltip: 'ダメージ数字のフォントサイズ'
})
public fontSize: number = 32;
@property({
tooltip: 'ダメージ数字の色'
})
public textColor: Color = new Color(255, 80, 80, 255);
@property({
tooltip: 'アウトラインを使用するかどうか'
})
public useOutline: boolean = true;
@property({
tooltip: 'アウトラインの色'
})
public outlineColor: Color = new Color(0, 0, 0, 255);
@property({
tooltip: 'アウトラインの太さ(ピクセル)'
})
public outlineWidth: number = 2;
// =========================
// アニメーション系
// =========================
@property({
tooltip: 'ポップアップの表示時間(秒)。この時間をかけて移動・フェードアウトします。'
})
public duration: number = 0.6;
@property({
tooltip: '上方向への移動距離(ローカル座標のY)'
})
public moveDistance: number = 50;
@property({
tooltip: '開始時のスケール'
})
public startScale: number = 1.0;
@property({
tooltip: '終了時のスケール'
})
public endScale: number = 0.5;
@property({
tooltip: '開始時の不透明度(0〜255)'
})
public startOpacity: number = 255;
@property({
tooltip: '終了時の不透明度(0〜255)'
})
public endOpacity: number = 0;
@property({
tooltip: '表示位置にランダムオフセットを加えるかどうか'
})
public useRandomOffset: boolean = true;
@property({
tooltip: 'ランダムオフセットの半径(ピクセル)。useRandomOffset が true のときのみ有効です。'
})
public randomOffsetRadius: number = 10;
// =========================
// 生成位置系
// =========================
@property({
type: Node,
tooltip: 'ダメージ数字ノードをぶら下げる親ノード。未設定の場合は、このコンポーネントがアタッチされたノードの親を使用します。'
})
public parentForPopup: Node | null = null;
@property({
tooltip: 'アタッチノードからのローカルオフセット位置(x, y, z)'
})
public localOffset: Vec3 = new Vec3(0, 50, 0);
// =========================
// 数値フォーマット系
// =========================
@property({
tooltip: 'ダメージ値の前に付ける文字列(例: "-" で -100 のように表示)'
})
public prefix: string = '-';
@property({
tooltip: 'ダメージ値の後ろに付ける文字列(例: " HP")'
})
public suffix: string = '';
@property({
tooltip: '小数を四捨五入して整数表示するかどうか'
})
public useInteger: boolean = true;
// =========================
// ライフサイクル
// =========================
onLoad() {
// 必要な親ノードの存在チェック
if (!this.parentForPopup) {
if (!this.node.parent) {
console.error('[DamagePopup] 親ノードが存在しません。parentForPopup をインスペクタで設定するか、このノードを何かの子として配置してください。');
} else {
// デフォルトで自分の親を使用
this.parentForPopup = this.node.parent;
}
}
// duration の防御的補正
if (this.duration <= 0) {
console.warn('[DamagePopup] duration が 0 以下です。0.5 秒に補正します。');
this.duration = 0.5;
}
if (this.startOpacity < 0) this.startOpacity = 0;
if (this.startOpacity > 255) this.startOpacity = 255;
if (this.endOpacity < 0) this.endOpacity = 0;
if (this.endOpacity > 255) this.endOpacity = 255;
}
start() {
// 特に初期処理は不要だが、将来の拡張用にメソッドを残しておく
}
// update は使用しない(tween ベースでアニメーションする)
// =========================
// 公開API
// =========================
/**
* ダメージ値をポップアップ表示します。
* 攻撃処理側のスクリプトから呼び出してください。
* 例: this.damagePopup.showDamage(100);
*/
public showDamage(value: number) {
if (!isValid(this.node)) {
console.warn('[DamagePopup] このコンポーネントのノードは既に破棄されています。');
return;
}
// 親ノードの最終確認
let parent = this.parentForPopup;
if (!parent) {
parent = this.node.parent;
}
if (!parent) {
console.error('[DamagePopup] ダメージポップアップを生成する親ノードが見つかりません。');
return;
}
// =========================
// ノード生成
// =========================
const popupNode = new Node('DamagePopupLabel');
parent.addChild(popupNode);
// 位置の決定(アタッチノードのローカル座標 + オフセット)
const basePos = this.node.getPosition().clone().add(this.localOffset);
let finalPos = basePos.clone();
if (this.useRandomOffset && this.randomOffsetRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * this.randomOffsetRadius;
const offsetX = Math.cos(angle) * radius;
const offsetY = Math.sin(angle) * radius;
finalPos.x += offsetX;
finalPos.y += offsetY;
}
popupNode.setPosition(finalPos);
// スケール初期値
popupNode.setScale(this.startScale, this.startScale, this.startScale);
// Label コンポーネント追加
const label = popupNode.addComponent(Label);
label.fontSize = this.fontSize;
label.color = this.textColor.clone();
// フォント設定(BitmapFont が優先、その次に TTF)
if (this.bitmapFont) {
label.font = this.bitmapFont;
} else if (this.ttfFont) {
label.font = this.ttfFont;
}
// アウトライン設定
if (this.useOutline) {
const outline = popupNode.addComponent(LabelOutline);
outline.color = this.outlineColor.clone();
outline.width = this.outlineWidth;
}
// 数値のフォーマット
let displayValue = value;
if (this.useInteger) {
displayValue = Math.round(displayValue);
}
label.string = `${this.prefix}${displayValue}${this.suffix}`;
// 不透明度制御用コンポーネント
const uiOpacity = popupNode.addComponent(UIOpacity);
uiOpacity.opacity = this.startOpacity;
// =========================
// アニメーション(tween)
// =========================
const targetPos = finalPos.clone();
targetPos.y += this.moveDistance;
tween(popupNode)
.to(
this.duration,
{
position: targetPos,
scale: new Vec3(this.endScale, this.endScale, this.endScale)
}
)
.call(() => {
// ノードが既に破棄されていないか確認してから削除
if (isValid(popupNode)) {
popupNode.destroy();
}
})
.start();
tween(uiOpacity)
.to(
this.duration,
{ opacity: this.endOpacity }
)
.start();
}
}
コードのポイント解説
- onLoad
parentForPopupが未設定なら、自分の親ノードを自動的に使用します。durationが 0 以下の場合は 0.5 秒に補正し、ログで警告します。- 不透明度が 0〜255 の範囲に収まるように補正します。
- showDamage(value: number)
- 攻撃処理側から呼び出す公開APIです。
- ポップアップ用のノードを生成し、
Label・LabelOutline・UIOpacityをその場で追加します。 - 位置は「アタッチノードのローカル座標 +
localOffset+ ランダムオフセット」で決定します。 tweenを使って、位置・スケール・不透明度をduration秒かけて変化させます。- アニメーション終了後にポップアップノードを
destroy()してクリーンアップします。
- update を使わない設計
- アニメーションは
tweenに任せるため、update関数は不要です。 - これにより、コンポーネント自体のオーバーヘッドを減らし、シンプルな設計になります。
- アニメーションは
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
DamagePopup.tsにします。 - 自動生成されたテンプレートコードを削除し、本記事の
DamagePopupコードを丸ごと貼り付けて保存します。
2. テスト用シーンの準備
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選び、テスト用のキャラクターノードを作成します。
例: ノード名を Enemy にする。 - Canvas 配下に配置されていることを確認します(2D UI として表示する場合)。
3. DamagePopup コンポーネントのアタッチ
- Hierarchy で先ほど作成した Enemy ノードを選択します。
- Inspector パネルの下部で Add Component ボタンをクリックします。
- Custom → DamagePopup を選択してアタッチします。
4. インスペクタでプロパティを設定
Enemy ノードの Inspector に表示された DamagePopup の各プロパティを設定します。
- 表示系
- Bitmap Font / TrueType Font: 任意のフォントアセットがあればドラッグ&ドロップします。なければ空のままで構いません。
- Font Size:
32など好みのサイズに設定します。 - Text Color: 赤系(例: R=255, G=80, B=80, A=255)に設定するとダメージっぽくなります。
- Use Outline: チェックを入れます。
- Outline Color: 黒(R=0, G=0, B=0, A=255)を推奨。
- Outline Width:
2程度。
- アニメーション系
- Duration:
0.6〜0.8秒程度。 - Move Distance:
50。 - Start Scale:
1.0。 - End Scale:
0.5。 - Start Opacity:
255。 - End Opacity:
0。 - Use Random Offset: チェックを入れると、毎回少し違う位置に表示されます。
- Random Offset Radius:
10。
- Duration:
- 生成位置系
- Parent For Popup: 空のままにすると、Enemy の親ノード(通常は Canvas)が使用されます。
別のノード配下に出したい場合は、そのノードをドラッグ&ドロップします。 - Local Offset: (0, 50, 0) など、Enemy の少し上に出るように調整します。
- Parent For Popup: 空のままにすると、Enemy の親ノード(通常は Canvas)が使用されます。
- 数値フォーマット系
- Prefix:
"-"としておくと「-100」のような表記になります。 - Suffix: 必要に応じて
" HP"などを設定。 - Use Integer: 通常はチェックを入れて整数表示にします。
- Prefix:
5. ダメージ表示テスト用スクリプトから呼び出す
実際には攻撃処理スクリプトから showDamage を呼び出しますが、ここでは動作確認のための簡単なテストスクリプト例を示します。
import { _decorator, Component, Node, input, Input, EventKeyboard, KeyCode } from 'cc';
import { DamagePopup } from './DamagePopup';
const { ccclass, property } = _decorator;
@ccclass('DamagePopupTester')
export class DamagePopupTester extends Component {
@property({ type: DamagePopup, tooltip: 'テスト対象の DamagePopup コンポーネント' })
public damagePopup: DamagePopup | null = null;
start() {
if (!this.damagePopup) {
// 同じノードにアタッチされている DamagePopup を自動取得
this.damagePopup = this.getComponent(DamagePopup);
}
if (!this.damagePopup) {
console.error('[DamagePopupTester] DamagePopup コンポーネントが見つかりません。Inspector で設定するか、同じノードにアタッチしてください。');
}
// キーボード入力を監視(エディタのプレビューで使用)
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
}
onDestroy() {
input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
}
private onKeyDown(event: EventKeyboard) {
if (!this.damagePopup) {
return;
}
// スペースキーでテスト
if (event.keyCode === KeyCode.SPACE) {
const randomDamage = Math.floor(Math.random() * 50) + 50; // 50〜99
this.damagePopup.showDamage(randomDamage);
}
}
}
テスト手順:
- Assets で DamagePopupTester.ts を作成し、上記コードを貼り付けます。
- Hierarchy で Enemy ノードを選択し、Add Component → Custom → DamagePopupTester を追加します。
- Inspector の DamagePopupTester.damagePopup に、同じノードにアタッチされている DamagePopup をドラッグ&ドロップするか、空のままにして自動取得に任せます。
- シーンを再生し、ゲームビュー上で スペースキー を押すと、Enemy の頭上にランダムなダメージ数字がポップアップ表示されます。
まとめ
この記事では、DamagePopup コンポーネントを実装し、任意のノードにアタッチするだけで「ダメージ数字がふわっと跳ねて消える」演出を行えるようにしました。
- 外部の GameManager やシングルトンに一切依存しない完全に独立したコンポーネントである。
- フォント、色、移動距離、時間、スケール、ランダムオフセットなどをすべてインスペクタから調整可能。
showDamage(value: number)を呼ぶだけで、どこからでも簡単にダメージ表示ができる。- Label / LabelOutline / UIOpacity をコード内で追加するため、事前にノード構造を用意する必要がない。
このコンポーネントをベースに、クリティカルヒット用の色変更や、回転・バウンド・パーティクルとの連携などを追加していけば、演出の幅をさらに広げることができます。
ダメージ表示は多くのゲームで頻出する機能なので、今回のような汎用コンポーネントとして切り出しておくと、プロジェクトをまたいで再利用でき、開発効率が大きく向上します。
