initial shit

This commit is contained in:
2026-06-04 16:53:41 -05:00
parent f019615187
commit d3779cff20
828 changed files with 512567 additions and 0 deletions

21
addons/savekit/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fern Forest Games
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
addons/savekit/README.md Normal file
View File

@@ -0,0 +1,121 @@
# SaveKit for Godot
A library for saving and loading game state in Godot 4, with pluggable save file formats and a focus on ease of use.
Key features:
- **Easy to get started.** Add nodes to the `saveable` group, then use `SaveManager.save_game()` and `SaveManager.load_game()`.
- **Saves nodes and resources.** Built-in resources, like textures and packed scenes, are saved as references, while data from nodes and custom `SaveKitResource` subclasses is saved in its entirety. This avoids the code injection risks of Godot's `ResourceLoader`, while supporting complex data.
- **JSON and binary serialization built-in**, or implement your own custom save file format by extending `SaveKitSerializer` and `SaveKitDeserializer`.
- **Automatic by default, manual when you need it.** Reflection picks up exported properties for saving/loading automatically, or you can implement custom `save_to_dict` and `load_from_dict` methods for full control.
## Getting started
1. Enable the plugin in **Project > Project Settings > Plugins**. This also installs a `SaveManager` autoload.
2. Add all the nodes you want saved to the `saveable` group.
3. Call one method to save, another to load:
```gdscript
# Save to disk under user://save_games/MyGame/Slot 1.json
SaveManager.save_game(PackedStringArray(["MyGame", "Slot 1"]))
# Load it back later
SaveManager.load_game(PackedStringArray(["MyGame", "Slot 1"]))
```
This will iterate through nodes in the `saveable` group, serialize each node's exported properties, and write the file into `user://save_games/`. Then the reverse is done on load—creating or freeing nodes as needed so the scene tree matches the save file.
There are also other methods offering finer-grained control over the save/load process:
```gdscript
func save_scene_tree_in_memory() -> PackedByteArray
func save_scene_tree_to_disk(absolute_path: String) -> Error
func load_scene_tree_from_memory(data: PackedByteArray) -> bool
func load_scene_tree_from_file(absolute_path: String) -> Error
```
## Saving nodes
By default, SaveKit uses reflection to save all `@export` and `@export_storage` properties whose values differ from their defaults. For a lot of nodes, this is all you need:
```gdscript
extends CharacterBody2D
@export var health: int = 100
@export var player_name: String = ""
@export_storage var checkpoint: Vector2
```
When you need additional control, implement `save_to_dict` and `load_from_dict`:
```gdscript
extends RigidBody2D
func save_to_dict(s: SaveKitSerializer) -> Dictionary:
return {
"transform": s.encode_var(transform),
"linear_velocity": s.encode_var(linear_velocity),
}
func load_from_dict(s: SaveKitDeserializer, data: Dictionary) -> void:
var t: Transform2D = s.decode_var(data["transform"], TYPE_TRANSFORM2D)
PhysicsServer2D.body_set_state(get_rid(), PhysicsServer2D.BODY_STATE_TRANSFORM, t)
linear_velocity = s.decode_var(data["linear_velocity"], TYPE_VECTOR2)
```
You can also mix the two approaches by calling `serializer.default_save_to_dict()` and `deserializer.default_load_from_dict()` from your implementation.
### Node instantiation
If a saved node isn't in the scene tree at load time, SaveKit will instantiate it from the `scene_file_path` it was saved with and parent it where it belongs. Conversely, nodes in the `saveable` group that *aren't* in the save data are freed, so the scene tree always matches the save file after loading.
## Saving resources
For resources that represent persisted data—e.g., inventories, quest state, per-entity stat blocks—extend `SaveKitResource` rather than plain `Resource`:
```gdscript
class_name Inventory
extends SaveKitResource
@export var gold: int = 0
@export var items: Array[Item]
```
Any `SaveKitResource` referenced from a saved node is serialized automatically, and deduplicated. Like nodes, `SaveKitResource` uses reflection over exported properties by default, but you can always implement `save_to_dict` and `load_from_dict` for custom behavior.
Note that plain `Resource` references (textures, scenes, and other things baked into the PCK) are saved as path/UID references. SaveKit will only ever load such resources from within the `res://` filesystem, avoiding the risk of code injection from user-provided resource files.
## Lifecycle hooks
There are a variety of signals and methods to hook into the saving and loading process—`before_save`, `after_save`, `before_load`, `after_load`, etc. See the `SaveManager` API documentation for more details.
## Save file formats
SaveKit includes two built-in file formats:
- **JSON** (`json_serializer.gd`, `json_deserializer.gd`) — human-readable, easy to diff and debug.
- **Binary** (`binary_serializer.gd`, `binary_deserializer.gd`) — compact, obfuscated.
JSON, the default, is recommended in most cases. File size is rarely a concern, and making saves human-readable is more friendly to your players.
You can also implement a custom file format by extending `SaveKitSerializer` and `SaveKitDeserializer` and implementing the abstract methods.
Assign `SaveManager.serializer_script` and `SaveManager.deserializer_script` to switch between formats or use your own:
```gdscript
SaveManager.serializer_script = preload("res://addons/savekit/binary_serializer.gd")
SaveManager.deserializer_script = preload("res://addons/savekit/binary_deserializer.gd")
SaveManager.save_file_extension = ".sav"
```
## Learn more
The [included demo](demo/) has a small interactive scene that is fully saveable, and includes a live view into the JSON file format.
All public classes (`SaveManager`, `SaveKitSerializer`, `SaveKitDeserializer`, `SaveKitResource`) have documentation comments that work with Godot's built-in help. Browse them from the editor for the full API reference.
## License
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.

View File

