Reputation: 70
I was encountering some weird problems while loading from a scene to another and then back, so to check my sanity I made a minimal reproducible example of the problem.
My minimal example is the following. Two scenes, that are visually distinguishable, they both have at their root a copy of this script, with "next_scene" set to the other scene.
extends Node2D
@export var next_scene: PackedScene = null
func _process(delta: float) -> void:
if Input.is_action_just_pressed("space"):
print(next_scene)
get_tree().change_scene_to_packed(next_scene)
Expected behavior: I would be able to navigate from one to another back and forth.
Actual behavior: First load goes well, then going back fails because "next_scene" is said to be null
This happens no matter which scene I start with, proving that it isn't my setup that is wrong.
I tried to implement my own scene manager that would instantiate and queue_free() nodes instead, but I get IDENTICAL behavior. At this point I'm losing my mind as I've been at it for half a day.
Upvotes: 1
Views: 2222
Reputation: 40285
Godot will load resoruces set to export variables when loading the scene.
This means that while Godot is loading an scene, it would attempt to load the scene from the PackedScene
set in the export variable it has, and so on.
If these form a dependency cycle - for what I recall - it would lead to the scenes not loading at all.
To avoid this, instead of setting a PackedScene
set the path to the scene, which then you can load
(or use thread loading).
You said you created a Resource
where you store the scene path, so that you only need to update said resource (i.e. it is a single source of truth for the scene path).
Which I presume is something like this:
class_name SceneReference
extends Resource
@export_file("*.tscn", "*.scn") var scene_path:String
We can add to it the functionality of loading the scene:
class_name SceneReference
extends Resource
@export_file("*.tscn", "*.scn") var scene_path:String
func get_scene() -> PackedScene:
# Load the scene and return it
return load(scene_path)
Futhermore, we can ease creating these Resource
s:
@tool
class_name SceneReference
extends Resource
@export_file("*.tscn", "*.scn") var scene_path:String
func get_scene() -> PackedScene:
# Load the scene and return it
return load(scene_path)
static func create_for(scene:Node) -> SceneReference:
if (
not is_instance_valid(scene) # We need an existing Node
or scene.scene_file_path.is_empty() # And it must be the root of a scene
):
# We didn't get the root of a scene, return null
return null
# Compute the path to store the SceneReference
var path := scene.scene_file_path.get_basename() + ".tres"
# Load the SceneReference from the computed path if possible
# Create a new SceneReference otherwise
var resource:SceneReference
if ResourceLoader.exists(path, "SceneReference"):
resource = ResourceLoader.load(path, "SceneReference")
else:
resource = SceneReference.new()
# Ensure the path of the SceneReference is set
resource.scene_path = scene.scene_file_path
if Engine.is_editor_hint():
# Ensure it is saved at the computed path
ResourceSaver.save(resource, path, ResourceSaver.FLAG_CHANGE_PATH)
# Ensure it is up to date in Godot's cache
resource.take_over_path(path)
# Return the SceneReference
return resource
I have decided I want a method that takes the path (thus I have also added a check in case the path does not exist):
@tool
class_name SceneReference
extends Resource
@export_file("*.tscn", "*.scn") var scene_path:String
func get_scene() -> PackedScene:
# Load the scene and return it
return load(scene_path)
static func create_for(scene:Node) -> SceneReference:
if not is_instance_valid(scene):
# We didn't get a node
return null
return create_for_path(scene.scene_file_path)
static func create_for_path(scene_file_path:String) -> SceneReference:
if scene_file_path.is_empty() or not FileAccess.file_exists(scene_file_path):
# We didn't get a scene file path
return null
# Compute the path to store the SceneReference
var path := scene_file_path.get_basename() + ".tres"
# Load the SceneReference from the computed path if possible
# Create a new SceneReference otherwise
var resource:SceneReference
if ResourceLoader.exists(path, "SceneReference"):
resource = ResourceLoader.load(path, "SceneReference")
else:
resource = SceneReference.new()
# Ensure the path of the SceneReference is set
resource.scene_path = scene_file_path
if Engine.is_editor_hint():
# Ensure it is saved at the computed path
ResourceSaver.save(resource, path, ResourceSaver.FLAG_CHANGE_PATH)
# Ensure it is up to date in Godot's cache
resource.take_over_path(path)
# Return the SceneReference
return resource
That is all too great, but in practice it seems too easy modify the scene path when we do not intent to.
At first, I managed to store the path but not allow editing it...
But if the mechanism we use to create the SceneReference
puts gives it the name of the scene and stores it in the same path, we do not need to store the path at all.
This also eases moving the SceneReference
file and the scene to a difference path without the need of editing the SceneReference
. In fact, there won't be anything stored in the SceneReference
:
@tool
class_name SceneReference
extends Resource
signal load_progress(load_percentage:float)
signal loaded(scene:PackedScene)
var scene_path:String:
get:
return resource_path.get_basename()
set(_mod_value):
return
func get_scene() -> PackedScene:
# Load the scene and return it
return load(scene_path)
static func create_for(scene:Node) -> SceneReference:
if not is_instance_valid(scene):
# We didn't get a node
return null
return create_for_path(scene.scene_file_path)
static func create_for_path(scene_file_path:String) -> SceneReference:
if scene_file_path.is_empty() or not ResourceLoader.exists(scene_file_path, "PackedScene"):
# We didn't get a scene file path
return null
# Compute the path to store the SceneReference
var path := scene_file_path + ".tres"
# Load the SceneReference from the computed path if possible
# Create a new SceneReference otherwise
var resource:SceneReference
if ResourceLoader.exists(path, "SceneReference"):
resource = ResourceLoader.load(path, "SceneReference")
else:
resource = SceneReference.new()
if Engine.is_editor_hint():
# Ensure it is saved at the computed path
ResourceSaver.save(resource, path, ResourceSaver.FLAG_CHANGE_PATH)
# Ensure it is up to date in Godot's cache
resource.take_over_path(path)
# Return the SceneReference
return resource
Speaking of the mechanism to create them, you can use it form the script of the scenes like this:
@tool
func _ready() -> void:
SceneReference.create_for(self)
Or you could create a script you add to a node that does this for the scene root:
@tool
func _ready() -> void:
SceneReference.create_for(owner)
I decided to give it a name, and let it work both ways:
@tool
class_name SceneReferenciable
extends Node
func _ready() -> void:
SceneReference.create_for(owner if scene_file_path.is_empty() else self)
We can go further and add threaded loading to SceneReference
:
@tool
class_name SceneReference
extends Resource
signal load_progress(load_percentage:float)
signal loaded(scene:PackedScene)
var scene_path:String:
get:
return resource_path.get_basename()
set(_mod_value):
return
var load_percentage:float:
set(mod_value):
if load_percentage == mod_value:
return
load_percentage = mod_value
# The percentage changed, emit the signal
load_progress.emit(load_percentage)
var packed_scene:PackedScene:
get:
if typeof(_state) == TYPE_OBJECT:
return _state as PackedScene
return null
set(_mod_value):
return
var error:Error = OK
var _state:Variant = null
func begin_load() -> void:
if Engine.is_editor_hint():
# We do not load in the editor
return
match typeof(_state):
TYPE_OBJECT:
var result:PackedScene = _state
if is_instance_valid(result):
# It was loaded previously
emit_signal.call_deferred("loaded", result)
return
TYPE_INT:
# It already failed to load
emit_signal.call_deferred("loaded", null)
return
TYPE_NIL:
# Load not started yet
_load()
func _load() -> void:
var result:Variant = _request()
if typeof(result) == TYPE_FLOAT:
# Loading, set the percentage
load_percentage = result
# We will check again later
_load.call_deferred()
else:
# Done loading
load_percentage = 1.0
# try to get the result
var _packed_scene := result as PackedScene
if is_instance_valid(_packed_scene):
# We got a valid result
emit_signal.call_deferred("loaded", _packed_scene)
else:
# We got an error
if typeof(result) == TYPE_INT:
error = result
else:
error = FAILED
emit_signal.call_deferred("loaded", null)
func _request() -> Variant:
match typeof(_state):
TYPE_NIL:
# Not started
if not ResourceLoader.exists(scene_path, "PackedScene"):
# Failed
_state = ERR_DOES_NOT_EXIST
return ERR_DOES_NOT_EXIST
var err := ResourceLoader.load_threaded_request(scene_path, "PackedScene")
if OK != err:
# Failed
_state = err
return err
else:
# Loading
_state = 0.0
return 0.0
TYPE_OBJECT:
# Succeeded
var state_as_resource := _state as PackedScene
if is_instance_valid(state_as_resource):
return state_as_resource
else:
return FAILED
TYPE_INT:
# Failed, return error code
return _state
TYPE_FLOAT:
# Loading
var state_as_float:float = _state
var progress:Array[float] = []
var status := ResourceLoader.load_threaded_get_status(scene_path, progress)
if status == ResourceLoader.THREAD_LOAD_LOADED:
# Succeeded
var result := ResourceLoader.load_threaded_get(scene_path)
_state = result
return result
elif status == ResourceLoader.THREAD_LOAD_FAILED:
# Failed
_state = FAILED
return FAILED
else:
# Loading
var percentage := maxf(progress[0], state_as_float)
_state = percentage
return percentage
_:
return FAILED
static func create_for(scene:Node) -> SceneReference:
if not is_instance_valid(scene):
# We didn't get a node
return null
return create_for_path(scene.scene_file_path)
static func create_for_path(scene_file_path:String) -> SceneReference:
if scene_file_path.is_empty() or not ResourceLoader.exists(scene_file_path, "PackedScene"):
# We didn't get a scene file path
return null
# Compute the path to store the SceneReference
var path := scene_file_path + ".tres"
# Load the SceneReference from the computed path if possible
# Create a new SceneReference otherwise
var resource:SceneReference
if ResourceLoader.exists(path, "SceneReference"):
resource = ResourceLoader.load(path, "SceneReference")
else:
resource = SceneReference.new()
if Engine.is_editor_hint():
# Ensure it is saved at the computed path
ResourceSaver.save(resource, path, ResourceSaver.FLAG_CHANGE_PATH)
# Ensure it is up to date in Godot's cache
resource.take_over_path(path)
# Return the SceneReference
return resource
Note: I emit the signal using a call_deferred
and also emit it when the load fails for the benefit of the code consuming it, which could be like this:
@export var scene_reference:SceneReference
func _ready() -> void:
scene_reference.begin_load()
var scene:PackedScene = await scene_reference.loaded
prints(scene)
Using call_deferred
I ensure the signal happens after returning, and by emiting it even if the load failed I ensure to not have the script awaiting forever.
You could, of course, connect to the signals if you prefer... In which case be aware that loaded
might be emited multiple times (I recommend using the flag CONNECT_ONE_SHOT
).
Upvotes: 1