Tecnica
Progettare il motore di navigazione a grafo

Uno sguardo sotto il cofano della navigazione del player, dove le vignette sono nodi e i collegamenti hanno condizioni: il motore di flusso, il contesto delle variabili appiattito, JSON Logic che nel dubbio chiude il passaggio, priorità, transizioni e movimenti di camera ereditati, la navigazione all’indietro e l’analisi del grafo che riusa anche il CMS.
Ogni capitolo PanelWave è un grafo orientato: le vignette sono nodi, i collegamenti dicono quale vignetta può seguire quale, a quale condizione e con quale transizione visiva. Il formato definisce questa struttura; il player deve percorrerla, migliaia di volte per sessione di lettura, senza mai lasciare un lettore a piedi. Questo post parla della parte del player che compie quel percorso: il motore di flusso, l’archivio delle variabili che consulta e le scelte di progettazione dietro entrambi. Se vuoi prima l’introduzione concettuale, leggi Graph Navigation; questo articolo la dà per letta.
Perché un grafo, e perché il motore è minuscolo
L’alternativa a un grafo è un ordine di pagine con casi speciali: qui una pagina di decisione, là una tabella di salti. Noi volevamo l’opposto: una sola primitiva che renda gratuite le storie lineari ed economiche le ramificazioni. Un fumetto tradizionale è una catena di collegamenti incondizionati. Una scelta sono due collegamenti con condizioni diverse. Un ciclo, una deviazione, una scena facoltativa, un percorso che riconverge: tutte forme ordinarie di grafo, e la navigazione all’indietro funziona allo stesso modo per tutte.
Questa decisione ha permesso al motore di restare piccolo. Il motore di flusso è un servizio puro, senza stato proprio: gli dai un grafo, l’id della vignetta corrente e un oggetto di contesto, e ti restituisce dove andare dopo. Tutto il resto, da dove si trova il lettore a che cosa contengono le variabili, vive in altri servizi. È nato con 43 unit test, prima che esistesse una sola riga di rendering, ed è ancora la parte del player che cambia meno. L’API del servizio è elencata in Core Services.
I quattro passi di una navigazione
Quando il lettore avanza, con un tasto, uno swipe, la riproduzione automatica o un hotspot, la shell chiede al motore la vignetta successiva. Il motore fa esattamente quattro cose, e l’ordine conta:
Il diagramma di sequenza e le regole esatte sono in State, Conditions and Navigation. Da questa impostazione discendono due conseguenze. Gli autori ottengono un ripiego gratis: metti un collegamento incondizionato con priorità zero accanto a quelli condizionali, e un lettore che è andato avanti senza scegliere prosegue comunque. E il motore non deve mai sapere che cosa significa una condizione; deve solo sapere se è passata.
- Raccogliere ogni collegamento il cui from è la vignetta corrente. Nessun collegamento significa che la vignetta è un punto finale, e il motore lo dice invece di tirare a indovinare.
- Filtrare i candidati valutando la condizione JSON Logic di ciascun collegamento rispetto al contesto. Un collegamento senza condizione passa sempre.
- Ordinare i collegamenti rimasti per priorità, dal numero più alto; una priorità mancante vale zero. A parità di priorità vale l’ordine del manifest.
- Restituire la destinazione del primo collegamento rimasto, con la sua transizione e le sue azioni. Se non ne resta nessuno, anche qui il risultato è una destinazione nulla.
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 };Nel dubbio, il collegamento resta chiuso
Le condizioni sono JSON Logic, valutate tramite un sottile wrapper attorno a json-logic-js. Il wrapper segue tre regole, e definirle ha richiesto più tempo di quanto il codice lasci pensare. Una condizione mancante è vera, perché un collegamento senza condizione deve sempre essere percorribile. Un booleano letterale viene restituito così com’è, così un manifest può accendere o spegnere un collegamento a mano durante la creazione. E qualsiasi errore di valutazione, che sia un operatore sconosciuto, una logica malformata o un’eccezione, viene intercettato, registrato e restituito come false.
Cosa fare in caso di errore è stata la scelta più discussa. Se l’errore aprisse il collegamento (fail-open), una condizione rotta si comporterebbe come un collegamento di ripiego e nasconderebbe gli errori di creazione finché un lettore non finisce su un percorso non voluto. Se invece lo chiude (fail-closed), il collegamento rotto sparisce, e la validazione del CMS e la modalità Simulate del Graph Editor lo fanno emergere subito. Tra un errore rumoroso nell’editor e uno silenzioso in produzione, abbiamo scelto il primo. Lo stesso valutatore serve per ogni condizione del formato: collegamenti, condizioni di visibilità su livelli, balloon e hotspot, e varianti di vignetta, quindi una regola imparata una volta vale ovunque. Il wrapper include anche operatori di comodo come in, contains, matches, between e isEmpty, e una funzione che estrae ogni riferimento a variabili da una regola: è così che gli strumenti possono elencare ciò da cui dipende un capitolo.
Un unico contesto piatto da cinque ambiti
Le condizioni non vedono mai direttamente l’archivio delle variabili. L’archivio conserva i valori in cinque ambiti corrispondenti allo schema (global, chapter, page, session e persistent), e subito prima di una navigazione la shell gli chiede di appiattirli in un unico oggetto. L’ordine di fusione è voluto: prima global, poi session, persistent, l’ambito del capitolo corrente e infine quello della pagina corrente; a parità di chiave vince l’ultimo. Una variabile con ambito chapter oscura quindi una global con lo stesso nome finché il lettore si trova in quel capitolo: proprio ciò che serve per lo stato di un enigma limitato a un capitolo. Gli id con namespace separati da punti, come story.choice, diventano percorsi annidati nel contesto, così l’operatore var di JSON Logic li risolve senza alcun trattamento speciale.
L’archivio valida ogni scrittura rispetto alle definizioni del manifest: tipo, intervallo, appartenenza all’enum e flag di sola lettura. Le scritture che non passano vengono scartate con un avviso invece di corrompere lo stato. Solo l’ambito persistent tocca localStorage, e ripiega sulla memoria quando lo storage non è disponibile. La tabella degli ambiti e le regole di scrittura sono in State, Conditions and Navigation; i dettagli sulla persistenza in Saving Progress.
Cosa porta un collegamento: transizioni e camera
Un collegamento non dice solo dove andare; dice anche come deve apparire il cambio. Lo schema 1.2 ha permesso ai collegamenti di ereditare una transizione predefinita dal preset di output attivo, così gli autori impostano fade-300ms una volta per formato invece che su ogni collegamento. Il motore risolve da sé questa ereditarietà: vince la transizione del collegamento, altrimenti quella predefinita del preset. Quando nessun formato è attivo, il valore predefinito si usa solo se tutti i preset definiti concordano, e un semplice stacco è l’ultima risorsa. La regola è volutamente prudente: indovinare un formato e animare nel modo sbagliato è peggio che non animare.
Lo schema 1.4 ha aggiunto la tela infinita, e con essa un secondo dato che viaggia con il collegamento: il movimento di camera. Il motore lo tratta esattamente come una transizione, prima il valore del collegamento, poi quello predefinito del preset, e lo restituisce insieme alla destinazione perché la vista tela possa scivolare. Un percorso diretto, ad arco o a waypoint con un profilo di zoom hold, pull-back o dive: il motore di flusso non interpreta nulla di tutto questo, si limita a passarlo oltre. La semantica della navigazione è identica in ogni modalità di visualizzazione, ed è questa proprietà che ha permesso di aggiungere la vista tela senza toccare la regola di selezione. Vedi Canvas View e il riferimento del formato per la tela.
Tornare indietro è lo stesso collegamento, al contrario
«Indietro» non pesca dalla cronologia. Il motore cerca i collegamenti che puntano alla vignetta corrente e ne restituisce le origini; la shell prende la prima. Per l’animazione trova il collegamento percorso originariamente dalla vignetta precedente a quella corrente e lo inverte: slide-left diventa slide-right, up diventa down, e le transizioni senza direzione come fade o zoom tornano invariate. I movimenti di camera sulla tela vengono invertiti allo stesso modo, con i waypoint in ordine inverso, così la camera ripercorre il tragitto definito dall’autore invece di teletrasportarsi. In questo modo la navigazione all’indietro resta coerente con ciò che l’autore ha progettato, senza memorizzare un registro delle animazioni per sessione.
Gli strumenti di analisi, e chi li usa
Per avanzare di un passo basta un solo metodo; il resto del motore è quasi tutto analisi del grafo, e ignora di proposito le condizioni. Ci sono funzioni per verificare se una vignetta è un ingresso o un punto finale, per trovare tutti i punti finali, per testare la raggiungibilità con una ricerca in ampiezza a profondità limitata, per trovare il percorso più breve tra due vignette, per rilevare i cicli con una ricerca in profondità e uno stack di ricorsione, e per elencare ogni vignetta raggiungibile da un nodo di partenza. Ignorare le condizioni è l’impostazione giusta per le domande strutturali: un autore che chiede se una vignetta si può raggiungere vuole una risposta sul grafo, non sul valore attuale di una variabile.
È lo stesso vocabolario che parla il CMS. Il Graph Editor segnala nodi irraggiungibili, cicli, nodi isolati e vicoli ciechi con i propri passaggi in ampiezza e in profondità, impagina la mappa con dagre, evidenzia i problemi sul canvas e offre correzioni rapide come collegare un nodo orfano all’ingresso. La sua modalità Simulate percorre la struttura come fanno le funzioni di analisi del motore, elencando ogni collegamento in uscita con la sua condizione in linea. Il controllo pre-pubblicazione su tutta l’opera ripete il controllo di raggiungibilità prima di qualsiasi pubblicazione. Le condizioni con valori reali si provano in Preview, dove le sostituzioni delle variabili alimentano lo stesso contesto appiattito che vede il motore.
I diritti di accesso restano fuori dal grafo
La monetizzazione si sarebbe potuta modellare come condizioni sui collegamenti. L’abbiamo tenuta fuori dal grafo di proposito. Se un lettore può entrare in una vignetta lo decide un adattatore dei diritti di accesso implementato dall’host, atteso prima di ogni navigazione, con una cache di breve durata nel servizio dei diritti di accesso. Il motore espone ponti per verificare una vignetta, un capitolo o un’opera, ma il grafo resta una descrizione della storia, non di chi ha pagato. I dati verificati che l’host conosce, come un flag di accesso o l’età di un utente, possono comunque essere inseriti all’avvio in variabili di sola lettura, così le condizioni possono farvi riferimento senza che i contenuti della storia possano modificarli. Vedi Paywall and Entitlement.
Compromessi che rifaremmo, e uno che stiamo rivedendo
- Motore puro, shell con stato. Ogni navigazione è una funzione di grafo, posizione e contesto. Questo rende il motore banale da testare e permette al CMS di eseguire la stessa logica nel suo simulatore.
- Condizioni nei dati, non nel codice. JSON Logic è abbastanza piccolo da scriverlo a mano e abbastanza sicuro da valutarlo da manifest non fidati. Gli operatori personalizzati esistono, ma nulla nel formato di base ne ha bisogno.
- Struttura e stato sono domande separate. Le funzioni di analisi ignorano le condizioni; la funzione che avanza di un passo le rispetta. Mescolare le due renderebbe sbagliate entrambe le risposte, almeno ogni tanto.
- Le azioni sui collegamenti vengono restituite, non applicate. Il motore restituisce le mutazioni delle variabili di un collegamento nel risultato della navigazione e lascia all’host il compito di applicarle. Così il motore resta privo di effetti collaterali, ma per ora significa anche che la shell non le applica in automatico: è l’unica voce di questo elenco che stiamo rivedendo. Il comportamento attuale è documentato in Mutations.
Per approfondire
- Graph: ogni proprietà di un grafo, di un collegamento e di una mutazione, con note sulla validazione.
- Player Architecture: dove si colloca il motore nel flusso dei dati dal manifest alla vignetta resa.
- Variables: il modello di stato da cui viene appiattito il contesto.
- Narrazioni ramificate senza gli spaghetti: gli schemi di creazione che sfruttano bene tutto quanto sopra.