@@ -0,0 +1,188 @@
extends "deserializer.gd"
## Deserializes save data from binary format.
const BinarySerializer := preload("binary_serializer.gd")
var _node_deserialization_stack: Array[NodePath] = []
var _saved_nodes: Dictionary[NodePath, Dictionary]
var _saved_resources_by_id: Dictionary[int, Dictionary]
var _loaded_resources_by_id: Dictionary[int, SaveKitResource]
func prepare_load_from_memory(data: PackedByteArray) -> bool:
if data.size() < BinarySerializer._FILE_HEADER_SIZE:
push_error("Save data is too small to contain required header information")
return false
var version := data.decode_u32(BinarySerializer._SERIALIZATION_VERSION_U32_OFFSET)
if version != BinarySerializer._SERIALIZATION_VERSION:
push_error("Unsupported save data version: ", version)
return false
var saved_nodes_length := data.decode_var_size(BinarySerializer._FILE_HEADER_SIZE)
_saved_nodes.assign(data.decode_var(BinarySerializer._FILE_HEADER_SIZE) as Dictionary)
_saved_resources_by_id.assign(data.decode_var(BinarySerializer._FILE_HEADER_SIZE + saved_nodes_length) as Dictionary)
_node_deserialization_stack.assign(_saved_nodes.keys())
sort_node_paths_in_load_order(_node_deserialization_stack)
return true
func decode_var(value: Variant, expected_type: Variant.Type, expected_class_name: StringName = &"") -> Variant:
match expected_type:
TYPE_CALLABLE:
push_error("Cannot deserialize callable value ", value)
return null
TYPE_OBJECT:
var buffer := value as PackedByteArray
if not buffer:
push_warning("Expected a PackedByteArray when deserializing an object, got: ", value)
return null
var type_tag := buffer.get(0)
match type_tag:
BinarySerializer._ENCODED_RESOURCE_REFERENCE_TAG:
return _decode_resource_reference(buffer, expected_class_name)
BinarySerializer._ENCODED_NODE_REFERENCE_TAG:
var path_length := buffer.decode_u32(BinarySerializer._ENCODED_NODE_REFERENCE_PATH_LENGTH_U32_OFFSET)
if path_length <= 0:
push_warning("Invalid path length ", path_length, " found when deserializing a node reference")
return null
var path_buffer := buffer.slice(BinarySerializer._ENCODED_NODE_REFERENCE_DATA_OFFSET, BinarySerializer._ENCODED_NODE_REFERENCE_DATA_OFFSET + path_length)
var node_path := NodePath(path_buffer.get_string_from_utf8())
return _decode_node_reference(node_path)
BinarySerializer._SAVED_RESOURCE_REFERENCE_TAG:
return _load_resource(buffer)
_:
push_warning("Unknown type tag ", type_tag, " found when deserializing an object")
return null
TYPE_ARRAY:
var array := value as Array
if not array:
push_warning("Expected an array when deserializing an array, got: ", value)
return null
return array.map(_decode_var_with_type_info)
TYPE_DICTIONARY:
var dictionary := value as Dictionary
if not dictionary:
push_warning("Expected a dictionary when deserializing a dictionary, got: ", value)
return null
var decoded_dictionary: Dictionary
for key: Variant in dictionary:
var decoded_key: Variant = _decode_var_with_type_info(key)
var decoded_value: Variant = _decode_var_with_type_info(dictionary[key])
decoded_dictionary[decoded_key] = decoded_value
return decoded_dictionary
_:
return value
func _decode_var_with_type_info(value: Variant) -> Variant:
var buffer := value as PackedByteArray
if not buffer:
push_warning("Expected a PackedByteArray when decoding a typed value, got: ", value)
return null
var type := buffer.decode_u8(BinarySerializer._ENCODED_TYPED_VALUE_TYPE_U8_OFFSET) as Variant.Type
var classname_length := buffer.decode_u16(BinarySerializer._ENCODED_TYPED_VALUE_CLASS_NAME_LENGTH_U16_OFFSET)
var classname: StringName = ""
if classname_length:
var classname_buffer := buffer.slice(BinarySerializer._ENCODED_TYPED_VALUE_DATA_OFFSET, BinarySerializer._ENCODED_TYPED_VALUE_DATA_OFFSET + classname_length)
classname = StringName(classname_buffer.get_string_from_utf8())
var encoded_value := buffer.slice(BinarySerializer._ENCODED_TYPED_VALUE_DATA_OFFSET + classname_length)
return decode_var(bytes_to_var(encoded_value), type, classname)
func _decode_node_reference(node_path: NodePath) -> Node:
# To ensure we can convert this node path into a valid node reference, we need to effectively "preload" the target node and all of its ancestors.
# This process is similar to load_node(), but circumventing the normal order and without actually loading data into the nodes yet.
if node_path.get_name_count() > 1:
var parent_node := _decode_node_reference(node_path.slice(0, -1))
if not parent_node:
return null
var save_dict: Dictionary = _saved_nodes.get(node_path, {})
var scene_file_path: String = save_dict.get(BinarySerializer._NODE_SCENE_FILE_PATH_KEY, "")
return find_or_instantiate_node(node_path, scene_file_path, false)
## Decodes a reference to a resource, loading it by UID or path as appropriate.
##
## Note: [param expected_class_name] should refer to a class that exists within [ClassDB] (i.e., built-in or GDExtension classes). It should [i]not[/i] contain the name of a script-defined [code]class_name[/code].
func _decode_resource_reference(buffer: PackedByteArray, expected_class_name: StringName) -> Resource:
var path_length := buffer.decode_u32(BinarySerializer._ENCODED_RESOURCE_REFERENCE_PATH_LENGTH_U32_OFFSET)
var uid_length := buffer.decode_u32(BinarySerializer._ENCODED_RESOURCE_REFERENCE_UID_LENGTH_U32_OFFSET)
var path_buffer := buffer.slice(BinarySerializer._ENCODED_RESOURCE_REFERENCE_DATA_OFFSET, BinarySerializer._ENCODED_RESOURCE_REFERENCE_DATA_OFFSET + path_length)
var uid_buffer: PackedByteArray
if uid_length:
uid_buffer = buffer.slice(BinarySerializer._ENCODED_RESOURCE_REFERENCE_DATA_OFFSET + path_length, BinarySerializer._ENCODED_RESOURCE_REFERENCE_DATA_OFFSET + path_length + uid_length)
var resource_path := path_buffer.get_string_from_utf8()
if uid_buffer:
var id := ResourceUID.text_to_id(uid_buffer.get_string_from_utf8())
if ResourceUID.has_id(id):
resource_path = ResourceUID.get_id_path(id)
var allowed_extensions := ResourceLoader.get_recognized_extensions_for_type(expected_class_name if expected_class_name else &"Resource")
return ResourceUtils.safe_load_resource(resource_path, allowed_extensions)
## Returns how many nodes remain to be loaded from the save data. This can be used to determine loading progress.
func get_remaining_node_count() -> int:
return _node_deserialization_stack.size()
func is_finished() -> bool:
return not _node_deserialization_stack
func load_node() -> Node:
var node_path: NodePath = _node_deserialization_stack.pop_back()
if not node_path:
return null
var save_dict: Dictionary = _saved_nodes[node_path]
_saved_nodes.erase(node_path)
var scene_file_path: String = save_dict.get(BinarySerializer._NODE_SCENE_FILE_PATH_KEY, "")
save_dict.erase(BinarySerializer._NODE_SCENE_FILE_PATH_KEY)
var node := find_or_instantiate_node(node_path, scene_file_path, true)
if node:
load_node_from_dict(node, save_dict)
return node
## Loads a [SaveKitResource] from the save data. If the resource has already been loaded, the existing instance will be returned.
func _load_resource(buffer: PackedByteArray) -> SaveKitResource:
var resource_id := buffer.decode_u64(BinarySerializer._SAVED_RESOURCE_REFERENCE_ID_U64_OFFSET)
var resource: SaveKitResource = _loaded_resources_by_id.get(resource_id)
if not resource:
var save_dict: Dictionary = _saved_resources_by_id.get(resource_id, {})
if not save_dict:
push_error("No saved resource found with ID ", resource_id)
return null
_saved_resources_by_id.erase(resource_id)
var script: Script = _decode_resource_reference(save_dict.get(BinarySerializer._SAVED_RESOURCE_SCRIPT_KEY) as PackedByteArray, "Script")
if not script:
push_error("Failed to decode script for resource with ID ", resource_id, ", cannot load resource")
return null
save_dict.erase(BinarySerializer._SAVED_RESOURCE_SCRIPT_KEY)
@warning_ignore("unsafe_method_access")
resource = script.new()
resource.load_from_dict(self , save_dict)
_loaded_resources_by_id[resource_id] = resource
return resource

View File

@@ -0,0 +1 @@
uid://n8c08uy53j8x

View File

