initial shit
This commit is contained in:
21
addons/savekit/LICENSE
Normal file
21
addons/savekit/LICENSE
Normal 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
121
addons/savekit/README.md
Normal 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.
|
||||
188
addons/savekit/binary_deserializer.gd
Normal file
188
addons/savekit/binary_deserializer.gd
Normal 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
|
||||
1
addons/savekit/binary_deserializer.gd.uid
Normal file
1
addons/savekit/binary_deserializer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://n8c08uy53j8x
|
||||
184
addons/savekit/binary_serializer.gd
Normal file
184
addons/savekit/binary_serializer.gd
Normal 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
|
||||
1
addons/savekit/binary_serializer.gd.uid
Normal file
1
addons/savekit/binary_serializer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b1n51mkjkbyb0
|
||||
145
addons/savekit/deserializer.gd
Normal file
145
addons/savekit/deserializer.gd
Normal 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())
|
||||
1
addons/savekit/deserializer.gd.uid
Normal file
1
addons/savekit/deserializer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c3cifjyaim8g4
|
||||
181
addons/savekit/json_deserializer.gd
Normal file
181
addons/savekit/json_deserializer.gd
Normal 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
|
||||
1
addons/savekit/json_deserializer.gd.uid
Normal file
1
addons/savekit/json_deserializer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bphgneyc5sa15
|
||||
164
addons/savekit/json_serializer.gd
Normal file
164
addons/savekit/json_serializer.gd
Normal 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,
|
||||
}
|
||||
1
addons/savekit/json_serializer.gd.uid
Normal file
1
addons/savekit/json_serializer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://diyshoqvkrhpx
|
||||
7
addons/savekit/plugin.cfg
Normal file
7
addons/savekit/plugin.cfg
Normal 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
19
addons/savekit/plugin.gd
Normal 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
|
||||
1
addons/savekit/plugin.gd.uid
Normal file
1
addons/savekit/plugin.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b6yaurqqkdt2u
|
||||
54
addons/savekit/reflection_utils.gd
Normal file
54
addons/savekit/reflection_utils.gd
Normal 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
|
||||
1
addons/savekit/reflection_utils.gd.uid
Normal file
1
addons/savekit/reflection_utils.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://deu2lfsa81noa
|
||||
71
addons/savekit/resource.gd
Normal file
71
addons/savekit/resource.gd
Normal 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()
|
||||
1
addons/savekit/resource.gd.uid
Normal file
1
addons/savekit/resource.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://daurtud8w7utj
|
||||
13
addons/savekit/resource_utils.gd
Normal file
13
addons/savekit/resource_utils.gd
Normal 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
|
||||
1
addons/savekit/resource_utils.gd.uid
Normal file
1
addons/savekit/resource_utils.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b4yrtgqmcib0v
|
||||
42
addons/savekit/save_game_file.gd
Normal file
42
addons/savekit/save_game_file.gd
Normal 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]
|
||||
1
addons/savekit/save_game_file.gd.uid
Normal file
1
addons/savekit/save_game_file.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://mt5re1s6mf8i
|
||||
293
addons/savekit/save_manager.gd
Normal file
293
addons/savekit/save_manager.gd
Normal 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 + "/"
|
||||
1
addons/savekit/save_manager.gd.uid
Normal file
1
addons/savekit/save_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ck4ih2layufdo
|
||||
81
addons/savekit/serializer.gd
Normal file
81
addons/savekit/serializer.gd
Normal 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()
|
||||
1
addons/savekit/serializer.gd.uid
Normal file
1
addons/savekit/serializer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://hwjubtw86ghm
|
||||
Reference in New Issue
Block a user