【Cocos Creator 3.8】NotificationToaster の実装:アタッチするだけで「セーブしました」などの通知メッセージを画面隅にスライドイン表示して数秒後に自動で消える汎用スクリプト
このコンポーネントは、UI ノードにアタッチするだけで「トースト通知(画面端に一時的に出る小さなメッセージ)」を簡単に実装できる汎用スクリプトです。
テキストを指定してメソッドを呼ぶだけで、指定した画面の隅からスライドイン → 一定時間表示 → スライドアウト → 自動で非表示、までをまとめて行います。
コンポーネントの設計方針
NotificationToaster の目的は、「他の管理スクリプトに依存せず、UI ノードにアタッチするだけでトースト通知が使えること」です。
- 外部のシングルトンや GameManager に依存しない。
- 必要な UI コンポーネントは、自ノード上の Label / RichText / Sprite などを Inspector から指定して使う。
- アニメーションは update 内での位置補間(イージング)により実装し、Tween や Timeline など他のスクリプトには依存しない。
- 表示位置(画面のどの隅か)、スライドイン方向、表示時間、アニメーション速度などを
@propertyで柔軟に調整できるようにする。
また、防御的な実装として:
- テキスト表示用に指定されたノードに
LabelまたはRichTextが無い場合は、エラーログを出して動作を止める。 - Canvas / UITransform が無くても動くように、「画面隅」という概念をローカル座標ベースで扱うモードも用意する。
Inspector で設定可能なプロパティ設計
以下のようなプロパティを用意します。
- textTargetNode: Node | null
表示テキストを反映するノード。
– このノードにLabelまたはRichTextが付いている必要があります。
– 未指定の場合は、自ノードから Label / RichText を自動探索します。 - defaultMessage: string
show()を引数なしで呼んだときに表示されるデフォルトメッセージ。
例: 「セーブしました」「保存完了」など。 - corner: enum
トーストを表示する画面の隅。
–TopLeft/TopRight/BottomLeft/BottomRight - useScreenSpace: boolean
–true: Canvas の UITransform を使い、画面サイズに応じた絶対位置で隅を計算する。
–false: 自ノードのローカル座標系だけで位置を決める(Canvas に依存しない)。 - margin: Vec2
画面(または親ノード)の端からのオフセット。
– x: 横方向の余白(ピクセル)
– y: 縦方向の余白(ピクセル) - slideDistance: number
スライドイン・スライドアウトする距離(ピクセル)。
– 正の値で、画面外から中に入ってくる感覚を調整。 - slideDuration: number
スライドイン・スライドアウトにかける時間(秒)。
– 例: 0.25 ~ 0.5 くらいが自然。 - displayDuration: number
完全に表示された状態で維持する時間(秒)。
– 例: 1.5 ~ 3.0 くらい。 - autoHideOnStart: boolean
–true:start()時に自動的に非表示状態にしておく。
–false: シーン上で見える位置に置いたままにする。 - timeScaleIndependent: boolean
–true:Game.deltaTimeを使い、TimeScale の影響を受けないトースト表示。
–false: 通常のdtを使う(ゲームのポーズと一緒に止まる)。 - debugLog: boolean
–true: 表示/非表示などの状態遷移をconsole.logに出力してデバッグしやすくする。
–false: ログを出さない。
コンポーネントは以下のような API を持ちます。
show(message?: string): void
– メッセージを指定してトーストを表示。
– 引数省略時はdefaultMessageを使用。hide(): void
– 途中でも強制的にスライドアウトして非表示にする。
TypeScriptコードの実装
import { _decorator, Component, Node, Label, RichText, UITransform, Canvas, Vec2, Vec3, Game, game } from 'cc';
const { ccclass, property } = _decorator;
enum ToastCorner {
TopLeft = 0,
TopRight = 1,
BottomLeft = 2,
BottomRight = 3,
}
enum ToastState {
Hidden = 0,
SlidingIn = 1,
Showing = 2,
SlidingOut = 3,
}
@ccclass('NotificationToaster')
export class NotificationToaster extends Component {
@property({
type: Node,
tooltip: '通知テキストを表示するターゲットノード。\n' +
'Label か RichText コンポーネントを持つノードを指定してください。\n' +
'未指定の場合、このコンポーネントが付いているノードから自動で探索します。'
})
public textTargetNode: Node | null = null;
@property({
tooltip: 'show() を引数なしで呼び出した際に表示されるデフォルトメッセージ。'
})
public defaultMessage: string = 'セーブしました';
@property({
type: ToastCorner,
tooltip: 'トーストを表示する画面(または親ノード)の隅を指定します。'
})
public corner: ToastCorner = ToastCorner.BottomRight;
@property({
tooltip: 'true の場合、Canvas の UITransform を使って画面サイズに対する絶対位置で隅を計算します。\n' +
'false の場合、親ノードのローカル座標のみを使って位置を決めます(Canvas 非依存)。'
})
public useScreenSpace: boolean = true;
@property({
type: Vec2,
tooltip: '画面端(または親ノードの境界)からのオフセット(ピクセル)。\n' +
'x: 横方向の余白, y: 縦方向の余白'
})
public margin: Vec2 = new Vec2(20, 20);
@property({
tooltip: 'スライドイン / スライドアウトの距離(ピクセル)。\n' +
'大きいほど画面外から飛び込んでくるように見えます。'
})
public slideDistance: number = 200;
@property({
tooltip: 'スライドイン / スライドアウトにかける時間(秒)。'
})
public slideDuration: number = 0.3;
@property({
tooltip: '完全に表示された状態で維持する時間(秒)。'
})
public displayDuration: number = 2.0;
@property({
tooltip: 'true の場合、start() 時に自動的に非表示状態(隠れた位置)に移動します。'
})
public autoHideOnStart: boolean = true;
@property({
tooltip: 'true の場合、Game.deltaTime を使用し、TimeScale の影響を受けない時間計測を行います。'
})
public timeScaleIndependent: boolean = false;
@property({
tooltip: 'true にすると、状態遷移などのデバッグログを console に出力します。'
})
public debugLog: boolean = false;
// 内部用フィールド
private _label: Label | null = null;
private _richText: RichText | null = null;
private _state: ToastState = ToastState.Hidden;
private _stateTime: number = 0; // 現在の状態に入ってからの経過時間
private _hiddenPosition: Vec3 = new Vec3();
private _visiblePosition: Vec3 = new Vec3();
private _canvas: Canvas | null = null;
private _uiTransform: UITransform | null = null;
onLoad() {
// テキストターゲットの自動探索
if (!this.textTargetNode) {
this.textTargetNode = this.node;
}
if (this.textTargetNode) {
this._label = this.textTargetNode.getComponent(Label);
this._richText = this.textTargetNode.getComponent(RichText);
}
if (!this._label && !this._richText) {
console.error(
'[NotificationToaster] textTargetNode に Label または RichText が見つかりませんでした。\n' +
'テキスト表示用ノードを指定し、そのノードに Label か RichText コンポーネントを追加してください。',
this.node
);
}
// Canvas の取得(useScreenSpace が true のときのみ意味がある)
if (this.useScreenSpace) {
this._canvas = this.node.scene?.getComponentInChildren(Canvas) || null;
if (!this._canvas) {
console.warn(
'[NotificationToaster] useScreenSpace=true ですが、シーン内に Canvas が見つかりませんでした。\n' +
'Canvas が無い場合は useScreenSpace=false にするか、シーンに Canvas を追加してください。',
this.node
);
} else {
this._uiTransform = this._canvas.getComponent(UITransform);
if (!this._uiTransform) {
console.warn(
'[NotificationToaster] Canvas に UITransform が見つかりませんでした。\n' +
'通常 Canvas には UITransform が付与されます。UI ベースのレイアウトを使用する場合は確認してください。',
this._canvas
);
}
}
}
}
start() {
// 表示 / 非表示の基準位置を計算
this._calculatePositions();
if (this.autoHideOnStart) {
// 初期状態を完全に隠れた位置にしておく
this.node.setPosition(this._hiddenPosition);
this._setState(ToastState.Hidden);
} else {
// シーン上で配置した位置を「表示位置」とみなす場合
this._visiblePosition = this.node.position.clone();
this._hiddenPosition = this._calculateHiddenFromVisible(this._visiblePosition);
this._setState(ToastState.Hidden);
}
}
update(dt: number) {
const delta = this.timeScaleIndependent ? game.deltaTime : dt;
this._stateTime += delta;
switch (this._state) {
case ToastState.SlidingIn:
this._updateSlidingIn(delta);
break;
case ToastState.Showing:
this._updateShowing(delta);
break;
case ToastState.SlidingOut:
this._updateSlidingOut(delta);
break;
case ToastState.Hidden:
default:
// 何もしない
break;
}
}
/**
* トーストを表示します。
* @param message 表示するメッセージ。省略時は defaultMessage を使用します。
*/
public show(message?: string): void {
if (!this._label && !this._richText) {
console.error(
'[NotificationToaster.show] Label / RichText が設定されていないため、トーストを表示できません。',
this.node
);
return;
}
const msg = message ?? this.defaultMessage;
if (this._label) {
this._label.string = msg;
}
if (this._richText) {
this._richText.string = msg;
}
// 位置の再計算(画面サイズが変わっている可能性もあるため)
this._calculatePositions();
// 即座に隠れた位置に移動してからスライドインを開始
this.node.setPosition(this._hiddenPosition);
this._setState(ToastState.SlidingIn);
}
/**
* トーストを強制的に非表示にします。
* すでに非表示の場合は何もしません。
*/
public hide(): void {
if (this._state === ToastState.Hidden) {
return;
}
// 即座にスライドアウト状態に移行
this._setState(ToastState.SlidingOut);
}
// =========================
// 内部処理
// =========================
private _setState(newState: ToastState): void {
if (this.debugLog) {
console.log(
`[NotificationToaster] state: ${ToastState[this._state]} -> ${ToastState[newState]}`,
this.node
);
}
this._state = newState;
this._stateTime = 0;
}
private _updateSlidingIn(dt: number): void {
if (this.slideDuration <= 0) {
// 即座に表示位置へ
this.node.setPosition(this._visiblePosition);
this._setState(ToastState.Showing);
return;
}
const t = Math.min(this._stateTime / this.slideDuration, 1.0);
const eased = this._easeOutCubic(t);
const newPos = this._lerpVec3(this._hiddenPosition, this._visiblePosition, eased);
this.node.setPosition(newPos);
if (t >= 1.0) {
this._setState(ToastState.Showing);
}
}
private _updateShowing(dt: number): void {
if (this._stateTime >= this.displayDuration) {
this._setState(ToastState.SlidingOut);
}
}
private _updateSlidingOut(dt: number): void {
if (this.slideDuration <= 0) {
this.node.setPosition(this._hiddenPosition);
this._setState(ToastState.Hidden);
return;
}
const t = Math.min(this._stateTime / this.slideDuration, 1.0);
const eased = this._easeInCubic(t);
const newPos = this._lerpVec3(this._visiblePosition, this._hiddenPosition, eased);
this.node.setPosition(newPos);
if (t >= 1.0) {
this._setState(ToastState.Hidden);
}
}
/**
* 画面(または親ノード)基準で、visible / hidden の位置を計算します。
*/
private _calculatePositions(): void {
// 表示位置の計算
const visible = this._calculateVisiblePosition();
this._visiblePosition.set(visible);
// 非表示位置の計算(visiblePosition から slideDistance 分だけ外側にずらす)
this._hiddenPosition = this._calculateHiddenFromVisible(this._visiblePosition);
}
private _calculateVisiblePosition(): Vec3 {
// 基本は親ノードのローカル空間で計算
const parent = this.node.parent;
if (!parent) {
// 親が無い場合は自身のローカル座標系を基準にする
console.warn(
'[NotificationToaster] 親ノードが存在しません。ローカル座標 (0,0) を基準に計算します。',
this.node
);
}
let width = 0;
let height = 0;
if (this.useScreenSpace && this._uiTransform) {
// 画面サイズを使用
width = this._uiTransform.width;
height = this._uiTransform.height;
} else {
// 親ノードの UITransform があればそれを使用
const parentTransform = parent ? parent.getComponent(UITransform) : null;
if (parentTransform) {
width = parentTransform.width;
height = parentTransform.height;
} else {
// 何もない場合は、原点を中心とした仮の 1920x1080 画面として扱う
width = 1920;
height = 1080;
console.warn(
'[NotificationToaster] UITransform が見つからなかったため、仮の画面サイズ (1920x1080) を使用します。\n' +
'正確な位置制御を行うには、Canvas または親ノードに UITransform を付与してください。',
this.node
);
}
}
// 画面座標系: 中心(0,0) と仮定し、隅の座標を計算
const halfW = width / 2;
const halfH = height / 2;
let x = 0;
let y = 0;
switch (this.corner) {
case ToastCorner.TopLeft:
x = -halfW + this.margin.x;
y = halfH - this.margin.y;
break;
case ToastCorner.TopRight:
x = halfW - this.margin.x;
y = halfH - this.margin.y;
break;
case ToastCorner.BottomLeft:
x = -halfW + this.margin.x;
y = -halfH + this.margin.y;
break;
case ToastCorner.BottomRight:
default:
x = halfW - this.margin.x;
y = -halfH + this.margin.y;
break;
}
return new Vec3(x, y, 0);
}
private _calculateHiddenFromVisible(visible: Vec3): Vec3 {
const hidden = visible.clone();
// コーナーに応じて、画面外側へ slideDistance 分ずらす
switch (this.corner) {
case ToastCorner.TopLeft:
hidden.x -= this.slideDistance;
break;
case ToastCorner.TopRight:
hidden.x += this.slideDistance;
break;
case ToastCorner.BottomLeft:
hidden.x -= this.slideDistance;
break;
case ToastCorner.BottomRight:
default:
hidden.x += this.slideDistance;
break;
}
return hidden;
}
private _lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 {
const x = a.x + (b.x - a.x) * t;
const y = a.y + (b.y - a.y) * t;
const z = a.z + (b.z - a.z) * t;
return new Vec3(x, y, z);
}
private _easeOutCubic(t: number): number {
// 1 - (1 - t)^3
const inv = 1 - t;
return 1 - inv * inv * inv;
}
private _easeInCubic(t: number): number {
// t^3
return t * t * t;
}
}
コードのポイント解説
- onLoad
–textTargetNodeが未設定なら自ノードを使い、Label/RichTextを探索。
– 見つからない場合はconsole.errorを出して以降のshow()では何もしない。
–useScreenSpaceが true のときはシーン内のCanvasを探索し、そのUITransformから画面サイズを取得。 - start
–_calculatePositions()で「表示位置(visible)」と「隠れ位置(hidden)」を計算。
–autoHideOnStartが true なら隠れ位置に移動して非表示状態として開始。 - update
–timeScaleIndependentに応じてdtかgame.deltaTimeを使用。
– 状態マシン(ToastState)に基づいて_updateSlidingIn/_updateShowing/_updateSlidingOutを呼び分け。 - show(message?)
– Label / RichText にメッセージをセット。
– 画面サイズが変わった可能性を考慮して毎回位置を再計算。
– 隠れ位置からSlidingIn状態に遷移。 - hide()
– 途中でも強制的にSlidingOut状態へ。
– すでに Hidden の場合は何もしない。 - _calculateVisiblePosition()
–useScreenSpaceが true かつ Canvas のUITransformがあれば、その width/height を使い「画面の隅」を計算。
– そうでない場合は親ノードのUITransformを使う。
– それも無ければ 1920×1080 を仮定して警告を出す。 - _calculateHiddenFromVisible()
– コーナーに応じてslideDistance分だけ外側へオフセットした位置を計算。 - イージング関数
– スライドイン:_easeOutCubic(最初早く、終わりゆっくり)
– スライドアウト:_easeInCubic(最初ゆっくり、終わり早く)
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を NotificationToaster.ts にします。
- 自動生成されたコードをすべて削除し、上記の TypeScript コードをそのまま貼り付けて保存します。
2. トースト用 UI ノードの作成
- Hierarchy パネルで右クリック → Create → UI → Canvas を選択し、Canvas が無い場合は作成します。
- Canvas の子として、右クリック → Create → UI → Label を選択し、ToastLabel など分かりやすい名前に変更します。
- ToastLabel ノードを選択し、Inspector の Label コンポーネントで以下のように設定すると見やすくなります(任意):
– String: 「セーブしました」など適当な文字列
– Font Size: 24 〜 32 くらい
– Overflow: SHRINK または RESIZE_HEIGHT など好みに応じて調整 - 背景が欲しい場合は、Label の親ノードとして Sprite を追加し、その上に Label を載せる形にしても構いません。
– 例: ToastPanel(Sprite) → 子に ToastLabel(Label)
– この場合、NotificationToaster は ToastPanel にアタッチし、textTargetNode に ToastLabel を指定します。
3. NotificationToaster コンポーネントのアタッチ
- トーストとして動かしたいノード(例: ToastPanel または ToastLabel)を選択します。
- Inspector の一番下で Add Component → Custom → NotificationToaster を選択し、アタッチします。
- Inspector で NotificationToaster のプロパティを設定します。例として:
- textTargetNode:
– ToastPanel にアタッチした場合 → ToastLabel ノードをドラッグ&ドロップ。
– ToastLabel にアタッチした場合 → 空のままで OK(自動で自ノードの Label を探します)。 - defaultMessage: 「セーブしました」
- corner: BottomRight(右下)
- useScreenSpace: true(Canvas ベースで画面隅に配置)
- margin: (20, 20)
- slideDistance: 200
- slideDuration: 0.3
- displayDuration: 2.0
- autoHideOnStart: true
- timeScaleIndependent: false(まずは通常 dt で動かす)
- debugLog: true(挙動を確認したい場合)
4. シーンからトーストを呼び出して動作確認
NotificationToaster 自体は単体で完結していますが、実際に動かすにはどこかから show() を呼び出す必要があります。
ここではテスト用に簡単なボタンを作り、クリック時にトーストを表示する例を示します。
4-1. テスト用ボタンの作成
- Hierarchy で Canvas を右クリック → Create → UI → Button を選択し、TestButton という名前にします。
- Button の子に自動で Text(Label) が付くので、文字列を「Show Toast」などに変更します。
4-2. ボタンから NotificationToaster.show() を呼ぶ簡易スクリプト
- Assets パネルで右クリック → Create → TypeScript から ToastTestButton.ts を作成します。
- 以下のような最小限のテストコードを書きます。
import { _decorator, Component, Node } from 'cc';
import { NotificationToaster } from './NotificationToaster';
const { ccclass, property } = _decorator;
@ccclass('ToastTestButton')
export class ToastTestButton extends Component {
@property({
type: NotificationToaster,
tooltip: 'トースト通知を表示する NotificationToaster コンポーネントを指定します。'
})
public toaster: NotificationToaster | null = null;
// Button の Click Events からこのメソッドを呼び出します
public onClickShowToast() {
if (this.toaster) {
this.toaster.show('セーブしました!');
} else {
console.warn('[ToastTestButton] toaster が設定されていません。', this.node);
}
}
}
- TestButton ノードを選択し、Inspector の Add Component → Custom → ToastTestButton を追加します。
- ToastTestButton の toaster プロパティに、先ほど NotificationToaster をアタッチしたノード(ToastPanel など)をドラッグ&ドロップします。
- TestButton の Button コンポーネントを開き、Click Events に以下のように設定します:
- + ボタンを押してイベントを追加。
- Node: TestButton ノードをドラッグ&ドロップ。
- Component: ToastTestButton を選択。
- Handler: onClickShowToast を選択。
4-3. 実行して確認
- エディタ右上の Play ボタン でシミュレータまたはブラウザで実行します。
- ゲーム画面右下(設定した corner / margin に応じた位置)から、トーストノードが画面外 → 画面内へスライドインすることを確認します。
- displayDuration 秒後に、スライドアウトして自動的に消えることを確認します。
- 何度ボタンを押しても、そのたびに新しいメッセージでトーストが表示されることを確認します。
5. シーン内から直接呼び出す場合の例
ボタンを使わず、例えばセーブ処理の完了時に直接トーストを出したい場合は、以下のようにします。
- セーブ処理を行っているスクリプト内で、NotificationToaster を持つノードへの参照を
@property(Node)などで持ち、
getComponent(NotificationToaster)してshow()を呼び出す。
例:
import { _decorator, Component, Node } from 'cc';
import { NotificationToaster } from './NotificationToaster';
const { ccclass, property } = _decorator;
@ccclass('SaveController')
export class SaveController extends Component {
@property({
type: Node,
tooltip: 'NotificationToaster がアタッチされているノードを指定します。'
})
public toasterNode: Node | null = null;
public onSaveCompleted() {
if (!this.toasterNode) {
console.warn('[SaveController] toasterNode が設定されていません。', this.node);
return;
}
const toaster = this.toasterNode.getComponent(NotificationToaster);
if (!toaster) {
console.warn('[SaveController] toasterNode に NotificationToaster が見つかりません。', this.toasterNode);
return;
}
toaster.show('セーブしました!');
}
}
まとめ
今回実装した NotificationToaster コンポーネントは、
- Label / RichText を持つ UI ノードにアタッチするだけで、トースト通知の表示〜自動非表示までを完結できる。
- Canvas や GameManager など、外部のカスタムスクリプトに依存しない完全な独立コンポーネントである。
- 表示位置(画面の隅)、マージン、スライド距離・速度、表示時間、TimeScale 依存/非依存などを Inspector から柔軟に調整できる。
応用例としては:
- 「セーブしました」「アイテムを獲得しました」「レベルアップ!」などのシステムメッセージ。
- オンラインゲームの「接続が切断されました」「再接続中…」といったステータス表示。
- チュートリアルやヒントの簡易通知。
いずれも、シーン内の任意のスクリプトから show("メッセージ") を呼ぶだけで実現できます。
ゲームごとにトーストロジックを毎回書き直す必要がなくなり、UI 通知の実装コストを大きく削減できます。
このコンポーネントをベースに、フェードやスケールアニメーション、スタック表示(複数トーストを縦に並べる)などを追加していけば、汎用的な通知システムとしてさらに発展させることも可能です。