@@ -0,0 +1,184 @@
extends "serializer.gd"
## Serializes save data into a binary format.
var _finalized: bool = false
var _saved_nodes: Dictionary[NodePath, Dictionary]
var _saved_resources_by_id: Dictionary[int, Dictionary]
# File header layout
enum {
_SERIALIZATION_VERSION_U32_OFFSET = 0,
_FILE_HEADER_SIZE = 4,
}
# u8 tags, used to differentiate types of Object
enum {
_ENCODED_RESOURCE_REFERENCE_TAG = 1,
_ENCODED_NODE_REFERENCE_TAG = 2,
_SAVED_RESOURCE_REFERENCE_TAG = 3,
}
# Resource reference layout
enum {
_ENCODED_RESOURCE_REFERENCE_PATH_LENGTH_U32_OFFSET = 1,
_ENCODED_RESOURCE_REFERENCE_UID_LENGTH_U32_OFFSET = 5,
_ENCODED_RESOURCE_REFERENCE_DATA_OFFSET = 9,
}
# Node reference layout
enum {
_ENCODED_NODE_REFERENCE_PATH_LENGTH_U32_OFFSET = 1,
_ENCODED_NODE_REFERENCE_DATA_OFFSET = 5,
}
# Saved resource reference layout
enum {
_SAVED_RESOURCE_REFERENCE_ID_U64_OFFSET = 1,
_SAVED_RESOURCE_REFERENCE_SIZE = 9,
}
enum {
_ENCODED_TYPED_VALUE_TYPE_U8_OFFSET = 0,
_ENCODED_TYPED_VALUE_CLASS_NAME_LENGTH_U16_OFFSET = 1,
_ENCODED_TYPED_VALUE_DATA_OFFSET = 3,
}
const _NODE_SCENE_FILE_PATH_KEY := 0xF17E
const _SAVED_RESOURCE_SCRIPT_KEY := 0xC0DE
const _SERIALIZATION_VERSION: int = 1
func _notification(what: int) -> void:
match what:
NOTIFICATION_PREDELETE:
if not _finalized and (_saved_nodes or _saved_resources_by_id):
push_warning("Binary serializer was not finalized before it was freed. Data is not actually being saved!")
func encode_var(value: Variant) -> Variant:
match typeof(value):
TYPE_CALLABLE:
push_error("Cannot serialize callable value ", value)
return null
TYPE_OBJECT:
if value is SaveKitResource:
return save_resource(value as SaveKitResource)
elif value is Resource:
return encode_resource_reference(value as Resource)
elif value is Node:
return encode_node_reference(value as Node)
else:
push_warning("Cannot serialize non-Resource, non-Node object: ", value)
return null
TYPE_ARRAY:
var array: Array = value
return array.map(_encode_var_with_type_info)
TYPE_DICTIONARY:
var dictionary: Dictionary = value
var encoded_dictionary: Dictionary
for key: Variant in dictionary:
var encoded_key: Variant = _encode_var_with_type_info(key)
var encoded_value: Variant = _encode_var_with_type_info(dictionary[key])
encoded_dictionary[encoded_key] = encoded_value
return encoded_dictionary
_:
return value
func _encode_var_with_type_info(value: Variant) -> Variant:
var buffer: PackedByteArray
buffer.resize(_ENCODED_TYPED_VALUE_DATA_OFFSET)
buffer.encode_u8(_ENCODED_TYPED_VALUE_TYPE_U8_OFFSET, typeof(value))
if value is Object:
var classname_buffer := (value as Object).get_class().to_utf8_buffer()
buffer.encode_u16(_ENCODED_TYPED_VALUE_CLASS_NAME_LENGTH_U16_OFFSET, classname_buffer.size())
buffer.append_array(classname_buffer)
buffer.append_array(var_to_bytes(encode_var(value)))
return buffer
func save_node(node: Node) -> void:
var node_path := save_path_for_node(node)
var save_dict := save_node_to_dict(node)
if node.scene_file_path:
save_dict[_NODE_SCENE_FILE_PATH_KEY] = node.scene_file_path
# TODO: else, save script reference for programmatic instantiation?
_saved_nodes[node_path] = save_dict
func finalize_save_in_memory() -> PackedByteArray:
var buffer: PackedByteArray
buffer.resize(_FILE_HEADER_SIZE)
buffer.encode_u32(_SERIALIZATION_VERSION_U32_OFFSET, _SERIALIZATION_VERSION)
buffer.append_array(var_to_bytes(_saved_nodes))
buffer.append_array(var_to_bytes(_saved_resources_by_id))
_finalized = true
return buffer
## Encodes a reference to a resource that can be loaded from the [code]res://[/code] filesystem later.
##
## This does not serialize any properties or other data from the resource, only enough information to load it later. This is appropriate for resources that are expected to be shared across saves and exist independently of the save data (e.g., sprites, sound effects, static game data).
func encode_resource_reference(resource: Resource) -> Variant:
if not resource.resource_path:
push_warning("Cannot encode reference to resource ", resource, " as it does not have a resource_path")
return null
var buffer: PackedByteArray
buffer.resize(_ENCODED_RESOURCE_REFERENCE_DATA_OFFSET)
buffer.set(0, _ENCODED_RESOURCE_REFERENCE_TAG)
var path_buffer := resource.resource_path.to_utf8_buffer()
buffer.encode_u32(_ENCODED_RESOURCE_REFERENCE_PATH_LENGTH_U32_OFFSET, path_buffer.size())
buffer.append_array(path_buffer)
var uid := ResourceUID.path_to_uid(resource.resource_path)
if uid == resource.resource_path:
buffer.encode_u32(_ENCODED_RESOURCE_REFERENCE_UID_LENGTH_U32_OFFSET, 0)
else:
var uid_buffer := uid.to_utf8_buffer()
buffer.encode_u32(_ENCODED_RESOURCE_REFERENCE_UID_LENGTH_U32_OFFSET, uid_buffer.size())
buffer.append_array(uid_buffer)
return buffer
## Encodes a reference to a node in the scene tree.
##
## This does not serialize any properties or other data from the node, only enough information to find it in the scene tree later. This is appropriate for cross-references [i]between[/i] nodes, or references [i]from[/i] resources [i]to[/i] nodes. Node data itself will be serialized separately using [method save_node].
func encode_node_reference(node: Node) -> Variant:
var buffer: PackedByteArray
buffer.resize(_ENCODED_NODE_REFERENCE_DATA_OFFSET)
buffer.set(0, _ENCODED_NODE_REFERENCE_TAG)
var path_buffer := str(save_path_for_node(node)).to_utf8_buffer()
buffer.encode_u32(_ENCODED_NODE_REFERENCE_PATH_LENGTH_U32_OFFSET, path_buffer.size())
buffer.append_array(path_buffer)
return buffer
## Adds [param resource] to the save data, serializing its properties according to [method SaveKitResource.save_to_dict]. Returns a reference that can be used to link to this resource from other saved data.
##
## If [param resource] has already been saved, it will not be saved again; instead, a reference to the previously saved data will be returned.
func save_resource(resource: SaveKitResource) -> Variant:
var instance_id := resource.get_instance_id()
if instance_id not in _saved_resources_by_id:
# Register before encoding, to avoid infinite recursion in case of circular references
_saved_resources_by_id[instance_id] = {}
var save_dict := resource.save_to_dict(self )
var script: Script = resource.get_script()
save_dict[_SAVED_RESOURCE_SCRIPT_KEY] = encode_resource_reference(script)
_saved_resources_by_id[instance_id] = save_dict
var buffer: PackedByteArray
buffer.resize(_SAVED_RESOURCE_REFERENCE_SIZE)
buffer.set(0, _SAVED_RESOURCE_REFERENCE_TAG)
buffer.encode_u64(_SAVED_RESOURCE_REFERENCE_ID_U64_OFFSET, instance_id)
return buffer

View File

@@ -0,0 +1 @@
uid://b1n51mkjkbyb0

View File

