Parte: 18 — Arquitectura de gameplay y sistemas sistémicos · Fuente: Charlas de GDC sobre economías de juego y virtual economy design · Schell, «The Art of Game Design» ⏱️ Duración estimada: 120 min · Nivel: Avanzado
Implementar la economía interna: monedas, fuentes, sumideros, tiendas, precios, compra y venta, historial de transacciones y las herramientas para saber si el conjunto está sano o se está inflando. La clase 158 enseñó a diseñar la economía del jugador y la 161 a balancear con hojas de cálculo; aquí se construye el sistema que ejecuta ese diseño y, sobre todo, el que permite medirlo.
La idea central es simple y casi nunca se implementa: toda variación de moneda pasa por un único punto y queda registrada con su motivo. Con eso obtienes gratis el balance de fuentes y sumideros, la detección de inflación, la auditoría anti-trampas y los datos de telemetría que necesitarás en la Parte 19. Sin eso, tienes oro += 50 en 40 sitios y ninguna forma de saber por qué los jugadores tienen tres millones.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Tipos de moneda | Cada una tiene reglas distintas: mezclarlas rompe el diseño y la ley. |
| 2 | Punto único de mutación | Sin él no hay telemetría, ni auditoría, ni balance. |
| 3 | Fuentes (faucets) | Todo lo que crea dinero: loot, quests, ventas. |
| 4 | Sumideros (sinks) | Todo lo que lo destruye: reparaciones, viajes, mejoras. |
| 5 | Balance neto | La cifra que decide si tu economía se infla o se ahoga. |
| 6 | Precios y márgenes | El diferencial compra/venta es el sumidero más discreto. |
| 7 | Tienda con stock | Convierte comprar en una decisión, no en un grifo infinito. |
| 8 | Precios dinámicos | Oferta y demanda o simple escasez temporal. |
| 9 | Historial de transacciones | El registro que permite responder "¿de dónde salió esto?". |
| 10 | Simulación económica | Probar 100 horas de juego en 2 segundos antes de publicar. |
loot, venta, reparacion). Clave: sin taxonomía cerrada, la telemetría es inútil.Necesitas el catálogo (294), el inventario con transacciones (295) y el árbol de coste de crafteo (301) para derivar precios coherentes. Trabajaremos en res://dominio/economia/. Ten a mano el análisis de loot de la clase 300: la moneda que sueltan los enemigos es tu principal fuente, y su valor esperado por hora es el primer número del balance.
class_name Monedero
extends RefCounted
signal saldo_cambiado(moneda: StringName, nuevo: int, delta: int, motivo: StringName)
const MONEDAS := [&"oro", &"gemas", &"fragmentos"]
var _saldos := {}
var _historial: Array[Dictionary] = []
var _tiempo_juego: float = 0.0
func _init() -> void:
for m in MONEDAS:
_saldos[m] = 0
func saldo(m: StringName) -> int:
return _saldos.get(m, 0)
func avanzar_tiempo(delta: float) -> void:
_tiempo_juego += delta
# --- ÚNICO punto de mutación de todo el juego -----------------------------
func _mover(m: StringName, delta: int, motivo: StringName) -> bool:
if delta == 0 or not _saldos.has(m):
return false
var nuevo := _saldos[m] + delta
if nuevo < 0:
return false # no se permite saldo negativo, nunca
_saldos[m] = nuevo
_historial.append({
"t": _tiempo_juego, "moneda": String(m),
"delta": delta, "saldo": nuevo, "motivo": String(motivo),
})
saldo_cambiado.emit(m, nuevo, delta, motivo)
return true
func ingresar(m: StringName, cantidad: int, motivo: StringName) -> bool:
assert(cantidad > 0, "ingresar espera cantidades positivas")
return _mover(m, cantidad, motivo)
func gastar(m: StringName, cantidad: int, motivo: StringName) -> bool:
assert(cantidad > 0, "gastar espera cantidades positivas")
return _mover(m, -cantidad, motivo)
func puede_pagar(m: StringName, cantidad: int) -> bool:
return saldo(m) >= cantidad
El motivo obligatorio en la firma no es burocracia: es lo que hace que exista el informe del paso 5. Si fuera opcional, en tres semanas la mitad de las transacciones dirían "desconocido".
func informe() -> Dictionary:
var fuentes := {}
var sumideros := {}
for t in _historial:
var clave := "%s:%s" % [t["moneda"], t["motivo"]]
if t["delta"] > 0:
fuentes[clave] = fuentes.get(clave, 0) + t["delta"]
else:
sumideros[clave] = sumideros.get(clave, 0) - t["delta"]
var total_in := 0; var total_out := 0
for v in fuentes.values(): total_in += v
for v in sumideros.values(): total_out += v
var horas := maxf(_tiempo_juego / 3600.0, 0.0001)
return {
"fuentes": fuentes, "sumideros": sumideros,
"neto": total_in - total_out,
"neto_por_hora": (total_in - total_out) / horas,
"transacciones": _historial.size(),
}
func auditar() -> bool:
"""El saldo debe ser exactamente la suma de los deltas. Si no, hay un bug."""
var suma := {}
for m in MONEDAS: suma[m] = 0
for t in _historial:
suma[StringName(t["moneda"])] += int(t["delta"])
for m in MONEDAS:
if suma[m] != _saldos[m]:
push_error("auditoría fallida en %s: historial %d vs saldo %d" % [m, suma[m], _saldos[m]])
return false
return true
class_name Precios
extends RefCounted
const MARGEN_VENTA := 0.4 # el jugador recibe el 40 % al vender: spread del 60 %
var _base: BaseDeItems
var _crafteo: Crafteo
func _init(base: BaseDeItems, crafteo: Crafteo) -> void:
_base = base
_crafteo = crafteo
func precio_compra(id: StringName, modificador := 1.0) -> int:
var d := _base.obtener(id)
var por_rareza: float = Item.PESO_RAREZA.get(d.rareza, 1.0)
return maxi(1, int(round(d.valor * por_rareza * modificador)))
func precio_venta(id: StringName) -> int:
# Vender SIEMPRE por debajo de comprar: si no, comprar y vender es una
# máquina de dinero y el jugador la encontrará en veinte minutos.
return maxi(1, int(floor(precio_compra(id) * MARGEN_VENTA)))
func coherente(id: StringName) -> bool:
"""Craftear no puede salir más caro que comprar el resultado y venderlo."""
var coste := 0
for material in _crafteo.coste_base(id):
coste += precio_compra(material) * _crafteo.coste_base(id)[material]
return precio_venta(id) < coste or coste == 0
class_name Tienda
extends RefCounted
signal comprado(id: StringName, cantidad: int, coste: int)
signal vendido(id: StringName, cantidad: int, pago: int)
enum Fallo { OK, SIN_STOCK, SIN_SALDO, SIN_ESPACIO, NO_COMPRA_ESE_ITEM, SIN_ITEM }
var id_tienda: StringName
var moneda: StringName = &"oro"
var _precios: Precios
var _stock := {} # id -> unidades disponibles
var _stock_max := {}
var _tiempo_restock := 0.0
var periodo_restock := 300.0
var modificador_precio := 1.0 # reputación, dificultad, eventos
var compra_tags: PackedStringArray = ["arma", "consumible", "material"]
func _init(precios: Precios) -> void:
_precios = precios
func abastecer(id: StringName, unidades: int) -> void:
_stock[id] = unidades
_stock_max[id] = unidades
func tick(delta: float) -> void:
_tiempo_restock += delta
while _tiempo_restock >= periodo_restock:
_tiempo_restock -= periodo_restock
for id in _stock_max:
_stock[id] = mini(_stock_max[id], _stock.get(id, 0) + 1)
func precio(id: StringName) -> int:
# Escasez: cuanto menos queda, más caro. Explicable y con tope.
var ratio := 1.0
if _stock_max.get(id, 0) > 0:
var q := float(_stock.get(id, 0)) / float(_stock_max[id])
ratio = lerpf(1.5, 1.0, clampf(q, 0.0, 1.0))
return _precios.precio_compra(id, modificador_precio * ratio)
func comprar(id: StringName, n: int, inv: Inventario, w: Monedero) -> Fallo:
if _stock.get(id, 0) < n:
return Fallo.SIN_STOCK
var coste := precio(id) * n
if not w.puede_pagar(moneda, coste):
return Fallo.SIN_SALDO
if inv.cabe(id, n) < n:
return Fallo.SIN_ESPACIO
# Orden importante: primero cobramos, luego entregamos; si la entrega falla,
# devolvemos. Al revés, un fallo al cobrar regala el item.
if not w.gastar(moneda, coste, StringName("compra:%s" % id_tienda)):
return Fallo.SIN_SALDO
if not inv.agregar_todo_o_nada(id, n):
w.ingresar(moneda, coste, StringName("rollback_compra:%s" % id_tienda))
return Fallo.SIN_ESPACIO
_stock[id] -= n
comprado.emit(id, n, coste)
return Fallo.OK
func vender(id: StringName, n: int, inv: Inventario, w: Monedero, base: BaseDeItems) -> Fallo:
if inv.contar(id) < n:
return Fallo.SIN_ITEM
var d := base.obtener(id)
var acepta := false
for t in compra_tags:
if d.tiene_tag(t):
acepta = true
if not acepta:
return Fallo.NO_COMPRA_ESE_ITEM
var pago := _precios.precio_venta(id) * n
if not inv.quitar(id, n):
return Fallo.SIN_ITEM
w.ingresar(moneda, pago, StringName("venta:%s" % id_tienda))
_stock[id] = _stock.get(id, 0) + n # lo vendido vuelve al stock
vendido.emit(id, n, pago)
return Fallo.OK
extends SceneTree # godot --headless --script res://herramientas/simular_economia.gd
const HORAS := 100
const ENEMIGOS_POR_HORA := 120
const MUERTES_POR_HORA := 1.5
func _init() -> void:
var rng := RandomNumberGenerator.new(); rng.seed = 7
var base := BaseDeItems.new(); base.cargar_desde_json("res://datos/items.json")
var tablas := TablasDeLoot.new(base, rng); tablas.cargar("res://datos/loot.json")
var w := Monedero.new()
for h in HORAS:
w.avanzar_tiempo(3600.0)
# FUENTES
for e in ENEMIGOS_POR_HORA:
for s in tablas.tirar(&"humanoide_basico", {"nivel": 20}):
if s.id == &"moneda_oro":
w.ingresar(&"oro", s.cantidad, &"loot")
w.ingresar(&"oro", 500, &"quests")
# SUMIDEROS
w.gastar(&"oro", mini(w.saldo(&"oro"), 300), &"consumibles")
w.gastar(&"oro", mini(w.saldo(&"oro"), int(200 * MUERTES_POR_HORA)), &"reparacion")
var inf := w.informe()
print("== Simulación de %d horas ==" % HORAS)
for clave in inf["fuentes"]: printt(" fuente ", clave, inf["fuentes"][clave])
for clave in inf["sumideros"]: printt(" sumidero", clave, inf["sumideros"][clave])
print("Saldo final: %d oro · neto/hora: %.1f" % [w.saldo(&"oro"), inf["neto_por_hora"]])
assert(w.auditar(), "la auditoría no cuadra")
quit()
Si neto_por_hora crece sin freno, tu economía se infla: el jugador acabará con más oro del que existe nada que comprar, y el dinero dejará de motivar. La corrección casi nunca es "que caiga menos oro" (se siente mal), sino añadir sumideros deseables: mejoras caras, cosmética, reparaciones, viajes rápidos.
var w := Monedero.new()
assert(not w.gastar(&"oro", 10, &"test"), "se permitió saldo negativo")
w.ingresar(&"oro", 100, &"loot")
assert(w.saldo(&"oro") == 100 and w.auditar())
var t := Tienda.new(precios); t.abastecer(&"pocion_menor", 5)
var antes := w.saldo(&"oro")
assert(t.comprar(&"pocion_menor", 99, inv, w) == Tienda.Fallo.SIN_STOCK)
assert(w.saldo(&"oro") == antes, "una compra fallida movió dinero")
assert(precios.precio_venta(&"pocion_menor") < precios.precio_compra(&"pocion_menor"),
"comprar y vender es una máquina de dinero")
gemas que no se pueda vender ni comprar con oro, y prueba que no hay conversión posible.Implementa el monedero con punto único de mutación e historial, precios derivados del coste de crafteo, una tienda con stock, reposición y precio por escasez, y un simulador de 100 horas.
Criterio de aceptación: (a) godot --headless --script res://herramientas/simular_economia.gd imprime fuentes, sumideros y neto por hora, y termina con auditar() == true; (b) no existe ninguna llamada que modifique un saldo fuera de _mover, comprobable porque _saldos solo se escribe en esa función; (c) para todos los items del catálogo, precio_venta < precio_compra; (d) una compra fallida por cualquiera de los cinco motivos deja saldo, inventario y stock exactamente igual que antes; (e) una prueba con al menos 15 aserciones cubre saldo negativo, stock insuficiente, sin espacio, item no aceptado por la tienda y rollback de la entrega.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| Los jugadores tienen millones de oro a las 30 horas | Fuentes sin sumideros equivalentes. Mide el neto por hora en el simulador y añade sumideros deseables. |
| No hay forma de saber de dónde salió el dinero | Se muta el saldo en 40 sitios. Punto único de mutación con motivo obligatorio. |
| Comprar y vender lo mismo da beneficio | El margen de venta es demasiado alto. precio_venta siempre por debajo de precio_compra. |
| Craftear y vender es más rentable que jugar | El precio del resultado supera el de sus materiales. Compara con coste_base. |
| Una compra fallida se queda con el dinero | No hay rollback tras cobrar. Devuelve al fallar la entrega, o usa la transacción. |
| El saldo aparece negativo tras un bug | No se validó nuevo < 0. La validación va en _mover, no en cada llamador. |
| La tienda es un grifo infinito de pociones | Sin stock ni reposición. Añade ambos: convierten comprar en decisión. |
| La economía se rompe con un item nuevo | Se le puso valor a ojo. Derívalo del coste de crafteo y valida en CI. |
❓ ¿Por qué obligar a un motivo en cada transacción? Porque es lo que convierte tu economía en algo medible. Con motivos tienes el informe del paso 2 gratis, la telemetría de la Parte 19 casi hecha y la capacidad de responder a un jugador que dice que le falta oro. Sin motivos, tienes un número.
❓ ¿Los sumideros no frustran al jugador? Los malos, sí (impuestos arbitrarios). Los buenos son cosas que el jugador quiere comprar: mejoras, cosmética, comodidades. La regla práctica es que el sumidero debe dar algo a cambio, aunque sea estatus.
❓ ¿Cómo pongo el precio de un item nuevo? Calcula su coste de crafteo en materiales base (clase 301), valora esos materiales con las tablas de loot (clase 300) y añade un margen. Después pásalo por coherente() para que no genere beneficio.
❓ ¿Y si el juego tiene compras reales? Entonces la moneda dura deja de ser un tema de gameplay y pasa a ser un tema legal, fiscal y de seguridad: la autoridad debe estar en el servidor, con entitlements y recibos verificados. Eso se trata en las clases 312 y 314, y el marco legal en la clase 273.
❓ ¿Cuánto historial guardo? En local, una ventana (las últimas 500 transacciones) basta para depurar y no engorda el save. La contabilidad completa, si hace falta, vive en el servidor: allí es un registro de auditoría con retención definida (clase 317).
StringName: https://docs.godotengine.org/en/4.3/classes/class_stringname.html · uso: lectura de respaldo del objetivo; no se usa en el procedimientoFileAccess y exportación de datos (CSV del historial): https://docs.godotengine.org/en/4.3/classes/class_fileaccess.html · uso: respalda el Tema 9 «Historial de transacciones»Clase 302 - Progresión y skill trees