Parte: 18 — Arquitectura de gameplay y sistemas sistémicos · Fuente: Charlas de GDC sobre sistemas de facciones y simulación social · Millington & Funge, «Artificial Intelligence for Games» ⏱️ Duración estimada: 110 min · Nivel: Avanzado
Implementar el sistema de facciones y reputación: quién es amigo de quién, cómo cambia esa relación con lo que hace el jugador y qué consecuencias tiene en el diálogo, el comercio, las quests y la IA. Es el sistema que convierte un mundo de NPC estáticos en uno que reacciona, y el que hace que una decisión tomada en la hora 3 se note en la hora 20.
Construirás una matriz de relaciones entre facciones, umbrales con nombre (hostil, neutral, aliado), propagación de reputación entre facciones aliadas y enemigas, decaimiento hacia la neutralidad y un conjunto de consultas que los demás sistemas usan sin conocer los detalles. Al terminar, matar a un bandido subirá tu reputación con la guardia, la bajará con el gremio de ladrones y hará que el mercader del gremio te suba los precios — todo sin un solo if a medida.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Facción | La unidad social del mundo: agrupa NPC y define bandos. |
| 2 | Matriz de relaciones | Define quién ataca a quién sin escribirlo caso por caso. |
| 3 | Reputación del jugador | El número que resume tu historia con cada facción. |
| 4 | Umbrales nombrados | Convierten un número en algo que el diseño puede usar. |
| 5 | Propagación | Ayudar a A perjudica a su enemigo B: emergencia con dos líneas. |
| 6 | Decaimiento | Decide si el mundo perdona con el tiempo. |
| 7 | Relación individual | Un NPC puede apreciarte aunque su facción te odie. |
| 8 | Hostilidad y agresión | La IA consulta esto para decidir a quién atacar. |
| 9 | Consecuencias en sistemas | Precios, diálogo y quests leen la misma consulta. |
| 10 | Persistencia y validación | El estado social es save; la matriz es contenido. |
[-100, 100]. Clave: el número es interno; el jugador ve el umbral.odiado, hostil, neutral, amistoso, aliado). Clave: las reglas de diseño se escriben sobre el nombre, no sobre el número.[-1, 1] que dice si dos facciones son enemigas o aliadas. Clave: es el multiplicador de la propagación.es_hostil, modificador_precio). Clave: es la interfaz que evita el acoplamiento.Necesitas el diálogo (304) y las quests (305) para las integraciones, y la economía (303) para el modificador de precios. Trabajaremos en res://dominio/social/ y res://datos/facciones.json. Este sistema se conecta al mismo bus de eventos de la clase anterior: es la segunda vez que lo reutilizas, y no será la última.
{
"version": 1,
"umbrales": [
{ "nombre": "odiado", "desde": -100 },
{ "nombre": "hostil", "desde": -50 },
{ "nombre": "neutral", "desde": -10 },
{ "nombre": "amistoso", "desde": 25 },
{ "nombre": "aliado", "desde": 70 }
],
"facciones": [
{ "id": "guardia", "nombre": "fac.guardia", "decaimiento": 0.5 },
{ "id": "ladrones", "nombre": "fac.ladrones", "decaimiento": 0.0 },
{ "id": "gremio", "nombre": "fac.gremio", "decaimiento": 0.2 },
{ "id": "bandidos", "nombre": "fac.bandidos", "decaimiento": 0.0 }
],
"relaciones": [
{ "a": "guardia", "b": "bandidos", "afinidad": -1.0 },
{ "a": "guardia", "b": "ladrones", "afinidad": -0.6 },
{ "a": "guardia", "b": "gremio", "afinidad": 0.7 },
{ "a": "ladrones", "b": "bandidos", "afinidad": 0.3 },
{ "a": "gremio", "b": "ladrones", "afinidad": -0.4 }
],
"eventos": [
{ "id": "matar_bandido", "cambios": [{ "faccion": "bandidos", "valor": -8 }] },
{ "id": "robar_a_gremio", "cambios": [{ "faccion": "gremio", "valor": -15 }] },
{ "id": "quest_guardia_completada", "cambios": [{ "faccion": "guardia", "valor": 20 }] }
]
}
Fíjate en que un evento solo declara el cambio directo. Lo demás lo hará la propagación: matar a un bandido bajará la reputación con bandidos y, por la afinidad -1.0, subirá con la guardia sin que nadie lo escriba.
class_name Social
extends RefCounted
signal reputacion_cambiada(faccion: StringName, nueva: float, umbral: String)
signal umbral_cruzado(faccion: StringName, anterior: String, nuevo: String)
const MIN := -100.0
const MAX := 100.0
const FACTOR_PROPAGACION := 0.5 # la propagación vale la mitad que el cambio directo
var _facciones := {} # id -> Dictionary
var _afinidad := {} # "a|b" -> float
var _eventos := {} # id -> Array[cambios]
var _umbrales: Array = [] # [{nombre, desde}] ordenado
var _reputacion := {} # id -> float (ESTADO: va al save)
var _individual := {} # id_npc -> float (ESTADO)
var _bloqueadas := {} # id -> true
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 ["facciones.json ilegible"]
_umbrales = d.get("umbrales", [])
_umbrales.sort_custom(func(a, b): return float(a["desde"]) < float(b["desde"]))
for f in d.get("facciones", []):
var id := StringName(str(f["id"]))
_facciones[id] = f
_reputacion[id] = 0.0
for r in d.get("relaciones", []):
_fijar_afinidad(StringName(str(r["a"])), StringName(str(r["b"])), float(r["afinidad"]))
for e in d.get("eventos", []):
_eventos[StringName(str(e["id"]))] = e.get("cambios", [])
return validar()
func _clave(a: StringName, b: StringName) -> String:
# Clave ordenada: la relación es simétrica, así que "a|b" y "b|a" son la misma.
return "%s|%s" % ([String(a), String(b)] if String(a) < String(b) else [String(b), String(a)])
func _fijar_afinidad(a: StringName, b: StringName, v: float) -> void:
_afinidad[_clave(a, b)] = clampf(v, -1.0, 1.0)
func afinidad(a: StringName, b: StringName) -> float:
if a == b:
return 1.0
return _afinidad.get(_clave(a, b), 0.0)
func reputacion(f: StringName) -> float:
return _reputacion.get(f, 0.0)
func umbral(f: StringName) -> String:
var actual := "neutral"
for u in _umbrales:
if reputacion(f) >= float(u["desde"]):
actual = str(u["nombre"])
return actual
func es_hostil(f: StringName) -> bool:
return umbral(f) in ["hostil", "odiado"]
func es_aliado(f: StringName) -> bool:
return umbral(f) == "aliado"
func modificador_precio(f: StringName) -> float:
# De 1.25 (te odian) a 0.85 (aliado). Explicable y acotado: un jugador debe
# poder entender por qué el pan le cuesta más aquí.
var r := reputacion(f)
return clampf(1.05 - (r / 100.0) * 0.2, 0.85, 1.25)
func actitud_hacia(id_npc: StringName, f: StringName) -> float:
# La relación personal se SUMA a la de facción: un NPC puede apreciarte
# aunque su facción te odie, que es donde vive la narrativa interesante.
return clampf(reputacion(f) + _individual.get(id_npc, 0.0), MIN, MAX)
func aplicar_evento(id_evento: StringName) -> void:
if not _eventos.has(id_evento):
push_warning("evento social desconocido: %s" % id_evento)
return
for c in _eventos[id_evento]:
modificar(StringName(str(c["faccion"])), float(c["valor"]), true)
func modificar(f: StringName, delta: float, propagar := true) -> void:
if not _facciones.has(f) or is_zero_approx(delta):
return
_aplicar(f, delta)
if not propagar:
return
for otra in _facciones:
if otra == f:
continue
var a := afinidad(f, otra)
if is_zero_approx(a):
continue
# Enemigo de mi enemigo: afinidad -1 invierte el signo del cambio.
_aplicar(otra, delta * a * FACTOR_PROPAGACION)
func _aplicar(f: StringName, delta: float) -> void:
if _bloqueadas.has(f) and delta > 0.0:
return # decisión irreversible: ya no se puede subir
var antes := umbral(f)
_reputacion[f] = clampf(reputacion(f) + delta, MIN, MAX)
var ahora := umbral(f)
reputacion_cambiada.emit(f, _reputacion[f], ahora)
if antes != ahora:
umbral_cruzado.emit(f, antes, ahora)
func bloquear(f: StringName) -> void:
_bloqueadas[f] = true
func tick(delta_horas: float) -> void:
for f in _facciones:
var tasa := float(_facciones[f].get("decaimiento", 0.0))
if is_zero_approx(tasa) or _bloqueadas.has(f):
continue
var r := reputacion(f)
if is_zero_approx(r):
continue
# Tiende a 0 sin pasarse: move_toward evita oscilar alrededor del cero.
_aplicar(f, move_toward(r, 0.0, tasa * delta_horas) - r)
# En el arranque, no dentro de cada sistema.
func conectar(bus: EventosJuego, cond: Condiciones, tienda: Tienda) -> void:
bus.enemigo_muerto.connect(func(id, _p):
if id == &"bandido":
aplicar_evento(&"matar_bandido"))
# El diálogo pregunta; no sabe qué es una facción.
cond.consultas[&"reputacion_min"] = func(d: Dictionary) -> bool:
return reputacion(StringName(str(d["faccion"]))) >= float(d["valor"])
# La tienda ajusta precios; tampoco sabe qué es la reputación.
reputacion_cambiada.connect(func(f, _v, _u):
if f == tienda.id_tienda:
tienda.modificador_precio = modificador_precio(f))
func validar() -> Array[String]:
var errores: Array[String] = []
if _umbrales.is_empty():
errores.append("no hay umbrales definidos")
for clave in _afinidad:
for parte in clave.split("|"):
if not _facciones.has(StringName(parte)):
errores.append("relación con facción inexistente: '%s'" % parte)
for id in _eventos:
for c in _eventos[id]:
if not _facciones.has(StringName(str(c.get("faccion", "")))):
errores.append("evento '%s': facción inexistente '%s'" % [id, c.get("faccion", "")])
# Una facción aislada nunca propagará nada: suele ser un olvido, no un diseño.
for f in _facciones:
var tiene := false
for otra in _facciones:
if otra != f and not is_zero_approx(afinidad(f, otra)):
tiene = true
if not tiene:
errores.append("aviso: la facción '%s' no tiene ninguna relación definida" % f)
return errores
func a_dict() -> Dictionary:
return {"reputacion": _reputacion, "individual": _individual,
"bloqueadas": _bloqueadas.keys().map(func(k): return String(k))}
func de_dict(d: Dictionary) -> void:
for clave in d.get("reputacion", {}):
var id := StringName(str(clave))
if _facciones.has(id):
_reputacion[id] = float(d["reputacion"][clave])
_individual = d.get("individual", {}).duplicate()
_bloqueadas.clear()
for k in d.get("bloqueadas", []):
_bloqueadas[StringName(str(k))] = true
extends SceneTree
func _init() -> void:
var s := Social.new()
var avisos := s.cargar("res://datos/facciones.json")
assert(avisos.filter(func(e): return not e.begins_with("aviso")).is_empty(), "la matriz no valida")
# Matar bandidos baja con bandidos y sube con la guardia (afinidad -1).
s.aplicar_evento(&"matar_bandido")
assert(s.reputacion(&"bandidos") == -8.0)
assert(s.reputacion(&"guardia") == 4.0, "la propagación no llegó a la guardia")
# Y baja algo con ladrones, aliados parciales de los bandidos (0.3).
assert(is_equal_approx(s.reputacion(&"ladrones"), -1.2), "propagación indirecta incorrecta")
# Umbral y precio.
for i in 10: s.modificar(&"gremio", 10.0, false)
assert(s.umbral(&"gremio") == "aliado")
assert(s.modificador_precio(&"gremio") < 1.0, "un aliado debería vender más barato")
# Decaimiento: la guardia perdona, los bandidos no.
var rep_bandidos := s.reputacion(&"bandidos")
s.tick(10.0)
assert(s.reputacion(&"bandidos") == rep_bandidos, "los bandidos no deberían olvidar")
print("== 6 comprobaciones, 0 fallos ==")
quit()
es_hostil para elegir objetivo y prueba dos facciones peleando entre sí.Implementa el sistema con al menos cuatro facciones, matriz de relaciones, cinco umbrales, propagación, decaimiento por facción, relaciones individuales, bloqueo irreversible e integración con diálogo, comercio y quests.
Criterio de aceptación: una prueba headless con al menos 16 aserciones demuestra que: (a) un evento que baja 10 puntos con una facción sube exactamente 10 × |afinidad| × FACTOR_PROPAGACION con su enemiga; (b) cruzar un umbral emite umbral_cruzado con el nombre anterior y el nuevo, una sola vez por cruce; (c) la reputación queda siempre acotada en [-100, 100] tras 1.000 modificaciones aleatorias; (d) una facción con decaimiento 0 no cambia con tick(), y una con decaimiento positivo se acerca a 0 sin cruzarlo; (e) una facción bloqueada no sube pero sí baja; (f) el validador detecta una relación con facción inexistente y un evento con facción inexistente.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| Ayudar a una facción no afecta a nadie más | Falta la propagación, o la matriz está vacía. Comprueba las afinidades. |
| La reputación oscila alrededor de cero con el decaimiento | Se resta un valor fijo. Usa move_toward para no pasarse. |
umbral_cruzado se emite en cada golpe |
Se compara el valor, no el umbral. Compara el nombre antes y después. |
| Toda la ciudad me ataca por un error | La propagación es demasiado fuerte o la matriz demasiado polarizada. Ajusta FACTOR_PROPAGACION. |
| Un NPC amigo me ataca porque su facción me odia | Solo se consulta la facción. Usa actitud_hacia con la relación individual. |
| Los precios cambian y el jugador no entiende por qué | Falta comunicar el umbral. Muestra el estado de reputación en la tienda. |
| La reputación se dispara a 500 | Falta el clampf. Acota siempre en _aplicar. |
| Añadir una facción rompe el balance social | La matriz es incompleta. Valida que toda facción tenga relaciones. |
❓ ¿Un número por facción o una matriz completa jugador–NPC? Empieza por el número por facción más una tabla dispersa de excepciones individuales: es el 95 % del valor con el 10 % de la complejidad. Una matriz completa solo compensa en juegos de simulación social donde las relaciones NPC-NPC son el núcleo.
❓ ¿La afinidad debe ser simétrica? Por defecto sí, y por eso la clave está ordenada. Si tu diseño necesita asimetría (A desprecia a B pero B admira a A), guarda las dos direcciones — pero hazlo a conciencia, porque duplica la superficie a validar.
❓ ¿Decaimiento sí o no? Con decaimiento el mundo perdona y el jugador puede recuperarse de un error; sin él, las decisiones son permanentes y pesan más. Muchos juegos lo aplican solo a la reputación positiva (lo bueno se olvida, lo malo no), lo cual se consigue con una condición en tick.
❓ ¿Cómo evito que el jugador quede sin acceso a contenido? Con dos herramientas: umbrales de no-retorno explícitos y documentados (para que la irreversibilidad sea una decisión y no un accidente), y misiones de redención. Lo que no debe pasar nunca es que un jugador quede bloqueado sin haberlo elegido.
❓ ¿Esto sirve para IA de combate? Directamente: la IA pregunta es_hostil(faccion_del_objetivo) en vez de tener listas de enemigos. Con eso, dos facciones hostiles entre sí pelean solas y el jugador puede provocar el conflicto — emergencia gratis, en la línea de la clase 169.
@GlobalScope.move_toward y funciones matemáticas: https://docs.godotengine.org/en/4.3/classes/class_@globalscope.html · uso: lectura de respaldo del objetivo; no se usa en el procedimiento