@@ -0,0 +1,145 @@
@abstract
class_name SaveKitDeserializer
extends RefCounted
## Base class for deserializers that load saved data into nodes.
##
## Subclasses can implement custom save data formats by providing implementations for the abstract methods.
## The scene tree for finding and adding nodes during loading. This must be set before loading any nodes.
var scene_tree: SceneTree
## The name for a method that Nodes can implement to customize how they are loaded from a dictionary. The method should have the following signature:
##
## [codeblock]
## func load_from_dict(deserializer: Deserializer, data: Dictionary) -> void
## [/codeblock]
##
## Within this method, nodes can use the deserializer's [method decode_var] method to decode values from the provided dictionary.
var load_from_dict_method: StringName = &"load_from_dict"
## A scene tree group for finding and adding nodes during loading.
##
## Nodes to be loaded must be in this group to be found, and nodes added to the tree during loading will be added to this group.
var saveable_node_group: StringName = &"saveable"
## Emitted when a new node is added to the scene tree during loading.
signal node_created(node: Node)
const ResourceUtils := preload("resource_utils.gd")
## Prepares the deserializer with the given save data. Returns false if the save data is invalid.
@abstract
func prepare_load_from_memory(data: PackedByteArray) -> bool
## Prepares the deserializer by loading save data from the given file path. Returns an error if loading failed.
func prepare_load_from_file(path: String) -> Error:
var bytes := FileAccess.get_file_as_bytes(path)
if not bytes:
return FileAccess.get_open_error()
if not prepare_load_from_memory(bytes):
return ERR_INVALID_DATA
return OK
## Decodes a saved value into a runtime value that can be set on a Node or Resource.
##
## Callers must provide [param expected_type] (and [param expected_class_name], if applicable) to guide the deserializer in how to decode the value. See [method default_load_from_dict] for an example.
##
## Note: [param expected_class_name] should refer to a class that exists within [ClassDB] (i.e., built-in or GDExtension classes). It should [i]not[/i] contain the name of a script-defined [code]class_name[/code].
@abstract
func decode_var(value: Variant, expected_type: Variant.Type, expected_class_name: StringName = &"") -> Variant
## Returns whether deserialization has finished (i.e., all nodes have been loaded).
@abstract
func is_finished() -> bool
## Loads the next node from the save data, returning the loaded node or null if an error occurred. The node will be added to the scene tree automatically, if not already present.
##
## Internally, this will call [method find_or_instantiate_node] and [method load_node_from_dict].
##
## Note that a null return value does not necessarily mean that deserialization has finished. Call [method is_finished] to check.
@abstract
func load_node() -> Node
## Loads data into [param node] from the given dictionary.
func load_node_from_dict(node: Node, dict: Dictionary) -> void:
if not node.has_method(load_from_dict_method):
return default_load_from_dict(node, dict)
node.call(load_from_dict_method, self , dict)
## Implements the default behavior for [method load_node_from_dict], for the case where the node does not implement a custom [member load_from_dict_method]. This will set the node's properties to the decoded values of [param data].
##
## This method can also be called from a custom [member load_from_dict_method] implementation, to load some properties automatically and implement custom behavior for others. [param only_properties] can be used to specify a subset of properties to load from [param data].
func default_load_from_dict(node: Node, data: Dictionary, only_properties: PackedStringArray = PackedStringArray()) -> void:
var properties_by_name: Dictionary[String, Dictionary]
for property in node.get_property_list():
var name: String = property["name"]
properties_by_name[name] = property
for name: String in data:
if only_properties and name not in only_properties:
continue
var property: Dictionary = properties_by_name.get(name, {})
if not property:
push_warning("Cannot load saved property ", name, " not currently found on node ", node.get_path())
continue
var usage_flags: PropertyUsageFlags = property["usage"]
if usage_flags & PROPERTY_USAGE_STORAGE == 0:
push_warning("Not loading property ", name, " with storage disabled")
continue
var encoded_value: Variant = data[name]
var type: Variant.Type = property["type"]
var classname: StringName = property.get("class_name", &"")
var decoded_value: Variant = decode_var(encoded_value, type, classname)
node.set(name, decoded_value)
## Finds a node at [param node_path] in the scene tree, or instantiates it from [param scene_file_path] and adds it to the scene tree if it did not already exist. Returns null if the node could not be found or instantiated.
##
## The node must be a member of [member saveable_node_group] to be found successfully.
func find_or_instantiate_node(node_path: NodePath, scene_file_path: String, fail_on_missing_group: bool = true) -> Node:
if not scene_tree:
push_error("scene_tree must be set on deserializer to find or instantiate nodes")
return null
var node := scene_tree.root.get_node_or_null(node_path)
if not node:
var parent_path := node_path.slice(0, -1)
var parent_node := scene_tree.root.get_node_or_null(parent_path)
if not parent_node:
push_warning("Could not find parent ", parent_path, " for node ", node_path, " while loading, adding to root")
parent_node = scene_tree.root
if not scene_file_path:
# TODO: Instantiate via script reference instead
push_error("Cannot instantiate node ", node_path, " that is missing from the scene tree, as it has no scene file path")
return null
var scene_extensions := ResourceLoader.get_recognized_extensions_for_type("PackedScene")
var scene: PackedScene = ResourceUtils.safe_load_resource(scene_file_path, scene_extensions)
if not scene:
push_error("Failed to load scene for node ", node_path, " from path ", scene_file_path)
return null
node = scene.instantiate()
node.name = node_path.get_name(node_path.get_name_count() - 1)
node.add_to_group(saveable_node_group)
parent_node.add_child(node)
node_created.emit(node)
elif fail_on_missing_group and not node.is_in_group(saveable_node_group):
push_warning("Node ", node_path, " is not in group \"", saveable_node_group, "\", refusing to load it")
return null
return node
func sort_node_paths_in_load_order(r_node_paths: Array[NodePath]) -> void:
# Load nodes in order of depth, to ensure parents are loaded before children.
# We'll use this to instantiate any missing nodes along the way.
r_node_paths.sort_custom(func(a: NodePath, b: NodePath) -> bool:
# We're creating a stack, so sort nodes to load FIRST at the end
return a.get_name_count() > b.get_name_count())

View File

@@ -0,0 +1 @@
uid://c3cifjyaim8g4

View File

@@ -0,0 +1,181 @@
extends "deserializer.gd"
## Deserializes save data from JSON format.
const JSONSerializer := preload("json_serializer.gd")
var _node_deserialization_stack: Array[NodePath] = []
var _saved_nodes: Dictionary[NodePath, Dictionary]
var _saved_resources_by_id: Dictionary[String, Dictionary]
var _loaded_resources_by_id: Dictionary[String, SaveKitResource]
func prepare_load_from_memory(data: PackedByteArray) -> bool:
var json_string := data.get_string_from_utf8()
if not json_string:
push_error("Failed to decode UTF-8 from save data")
return false
var save_dict := JSON.parse_string(json_string) as Dictionary
if not save_dict:
push_error("Failed to parse JSON from save data")
return false
var version: int = save_dict.get(JSONSerializer._SERIALIZATION_VERSION_KEY, 0)
if version != JSONSerializer._SERIALIZATION_VERSION:
push_error("Unsupported save data version: ", version)
return false
_saved_nodes.assign(save_dict.get(JSONSerializer._NODES_KEY, {}) as Dictionary)
_saved_resources_by_id.assign(save_dict.get(JSONSerializer._RESOURCES_KEY, {}) as Dictionary)
_node_deserialization_stack.assign(_saved_nodes.keys())
sort_node_paths_in_load_order(_node_deserialization_stack)
return true
func _notification(what: int) -> void:
match what:
NOTIFICATION_PREDELETE:
if _node_deserialization_stack:
push_warning("JSON deserializer freed before all nodes were loaded. Remaining nodes that were not loaded: ", _saved_nodes.keys())
elif _saved_resources_by_id:
push_warning("Unexpected dangling saved resources that were never loaded: ", _saved_resources_by_id)
func decode_var(value: Variant, expected_type: Variant.Type, expected_class_name: StringName = &"") -> Variant:
match expected_type:
TYPE_RID, TYPE_CALLABLE, TYPE_SIGNAL:
push_warning("Cannot deserialize value of type ", type_string(expected_type), ": ", value)
return null
TYPE_OBJECT:
var value_dict := value as Dictionary
if not value_dict:
push_warning("Expected a dictionary when deserializing an object, got: ", value)
return null
var saved_resource_id: String = value_dict.get(JSONSerializer._SAVED_RESOURCE_ID_KEY, "")
if saved_resource_id:
return _load_resource(saved_resource_id)
var encoded_resource_reference_path: String = value_dict.get(JSONSerializer._ENCODED_RESOURCE_REFERENCE_PATH_KEY, "")
if encoded_resource_reference_path:
var encoded_resource_reference_uid: String = value_dict.get(JSONSerializer._ENCODED_RESOURCE_REFERENCE_UID_KEY, "")
return _decode_resource_reference(encoded_resource_reference_path, encoded_resource_reference_uid, expected_class_name)
var encoded_node_reference: String = value_dict.get(JSONSerializer._ENCODED_NODE_REFERENCE_KEY, "")
if encoded_node_reference:
return _decode_node_reference(NodePath(encoded_node_reference))
push_warning("Cannot deserialize object from dictionary: ", value_dict)
return null
TYPE_ARRAY:
var array := value as Array
if not array:
push_warning("Expected an array when deserializing an array, got: ", value)
return null
return array.map(_decode_var_with_type_info)
TYPE_DICTIONARY:
var dictionary := value as Dictionary
if not dictionary:
push_warning("Expected a dictionary when deserializing a dictionary, got: ", value)
return null
var decoded_dictionary: Dictionary
for key: String in dictionary:
var parsed_key: Variant = JSON.parse_string(key)
var decoded_key: Variant = _decode_var_with_type_info(parsed_key)
var decoded_value: Variant = _decode_var_with_type_info(dictionary[key])
decoded_dictionary[decoded_key] = decoded_value
return decoded_dictionary
_:
return JSON.to_native(value)
func _decode_var_with_type_info(value: Variant) -> Variant:
var value_dict := value as Dictionary
if not value_dict:
push_warning("Expected a dictionary when decoding a typed value, got: ", value)
return null
var type: Variant.Type = value_dict.get(JSONSerializer._ENCODED_TYPED_VALUE_TYPE_KEY, TYPE_NIL)
var classname: StringName = value_dict.get(JSONSerializer._ENCODED_TYPED_VALUE_CLASS_NAME_KEY, &"")
var encoded_value: Variant = value_dict.get(JSONSerializer._ENCODED_TYPED_VALUE_KEY, null)
return decode_var(encoded_value, type, classname)
## Decodes a reference to a node expected in the scene tree at the given path.
##
## If the node does not already exist in the scene tree, but does exist in the save data, it will be instantiated and added to the tree. Returns null if the node cannot be found or instantiated.
func _decode_node_reference(node_path: NodePath) -> Node:
# To ensure we can convert this node path into a valid node reference, we need to effectively "preload" the target node and all of its ancestors.
# This process is similar to load_node(), but circumventing the normal order and without actually loading data into the nodes yet.
if node_path.get_name_count() > 1:
var parent_node := _decode_node_reference(node_path.slice(0, -1))
if not parent_node:
return null
var save_dict: Dictionary = _saved_nodes.get(node_path, {})
var scene_file_path: String = save_dict.get(JSONSerializer._NODE_SCENE_FILE_PATH_KEY, "")
return find_or_instantiate_node(node_path, scene_file_path, false)
## Decodes a reference to a resource, loading it by UID or path as appropriate.
##
## Note: [param expected_class_name] should refer to a class that exists within [ClassDB] (i.e., built-in or GDExtension classes). It should [i]not[/i] contain the name of a script-defined [code]class_name[/code].
func _decode_resource_reference(resource_path: String, resource_uid: String = "", expected_class_name: StringName = &"") -> Resource:
if resource_uid:
var id := ResourceUID.text_to_id(resource_uid)
if ResourceUID.has_id(id):
resource_path = ResourceUID.get_id_path(id)
var allowed_extensions := ResourceLoader.get_recognized_extensions_for_type(expected_class_name if expected_class_name else &"Resource")
return ResourceUtils.safe_load_resource(resource_path, allowed_extensions)
## Returns how many nodes remain to be loaded from the save data. This can be used to determine loading progress.
func get_remaining_node_count() -> int:
return _node_deserialization_stack.size()
func is_finished() -> bool:
return not _node_deserialization_stack
func load_node() -> Node:
var node_path: NodePath = _node_deserialization_stack.pop_back()
if not node_path:
return null
var save_dict: Dictionary = _saved_nodes[node_path]
_saved_nodes.erase(node_path)
var scene_file_path: String = save_dict.get(JSONSerializer._NODE_SCENE_FILE_PATH_KEY, "")
save_dict.erase(JSONSerializer._NODE_SCENE_FILE_PATH_KEY)
var node := find_or_instantiate_node(node_path, scene_file_path, true)
if node:
load_node_from_dict(node, save_dict)
return node
## Loads a [SaveKitResource] from the save data. If the resource has already been loaded, the existing instance will be returned.
func _load_resource(id: String) -> SaveKitResource:
var resource: SaveKitResource = _loaded_resources_by_id.get(id)
if not resource:
var save_dict: Dictionary = _saved_resources_by_id.get(id, {})
if not save_dict:
push_error("No saved resource found with ID ", id)
return null
_saved_resources_by_id.erase(id)
var script: Script = decode_var(save_dict.get(JSONSerializer._SAVED_RESOURCE_SCRIPT_KEY, {}), TYPE_OBJECT, "Script")
if not script:
push_error("Failed to decode script for resource with ID ", id, ", cannot load resource")
return null
save_dict.erase(JSONSerializer._SAVED_RESOURCE_SCRIPT_KEY)
@warning_ignore("unsafe_method_access")
resource = script.new()
resource.load_from_dict(self , save_dict)
_loaded_resources_by_id[id] = resource
return resource

