beasty-visual-novel / scripting / custom-mode.md

Modo personalizado

VNAppState.Custom es la puerta abierta. No tiene UI incorporada: el motor cambia a este estado, te entrega la pantalla, y se detiene. Pon tu minijuego, sistema de batalla, ciclo de granja o mapa del mundo en él, y participa en todo lo demás que hace el motor — el almacén de variables compartido, el guardado, el rollback entre modos, el reloj, las misiones, el inventario.

Entrar y salir

VNGameController.Instance.EnterCustom();

EnterCustom registra el modo que está dejando como un límite de rollback, detiene cualquier historia en ejecución, y establece State en Custom. No crea nada. Muestra tu propia UI desde un manejador de StateChanged:

using Beasty.VN.Runtime;
using UnityEngine;

public sealed class MinigameRoot : MonoBehaviour
{
    [SerializeField] private GameObject ui;

    private void OnEnable()
    {
        var game = VNGameController.Instance;
        game.StateChanged += HandleStateChanged;
        HandleStateChanged(game.State);
    }

    private void OnDisable()
    {
        if (VNGameController.Instance != null)
            VNGameController.Instance.StateChanged -= HandleStateChanged;
    }

    private void HandleStateChanged(VNAppState state) => ui.SetActive(state == VNAppState.Custom);
}

Para salir, llama a la transición que corresponda a dónde debería ir el jugador:

Salir haciaLlama a
La sala de la que vino el jugadorVNGameController.Instance.RollbackFromCustom()
Una sala específicaEnterFreeRoam(new FreeRoamSaveState { scenarioId = ..., roomId = ... })
Una historiaEnterVisualNovel(project, nodeId)
El menú principalGoToMainMenu()

RollbackFromCustom retrocede a través del límite más reciente — la sala, la historia, o un estado Custom anterior — y devuelve false cuando no hay nada a lo que retroceder. Conéctalo a tu input de Atrás y recurre a GoToMainMenu() cuando devuelva false.

Guardar tu modo

El motor no puede saber cuál es el estado de tu modo, así que te lo pide, como string, y lo guarda tal cual.

public Func<string> CaptureCustomStateJson;   // tú lo llenas
public Action<string> RestoreCustomStateJson; // tú lo llenas

Asigna ambos una sola vez, temprano. Al guardar, si la aplicación está en el estado Custom, el controlador llama a CaptureCustomStateJson y escribe el resultado en VisualNovelSaveData.customStateJson. Al cargar, entra en Custom y devuelve el string a RestoreCustomStateJson. El motor nunca inspecciona el blob. JSON es el formato obvio — JsonUtility.ToJson alcanza — pero cualquier string funciona.

Los mismos dos hooks alimentan el rollback entre modos: PushCustomRollback() captura el estado custom actual como un paso hacia atrás. Llámalo justo ANTES de cambiar algo que el jugador debería poder deshacer, o antes de cambiar de modo. No hace nada si CaptureCustomStateJson no está conectado.

Nota No pongas tu puntuación en customStateJson si pertenece al mundo. Un número que la historia lee en una condición, que una misión verifica, o que un HUD muestra debería ser una variable de VN — VN.SetInt, o el almacén compartido — porque esas ya se guardan, ya se rebobinan y ya se pueden usar desde cualquier condición. Reserva customStateJson para el estado que es genuinamente interno a tu modo: el layout de un tablero, una mano de cartas, un contador de oleadas.

Los hooks de FreeRoam

El mismo patrón, pero el motor ya trae una implementación, así que solo tocas esto si estás reemplazando FreeRoam:

public Func<FreeRoamSaveState> CaptureFreeRoamState;
public Action<FreeRoamSaveState> RestoreFreeRoamState;
public Func<string, Sprite> ResolveRoomBackground;
public Action<FreeRoamMapGraph, List<string>, Action<string>> RoomSelectionRequested;

ResolveRoomBackground mapea un id de sala al sprite que esa sala muestra actualmente, con las condiciones ya resueltas. El motor lo llama después de una carga para repintar la sala detrás de un diálogo que no tiene su propio bloque Backdrop. Si tu modo posee las salas, conéctalo o ese fondo vuelve vacío.

RoomSelectionRequested es el hook que una UI de selector de sala registra para que un bloque Choose room tenga a quién preguntarle. Sin ninguno registrado, el motor elige una sala por defecto y registra un aviso en el log.

Los hooks de guardado

