This is a challenge I recently solved for my game, maybe not fully yet as I’ve not dealt with all the edge cases. It is not that hard, it just requires some moving parts.
This is for you and anyone in the future looking for a solution for something like this.
The way I do it is that I let each enemy handle its own spawning in a level. For each area/level, there is an area_state config file attached to it that contains information about dead enemies and other persistent activities. The state file looks like this:
##Area 1 state file area_1.config
[Enemy1]
is_dead=true
death_position=Vector2(2802.87, -328.575)
[BreakableBox1]
destroyed=true
[Enemy2]
is_dead=true
death_position=Vector2(2890.36, -325.202)
[Enemy3]
is_dead=true
death_position=Vector2(6575.32, 695.425)
As you can see, each enemy is referenced by name, and it stores the needed information to know if it should respawn, or where it should respawn its carcass as dead.
Each enemy or interactable object registers itself with the GameManager autoload when it spawns in the level for the first time. It also updates its status when it dies or gets destroyed.
I have a class called enemy that all enemies extend from. In that class, when the enemy node enters the tree, it checks the the area_state file, if is_dead
is true, it destroys itself if not, it spawns normally. Below is what a sample of the code look like in the enemy script;
func _enter_tree() -> void:
##I set some enemies to persist in the level so they always respawn
if not persist_world_state:
return
##Enemy gets its death state from the GameManager or registers itself for the first time
var dead = await GameAreaManager.load_object_state(self, "is_dead", is_dead)
if dead:
##If enemy is dead, it destroys the enemy
var death_position: Vector2 = await GameAreaManager.load_object_state(self, "death_position", global_position)
queue_free()
Destroyable objects also share a similar script.
The saving and loading of the area_state looks something like this;
##check if area state exists, then load or create it
var config_file: ConfigFile = ConfigFile.new()
var err = config_file.load("user://areas/area_1/area_1_state.config")
if err != OK:
config_file.save("user://areas/area_1/area_1_state.config")
func save_object_state(object: Node2D, property: String, value: Variant):
config_file.set_value(object.name, property, value)
save_config_file(config_file, dir_path, file_path)
func load_object_state(object: Node2D, property: String, default_value: Variant = null) -> Variant:
return config_file.get_value(object.name, property, default_value)
In summary, the interaction looks like this:
Object -> GameManager -> AreaStateFile.config
I hope this helps you. It may not be something you can get done immediately, but it will give you an idea of how to get it done. At the end of the day, you really can’t escape making a save system, and this architecture makes it modular enough for all object types.