State Management
All persistent data generated and used by Naninovel at runtime is divided into three categories:
- Game state
- Global state
- User settings
The data is serialized to JSON format and stored as either binary .nson (default) or text .json save slot files under a platform-specific persistent data directory. On WebGL, due to security policies in modern browsers, the serialized data is stored in the IndexedDB instead.
The serialization behavior is controlled by serialization handlers independently for game saves, global state, and user settings. By default, universal serialization handlers are used. In most cases, they will use asynchronous System.IO to read and write the slot files to the local file system. However, on some platforms (e.g., consoles) the .NET IO APIs are not available, in which case the universal handlers fallback to Unity's cross-platform PlayerPrefs.
Serialization handlers, path to the save folder, maximum allowed number of save slots, and other related parameters can be modified via the state configuration menu.
Game State
Game state is data that varies per game save slot, describing the state of engine services and other objects in relation to player progress. Examples include: the currently played scenario script and the index of the played script command within the script, currently visible characters and their positions on scene, currently played background music track name and its volume, and so on.
To save or load current game state to a specific save slot, use the IStateManager engine service as follows:
// Get instance of a state manager.
var stateManager = Engine.GetService<IStateManager>();
// Save current game session to `mySaveSlot` slot.
await stateManager.SaveGame("mySaveSlot");
// Load game session from `mySaveSlot` slot.
await stateManager.LoadGame("mySaveSlot");
// You can also use quick save-load methods without specifying slot names.
await stateManager.QuickSave();
await stateManager.QuickLoad();Note that the save-load API is asynchronous. If you're invoking the API from synchronous methods, use IStateManager.OnGameSaveFinished and IStateManager.OnGameLoadFinished to subscribe to completion events.
Global State
Some data should be persistent across game sessions. For example, the "Skip Read Text" feature requires the engine to store which scenario script commands were executed at least once (meaning the player has already "seen" them). Such data is stored in a single "global" save slot and doesn't depend on game save-load operations.
The global state is loaded automatically on engine initialization. You can save the global state at any time using IStateManager:
await stateManager.SaveGlobalState();User Settings
Like global state, user settings data (display resolution, language, sound volume, etc.) is stored in a single save slot, but is treated differently by default: the generated save file is placed outside the "Saves" folder and formatted in a readable way so that users can modify the values if they wish.
User settings are loaded automatically on engine initialization. You can save settings at any time using IStateManager:
await stateManager.SaveSettings();Custom State
You can delegate state handling of your custom objects to IStateManager, so they serialize to save slots with all the engine's data when the player saves and deserialize back when the game is loaded. Built-in state-related features (e.g., rollback) will also work out of the box with custom state.
The following example demonstrates delegating state handling of a MyCustomBehaviour component.
using UnityEngine;
using Naninovel;
public class MyCustomBehaviour : MonoBehaviour
{
[System.Serializable]
private class GameState
{
public bool MyCustomBool;
public string MyCustomString;
}
private bool myCustomBool;
private string myCustomString;
private IStateManager stateManager;
private void Awake ()
{
stateManager = Engine.GetService<IStateManager>();
}
private void OnEnable ()
{
stateManager.AddOnGameSerializeTask(SerializeState);
stateManager.AddOnGameDeserializeTask(DeserializeState);
}
private void OnDisable ()
{
stateManager.RemoveOnGameSerializeTask(SerializeState);
stateManager.RemoveOnGameDeserializeTask(DeserializeState);
}
private void SerializeState (GameStateMap stateMap)
{
var state = new GameState {
MyCustomBool = myCustomBool,
MyCustomString = myCustomString
};
stateMap.SetState(state);
}
private Awaitable DeserializeState (GameStateMap stateMap)
{
var state = stateMap.GetState<GameState>();
if (state is null) return Async.Completed;
myCustomBool = state.MyCustomBool;
myCustomString = state.MyCustomString;
return Async.Completed;
}
}If your custom object is created after the game state is loaded, use LastGameState to access the last loaded state and manually invoke the deserialize method:
private async void Start ()
{
if (stateManager.LastGameState is { } state)
await DeserializeState(state);
}EXAMPLE
A more advanced example of using custom state with a list of custom structs to save-load game state of an inventory UI can be found in the inventory sample. Specifically, de-/serialization of the custom state is implemented in Scripts/Runtime/Inventory/UI/InventoryUI.cs.
You can also access global and settings state of the engine to store custom data with them. Unlike game state, which is specific to game sessions and requires subscribing to save/load events, global and settings state objects are singletons and can be directly accessed via properties of the state manager.
[System.Serializable]
class MySettings
{
public bool MySettingsBool;
}
[System.Serializable]
class MyGlobal
{
public string MyGlobalString;
}
MySettings MySettings
{
get => stateManager.SettingsState.GetState<MySettings>();
set => stateManager.SettingsState.SetState<MySettings>(value);
}
MyGlobal MyGlobal
{
get => stateManager.GlobalState.GetState<MyGlobal>();
set => stateManager.GlobalState.SetState<MyGlobal>(value);
}State objects are indexed by type. In some cases you may have multiple object instances of the same type each with their own state. Both GetState and SetState methods allow providing an optional instanceId argument to discriminate such objects, e.g.:
[System.Serializable]
class MonsterState
{
public int Health;
}
var monster1 = stateMap.GetState<MonsterState>("1");
var monster2 = stateMap.GetState<MonsterState>("2");Custom Serialization Handlers
By default, when universal serialization handlers are selected, the engine state (game saves, global state, settings) is serialized via asynchronous System.IO or with Unity's cross-platform PlayerPrefs as a fallback for some platforms. To customize the serialization scenario, use custom handlers.
To add a custom handler, implement ISaveSlotManager<GameStateMap>, ISaveSlotManager<GlobalStateMap>, and ISaveSlotManager<SettingsStateMap> interfaces for the game save slots, global state, and settings respectively (each should have its own implementing class).
Implementations are expected to have a public constructor with StateConfiguration and string arguments, where the first is the state configuration object and second is the path to the saves folder; you can ignore the arguments in your custom implementation if desired.
Below is an example of a custom settings serialization handler that only logs when any of its methods are invoked.
using Naninovel;
using System;
using UnityEngine;
public class CustomSettingsSlotManager : ISaveSlotManager<SettingsStateMap>
{
public event Action<string> OnBeforeSave;
public event Action<string> OnSaved;
public event Action<string> OnBeforeLoad;
public event Action<string> OnLoaded;
public event Action<string> OnBeforeDelete;
public event Action<string> OnDeleted;
public event Action<string, string> OnBeforeRename;
public event Action<string, string> OnRenamed;
public bool Loading => false;
public bool Saving => false;
public CustomSettingsSlotManager (StateConfiguration config, string saveDir)
{
Debug.Log($"Ctor({saveDir})");
}
public bool AnySaveExists () => true;
public bool SaveSlotExists (string slotId) => true;
public void DeleteSaveSlot (string slotId)
{
Debug.Log($"DeleteSaveSlot({slotId})");
}
public void RenameSaveSlot (string sourceSlotId, string destSlotId)
{
Debug.Log($"RenameSaveSlot({sourceSlotId},{destSlotId})");
}
public Awaitable Save (string slotId, SettingsStateMap data)
{
Debug.Log($"Save({slotId})");
return Async.Completed;
}
public Awaitable<SettingsStateMap> Load (string slotId)
{
Debug.Log($"Load({slotId})");
return Async.Result(new SettingsStateMap());
}
public Awaitable<SettingsStateMap> LoadOrDefault (string slotId)
{
return Load(slotId);
}
}NOTE
You can pick any name for your custom serialization handler; CustomSettingsSlotManager is just an example.
When a custom handler is implemented, it appears in the state configuration menu, where you can select it instead of the built-in one.