Godotでちょっとリッチなゲームを作り始めると、ステージ切り替え時の「一瞬固まる」感じ、気になりますよね。
特に 3D や大量のタイルマップ、重いシーンを change_scene_to_file() 一発で読み込んでいると、どうしてもフレームが止まりがちです。
典型的な実装だと:
- 各ステージ用のシーンに直接スクリプトを書いて、
_ready()でリソースを読み込む - ステージ管理用の「巨大な」マネージャシーンを作り、そこで全部のロード・切り替えをやる
- もしくは、毎回
ResourceLoader.load()を直書きして場当たり的に対応
こういうやり方だと、
- ステージが増えるたびにマネージャのスクリプトが肥大化
- 「どこで何を読み込んでいるか」が分散して追いづらい
- 将来「ロード画面をつけたい」「プログレスバーを出したい」ときに大改造が必要
…という、ありがちな「継承と巨大マネージャ問題」にハマりがちです。
そこで今回は、「シーンを切り替えるノード」と「シーンを先読みしておくノード」をきっちり分離して、どこにでもポン付けできるコンポーネントとして使えるようにした 「ScenePreloader」コンポーネント を紹介します。
ステージや UI のシーンにこのコンポーネントをアタッチしておくだけで、「裏でこっそり次のシーンを読み込んでおく」ことができるようになります。
【Godot 4】ロード待ちを先回り!「ScenePreloader」コンポーネント
このコンポーネントは、ResourceLoader.load_threaded_request() / load_threaded_get() をラップして、
- 指定したシーンをバックグラウンドで先読み
- 進捗率(0.0〜1.0)を取得
- 読み込み完了時にシグナルを発火
- 必要になったタイミングで即座に
PackedSceneを受け取る
といった処理を、どのノードにも後付けできる「合成ベース」のコンポーネントとして提供します。
フルコード(GDScript / Godot 4)
extends Node
class_name ScenePreloader
"""
ScenePreloader (シーン先読み) コンポーネント
・重いステージシーンなどをバックグラウンドで先読みしておく
・進捗率を取得できる
・読み込み完了をシグナルで通知
・必要になったタイミングで PackedScene を即取得
どのノードにもアタッチして使える「合成」志向のコンポーネントです。
"""
## --- エクスポートパラメータ ---
@export_file("*.tscn", "*.scn") var scene_path: String = "":
## 先読みしたいシーンのパス
## 例: "res://scenes/stage_02.tscn"
set(value):
scene_path = value
# すでに読み込み済みだったらリセット
if is_inside_tree():
_reset_state()
@export var auto_preload_on_ready: bool = true:
## ノードが ready になったタイミングで自動的に先読みを開始するか
## false にしておけば、手動で preload_scene() を呼ぶ運用もできます
set(value):
auto_preload_on_ready = value
@export_range(0.0, 10.0, 0.1) var poll_interval_sec: float = 0.1:
## バックグラウンドロードの進捗をポーリングする間隔(秒)
## 0.0 にするとポーリングは行わず、手動で poll_progress() を呼ぶ前提になります
set(value):
poll_interval_sec = max(value, 0.0)
## --- シグナル ---
signal preload_started(path: String)
## 先読み開始時に発火
signal preload_progress(path: String, progress: float)
## 進捗更新時に発火(0.0〜1.0)
signal preload_completed(path: String, packed_scene: PackedScene)
## 読み込み完了時に発火
signal preload_failed(path: String, error_code: int)
## 読み込み失敗時に発火
## --- 内部状態 ---
var _is_preloading: bool = false
var _is_loaded: bool = false
var _loaded_scene: PackedScene = null
var _current_progress: float = 0.0
var _thread_request_id: int = 0
var _poll_timer: Timer = null
func _ready() -> void:
# ポーリング用タイマーを内部で生成(シーンツリーにはぶら下げる)
_poll_timer = Timer.new()
_poll_timer.one_shot = false
_poll_timer.autostart = false
add_child(_poll_timer)
_poll_timer.timeout.connect(_on_poll_timer_timeout)
if auto_preload_on_ready and scene_path != "":
preload_scene()
func _exit_tree() -> void:
# シーンから抜ける際に、スレッドロードをキャンセル(Godot 4.3 以降)
if _is_preloading:
ResourceLoader.load_threaded_cancel(scene_path)
## --- 公開 API ---
func preload_scene() -> void:
"""
指定された scene_path のシーンをバックグラウンドで先読み開始する。
・scene_path が空の場合は何もしない
・すでに読み込み済みの場合は何もしない
・進捗は get_progress() または preload_progress シグナルで取得可能
"""
if scene_path == "":
push_warning("ScenePreloader: scene_path が設定されていません。")
return
if _is_loaded:
# すでに読み込み済みなら何もしない
return
if _is_preloading:
# すでに先読み中なら何もしない
return
_reset_state()
var err := ResourceLoader.load_threaded_request(scene_path)
if err != OK:
push_error("ScenePreloader: load_threaded_request に失敗しました: %s (code=%d)" % [scene_path, err])
emit_signal("preload_failed", scene_path, err)
return
_is_preloading = true
emit_signal("preload_started", scene_path)
# ポーリング開始
if poll_interval_sec > 0.0:
_poll_timer.wait_time = poll_interval_sec
_poll_timer.start()
func is_loaded() -> bool:
"""
先読みが完了しているかどうか。
"""
return _is_loaded and _loaded_scene != null
func is_preloading() -> bool:
"""
現在バックグラウンドで読み込み中かどうか。
"""
return _is_preloading
func get_progress() -> float:
"""
現在の進捗率(0.0〜1.0)を返す。
"""
return _current_progress
func take_scene() -> PackedScene:
"""
読み込み済みの PackedScene を取得する。
・取得後も内部には保持されたまま(何度でも呼び出せる)
・未読み込みの場合は null を返す
"""
if not is_loaded():
return null
return _loaded_scene
func instantiate_scene() -> Node:
"""
読み込み済みシーンをインスタンス化して返すヘルパー。
・未読み込みなら null を返す
・通常は以下のように利用:
- var inst = scene_preloader.instantiate_scene()
- add_child(inst)
"""
if not is_loaded():
return null
if _loaded_scene == null:
return null
return _loaded_scene.instantiate()
func poll_progress() -> void:
"""
手動で進捗をポーリングしたい場合に呼び出す。
・poll_interval_sec = 0.0 のときは自動ポーリングしないので、
任意のタイミングでこの関数を呼んでください。
"""
if not _is_preloading:
return
_update_progress()
## --- 内部処理 ---
func _reset_state() -> void:
if _is_preloading:
# 念のためキャンセル
ResourceLoader.load_threaded_cancel(scene_path)
_is_preloading = false
_is_loaded = false
_loaded_scene = null
_current_progress = 0.0
_thread_request_id = 0
if _poll_timer:
_poll_timer.stop()
func _on_poll_timer_timeout() -> void:
_update_progress()
func _update_progress() -> void:
if not _is_preloading:
return
var progress := PackedFloat32Array()
var status := ResourceLoader.load_threaded_get_status(scene_path, progress)
match status:
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_IN_PROGRESS:
if progress.size() > 0:
_current_progress = clamp(progress[0], 0.0, 1.0)
emit_signal("preload_progress", scene_path, _current_progress)
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_FAILED:
_is_preloading = false
_poll_timer.stop()
emit_signal("preload_failed", scene_path, ERR_CANT_OPEN)
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_LOADED:
# 読み込み完了
_is_preloading = false
_poll_timer.stop()
_current_progress = 1.0
var res := ResourceLoader.load_threaded_get(scene_path)
if res == null or not (res is PackedScene):
push_error("ScenePreloader: 読み込んだリソースが PackedScene ではありません: %s" % scene_path)
emit_signal("preload_failed", scene_path, ERR_FILE_UNRECOGNIZED)
return
_loaded_scene = res
_is_loaded = true
emit_signal("preload_progress", scene_path, 1.0)
emit_signal("preload_completed", scene_path, _loaded_scene)
_:
# 予期しないステータス
push_warning("ScenePreloader: 不明なステータス: %s" % str(status))
使い方の手順
ここからは、実際にプロジェクトに組み込む手順を見ていきましょう。
手順①: スクリプトを保存して、コンポーネントとして使えるようにする
- 上記のコードを
res://components/scene_preloader.gdなどに保存します。 class_name ScenePreloaderを定義しているので、エディタから「ノードを追加」で ScenePreloader が検索できるようになります。
手順②: 例1 – ステージ遷移用の「ハブシーン」にアタッチする
例えば、ステージ 1 からステージ 2 に移動するときに「次のステージを裏で先読み」したいケースを考えます。
シーン構成例:
StageHub (Node2D) ├── UI (CanvasLayer) │ └── Label └── ScenePreloader (Node) <-- ★ これを追加
StageHub.tscnのルートにNode2Dを置き、UI やボタンを配置。- 同じルートに ScenePreloader ノードを追加。
- インスペクタで
scene_pathに「次のステージ」のシーンパス(例:res://scenes/stage_02.tscn)を設定。
StageHub.gd に、ロード完了を待ってからシーン切り替えする処理を書いてみます。
extends Node2D
@onready var preloader: ScenePreloader = $ScenePreloader
@onready var label: Label = $UI/Label
var _ready_to_go: bool = false
func _ready() -> void:
# シグナル接続
preloader.preload_progress.connect(_on_preload_progress)
preloader.preload_completed.connect(_on_preload_completed)
preloader.preload_failed.connect(_on_preload_failed)
# 自動先読みがオフならここで明示的に開始
if not preloader.auto_preload_on_ready:
preloader.preload_scene()
label.text = "次のステージを読み込み中..."
func _on_preload_progress(path: String, progress: float) -> void:
label.text = "読み込み中: %d %%" % int(progress * 100.0)
func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
label.text = "読み込み完了!ボタンで移動できます。"
_ready_to_go = true
func _on_preload_failed(path: String, error_code: int) -> void:
label.text = "読み込み失敗… :("
push_error("Failed to preload scene: %s (code=%d)" % [path, error_code])
func _on_GoNextButton_pressed() -> void:
# ボタンは UI 配下に置いている想定
if not _ready_to_go:
label.text = "まだロード中です…"
return
# すでに PackedScene は読み込み済みなので、即座に切り替え可能
var packed := preloader.take_scene()
if packed == null:
push_error("ScenePreloader: take_scene() が null を返しました。")
return
get_tree().change_scene_to_packed(packed)
この構成にしておくと、ステージ 1 をプレイしている間に、次のステージ(ステージ 2)を裏で読み込んでおけるので、
切り替え時のカクつきをかなり抑えられます。
手順③: 例2 – タイトル画面で「ゲーム本編」を先読みする
タイトル画面の時点でゲーム本編シーンを先読みしておくと、
「スタートボタンを押してからのロード待ち」がほぼゼロになります。
シーン構成例:
TitleScreen (Control) ├── VBoxContainer │ ├── Label │ └── StartButton (Button) └── ScenePreloader (Node) <-- ★
TitleScreen.gd の例:
extends Control
@onready var preloader: ScenePreloader = $ScenePreloader
@onready var start_button: Button = $VBoxContainer/StartButton
@onready var label: Label = $VBoxContainer/Label
func _ready() -> void:
preloader.preload_completed.connect(_on_preload_completed)
preloader.preload_failed.connect(_on_preload_failed)
# タイトル表示中に先読み開始(auto_preload_on_ready = true でもOK)
preloader.preload_scene()
start_button.disabled = true
label.text = "ゲームデータを読み込み中..."
func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
label.text = "準備完了!スタートボタンで開始できます。"
start_button.disabled = false
func _on_preload_failed(path: String, error_code: int) -> void:
label.text = "ロードに失敗しました…"
start_button.disabled = true
func _on_StartButton_pressed() -> void:
var packed := preloader.take_scene()
if packed == null:
push_error("ScenePreloader: 本編シーンが読み込まれていません。")
return
get_tree().change_scene_to_packed(packed)
手順④: 例3 – ロード専用シーンでプログレスバーを表示する
最後に、いわゆる「ロード画面」シーンでプログレスバーを出すパターンです。
ここでも、巨大な「ロードマネージャ」を作るのではなく、ScenePreloader を 1 個アタッチするだけにしておきます。
シーン構成例:
LoadingScreen (Control) ├── ProgressBar ├── Label └── ScenePreloader (Node)
LoadingScreen.gd の例:
extends Control
@onready var preloader: ScenePreloader = $ScenePreloader
@onready var progress_bar: ProgressBar = $ProgressBar
@onready var label: Label = $Label
func _ready() -> void:
progress_bar.value = 0
label.text = "読み込み中..."
preloader.preload_progress.connect(_on_preload_progress)
preloader.preload_completed.connect(_on_preload_completed)
preloader.preload_failed.connect(_on_preload_failed)
preloader.preload_scene()
func _on_preload_progress(path: String, progress: float) -> void:
progress_bar.value = progress * 100.0
label.text = "読み込み中... %d%%" % int(progress * 100.0)
func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
label.text = "完了!シーンを切り替えます..."
await get_tree().create_timer(0.2).timeout
get_tree().change_scene_to_packed(packed_scene)
func _on_preload_failed(path: String, error_code: int) -> void:
label.text = "読み込みに失敗しました (code=%d)" % error_code
メリットと応用
この ScenePreloader をコンポーネントとして導入するメリットはかなり多いです。
- シーン構造がスッキリ
「ロード専用の巨大マネージャシーン」を作らずに済みます。
タイトル画面、ステージハブ、ロード画面など、「先読みしたい場所」にだけ ScenePreloader をポン付けできます。 - 責務が明確
「シーンをいつ・どこで先読みするか」という責務を、各シーンごとに局所化できます。
変更したいときも、そのシーンだけ見れば OK です。 - 再利用性が高い
どのゲームでも「重いシーンを裏で読みたい」ニーズはほぼ共通なので、
res://components/scene_preloader.gdを別プロジェクトに持っていくだけで、すぐ再利用できます。 - 継承ツリーを増やさない
「ロード機能付きタイトル画面」とか「ロード機能付きステージハブ」みたいな、
意味不明な継承階層を作らずに済みます。
ただのControlやNode2Dに、ScenePreloader をアタッチするだけで OK です。
さらに、応用としては:
- ステージごとに「次に来る可能性が高いステージ」を複数パターン先読みする
- UI の重いアニメーションシーンを先読みしておく
- オンラインゲームで「次のマップを予測して先読み」する
など、ゲームの体感クオリティを上げるアイデアにそのまま使えます。
改造案:複数シーンをキューに入れて順番に先読みする
「次のステージ候補が複数あるから、順番に先読みしておきたい」というケース向けに、
簡易的な「プリロードキュー」を追加する改造案です。
ScenePreloader に、例えばこんなメソッドを追加すると:
var _queue: Array[String] = []
func enqueue_scene(path: String) -> void:
"""
複数シーンを順番に先読みしたい場合の簡易キュー。
現在何も読み込んでいなければ即座に先読み開始し、
読み込み中であればキューに積んでおく。
"""
_queue.append(path)
# 何も読み込み中でなければ、すぐに次を開始
if not _is_preloading and not is_loaded():
_start_next_from_queue()
func _start_next_from_queue() -> void:
if _queue.is_empty():
return
scene_path = _queue.pop_front()
preload_scene()
func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
# もともとの処理を維持しつつ、次のキューを開始
emit_signal("preload_completed", path, packed_scene)
_start_next_from_queue()
こんな感じで、「複数のシーンを順番に先読みするプリローダー」としても発展させられます。
もちろん、これも巨大なマネージャではなく、あくまで 1 つのコンポーネントとして各所に配備できるのがポイントですね。
継承より合成で、ロード周りもサクッとコンポーネント化していきましょう。
