Parte: 18 — Arquitectura de gameplay y sistemas sistémicos · Fuente: Charlas de GDC sobre herramientas narrativas y diálogo ramificado · Documentación de localización de Godot 4 ⏱️ Duración estimada: 120 min · Nivel: Avanzado
Construir un sistema de diálogo data-driven: un grafo de nodos con opciones, condiciones, variables, consecuencias y localización, ejecutado por un intérprete que no sabe nada del contenido concreto. La clase 168 enseñó a escribir narrativa; aquí construyes la máquina que la ejecuta.
El objetivo real no es "mostrar texto": es que un guionista pueda escribir una conversación de 40 nodos con ramas condicionales, probarla y publicarla sin tocar el código ni pedirle nada a un programador. Eso implica tres cosas que vas a implementar: un formato de datos legible y validable, un intérprete con un almacén de variables (blackboard), y un validador que detecte nodos huérfanos, saltos rotos y ramas inalcanzables antes de que un jugador se quede atascado mirando a un NPC.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Grafo de nodos | Es la estructura natural de una conversación con ramas. |
| 2 | Nodo de línea y de elección | Dos tipos bastan para casi todo. |
| 3 | Blackboard de variables | El estado narrativo que no cabe en quests ni en el inventario. |
| 4 | Condiciones | Lo que hace que la conversación reaccione a la partida. |
| 5 | Consecuencias | El diálogo debe poder cambiar el mundo, con control. |
| 6 | Opciones agotables | "Ya se lo pregunté" es un requisito universal. |
| 7 | Localización | El texto no vive en el grafo: viven las claves. |
| 8 | Persistencia | Lo dicho debe sobrevivir al guardado. |
| 9 | Validación | Un salto roto deja al jugador encerrado en una conversación. |
| 10 | Separación intérprete/presentación | La misma conversación en bocadillo, pantalla completa o log. |
conoce_al_herrero, veces_preguntado). Clave: es estado del jugador y va al save.una_vez): opción que desaparece tras usarse. Clave: requiere registrar las opciones ya elegidas.Necesitas el inventario (295) para condiciones de objetos y, opcionalmente, la progresión (302). Trabajaremos en res://dominio/dialogo/ y res://datos/dialogos/*.json. Para la localización, revisa la documentación de internacionalización de Godot y el lab de UI accesible de la Parte 10, que ya usa CSV de traducciones.
{
"id": "herrero_intro",
"inicio": "saludo",
"nodos": {
"saludo": {
"tipo": "linea",
"hablante": "herrero",
"texto": "dlg.herrero.saludo",
"siguiente": "menu"
},
"menu": {
"tipo": "eleccion",
"opciones": [
{ "texto": "dlg.herrero.op_trabajo", "destino": "trabajo",
"condicion": { "flag_no": "herrero_contratado" } },
{ "texto": "dlg.herrero.op_espada", "destino": "espada",
"condicion": { "tiene_item": "mineral_hierro", "cantidad": 3 } },
{ "texto": "dlg.herrero.op_secreto", "destino": "secreto",
"condicion": { "reputacion_min": { "faccion": "gremio", "valor": 50 } },
"una_vez": true },
{ "texto": "dlg.comun.adios", "destino": "fin" }
]
},
"trabajo": {
"tipo": "linea", "hablante": "herrero", "texto": "dlg.herrero.trabajo",
"efectos": [{ "tipo": "flag", "clave": "herrero_contratado", "valor": true }],
"siguiente": "menu"
},
"espada": {
"tipo": "linea", "hablante": "herrero", "texto": "dlg.herrero.espada",
"efectos": [
{ "tipo": "quitar_item", "item": "mineral_hierro", "cantidad": 3 },
{ "tipo": "dar_item", "item": "espada_hierro", "cantidad": 1 }
],
"siguiente": "menu"
},
"secreto": { "tipo": "linea", "hablante": "herrero", "texto": "dlg.herrero.secreto",
"efectos": [{ "tipo": "iniciar_quest", "quest": "forja_perdida" }],
"siguiente": "fin" },
"fin": { "tipo": "fin" }
}
}
class_name Blackboard
extends RefCounted
signal cambio(clave: StringName, valor: Variant)
var _vars := {}
var _opciones_usadas := {} # "dialogo:nodo:indice" -> true
func set_var(clave: StringName, valor: Variant) -> void:
_vars[clave] = valor
cambio.emit(clave, valor)
func get_var(clave: StringName, por_defecto: Variant = null) -> Variant:
return _vars.get(clave, por_defecto)
func flag(clave: StringName) -> bool:
return bool(_vars.get(clave, false))
func incrementar(clave: StringName, n := 1) -> void:
set_var(clave, int(_vars.get(clave, 0)) + n)
func marcar_opcion(clave: String) -> void:
_opciones_usadas[clave] = true
func opcion_usada(clave: String) -> bool:
return _opciones_usadas.has(clave)
func a_dict() -> Dictionary:
return {"vars": _vars, "opciones": _opciones_usadas.keys()}
func de_dict(d: Dictionary) -> void:
_vars = d.get("vars", {}).duplicate()
_opciones_usadas.clear()
for k in d.get("opciones", []):
_opciones_usadas[str(k)] = true
eval().class_name Condiciones
extends RefCounted
var bb: Blackboard
var inv: Inventario
var consultas := {} # StringName -> Callable, para quests/reputación (clases 305-306)
func evaluar(c: Dictionary) -> bool:
if c.is_empty():
return true
# Todas las claves presentes deben cumplirse (AND implícito).
if c.has("flag") and not bb.flag(StringName(str(c["flag"]))):
return false
if c.has("flag_no") and bb.flag(StringName(str(c["flag_no"]))):
return false
if c.has("tiene_item"):
if inv == null or inv.contar(StringName(str(c["tiene_item"]))) < int(c.get("cantidad", 1)):
return false
if c.has("var_min"):
var d: Dictionary = c["var_min"]
if int(bb.get_var(StringName(str(d["clave"])), 0)) < int(d["valor"]):
return false
for clave in ["quest_activa", "quest_completada", "reputacion_min"]:
if c.has(clave):
# Delegamos en quien sepa: así el diálogo no depende de quests ni facciones.
var f: Callable = consultas.get(StringName(clave), Callable())
if not f.is_valid() or not bool(f.call(c[clave])):
return false
if c.has("cualquiera"):
var alguna := false
for sub in c["cualquiera"]:
alguna = alguna or evaluar(sub)
if not alguna:
return false
return true
class_name Efectos_Dialogo
extends RefCounted
signal efecto_desconocido(tipo: String)
var bb: Blackboard
var acciones := {} # "dar_item" -> Callable(Dictionary)
func aplicar(lista: Array) -> void:
for e in lista:
var tipo := str(e.get("tipo", ""))
match tipo:
"flag":
bb.set_var(StringName(str(e["clave"])), e.get("valor", true))
"incrementar":
bb.incrementar(StringName(str(e["clave"])), int(e.get("valor", 1)))
_:
# Todo lo demás (items, quests, reputación) lo resuelve quien sabe.
var f: Callable = acciones.get(tipo, Callable())
if f.is_valid():
f.call(e)
else:
efecto_desconocido.emit(tipo)
Un efecto desconocido avisa en vez de fallar en silencio: es la diferencia entre un guion que se detecta roto en CI y un NPC que no da la recompensa prometida.
class_name Dialogo
extends RefCounted
signal linea(hablante: String, clave_texto: String)
signal opciones(lista: Array) # [{indice, clave_texto, bloqueada}]
signal terminado(id: StringName)
var _grafo := {}
var _id: StringName
var _actual: StringName = &""
var _cond: Condiciones
var _fx: Efectos_Dialogo
var _bb: Blackboard
func _init(cond: Condiciones, fx: Efectos_Dialogo, bb: Blackboard) -> void:
_cond = cond; _fx = fx; _bb = bb
func cargar(ruta: String) -> Array[String]:
var d = JSON.parse_string(FileAccess.open(ruta, FileAccess.READ).get_as_text())
if typeof(d) != TYPE_DICTIONARY:
return ["diálogo ilegible: " + ruta]
_id = StringName(str(d.get("id", "")))
_grafo = d.get("nodos", {})
_actual = StringName(str(d.get("inicio", "")))
return Validador_Dialogo.validar(_id, _grafo, StringName(str(d.get("inicio", ""))))
func empezar() -> void:
_entrar(_actual)
func nodo_actual() -> StringName:
return _actual
func _entrar(id: StringName) -> void:
_actual = id
if not _grafo.has(id):
push_error("nodo de diálogo inexistente: %s" % id)
terminado.emit(_id)
return
var n: Dictionary = _grafo[id]
_bb.incrementar(StringName("visto:%s:%s" % [_id, id]))
_fx.aplicar(n.get("efectos", []))
match str(n.get("tipo", "linea")):
"fin":
terminado.emit(_id)
"eleccion":
opciones.emit(_opciones_visibles(n))
_:
linea.emit(str(n.get("hablante", "")), str(n.get("texto", "")))
func avanzar() -> void:
"""Para nodos de línea: pasa al siguiente cuando el jugador pulsa."""
var n: Dictionary = _grafo.get(_actual, {})
if str(n.get("tipo", "linea")) != "linea":
return
_entrar(StringName(str(n.get("siguiente", ""))))
func elegir(indice: int) -> bool:
var n: Dictionary = _grafo.get(_actual, {})
var visibles := _opciones_visibles(n)
var elegida := visibles.filter(func(o): return o["indice"] == indice)
if elegida.is_empty() or elegida[0]["bloqueada"]:
return false
var op: Dictionary = n["opciones"][indice]
if bool(op.get("una_vez", false)):
_bb.marcar_opcion("%s:%s:%d" % [_id, _actual, indice])
_fx.aplicar(op.get("efectos", []))
_entrar(StringName(str(op.get("destino", ""))))
return true
func _opciones_visibles(n: Dictionary) -> Array:
var salida := []
var lista: Array = n.get("opciones", [])
for i in lista.size():
var op: Dictionary = lista[i]
if bool(op.get("una_vez", false)) and _bb.opcion_usada("%s:%s:%d" % [_id, _actual, i]):
continue
var cumple := _cond.evaluar(op.get("condicion", {}))
# 'oculta_si_no_cumple' distingue "no aparece" de "aparece en gris":
# enseñar la opción bloqueada es una herramienta narrativa potente.
if not cumple and bool(op.get("ocultar", true)):
continue
salida.append({"indice": i, "clave_texto": str(op.get("texto", "")), "bloqueada": not cumple})
return salida
class_name Validador_Dialogo
extends RefCounted
static func validar(id: StringName, grafo: Dictionary, inicio: StringName) -> Array[String]:
var errores: Array[String] = []
if not grafo.has(inicio):
errores.append("%s: el nodo inicial '%s' no existe" % [id, inicio])
return errores
var alcanzables := {}
var pila := [inicio]
while not pila.is_empty():
var actual: StringName = pila.pop_back()
if alcanzables.has(actual):
continue
alcanzables[actual] = true
var n: Dictionary = grafo.get(actual, {})
var destinos: Array[StringName] = []
if n.has("siguiente"):
destinos.append(StringName(str(n["siguiente"])))
for op in n.get("opciones", []):
destinos.append(StringName(str(op.get("destino", ""))))
for d in destinos:
if not grafo.has(d):
errores.append("%s/%s: salto roto a '%s'" % [id, actual, d])
else:
pila.append(d)
# Callejón sin salida: ni es 'fin' ni lleva a ningún sitio.
if str(n.get("tipo", "linea")) != "fin" and destinos.is_empty():
errores.append("%s/%s: callejón sin salida (ni 'fin' ni salidas)" % [id, actual])
for nodo in grafo:
if not alcanzables.has(StringName(str(nodo))):
errores.append("%s: nodo huérfano '%s' (nadie llega a él)" % [id, nodo])
return errores
extends SceneTree
func _init() -> void:
var bb := Blackboard.new()
var base := BaseDeItems.new(); base.cargar_desde_json("res://datos/items.json")
var inv := Inventario.new(base, 10)
var cond := Condiciones.new(); cond.bb = bb; cond.inv = inv
var fx := Efectos_Dialogo.new(); fx.bb = bb
fx.acciones["dar_item"] = func(e): inv.agregar(StringName(str(e["item"])), int(e.get("cantidad", 1)))
fx.acciones["quitar_item"] = func(e): inv.quitar(StringName(str(e["item"])), int(e.get("cantidad", 1)))
var dlg := Dialogo.new(cond, fx, bb)
assert(dlg.cargar("res://datos/dialogos/herrero_intro.json").is_empty(), "el grafo no valida")
var ops := []
dlg.opciones.connect(func(l): ops = l)
dlg.empezar()
dlg.avanzar() # saludo → menu
# Sin mineral, la opción de la espada no aparece.
assert(ops.filter(func(o): return o["indice"] == 1).is_empty(), "opción visible sin cumplir condición")
inv.agregar(&"mineral_hierro", 3)
dlg.elegir(0) # contratar
dlg.avanzar() # vuelve al menú
assert(bb.flag(&"herrero_contratado"), "el efecto de flag no se aplicó")
# Ya contratado: la opción 0 (que exigía flag_no) desaparece...
assert(ops.filter(func(o): return o["indice"] == 0).is_empty(), "la opción de contratar sigue visible")
# ...y la 1 aparece, porque ahora sí hay mineral.
assert(not ops.filter(func(o): return o["indice"] == 1).is_empty(), "la opción de la espada debería aparecer")
dlg.elegir(1)
assert(inv.contar(&"espada_hierro") == 1 and inv.contar(&"mineral_hierro") == 0)
print("== 6 comprobaciones, 0 fallos ==")
quit()
aleatorio que elija salida por peso usando el RNG inyectado de la clase 300.condicional que salte a un destino u otro sin mostrar texto.Implementa el sistema completo y escribe una conversación de al menos 15 nodos con tres ramas condicionales, una opción agotable, una opción visible pero bloqueada, efectos sobre inventario y variables, y localización por claves.
Criterio de aceptación: (a) godot --headless --script res://herramientas/validar_dialogos.gd valida todos los diálogos y devuelve 0; (b) al introducir a propósito un salto roto, un nodo huérfano y un callejón sin salida, el validador los detecta los tres con su id; (c) una prueba con al menos 15 aserciones recorre las tres ramas y comprueba que las condiciones ocultan/bloquean las opciones correctas; (d) la opción una_vez desaparece tras usarse y sigue desaparecida tras a_dict() → de_dict() del blackboard; (e) el sistema no contiene ninguna llamada a eval ni ejecuta código venido del JSON.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| El jugador se queda atrapado en un diálogo | Callejón sin salida o salto roto. El validador los detecta antes de publicar. |
| Una rama escrita nunca se ve | Nodo huérfano o condición imposible. Comprueba alcanzabilidad y revisa la condición. |
| Al recargar la partida el NPC repite lo ya dicho | El blackboard no se guarda. Es estado del jugador: va al save. |
| Un efecto prometido no ocurre | Tipo de efecto no registrado en el despachador. Emite efecto_desconocido y falla en CI. |
| Traducir obliga a duplicar el grafo | Se escribió el texto dentro del JSON. Usa claves y resuélvelas en presentación. |
| Las opciones cambian de sitio al cumplirse una condición | La UI usa el índice de la lista visible. Usa el índice original del nodo. |
| "Invalid call. Nonexistent function 'call'" en efectos | Se guardó una Callable inválida en el diccionario. Comprueba is_valid(). |
| Añadir una condición nueva obliga a tocar diez sitios | La evaluación está repartida. Centraliza en Condiciones.evaluar. |
❓ ¿Por qué no permitir expresiones o código en el JSON? Porque abre la puerta a ejecutar cualquier cosa desde un archivo de contenido —que puede venir de un mod— y porque un intérprete de expresiones es un lenguaje que tendrás que documentar, depurar y versionar. Una lista cerrada de condiciones cubre el 95 % de los casos y es segura por construcción (clase 309).
❓ ¿Uso una herramienta externa (Yarn, Ink, Twine)? Son excelentes y muy usadas en producción. Esta clase te enseña el modelo para que sepas qué hacen por dentro, puedas evaluar cuál encaja y puedas escribir el intérprete si tu juego tiene requisitos que ninguna cubre. En cualquier caso, exportarás a un grafo muy parecido a este.
❓ ¿El diálogo debe conocer las quests? No directamente. Le pasas Callable de consulta y de acción, así el sistema de diálogo funciona igual con o sin sistema de quests, y ambos se prueban por separado. Ese es el patrón de inyección de la clase 293.
❓ ¿Cuándo mostrar una opción bloqueada en gris y cuándo ocultarla? Bloqueada en gris cuando quieres enseñar al jugador que existe un camino (necesita 3 minerales, necesita reputación). Oculta cuando revelarla estropearía la sorpresa o revelaría contenido que aún no debe conocer. Es una decisión narrativa y por eso está en el dato (ocultar).
❓ ¿Y el diálogo generado por IA? El grafo sigue mandando: un NPC con LLM puede rellenar el texto de un nodo, pero las condiciones, los efectos y las transiciones deben seguir pasando por este intérprete y su validación. Es exactamente la arquitectura de la clase 333.
JSON y Callable: https://docs.godotengine.org/en/4.3/classes/class_callable.html · uso: lectura de respaldo del objetivo; no se usa en el procedimientoClase 303 - Economía interna implementada