シーン切り替えやリソースの非同期ロードを書くとき、SceneTree.change_scene_to_file() や ResourceLoader.load_threaded_request() を素直に使うと、つい「ロード中のUI」を各シーンにベタ書きしがちですよね。
シーンごとに CanvasLayer を置いてスピナーを回したり、フェード演出を足したりしていると、だんだん「どのシーンにどのローディングUIがあるのか」分からなくなります。
さらに、Godot 4 では非同期処理が書きやすくなった反面、ロード状態に応じて UI を出したり消したりするコードを各シーンに分散させると、メンテがつらくなります。
そこで今回は「ロード中のスピナー表示」をコンポーネント化して、どのシーンにも簡単にポン付けできる LoadingSpinner コンポーネントを用意してみましょう。
「ロード待機UI」はプレイヤーや敵の挙動とは関係ないので、専用の親クラスを作って継承するよりも、CanvasLayer にコンポーネントとしてアタッチして、必要なシーンにだけ追加するのが相性抜群です。
【Godot 4】非同期ロードを可視化!「LoadingSpinner」コンポーネント
このコンポーネントは:
- 画面右下にスピナー画像を表示
- ロード中だけ自動で回転・表示
- スクリプトから
start()/stop()を呼ぶだけ - 位置・サイズ・回転速度・マージンをインスペクタから調整可能
という、シンプルだけど使い回しやすい設計になっています。
コンポーネントのフルコード
extends CanvasLayer
class_name LoadingSpinner
##
## 非同期ロード中に画面右下でクルクル回るスピナーUIコンポーネント
## - CanvasLayer にアタッチして使う想定
## - start() / stop() を呼ぶだけで表示・非表示を制御
##
@export_category("Sprite 設定")
## スピナーとして表示するテクスチャ
@export var spinner_texture: Texture2D
## スピナーの描画サイズ(ピクセル)
@export var spinner_size: Vector2 = Vector2(48, 48)
## スピナーの色(白にしておくとテクスチャそのまま)
@export var modulate_color: Color = Color(1, 1, 1, 1)
@export_category("位置・マージン")
## 画面右下からのオフセット(ピクセル)
@export var margin_right: float = 16.0
@export var margin_bottom: float = 16.0
## 右下以外に置きたい場合はここを変える
## "bottom_right" / "bottom_left" / "top_right" / "top_left"
@export_enum("bottom_right", "bottom_left", "top_right", "top_left")
var anchor_position: String = "bottom_right"
@export_category("アニメーション")
## 1秒あたりの回転角度(度)
@export var rotation_speed_deg: float = 360.0
## ロード中にだけスピナーを回転させるかどうか
@export var rotate_only_when_visible: bool = true
@export_category("自動制御")
## true の場合、シーンロード中(process_mode による)もスピナーは描画される
## 通常は CanvasLayer のデフォルトで OK
@export var pause_ignored: bool = true
## 内部状態:現在ロード中かどうか
var _is_loading: bool = false
## 実際に描画する Sprite2D ノード
var _sprite: Sprite2D
func _ready() -> void:
# CanvasLayer の pause_mode 設定
# ロード中にゲームを一時停止してもスピナーは回したい、という場合に便利
if pause_ignored:
pause_mode = Node.PAUSE_MODE_PROCESS
else:
pause_mode = Node.PAUSE_MODE_INHERIT
# 子ノードとして Sprite2D を動的に生成
_sprite = Sprite2D.new()
add_child(_sprite)
# 初期設定
_sprite.texture = spinner_texture
_sprite.modulate = modulate_color
_sprite.visible = false # 初期状態では非表示
# サイズを調整
_update_sprite_size()
# 画面サイズに応じて位置を更新
_update_sprite_position()
# ビューポートサイズ変更に追従
var viewport := get_viewport()
if viewport:
viewport.size_changed.connect(_on_viewport_size_changed)
func _process(delta: float) -> void:
if not is_instance_valid(_sprite):
return
# 回転アニメーション
if _sprite.visible or not rotate_only_when_visible:
_sprite.rotation_degrees += rotation_speed_deg * delta
func _on_viewport_size_changed() -> void:
_update_sprite_position()
func _update_sprite_size() -> void:
if not is_instance_valid(_sprite):
return
if spinner_texture:
# Texture のサイズをスケールで合わせる
var tex_size: Vector2 = spinner_texture.get_size()
if tex_size.x > 0 and tex_size.y > 0:
_sprite.scale = spinner_size / tex_size
else:
_sprite.scale = Vector2.ONE
else:
_sprite.scale = Vector2.ONE
func _update_sprite_position() -> void:
if not is_instance_valid(_sprite):
return
var viewport := get_viewport()
if not viewport:
return
var screen_size: Vector2 = viewport.get_visible_rect().size
var pos: Vector2 = Vector2.ZERO
# スプライトの実効サイズ(スケール後)を算出
var tex_size: Vector2 = spinner_size
if spinner_texture:
tex_size = spinner_size
else:
tex_size = Vector2(48, 48)
match anchor_position:
"bottom_right":
pos.x = screen_size.x - margin_right - tex_size.x / 2.0
pos.y = screen_size.y - margin_bottom - tex_size.y / 2.0
"bottom_left":
pos.x = margin_right + tex_size.x / 2.0
pos.y = screen_size.y - margin_bottom - tex_size.y / 2.0
"top_right":
pos.x = screen_size.x - margin_right - tex_size.x / 2.0
pos.y = margin_bottom + tex_size.y / 2.0
"top_left":
pos.x = margin_right + tex_size.x / 2.0
pos.y = margin_bottom + tex_size.y / 2.0
_:
# 想定外の値になった場合は bottom_right 扱い
pos.x = screen_size.x - margin_right - tex_size.x / 2.0
pos.y = screen_size.y - margin_bottom - tex_size.y / 2.0
_sprite.position = pos
# --- 公開API ---------------------------------------------------------
## ロード開始時に呼ぶ
func start() -> void:
_is_loading = true
if is_instance_valid(_sprite):
_sprite.visible = true
## ロード完了時に呼ぶ
func stop() -> void:
_is_loading = false
if is_instance_valid(_sprite):
_sprite.visible = false
## ロード状態を問い合わせる
func is_loading() -> bool:
return _is_loading
## テクスチャやサイズをコードから変更したい場合用
func configure(texture: Texture2D = spinner_texture, size: Vector2 = spinner_size) -> void:
spinner_texture = texture
spinner_size = size
if is_instance_valid(_sprite):
_sprite.texture = spinner_texture
_update_sprite_size()
_update_sprite_position()
使い方の手順
手順①: コンポーネントスクリプトを用意する
上記の LoadingSpinner.gd を res://addons/components/ui/LoadingSpinner.gd など好きな場所に保存します。
手順②: 共通UIシーンにアタッチする
ロード中スピナーは多くの場合「どのシーンでも共通で使いたい」ので、UI専用のルートシーンにアタッチするのがおすすめです。
例: ゲーム全体のルートシーン構成
Main (Node)
├── World (Node) # ゲーム本体を入れ替える場所
└── UI (CanvasLayer)
└── LoadingSpinner (CanvasLayer) <-- このコンポーネント
あるいは、プレイヤーやメニューシーンなど、特定シーンだけで使いたい場合は以下のようにします。
GameScene (Node2D) ├── Player (CharacterBody2D) │ ├── Sprite2D │ └── CollisionShape2D └── LoadingSpinner (CanvasLayer) <-- コンポーネント
- シーンツリーで UI 用の CanvasLayer を選択(または新規作成)。
- 「+」ボタンで 子ノードに CanvasLayer を追加し、名前を
LoadingSpinnerに変更。 - そのノードに上記スクリプト
LoadingSpinner.gdをアタッチ。 - インスペクタで
spinner_textureにスピナー用の PNG などを設定。
手順③: 非同期ロード処理から start()/stop() を呼ぶ
次に、シーン切り替えやリソースロードのスクリプトから、LoadingSpinner を取得して start() / stop() を呼びます。
例: メインシーンでステージシーンを非同期ロードする場合
extends Node
@onready var world_root: Node = $World
@onready var loading_spinner: LoadingSpinner = $UI/LoadingSpinner
func _ready() -> void:
# 最初のステージをロード
await load_stage_async("res://scenes/stage_01.tscn")
## ステージを非同期ロードするサンプル
func load_stage_async(path: String) -> void:
loading_spinner.start()
# 既存ステージを削除
for child in world_root.get_children():
child.queue_free()
# 非同期ロード
var loader := ResourceLoader.load_threaded_request(path)
var status := ResourceLoader.ThreadLoadStatus.THREAD_LOAD_IN_PROGRESS
while status == ResourceLoader.ThreadLoadStatus.THREAD_LOAD_IN_PROGRESS:
status = ResourceLoader.load_threaded_get_status(path)
await get_tree().process_frame # 1フレーム待つ
if status == ResourceLoader.ThreadLoadStatus.THREAD_LOAD_LOADED:
var packed: PackedScene = ResourceLoader.load_threaded_get(path)
var stage := packed.instantiate()
world_root.add_child(stage)
else:
push_error("Failed to load stage: %s" % path)
loading_spinner.stop()
このように、ロード処理の前後に loading_spinner.start() / loading_spinner.stop() を挟むだけで、画面右下にクルクル回るアイコンが表示されるようになります。
手順④: プレイヤーやメニューからも共通で呼び出す
例えば、プレイヤーがワープポータルに入ったときだけスピナーを出したい場合:
World (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ └── CollisionShape2D
├── Portal (Area2D)
│ ├── CollisionShape2D
│ └── Sprite2D
└── UI (CanvasLayer)
└── LoadingSpinner (CanvasLayer)
ポータル側のスクリプト:
extends Area2D
@export var target_stage_path: String
func _on_body_entered(body: Node) -> void:
if body.name == "Player":
var spinner := get_tree().get_first_node_in_group("loading_spinner")
# グループ登録しておくとどこからでも取得しやすい
if spinner and spinner is LoadingSpinner:
spinner.start()
await _change_stage_async(target_stage_path)
if spinner and spinner is LoadingSpinner:
spinner.stop()
func _change_stage_async(path: String) -> void:
# ここに非同期ロード処理を書く(前述の load_stage_async と同様)
pass
※ LoadingSpinner ノードを「Groups」タブから loading_spinner グループに入れておくと、どのノードからでも簡単に見つけられて便利です。
メリットと応用
- シーン構造がスッキリ:ロードUIを各シーンにバラバラに置く必要がなく、共通UIシーン + コンポーネントに集約できます。
- 継承地獄を回避:ロード処理を書くたびに「ローディング付きベースクラス」を継承する必要はありません。
どんなシーンにも、必要なら後からLoadingSpinnerをペタッと貼るだけです。 - 使い回しやすい:ゲームをまたいでも、スクリプトとテクスチャをコピペすればすぐ再利用できます。
- デザイナーとの分業がしやすい:見た目(テクスチャ・色・サイズ・位置)はインスペクタで調整できるので、コードを触らずにUIデザインを変えられます。
応用としては:
- フェードイン・フェードアウトと組み合わせて、ロード開始時にスピナーをフェードインさせる
- ロード時間が一定時間以上になったときだけスピナーを出す(短いロードでは出さない)
- テキスト「Loading…」やプログレスバーと組み合わせた複合コンポーネントに発展させる
改造案: 一定時間経過してからスピナーを表示する
ロードが一瞬で終わる場合、毎回スピナーがチラ見えするのはうるさいですよね。
以下のように「ディレイ付き start()」を追加して、0.3秒以上かかったときだけスピナーを出すようにするのもおすすめです。
## ロード開始から delay 秒経過したらスピナーを表示する
func start_with_delay(delay: float = 0.3) -> void:
_is_loading = true
_sprite.visible = false
# 非同期で待機
await get_tree().create_timer(delay).timeout
# まだロード中なら表示
if _is_loading and is_instance_valid(_sprite):
_sprite.visible = true
このように、ロードに関するUIロジックを LoadingSpinner コンポーネントに閉じ込めておくと、ゲーム本体のコードは「ロードすること」だけに集中できてとてもスッキリします。
継承より合成、どんどん進めていきましょう。