View File

@@ -0,0 +1 @@
uid://bphgneyc5sa15

View File

@@ -0,0 +1,164 @@
extends "serializer.gd"
## Serializes save data into JSON format.
## Passed to [method JSON.stringify], this controls if and how something is indented in the serialized JSON. This string will be used where there should be an indent in the output.
##
## For example, [code]" "[/code] will indent with two spaces, and [code]"\t"[/code] will indent with tabs. Set to [code]""[/code] to not prettify at all.
##
## Enabling this is helpful for creating human-readable JSON.
var indent: String = "\t"
## Passed to [method JSON.stringify], this controls whether keys in serialized JSON dictionaries are sorted alphabetically. Enabling this can be helpful for creating human-readable JSON, but may have a performance cost.
var sort_keys: bool = false
## Passed to [method JSON.stringify], this controls whether floating-point numbers are stringified including all unreliable digits. Enabling this guarantees exact decoding of floats, but may increase the size of the JSON output.
var full_precision: bool = false
var _finalized: bool = false
var _saved_nodes: Dictionary[NodePath, Dictionary]
var _saved_resources_by_id: Dictionary[String, Dictionary]
const _NODE_SCENE_FILE_PATH_KEY: String = "scene_file_path"
const _NODES_KEY := "nodes"
const _RESOURCES_KEY := "resources"
const _ENCODED_NODE_REFERENCE_KEY := "node"
const _ENCODED_RESOURCE_REFERENCE_PATH_KEY := "path"
const _ENCODED_RESOURCE_REFERENCE_UID_KEY := "uid"
const _ENCODED_TYPED_VALUE_KEY := "v"
const _ENCODED_TYPED_VALUE_TYPE_KEY := "t"
const _ENCODED_TYPED_VALUE_CLASS_NAME_KEY := "c"
const _SAVED_RESOURCE_ID_KEY := "res"
const _SAVED_RESOURCE_SCRIPT_KEY := "script"
const _SERIALIZATION_VERSION_KEY: String = "version"
const _SERIALIZATION_VERSION: int = 1
func _notification(what: int) -> void:
match what:
NOTIFICATION_PREDELETE:
if not _finalized and (_saved_nodes or _saved_resources_by_id):
push_warning("JSON serializer was not finalized before it was freed. Data is not actually being saved!")
func finalize_save_in_memory() -> PackedByteArray:
var save_dict := {
_SERIALIZATION_VERSION_KEY: _SERIALIZATION_VERSION,
_NODES_KEY: _saved_nodes,
}
if _saved_resources_by_id:
save_dict[_RESOURCES_KEY] = _saved_resources_by_id
var json_string := JSON.stringify(save_dict, indent, sort_keys, full_precision)
_finalized = true
return json_string.to_utf8_buffer()
func encode_var(value: Variant) -> Variant:
match typeof(value):
TYPE_RID, TYPE_CALLABLE, TYPE_SIGNAL:
push_warning("Cannot serialize value of type ", type_string(typeof(value)), ": ", value)
return null
TYPE_OBJECT:
if value is SaveKitResource:
return save_resource(value as SaveKitResource)
elif value is Resource:
return encode_resource_reference(value as Resource)
elif value is Node:
return encode_node_reference(value as Node)
else:
push_warning("Cannot serialize non-Resource, non-Node object: ", value)
return null
TYPE_ARRAY:
var array: Array = value
return array.map(_encode_var_with_type_info)
TYPE_DICTIONARY:
var dictionary: Dictionary = value
var encoded_dictionary: Dictionary[String, Variant]
for key: Variant in dictionary:
var encoded_key: Variant = _encode_var_with_type_info(key)
var encoded_value: Variant = _encode_var_with_type_info(dictionary[key])
# JSON keys must be strings, so (wastefully) recursively encode them.
# The alternative would be to encode all object types into strings all of the time, which is wasteful in a different way.
var stringified_key := JSON.stringify(encoded_key, "", false)
encoded_dictionary[stringified_key] = encoded_value
return encoded_dictionary
_:
return JSON.from_native(value)
func _encode_var_with_type_info(value: Variant) -> Variant:
var encoded := {
_ENCODED_TYPED_VALUE_KEY: encode_var(value),
_ENCODED_TYPED_VALUE_TYPE_KEY: typeof(value),
}
if value is Object:
encoded[_ENCODED_TYPED_VALUE_CLASS_NAME_KEY] = (value as Object).get_class()
return encoded
## Encodes a reference to a resource that can be loaded from the [code]res://[/code] filesystem later.
##
## This does not serialize any properties or other data from the resource, only enough information to load it later. This is appropriate for resources that are expected to be shared across saves and exist independently of the save data (e.g., sprites, sound effects, static game data).
func encode_resource_reference(resource: Resource) -> Variant:
if not resource.resource_path:
push_warning("Cannot encode reference to resource ", resource, " as it does not have a resource_path")
return null
var uid := ResourceUID.path_to_uid(resource.resource_path)
if uid == resource.resource_path:
return {
_ENCODED_RESOURCE_REFERENCE_PATH_KEY: resource.resource_path,
}
else:
return {
_ENCODED_RESOURCE_REFERENCE_UID_KEY: uid,
_ENCODED_RESOURCE_REFERENCE_PATH_KEY: resource.resource_path,
}
## Encodes a reference to a node in the scene tree.
##
## This does not serialize any properties or other data from the node, only enough information to find it in the scene tree later. This is appropriate for cross-references [i]between[/i] nodes, or references [i]from[/i] resources [i]to[/i] nodes. Node data itself will be serialized separately using [method save_node].
func encode_node_reference(node: Node) -> Variant:
return {
_ENCODED_NODE_REFERENCE_KEY: str(save_path_for_node(node)),
}
func save_node(node: Node) -> void:
var node_path := save_path_for_node(node)
if node_path in _saved_nodes:
push_warning("Node ", node_path, " has already been saved, overwriting")
var save_dict := save_node_to_dict(node)
if node.scene_file_path:
save_dict[_NODE_SCENE_FILE_PATH_KEY] = node.scene_file_path
# TODO: else, save script reference for programmatic instantiation?
_saved_nodes[node_path] = save_dict
## Adds [param resource] to the save data, serializing its properties according to [method SaveKitResource.save_to_dict]. Returns a reference that can be used to link to this resource from other saved data.
##
## If [param resource] has already been saved, it will not be saved again; instead, a reference to the previously saved data will be returned.
func save_resource(resource: SaveKitResource) -> Variant:
var instance_id := str(resource.get_instance_id())
if instance_id not in _saved_resources_by_id:
# Register a placeholder before encoding, to avoid infinite recursion in case of circular references
_saved_resources_by_id[instance_id] = {}
var save_dict := resource.save_to_dict(self )
var script: Script = resource.get_script()
save_dict[_SAVED_RESOURCE_SCRIPT_KEY] = encode_resource_reference(script)
_saved_resources_by_id[instance_id] = save_dict
return {
_SAVED_RESOURCE_ID_KEY: instance_id,
}

