Godotで「セーブしました」「アイテムを取得しました」みたいな軽い通知を出したいとき、つい以下みたいな実装をしがちですよね。
- 各シーンごとに
LabelやPanelを置いて、アニメーションを個別実装 - UI専用のシーンを作って、そこに「トースト用ノード」をベタ書き
- プレイヤーシーンやUIシーンを継承して「通知付きプレイヤー」「通知付きUI」を量産
これ、最初は動くんですが、だんだん次のような地獄になりがちです。
- 「通知の見た目を変えたい」だけなのに、全シーンを修正する羽目になる
- 「敵も通知を出したい」となった瞬間、また同じロジックをコピペ
- UIシーンが「巨大な神クラス」になっていき、触るのが怖くなる
そこで今回は、どのシーンからでも簡単に呼び出せて、かつシーンツリーを汚さない「通知トースト」コンポーネントを作ってみましょう。
継承ではなく 合成(コンポーネント) で実現することで、どんなゲームにもサクッと組み込めるようにします。
【Godot 4】画面隅にシュッと出してスッと消える!「NotificationToaster」コンポーネント
今回の NotificationToaster コンポーネントは、こんなことをしてくれます。
- 画面の四隅のどこに出すかを選べる
- メッセージをキューに貯めて、順番に表示
- スライドイン&スライドアウトの簡単アニメーション
- どのノードからでも
toaster.show_toast("メッセージ")で呼び出せる
UI専用の大きなシーンを継承で拡張するのではなく、「NotificationToaster」コンポーネントを 1 個シーンに置いておくだけで、どこからでも通知を出せるようにしていきます。
フルコード(GDScript / Godot 4)
extends Control
class_name NotificationToaster
## 画面隅にスライドインする「通知トースト」コンポーネント
##
## 任意のシーンに1つ置いておき、他のノードから
## toaster.show_toast("セーブしました")
## のように呼び出して使います。
@tool
@icon("res://icon.svg") # 適宜変更してください
## トーストを表示する画面の位置
enum ToastCorner {
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
}
## ---- エクスポートパラメータ ----
@export var corner: ToastCorner = ToastCorner.TOP_RIGHT:
set(value):
corner = value
if is_inside_tree():
_update_anchor_from_corner()
@export_range(0.1, 10.0, 0.1)
var display_time: float = 2.5 ## 1つのトーストを表示しておく秒数
@export_range(0.05, 5.0, 0.05)
var slide_duration: float = 0.3 ## スライドイン・アウトにかける秒数
@export_range(0.0, 200.0, 1.0)
var vertical_gap: float = 8.0 ## トースト同士の縦方向の隙間(今回は1件ずつ表示なので予備)
@export var max_width: int = 320 ## トーストの最大幅(自動折り返し用)
@export var max_queue_size: int = 10 ## キューに貯められる最大メッセージ数
@export var font: Font = null ## 任意のフォント。nullならデフォルトテーマを使用
@export var background_color: Color = Color(0, 0, 0, 0.85) ## 背景色
@export var text_color: Color = Color(1, 1, 1, 1) ## 文字色
@export_range(0.0, 64.0, 1.0)
var padding: float = 12.0 ## 内側の余白
@export_range(0.0, 64.0, 1.0)
var margin_from_edge: float = 24.0 ## 画面端からのマージン
@export var enable_shadow: bool = true
@export var shadow_color: Color = Color(0, 0, 0, 0.6)
@export_range(0.0, 32.0, 1.0)
var shadow_size: float = 8.0
## ---- 内部状態 ----
var _queue: Array[String] = [] ## 表示待ちメッセージのキュー
var _is_showing: bool = false ## 現在アニメーション中かどうか
var _panel: Panel = null
var _label: Label = null
var _tween: Tween = null
func _ready() -> void:
## ビューポート全体を覆う透明なControlとして使う
mouse_filter = Control.MOUSE_FILTER_IGNORE
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 0.0
offset_top = 0.0
offset_right = 0.0
offset_bottom = 0.0
_create_toast_nodes()
_update_anchor_from_corner()
_hide_panel_immediately()
func _create_toast_nodes() -> void:
## 背景パネル
_panel = Panel.new()
_panel.name = "ToastPanel"
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_panel)
## スタイル(背景色、シャドウなど)
var style := StyleBoxFlat.new()
style.bg_color = background_color
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = padding
style.content_margin_right = padding
style.content_margin_top = padding
style.content_margin_bottom = padding
if enable_shadow:
style.shadow_color = shadow_color
style.shadow_size = shadow_size
style.shadow_offset = Vector2(0, 2)
_panel.add_theme_stylebox_override("panel", style)
## ラベル
_label = Label.new()
_label.name = "ToastLabel"
_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_label.modulate = text_color
if font:
_label.add_theme_font_override("font", font)
_panel.add_child(_label)
## パネルの最小サイズなど
_panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_panel.custom_minimum_size.x = 0
_panel.custom_minimum_size.y = 0
_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_label.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_panel.visible = false
func _update_anchor_from_corner() -> void:
if not _panel:
return
## パネルは内容に応じてサイズが変わるので、アンカーは0にして
## offsetだけで位置を制御します。
_panel.anchor_left = 0.0
_panel.anchor_top = 0.0
_panel.anchor_right = 0.0
_panel.anchor_bottom = 0.0
match corner:
ToastCorner.TOP_LEFT:
_panel.pivot_offset = Vector2.ZERO
ToastCorner.TOP_RIGHT:
_panel.pivot_offset = Vector2.ZERO
ToastCorner.BOTTOM_LEFT:
_panel.pivot_offset = Vector2.ZERO
ToastCorner.BOTTOM_RIGHT:
_panel.pivot_offset = Vector2.ZERO
## 実際の配置は _position_panel() で行います
func show_toast(message: String) -> void:
## 外部から呼び出す公開API
if message.is_empty():
return
if _queue.size() >= max_queue_size:
## これ以上貯めない方針。古いものを捨てるなら pop_front などに変更可。
return
_queue.push_back(message)
if not _is_showing:
_process_next_toast()
func clear_queue() -> void:
## 溜まっているメッセージをすべて破棄
_queue.clear()
func _process_next_toast() -> void:
if _queue.is_empty():
_is_showing = false
return
_is_showing = true
var message := _queue.pop_front()
_show_single_toast(message)
func _show_single_toast(message: String) -> void:
if not _panel or not _label:
return
_label.text = message
_label.visible_ratio = 1.0
## サイズを計算するために一旦表示
_panel.visible = true
_panel.reset_size()
## 最大幅を制限
_panel.size = Vector2.ZERO
_panel.custom_minimum_size.x = 0
await get_tree().process_frame
var min_size := _panel.get_combined_minimum_size()
if min_size.x > max_width:
_panel.custom_minimum_size.x = max_width
else:
_panel.custom_minimum_size.x = min_size.x
await get_tree().process_frame
_panel.reset_size()
_position_panel()
## スライドインの開始位置と終了位置を決める
var start_pos := _get_offscreen_position()
var end_pos := _get_onscreen_position()
_panel.position = start_pos
if _tween:
_tween.kill()
_tween = create_tween()
_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
## スライドイン
_tween.tween_property(_panel, "position", end_pos, slide_duration)
## 表示維持
_tween.tween_interval(display_time)
## スライドアウト
_tween.tween_property(_panel, "position", start_pos, slide_duration)
## 終了後に次のトーストへ
_tween.finished.connect(_on_tween_finished, CONNECT_ONE_SHOT)
func _on_tween_finished() -> void:
_hide_panel_immediately()
_process_next_toast()
func _hide_panel_immediately() -> void:
if _panel:
_panel.visible = false
func _position_panel() -> void:
## 現在のビューポートサイズに基づいて、画面四隅のどこに置くか決める
var viewport_rect := get_viewport_rect()
var vp_size := viewport_rect.size
var panel_size := _panel.size
var pos := Vector2.ZERO
match corner:
ToastCorner.TOP_LEFT:
pos.x = margin_from_edge
pos.y = margin_from_edge
ToastCorner.TOP_RIGHT:
pos.x = vp_size.x - panel_size.x - margin_from_edge
pos.y = margin_from_edge
ToastCorner.BOTTOM_LEFT:
pos.x = margin_from_edge
pos.y = vp_size.y - panel_size.y - margin_from_edge
ToastCorner.BOTTOM_RIGHT:
pos.x = vp_size.x - panel_size.x - margin_from_edge
pos.y = vp_size.y - panel_size.y - margin_from_edge
_panel.position = pos
func _get_onscreen_position() -> Vector2:
## 画面内に収まる位置(_position_panel と同じ)
var viewport_rect := get_viewport_rect()
var vp_size := viewport_rect.size
var panel_size := _panel.size
var pos := Vector2.ZERO
match corner:
ToastCorner.TOP_LEFT:
pos.x = margin_from_edge
pos.y = margin_from_edge
ToastCorner.TOP_RIGHT:
pos.x = vp_size.x - panel_size.x - margin_from_edge
pos.y = margin_from_edge
ToastCorner.BOTTOM_LEFT:
pos.x = margin_from_edge
pos.y = vp_size.y - panel_size.y - margin_from_edge
ToastCorner.BOTTOM_RIGHT:
pos.x = vp_size.x - panel_size.x - margin_from_edge
pos.y = vp_size.y - panel_size.y - margin_from_edge
return pos
func _get_offscreen_position() -> Vector2:
## 画面外(スライドイン前/スライドアウト後)の位置
var viewport_rect := get_viewport_rect()
var vp_size := viewport_rect.size
var panel_size := _panel.size
var pos := _get_onscreen_position()
match corner:
ToastCorner.TOP_LEFT:
## 左上から左方向に飛び出しておく
pos.x = -panel_size.x - 16
ToastCorner.TOP_RIGHT:
## 右上から右方向へ
pos.x = vp_size.x + 16
ToastCorner.BOTTOM_LEFT:
## 左下から左方向へ
pos.x = -panel_size.x - 16
ToastCorner.BOTTOM_RIGHT:
## 右下から右方向へ
pos.x = vp_size.x + 16
return pos
func _notification(what: int) -> void:
## 画面サイズが変わったときに位置を調整
if what == NOTIFICATION_RESIZED:
if _panel and _panel.visible:
_position_panel()
使い方の手順
ここからは、実際にゲームに組み込む手順を見ていきましょう。
手順①: コンポーネント用シーン or スクリプトを用意する
- 上記のコードを
NotificationToaster.gdとして保存します。 - 新規シーンを作成し、
Controlをルートにします。 - その
ControlにNotificationToaster.gdをアタッチします。 - シーンを
NotificationToaster.tscnとして保存しておくと便利です。
このシーンは「UIコンポーネント」として再利用する前提なので、ゲームごとに 1 回作ればOKです。
手順②: メインシーンに Toaster を配置する
よくある 2D ゲームの例として、以下のようなメインシーン構成を考えます。
Main (Node2D)
├── Player (CharacterBody2D)
├── Enemies (Node2D)
├── Level (TileMap)
└── UI (CanvasLayer)
└── NotificationToaster (Control) ← このコンポーネント
ポイント:
NotificationToasterはCanvasLayerの子に置くと、ゲーム画面のカメラ移動に影響されず、常に画面の同じ位置に表示されます。UI以下に「通知」「HPバー」「ミニマップ」などをコンポーネントとして並べると、シーン構造がとても見通しよくなります。
手順③: スクリプトから通知を出す
例えば、プレイヤーがセーブポイントに触れたときに「セーブしました」と表示したい場合:
Main (Node2D)
├── Player (CharacterBody2D)
│ └── Player.gd
├── SavePoint (Area2D)
│ └── SavePoint.gd
└── UI (CanvasLayer)
└── NotificationToaster (Control)
SavePoint.gd から Toaster を呼び出す例です。
extends Area2D
@onready var toaster: NotificationToaster = get_tree().get_first_node_in_group("notification_toaster")
func _ready() -> void:
## NotificationToaster 側で group を設定しておくと便利です。
## 例: NotificationToaster の _ready() で
## add_to_group("notification_toaster")
## としておく。
pass
func _on_body_entered(body: Node) -> void:
if body.is_in_group("player"):
_save_game()
if toaster:
toaster.show_toast("セーブしました")
func _save_game() -> void:
## 実際のセーブ処理(ダミー)
print("Game saved!")
もしくは、もっとシンプルに「メインシーンから直接参照」でもOKです。
# Main.gd
extends Node2D
@onready var toaster: NotificationToaster = $UI/NotificationToaster
func on_game_saved() -> void:
toaster.show_toast("セーブしました")
このように、ゲームロジック側は「通知の見た目」や「アニメーション」を一切気にせず、とにかく show_toast() だけ呼べばよい、という分離ができます。
手順④: 他の用途にもガンガン使い回す
例えば、次のようなシーン構成も簡単に作れます。
BattleScene (Node2D)
├── Player (CharacterBody2D)
├── EnemyBoss (CharacterBody2D)
├── Camera2D
└── UI (CanvasLayer)
├── HPBar (Control)
├── SkillPanel (Control)
└── NotificationToaster (Control)
- ボスが第二形態になったとき:
toaster.show_toast("ボスが本気を出した!") - スキルクールダウンが終わったとき:
toaster.show_toast("スキルが使用可能になりました") - チュートリアルのヒント表示などにも使えます
いずれも「NotificationToaster コンポーネントをシーンに1つ置く」だけで完結するのがポイントです。
メリットと応用
このコンポーネントを使うことで、次のようなメリットがあります。
- シーン構造がスッキリ
通知用のノードを各シーンにバラバラに置かなくて済み、UI/NotificationToaster1 箇所に集約できます。 - 見た目の変更が一括でできる
背景色・フォント・角丸・影などを NotificationToaster 側で変えるだけで、ゲーム全体の通知デザインが統一されます。 - ロジックとの分離
プレイヤーや敵のスクリプトは「いつ通知を出すか」だけを考えればよく、「どう表示するか」はコンポーネントに丸投げできます。 - 継承ではなく合成
「通知付きUI」「通知付きプレイヤー」みたいな継承ツリーを増やさず、どんなシーンにも同じコンポーネントをポン付けできます。
さらに、応用としては:
- トーストの種別(成功 / 警告 / エラー)ごとに色を変える
- サウンドエフェクトを鳴らす
- アイコン付きのトースト(アイテム画像など)を出す
などが考えられます。
改造案:トースト種別ごとに色を変える
例えば、「成功」「警告」「エラー」の3種類で背景色を変えたい場合、show_toast() をオーバーロードするような関数を追加できます。
enum ToastType {
INFO,
WARNING,
ERROR,
}
func show_typed_toast(message: String, toast_type: ToastType = ToastType.INFO) -> void:
## 種別に応じてスタイルを変える簡易版
var style: StyleBoxFlat = _panel.get_theme_stylebox("panel") as StyleBoxFlat
if not style:
return
match toast_type:
ToastType.INFO:
style.bg_color = Color(0, 0, 0, 0.85)
ToastType.WARNING:
style.bg_color = Color(0.9, 0.6, 0.1, 0.95)
ToastType.ERROR:
style.bg_color = Color(0.8, 0.2, 0.2, 0.95)
_panel.add_theme_stylebox_override("panel", style)
## 実際の表示処理は既存の show_toast() に委譲
show_toast(message)
こうしておけば、ゲーム側からは:
toaster.show_typed_toast("セーブしました", NotificationToaster.ToastType.INFO)
toaster.show_typed_toast("MPが足りません", NotificationToaster.ToastType.WARNING)
toaster.show_typed_toast("接続が切断されました", NotificationToaster.ToastType.ERROR)
のように呼び分けられます。
UIの表現力は上がりますが、ゲームロジックは依然として NotificationToaster に依存するだけなので、コンポーネント指向のメリットはそのままですね。
こんな感じで、まずはシンプルなトースト通知をコンポーネントとして導入しつつ、必要になったら少しずつ機能を足していく、というスタイルで育てていきましょう。
