Save System EN - antonprv/LoneBrawler GitHub Wiki
๐ฌ๐ง English | ๐ท๐บ ะ ัััะบะธะน
- Overview
- GameProgress - the data model
- SaveLoadService
- IProgressReader / IProgressWriter
- Live Progress Sync
- Serialisable Collections
- Custom Vector Types
All mutable runtime state lives in a single serialisable root object (GameProgress). Serialisation uses Newtonsoft Json.NET. Storage is PlayerPrefs for local saves and the Yandex cloud API when cloud saves are enabled. Every component that owns persistent state implements IProgressReader, IProgressWriter, or both. SaveLoadService collects all writers registered in GameFactory.ProgressWriters and calls them in one pass on save.
[Serializable]
public sealed class GameProgress
{
public long SaveTimeUTC;
public WorldData PlayerWorldData;
public PLayerState PLayerState;
public PlayerStats PlayerStats;
public EnemiesKilled EnemiesKilled;
public SoulsCollected SoulsCollected;
public BuffsRegistry BuffsRegistry;
public InventorySaveData Inventory;
}WorldData - holds TransformOnLevel (the player's last position, rotation, and scene name) plus LastTeleportUniqueName and LastTeleportTimeUTC. IsValid() returns false if TransformOnLevel was never written (initial save state), stopping a teleport-based spawn before the player has moved at all.
PLayerState - MaxHealth and CurrentHealth. Initialised from IPlayerDataSubervice on new game. IsValid() returns false when both values are zero (default-constructed, never populated).
PlayerStats - MovementSpeed, RotationSpeed, Damage, Range, Radius, MaxEnemiesHit. These are the modified values - if the player has active Constant buffs, the multiplied numbers are stored here. This is intentional: restoring a Constant buff must not re-apply the multiplier on top of already-modified stats.
EnemiesKilled - contains HashSetData<string> ClearedSpawners. When all enemies from a spawner are dead, the spawner's unique ID goes in here. On the next load the spawner reads ReadProgress and skips spawning if its ID is already in the set.
SoulsCollected - Amount (current balance) and DictionaryData<string, Vector3> LeftSpawners (spawner IDs to positions for souls that dropped but haven't been collected yet).
BuffsRegistry - List<BuffSaveEntry>, each entry holding ClassName, ActivationType, State, and RemainingDuration. See Buff System for restore details.
InventorySaveData - two List<InventorySlotData> (main inventory + hotbar) and SelectedHotbarIndex. Each InventorySlotData holds BuffClassName and Count.
Each section has IsValid(). GameProgress exposes composite guards:
public bool IsWorldDataValid() => PlayerWorldData != null && PlayerWorldData.IsValid();
public bool IsPlayerStatsValid() => PlayerStats != null && PlayerStats.IsValid();
public bool IsPlayerDataValid() => PLayerState != null && PLayerState.IsValid();LoadProgressState calls these to decide whether to load the save or build a fresh GameProgress.
SaveLoadService coordinates saving and loading. All dependencies come through constructor injection.
public void SaveProgress(bool isInitial = false, bool skipUTC = false)
{
if (!isInitial)
{
foreach (IProgressWriter writer in _gameFactory.ProgressWriters)
writer?.WriteToProgress(_progressService.Progress);
_buffTracker.WriteToProgress(_progressService.Progress);
_progressService.Progress.Inventory = _inventoryService.GetSaveData();
}
if (!skipUTC)
_progressService.Progress.SaveTimeUTC = isInitial ? 0 : _timeService.UtcNow.Ticks;
_soundService.WriteToSettings(_progressService.SystemSettings);
_playerPrefs.SetString(_progressService.ProgressKey,
_progressService.Progress.ToSerialized());
_playerPrefs.SetString(_progressService.SystemSettingsKey,
_progressService.SystemSettings.ToSerialized());
_playerPrefs.Save();
}isInitial is true only when starting a new game. In that case the writer pass is skipped entirely - writing gameplay state from the previous session into a fresh GameProgress would corrupt the new run.
Sound settings (SystemSettings) always save regardless of isInitial - volume preferences are session-independent.
Both deserialise from PlayerPrefs and return null if the key is absent. LoadProgressState handles the null case and constructs a default GameProgress when needed.
public interface IProgressReader
{
void ReadProgress(GameProgress progress);
}
public interface IProgressWriter
{
void WriteToProgress(GameProgress progress);
}Any component that needs to persist state implements one or both. GameFactory registers every created component that carries these interfaces into ProgressReaders and ProgressWriters lists.
LoadLevelState
|
|- GameFactory.CreatePlayer() -> registers PlayerHealth, PlayerMove
|- GameFactory.CreateEnemySpawners() -> registers EnemyLootSpawner instances
|
+- InformProgressReaders(GameProgress)
| calls ReadProgress() on every registered IProgressReader
v
PlayerHealth restores CurrentHealth / MaxHealth
PlayerMove restores MovementSpeed / RotationSpeed
EnemyLootSpawner checks ClearedSpawners -> skips or spawns
SoulsTrackerService restores souls amount
SaveLoadService.SaveProgress()
|
+- foreach writer in GameFactory.ProgressWriters:
writer.WriteToProgress(progress)
|
PlayerHealth writes CurrentHealth / MaxHealth -> PLayerState
PlayerMove writes MovementSpeed -> PlayerStats
PlayerHealth writes transform -> WorldData (via SaveComponent)
LiveProgressSync is a ZenjexBehaviour that manages automatic saves during gameplay.
private IEnumerator SyncLoop()
{
var interval = new WaitForSeconds(SyncIntervalSeconds); // 5 seconds
while (true)
{
yield return interval;
_saveLoad.SaveProgress();
}
}The loop starts in GameLoopState.Enter() and stops in GameLoopState.Exit(). It skips the main menu scene - StartSyncLoop() checks SceneManager.GetActiveScene().name and returns early if it's the menu.
public void OnQuitGame()
{
_saveLoad?.SaveProgress();
}OnQuitGame is called by the Yandex Games SDK (YG plugin) when the browser tab closes or refreshes. It must be a public parameterless method - the SDK calls it by name through JavaScript interop. The save is synchronous to guarantee completion before the page unloads.
Unity's built-in serialisation doesn't support Dictionary<K,V> or HashSet<T>. The project provides custom replacements.
Extends Dictionary<K,V> and implements ISerializationCallbackReceiver:
[SerializeField] private List<TKey> keyData = new();
[SerializeField] private List<TValue> valueData = new();
public void OnBeforeSerialize() => SynchronizeListsWithDictionary();
public void OnAfterDeserialize() => RebuildDictionaryFromSerializedData();RebuildDictionaryFromSerializedData uses Mathf.Min(keyData.Count, valueData.Count) to guard against mismatched list lengths (possible after Undo in the Editor) and skips null or default keys. ForceSerialization() is there for Editor code that modifies the dictionary manually and needs the lists in sync immediately.
Same ISerializationCallbackReceiver pattern. The backing List<T> deduplicates during OnAfterDeserialize to maintain set semantics even if the serialised data has duplicates.
Both types ship with custom PropertyDrawer implementations so they render as standard Unity list fields in the Inspector.
Unity's Vector3, Quaternion, and Transform don't serialise to JSON by default. The project defines serialisable counterparts:
| Custom type | Unity equivalent | Notes |
|---|---|---|
Vector3Data |
Vector3 |
x, y, z fields; ToVector3() / ToVector3Data() extensions |
QuatData |
Quaternion |
x, y, z, w; QuatComplex handles slerp math |
TransformData |
position + rotation + scale | Used in PlayerWorldData
|
Coordinates |
position + rotation (no scale) | Used for spawn point positions in LevelStaticData
|
All four have PropertyDrawer implementations that match Unity's native field widgets in the Inspector. UnityConversionExtensions provides the conversion surface: ToTransformData(), ToCoordinates(), ApplyTo(), and so on.