Presentazione gestore di dialoghi

PRESENTAZIONE GESTORE DI DIALOGHI DI @maximilian.volt
INTRODUZIONE
La gestione strutturata dei dialoghi all'interno di un videogioco è sempre stata una problematica composta che molti sviluppatori affrontano nella stesura del loro progetto. Delle domande che ci si pone sempre per quanto riguarda l'implementazione di un tale sistema sono:
- "Come strutturare le informazioni dei dialoghi nel gioco?"
- "Come gestire il branching narrativo?"
- "E per quanto riguarda gli effetti, come metterli dentro?"
- "Come gestire altre lingue per internazionalizzazione/localizzazione?"
Insomma, sono tutte funzionalità che, ad implementarle, sicuramente non rendono il lavoro una passeggiata. Nonostante ciò ho voluto fare un tentativo per rendere il tutto quanto più semplice possibile al programmatore.
Il sistema qui proposto implementa un dialog manager gerarchico e data-driven, separando in modo netto:
- Definizione dei dati (scene, sequenze, battute, effetti)
- Logica di flusso (salti, scelte, condizioni)
- Esecuzione in runtime (runner, stato, avanzamento)
- Codifica e decodifica dei metadati tramite maschere bitwise
L'approccio proposto privilegia la composizione modulare e facilita i processi di serializzazione e deserializzazione della struttura dei dialoghi rendendo il sistema adatto per Visual Novel, RPG ma anche per titoli narrativi ibridi.
L'IDEA
Prima di mostrare come usare l'asset appena descritto, si consideri la "filosofia" chiave dietro il lavoro: i dialoghi vanno scritti al più una volta sola.
Con tutte le funzionalità di salto e diramazione che prevede questo engine, è possibile evitare la ripetizione delle stesse battute, eliminando quindi potenziali inconsistenze derivabili da un tale approccio.
Un'altra cosa a cui prestare attenzione è la modalità di stesura della stuttura narrativa, che nonostante sia intrecciata per quanto riguarda i collegamenti a entità dialogiche presenta comunque una solida linearità. L'engine è appunto ottimizzato su questo.
UNO SGUARDO ALLE FUNZIONALITÀ DEL GESTORE
Il sistema generalizza la struttura narrativa come segue:
1. DialogRunner(): esecutori in grado di recuperare i dialoghi e tenere traccia della loro posizione nella struttura.
2. DialogManager(): database dove viene memorizzata tutta la struttura narrativa.
3. DialogLinkable(): definiscono la struttura narrativa.
- Dialog(): indica una singola battuta all'interno della struttura narrativa.
- DialogSequence(): indica una collezione di battute sensata, ad esempio per un'interazione con un personaggio in un RPG oppure per indicare un sottoargomento di una conversazione in una scena di un Visual Novel.
- DialogScene(): indica una collezione di sequenze, utile per raggrupparle logicamente ad esempio in base al livello in un RPG oppure come ambientazione "completa" all'interno di una Visual Novel (scena, insomma).
4. DialogFX(): definiscono i comportamenti e la logica di flusso e possono essere contenuti solo dentro Dialog().
Il sistema prevede inoltre un'interfaccia per creare effetti personalizzati ed è disposto di un ciclo di esecuzione degli effetti dei dialoghi.
0. L'APPROCCIO CONSIGLIATO
Per velocizzare e rendere sicuro il procedimento più lungo è consigliato seguire questi passaggi preliminari.
COMPLETAMENTO DEGLI ENUMERATORI
Il gestore prevede tanti enumeratori quanti sono i costrutti elencati sopra, ognuno con la propria inizializzazione. Questi possono essere ampliati con delle voci personalizzate per rendere il codice più comprensibile dando senso a valori numerici sparsi nel codice.
Si prenda ad esempio la definizione dell'enumeratore DIALOG:
enum DIALOG
{
// Speakers
SPEAKER_NONE = 0,
SPEAKER_SYSTEM,
SPEAKER_NARRATOR,
// ... (<- ampliabile con le proprie voci)
SPEAKER_COUNT,
SPEAKER_DEFAULT = DIALOG.SPEAKER_NONE,
}
È possibile piazzare al posto di "// ..." le proprie voci, assegnando simultaneamente un indice numerico allo speaker in questione. Aggiungendo ad esempio SPEAKER_MAXIMILIANVOLT al posto dei puntini, stiamo assegnando in questo caso il valore 3 (leggi qui) al nuovo speaker che, in caso volessimo referenziarlo altrove nel codice, renderebbe possibile scrivere DIALOG.SPEAKER_MAXIMILIANVOLT.
⚠️ Attenzione: si consiglia di lasciare le voci elencate senza assegnare loro espressioni personalizzate. Quindi:
enum _
{
VOCE, // ✔️
VOCE = 4, // ❌
}
Questo permette alle voci come DIALOG.SPEAKER_COUNT e similari di tenere traccia anche del numero di speaker/altro registrati correttamente. In caso si necessitasse assegnare dei particolari valori a personaggi/etc., si presti attenzione anche ad aggiornare le voci dedicate al conteggio dell'informazione in questione.
Lo stesso procedimento è ripetibile per gli altri dati di interesse nel progetto.
♻️ Suggerimento: nella maggior parte dei casi per gli RPG è sufficiente aggiungere solo le voci per gli speaker e le emozioni mentre per le Visual Novel potrebbero servirne altre anche per scene, sequenze, etc.
CREAZIONE DI VARIABILI LOCALI
Dopo aver definito tutte le voci degli enumeratori si può procedere al salvataggio di tutti i dati utili in variabili locali per velocizzare il processo di stesura della struttura narrativa. Tornando all'esempio di prima, si potrebbe fare:
var spk_maximilian = Dialog.speaker(DIALOG.SPEAKER_MAXIMILIANVOLT);
var emt_neutral = Dialog.emotion(DIALOG.EMOTION_NONE);
Questo rende la composizione delle maschere di bit (usate per definire tutte le informazioni di ogni oggetto) molto veloce, permettendo di digitare solamente, nel nostro caso, spk_maximilian | emt_neutral, creando una maschera che descrive come la battuta venga detta da Maximilian con un'espressione neutrale. La nomenclatura è adattabile in base al vostro stile, quindi anche combinazioni come maximilian | neutral sono ottime per velocizzare il procedimento.
⚠️ Attenzione: si è fatto uso dell'OR BITWISE |, non dell'OR LOGICO ||.
♻️ Suggerimento: l'operatore OR BITWISE ("|") lo si può considerare come un "separatore" che codifica più informazioni su un solo numero. Un esempio concreto potrebbe essere la numerazione delle stanze all'interno di un edificio: numeri di stanza come come 145, 314, etc. possono codificare un informazione con ogni cifra, ad esempio: PIANO | CORRIDOIO | STANZA, quindi 3 | 1 | 4. Nel nostro caso abbiamo adoperato con i numeri in base 10, il computer farà la stessa cosa ma con la base 2.
Si consiglia di salvare per brevità nella composizione delle impostazioni le informazioni di proprio interesse. Qui sotto seguita un elenco con le informazioni di ogni entità dialogica.
- DialogFX(): type, trigger, tag.
- Dialog(): speaker, emotion, anchor, textbox, tag.
- DialogSequence(): tag.
- DialogScene(): bg, bgm, bgs, tag.
1. REGISTRAZIONE DEGLI EFFETTI: DialogFX()
In questa fase si possono registrare gli effetti personalizzati da far eseguire ai dialoghi tramite funzioni; queste saranno poi memorizzate su mappe interne al gestore. Il metodo che permette la registrazione è il seguente:
DialogFX.register(func, [register_settings], [type])
Dove func è la funzione da registrare, register_settings indica le impostazioni per la registrazione (su che mappa salvare la funzione, default: DIALOG_FX.REGISTER_SETTING_FX_FUNC, ovvero registra un effetto personalizzato) e type indica invece un indice corrispondente al tipo dove salvare la funzione. La funzione restituisce l'indice di dove viene salvata quest'ultima. Nel caso di effetti normali, questo indice ne specifica anche il tipo, rendendolo utilizzabile anche nella composizione della maschera delle impostazioni.
Si fa distinzione tra 3 tipi di mappe dove è possibile salvare le funzioni.
MAPPA FUNZIONI DEGLI FX
Questa mappa contiene le funzioni degli effetti personalizzati. Gli effetti di questo tipo non hanno limitazioni su tipi di ritorno o comportamenti che devono assumere.
// Es: funzione che stampa in debug quanti argomenti riceve
var fx_index = DialogFX.register(function(argv) {
show_debug_message(array_length(argv));
});
MAPPA CONDIZIONI DEGLI FX
Questa mappa contiene le funzioni delle condizioni di effetti che sono eseguiti solo se queste restituiscono un valore veritiero. Per questo dovrebbero restituire un valore booleano. Gli effetti che usano funzioni di questa mappa sono fallback e dispatch (approfonditi sotto).
Si consideri una possibile deviazione nel flusso dei dialoghi: se ad esempio il protagonista ha abbastanza valuta di gioco mentre parla con un mercante, allora il dialogo può saltare in una sequenza dedicata dove appunto il dialogo procede.
// Es: condizione per il controllo valuta-costo
var fx_condition_index = DialogFX.register(function(argv) {
return O_PLAYER.currency >= global.items[argv[0]].price;
}, DIALOG_FX.REGISTER_SETTING_FX_FUNC_CONDITION);
MAPPA INDICIZZATORI DEGLI FX
Questa mappa contiene le funzioni di effetti che possono avere più alternative, come ad esempio effetti di selezione del salto come dispatch e choice (approfonditi sotto). Queste funzioni devono quindi restituire un numero corrispondente ad un indice valido per gli effetti dei dialoghi utilizzatori.
Si consideri ad esempio se nel proprio gioco si volesse fare che i dialoghi cambiano in base al numero di membri presenti nel proprio party. Invece di inserire innumerevoli controlli per recuperare suddetti dialoghi, si potrebbero creare tante sequenze quante sono le versioni dei dialoghi possibili. Con un po' di organizzazione si possono collegare tutte le versioni del dialogo con una funzione indicizzatrice come segue:
/*
* Si organizzino globalmente le possibili versioni dei dialoghi come segue:
* dialoghi con 1 membro -> opzione di indice 0
* dialoghi con 2 membri -> opzione di indice 1
* dialoghi con 3 membri -> opzione di indice 2
* etc. (in maniera costante)
*/
var fx_indexer_index = DialogFX.register(function(argv) {
return O_GAME_PARTY.member_count - 1;
}, DIALOG_FX.REGISTER_SETTING_FX_FUNC_INDEXER);
Gli esempi sopra mostrati vengono ripresi successivamente con la creazione delle entità dialogiche.
⚠️ Attenzione: ogni funzione registrata dovrebbe seguire questo formato:
/**
* @param {Array} argv Gli argomenti della funzione.
*/
function(argv) { }
2a. CREAZIONE/RECUPERO DEL DATABASE: DialogManager()
Giunti a questo punto è possibile iniziare a stendere la struttura dei propri dialoghi. Questo sistema permette di creare il database sia all'interno del codice di gioco che serializzandolo all'interno di file JSON. Se si ha già la struttura completa a disposizione, si può successivamente procedere al passo 3, altrimenti si costruisca al passo 2b.
1. Creazione vuota
dialog_manager = dialog_manager_create();
2. Creazione da JSON
dialog_manager = dialog_manager_create("{...}");
3. Creazione da file (gestione automatica)
dialog_manager = dialog_manager_create("dialog.json", true);
4. Creazione da file handle (gestione manuale)
var file = file_text_open_read("dialogs.json");
dialog_manager = dialog_manager_create(file, true);
file_text_close(file);
2b. DEFINIZIONE DEL DATABASE: DialogLinkable()
Una volta realizzata la mappatura di valori/funzioni di interesse per il caso d'uso nel vostro progetto e creato il riferimento al database, si proceda quindi con la stesura della struttura narrativa. Si tenga a mente il seguente schema per definire il proprio modello:
DialogManager() -> DialogScene() -> DialogSequence() -> Dialog() -> DialogFX()
Dove -> indica "è collezione di".
⚠️ Attenzione: si consiglia di valutare quali riferimenti salvare in base alle necessità del proprio progetto. Se si sta creando un RPG o un Visual Novel spesso conviene salvare solo scene o sequenze. Se si sta salvando testi relativi a menù o interfacce grafiche allora conviene salvare i dialoghi. Negli esempi a venire si usa il modello "RPG".
CREAZIONE DI ELEMENTI
Si possono creare le strutture dialogiche con i seguenti comandi:
// 1. Dialoghi
dialog_create(text, [settings_mask], [fx_map])
// 2. Sequenze
dialog_sequence_create(dialogs, [settings_mask], [speakers])
// 3. Scene
dialog_scene_create(sequences, [settings_mask])
1. Dialogo: text è il testo della battuta, settings_mask indica le impostazioni del dialogo e fx_map è la lista di effetti da eseguire.
2. Sequenza: dialogs è la lista di dialoghi, settings_mask indica le impostazioni della sequenza e speaker_map è la lista di indici degli speaker (se si mappano relativamente con gli indici possibili di questo array, si potrebbero recuperare come sequence.speakers[dialog.speaker()]).
3. Scena: sequences indica la lista delle sequenze, settings_mask indica le impostazioni della scena.
Esempio:
sequence_1 = dialog_sequence_create([
// Array di dialoghi
dialog_create("< Il protagonista si sveglia dall'incubo. >", narrator),
dialog_create("Whew, fortunatamente era solo un incubo.", protagonist | relieved),
// ...
]);
scene_1 = dialog_scene_create([
// Array di sequenze
sequence_1,
// ...
]);
Infine, per l'aggiunta al gestore, si usi:
dialog_manager.add([
// Array di scene
scene_1,
// ...
]);
CREAZIONE DI EFFETTI NORMALI
È possibile creare effetti personalizzati usando la funzione dialog_fx_create():
dialog_fx_create(settings_mask, argv)
Dove settings_mask indica le impostazioni dell'effetto (tipo, attivatore) e argv eventuali argomenti statici da passargli.
Se si ha registrato una funzione personalizzata nella mappa degli FX normali, il suo indice corrisponde anche al tipo, permettendo di scrivere:
var fx_custom_type = DialogFX.register(function(argv) { }); // <- Tipo dell'effetto
var fx_custom = dialog_fx_create(trigger_on_enter | fx_custom_type);
⚠️ Attenzione: se si vogliono avere dati dinamici a tempo di esecuzione, sono necessari dei riferimenti a strutture dati come array o struct, poiché usando gli operatori "." e "[]" è possibile utilizzare il dato nell'esatta locazione di memoria specificata. Se si necessitasse serializzare le informazioni, la soluzione migliore è registrare funzioni per il recupero delle informazioni, siccome verrebbe serializzato l'indice senza il rischio di effettuare delle copie ai dati. L'indice appunto indica che azione deve fare e il gestore può richiamare la funzione apposita per recuperare i dati.
CREAZIONE DI EFFETTI FLUSSO-RISOLUTORI
Un effetto flusso-risolutore è un particolare tipo di effetto (già predisposto) in grado di spostare un esecutore di dialoghi sulla posizione in cui l'effetto lo conduce.
Ogni effetto flusso-risolutore ha una propria funzione helper di instanziazione per velocizzare il processo:
// 0. Opzione di flusso (RARAMENTE DA UTILIZZARE)
dialog_fx_create_flow_option(jump_position, jump_settings, [prompt], [metadata])
// 1. Jump
dialog_fx_create_jump(settings_mask, [flow_option])
// 2. Dispatch
dialog_fx_create_dispatch(settings_mask, [flow_options], [fx_indexer_index], [fx_condition_index], [fx_indexer_argv], [fx_condition_argv])
// 3. Fallback
dialog_fx_create_fallback(settings_mask, [flow_option], [fx_condition_index], [fx_condition_argv])
// 4. Choice
dialog_fx_create_choice(settings_mask, [flow_options], [fx_indexer_index], [fx_indexer_argv])
Dove settings_mask indica le impostazioni dell'effetto, flow_option/s indica la/le opzioni di flusso raggiungibili dall'effetto, fx_*_index e fx_*_argv indicano l'indice della funzione dalla mappa corrispondente ed eventuali argomenti da passargli. Con gli esempi 1-4 non serve specificare nella maschera delle impostazioni che tipo di effetto si sta creando: la funzione lo sa già.
Una piccola scaletta mentale comoda per capire l'uso di questi effetti è:
- jump: un solo target senza condizioni (goto)
- dispatch: uno o più target con o senza condizioni ([if +] switch)
- fallback: un solo target con condizione (if)
- choice: uno o più target senza condizioni (input utente + switch)
♻️ Suggerimento: dialog_fx_create_flow_option() si usa solo per salti esclusivamente programmabili prima di stendere la struttura, quindi se a priori si sa dove deve portare il salto. Successivamente segue la spiegazione di come collegare gli elementi dialogici, e spesso sarà sufficiente usare un metodo apposito per i salti assoluti.
COLLEGAMENTO DI ELEMENTI CON EFFETTI FLUSSO-RISOLUTORI
Nella maggior parte dei casi, siccome i salti riconducono a strutture di dialogo effettivamente esistenti invece che a "salti teorici" (specificando unità e direzione solo), è possibile indicare direttamente sui dialoghi bersaglio dello specifico salto assoluto da quale effetto provengono, grazie al metodo Dialog.from():
Dialog.from(fx, [prompt], [index], [metadata])
Dove fx indica l'effetto, prompt l'eventuale testo della scelta (spesso solo usati per effetti choice), index l'indice di dove questa scelta sarà posizionata all'interno della lista (solo per choice e dispatch) e metadata ulteriori dati satellite per permettere ancora più personalizzazioni (per esempio una scelta "cattiva" può leggere questa informazione per farla disegnare in rosso).
var scelta = dialog_fx_create_choice(trigger_on_leave);
sequenza_iniziale = dialog_sequence_create([
dialog_create("Battuta con scelta", 0, [scelta]),
]);
sequenza_opz1 = dialog_sequence_create([
dialog_create("Battuta dopo opzione 1 della scelta")
.from(scelta, "Opzione 1"),
// ...
]);
sequenza_opz2 = dialog_sequence_create([
dialog_create("Battuta dopo opzione 2 della scelta")
.from(scelta, "Opzione 2"),
// ...
]);
♻️ Suggerimento: il metodo .from() può essere usato per tutti i tipi di effetti flusso-risolutori, quindi anche con jump, dispatch e fallback. Il metodo è anche concatenabile, quindi se un dialogo è raggiungibile da più punti è possibile fare:
dialog.from(...).from(...).from(...).from(...)
Così facendo, tutti gli effetti elencati aggiungeranno il dialogo che ha invocato il metodo nella loro lista dei target con le opzioni specificate per ciascuno.
Questo è il meccanismo che rende lineare il branching dei dialoghi: si crea l'effetto diramatore in anticipo e si collega chiamando il metodo .from() dopo.
⚠️ Attenzione: jump e fallback sono definiti per avere una sola opzione di salto e invocare questo metodo su di essi la sovrascriverà.
4. CREAZIONE DELL'INTERPRETE: DialogRunner()
Ultimata la definizione del database, si passa dunque all'esecutore dei dialoghi. Non è richiesto che l'esecutore sia creato dallo stesso oggetto che ospita il gestore; al contrario è possibile avere più istanze in base alla necessità nel progetto (ad esempio molteplici battute visualizzabili simultaneamente in un multiplayer potrebbero utilizzare più esecutori). L'instaziazione di un singolo esecutore è realizzabile con un semplice comando:
dialog_runner = dialog_runner_create(dialog_manager);
Si ricorda che l'interprete/esecutore è l'oggetto che conosce la propria posizione all'interno del database di dialoghi e cicla seguendo le regole di flusso definite dagli effetti dell'utente.
5. ESECUZIONE DEI DIALOGHI
CARICAMENTO DI DIALOGHI
È possibile specificare un dialogo da caricare per spostare l'esecutore sulla posizione specificata con il metodo dedicato .load(). La differenza tra salto e caricamento risiede rispettivamente nell'esecuzione o meno degli effetti: un salto esegue gli effetti, un caricamento no.
DialogRunner.load(position, [busy], [argv])
Dove position indica quale codice posizione o quale elemento dialogico caricare, busy indica se deve performare un salto e argv indica eventuali argomenti da passare agli effetti (solo usato per testing particolari su un singolo effetto). Ad esempio:
// Premere PSM carica il primo dialogo di scene_1 senza effettuare salti
if (mouse_check_button_pressed(mb_left)) {
dialog_runner.load(scene_1);
}
ESECUZIONE DEGLI EFFETTI FLUSSO-RISOLUTORI
Gli effetti flusso-risolutori hanno la particolarità di alterare il normale flusso di un salto. Se, infatti, un tale effetto viene eseguito, questo reindirizza il flusso ad un nuovo dialogo, eseguendo i suoi effetti finché non finisce la catena di salti che ogni effetto produce.
⚠️ Attenzione: l'esecuzione di un effetto flusso-risolutore interrompe quella di tutti gli effetti con la stessa fase di attivazione che lo seguono. Se si vuole avere la garanzia di eseguire determinate operazioni prima di lasciare un dialogo, si mettano gli effetti flusso-risolutori come gli ultimi della lista all'interno dell'oggetto Dialog().
CICLO DI ESECUZIONE
Si definisca brevemente il comportamento delle varie opzioni DIALOG_FX.TRIGGER_*:
- ON_ENTER: l'esecuzione avviene una volta sola quando il dialogo sta per essere raggiunto
- ON_STAY: l'esecuzione avviene ogni frame in cui il dialogo persiste nell'esecutore
- ON_LEAVE: l'esecuzione avviene una volta sola quando il dialogo sta per essere lasciato
- ON_CUSTOM: ulteriore opzione per permettere al programmatore di attivare l'effetto quando desidera (manuale)
⚠️ Attenzione: le fasi di esecuzione degli effetti non si sovrappongono. Il trigger ON_STAY non viene eseguito nei momenti in cui il dialogo viene caricato o lasciato.
Il ciclo di esecuzione del runner è strettamente legato alla presenza o meno di effetti flusso-risolutori all'interno del dialogo su cui è attualmente posizionato l'esecutore. Gli effetti inseriti all'interno di un Dialog() sono eseguiti in ordine e filtrati in base alla fase corrente dell'esecutore.
Se ad esempio l'esecutore sta lasciando un dialogo eseguirà, nell'ordine con cui li incontra, gli effetti con attivazione all'ON_LEAVE fino al primo effetto flusso-risolutore o fino alla fine della lista per poi proseguire con il ciclo.
La logica del ciclo di esecuzione normale è la seguente:
0. Il dialogo esegue gli effetti ON_STAY finché non si tenta di avanzare
1. Sono eseguiti in ordine gli effetti all'ON_LEAVE del dialogo corrente
2. Se un effetto flusso-risolutore viene eseguito viene aggiornato il dialogo target
3. Il dialogo corrente diventa il dialogo target
4. Sono eseguiti in ordine gli effetti all'ON_ENTER del dialogo target
5. Se un effetto flusso-risolutore viene eseguito, viene aggiornato il dialogo target e il ciclo ricomincia dal punto 1, altrimenti si passa al punto 6
6. Il ciclo di esecuzione termina
AVANZAMENTO E PREVISIONE DI DIALOGHI
I DialogRunner() prevedono anche dei metodi per avanzare e per simulare un avanzamento. Si definisce avanzamento un salto relativo frequente, ad esempio un salto che ha come impostazioni: "1 unità di tipo DIALOGO" (frequente in quanto ogni volta che andiamo avanti a leggere con una casella di testo stiamo concettualmente facendo quello). I metodi che il gestore mette a disposizione sono due:
// 1. Avanzamento
dialog_runner.advance(shift, [jump_settings], [prev_position], [argv])
// 2. Previsione
dialog_runner.predict(shift, [jump_settings], [prev_position], [argv])
Dove shift indica la quantità di spostamento, jump_settings le impostazioni del salto (unità di spostamento, comportamenti particolari per mantersi su entità dialogiche o per saltare fasi di esecuzione), prev_position indica la posizione precedente da considerare prima dell'avanzamento (il valore di default è quella attuale del runner) e argv indica gli argomenti da passare ad eventuali effetti (usato solo per testing).
La differenza tra .predict() e .advance() sta nella creazione e lavoro su una copia dei dati: il primo metodo simula il salto copiando i dati della struttura, il secondo apporta modifiche direttamente sull'originale.
⚠️ Attenzione: se si vuole lavorare con delle copie si faccia attenzione sia ai riferimenti di queste sia ad eventuali effetti collaterali che provocano gli effetti fuori dalla struttura dati dell'esecutore (variabili globali, etc.).
6. ULTERIORI FUNZIONALITÀ DI RIFERIMENTO
Per brevità non verranno elencate tutte le funzioni disponibili, ma è possibile trovare tutti gli esempi utili sul file manual.gml all'interno della repository dedicata. A breve, nella stessa repository (probabilmente su una cartella diversa), verrà caricato anche un progetto scaricabile dove poter visualizzare e provare il codice.
ESEMPI E CASI D'USO
Successivamente vengono mostrati esempi di come utilizzare il gestore per creare una semplice interfaccia di dialogo assieme ad altre idee per integrare l'asset all'interno dei vostri giochi.
Si ricorda che il gestore organizza i dati e ne facilita il recupero, ma l'interpretazione e il lavoro con questi spetta poi al programmatore!
ESEMPIO: PROGRAMMARE UNA CASELLA DI TESTO CON IL GESTORE
Dai pareri di due tra i membri più anziani della comunità un gestore di dialoghi dovrebbe avere/rendere facile implementare:
1. Possibilità di personalizzazione della textbox
2. Azioni personalizzabili da eseguire durante i caricamenti dei dialoghi
3. Controlli sulla lunghezza del testo
4. Possibilità di avere o non avere avatar
5. Effetto typewriter con completamento della battuta
6. Autoclose ed effetti sonori
7. Branching con scelte
⚠️ Attenzione: quello fornito sotto non è un codice completo, bensì solo una bozza a scopo dimostrativo. Si consiglia di consultare il manuale allegato nell'asset (file manual.gml) per comprendere il funzionamento del codice.
CREAZIONE DELLA STRUTTURA DIALOGICA
dialog_manger = dialog_manager_create();
dialog_runner = dialog_runner_create(dialog_manager);
#region Struttura dialogica
// 2. Registrazione
var duck = Dialog.speaker(DIALOG.SPEAKER_DUCK);
var fxtrigger_onleave = DialogFX.trigger(DIALOG_FX.TRIGGER_ON_LEAVE);
var fxtype_sound = DialogFX.register(function(argv) {
audio_play_sound(asset_get_index(argv[0]), 10, false);
});
var fxcond_gametime = DialogFX.register(function(argv) {
return current_time < argv[0] * 1000;
});
// 2. Uso
var fx_sound = dialog_fx_create(fxtype_sound | fxtrigger_onleave, ["snd_quack"]);
var fx_loop = dialog_fx_create_fallback(fxtrigger_onleave, undefined, fxcond_gametime, [30]);
sequence_1 = dialog_sequence_create([
dialog_create("Quack!", duck, [fx_sound]).from(fx_loop), // <- Bersaglio del salto
dialog_create("I will keep quacking until 30s have passed.", duck, [fx_loop]), // <- Inizio del salto
dialog_create("It seems like 30s have passed!", duck),
]);
// ...
scene_1 = dialog_scene_create([
sequence_1,
// ...
]);
// ...
dialog_manager.add([
scene_1,
// ...
]);
#endregion
// 3. Controllo lunghezza testo (proprietà statiche costruttore)
Dialog.TEXT_WIDTH_MAX = 400;
Dialog.TEXT_WIDTH_FUNC = function(dialog)
{
var w = string_width(dialog.text);
if (w > Dialog.TEXT_WIDTH_MAX) {
// Tentativo di modificare il testo per farlo entrare...
w = string_width(dialog.text);
}
return w;
}
// Dialogo corrente salvato per comodità
dialog = dialog_runner.load(sequence_1).dialog();
// 5. Variabili per effetto typewriter
text_length = 0;
text_character_speed = .8;
text_character_index = 0;
IMPOSTAZIONI PER CICLARE I DIALOGHI CON GESTIONE SCELTE E AUTOCLOSE
Si consideri lo step event per l'oggetto O_DIALOG. Per implementarlo con tutte le funzionalità richieste (typewriter, autoclose, scelte) una soluzione possibile è:
if (!active) {
exit;
}
var choice = dialog.choice(); // C'è una scelta attualmente?
var option_count = choice != undefined ? array_length(choice.options()) : 0;
var key_advance = keyboard_check_pressed(vk_enter);
var key_down = keyboard_check_pressed(vk_down);
var key_up = keyboard_check_pressed(vk_up);
var fill = key_advance;
var shift = fill && text_character_index >= text_length;
// "Non uscire dalla sequenza ed esegui una scelta solo se presente"
var jump_options = DIALOG_RUNNER.JUMP_OPTION_MAINTAIN_SEQUENCE // 6. Impostazione per autoclose
| DIALOG_RUNNER.JUMP_OPTION_CHOICE * (option_count > 0) // 7. Impostazione per scelte
;
// Gestisco l'input per la selezione della scelta
if (option_count > 0) {
var incr = key_down - key_up;
dialog_runner.choice_index = clamp(dialog_runner.choice_index + incr, 0, option_count - 1);
}
dialog = dialog_runner.advance(shift, jump_options)
.dialog() // <- Ottiene il dialogo corrente
;
if (fill) {
text_character_index = text_length;
}
if (shift != 0) {
text_length = string_length(dialog.text);
text_character_index = 0;
}
// 5. Typewriter
text_character_index += text_character_speed;
// 6. Se avanzando sarebbe uscito dalla sequenza, imposto le variabili per l'autoclose
if (dialog_runner.status & DIALOG_RUNNER.STATUS_MAINTAINED_SEQUENCE) {
active = false;
}
♻️ Suggerimento: l'operatore AND BITWISE ("&") lo si può considerare come l'opposto dell'OR BITWISE ("|") visto prima. Mentre l'OR serve a comporre l'informazione, l'AND serve a leggerla. Quindi facendo 314 & CORRIDOIO otterremmo 010 (filtra solo il dato di interesse e azzera gli altri). In questo caso STATUS_MAINTAINED_SEQUENCE ci dice se saremmo usciti dalla sequenza.
IMPLEMENTAZIONE GRAFICA
Il codice per la parte grafica dà giusto un'idea di come si potrebbe procedere. Si consideri l'implementazione di alcune funzioni e strutture dati helper:
/// O_DIALOG create event
speaker_data = [
["Personaggio 1", spr_portrait_personaggio_1 /*, dati vocali, etc. */],
["Personaggio 2", spr_portrait_personaggio_2],
["Personaggio 3", spr_portrait_personaggio_3],
// ...
];
dialog_get_name = function(dialog) {
return speaker_data[dialog.speaker()][0];
}
dialog_get_portait = function(dialog) {
return speaker_data[dialog.speaker()][1];
}
dialog_speaker_is_protagonist = function(dialog) {
return dialog.speaker() == DIALOG.SPEAKER_PERSONAGGIO_1;
}
dialog_should_show_name = function(dialog) {
return dialog.tag() != DIALOG.TAG_NASCONDI_NOME;
}
// Etc.
Poi, nel draw event, una logica potrebbe essere:
if (!active || dialog == undefined) {
exit;
}
draw_dialog_textbox(dialog.textbox()); // 1. Textbox
var portrait = dialog_get_portrait(dialog);
var text_to_draw = string_copy(dialog.text, 1, text_character_index); // 5. Typewriter
// Calcolo coordinate del testo
// 4. Avatar
if (portrait != undefined) {
// Ricalcolo coordinate del testo
draw_dialog_portrait(portrait);
}
if (dialog_should_show_name(dialog)) {
draw_dialog_name(dialog_get_name(dialog));
}
draw_dialog_text(text_to_draw);
var choice = dialog.choice();
if (choice == undefined) {
exit;
}
var option_count = array_length(choice.options());
for (var i = 0; i < option_count; ++i)
{
var option_text = choice.prompt(i);
if (i == dialog_runner.choice_index) {
// Personalizzazione in base alla selezione corrispondente
}
draw_dialog_choice_option_text(option_text, i);
}
ESPANSIONE NEL PROGETTO
Ora che si ha una base solida per l'oggetto O_DIALOG, si può ampliare il funzionamento e le interazioni con il gestore. Ad esempio, si potrebbe pensare che se un O_PLAYER interagisce con un O_NPC oppure O_ITEM etc., potrebbe apparire una textbox che mostra le sequenze correlate alle interazioni. Si potrebbe quindi pensare di assegnare ad ogni istanza il riferimento alle sequenze da mostrare.
/// Codice creazione di un'istanza di O_NPC
interaction_sequences = [
O_DIALOG.sequenza_1,
O_DIALOG.sequenza_2,
O_DIALOG.sequenza_3,
];
interaction_count = 0;
Per poi leggere, ad ogni interazione:
// Restituisce l'interazione attuale e aumenta il contatore
interact = function() {
var indice = min(interaction_count++, array_length(interaction_sequences) - 1);
return interaction_sequences[indice];
}
In quel caso si realizzi una funzione helper per aggiornare tutti i dati, anche per effetti visivi:
/// O_DIALOG create event
resume = function(dialog_linkable, busy = false)
{
dialog = dialog_runner.load(dialog_linkable, busy).dialog();
text_length = string_length(dialog.text);
text_character_index = 0;
active = true;
return dialog;
}
E infine, interagendo, si potrebbe appunto avere:
O_DIALOG.resume(INST_O_NPC.interact(), false);
CONSIGLI PERSONALI PER LO SCRIPT
Avendo utilizzato l'asset in vari progetti mi sento di poter dare alcuni consigli pratici per utilizzarlo al meglio.
1. Esplorare tutte le impostazioni e comportamenti del gestore, partendo da dialog_*, DIALOG_* e Dialog*().
2. Creare qualche struttura semplice per vedere come si collegano i vari punti del dialogo.
3. Usare una nomenclatura sagace per permettere il riconoscimento efficace degli elementi dialogici. Ad esempio, sn01sq01_nomeevento rende facile capire sia cosa succede su quella particolare sequenza (quindi ricordare meglio quali battute possono esservi presenti) sia localizzare la posizione nel gestore e potenzialmente nel codice.
4. Salvare i riferimenti di ogni struttura dialogica di interesse dentro variabili di istanza. Questo rende solidi i riferimenti alle strutture e diminuisce la probabilità di incongruenze.
5. Creare una variabile-funzione nell'oggetto che ospita il gestore dei dialoghi per deserializzare i dati e per aggiornare i riferimenti, ad esempio:
/// Create event di O_DIALOG
setup = function(filename)
{
// Deserializzazione
dialog_manager.deserialize(filename, true);
// Aggiornamento dei riferimenti
sn00 = dialog_manager.scene(0);
sn00sq00_event1 = sn00.sequence(0);
sn00sq01_event2 = sn00.sequence(1);
// ...
}
/// Altrove
interaction_sequences = [
O_DIALOG.sn00sq00_event1,
O_DIALOG.sn00sq01_event2,
// ...
];6. Salvare tutti gli script per la creazione del database dialogico, serializzare il contenuto su file, caricarlo lato codice e rendere inattivo lo script di creazione. Qualora dovesse servire aggiornare la struttura, si può riutilizzare lo script per ricomporla.
7. Per aggiungere il supporto per più lingue al gestore si può utilizzare lo script di una lingua già esistente e modificare i testi. Nel caso in cui servissero più battute all'interno di una DialogSequence() non ci sarebbe nessun problema: essendo le posizioni partizionate (e non direttamente sequenziali), i salti non verrebbero compromessi. Si presti solo attenzione al posizionamento degli effetti di salto in maniera tale che sia fedele all'originale e ai salti relativi in generale.
8. Modificare o usare gli internals (spesso prefissi da __) solo se strettamente necessario. Tutte le operazioni utili non necessitano referenziarli direttamente.
9. Si segnalino bug e feature request sulla pagina di Github dello script oppure su Discord: ogni feedback renderà il gestore sempre più adatto alle esigenze di tutti.
♻️ Suggerimento: capiamo come funzionano le posizioni partizionate. Consideriamo l'esempio di prima dei numeri di stanza all'interno di un edificio e trattiamo una singola stanza come dialogo, un corridoio come sequenza e un piano come una scena. Se si volesse salire di piano ad esempio dalla stanza 220, si intuisce che si può andare direttamente alla stanza 300, senza passare magari per le altre 79 (o meno) stanze rimanenti. Analogamente se si finisce di visitare tutte le stanze di un corridoio passeremo a quello successivo (ad es. 317 -> 320). Questo implica che c'è una capienza limitata per ogni "contenitore" (al massimo 10 stanze per corridoio, 10 corridoi per piano e 10 piani). Con il gestore di dialoghi i limiti sono 2048 dialoghi per sequenza, 512 sequenze per scena e 1024 scene. Questi dati sono modificabili a proprio piacimento dentro l'enumeratore DIALOG_MANAGER (il numero di bit per la codifica della posizione).
CREDITI E TERMINI DI UTILIZZO
L'asset descritto è reperibile sulla mia pagina di GitHub: @MaximilianVolt. L'utilizzo del presente script è consentito alle seguenti condizioni.
È consentito:
- Importare lo script all'interno di progetti personali o commerciali.
- Modificare lo script per adattarlo alle esigenze del progetto.
- Distribuire online il codice sorgente esclusivamente come parte integrante di un gioco o progetto che lo utilizza attivamente.
- Analizzare e studiare il funzionamento dello script al fine di comprenderne logiche, strutture e approcci architetturali, assieme alla realizzazione di uno script con funzionalità simili, a condizione che:
- il nuovo codice sia interamente riscritto da zero;
- non vengano copiati, integralmente o parzialmente, porzioni di codice, strutture testuali, commenti o organizzazioni interne riconducibili allo script originale;
- non venga distribuita alcuna parte del codice originale, neppure modificata.
La somiglianza funzionale o concettuale non costituisce violazione, purché l'implementazione sia frutto di sviluppo indipendente e non derivi da copia o adattamento diretto del codice sorgente originale.
L'autore originale (@MaximilianVolt) deve essere sempre creditato. In particolare:
- Il credito deve essere chiaramente visibile all'interno del progetto (es. sezione crediti, documentazione, repository, o file di licenza allegato).
- Il nome dell'autore non può; essere rimosso, sostituito o alterato.
- Anche in caso di modifiche sostanziali allo script, l'attribuzione all'autore originale rimane obbligatoria.
Non è consentito:
- Ridistribuire lo script come asset, risorsa, pacchetto o libreria autonoma, gratuita o a pagamento.
- Ricaricare o pubblicare lo script (integralmente o in parte) senza attribuzione all'autore originale.
- Modificare, falsificare o rimuovere le informazioni di credito o paternità presenti nello script o nella documentazione associata.
La licenza descritta sopra concede un diritto d'uso, non un trasferimento di proprietà. Tutti i diritti non esplicitamente concessi rimangono riservati all'autore.
CONCLUSIONE
Il sistema descritto rappresenta un modo strutturato e riutilizzabile per affrontare la gestione dei dialoghi senza dover riscrivere ogni volta la stessa logica di base. L'idea centrale si basa appunto sul separare chiaramente cosa sono i dialoghi (dati) da come vengono eseguiti (runner) e da cosa producono nel gioco (effetti e logica esterna).
Questo approccio permette di lavorare sui contenuti in modo più organizzato, mantenere sotto controllo il branching in maniera sorprendentemente lineare e aggiungere comportamenti complessi senza trasformare il codice in una sequenza difficile da mantenere: le scene strutturano il contesto, le sequenze organizzano il flusso, i dialoghi rappresentano la minima unità dialogica con le battute contenendo inoltre gli FX, che gestiscono tutta la parte dinamica.
Il sistema lascia al progetto la libertà di decidere come mostrare testi, ritratti, animazioni o scelte. In questo modo si può adattare a stili di gioco diversi senza doverne modificare il nucleo.
Nel complesso l'auspicio è che questo strumento semplifichi nel tempo la gestione della narrativa: meno codice ripetuto, più controllo sul flusso e maggiore facilità nell'espandere il sistema aiutano il progetto a mantenere la sua robustezza man mano che cresce e vede svilupparsi nuovi rami all'interno della sua dimensione narrativa.
Detto questo,
dialog_create("alla prossima! Buona fortuna alla Compe 2026!", maximilian.volt | out)