Persistence/PersistenceManager.cs
using HC3.Terrain;
using System;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace HC3.Persistence;
/// <summary>
/// Handles saving and loading the game.
/// </summary>
public sealed class PersistenceManager : Component,
ISaveDataProperty<PersistenceManager.SaveMetadata>,
Component.ISceneStage
{
public const string SaveDirectoryName = "Saves";
/// <summary>
/// Increment this whenever a save-breaking change is made. Saves with a different version are automatically deleted.
/// </summary>
public const int CurrentSaveVersion = 2;
/// <summary>
/// Singleton
/// </summary>
public static PersistenceManager? Instance { get; private set; }
[ConVar( "hc3.current_save", ConVarFlags.Saved | ConVarFlags.Replicated )]
public static string CurrentSaveName { get; set; } = "Default";
[Property]
public int AutoSavePeriodMinutes { get; set; } = 2;
public DateTime? CreatedTimeUtc { get; private set; }
private RealTimeSince _lastAutoSave;
public bool IsLoading { get; private set; }
protected override void OnAwake()
{
CreatedTimeUtc = DateTime.UtcNow;
}
protected override void OnStart()
{
Instance = this;
// Only the host should load the game
if ( !Networking.IsHost )
return;
if ( !Load() )
{
Scene.GetAllComponents<TerrainGenerator>()
.FirstOrDefault()?
.Generate();
}
}
protected override void OnFixedUpdate()
{
// Only the host should save the game
if ( !Networking.IsHost )
return;
if ( AutoSavePeriodMinutes > 0 && _lastAutoSave > AutoSavePeriodMinutes * 60 )
{
AutoSave();
}
}
private string GetSavePath( string saveName = null ) =>
System.IO.Path.Combine( SaveDirectoryName, $"{saveName ?? CurrentSaveName}.json" );
public bool NewGame( string name )
{
if ( FileSystem.Data.FileExists( GetSavePath( name ) ) )
{
return false;
}
CurrentSaveName = name;
Scene.Load( Scene.Source );
return true;
}
public bool LoadGame( string name )
{
if ( !FileSystem.Data.FileExists( GetSavePath( name ) ) )
{
return false;
}
CurrentSaveName = name;
Scene.Load( Scene.Source );
return true;
}
[Button( "Save Game", "save" )]
private void SaveButton() => Save();
public void Save( string? savePath = null )
{
savePath ??= GetSavePath( CurrentSaveName );
var data = new JsonObject();
var timer = Stopwatch.StartNew();
data.Add( "Version", CurrentSaveVersion );
foreach ( var property in FindProperties() )
{
try
{
data.Add( property.PropertyName, property.WriteValue( Scene ) );
}
catch ( Exception ex )
{
Log.Error( ex, $"Exception while saving property \"{property.PropertyName}\"" );
}
}
NotificationPanel.Instance?.Broadcast( $"Saved \"{CurrentSaveName}\" in {timer.Elapsed.TotalMilliseconds:F2}ms", "save" );
if ( !FileSystem.Data.DirectoryExists( SaveDirectoryName ) )
{
FileSystem.Data.CreateDirectory( SaveDirectoryName );
}
FileSystem.Data.WriteJson( savePath, data );
_lastAutoSave = 0;
}
private void AutoSave()
{
// TODO: save to different file?
Save();
_lastAutoSave = 0;
}
[Button( "Delete Save", "delete" )]
public void DeleteCurrentSave()
{
if ( Game.IsPlaying ) return;
FileSystem.Data.DeleteFile( GetSavePath( CurrentSaveName ) );
}
[Button( "Load Save", "refresh" )]
private void LoadButton() => Load();
public bool Load( string? savePath = null )
{
savePath ??= GetSavePath( CurrentSaveName );
if ( !FileSystem.Data.DirectoryExists( SaveDirectoryName ) ) return false;
if ( !FileSystem.Data.FileExists( savePath ) ) return false;
if ( FileSystem.Data.ReadJson<JsonObject>( savePath ) is not { } data ) return false;
// Version check — delete incompatible saves and start fresh
var savedVersion = data.TryGetPropertyValue( "Version", out var versionNode )
? versionNode?.GetValue<int>() ?? 0
: 0;
if ( savedVersion != CurrentSaveVersion )
{
Log.Warning( $"[PersistenceManager] Save version mismatch (save={savedVersion}, current={CurrentSaveVersion}). Deleting incompatible save." );
FileSystem.Data.DeleteFile( savePath );
return false;
}
IsLoading = true;
var timer = Stopwatch.StartNew();
foreach ( var property in FindProperties() )
{
if ( !data.TryGetPropertyValue( property.PropertyName, out var value ) || value is null )
{
continue;
}
try
{
property.ReadValue( Scene, value );
}
catch ( Exception ex )
{
Log.Error( ex, $"Exception while loading property \"{property.PropertyName}\"" );
}
}
Log.Info( $"Loaded game in {timer.Elapsed.TotalMilliseconds:F2}ms" );
_lastAutoSave = 0;
IsLoading = false;
GridManager.Instance.DirtyRegions();
return true;
}
public IEnumerable<string> GetAllSaveNames()
{
if ( !FileSystem.Data.DirectoryExists( SaveDirectoryName ) )
return Enumerable.Empty<string>();
var files = FileSystem.Data.FindFile( SaveDirectoryName, "*.json" );
return files
.Select( x => System.IO.Path.GetFileNameWithoutExtension( x ) )
.Where( x => x != null )
.ToArray();
}
/// <summary>
/// Save properties can come from <see cref="Component"/>s, <see cref="GameObjectSystem"/>s,
/// or little helper classes with public parameterless constructors.
/// </summary>
private ImmutableArray<ISaveDataProperty> FindProperties()
{
var found = new List<ISaveDataProperty>();
// Find (singleton) Components or GameObjectSystems implementing ISaveDataProperty
foreach ( var component in Scene.GetAll<ISaveDataProperty>() )
{
found.Add( component );
}
// Find types implementing ISaveDataProperty that aren't components or systems
foreach ( var type in TypeLibrary.GetTypes<ISaveDataProperty>() )
{
if ( type.IsAbstract ) continue;
if ( type.TargetType.IsAssignableTo( typeof( Component ) ) ) continue;
if ( type.TargetType.IsAssignableTo( typeof( GameObjectSystem ) ) ) continue;
// Try to create an instance, fail silently
try
{
found.Add( type.Create<ISaveDataProperty>() );
}
catch
{
//
}
}
return found
.DistinctBy( x => x.PropertyName )
.OrderBy( x => x.PropertyOrder )
.ThenBy( x => x.PropertyName )
.ToImmutableArray();
}
private record SaveMetadata( string Name, DateTime CreatedTimeUtc, DateTime ModifiedTimeUtc );
string ISaveDataProperty.PropertyName => "Metadata";
int ISaveDataProperty.PropertyOrder => int.MinValue;
SaveMetadata ISaveDataProperty<SaveMetadata>.WriteValue( Scene scene ) =>
new( CurrentSaveName, CreatedTimeUtc ?? DateTime.UtcNow, DateTime.UtcNow );
void ISaveDataProperty<SaveMetadata>.ReadValue( Scene scene, SaveMetadata model )
{
CurrentSaveName = model.Name;
CreatedTimeUtc = model.CreatedTimeUtc;
}
internal static async Task WaitForLoad()
{
while ( Instance.IsLoading )
{
await Instance.Task.Delay( 100 );
}
}
}