Skip to content

Instantly share code, notes, and snippets.

@BigHandInSky
Last active November 25, 2021 15:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save BigHandInSky/673670343b42ed9e43b892daf24d095f to your computer and use it in GitHub Desktop.
Save BigHandInSky/673670343b42ed9e43b892daf24d095f to your computer and use it in GitHub Desktop.
Simple Persistence system that can started from any point
// maintenanceDay
// 2020123017:11
using System;
using System.Collections.Generic;
using _project.utilities;
using Rebar.Persistence;
using UnityEngine;
namespace _project.Persistence
{
[Serializable] // this makes it visible in-editor
public class PersistenceData : PersistenceDataBase
{
// notes:
// - saves aren't done per set, they must be called manually after an assumed batch of edits
// - all variables must be public (and not properties) in order to be persisted
public string story_nickname = "Orby"; // whwenever a story is started, this is loaded from and applied to the Ink variables state
// other story variables here
// trophy-related
public List<string> characterTagsMet = new List<string>(); // set by script, is a list of character tag
// other trophy variables
#region Act Serialisation
// to simplify the process of getting/setting levels:
// the dictionary holds the group > index/data, to serve as an easier/more performant access,
private Dictionary<int, ActPersist> _actPersistLookup;
// where, when saving/loading, the dictionary is packed/parsed from this list
/// <summary>
/// Do not use this, it's here for saving/loading. Use GetPersistForAct(int)
/// </summary>
public List<ActPersist> acts;
// stores the data needed for persisting an act as a whole
[Serializable]
public class ActPersist
{
public int index;
// whether this act is in progress in some form, is here to simplify title menu stuff
public bool isInProgress;
// whether this was ever completed, as you can replay acts whenever you want
public bool hasEverBeenCompleted;
// the list of completed fixables, where it's the order of the indices completed
public List<int> completedFixables;
// track which knots have been finished, in the order they were done
public List<string> completedStoryKnots;
public ActPersist( int a )
{
index = a;
completedFixables = new List<int>();
completedStoryKnots = new List<string>();
}
// clear progress, but not hasEverBeenCompleted
public void ClearProgress()
{
isInProgress = false;
completedFixables = new List<int>();
completedStoryKnots = new List<string>();
}
public bool AddFixable( int siblingIndex )
{
if ( !completedFixables.Contains( siblingIndex ) )
{
Debug.Log( $"ActPersist[{index.ToString()}].AddFixable({siblingIndex})" );
completedFixables.Add( siblingIndex );
return true;
}
return false;
}
public bool AddStory( string knot )
{
if ( !completedStoryKnots.Contains( knot ) )
{
Debug.Log( $"ActPersist[{index.ToString()}].AddStory({knot})" );
completedStoryKnots.Add( knot );
return true;
}
return false;
}
}
public ActPersist GetPersistForAct( int actIndex )
{
CheckAndPopulateActLookup();
CheckAndAddActKey( actIndex );
return _actPersistLookup[actIndex];
}
private void CheckAndPopulateActLookup()
{
if ( _actPersistLookup != null )
return;
_actPersistLookup = new Dictionary<int, ActPersist>();
// if the level list is valid, parse the current values from it
if ( acts == null || acts.Count <= 0 )
return;
foreach ( ActPersist persist in acts )
{
if ( !_actPersistLookup.ContainsKey( persist.index ) )
{
_actPersistLookup.Add( persist.index, persist );
}
}
}
private void CheckAndAddActKey( int actIndex )
{
if ( !_actPersistLookup.ContainsKey( actIndex) )
{
_actPersistLookup.Add( actIndex, new ActPersist( actIndex ) );
}
}
#endregion
public override void PrepareDataForSave()
{
// write to the act list,
// we do this by clearing the list & refresh based on the current dictionary state
CheckAndPopulateActLookup();
acts = new List<ActPersist>();
foreach ( var pair in _actPersistLookup )
{
acts.Add( pair.Value );
}
}
public override int GetCurrentDataVersion()
{
return 1;
}
public override void HandleDifferentVersion( int loadedVersionNumber )
{
// nothing for now
}
public override void ResetAll()
{
disableTrophies = false;
// don't reset ratings
_actPersistLookup = new Dictionary<int, ActPersist>();
acts = new List<ActPersist>();
}
public override void UnlockAll()
{
disableTrophies = true;
hasRated = true;
_actPersistLookup = new Dictionary<int, ActPersist>();
acts = new List<ActPersist>();
for ( int i = 0; i < GameConstants.ACTS; i++ )
{
var p = GetPersistForAct( i );
p.hasEverBeenCompleted = true;
}
}
public bool HasKnotBeenPlayed( string knotName )
{
foreach ( var actPersist in acts )
{
if ( actPersist.completedStoryKnots.Contains( knotName ) )
return true;
}
return false;
}
public bool HaveAllActsBeenCompleted()
{
return acts.Count == GameConstants.ACTS && acts.TrueForAll( x => x.hasEverBeenCompleted );
}
public bool HasActBeenCompleted( int i )
{
return acts[i].hasEverBeenCompleted;
}
public void CheckAndAddCharacter( string tag )
{
if ( !HasMetCharacter( tag ) )
characterTagsMet.Add( tag );
}
public bool HasMetCharacter( string tag )
{
if ( characterTagsMet == null )
{
characterTagsMet = new List<string>();
return false;
}
return characterTagsMet.Contains( tag );
}
}
}
using System;
using System.IO;
using JetBrains.Annotations;
using UnityEngine;
namespace Rebar.Persistence
{
/// <summary>
/// Base class for a project to inherit, to persist to/from a given .json file
/// </summary>
public abstract class PersistenceDataBase
{
public const string FILENAME = "persistence";
public int version; // used as part of persistence, to help address bugs written into save-games
public abstract void PrepareDataForSave();
public abstract int GetCurrentDataVersion();
public abstract void HandleDifferentVersion( int loadedVersionNumber );
public abstract void ResetAll();
public abstract void UnlockAll();
#region Save/Load
public static bool Save<T>(T data) where T : PersistenceDataBase
{
data.PrepareDataForSave();
return SaveLoadUtility.Save( data, FILENAME );
}
public static void Load<T>(out T data,
out bool loaded, out bool fromBackup, out bool fileExists) where T : PersistenceDataBase
{
data = null;
loaded = false;
fromBackup = false;
fileExists = false;
if ( !SaveLoadUtility.DoesFileOrBackupExist( FILENAME ) )
{
Debug.Log( "Persistence Load: failed, files not found" );
return;
}
fileExists = true;
// attempt main first
if ( SaveLoadUtility.CheckAndLoad( out data, FILENAME, false ) )
{
Debug.Log( "Persistence Load: main file valid" );
loaded = true;
}
else
{
// else we need to fetch the backup
if ( SaveLoadUtility.CheckAndLoad( out data, FILENAME, true ) )
{
Debug.Log( "Persistence Load: backup file valid" );
loaded = true;
fromBackup = true; // for showing a popup to the player
}
else
{
Debug.Log( "Persistence Load: complete failure" );
// a complete failure to load either file, so we need to show a serious popup to the player
return;
}
}
if ( data.version != data.GetCurrentDataVersion() )
{
data.HandleDifferentVersion( data.version );
}
}
#endregion
}
}
// maintenanceDay
// 2020123017:10
using System.IO;
using _project.utilities;
using Rebar.Persistence;
using UnityEngine;
namespace _project.Persistence
{
// ReSharper disable once ClassNeverInstantiated.Global
public class PersistenceModel : PersistenceModelBase<PersistenceData>
{
public static void CheckForPreviousFile()
{
if ( SaveLoadUtility.DoesFileExist( "mDay_persistence" ) )
{
// copy it as our main-file persistence then rename it
var path = Path.Combine( Application.persistentDataPath, "mDay_persistence.json");
var dest = Path.Combine( Application.persistentDataPath, PersistenceDataBase.FILENAME + ".json");
var back = Path.Combine( Application.persistentDataPath, "mDay_persistence_backup.json");
File.Copy( path, dest, true );
File.Copy( path, back, true );
File.Delete( path );
}
}
// expects 0-3
public static void ResetAllDataForAct( int actIndex )
{
Debug.Log( $"PersistenceModel.ResetAllDataForAct({actIndex.ToString()})" );
data.GetPersistForAct( actIndex ).ClearProgress();
ResetVariablesForAct( actIndex );
}
public static void ResetVariablesForAct( int actIndex )
{
Debug.Log( $"PersistenceModel.ResetVariablesForAct({actIndex.ToString()})" );
switch ( actIndex )
{
case 0:
// do act 1 stuff
break;
case 1:
break;
case 2:
// do act 3 stuff
break;
case 3:
// do act 4 stuff
break;
}
}
#if UNITY_EDITOR
[UnityEditor.MenuItem( GameConstants.MENU_PERS_DIR + "Reset Persistence" )]
public static void ResetAll()
{
Reset();
}
[UnityEditor.MenuItem( GameConstants.MENU_PERS_DIR + "Unlock All Persistence" )]
public static void Unlock()
{
UnlockAll();
}
/*[UnityEditor.MenuItem( GameConstants.MENU_PERS_DIR + "Unlock All Persistence (and allow trophies)" )]
public static void UnlockAndTrophies()
{
UnlockAll();
data.disableTrophies = false;
Save();
}*/
#endif
}
}
#if UNITY_EDITOR
using UnityEditor;
#endif
using System;
using UnityEngine;
namespace Rebar.Persistence
{
/// <summary>
/// Model in charge of loading/saving the player's persistence to a text file
/// </summary>
public abstract class PersistenceModelBase<T> where T : PersistenceDataBase
{
private static T _data;
public static T data
{
get
{
if ( _data == null )
{
Load();
}
return _data;
}
}
public delegate void PersistenceEvent( T data );
public static PersistenceEvent OnLoad;
public static PersistenceEvent OnLoadedBackup;
public static PersistenceEvent OnLoadError;
public static PersistenceEvent OnSave;
public static PersistenceEvent OnReset;
public static void Load()
{
PersistenceDataBase.Load( out T temp, out var loaded, out var fromBackup, out var fileExists );
if ( loaded )
{
Debug.Log( $"PersistenceModel.Load: success (from backup? {fromBackup})" );
_data = temp;
OnLoad?.Invoke( _data );
if ( fromBackup )
{
OnLoadedBackup?.Invoke( _data );
// immediately save the backup as our main file for safety
Save();
}
}
else
{
if ( fileExists ) // if it doesn't exist, assume we're playing from scratch
{
Debug.Log( "PersistenceModel.Load: failed, calling Reset" );
OnLoadError?.Invoke( null );
}
Reset();
}
}
public static void Save()
{
if ( _data == null )
{
throw new InvalidOperationException( "PersistenceModel.Save: data is null, this should not happen - you should always load first" ) ;
}
if ( PersistenceDataBase.Save( data ) )
{
OnSave?.Invoke( data );
}
}
public static void Reset()
{
Debug.Log( "PersistenceModel.Reset called" );
if ( _data == null )
{
_data = (T) Activator.CreateInstance( typeof(T) );
}
_data.ResetAll();
OnReset?.Invoke( _data );
Save();
}
public static void UnlockAll()
{
Debug.Log( "PersistenceModel.UnlockAll called" );
// don't reset the instance, as we may be storing stats data
_data.UnlockAll();
Save();
}
protected static void ClearData() => _data = null;
}
}
using System;
using System.Data;
using System.IO;
using JetBrains.Annotations;
using UnityEngine;
namespace Rebar.Persistence
{
// all our persisted classes are based around the same idea:
// - take a class that can be serialised,
// - save it with unity's JSONUtility to a specific, consistent path
// - load it later
// this class is here to handle all of that for each case with the following addons:
// - when saving, save a backup
// - when loading, check if the file is malformed, and error out
// - where it can then be called to load the backup, setting that to be the main file
public static class SaveLoadUtility
{
private const string FILE_SUFFIX = ".json";
private const string BACKUP_SUFFIX = "_backup";
[NotNull]
private static string GetPathFor(string filename, bool backup)
{
if ( string.IsNullOrEmpty( filename ) )
throw new InvalidDataException( "SaveLoadUtility.Save got an invalid filename!" );
if ( string.IsNullOrEmpty( Application.persistentDataPath ) )
throw new InvalidDataException( "SaveLoadUtility.Save got an invalid dataPath!" );
return backup
? Path.Combine( Application.persistentDataPath, filename + BACKUP_SUFFIX + FILE_SUFFIX )
: Path.Combine( Application.persistentDataPath, filename + FILE_SUFFIX );
}
private static void CheckAndMakeDirectory() // called after GetPathFor is called, so no biggy
{
var path = Application.persistentDataPath;
if ( !Directory.Exists( path ) )
{
Debug.Log( $"SaveLoadUtility: creating persistence directory: {path}" );
// catch dumb gotcha with a missing folder path
Directory.CreateDirectory( path );
}
}
public static bool DoesFileOrBackupExist( string filename ) =>
DoesFileExist( filename ) || DoesBackupExist( filename );
public static bool DoesFileExist( string filename ) => File.Exists( GetPathFor( filename, false ) );
public static bool DoesBackupExist( string filename ) => File.Exists( GetPathFor( filename, true ) );
/// <summary>
/// Save data type with filename (no extension) to the project's current persistentDataPath directory,
/// returns false if an exception occurred during saving
/// </summary>
public static bool Save<T>(T data, string filename) where T : class
{
if ( data == null )
{
Debug.LogException( new InvalidDataException($"SaveLoadUtility.Save got a null data (of type {typeof(T).ToString()}") );
return false;
}
try
{
var path = GetPathFor( filename, false );
var backupPath= GetPathFor( filename, true );
Debug.Log( $"SaveLoadUtility.Save saving to {path}" );
CheckAndMakeDirectory();
var write = JsonUtility.ToJson( data );
if ( File.Exists( path ) )
{
// if the file exists, but is empty, then ignore it
if ( string.IsNullOrEmpty( File.ReadAllText( path ) ) )
{
// do nothing
Debug.LogWarning( "SaveLoadUtility.Save: not copying main file to backup as main file is empty" );
}
else
{
File.Copy( path, backupPath, true );
}
}
File.WriteAllText( path, write );
}
catch ( Exception e )
{
Debug.LogException( e );
return false;
}
return true;
}
/// <summary>
/// Attempts to load from the given filename as the given data. If if fails in some form it will return false (assume the file cannot be saved)
/// </summary>
public static bool CheckAndLoad<T>( out T data, string filename, bool backup ) where T : class
{
data = null;
try
{
var path = GetPathFor( filename, backup );
Debug.Log( $"SaveLoadUtility.CheckAndLoad attempting to load {path}" );
CheckAndMakeDirectory();
var text = File.ReadAllText( path );
if ( string.IsNullOrEmpty( text ) )
throw new DataException( "SaveLoadUtility.CheckAndLoad: ReadAllText from path got NullOrEmpty" );
data = JsonUtility.FromJson<T>( text );
}
catch ( Exception e )
{
Debug.LogWarning( $"SaveLoadUtility.CheckAndLoad: error fetching/parsing {filename}'s JSON:" );
Debug.LogException( e );
return false;
}
return true;
}
}
}
namespace _project.splash
{
public class SplashScene : MonoBehaviour
{
// <awake, references, etc>
private void OnPersistenceLoadError( PersistenceData data )
{
errorsToShow.Add(
"An error occurred when trying to load your progress file, and the backup could not be loaded (i.e: all your progress is gone)"
+ "\n\nIf you want to attempt recovery, please contact me at bighandinsky@gmail.com before continuing." );
}
private void OnPersistenceLoadedBackup( PersistenceData data )
{
errorsToShow.Add( "An error occurred when trying to load your progress file. A backup file has been loaded instead." );
}
private void OnSettingsLoadError( SettingsData data )
{
errorsToShow.Add( "An error occurred when trying to load your settings file, and the backup could not be loaded, all settings have been reset to defaults" );
}
private void OnSettingsLoadedBackup( SettingsData data )
{
errorsToShow.Add( "An error occurred when trying to load your settings file. A backup file has been loaded instead." );
}
private void OnTrophyLoadError()
{
errorsToShow.Add(
"An error occurred when trying to load your achievements file, and the backup could not be loaded (i.e: all your achievement progress is gone)"
+ "\n\nIf you want to attempt recovery, please contact me at bighandinsky@gmail.com before continuing." );
}
private void OnTrophyLoadedBackup()
{
errorsToShow.Add( "An error occurred when trying to load your achievements file. A backup file has been loaded instead." );
}
private IEnumerator Notices()
{
PersistenceModel.CheckForPreviousFile();
// call the persistence/settings/trophy systems to load, in case an error is encountered, show messages
PersistenceModel.Load();
SettingsModel.Load( false );
// trophy manager works solo
yield return new WaitForEndOfFrame();
foreach ( var body in errorsToShow )
{
bool awaitingDialog = true;
errorDialog.OpenWithString(
"Error",
body,
i =>
{
awaitingDialog = false;
} );
while ( awaitingDialog )
{
yield return null;
}
}
// <then continue with the splash scene>
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment