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");
}
}
}