シーン切り替えやリソースの非同期ロードを書くとき、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.gdres://addons/components/ui/LoadingSpinner.gd など好きな場所に保存します。

手順②: 共通UIシーンにアタッチする

ロード中スピナーは多くの場合「どのシーンでも共通で使いたい」ので、UI専用のルートシーンにアタッチするのがおすすめです。

例: ゲーム全体のルートシーン構成

Main (Node)
 ├── World (Node)             # ゲーム本体を入れ替える場所
 └── UI (CanvasLayer)
      └── LoadingSpinner (CanvasLayer)  <-- このコンポーネント

あるいは、プレイヤーやメニューシーンなど、特定シーンだけで使いたい場合は以下のようにします。

GameScene (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    └── CollisionShape2D
 └── LoadingSpinner (CanvasLayer)  <-- コンポーネント
  1. シーンツリーで UI 用の CanvasLayer を選択(または新規作成)。
  2. 「+」ボタンで 子ノードに CanvasLayer を追加し、名前を LoadingSpinner に変更。
  3. そのノードに上記スクリプト LoadingSpinner.gd をアタッチ。
  4. インスペクタで 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 コンポーネントに閉じ込めておくと、ゲーム本体のコードは「ロードすること」だけに集中できてとてもスッキリします。
継承より合成、どんどん進めていきましょう。