Processing unlocks from save data

Basically, I’m trying to figure out how to load the player character with their unlocked skills from the save data, and sync that with the UI.
I’ve gone through a few tutorials and fruitless Google searches, wondering if I even have the right question.

Confession: I didn’t start the save system early on

What I have now:

  • the save data
  • an algorithm to convert the data (not sharing :frowning:
  • a mediator to transfer the data
  • a skill-button to call the unlock function (public void Unlock())

Problem
The first issue might be the scene setup.

The game scene starts with the menu UI inactive (which isn’t a must), so scripts on the buttons don’t Awake yet, or Start until active.
? Maybe I make a loading screen over everything and secretly load the UI scripts?
I was thinking I could reuse the Unlock function on the buttons and just have them ‘Unlock’ on loading the save, but the proper order for that might not be correct.

  • load game data
  • process data
  • send to UI?

Ideas so far
My first attempt was to have each skill-button add itself to a list. Then, the save data is compared with the list and unlocked accordingly. Problem is, the buttons are inactive and can’t add themselves to anything until active.
But as for code and timing, the skills need to be unlocked BEFORE the player opens the menu. So am I looking at moving the unlock function elsewhere down the line to match up with the save data?
The menu could also be synced once the player opens it, whenever that may be.

I tend to go a bit overkill when making a UI. I like using dependency injection and MVVM with reactive extensions (R3) for databinding.

So in the example below I use ScriptableObjects to store the data so I can change it while the game is running in the editor (second half of the video). Then on release I can swap out the datasource for something else e.g. cloud save.

There are a lot of ways of setting it up and it really depends on the project. I don’t like using singletons and static but it’s easier to demonstrate so I’ll use it for now. I also like using services like SaveService or SkillService. I also don’t let the UI interact directly with the save data though SaveService, instead other services will interact with it e.g. SkillService. Another important thing with the services is to make the data read only in most cases e.g. SkillService.SkillPoints can get the value but not directly set the value, instead it has something like SkillService.PurchaseSkill(skillId). This makes it more maintainable e.g. maybe I have an ability that makes purchase cheaper, this means I only have one location that needs code changes.

To connect the data to the UI there are normally two ways. The first is just accessing the data directly e.g.

SkillService.PurchaseSkill(5);
RefreshUI(); // e.g. lblSkillPoints.Text = SkillService.SkillPoints.ToString();

One issue with this is that you tend to refresh the entire UI so you need to keep track of the selected item if you’re using gamepad navigation.

The other is with databinding, there are a few ways to databind e.g. INotifyPropertyChanged. But I like using ReactiveProperties from R3. So it might look like this instead.

Void Awake() 
{
SkillService.SkillPoints.Subscribe(x => lblSkillPoints.text = x.ToString());
}

Then calling SkillService.PurchaseSkill(5); will automatically refresh the UI. Databinding is a lot more useful when you have two way binding like an input text field e.g. txtFirstName.BindTo(data.FirstName).AddTo(this);. Databinding is also built into UIToolkit.

For most games the data will just all be local. So normally it’s easier to start with a loader scene that loads the save then goes to the main UI e.g. all the data is ready in Awake. This also allows for async loading time. The same goes for loading a save slot, just preload the data before entering. One issue with this is that you can’t easily test other scenes as you always need to start in the bootstrap scene.

If the data is not local (maybe stored on a remote database) then it might be better to just make everything async. So in this case you could start the SkillsUI directly and have something like.

async Task<bool> RefreshUI() 
{
    btnPurchase.interactable = false;
    Var skillData = await SkillService.GetData();
     lblSkillPoints.Text = skillData.SkillPoints.ToString();
     btnPurchase.interactable = true;
}

Void Awake() {
  Await RefreshUI();
  btnPurchase.OnClick.AddListener(() => {
       Var success = await SkillService.PurchaseSkill(5);
       RefreshUI();
  });
}

I mean this process is generally how most UI works. It’s always best to keep your data separate, and your UI is a means to display and modify said data. The UI should not be the data itself.

If you set up your data correctly, it can just be modifying the save-data directly, preventing the need for any conversion.

Thanks, I’ll look into this. Looks like there’s still plenty for me to learn about c#.
I barely started with my save system, so maybe it’s not too late.

I assume that these methods aren’t tied to the type of the data stored? Rather, the data type doesn’t matter?
At this point my data is a bit cryptic. Most of what I’ve read, people suggest JSON, which wouldn’t change much.

I mean JSON is just a format for serialising data, after which you then usually write to disk. It’s not necessarily the data itself. That should be represented in C# in the case of Unity.

Unless you’re working with large volumes of data, you shouldn’t need to constantly read and write this data from disk.

You can just load the save data into memory, have it sit there for the session - writing the data to disk as/when needed - until the player wants to play a different save or quit.

And generally certain bits of UI are tied to certain types of data. Though this can of course be abstracted to makes things reusable. Depends on the use case.

Right. The data is currently in a basic delimited format (“data, data…”).
I know that JSON stores it in key-value pairs, which would only slightly change the process.
Right now it’s just 4 lines of data saved, not representing everything yet, but what is currently reachable.
So yes, I load the data on start and characters, world and enemies will process that however.

The UI now just need to change based on if they’re unlocked.
So do the menus process the data on load somehow, or when the player opens them?
Or is this a relative order?

Yeah, the data isn’t normally directly tied to the stored type. This is fairly common when making non-game related apps as they tend to connect to a remote database. The following is mainly for UI / services, for gameplay I normally stick to the standard MonoBehaviour style. This is also just my personal preference so I’d suggest just using whatever style you prefer.

Let’s say this is my save data. The main goal is to make it compact for json serialization and to avoid too much logic.

[System.Serializable]
public class SaveData
{
    public int saveVersion;
    public int skillPoints;
    public List<int> unlockedSkills;
    public bool HasUnlockedSkill(int id) => unlockedSkills.Contains(id);
}

The SaveService doesn’t do much, just load and save to json. You can get a bit more fancy and make interfaces if you want to change it (maybe a Steam cloud save). I’d also recommend having a default save state e.g. this example gives some skill points and unlocks the first skill.

public class SaveService
{ 
    public SaveData saveData;
    public string FilePath => Path.Combine(Application.persistentDataPath, "Save.json");
    public void Load() => saveData = File.Exists(FilePath) ? JsonConvert.DeserializeObject<SaveData>(File.ReadAllText(FilePath)) : 
        new SaveData { skillPoints = 5, unlockedSkills = new List<int>(new int[] { 0 }) };
    public void Save() => File.WriteAllText(FilePath, JsonConvert.SerializeObject(saveData));
}

Some data is used for configuration e.g. localized names, cost. I like using ScriptableObjects for this.

public class SkillAsset : ScriptableObject
{
    public int id;
    public LocalizedString skillName;
    public LocalizedString description;
    public int cost;
}

As you mentioned, I normally make another class to hold the data returned by a service. I like using records for this (basically a class with read only properties). This has two benefits: the first is that you can combine data e.g. this contains the fixed data like skill name / cost and persistent data like the unlocked state. The second benefit is that you can convert the data e.g. instead of a LocalizedString I convert it to a standard string.

public record SkillRecord(int ID, string Name, string Description, int Cost, bool Unlocked, bool CanAfford);

Another option is to make this a container class which holds the SkillAsset and SaveData. Then maybe make some public properties to access the data. I sometimes do this if I’m implementing INotifyPropertyChanged.

A basic SkillService might look something like this:

public class SkillService
{
    SaveService saveService;
    Dictionary<int, SkillAsset> skillAssets;
    public int SkillPoints => saveService.saveData.skillPoints;

    public SkillService(SaveService saveService, Dictionary<int, SkillAsset> skillAssets)
    {
        this.saveService = saveService;
        this.skillAssets = skillAssets;
    }

    public SkillRecord GetSkill(int id)
    {
        var skill = skillAssets[id];
        return new SkillRecord(id, skill.skillName.GetLocalizedString(), skill.description.GetLocalizedString(), skill.cost, 
            saveService.saveData.HasUnlockedSkill(id), saveService.saveData.skillPoints >= skill.cost);
    }

    public SkillRecord[] GetSkills()
    {
        var res = new List<SkillRecord>();
        foreach (var skill in skillAssets.Values)  
        { 
            res.Add(GetSkill(skill.id));
        }
        return res.ToArray();
    }

    public bool PurchaseSkill(int id)
    {
        var skill = GetSkill(id);
        if (saveService.saveData.skillPoints >= skill.Cost && saveService.saveData.unlockedSkills.Contains(id) == false)
        {
            saveService.saveData.skillPoints -= skill.Cost;
            saveService.saveData.unlockedSkills.Add(id);
            saveService.Save();
            return true;
        }
        else
        {
            return false;
        }
    }

    public void DebugSetSkillPoints(int points)
    {
        saveService.saveData.skillPoints = points;
        saveService.Save();
    } 
}

Then the UI only needs to query the SkillService. Note that it doesn’t need to know about the save data or skill assets. I like using OnGUI to create a basic test UI and then create a proper UI afterwards. Having the logic outside the UI also makes it easier to change the UI e.g. different layouts for PC and mobile.

public void OnGUI()
    {
        var skillPoints = skillService.SkillPoints; 
        if (skillPointsString == null)
        {
            skillPointsString = skillPoints.ToString();
        }

        GUILayout.BeginHorizontal();
        GUILayout.Label("SkillPoints: ");
        skillPointsString = GUILayout.TextField(skillPointsString);
        if (GUILayout.Button("Update"))
        {
            skillService.DebugSetSkillPoints(int.Parse(skillPointsString));
        }
        GUILayout.EndHorizontal();

        var skills = skillService.GetSkills();
        foreach (var skill in skills)
        {
            if (skill.Unlocked)
            {
                GUILayout.Label($"{skill.Name} is Unlocked");
            }
            else if (skill.CanAfford)
            {
                if (GUILayout.Button($"Purchase {skill.Name} for {skill.Cost}"))
                {
                    skillService.PurchaseSkill(skill.ID);
                }
            }
            else
            {
                GUILayout.Label($"{skill.Name} is locked. You require {skill.Cost} skill point to unlock it");
            }
        }
    }

I’d be careful when using custom save formats. I used a custom format in the past with binary writer / reader but the saves but it can quickly run into issues with versions (new or removed properties). Json handles different versions quite well without much extra work.

Probably the easiest option is to just load the data once in a bootstrap scene before changing to the UI scene. As @spiney199 mentioned, games aren’t really like standard apps as you can just load the entire save into memory and reuse it.

1 Like

Thank you everyone!
I will look into Bootstrap and databinding. See how those work.

The current solution seemed to be separating things properly into the right classes.
So, after some refactoring, I managed to produce the desired effect.

1 Like