fpx · dialog
Grafos de diálogo ramificado con condiciones (nivel, quests) contra un contexto inyectado. Runtime puro: jamás toca estado global.
El ZIP incluye: addons/fpx/dialog + sus tests.
Qué es
Diálogo ramificado como grafo de datos (nodos con texto del NPC + opciones del jugador que navegan o disparan acciones de vocabulario congelado) y DialogRuntime PURO: filtra opciones visibles por condiciones (nivel mínimo, quest hecha/activa, auto-ocultar al completar) contra un context INYECTADO, y devuelve las acciones para que la UI las ejecute — jamás toca estado global.
Instalación
- Descarga
fpx-dialog-v0.1.0.zip(botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
tu-juego/
├── project.godot
├── addons/
│ └── fpx/
│ └── dialog/
└── tests/ ← opcional: bórralo si no quieres los tests- Abre el proyecto (o corre
--importheadless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script. - Opcional — verifica con la suite (necesita GdUnit4, ver la guía):
-a tests/dialog.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# El grafo dorado de la fixture (misma estructura que maestro_roderic de
# Stremus). Tus grafos reales son .tres en res://data/dialogs.
var runtime := DialogRuntime.new()
runtime.start(DialogFixtures.golden_graph())
# El contexto se INYECTA: el runtime jamás toca estado global.
var context := {
"level": 3,
"quest_done": func(id: StringName) -> bool:
return id == &"reconocimiento_del_perimetro",
"quest_active": func(_id: StringName) -> bool:
return false,
}
print(runtime.current_text()) # el texto del NPC en el nodo actual
# Opciones filtradas por condiciones (nivel mínimo, quest hecha, auto-ocultar).
var options := runtime.visible_options(context)
for i in options.size():
print(i, ": ", options[i].text)
# choose() navega el grafo o te DEVUELVE la acción — ejecutarla es cosa tuya.
var result := runtime.choose(0)
match result["kind"]:
&"goto": # ya movió el nodo actual
print(runtime.current_text())
&"action": # p. ej. offer_quest / open_shop: la ejecuta tu juego
start_quest(result["quest_id"])
&"end": # cerrar el panel
pass
&"none": # índice inválido: jamás crashea
passDemo en vivo
El componente REAL corriendo en tu navegador — Godot 4.7 exportado a Web sobre las fixtures del kit. La primera carga baja el motor (~10 MB comprimido) y se comparte entre todas las demos.
API pública
| Clase | Qué da |
|---|---|
DialogGraphDefinition (Resource) | id, start_node, nodes · node_by_id(id) · by_id(dialog_id) desde DIALOGS_DIR |
DialogNodeDefinition / DialogOptionDefinition | texto, goto, action, quest_id, required_level, required_quest_done, hide_if_quest_done |
DialogRuntime | start(graph) · current_node() / current_text() · visible_options(context) · choose(index) -> Dictionary · goto_node(id) · option_visible(option, context) estática |
context = {level: int, quest_done: Callable(id) -> bool, quest_active: Callable(id) -> bool}.
Firmas completas (del código)
DialogGraphDefinitionextends Resourcedialog_graph_definition.gd
Grafo de DIÁLOGO RAMIFICADO como datos (ED-3, plan_editor_contenido.md §4.1): opciones y sub-opciones a N niveles, con condiciones y acciones. Se resuelve por id contra data/dialogs/ (mismo "Db" mínimo que QuestDefinition.by_id / MapDefinition.by_id). Un NpcDefinition apunta a un grafo con su campo dialog; sin grafo, el NPC sigue con dialog_lines (greybox intacto — compatibilidad TOTAL).
const DIALOGS_DIR: String = "res://data/dialogs"@export var id: StringName@export var display_name: String = ""@export var start_node: StringNameId del nodo por el que ARRANCA la conversación (debe existir en nodes).
@export var nodes: Array[DialogNodeDefinition] = []func node_by_id(node_id: StringName) -> DialogNodeDefinitionNodo con ese id, o null si no existe (el runtime/validador deciden qué hacer).
static func by_id(dialog_id: StringName) -> DialogGraphDefinitionResolución por id contra data/dialogs (patrón QuestDefinition.by_id): null + warning si no existe — el llamador decide el fallback (dialog_lines).
DialogNodeDefinitionextends Resourcedialog_node_definition.gd
Un NODO del grafo de diálogo ramificado (ED-3, plan_editor_contenido.md §4.1): el texto que dice el NPC en este punto de la conversación y las OPCIONES del jugador (aristas hacia otros nodos o acciones). El grafo lo recorre DialogRuntime (núcleo puro) y lo pinta DialogPanel.
@export var id: StringNameId ÚNICO del nodo dentro del grafo (destino de goto de las opciones y posible start_node del grafo). Congelado como los demás ids del pipeline.
@export var options: Array[DialogOptionDefinition] = []Opciones del jugador en este nodo (vacío = nodo terminal; la UI ofrece «Marcharte.» para cerrar de todas formas).
DialogOptionDefinitionextends Resourcedialog_option_definition.gd
Una OPCIÓN del jugador dentro de un nodo de diálogo ramificado (ED-3, plan_editor_contenido.md §4.1). Es una arista del grafo: o navega a otro nodo (goto) o dispara una ACCIÓN que la UI ejecuta (el runtime jamás toca GameState — mismo espíritu que QuestOfferFlow/QuestLog puros). Compatibilidad: los NPC sin grafo siguen usando NpcDefinition.dialog_lines; este esquema solo entra en juego cuando NpcDefinition.dialog != null.
const ACTIONS: Array[StringName] = …Vocabulario CONGELADO de acciones (solo se AMPLÍA al final, regla dura del pipeline de contenido). &"" = la opción solo navega (usa goto). - &"end" : cierra el diálogo (goto se ignora). - &"offer_quest" : arranca la conversación de OFERTA de quest_id (reusa el QuestOfferFlow existente). - &"open_shop" : abre la tienda del NPC (si es vendedor). - &"promote" : muestra las opciones de promoción del NPC (P-22). - &"reset" : muestra los resets del Reconfigurador (P-26).
@export var text: String = ""Texto que ve el jugador en la fila de opción.
@export var goto: StringName = &""Id del nodo destino al elegir esta opción; &"" si la acción cierra el diálogo (o si la opción SOLO dispara una acción sin navegar).
@export var action: StringName = &""Acción a ejecutar (vocabulario ACTIONS). &"" = solo navega con goto.
@export var quest_id: StringName = &""Quest a ofrecer cuando action es &"offer_quest" (id contra data/quests).
@export var required_level: int = 0Nivel MÍNIMO del personaje para ver la opción; 0 = sin requisito.
@export var required_quest_done: StringName = &""La opción SOLO se muestra si esta quest está COMPLETADA; &"" = sin requisito.
@export var required_quest_active: StringName = &""La opción SOLO se muestra si esta quest está ACTIVA; &"" = sin requisito.
@export var hide_if_quest_done: StringName = &""La opción se OCULTA si esta quest está completada; &"" = sin requisito (natural para no re-ofrecer una quest ya cumplida).
func has_action() -> bool¿Esta opción dispara una acción (distinta de la mera navegación)?
DialogRuntimeextends RefCounteddialog_runtime.gd
Recorrido PURO de un grafo de diálogo ramificado (ED-3, plan_editor_contenido.md §4.1): mantiene el nodo actual, filtra las opciones visibles por sus condiciones y resuelve la elección. NO ejecuta acciones ni toca GameState — las DEVUELVE para que la UI las ejecute (mismo espíritu que QuestLog/QuestOfferFlow puros). Testeable headless. El context es un Diccionario INYECTADO (desacople total de GameState): { "level": int, "quest_done": Callable(StringName) -> bool, "quest_active": Callable(StringName) -> bool } Cualquier clave ausente se trata como "sin dato" (nivel 0, checks falsos).
func start(graph: DialogGraphDefinition) -> voidArranca el recorrido en el start_node del grafo. context es opcional aquí (la visibilidad se evalúa al pedir visible_options).
func current_node() -> DialogNodeDefinitionNodo actual (null si el grafo no arrancó o el start_node no existe).
func current_text() -> StringTexto del NPC en el nodo actual ("" si no hay nodo).
func visible_options(context: Dictionary = {}) -> Array[DialogOptionDefinition]Opciones VISIBLES del nodo actual según las condiciones del context. Cachea el resultado para que choose(index) opere sobre el mismo listado que la UI acaba de pintar.
func choose(index: int) -> DictionaryResuelve la elección del índice index sobre las ÚLTIMAS opciones visibles (las de la llamada previa a visible_options). Devuelve el INTENTO, sin ejecutar nada: { "kind": &"goto", "node": StringName } → ya movió el nodo actual { "kind": &"end" } → cerrar el diálogo { "kind": &"action", "action": StringName, "quest_id": StringName, "goto": StringName } → la UI ejecuta la acción { "kind": &"none" } → índice inválido / opción vacía Un índice fuera de rango JAMÁS crashea (kind &"none").
func goto_node(node_id: StringName) -> boolNavega DIRECTO a un nodo por id (lo usa la UI para «Volver» tras una acción que no movió el grafo). Falso si el nodo no existe (no crashea).
static func option_visible(option: DialogOptionDefinition, context: Dictionary) -> bool¿La opción es visible con este contexto? PURA y estática (testeable sin instanciar el runtime). Cada condición ausente (&"" o 0) no filtra.
Qué configura el juego
Sus grafos .tres (o en memoria), el context y la ejecución real de las acciones (offer_quest, open_shop, …).
Fixture mínima
testing/dialog_fixtures.gd — DialogFixtures.golden_graph(): 3 nodos, 2 niveles de opciones y una oferta condicionada (misma estructura que maestro_roderic).
dialog_fixtures.gdcódigo completo
# res://addons/fpx/dialog/testing/dialog_fixtures.gd
class_name DialogFixtures
extends RefCounted
## Fixture mínima del componente `dialog`: un grafo dorado EN MEMORIA con la
## misma estructura que el ejemplar `maestro_roderic` de Stremus (start →
## entrenamiento → prueba, con la oferta de quest condicionada por nivel,
## quest previa hecha y auto-ocultación al completarla) — cero `res://data`.
## Los tests del kit recorren ESTE grafo; el `.tres` real de Stremus sigue
## siendo el ejemplar oro de contenido y lo cubren sus tests de contenido.
static func _option(text: String, goto: StringName = &"", action: StringName = &"",
quest_id: StringName = &"") -> DialogOptionDefinition:
var option := DialogOptionDefinition.new()
option.text = text
option.goto = goto
option.action = action
option.quest_id = quest_id
return option
static func _node(id: StringName, text: String,
options: Array[DialogOptionDefinition]) -> DialogNodeDefinition:
var node := DialogNodeDefinition.new()
node.id = id
node.text = text
node.options = options
return node
## Grafo dorado: 3 nodos, 2 niveles de opciones y una oferta condicionada.
static func golden_graph() -> DialogGraphDefinition:
var offer := _option("Estoy listo para la prueba.", &"", &"offer_quest",
&"la_senda_del_espadachin")
offer.required_level = 2
offer.required_quest_done = &"reconocimiento_del_perimetro"
offer.hide_if_quest_done = &"la_senda_del_espadachin"
var start := _node(&"saludo", "Bienvenido, recluta. ¿Qué buscas?", [
_option("Quiero aprender la espada.", &"entrenamiento"),
_option("Nada, sigo mi camino.", &"", &"end"),
])
var training := _node(&"entrenamiento",
"El acero exige disciplina. Pregunta lo que necesites.", [
_option("Háblame de la prueba.", &"prueba"),
_option("Volvamos al principio.", &"saludo"),
])
var trial := _node(&"prueba",
"Solo quien ha reconocido el perímetro puede intentarlo.", [
offer,
_option("Aún no estoy listo.", &"entrenamiento"),
])
var graph := DialogGraphDefinition.new()
graph.id = &"grafo_dorado"
graph.display_name = "Grafo dorado del kit"
graph.start_node = &"saludo"
var nodes: Array[DialogNodeDefinition] = [start, training, trial]
graph.nodes = nodes
return graphCómo se prueba
tests/dialog/dialog_runtime_test.gd — recorrido dorado, matriz de condiciones, índices inválidos sin crash.
Ejemplar oro (Stremus)
scripts/resources/dialog_*_definition.gd + scripts/core/dialog/dialog_runtime.gd (main@0be95b4); el grafo maestro_roderic de data/dialogs/.
Deuda declarada
- FLAG-DIALOG-UI: el panel de diálogo del ejemplar no se extrae; cada juego pinta el suyo (o llega con
ui_kit, FK-7). - FLAG-I18N (D-FW-8): los textos viven en el
.tres.