Técnico
Diseñar el motor de navegación por grafo

Bajo el capó de la navegación del reproductor: cómo funcionan las viñetas como nodos y las aristas con condiciones. El motor de flujo, el contexto de variables aplanado, un JSON Logic que se cierra ante cualquier error, las prioridades, las transiciones y movimientos de cámara heredados, la navegación hacia atrás y el análisis del grafo que reutiliza el CMS.
Cada capítulo de PanelWave es un grafo dirigido: las viñetas son nodos, y las aristas dicen qué viñeta puede seguir a cuál, con qué condición y con qué transición visual. El formato define esa estructura; el reproductor tiene que recorrerla miles de veces por sesión de lectura, sin dejar nunca a un lector tirado. Este artículo trata de la pieza del reproductor que hace ese recorrido: el motor de flujo, el almacén de variables que consulta y las decisiones de diseño que hay detrás de ambos. Si antes quieres la introducción conceptual, lee Navegación por grafo; este artículo la da por leída.
Por qué un grafo, y por qué el motor es diminuto
La alternativa a un grafo es un orden de páginas más casos especiales: aquí una página de decisión, allí una tabla de saltos. Nosotros queríamos lo contrario: una sola primitiva que haga gratis las historias lineales y barata la ramificación. Un cómic convencional es una cadena de aristas incondicionales. Una decisión son dos aristas con condiciones distintas. Un bucle, un desvío, una escena opcional, un camino que vuelve a converger: todas son formas normales de un grafo, y la navegación hacia atrás funciona igual para todas.
Esa decisión permitió que el motor siguiera siendo pequeño. El motor de flujo es un servicio puro sin estado propio: le das un grafo, el id de la viñeta actual y un objeto de contexto, y te devuelve adónde ir a continuación. Todo lo demás, desde dónde está el lector hasta qué valores tienen las variables, vive en otros servicios. Se escribió con 43 pruebas unitarias antes de que existiera ningún dibujo en pantalla, y sigue siendo la parte del reproductor que menos cambia. La API del servicio aparece en Servicios principales.
Los cuatro pasos de una navegación
Cuando el lector avanza, ya sea con una tecla, un gesto, la reproducción automática o una zona interactiva, el shell pide al motor la siguiente viñeta. El motor hace exactamente cuatro cosas, y el orden importa:
El diagrama de secuencia y las reglas exactas están en Estado, condiciones y navegación. De este diseño se derivan dos consecuencias. Los autores obtienen un respaldo gratis: pon una arista incondicional con prioridad cero junto a las condicionales y un lector que avanzó sin elegir seguirá adelante. Y el motor nunca necesita saber qué significa una condición; solo tiene que saber si se ha cumplido.
- Reúne todas las aristas cuyo from es la viñeta actual. Si no hay aristas, la viñeta es un final, y el motor lo dice en lugar de adivinar.
- Filtra las candidatas: evalúa la condición JSON Logic de cada arista con ese contexto. Una arista sin condición siempre pasa.
- Ordena las supervivientes por prioridad, de mayor a menor número; sin prioridad, cuenta como cero. Entre prioridades iguales, el orden es el del manifiesto.
- Devuelve el destino de la primera superviviente junto con su transición y sus acciones de arista. Si no ha sobrevivido ninguna, el resultado vuelve a ser un destino nulo.
const candidates = graph.edges.filter((e) => e.from === currentPanelId);
const passing = candidates.filter((e) => evaluateJsonLogic(e.condition, context));
const winner = [...passing].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0];
return winner
? { nextPanelId: winner.to, transition: winner.transition ?? defaultTransition, action: winner.action }
: { nextPanelId: null };Si una condición falla, la arista se cierra
Las condiciones son JSON Logic, evaluadas mediante una fina capa sobre json-logic-js. Esa capa tiene tres reglas que costó más decidir de lo que sugiere el código. Una condición ausente es verdadera, porque una arista sin condición siempre debe poder recorrerse. Un booleano literal se devuelve tal cual, para que un manifiesto pueda activar o desactivar una arista a mano durante la creación. Y cualquier error de evaluación, ya sea un operador desconocido, una lógica mal formada o una excepción, se captura, se registra y se devuelve como falso.
Cerrar ante el error fue la decisión polémica. La alternativa, dejar pasar la arista, haría que una condición rota se comportara como una arista de respaldo y escondería los errores de autoría hasta que un lector tomara un camino no previsto. Al cerrarla, la arista rota desaparece, y la validación del CMS y el modo Simulate del editor de grafos lo señalan de inmediato. Preferimos un error que haga ruido en el editor a uno que calle en producción. El mismo evaluador se usa para todas las condiciones del formato: aristas, condiciones de visibilidad en capas, bocadillos y zonas interactivas, y variantes de viñeta, así que una regla aprendida una vez sirve en todas partes. La capa también incluye operadores prácticos como in, contains, matches, between e isEmpty, y una función que extrae todas las referencias a variables de una regla; así las herramientas pueden enumerar de qué depende un capítulo.
Un contexto plano a partir de cinco ámbitos
Las condiciones nunca ven directamente el almacén de variables. El almacén guarda los valores en cinco ámbitos que coinciden con el esquema (global, chapter, page, session y persistent), y justo antes de una navegación el shell le pide que los aplane en un único objeto. El orden de fusión es deliberado: primero global, después session, persistent, el ámbito del capítulo actual y, por último, el ámbito de la página actual, y en caso de colisión de claves gana la entrada posterior. Así, una variable con ámbito de capítulo oculta a una global del mismo nombre mientras el lector está dentro de ese capítulo, que es exactamente lo que necesita el estado de un acertijo por capítulo. Los identificadores con espacios de nombres separados por puntos, como story.choice, se convierten en rutas anidadas del contexto, de modo que el operador var de JSON Logic los resuelve sin ningún tratamiento especial.
El almacén valida cada escritura con las definiciones del manifiesto: tipo, rango, pertenencia al enum y el indicador de solo lectura. Las escrituras que fallan se descartan con un aviso en lugar de corromper el estado. Solo el ámbito persistente toca localStorage, y pasa a memoria cuando el almacenamiento no está disponible. La tabla de ámbitos y las reglas de escritura están en Estado, condiciones y navegación; los detalles de la persistencia, en Guardar el progreso.
Lo que viaja con la arista: transiciones y movimientos de cámara
Una arista no solo dice adónde ir; también dice cómo debe verse el cambio. El esquema 1.2 permitió que las aristas heredaran una transición predeterminada del preajuste de salida activo, así que los autores definen fade-300ms una vez por formato en lugar de en cada arista. El motor resuelve esa herencia por sí mismo: gana la transición propia de la arista y, si no la hay, la del preajuste. Cuando no hay ningún formato activo, el valor predeterminado solo se usa si todos los preajustes definidos coinciden en él, y un corte seco es el último recurso. La regla es conservadora a propósito: adivinar un formato y animar mal es peor que no animar.
El esquema 1.4 añadió el lienzo infinito, y con él una segunda cosa que viaja en una arista: el movimiento de cámara. El motor lo trata exactamente igual que una transición, primero el valor de la arista y después el del preajuste, y lo devuelve junto al destino para que la vista de lienzo pueda deslizarse. Trayectoria recta, en arco o por puntos de paso, con zoom fijo, de alejamiento o de inmersión: el motor de flujo no interpreta nada de eso, solo lo pasa. La semántica de navegación es idéntica en todos los modos de vista, y esa es la propiedad que permitió añadir la vista de lienzo sin tocar la regla de selección. Consulta Vista de lienzo y la referencia del formato de lienzo.
Volver atrás es la misma arista, invertida
«Anterior» no es sacar un elemento de un historial. El motor busca las aristas que apuntan a la viñeta actual y devuelve sus orígenes; el shell toma el primero. Para la animación, busca la arista que se recorrió originalmente de la viñeta anterior a la actual y la invierte: slide-left pasa a ser slide-right, up pasa a ser down, y las transiciones sin dirección, como fade o zoom, vuelven sin cambios. Los movimientos de cámara del lienzo se invierten de la misma manera, con los puntos de paso en orden inverso, así que la cámara vuelve sobre la trayectoria definida en lugar de teletransportarse. Esto mantiene la navegación hacia atrás coherente con lo que diseñó el autor sin guardar un registro de animaciones por sesión.
Las herramientas de análisis, y quién las usa
Avanzar es un solo método; la mayor parte del motor es análisis del grafo que ignora las condiciones a propósito. Hay funciones para comprobar si una viñeta es una entrada o un final, encontrar todos los finales, comprobar la alcanzabilidad (búsqueda en anchura con profundidad limitada), hallar el camino más corto entre dos viñetas, detectar ciclos (búsqueda en profundidad con pila de recursión) y enumerar todas las viñetas alcanzables desde un nodo inicial. Para las preguntas estructurales, ignorar las condiciones es lo correcto: un autor que pregunta si se puede llegar a una viñeta quiere saber algo del grafo, no del valor actual de una variable.
Es el mismo vocabulario que habla el CMS. El Graph Editor señala nodos inalcanzables, ciclos, nodos aislados y callejones sin salida con sus propias pasadas en anchura y en profundidad, dispone el mapa con dagre, resalta los problemas en el lienzo y ofrece correcciones rápidas como conectar un nodo huérfano a la entrada. Su modo Simulate recorre la estructura como lo hacen las funciones de análisis del motor, enumerando cada arista de salida con su condición en línea. La validación Preflight de toda la obra repite la comprobación de alcanzabilidad antes de publicar nada. Las condiciones con valores reales se prueban en Preview, donde los valores forzados de las variables alimentan el mismo contexto aplanado que ve el motor.
Los derechos de acceso quedan fuera del grafo
La monetización se podría haber modelado como condiciones en las aristas. La dejamos fuera del grafo a propósito. Si un lector puede entrar en una viñeta lo decide un adaptador de derechos de acceso que implementa el anfitrión. El reproductor espera su respuesta antes de cada navegación, y el servicio de derechos de acceso la guarda poco tiempo en caché. El motor ofrece puentes para comprobar una viñeta, un capítulo o una obra, pero el grafo en sí sigue siendo una descripción de la historia, no de quién ha pagado por ella. Los datos verificados que conoce el anfitrión, como un indicador de derecho de acceso o la edad de un usuario, se pueden seguir introduciendo en variables de solo lectura al arrancar, para que las condiciones puedan referirse a ellos sin que el contenido de la historia pueda cambiarlos. Consulta Muro de pago y derechos de acceso.
Decisiones que volveríamos a tomar, y una que estamos revisando
- Motor puro, shell con estado. Cada navegación es una función del grafo, la posición y el contexto. Eso hace que el motor sea trivial de probar y que el CMS pueda ejecutar la misma lógica en su simulador.
- Condiciones en los datos, no en el código. JSON Logic es lo bastante pequeño para escribirlo a mano y lo bastante seguro para evaluarlo desde manifiestos no fiables. Existen operadores personalizados, pero nada del formato principal los necesita.
- La estructura y el estado son preguntas distintas. Las funciones de análisis ignoran las condiciones; la función de avance las respeta. Mezclarlas haría que ambas respuestas fallaran a veces.
- Las acciones de arista se devuelven, no se aplican. El motor devuelve las mutaciones de variables de una arista en el resultado de la navegación y deja su aplicación al anfitrión. Eso mantiene el motor libre de efectos secundarios, pero también significa que el shell todavía no las aplica automáticamente, y es el único punto de esta lista que estamos revisando. El comportamiento actual está documentado en Mutaciones.
Para seguir leyendo
- Grafo: todas las propiedades de un grafo, una arista y una mutación, con notas de validación.
- Arquitectura del reproductor: dónde se sitúa el motor en el flujo de datos del manifiesto a la viñeta dibujada.
- Variables: el modelo de estado a partir del que se aplana el contexto.
- Narrativas ramificadas sin espaguetis: los patrones de autoría que sacan partido de todo lo anterior.
