Ok folks here it is. I got it 95% of the way there. Special thanks to Matt Brelsford who helped me a ton with this sample code https://github.com/giantlight-matt/unity-webgl-itch-playerprefs/
Gaps
-
I couldn’t get the list saves to work. There’s a bug in my code and I’m not sure exactly how to operate on lists / arrays in JS. With this bug it is not possible to load a prior save other than with the continue button.
-
The code is ugly. I haven’t cleaned it up. Ideally I think what would make this neater is to put all persistence access (be it local file system, browser local storage, or player prefs) in a single class. You can then get rid of #if in our SavingSystem and SavingWrapper and just keep it in one class that does the appropriate thing based on the build.
Help needed: If anyone with a little more experience than me can look at the above two and try to address them that would be awesome!
Instructions on how to use.
- Create a Plugins folder under assets. Put the JS file there.
- Put UserDataManager somewhere. I used a UserDataManager namespace. It could possibly go in a GameDevTV library once properly refactored.
- Modify JsonSavingSystem and JsonSavingLibrary as shown.
P.S. Also apologies… I couldn’t figure out the correct markdown to get the expandable/collapsible text boxes and also have it render as code.
jsLibrary.jslib
mergeInto(LibraryManager.library, {
loadData: function(yourkey){
var returnStr = "";
if(localStorage.getItem(UTF8ToString(yourkey)) !==null)
{
returnStr = localStorage.getItem(UTF8ToString(yourkey));
}
var bufferSize = lengthBytesUTF8(returnStr) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(returnStr, buffer, bufferSize);
return buffer;
},
saveData: function(yourkey, yourdata){
localStorage.setItem(UTF8ToString(yourkey), UTF8ToString(yourdata));
},
deleteKey: function(yourkey){
localStorage.removeItem(UTF8ToString(yourkey));
},
deleteAllKeys: function(prefix){
for ( var i = 0, len = localStorage.length; i < len; ++i ) {
var key = localStorage.key(i);
if(key != null && key.startsWith(prefix)){
localStorage.removeItem(key);
}
}
},
listAllKeys: function(prefix){
var keyList = [];
for ( var i = 0, len = localStorage.length; i < len; ++i ) {
var key = localStorage.key(i);
if(key != null && key.startsWith(prefix)){
key = key.substring(prefix.length);
keyList.push(key);
}
}
return keyList;
}
});
UserDataManager.cs
using System.Runtime.InteropServices;
using UnityEngine;
public static class UserDataManager
{
private const string WebGLSavePath = "save_files/";
private const string WebGLPrefPath = "player_pref/";
private static string GetPathForKey(string key)
{
return WebGLSavePath + key;
}
private static string GetPathForPreferenceKey(string key)
{
return WebGLPrefPath + key;
}
public static void SetString(string key, string data)
{
#if UNITY_WEBGL && !UNITY_EDITOR
saveData(GetPathForKey(key), data);
#else
//TODO:
#endif
}
public static void SetPreferenceString(string key, string data)
{
#if UNITY_WEBGL && !UNITY_EDITOR
saveData(GetPathForPreferenceKey(key), data);
#else
PlayerPrefs.SetString(key, data);
#endif
}
public static string GetString(string key, string defaultValue = "")
{
#if UNITY_WEBGL && !UNITY_EDITOR
return loadData(GetPathForKey(key));
#else
//TODO:
return "";
#endif
}
public static string GetPreferenceString(string key)
{
#if UNITY_WEBGL && !UNITY_EDITOR
return loadData(GetPathForPreferenceKey(key));
#else
return PlayerPrefs.GetString(key);
#endif
}
public static bool HasKey(string key)
{
#if UNITY_WEBGL && !UNITY_EDITOR
var data = loadData(GetPathForKey(key));
return data != string.Empty;
#else
//TODO:
return false;
#endif
}
public static bool HasPreferenceKey(string key)
{
#if UNITY_WEBGL && !UNITY_EDITOR
var data = loadData(GetPathForPreferenceKey(key));
return data != string.Empty;
#else
return PlayerPrefs.HasKey(key);
#endif
}
public static void DeleteKey(string key)
{
#if UNITY_WEBGL && !UNITY_EDITOR
deleteKey(GetPathForKey(key));
#else
//TODO:
#endif
}
public static void DeleteAllSaves(string key)
{
#if UNITY_WEBGL && !UNITY_EDITOR
deleteAllKeys(WebGLSavePath);
#else
//TODO:
#endif
}
public static string[] ListAllSaveKeys()
{
#if UNITY_WEBGL && !UNITY_EDITOR
Debug.Log($"Key list: {listAllKeys(WebGLSavePath)}");
return listAllKeys(WebGLSavePath);
#else
//TODO:
return null;
#endif
}
[DllImport("__Internal")]
private static extern void saveData(string key, string data);
[DllImport("__Internal")]
private static extern string loadData(string key);
[DllImport("__Internal")]
private static extern string deleteKey(string key);
[DllImport("__Internal")]
private static extern string deleteAllKeys(string prefix);
[DllImport("__Internal")]
private static extern string[] listAllKeys(string prefix);
}
JsonSavingSystem.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using static UserDataManager;
namespace GameDevTV.Saving
{
public class JsonSavingSystem : MonoBehaviour
{
private const string extension = ".json";
/// <summary>
/// Will load the last scene that was saved and restore the state. This
/// must be run as a coroutine.
/// </summary>
/// <param name="saveFile">The save file to consult for loading.</param>
public IEnumerator LoadLastScene(string saveFile)
{
JObject state = LoadJsonFromFile(saveFile);
IDictionary<string, JToken> stateDict = state;
int buildIndex = SceneManager.GetActiveScene().buildIndex;
if (stateDict.ContainsKey("lastSceneBuildIndex"))
{
buildIndex = (int)stateDict["lastSceneBuildIndex"];
}
yield return SceneManager.LoadSceneAsync(buildIndex);
RestoreFromToken(state);
}
/// <summary>
/// Save the current scene to the provided save file.
/// </summary>
public void Save(string saveFile)
{
JObject state = LoadJsonFromFile(saveFile);
CaptureAsToken(state);
SaveFileAsJSon(saveFile, state);
}
/// <summary>
/// Delete the state in the given save file.
/// </summary>
public void Delete(string saveFile)
{
#if UNITY_WEBGL && !UNITY_EDITOR
UserDataManager.DeleteKey(saveFile);
#else
File.Delete(GetPathFromSaveFile(saveFile));
#endif
}
public void Load(string saveFile)
{
RestoreFromToken(LoadJsonFromFile(saveFile));
}
public bool SaveFileExists(string saveFile)
{
#if UNITY_WEBGL && !UNITY_EDITOR
Debug.Log($"Has Key for file: {saveFile} is status: {UserDataManager.HasKey(saveFile)}");
return UserDataManager.HasKey(saveFile);
#else
string path = GetPathFromSaveFile(saveFile);
return File.Exists(path);
#endif
}
public IEnumerable<string> ListSaves()
{
#if UNITY_WEBGL && !UNITY_EDITOR
return UserDataManager.ListAllSaveKeys();
#else
foreach (string path in Directory.EnumerateFiles(Application.persistentDataPath))
{
Debug.Log("Trying to get file at path: {path}");
if (Path.GetExtension(path) == extension)
{
yield return Path.GetFileNameWithoutExtension(path);
}
}
#endif
}
// PRIVATE
private JObject LoadJsonFromFile(string saveFile)
{
#if UNITY_WEBGL && !UNITY_EDITOR
Debug.Log($"Save file as seen in LoadJson: {saveFile} ");
string jsonSaveFileAsString = UserDataManager.GetString(saveFile);
Debug.Log($"returned file contents from LoadJSonFromFile {jsonSaveFileAsString}");
if (string.IsNullOrWhiteSpace(jsonSaveFileAsString)) return new JObject();
return JObject.Parse(jsonSaveFileAsString);
#else
string path = GetPathFromSaveFile(saveFile);
if (!File.Exists(path))
{
return new JObject();
}
using (var textReader = File.OpenText(path))
{
using (var reader = new JsonTextReader(textReader))
{
reader.FloatParseHandling = FloatParseHandling.Double;
return JObject.Load(reader);
}
}
#endif
}
private void SaveFileAsJSon(string saveFile, JObject state)
{
#if UNITY_WEBGL && !UNITY_EDITOR
Debug.Log($"Attempting to save file {saveFile} with data {state.ToString()} ");
UserDataManager.SetString(saveFile, state.ToString());
#else
string path = GetPathFromSaveFile(saveFile);
print("Saving to " + path);
using (var textWriter = File.CreateText(path))
{
using (var writer = new JsonTextWriter(textWriter))
{
writer.Formatting = Formatting.Indented;
state.WriteTo(writer);
}
}
#endif
}
private void CaptureAsToken(JObject state)
{
IDictionary<string, JToken> stateDict = state;
foreach (JsonSavableEntity savable in FindObjectsOfType<JsonSavableEntity>())
{
stateDict[savable.GetUniqueIdentifier()] = savable.CaptureAsJToken();
}
stateDict["lastSceneBuildIndex"] = SceneManager.GetActiveScene().buildIndex;
}
private void RestoreFromToken(JObject state)
{
IDictionary<string, JToken> stateDict = state;
foreach (JsonSavableEntity savable in FindObjectsOfType<JsonSavableEntity>())
{
string id = savable.GetUniqueIdentifier();
if (stateDict.ContainsKey(id))
{
savable.RestoreFromJToken(stateDict[id]);
}
}
}
private string GetPathFromSaveFile(string saveFile)
{
return Path.Combine(Application.persistentDataPath, saveFile + extension);
}
}
}
SavingWrapper.cs
using System;
using GameDevTV.Saving;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace RPG.SceneManagement
{
public class SavingWrapper : MonoBehaviour
{
private const string currentSaveKey = "currentSaveName";
[SerializeField] private float fadeInTime;
[SerializeField] private float fadeOutTime;
[SerializeField] private int firstSceneBuildIndex = 1;
[SerializeField] private int menuSceneBuildIndex = 0;
public void ContinueGame()
{
#if UNITY_WEBGL && !UNITY_EDITOR
Debug.Log($"Checking if Player Preference Key Exists: {currentSaveKey} and value: {UserDataManager.GetPreferenceString(currentSaveKey)}");
if (!UserDataManager.HasPreferenceKey(currentSaveKey)) return;
#else
if (!PlayerPrefs.HasKey(currentSaveKey)) return;
#endif
if (!GetComponent<JsonSavingSystem>().SaveFileExists(GetCurrentSave())) return;
StartCoroutine(LoadLastScene());
}
public void NewGame(string saveFile)
{
if (String.IsNullOrEmpty(saveFile)) return;
SetCurrentSave(saveFile);
StartCoroutine(LoadFirstSceneandSave());
}
public void LoadGame(string saveFile)
{
SetCurrentSave(saveFile);
ContinueGame();
}
public void LoadMenu()
{
StartCoroutine(LoadMenuScene());
}
private void SetCurrentSave(string saveFile)
{
#if UNITY_WEBGL && !UNITY_EDITOR
UserDataManager.SetPreferenceString(currentSaveKey, saveFile);
Debug.Log($"Setting Player Preference Current Save Key: {currentSaveKey} and value: {UserDataManager.GetPreferenceString(currentSaveKey)}");
#else
PlayerPrefs.SetString(currentSaveKey, saveFile);
#endif
}
private string GetCurrentSave()
{
#if UNITY_WEBGL && !UNITY_EDITOR
Debug.Log($"Getting current save from WebGL prefs. Key: {currentSaveKey} Value: {UserDataManager.GetPreferenceString(currentSaveKey)}");
return UserDataManager.GetPreferenceString(currentSaveKey);
#else
Debug.Log($"Get current save from playerprefs is: {PlayerPrefs.GetString(currentSaveKey)}");
return PlayerPrefs.GetString(currentSaveKey);
#endif
}
private IEnumerator LoadLastScene()
{
Fader fader = FindObjectOfType<Fader>();
yield return fader.FadeOut(fadeOutTime);
yield return GetComponent<JsonSavingSystem>().LoadLastScene(GetCurrentSave());
yield return fader.FadeIn(fadeInTime);
}
private IEnumerator LoadFirstScene()
{
Fader fader = FindObjectOfType<Fader>();
yield return fader.FadeOut(fadeOutTime);
yield return SceneManager.LoadSceneAsync(firstSceneBuildIndex);
yield return fader.FadeIn(fadeInTime);
}
private IEnumerator LoadFirstSceneandSave()
{
Fader fader = FindObjectOfType<Fader>();
yield return fader.FadeOut(fadeOutTime);
yield return SceneManager.LoadSceneAsync(firstSceneBuildIndex);
yield return fader.FadeIn(fadeInTime);
Save();
}
private IEnumerator LoadMenuScene()
{
Fader fader = FindObjectOfType<Fader>();
yield return fader.FadeOut(fadeOutTime);
yield return SceneManager.LoadSceneAsync(menuSceneBuildIndex);
yield return fader.FadeIn(fadeInTime);
}
private void Update()
{
// https://community.gamedev.tv/t/followups-on-pickup-destruction-race-conditions/228964
if (SceneManager.GetActiveScene().buildIndex == 0) return;
if (Input.GetKeyDown(KeyCode.S))
{
Save();
}
if (Input.GetKeyDown(KeyCode.L))
{
StartCoroutine(LoadLastScene());
}
if (Input.GetKeyDown(KeyCode.Delete))
{
Delete();
}
}
public void Load()
{
GetComponent<JsonSavingSystem>().Load(GetCurrentSave());
}
public void Save()
{
GetComponent<JsonSavingSystem>().Save(GetCurrentSave());
}
public void Delete()
{
GetComponent<JsonSavingSystem>().Delete(GetCurrentSave());
}
public IEnumerable<string> ListSaves()
{
return GetComponent<JsonSavingSystem>().ListSaves();
}
}
}