public static class VNSaveHooks
{
    public static event Action<VisualNovelSaveData> OnCaptureSave;
    public static event Action<VisualNovelSaveData> OnRestoreSave;
    public static event Action<VisualNovelSaveData> OnSceneRestoreFailed;
}
  • OnCaptureSave se dispara después de que el motor ha llenado la instantánea y antes de que se escriba. Última oportunidad para añadirle algo.
  • OnRestoreSave se dispara después de que una instantánea cargada ha sido aplicada al mundo.
  • OnSceneRestoreFailed se dispara cuando el estado de objetos de escena de una carga no pudo aplicarse: la historia está en la línea guardada, pero el mundo alrededor no es el que se guardó. Engánchalo y avísale al jugador. Dejarlo en un mundo medio restaurado que simplemente se ve sutilmente mal es peor que decírselo.

Los tres reinician sus suscriptores en SubsystemRegistration, así que Fast Enter Play Mode no los filtra.

Persistir tu propio MonoBehaviour

No necesitas el estado Custom para esto. Pon un componente BeastySaveable en cualquier GameObject de la escena, marca los componentes que quieres almacenar, y su estado viaja dentro de cada guardado de VN y cada límite de rollback — el motor captura el grupo de escena como parte de CaptureCurrent() y lo aplica un frame después de que el modo restaurado ha reconstruido su mundo.

Un fallo de captura hace fallar TODO el guardado, a propósito: mejor ningún guardado que uno que silenciosamente le falte estado que el jugador espera recuperar.

Lee Estado de escena antes de depender de esto — en particular, el sistema de guardado no almacena referencias a objetos de Unity (sprites, prefabs, otros componentes). Si el estado de tu minijuego es “qué prefab está en la ranura 3”, guarda el id, no el prefab.

Ejemplo completo: un minijuego con puntuación que vuelve a la sala

Un modo Custom completo. Mantiene una puntuación, la guarda, la restaura, y vuelve a donde vino el jugador.

using System;
using Beasty.VN.Runtime;
using UnityEngine;

public sealed class ScoreMinigame : MonoBehaviour
{
    [Serializable]
    private struct State
    {
        public int score;
        public int round;
    }

    [SerializeField] private GameObject ui;

    private int _score;
    private int _round;

    private void OnEnable()
    {
        var game = VNGameController.Instance;
        if (game == null) return;

        game.CaptureCustomStateJson = Capture;
        game.RestoreCustomStateJson = Restore;
        game.StateChanged += HandleStateChanged;
        HandleStateChanged(game.State);
    }

    private void OnDisable()
    {
        var game = VNGameController.Instance;
        if (game == null) return;

        game.StateChanged -= HandleStateChanged;
        if (game.CaptureCustomStateJson == (Func<string>)Capture) game.CaptureCustomStateJson = null;
        if (game.RestoreCustomStateJson == (Action<string>)Restore) game.RestoreCustomStateJson = null;
    }

    private void HandleStateChanged(VNAppState state) => ui.SetActive(state == VNAppState.Custom);

    // ─── Entrada ─────────────────────────────────────────────────────────────
    /// Llama esto desde la acción Custom de un objeto de FreeRoam, o desde donde sea.
    public void Play()
    {
        _score = 0;
        _round = 0;
        VNGameController.Instance.EnterCustom();   // registra la sala/historia que estamos dejando
    }

    // ─── Juego ───────────────────────────────────────────────────────────────
    public void ScorePoint()
    {
        VNGameController.Instance.PushCustomRollback();   // Atrás deshace esta ronda
        _score++;
        _round++;
    }

    // ─── Salida ──────────────────────────────────────────────────────────────
    public void Finish()
    {
        var game = VNGameController.Instance;

        // El resultado pertenece al mundo, así que es una variable de VN, no parte del blob:
        // las condiciones, las misiones y el HUD pueden verlo, y se guarda y rebobina gratis.
        game.SharedVariables.Set("minigame_best",
            Mathf.Max(game.SharedVariables.GetInt("minigame_best"), _score).ToString());

        BeastyTime.AdvanceDayparts(1);   // jugar tomó una tarde

        // Vuelve a donde vino el jugador; al menú principal si no hay a dónde volver.
        if (!game.RollbackFromCustom()) game.GoToMainMenu();
    }

    // ─── Persistencia ────────────────────────────────────────────────────────
    private string Capture() => JsonUtility.ToJson(new State { score = _score, round = _round });

    private void Restore(string json)
    {
        if (string.IsNullOrEmpty(json)) { _score = 0; _round = 0; return; }
        var state = JsonUtility.FromJson<State>(json);
        _score = state.score;
        _round = state.round;
    }
}

Guarda mientras este modo está en ejecución y la ranura registra appState = Custom, tu blob, el almacén compartido, el reloj, las misiones, el inventario y la cola de rollback. Cárgala y estás de vuelta en el minijuego, con la misma puntuación, con Back todavía capaz de salir a la sala desde la que empezaste.

Ver también