godotneers / saving-loading-video

Example project used in the saving and loading video
MIT License
39 stars 2 forks source link

Any tips for ID-based save data that doesn't require destroying/re-instantiating entities? #2

Open KesKim opened 6 months ago

KesKim commented 6 months ago

Greetings,

I did not find a separate website for Godotneers, so I thought I could ask a few questions that relate to the saving-loading video over here - I hope it's not too forward of me to do so.

It feels to me like this paradigm of free-an-object-then-reinstance it is overkill for some targets, and might be tricky for others where hierarchical setups are more delicate (for example: some parent has looked up a child object and destroying+reinstancing that child would break references).

So while I think your save/load system video and the accompanying PDF is absolutely wonderful, top-tier teaching material for newbies and experienced developers alike, I feel like it could benefit from the addition of discussing an ID-based save data solution as an aside or an addition to the existing system; what I've been trying to build in my own implementation, but seem to keep running into problems with. I don't really know if an ID-based system is really a necessity, but I've seen others write about using them even in Godot, so surely there are suitable use-cases and implementations somewhere; I only haven't so far read/heard any implementation details!

So far I've identified a few problems and less solutions:

  1. Getting unique IDs (solutions: using some UUID addon or just keeping a record of an incrementing ID integer, latter being naive, easier to setup but prone to collisions/conflicts if you don't work alone on your project)
  2. Assigning IDs 2.1. Manual assignment risks duplicate IDs and accidentally ID-less entities when missing setup. I think it's best avoided. 2.2. How to automate assignment without needing to write a lot of boilerplate code for every class that needs an ID? @tool scripts would require writing a lot of repetitive code for every class using such IDs, then additionally writing a lot of Engine.is_editor_hint() checks for every func of the class aside from the _ready() that's probably used to set the ID value in Editor. In Unity there's the option of declaring a Reset() method that is executed in Editor for every script instance when it's added anywhere. 2.3. When automatically assigning IDs, how to assign IDs only for "in level"/sub-scene instances of Nodes/scripts, i.e. instances of Enemy in Level 1, but never to Enemy.tscn? If the Enemy.tscn, template-purpose Scene would receive an ID, it would duplicate that ID to every instance of itself, very efficiently breaking the ID-based save system.

My background is in Unity, so it's possibly I keep looking for the kinds of solutions that don't exist here and don't realize or know the alternatives more suited for Godot.

In Unity my solutions would have been something along these lines:

Are you aware of any equivalencies for these solutions in Godot? Do you know of other methods to solve such issues with assigning unique IDs to saveable objects in Godot? If you have any thoughts or pointers to give, I would love a reply. Furthermore, if you consider it's worthy of an addendum to your already-excellent saving-loading-video, maybe such knowledge and this pattern could eventually become common knowledge for Godot users.

derkork commented 6 months ago

Well I don't claim to know all the answers, but lets maybe try to look at it from the other side. Why do we even think we need IDs for everything? Well to be able to restore relationships after a restore. And these relationships form dynamically at runtime. So say we have a tower defense game and the tower should remember its target when the game is saved and loaded. There are a few ways to do this.

One way would be to have a stable way in which targets are picked. Say the tower always attacks the target with the least health. Then we don't even need to save this relationship as the tower will simply acquire the same target after a load as it had before, because we have a stable rule on how to pick targets.

But lets say we don't have the luxury of a stable rule and we somehow need to save that relationship. Again we have multiple options. We could give our nodes IDs and save and restore these IDs and then provide a way to look up a saved node by ID. But that means we need to give every node an ID and also do so at runtime when we dynamically instantiate things. So I don't think just assigning IDs in the editor is going to cut it. So it needs to be in code and as you pointed out this would be a lot of boilerplate to add. And even if now every object we can save magically has an ID we still need a facility where a node can lookup other nodes by their ID which adds even more boilerplate.

I have discussed this very same issue a while ago with a friend and we found an approach that I think solves the problem without overly complicating things. We keep the same approach in general, e.g. we clear out the level and then reinstantiate what needs reinstatiation. While this may not be the most efficient way to do it (e.g. we throw away perfectly usable objects) this just eliminates a lot of edge cases which we don't need to care about anymore. To handle our relationships, we add a second phase to saving and loading.

In this second phase nodes can implement a method save_dependencies which takes a dictionary. In this dictionary the node can enter dependencies it has. E.g. for our tower this would look like this:

var _current_target:Enemy  # the target we're currently shooting at 

func save_dependencies(dependencies:Dictionary):
    dependencies["current_target"] = _current_target

Now in our SavedData we have an additional dictionary which records dependencies.

class_name SavedData  # base class for saved data
extends Resource

@export var dependencies:Dictionary = {}

All SavedData objects now support dependencies, so lets say we have specializations like SavedEnemy and SavedTower for holding the saved data of towers and enemies.

Problem is though, that the save_dependencies method added an Enemy to the dictictionary and not a SavedEnemy. So our SaverLoader would need to do a bit of bookeeping which node corresponds to which SavedData resource. Since it asked all our Nodes in the first step to provide a SavedData object it can keep track of that easily. And now it can use this information to resolve the node given in the dependencies back to the SavedData matching it.

image

So now in the saved game we store that our tower has a dependency named "current_target" and link that to the SavedEnemy, something that we can just do with resources (in JSON this would be a lot more complicated). And now on restore, we do what we already do , we clean out the level , create new nodes but this time we take note of which node we created for which saved data. After we have created all nodes, we can now run a second phase where we restore the dependencies. So we take the dependencies dictionary from our SavedData-derived object, and look at the values. The values are also SavedData objects so we can now resolve them. Becasue we created a node for each SavedData and we noted which node we created for which SavedData we can now replace the SavedData objects with the actual nodes we created. Now each node that can have dependencies now needs to have a restore_dependencies function which will get a dictionary with all the dependencies.

func restore_dependencies(dependencies:Dictionary):
   _current_target = dependencies["current_target"]

image

The receiving node can do whatever it needs to restore itself with the dependencies. So it can move itself or the dependency in the tree to restore its hierarchy. This probably has a few more rough edges to iron out but I think the general idea is workable. And it has the advantage that all the magic is hidden in the SaverLoader. Nodes that have no dependencies don't need to implement the methods for this and nodes that have dependencies just need to implement two methods where they can store and get back real node references and don't need to do all manual bookkeeping and ID resolving. AND we don't even need IDs. So what do you think - would this be workable for your case?

KesKim commented 6 months ago

Hi again, and thank you for the thoughtful and swift response!

I read this, let it simmer and read it again and I think you are right about the crucial parts: in my own case I'm new to Godot and have limited time learning a new language and a new engine alongside my day job (it's really hard to do both!) and I think I personally have trouble keeping up momentum anyway, so it's very key to go with the flow and accept easier answers. This is probably also true for a lot of your followers, as it makes sense to learn "a way" instead of striving towards a much more complicated "the way". "Perfection is the enemy of good (enough)", as they say.

That being said I do like this concept a lot.

The negative side is just a limitation to this technique that needs to be taken into account.

For my case this can work well in the sense that I'll stop lingering on the question and get to move forward, but I am still very interested in hearing if you ever learn of a way to handle UUIDs/GUIDs safely in Godot without manual assignment. I believe there must be someone who has sorted this out and I look forward to hearing from them one way or another.

Thanks again for the kindly very thorough response, and apologies for taking longer to respond to it than you took to answer my question; again I blame short evenings after ever-busy gamedev work. I'm going to try this approach and will write back (hopefully within a week) if implementing it brings up any comments.