View File

@@ -0,0 +1 @@
uid://diyshoqvkrhpx

View File

@@ -0,0 +1,7 @@
[plugin]
name="SaveKit"
description="Addon to save and load games"
author="Fern Forest Games Ltd"
version="0.1"
script="plugin.gd"

19
addons/savekit/plugin.gd Normal file
View File

@@ -0,0 +1,19 @@
@tool
extends EditorPlugin
const SAVE_MANAGER_AUTOLOAD_NAME := "SaveManager"
const SAVE_MANAGER_PATH := "save_manager.gd"
func _enable_plugin() -> void:
add_autoload_singleton(SAVE_MANAGER_AUTOLOAD_NAME, SAVE_MANAGER_PATH)
func _disable_plugin() -> void:
remove_autoload_singleton(SAVE_MANAGER_AUTOLOAD_NAME)
func _enter_tree() -> void:
# Initialization of the plugin goes here.
pass
func _exit_tree() -> void:
# Clean-up of the plugin goes here.
pass

View File

@@ -0,0 +1 @@
uid://b6yaurqqkdt2u

View File

@@ -0,0 +1,54 @@
## Lists all exported properties on [param obj] (from its built-in class and/or script) that have a non-default value.
##
## Returns an array of dictionaries matching the format of [method Object.get_property_list], with an additional [code]value[/code] key containing the current value of the property.
static func get_storable_non_default_properties(obj: Object) -> Array[Dictionary]:
var script: Script = obj.get_script()
var script_property_default_values: Dictionary[String, Variant]
get_script_default_property_values(script, script_property_default_values)
var builtin_class := obj.get_class()
var builtin_class_property_default_values := get_builtin_class_default_property_values(builtin_class)
var property_list := obj.get_property_list()
var non_default_properties: Array[Dictionary]
for property in property_list:
var name: String = property["name"]
if name == "script":
# Don't try to save script references here
continue
var usage: PropertyUsageFlags = property["usage"]
if usage & PROPERTY_USAGE_STORAGE == 0:
continue
var value: Variant = obj.get(name)
# Don't save default values
if name in script_property_default_values and value == script_property_default_values[name]:
continue
if name in builtin_class_property_default_values and value == builtin_class_property_default_values[name]:
continue
property["value"] = value
non_default_properties.append(property)
return non_default_properties
## Populates [param r_default_property_values] with the default values for all properties defined in [param script], including properties inherited from base scripts.
static func get_script_default_property_values(script: Script, r_default_property_values: Dictionary[String, Variant]) -> void:
if not script:
return
for property in script.get_script_property_list():
var name: String = property["name"]
r_default_property_values[name] = script.get_property_default_value(name)
## Returns a dictionary of default property values for a built-in (or GDExtension) class, including properties inherited from base classes if [param include_ancestors] is true.
static func get_builtin_class_default_property_values(builtin_class: String, include_ancestors: bool = true) -> Dictionary[String, Variant]:
var default_property_values: Dictionary[String, Variant] = {}
for property in ClassDB.class_get_property_list(builtin_class, not include_ancestors):
var name: String = property["name"]
default_property_values[name] = ClassDB.class_get_property_default_value(builtin_class, name)
return default_property_values

View File

@@ -0,0 +1 @@
uid://deu2lfsa81noa

View File

@@ -0,0 +1,71 @@
@abstract
class_name SaveKitResource
extends Resource
## Base class for user-defined resources that can be saved and loaded.
##
## [code]SaveKitResource[/code]s are used instead of the base [Resource] class to clearly identify data that is meant for persistence in save files, versus resource data that is part of the game's PCK.
## Emitted whenever this resource is saved.
signal saved
## Emitted whenever this resource is loaded.
signal loaded
const ReflectionUtils := preload("reflection_utils.gd")
const Serializer := preload("serializer.gd")
const Deserializer := preload("deserializer.gd")
## Saves data for this resource into a dictionary, suitable for persisting. This will serialize all of the resource's exported properties that have a non-default value.
##
## This method can be overridden to implement custom saving behavior.
func save_to_dict(s: Serializer) -> Dictionary:
var script: Script = get_script()
var script_property_default_values: Dictionary[String, Variant]
ReflectionUtils.get_script_default_property_values(script, script_property_default_values)
var save_dict := {}
for property in script.get_script_property_list():
var name: String = property["name"]
var usage: PropertyUsageFlags = property["usage"]
if usage & PROPERTY_USAGE_STORAGE == 0:
continue
var value: Variant = get(name)
# Don't save default values
if name in script_property_default_values and value == script_property_default_values[name]:
continue
save_dict[name] = s.encode_var(value)
saved.emit()
return save_dict
## Loads data into this resource from the given dictionary. This will set the resource's properties to the decoded values of [param data].
##
## This method can be overridden to implement custom loading behavior.
func load_from_dict(s: Deserializer, data: Dictionary) -> void:
var properties_by_name: Dictionary[String, Dictionary]
for property: Dictionary in self.get_property_list():
properties_by_name[property.name] = property
for name: String in data:
if name not in properties_by_name:
push_warning("Cannot load saved property ", name, " not currently found on resource ", self )
continue
var property := properties_by_name[name]
var usage_flags: PropertyUsageFlags = property["usage"]
if usage_flags & PROPERTY_USAGE_STORAGE == 0:
push_warning("Not loading property ", name, " with storage disabled")
continue
var encoded_value: Variant = data[name]
var type: Variant.Type = property["type"]
var classname: StringName = property.get("class_name", &"")
var decoded_value: Variant = s.decode_var(encoded_value, type, classname)
set(name, decoded_value)
loaded.emit()
emit_changed()

View File

@@ -0,0 +1 @@
uid://daurtud8w7utj

View File

@@ -0,0 +1,13 @@
## Loads a resource from [param path], only if it points into the [code]res://[/code] filesystem and has an allowed extension. This ensures that only intended resources within the game's PCK can be loaded, and prevents loading of arbitrary files from the user's filesystem.
static func safe_load_resource(path: String, allowed_extensions: PackedStringArray) -> Resource:
path = path.simplify_path()
if not path.is_absolute_path() or not path.begins_with("res://"):
push_warning("Invalid resource path ", path)
return null
for extension in allowed_extensions:
if path.ends_with(".%s" % extension):
return load(path)
push_warning("Resource path ", path, " does not have an allowed extension (", allowed_extensions, ")")
return null

View File

@@ -0,0 +1 @@
uid://b4yrtgqmcib0v

View File

