Parte: 18 — Arquitectura de gameplay y sistemas sistémicos · Fuente: Schell, «The Art of Game Design» (curvas de interés y progresión) · Charlas de GDC sobre diseño de progresión ⏱️ Duración estimada: 115 min · Nivel: Avanzado
Implementar la progresión: experiencia, niveles, puntos de habilidad, desbloqueos, árboles con prerrequisitos, respec y metaprogresión. Es el sistema que decide cuánto tiempo dura tu juego y cuándo el jugador se siente más fuerte, y es también donde más se nota una decisión arquitectónica que aquí vamos a tomar de forma explícita: separar el progreso persistente del estado de partida.
Esa separación es la que permite que un roguelike pierda todo al morir pero conserve las mejoras permanentes, que un RPG guarde tu nivel pero no tu posición en la mazmorra, y que un parche pueda recalcular las bonificaciones sin corromper la partida. Construirás una curva de XP parametrizable, un árbol de habilidades data-driven con validación de prerrequisitos y ciclos, y un respec que devuelve exactamente los puntos gastados.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | XP y curvas | La curva decide la duración del juego más que ninguna otra cifra. |
| 2 | Subida de nivel | Un evento discreto con consecuencias: stats, puntos, desbloqueos. |
| 3 | Puntos de habilidad | La moneda de la progresión: escasa y con decisiones. |
| 4 | Prerrequisitos | Convierten una lista de mejoras en un árbol con forma. |
| 5 | Grafo acíclico | Un ciclo de prerrequisitos hace inalcanzable media rama. |
| 6 | Coste creciente | Evita maximizar una sola rama sin coste de oportunidad. |
| 7 | Respec | Devolver puntos exige saber exactamente qué se gastó. |
| 8 | Desbloqueos | Habilidades, recetas, zonas: todo se gobierna igual. |
| 9 | Metaprogresión | Progreso que sobrevive a la partida: otra caja, otro save. |
| 10 | Persistente vs runtime | Guardar lo derivado es la fuente de las partidas corruptas. |
Necesitas Stats (clase 296) y, para los desbloqueos de habilidades, AbilitySystem (clase 297). Trabajaremos en res://dominio/progresion/ y res://datos/arbol_habilidades.json. La clase 160 de la Parte 8 diseñó las curvas de progresión y dificultad; aquí se implementan. Si aún no la has leído, hazlo antes: los números salen de allí.
class_name CurvaXP
extends RefCounted
enum Forma { LINEAL, CUADRATICA, EXPONENCIAL }
var forma: Forma = Forma.CUADRATICA
var base: float = 100.0
var factor: float = 1.15 # solo para exponencial
var nivel_max: int = 60
func coste_de_nivel(nivel: int) -> int:
"""XP necesaria para pasar DE `nivel` A `nivel + 1`."""
if nivel >= nivel_max:
return 0
match forma:
Forma.LINEAL: return int(base * nivel)
Forma.CUADRATICA: return int(base * nivel * nivel)
Forma.EXPONENCIAL: return int(base * pow(factor, nivel - 1))
_: return int(base)
func total_hasta(nivel: int) -> int:
var t := 0
for n in range(1, nivel):
t += coste_de_nivel(n)
return t
Con base = 100 y curva cuadrática: nivel 2 cuesta 100, nivel 10 cuesta 8.100, y llegar al 60 son ~7 millones de XP. Ese número es la duración de tu juego: compáralo con la XP que da matar un enemigo y sabrás cuántas horas dura antes de escribir una línea más.
class_name Progresion
extends RefCounted
signal xp_ganada(cantidad: int, total: int)
signal subio_nivel(nuevo: int, puntos_ganados: int)
signal desbloqueado(id: StringName)
const PUNTOS_POR_NIVEL := 1
var curva := CurvaXP.new()
var xp_total: int = 0 # ESTADO: lo único que se guarda del progreso
var puntos_disponibles: int = 0
var _desbloqueos := {}
func nivel() -> int:
# DERIVADO de la XP: si el diseño cambia la curva en un parche, todos los
# personajes se recalculan solos en vez de quedarse con el nivel viejo.
var n := 1
var acumulado := 0
while n < curva.nivel_max:
var coste := curva.coste_de_nivel(n)
if xp_total < acumulado + coste:
break
acumulado += coste
n += 1
return n
func xp_en_nivel_actual() -> int:
return xp_total - curva.total_hasta(nivel())
func xp_para_siguiente() -> int:
return curva.coste_de_nivel(nivel())
func ganar_xp(cantidad: int) -> void:
if cantidad <= 0:
return
var antes := nivel()
xp_total += cantidad
xp_ganada.emit(cantidad, xp_total)
var despues := nivel()
# Un jefe puede dar 3 niveles de golpe: hay que emitirlos todos, no solo uno.
for n in range(antes + 1, despues + 1):
puntos_disponibles += PUNTOS_POR_NIVEL
subio_nivel.emit(n, PUNTOS_POR_NIVEL)
func desbloquear(id: StringName) -> void:
if _desbloqueos.has(id):
return
_desbloqueos[id] = true
desbloqueado.emit(id)
func tiene_desbloqueo(id: StringName) -> bool:
return _desbloqueos.has(id)
func a_dict() -> Dictionary:
return {"xp": xp_total, "puntos": puntos_disponibles,
"desbloqueos": _desbloqueos.keys().map(func(k): return String(k))}
func de_dict(d: Dictionary) -> void:
xp_total = int(d.get("xp", 0))
puntos_disponibles = int(d.get("puntos", 0))
_desbloqueos.clear()
for k in d.get("desbloqueos", []):
_desbloqueos[StringName(str(k))] = true
{
"version": 1,
"nodos": [
{ "id": "vigor", "nombre": "Vigor", "rangos": 5, "coste": 1,
"requiere": [], "nivel_min": 1,
"efectos": [{ "stat": "constitucion", "modo": "plano", "valor": 2 }] },
{ "id": "coraza", "nombre": "Coraza", "rangos": 3, "coste": 1,
"requiere": [{ "nodo": "vigor", "rango": 3 }], "nivel_min": 5,
"efectos": [{ "stat": "defensa", "modo": "plano", "valor": 5 }] },
{ "id": "muro", "nombre": "Muro inquebrantable", "rangos": 1, "coste": 3,
"requiere": [{ "nodo": "coraza", "rango": 3 }], "nivel_min": 12,
"desbloquea_habilidad": "provocar",
"efectos": [{ "stat": "defensa", "modo": "porcentual", "valor": 0.15 }] }
]
}
class_name ArbolHabilidades
extends RefCounted
signal invertido(nodo: StringName, rango: int)
signal respec_hecho(puntos_devueltos: int)
var _nodos := {} # id -> Dictionary
var _invertido := {} # id -> rango actual (ESTADO)
var _stats: Stats
var _prog: Progresion
func _init(stats: Stats, prog: Progresion) -> void:
_stats = stats
_prog = prog
func cargar(ruta: String) -> Array[String]:
var datos = JSON.parse_string(FileAccess.open(ruta, FileAccess.READ).get_as_text())
for n in datos.get("nodos", []):
_nodos[StringName(str(n["id"]))] = n
return validar()
func rango(id: StringName) -> int:
return _invertido.get(id, 0)
func puede_invertir(id: StringName) -> bool:
if not _nodos.has(id):
return false
var n: Dictionary = _nodos[id]
if rango(id) >= int(n.get("rangos", 1)):
return false
if _prog.nivel() < int(n.get("nivel_min", 1)):
return false
if _prog.puntos_disponibles < int(n.get("coste", 1)):
return false
for req in n.get("requiere", []):
if rango(StringName(str(req["nodo"]))) < int(req.get("rango", 1)):
return false
return true
func invertir(id: StringName) -> bool:
if not puede_invertir(id):
return false
var n: Dictionary = _nodos[id]
_prog.puntos_disponibles -= int(n.get("coste", 1))
_invertido[id] = rango(id) + 1
_reaplicar(id)
if n.has("desbloquea_habilidad"):
_prog.desbloquear(StringName(str(n["desbloquea_habilidad"])))
invertido.emit(id, _invertido[id])
return true
func _origen(id: StringName) -> StringName:
return StringName("arbol:%s" % id)
func _reaplicar(id: StringName) -> void:
# Retirar y volver a poner: el modificador escala con el rango, así que
# sumar "uno más" cada vez acabaría desincronizado tras un respec.
_stats.retirar_origen(_origen(id))
var n: Dictionary = _nodos[id]
var r := rango(id)
for e in n.get("efectos", []):
var modo := {"plano": Estadistica.Modo.PLANO,
"porcentual": Estadistica.Modo.PORCENTUAL,
"multiplicativo": Estadistica.Modo.MULTIPLICATIVO}.get(
str(e.get("modo", "plano")), Estadistica.Modo.PLANO)
_stats.get_stat(StringName(str(e["stat"]))).agregar_mod(
_origen(id), modo, float(e["valor"]) * r)
func puntos_gastados() -> int:
var t := 0
for id in _invertido:
t += int(_nodos[id].get("coste", 1)) * _invertido[id]
return t
func respec() -> int:
var devueltos := puntos_gastados()
for id in _invertido.keys():
_stats.retirar_origen(_origen(id)) # residuo cero, como en la clase 298
_invertido.clear()
_prog.puntos_disponibles += devueltos
respec_hecho.emit(devueltos)
return devueltos
func validar() -> Array[String]:
var errores: Array[String] = []
for id in _nodos:
var n: Dictionary = _nodos[id]
if int(n.get("rangos", 1)) < 1:
errores.append("nodo '%s': rangos < 1" % id)
for req in n.get("requiere", []):
var rid := StringName(str(req["nodo"]))
if not _nodos.has(rid):
errores.append("nodo '%s': prerrequisito inexistente '%s'" % [id, rid])
elif int(req.get("rango", 1)) > int(_nodos[rid].get("rangos", 1)):
# Este es el error que más duele: la rama entera queda muerta y
# nada falla en runtime.
errores.append("nodo '%s': exige rango %d de '%s', que solo tiene %d"
% [id, int(req.get("rango", 1)), rid, int(_nodos[rid].get("rangos", 1))])
errores.append_array(_detectar_ciclos())
return errores
func _detectar_ciclos() -> Array[String]:
var estado := {} # 0 sin visitar, 1 en pila, 2 cerrado
var errores: Array[String] = []
var visitar := func(id, self_ref):
if estado.get(id, 0) == 1:
errores.append("ciclo de prerrequisitos en '%s'" % id)
return
if estado.get(id, 0) == 2:
return
estado[id] = 1
for req in _nodos[id].get("requiere", []):
var rid := StringName(str(req["nodo"]))
if _nodos.has(rid):
self_ref.call(rid, self_ref)
estado[id] = 2
for id in _nodos:
visitar.call(id, visitar)
return errores
class_name MetaProgresion
extends RefCounted
# Esto NO se pierde al morir. Va a su propio archivo, con su propia versión.
var fragmentos: int = 0
var mejoras := {} # id -> rango permanente
var records := {} # "mejor_ronda" -> valor
func aplicar_a(stats: Stats) -> void:
for id in mejoras:
stats.get_stat(&"vida_bonus").agregar_mod(
StringName("meta:%s" % id), Estadistica.Modo.PLANO, float(mejoras[id]) * 10.0)
func a_dict() -> Dictionary:
return {"fragmentos": fragmentos, "mejoras": mejoras, "records": records}
extends SceneTree
func _init() -> void:
var stats := Stats.new()
var prog := Progresion.new()
var arbol := ArbolHabilidades.new(stats, prog)
assert(arbol.cargar("res://datos/arbol_habilidades.json").is_empty(), "el árbol no valida")
# Subida múltiple: de golpe, del 1 al 3.
var niveles := []
prog.subio_nivel.connect(func(n, _p): niveles.append(n))
prog.ganar_xp(prog.curva.total_hasta(3))
assert(prog.nivel() == 3 and niveles == [2, 3], "las subidas múltiples no se emitieron")
assert(prog.puntos_disponibles == 2)
# Prerrequisito no cumplido.
assert(not arbol.puede_invertir(&"coraza"), "coraza no debería estar disponible")
var base_con := stats.valor(&"constitucion")
assert(arbol.invertir(&"vigor"))
assert(arbol.invertir(&"vigor"))
assert(stats.valor(&"constitucion") == base_con + 4.0, "el rango no escaló el efecto")
var devueltos := arbol.respec()
assert(devueltos == 2 and prog.puntos_disponibles == 2)
assert(stats.valor(&"constitucion") == base_con, "el respec dejó residuo")
print("== 8 comprobaciones, 0 fallos ==")
quit()
nivel_min supera el nivel máximo.Implementa progresión y árbol con al menos 12 nodos en tres ramas, prerrequisitos con rango, nodos multi-rango, un nodo que desbloquea una habilidad, respec completo y metaprogresión separada.
Criterio de aceptación: una prueba headless con al menos 18 aserciones demuestra que: (a) nivel() se deriva de xp_total y cambiar la curva recalcula el nivel sin tocar el save; (b) ganar XP suficiente para tres niveles emite tres eventos subio_nivel y concede tres puntos; (c) invertir en un nodo con prerrequisito no cumplido devuelve false y no gasta puntos; (d) el respec devuelve exactamente puntos_gastados() y deja todas las estadísticas en su valor base; (e) el validador detecta un prerrequisito inexistente, un prerrequisito con rango imposible y un ciclo introducido a propósito; (f) a_dict() → de_dict() conserva XP, puntos y desbloqueos.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| Un jefe da 3 niveles y solo se concede 1 punto | Se comparó nivel antes/después sin iterar. Recorre el rango de niveles ganados. |
| Al cambiar la curva en un parche los personajes conservan el nivel viejo | Se guardó el nivel. Guarda solo la XP y deriva el nivel. |
| Tras un respec el personaje conserva bonificaciones | Se retiraron los nodos pero no sus modificadores. Retira por origen en el respec. |
| Media rama del árbol es inalcanzable | Un prerrequisito pide un rango mayor que los rangos del nodo. Lo detecta el validador. |
| El árbol se cuelga al calcular disponibilidad | Ciclo de prerrequisitos. Detecta ciclos al cargar. |
| El roguelike pierde las mejoras permanentes al morir | Metaprogresión guardada en el save de partida. Sepáralas en dos archivos. |
| Los puntos se duplican al recargar | Se guardaron los puntos y se recalcularon al cargar. Elige una fuente y respétala. |
| Subir de nivel no actualiza el HUD | Falta conectar subio_nivel. La presentación se suscribe, no consulta cada frame. |
❓ ¿Qué curva elijo? Empieza por la cuadrática con base ajustada a tu duración objetivo: calcula total_hasta(nivel_max) y divide por la XP media que da una hora de juego. Si sale muy lejos de lo que quieres, ajusta base antes de tocar la forma.
❓ ¿Guardo el nivel o la XP? La XP. El nivel es derivado, y guardarlo crea dos fuentes de verdad que se desincronizan en cuanto un parche toca la curva. La misma regla vale para las stats derivadas de la clase 296.
❓ ¿Respec gratis o de pago? Gratis favorece la experimentación y reduce la ansiedad de elegir; con coste hace que las decisiones pesen. Es diseño puro. Técnicamente da igual: lo que no es negociable es que devuelva exactamente lo gastado y no deje residuos.
❓ ¿Metaprogresión en el mismo archivo que la partida? No. Van en archivos distintos porque tienen ciclos de vida distintos: la partida se borra al morir y la metaprogresión no. Además cada uno lleva su propio SAVE_VERSION (clase 307).
❓ ¿Cómo enseño el árbol al jugador? Con el mismo dato: el JSON tiene la topología, así que la UI puede dibujarlo automáticamente. Si tienes que colocar 40 nodos a mano en una escena, cada cambio de diseño te costará una tarde.
JSON y datos de juego: https://docs.godotengine.org/en/4.3/classes/class_json.html · uso: lectura de respaldo del objetivo; no se usa en el procedimientoClase 301 - Crafting y recetas