copy from github

This commit is contained in:
Sebastian Bularca
2026-03-27 15:14:08 +01:00
parent 4aefcfd47f
commit b5d13e86d9
63 changed files with 1706 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
namespace Jovian.SaveSystem {
/// <summary>
/// Converts typed data to and from byte arrays.
/// Implementations define the format (JSON, binary, etc.).
/// </summary>
public interface ISaveSerializer {
byte[] Serialize<TData>(TData data);
TData Deserialize<TData>(byte[] payload);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27f27474b2fc12f47b9eea77568ac3c6

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace Jovian.SaveSystem {
/// <summary>
/// Manages save slot allocation, session tracking, and auto-save rotation.
/// </summary>
public interface ISaveSlotManager {
SaveSlotInfo AllocateManualSlot(string sessionId);
SaveSlotInfo AllocateAutoSlot(string sessionId);
SaveSlotInfo AllocateQuickSlot(string sessionId);
IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId);
IReadOnlyList<SaveSessionInfo> GetAllSessions();
string CreateSession();
void DeleteSlot(SaveSlotInfo slot);
void DeleteSession(string sessionId);
bool HasAnySaves();
void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion);
void PersistIndex();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd45cbabfe17114419035e9591ac06ce

22
Runtime/ISaveStorage.cs Normal file
View File

@@ -0,0 +1,22 @@
using System.Threading.Tasks;
namespace Jovian.SaveSystem {
/// <summary>
/// Reads and writes raw byte arrays to a persistent location.
/// Has no knowledge of save data types or serialization formats.
/// </summary>
public interface ISaveStorage {
void Write(string path, byte[] data);
byte[] Read(string path);
bool Exists(string path);
void Delete(string path);
string[] List(string directoryPath);
void CreateDirectory(string path);
Task WriteAsync(string path, byte[] data);
Task<byte[]> ReadAsync(string path);
Task<bool> ExistsAsync(string path);
Task DeleteAsync(string path);
Task<string[]> ListAsync(string directoryPath);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f445979ba5d9414abd75819f53bfaba

24
Runtime/ISaveSystem.cs Normal file
View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Jovian.SaveSystem {
/// <summary>
/// Top-level facade for the save system. Orchestrates serialization,
/// storage, and slot management. This is the main API the game interacts with.
/// </summary>
public interface ISaveSystem {
string CreateSession();
bool HasAnySaves();
void Save<TData>(string sessionId, TData data, SaveSlotType slotType);
TData Load<TData>(SaveSlotInfo slot);
Task SaveAsync<TData>(string sessionId, TData data, SaveSlotType slotType);
Task<TData> LoadAsync<TData>(SaveSlotInfo slot);
IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId);
IReadOnlyList<SaveSessionInfo> GetAllSessions();
void DeleteSlot(SaveSlotInfo slot);
void DeleteSession(string sessionId);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54cb074c17a51c74d93aadb3ce7b2e47

View File

@@ -0,0 +1,16 @@
{
"name": "Jovian.SaveSystem",
"rootNamespace": "Jovian.SaveSystem",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a19ae8824b9d43447be860535961727d
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Runtime/Model.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1f99186168750fb469321dee33fd3397
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
using System;
using Newtonsoft.Json.Linq;
namespace Jovian.SaveSystem {
/// <summary>
/// On-disk wrapper that pairs minimal metadata with the game data payload.
/// Payload is stored as JToken so it remains readable JSON when using JsonSaveSerializer.
/// </summary>
[Serializable]
public sealed class SaveEnvelope {
public int version;
public long timestampUtc;
public SaveSlotType slotType;
public JToken payload;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ffa4184b4e99e3947ac73fa747d346d7

View File

@@ -0,0 +1,6 @@
namespace Jovian.SaveSystem {
public enum SaveFormat {
Json,
Binary
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a118c9ab8d89ad546ad67e8dc1a71c98

View File

@@ -0,0 +1,7 @@
namespace Jovian.SaveSystem {
public enum SaveSlotType {
Manual,
Auto,
Quick
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f1e478bd3e3fec43a55f4efec51577c

138
Runtime/SaveSystem.cs Normal file
View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Jovian.SaveSystem {
/// <summary>
/// Facade that orchestrates serialization, storage, and slot management.
/// Thread-safe: uses SemaphoreSlim for async and lock for sync operations.
/// </summary>
public sealed class SaveSystem : ISaveSystem {
private readonly ISaveSerializer serializer;
private readonly ISaveStorage storage;
private readonly ISaveSlotManager slotManager;
private readonly int saveVersion;
private readonly object syncLock = new object();
private readonly SemaphoreSlim asyncLock = new SemaphoreSlim(1, 1);
public SaveSystem(
ISaveSerializer serializer,
ISaveStorage storage,
ISaveSlotManager slotManager,
SaveSystemSettings settings) {
this.serializer = serializer;
this.storage = storage;
this.slotManager = slotManager;
saveVersion = settings.currentSaveVersion;
}
public string CreateSession() {
return slotManager.CreateSession();
}
public bool HasAnySaves() {
return slotManager.HasAnySaves();
}
public void Save<TData>(string sessionId, TData data, SaveSlotType slotType) {
lock(syncLock) {
SaveSlotInfo slot = AllocateSlot(sessionId, slotType);
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
SaveEnvelope envelope = new SaveEnvelope {
version = saveVersion,
timestampUtc = timestamp,
slotType = slotType,
payload = JToken.FromObject(data)
};
byte[] envelopeBytes = serializer.Serialize(envelope);
storage.Write(slot.filePath, envelopeBytes);
slotManager.UpdateSlotMetadata(slot, timestamp, saveVersion);
slotManager.PersistIndex();
Debug.Log($"[SaveSystem] Saved {slotType} to {slot.filePath}");
}
}
public TData Load<TData>(SaveSlotInfo slot) {
lock(syncLock) {
return LoadInternal<TData>(slot);
}
}
public async Task SaveAsync<TData>(string sessionId, TData data, SaveSlotType slotType) {
await asyncLock.WaitAsync().ConfigureAwait(false);
try {
SaveSlotInfo slot = AllocateSlot(sessionId, slotType);
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
SaveEnvelope envelope = new SaveEnvelope {
version = saveVersion,
timestampUtc = timestamp,
slotType = slotType,
payload = JToken.FromObject(data)
};
byte[] envelopeBytes = serializer.Serialize(envelope);
await storage.WriteAsync(slot.filePath, envelopeBytes).ConfigureAwait(false);
slotManager.UpdateSlotMetadata(slot, timestamp, saveVersion);
slotManager.PersistIndex();
Debug.Log($"[SaveSystem] Saved {slotType} to {slot.filePath}");
} finally {
asyncLock.Release();
}
}
public async Task<TData> LoadAsync<TData>(SaveSlotInfo slot) {
await asyncLock.WaitAsync().ConfigureAwait(false);
try {
return LoadInternal<TData>(slot);
} finally {
asyncLock.Release();
}
}
public IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId) {
return slotManager.GetSlots(sessionId);
}
public IReadOnlyList<SaveSessionInfo> GetAllSessions() {
return slotManager.GetAllSessions();
}
public void DeleteSlot(SaveSlotInfo slot) {
lock(syncLock) {
slotManager.DeleteSlot(slot);
}
}
public void DeleteSession(string sessionId) {
lock(syncLock) {
slotManager.DeleteSession(sessionId);
}
}
private SaveSlotInfo AllocateSlot(string sessionId, SaveSlotType slotType) {
return slotType switch {
SaveSlotType.Manual => slotManager.AllocateManualSlot(sessionId),
SaveSlotType.Auto => slotManager.AllocateAutoSlot(sessionId),
SaveSlotType.Quick => slotManager.AllocateQuickSlot(sessionId),
_ => throw new ArgumentOutOfRangeException(nameof(slotType), slotType, "Unknown slot type.")
};
}
private TData LoadInternal<TData>(SaveSlotInfo slot) {
byte[] envelopeBytes = storage.Read(slot.filePath);
SaveEnvelope envelope = serializer.Deserialize<SaveEnvelope>(envelopeBytes);
return envelope.payload.ToObject<TData>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1ef842c1c48d58641b6016af3c512d39

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee824fed683906741ab35b553a76486a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using Newtonsoft.Json;
namespace Jovian.SaveSystem {
/// <summary>
/// Serializes data to an obfuscated binary format.
/// Pipeline: JSON string → UTF-8 bytes → XOR obfuscation → DeflateStream compression.
/// </summary>
public sealed class BinarySaveSerializer : ISaveSerializer {
private readonly byte[] keyBytes;
private readonly JsonSerializerSettings serializerSettings;
public BinarySaveSerializer(string obfuscationKey) {
if(string.IsNullOrEmpty(obfuscationKey)) {
throw new ArgumentException("Obfuscation key must not be null or empty.", nameof(obfuscationKey));
}
keyBytes = Encoding.UTF8.GetBytes(obfuscationKey);
serializerSettings = new JsonSerializerSettings {
TypeNameHandling = TypeNameHandling.None,
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.None
};
}
public byte[] Serialize<TData>(TData data) {
string json = JsonConvert.SerializeObject(data, serializerSettings);
byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
byte[] obfuscated = ApplyXor(jsonBytes);
return Compress(obfuscated);
}
public TData Deserialize<TData>(byte[] payload) {
if(payload == null || payload.Length == 0) {
throw new ArgumentException("Payload is null or empty.", nameof(payload));
}
byte[] decompressed = Decompress(payload);
byte[] deobfuscated = ApplyXor(decompressed);
string json = Encoding.UTF8.GetString(deobfuscated);
return JsonConvert.DeserializeObject<TData>(json, serializerSettings);
}
private byte[] ApplyXor(byte[] data) {
byte[] result = new byte[data.Length];
for(int i = 0; i < data.Length; i++) {
result[i] = (byte)(data[i] ^ keyBytes[i % keyBytes.Length]);
}
return result;
}
private static byte[] Compress(byte[] data) {
using(MemoryStream output = new MemoryStream()) {
using(DeflateStream deflate = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true)) {
deflate.Write(data, 0, data.Length);
}
return output.ToArray();
}
}
private static byte[] Decompress(byte[] data) {
using(MemoryStream input = new MemoryStream(data)) {
using(DeflateStream deflate = new DeflateStream(input, CompressionMode.Decompress)) {
using(MemoryStream output = new MemoryStream()) {
deflate.CopyTo(output);
return output.ToArray();
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f7af3b3f06e67b408b0f52089acdeea

View File

@@ -0,0 +1,34 @@
using System;
using System.Text;
using Newtonsoft.Json;
namespace Jovian.SaveSystem {
/// <summary>
/// Serializes data to/from JSON using Newtonsoft.Json.
/// </summary>
public sealed class JsonSaveSerializer : ISaveSerializer {
private readonly JsonSerializerSettings serializerSettings;
public JsonSaveSerializer() {
serializerSettings = new JsonSerializerSettings {
TypeNameHandling = TypeNameHandling.None,
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented
};
}
public byte[] Serialize<TData>(TData data) {
string json = JsonConvert.SerializeObject(data, serializerSettings);
return Encoding.UTF8.GetBytes(json);
}
public TData Deserialize<TData>(byte[] payload) {
if(payload == null || payload.Length == 0) {
throw new ArgumentException("Payload is null or empty.", nameof(payload));
}
string json = Encoding.UTF8.GetString(payload);
return JsonConvert.DeserializeObject<TData>(json, serializerSettings);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2edaf26270d17a145a2c7f1fa7080d3a

8
Runtime/Settings.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a4191975d6dce814bbda5a57c1d17548
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,48 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
namespace Jovian.SaveSystem {
/// <summary>
/// Configuration for the save system. Stored as JSON in ProjectSettings/SaveSystemSettings.json.
/// </summary>
[Serializable]
public sealed class SaveSystemSettings {
private const string SettingsPath = "ProjectSettings/SaveSystemSettings.json";
public SaveFormat saveFormat = SaveFormat.Json;
public int maxAutoSavesPerSession = 3;
public int currentSaveVersion = 1;
public string obfuscationKey = "default-key";
public string saveDirectoryName = "saves";
public static SaveSystemSettings Load() {
if(!File.Exists(SettingsPath)) {
return new SaveSystemSettings();
}
try {
var json = File.ReadAllText(SettingsPath);
return JsonConvert.DeserializeObject<SaveSystemSettings>(json) ?? new SaveSystemSettings();
} catch(Exception e) {
Debug.LogWarning($"[SaveSystem] Failed to load settings: {e.Message}. Using defaults.");
return new SaveSystemSettings();
}
}
public void Save() {
try {
var directory = Path.GetDirectoryName(SettingsPath);
if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
var json = JsonConvert.SerializeObject(this, Formatting.Indented);
File.WriteAllText(SettingsPath, json);
} catch(Exception e) {
Debug.LogError($"[SaveSystem] Failed to save settings: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 86b7c6be79eeb6a48aa96d9041dcb5ed

8
Runtime/Slots.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b7eaf086b5f24df42a8d6a79de5c5c52
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
using System;
namespace Jovian.SaveSystem {
/// <summary>
/// Describes a save session (a new game playthrough).
/// </summary>
[Serializable]
public sealed class SaveSessionInfo {
public string sessionId;
public long creationDateUtc;
public long lastSaveDateUtc;
public DateTime CreationDateTime => DateTimeOffset.FromUnixTimeMilliseconds(creationDateUtc).UtcDateTime;
public DateTime LastSaveDateTime => DateTimeOffset.FromUnixTimeMilliseconds(lastSaveDateUtc).UtcDateTime;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f03201423cd9f364c85bc6ce44386fbd

View File

@@ -0,0 +1,26 @@
using System;
namespace Jovian.SaveSystem {
/// <summary>
/// Describes a single save slot within a session.
/// </summary>
[Serializable]
public sealed class SaveSlotInfo {
public string sessionId;
public SaveSlotType slotType;
public int slotNumber;
public string filePath;
public long timestampUtc;
public int saveVersion;
public string DisplayLabel =>
slotType switch {
SaveSlotType.Manual => $"Manual Save {slotNumber}",
SaveSlotType.Auto => $"Auto Save {slotNumber}",
SaveSlotType.Quick => "Quick Save",
_ => "Unknown"
};
public DateTime TimestampDateTime => DateTimeOffset.FromUnixTimeMilliseconds(timestampUtc).UtcDateTime;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c10256ba563528a4c9d92d158c6c009c

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
namespace Jovian.SaveSystem {
/// <summary>
/// Manages save slot allocation, session tracking, and auto-save rotation.
/// Maintains a persistent index file that tracks all sessions and their slots.
/// </summary>
public sealed class SaveSlotManager : ISaveSlotManager {
private const string IndexFileName = "index.json";
private const string ManualPrefix = "manual_";
private const string AutoPrefix = "auto_";
private const string QuickFileName = "quick.sav";
private const string SaveExtension = ".sav";
private readonly ISaveStorage storage;
private readonly int maxAutoSaves;
private SaveIndex index;
public SaveSlotManager(ISaveStorage storage, SaveSystemSettings settings) {
this.storage = storage;
maxAutoSaves = settings.maxAutoSavesPerSession;
LoadIndex();
}
public string CreateSession() {
string sessionId = Guid.NewGuid().ToString("N");
SaveSessionInfo session = new SaveSessionInfo {
sessionId = sessionId,
creationDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
lastSaveDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.sessions.Add(session);
storage.CreateDirectory(sessionId);
PersistIndex();
return sessionId;
}
public SaveSlotInfo AllocateManualSlot(string sessionId) {
List<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
int nextNumber = sessionSlots
.Where(s => s.slotType == SaveSlotType.Manual)
.Select(s => s.slotNumber)
.DefaultIfEmpty(0)
.Max() + 1;
SaveSlotInfo slot = new SaveSlotInfo {
sessionId = sessionId,
slotType = SaveSlotType.Manual,
slotNumber = nextNumber,
filePath = $"{sessionId}/{ManualPrefix}{nextNumber:D3}{SaveExtension}",
timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.slots.Add(slot);
return slot;
}
public SaveSlotInfo AllocateAutoSlot(string sessionId) {
List<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
List<SaveSlotInfo> autoSlots = sessionSlots
.Where(s => s.slotType == SaveSlotType.Auto)
.OrderBy(s => s.slotNumber)
.ToList();
if(autoSlots.Count >= maxAutoSaves) {
// Rotate: reuse the oldest slot
var oldest = autoSlots.OrderBy(s => s.timestampUtc).First();
oldest.timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return oldest;
}
int nextNumber = autoSlots.Count + 1;
SaveSlotInfo slot = new SaveSlotInfo {
sessionId = sessionId,
slotType = SaveSlotType.Auto,
slotNumber = nextNumber,
filePath = $"{sessionId}/{AutoPrefix}{nextNumber:D3}{SaveExtension}",
timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.slots.Add(slot);
return slot;
}
public SaveSlotInfo AllocateQuickSlot(string sessionId) {
List<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
SaveSlotInfo existingQuick = sessionSlots.FirstOrDefault(s => s.slotType == SaveSlotType.Quick);
if(existingQuick != null) {
existingQuick.timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return existingQuick;
}
SaveSlotInfo slot = new SaveSlotInfo {
sessionId = sessionId,
slotType = SaveSlotType.Quick,
slotNumber = 1,
filePath = $"{sessionId}/{QuickFileName}",
timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.slots.Add(slot);
return slot;
}
public IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId) {
return index.slots
.Where(s => s.sessionId == sessionId)
.OrderByDescending(s => s.timestampUtc)
.ToList();
}
public IReadOnlyList<SaveSessionInfo> GetAllSessions() {
return index.sessions
.OrderByDescending(s => s.lastSaveDateUtc)
.ToList();
}
public bool HasAnySaves() {
return index.slots.Count > 0;
}
public void DeleteSlot(SaveSlotInfo slot) {
storage.Delete(slot.filePath);
index.slots.RemoveAll(s => s.filePath == slot.filePath);
PersistIndex();
}
public void DeleteSession(string sessionId) {
List<SaveSlotInfo> slotsToDelete = index.slots
.Where(s => s.sessionId == sessionId)
.ToList();
foreach(SaveSlotInfo slot in slotsToDelete) {
storage.Delete(slot.filePath);
}
index.slots.RemoveAll(s => s.sessionId == sessionId);
index.sessions.RemoveAll(s => s.sessionId == sessionId);
PersistIndex();
}
public void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion) {
slot.timestampUtc = timestampUtc;
slot.saveVersion = saveVersion;
SaveSessionInfo session = index.sessions.FirstOrDefault(s => s.sessionId == slot.sessionId);
if(session != null) {
session.lastSaveDateUtc = timestampUtc;
}
}
public void PersistIndex() {
string json = JsonConvert.SerializeObject(index, Formatting.Indented);
byte[] bytes = Encoding.UTF8.GetBytes(json);
storage.Write(IndexFileName, bytes);
}
private void LoadIndex() {
if(!storage.Exists(IndexFileName)) {
index = new SaveIndex();
return;
}
try {
byte[] bytes = storage.Read(IndexFileName);
string json = Encoding.UTF8.GetString(bytes);
index = JsonConvert.DeserializeObject<SaveIndex>(json) ?? new SaveIndex();
} catch(Exception) {
index = new SaveIndex();
}
}
private List<SaveSlotInfo> GetOrCreateSessionSlots(string sessionId) {
if(!index.sessions.Any(s => s.sessionId == sessionId)) {
SaveSessionInfo session = new SaveSessionInfo {
sessionId = sessionId,
creationDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
lastSaveDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.sessions.Add(session);
storage.CreateDirectory(sessionId);
}
return index.slots.Where(s => s.sessionId == sessionId).ToList();
}
/// <summary>
/// Internal index structure persisted to disk.
/// </summary>
[Serializable]
private sealed class SaveIndex {
public List<SaveSessionInfo> sessions = new List<SaveSessionInfo>();
public List<SaveSlotInfo> slots = new List<SaveSlotInfo>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9498521d42a48944ba38a961e8e82459

8
Runtime/Storage.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a74728833fd95214a8e55433cc901ac6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,107 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
namespace Jovian.SaveSystem {
/// <summary>
/// File-system backed storage. Base path is typically Application.persistentDataPath.
/// All paths passed to methods are relative to the constructed base path.
/// </summary>
public sealed class FileSystemSaveStorage : ISaveStorage {
private readonly string basePath;
public FileSystemSaveStorage(string rootPath, string saveDirectoryName) {
basePath = Path.Combine(rootPath, saveDirectoryName);
}
private string ResolvePath(string relativePath) {
return Path.Combine(basePath, relativePath);
}
public void CreateDirectory(string path) {
string fullPath = ResolvePath(path);
if(!Directory.Exists(fullPath)) {
Directory.CreateDirectory(fullPath);
}
}
public void Write(string path, byte[] data) {
string fullPath = ResolvePath(path);
EnsureDirectory(fullPath);
File.WriteAllBytes(fullPath, data);
}
public byte[] Read(string path) {
string fullPath = ResolvePath(path);
if(!File.Exists(fullPath)) {
throw new FileNotFoundException($"Save file not found: {fullPath}");
}
return File.ReadAllBytes(fullPath);
}
public bool Exists(string path) {
return File.Exists(ResolvePath(path));
}
public void Delete(string path) {
string fullPath = ResolvePath(path);
if(File.Exists(fullPath)) {
File.Delete(fullPath);
}
}
public string[] List(string directoryPath) {
string fullPath = ResolvePath(directoryPath);
if(!Directory.Exists(fullPath)) {
return Array.Empty<string>();
}
return Directory.GetFiles(fullPath)
.Select(f => Path.GetFileName(f))
.ToArray();
}
public async Task WriteAsync(string path, byte[] data) {
string fullPath = ResolvePath(path);
EnsureDirectory(fullPath);
using(FileStream stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true)) {
await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false);
}
}
public async Task<byte[]> ReadAsync(string path) {
string fullPath = ResolvePath(path);
if(!File.Exists(fullPath)) {
throw new FileNotFoundException($"Save file not found: {fullPath}");
}
using(FileStream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true)) {
byte[] buffer = new byte[stream.Length];
await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
return buffer;
}
}
public Task<bool> ExistsAsync(string path) {
return Task.FromResult(Exists(path));
}
public Task DeleteAsync(string path) {
Delete(path);
return Task.CompletedTask;
}
public Task<string[]> ListAsync(string directoryPath) {
return Task.FromResult(List(directoryPath));
}
private static void EnsureDirectory(string filePath) {
string directory = Path.GetDirectoryName(filePath);
if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cba707f119fcd0143be086bff73bcd9d