@@ -0,0 +1,42 @@
extends RefCounted
## A description of a save game file on disk. Objects of this type are validated and returned by the save manager, and should not be created manually.
## The components of the save name. On disk, these are used to create separate save game directories. In game, this could be used, for example, to differentiate individual games [i]as well as[/i] different save slots within those games (e.g., [code]"My Cool Game", "Autosave 1"[/code]).
##
## Since these names may come from user input, the components are sanitized and validated before being assigned to this property. The resulting text may not exactly match what the user entered.
var save_name_components: PackedStringArray
## The absolute path to the save game file on disk.
var absolute_path: String
## The last modified time of the save game file, represented as a Unix timestamp.
var modified_at_unix_time: int
## The last modified time of the save game file, as an ISO 8601 date and time string ([code]YYYY-MM-DDTHH:MM:SS[/code]).
var modified_at_datetime: String:
get:
return Time.get_datetime_string_from_unix_time(modified_at_unix_time)
set(value):
modified_at_unix_time = Time.get_unix_time_from_datetime_string(value)
## Sanitizes user-provided save name components; for example, by disallowing invalid characters and directory traversal with [code]..[/code].
##
## Returns the sanitized path, which is guaranteed to be relative, or an empty string if the sanitized result is invalid.
static func sanitize_save_name_components(components: PackedStringArray) -> String:
if not components:
return ""
var validated_components: PackedStringArray
validated_components.resize(components.size())
for i in components.size():
validated_components[i] = components[i].validate_filename()
var save_path := "/".join(validated_components).simplify_path().replace(".", "_")
if save_path.is_absolute_path():
push_error("Save name must not be an absolute path: ", save_path)
return ""
return save_path
func _to_string() -> String:
return "SaveGameFile(save_name_components=%s, absolute_path=\"%s\", modified_at=%s)" % [save_name_components, absolute_path, modified_at_datetime]

View File

@@ -0,0 +1 @@
uid://mt5re1s6mf8i

View File

@@ -0,0 +1,293 @@
extends Node
## Coordinates saving and loading, using a configurable serializer and deserializer. This is the main entry point for saving and loading the scene tree.
##
## By default, this is installed as an autoload singleton named [code]SaveManager[/code] when the plugin is enabled, but it can also be used as a regular node if desired (e.g., to have multiple independent save managers with different configurations).
## A scene tree group containing all nodes that should be saved and loaded.
##
## [member Deserializer.saveable_node_group] will also be set to this value when the deserializer is created.
@export var saveable_node_group: StringName = &"saveable"
## The name for a method that Nodes can implement to perform actions before the SaveManager starts saving the scene tree.
##
## Will only be called on nodes that are members of [member saveable_node_group].
@export var before_save_method: StringName = &"before_save"
## The name for a method that Nodes can implement to perform actions after the SaveManager has saved the scene tree.
##
## Will only be called on nodes that are members of [member saveable_node_group].
@export var after_save_method: StringName = &"after_save"
## The name for a method that Nodes can implement to perform actions before the SaveManager starts loading the scene tree.
##
## Will only be called on nodes that are members of [member saveable_node_group] and [b]already in the scene tree[/b] before loading begins.
@export var before_load_method: StringName = &"before_load"
## The name for a method that Nodes can implement to perform actions after the SaveManager has loaded the scene tree.
##
## Will only be called on nodes that are members of [member saveable_node_group], including nodes added to the scene tree during loading. Nodes which were removed from the scene tree during loading will [b]not[/b] have this method called.
@export var after_load_method: StringName = &"after_load"
## The implementation of the [Serializer] interface to use for saving the scene tree.
@export var serializer_script: Script = preload("json_serializer.gd")
## The implementation of the [Deserializer] interface to use for loading the scene tree.
@export var deserializer_script: Script = preload("json_deserializer.gd")
## The directory to save and load games to/from.
@export_dir var save_games_directory: String = "user://save_games/"
## The extension to use with save game files, or an empty string to have no extension. Should include the dot if specified (e.g., [code].json[/code]).
@export var save_file_extension: String = ".json"
## Emitted before the SaveManager starts saving the scene tree.
signal before_save
## Emitted after the SaveManager has saved the scene tree.
signal after_save
## Emitted before the SaveManager starts loading the scene tree.
signal before_load
## Emitted after the SaveManager has loaded the scene tree.
signal after_load
## Emitted after [param node] has been saved.
signal node_saved(node: Node)
## Emitted after [param node] has been loaded.
signal node_loaded(node: Node)
## Emitted when [param node] has been created and added to the scene tree, as part of the loading process.
signal node_created(node: Node)
## Emitted when [param node] has been removed from the scene tree, as part of the loading process.
signal node_removed(node: Node)
const Deserializer := preload("deserializer.gd")
const SaveGameFile := preload("save_game_file.gd")
const Serializer := preload("serializer.gd")
func _save_scene_tree(finalizer: Callable) -> Variant:
before_save.emit()
var scene_tree := get_tree()
scene_tree.call_group(saveable_node_group, before_save_method)
@warning_ignore("unsafe_method_access")
var serializer: Serializer = serializer_script.new()
var saveable_nodes := scene_tree.get_nodes_in_group(saveable_node_group)
for node in saveable_nodes:
if node.is_queued_for_deletion():
push_warning("Node ", node.get_path(), " is queued for deletion, skipping it during save")
continue
serializer.save_node(node)
node_saved.emit(node)
scene_tree.call_group_flags(SceneTree.GROUP_CALL_REVERSE, saveable_node_group, after_save_method)
var result: Variant = finalizer.call(serializer)
after_save.emit()
return result
## Saves all [member saveable_node_group] nodes in the scene tree, returning a buffer containing the saved data.
func save_scene_tree_in_memory() -> PackedByteArray:
return _save_scene_tree(func(serializer: Serializer) -> Variant:
return serializer.finalize_save_in_memory()
)
## Saves all [member saveable_node_group] nodes in the scene tree, writing the saved data to the given file path. Returns an error if saving failed.
func save_scene_tree_to_disk(absolute_path: String) -> Error:
return _save_scene_tree(func(serializer: Serializer) -> Variant:
return serializer.finalize_save_to_disk(absolute_path)
)
## Creates a save game file, naming it according to [param save_name_components], and optionally overwriting any existing file.
##
## On disk, [param save_name_components] is used to create separate save game directories inside [member save_games_directory]. In game, this could be used, for example, to differentiate individual games [i]as well as[/i] different save slots within those games (e.g., [code]"My Cool Game", "Autosave 1"[/code]).
##
## Since these names may come from user input, the components are sanitized and validated before being assigned to this property. The resulting text may not exactly match what the user entered.
##
## Returns information about the created save game file, or null if saving failed.
func save_game(save_name_components: PackedStringArray, allow_overwrite: bool = false) -> SaveGameFile:
if not save_games_directory.is_absolute_path():
push_error("save_games_directory must be an absolute path: ", save_games_directory)
return null
var save_path := SaveGameFile.sanitize_save_name_components(save_name_components)
if not save_path:
push_error("After sanitization, save name components resulted in an empty filename: ", save_name_components)
return null
var sanitized_save_name_components := save_path.split("/")
var absolute_path := _normalized_save_games_directory().path_join(save_path + save_file_extension)
if not allow_overwrite and FileAccess.file_exists(absolute_path):
push_warning("Disallowing overwriting save file: ", absolute_path)
return null
var error := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir())
if error != OK:
push_error("Failed to create directory for save file: ", absolute_path, " (", error_string(error), ")")
return null
error = save_scene_tree_to_disk(absolute_path)
if error != OK:
push_error("Failed to save scene tree to disk: ", absolute_path, " (", error_string(error), ")")
return null
var save_game_file := SaveGameFile.new()
save_game_file.save_name_components = sanitized_save_name_components
save_game_file.absolute_path = absolute_path
save_game_file.modified_at_unix_time = FileAccess.get_modified_time(absolute_path)
return save_game_file
func _before_load() -> Deserializer:
before_load.emit()
var scene_tree := get_tree()
scene_tree.call_group(saveable_node_group, before_load_method)
@warning_ignore("unsafe_method_access")
var deserializer: Deserializer = deserializer_script.new()
deserializer.scene_tree = scene_tree
deserializer.saveable_node_group = saveable_node_group
deserializer.node_created.connect(_on_node_created)
return deserializer
func _load_scene_tree(deserializer: Deserializer) -> void:
var loaded_nodes: Array[Node]
while not deserializer.is_finished():
var node := deserializer.load_node()
if not node:
continue
loaded_nodes.append(node)
node_loaded.emit(node)
# TODO: Does this need to be deferred?
var scene_tree := get_tree()
for node in scene_tree.get_nodes_in_group(saveable_node_group):
if node not in loaded_nodes:
node.queue_free()
node_removed.emit(node)
scene_tree.call_group_flags(SceneTree.GROUP_CALL_REVERSE, saveable_node_group, after_load_method)
after_load.emit()
## Loads [member saveable_node_group] nodes into the scene tree from the given buffer of save data. Returns false if loading failed.
##
## Nodes will be added, removed, and updated as needed to match the provided save data.
func load_scene_tree_from_memory(data: PackedByteArray) -> bool:
var deserializer := _before_load()
if not deserializer.prepare_load_from_memory(data):
return false
_load_scene_tree(deserializer)
return true
## Loads [member saveable_node_group] nodes into the scene tree from file at the given path. Returns an error if loading failed.
##
## Nodes will be added, removed, and updated as needed to match the provided save data.
func load_scene_tree_from_file(absolute_path: String) -> Error:
var deserializer := _before_load()
var error := deserializer.prepare_load_from_file(absolute_path)
if error != OK:
return error
_load_scene_tree(deserializer)
return OK
## Opens a save game file that matches [param save_name_components] within [member save_games_directory], and loads it into the scene tree. Returns an error if loading failed.
func load_game(save_name_components: PackedStringArray) -> Error:
if not save_games_directory.is_absolute_path():
push_error("save_games_directory must be an absolute path: ", save_games_directory)
return ERR_INVALID_PARAMETER
var save_path := SaveGameFile.sanitize_save_name_components(save_name_components)
if not save_path:
push_error("After sanitization, save name components resulted in an empty filename: ", save_name_components)
return ERR_INVALID_PARAMETER
var absolute_path := _normalized_save_games_directory().path_join(save_path + save_file_extension)
return load_scene_tree_from_file(absolute_path)
## Looks up a save game file by its absolute path, or returns null if the path doesn't point to a valid save game file. The path must be within [member save_games_directory].
func get_save_file_at_path(path: String) -> SaveGameFile:
path = path.simplify_path()
var normalized_dir := _normalized_save_games_directory()
if not path.begins_with(normalized_dir):
push_warning("Save file path must be within save_games_directory: ", path)
return null
if not FileAccess.file_exists(path):
return null
var relative_path := path.substr(normalized_dir.length())
var save_game_file := SaveGameFile.new()
save_game_file.save_name_components = relative_path.get_basename().split("/")
save_game_file.absolute_path = path
save_game_file.modified_at_unix_time = FileAccess.get_modified_time(path)
return save_game_file
## Lists save game files that exist on disk.
##
## An optional [param directory_path] (which must be an absolute path) can be provided to limit the results to a specific subdirectory within [member save_games_directory]. By default, all save game files within [member save_games_directory] and its subdirectories will be listed.
##
## If [param recursive] is true, save game files within all subdirectories of [param directory_path] will be included.
##
## If [param sort_by_modified_time] is true, the resulting list will be sorted by modified time, with the most recently modified save games first. Otherwise, the order is not guaranteed.
func list_save_files(directory_path: String = "", recursive: bool = true, sort_by_modified_time: bool = true) -> Array[SaveGameFile]:
var normalized_dir := _normalized_save_games_directory()
if directory_path:
directory_path = directory_path.simplify_path()
if not directory_path.ends_with("/"):
directory_path += "/"
if not directory_path.begins_with(normalized_dir):
push_warning("Directory path must be within save_games_directory: ", directory_path)
return []
else:
directory_path = normalized_dir
var dir := DirAccess.open(directory_path)
if not dir:
push_warning("Could not list save games directory: ", directory_path, " (", error_string(DirAccess.get_open_error()), ")")
return []
dir.include_hidden = false
dir.include_navigational = false
dir.list_dir_begin()
var file_name := dir.get_next()
var save_files: Array[SaveGameFile]
while file_name:
if dir.current_is_dir():
if recursive:
save_files.append_array(list_save_files(directory_path.path_join(file_name), true, false))
elif file_name.ends_with(save_file_extension):
var save_file := get_save_file_at_path(directory_path.path_join(file_name))
if save_file:
save_files.append(save_file)
file_name = dir.get_next()
dir.list_dir_end()
if sort_by_modified_time:
save_files.sort_custom(func(a: SaveGameFile, b: SaveGameFile) -> bool:
# Sort most recent saves first
return a.modified_at_unix_time > b.modified_at_unix_time
)
return save_files
func _on_node_created(node: Node) -> void:
node_created.emit(node)
# Returns [member save_games_directory] normalized to always end with a trailing slash,
# so path comparisons and substring arithmetic behave consistently regardless of
# whether the user configured the directory with a trailing slash or not.
func _normalized_save_games_directory() -> String:
return save_games_directory if save_games_directory.ends_with("/") else save_games_directory + "/"

View File

@@ -0,0 +1 @@
uid://ck4ih2layufdo

View File

@@ -0,0 +1,81 @@
@abstract
class_name SaveKitSerializer
extends RefCounted
## Base class for serializers that persist nodes into save data.
##
## Subclasses can implement custom save data formats by providing implementations for the abstract methods.
const ReflectionUtils := preload("reflection_utils.gd")
## The name for a method that Nodes can implement to customize how they are saved to a dictionary. The method should have the following signature:
##
## [codeblock]
## func save_to_dict(serializer: Serializer) -> Dictionary
## [/codeblock]
##
## Within this method, nodes can use the serializer's [method encode_var] method to encode values into the returned dictionary.
var save_to_dict_method: StringName = &"save_to_dict"
## Nodes are normally serialized in save data according to their path in the scene tree. However, in some cases it may be desirable to override this path (e.g., to deduplicate the saved node with its original instantiation in a packed scene).
##
## This is the name for a property (of type [NodePath]) that Nodes can implement to provide a custom node path when saving.
var save_path_override_key: StringName = &"save_path_override"
## Encodes a runtime value for saving, returning a value that can be persisted.
@abstract
func encode_var(value: Variant) -> Variant
## Adds [param node] to the save data, serializing its properties according to [method save_node_to_dict].
@abstract
func save_node(node: Node) -> void
## After all nodes have been saved using [method save_node], this method can be called to get the finalized save data.
@abstract
func finalize_save_in_memory() -> PackedByteArray
## After all nodes have been saved using [method save_node], this method can be called to write the finalized save data to a file. Returns whether the file writing was successful.
func finalize_save_to_disk(path: String) -> Error:
var file := FileAccess.open(path, FileAccess.WRITE)
if not file:
return FileAccess.get_open_error()
var bytes := finalize_save_in_memory()
if not file.store_buffer(bytes):
return file.get_error()
return OK
## Saves data for [param node] into a dictionary, suitable for persisting.
func save_node_to_dict(node: Node) -> Dictionary:
if not node.has_method(save_to_dict_method):
return default_save_to_dict(node)
var save_dict: Variant = node.call(save_to_dict_method, self )
if save_dict is not Dictionary:
push_error("Node ", node.get_path(), " did not return a dictionary from ", save_to_dict_method, "()")
return {}
return save_dict
## Implements the default behavior for [method save_node_to_dict], for the case where the node does not implement a custom [member save_to_dict_method]. This will serialize all of the node's exported properties that have a non-default value.
##
## This method can also be called from a custom [member save_to_dict_method] implementation, to save some properties automatically and implement custom behavior for others. [param only_properties] can be used to specify a subset of properties to save from the node.
func default_save_to_dict(node: Node, only_properties: PackedStringArray = PackedStringArray()) -> Dictionary:
var save_dict := {}
for property_dict in ReflectionUtils.get_storable_non_default_properties(node):
var name: String = property_dict["name"]
if only_properties and name not in only_properties:
continue
var value: Variant = property_dict["value"]
save_dict[name] = encode_var(value)
return save_dict
## Returns the [NodePath] to associate with [param node] in save data, honoring any value set for [member save_path_override_key].
func save_path_for_node(node: Node) -> NodePath:
var path_override: Variant = node.get(save_path_override_key)
if path_override:
return path_override
return node.get_path()

View File

@@ -0,0 +1 @@
uid://hwjubtw86ghm