Parte: 18 — Arquitectura de gameplay y sistemas sistémicos · Fuente: Nystrom, «Game Programming Patterns» · Gregory, «Game Engine Architecture» (gameplay foundation systems) ⏱️ Duración estimada: 120 min · Nivel: Avanzado
Aprender a repartir un juego en capas para que siga siendo modificable cuando crezca. Hasta ahora tus proyectos han cabido en la cabeza: un script de jugador, uno de enemigo, un HUD. En cuanto aparecen inventario, habilidades, quests y guardado, ese modelo se rompe — no por falta de talento, sino por acoplamiento: todo conoce a todo, y cambiar una cosa obliga a tocar cinco.
En esta clase separarás un mini-juego monolítico en cuatro capas —dominio, gameplay, presentación e infraestructura— con una regla de dependencias en un solo sentido, comunicación por señales y servicios inyectados en vez de singletons globales. Al terminar, el sistema de salud del juego será una clase sin nodos que puedes probar sin abrir una escena, y la barra de vida se enterará de los cambios sin que nadie la llame por su nombre.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Las cuatro capas | Da un sitio evidente a cada archivo nuevo y evita el "cajón de sastre". |
| 2 | Regla de dependencia | Es la única regla que impide que la arquitectura se degrade sola. |
| 3 | Dominio sin motor | Lo que no depende de Godot se prueba en milisegundos y sobrevive a un cambio de motor. |
| 4 | Señales y eventos | Desacoplan al emisor del receptor: el sistema de salud no conoce el HUD. |
| 5 | Servicios e inyección | Hace explícitas las dependencias que un singleton esconde. |
| 6 | Data-driven | Mover el balance a datos permite cambiarlo sin recompilar ni redesplegar. |
| 7 | Límites de sistema | Definir qué entra y qué sale evita que un sistema se convierta en otro. |
| 8 | Composición sobre herencia | Un enemigo no "es un" personaje: "tiene" salud, movimiento e IA. |
| 9 | Dependencias circulares | El síntoma más fiable de que un límite está mal puesto. |
CharacterBody2D que consulta al sistema de movimiento). Clave: traduce entre entidades del motor y datos puros.setup()) en vez de que él las busque. Clave: hace posible sustituirlas en una prueba.goto global.Trabajarás sobre un proyecto Godot 4.x nuevo o sobre cualquiera de tus labs anteriores. No hace falta ningún plugin. Conviene tener a mano la documentación de señales, de RefCounted —la clase base de los objetos de dominio, que no son nodos y se liberan solos— y de Autoloads, para saber exactamente de qué nos estamos apartando y por qué.
El laboratorio de esta parte, labs/gameplay-systems/, está montado con esta estructura de carpetas: úsalo como referencia mientras lees.
Partimos de un script monolítico típico y lo separamos paso a paso.
extends CharacterBody2D
var vida := 100
var vida_max := 100
func recibir_golpe(dano: int) -> void:
vida -= dano
# El enemigo conoce el HUD, el audio, las partículas y la escena del loot.
get_node("/root/Mundo/HUD/BarraVida").value = vida
$SonidoGolpe.play()
if vida <= 0:
get_node("/root/Mundo").soltar_loot(global_position)
queue_free()
Nada de eso es "malo" por sí solo; el problema es la suma. Este enemigo no se puede probar sin un HUD, no se puede reutilizar en otra escena, y el día que la barra de vida cambie de sitio deja de funcionar en silencio.
res://dominio/salud.gd:class_name Salud
extends RefCounted
# El dominio no extiende Node: no está en el árbol, no tiene _process y no
# necesita una escena para existir. Eso es justo lo que lo hace testeable.
signal cambio(actual: int, maximo: int)
signal murio
var actual: int
var maximo: int
func _init(maximo_inicial: int) -> void:
maximo = max(1, maximo_inicial)
actual = maximo
func recibir(dano: int) -> void:
if actual <= 0 or dano <= 0:
return # ya está muerto o el golpe no hace nada
actual = max(0, actual - dano)
cambio.emit(actual, maximo)
if actual == 0:
murio.emit()
func curar(cantidad: int) -> void:
if actual <= 0 or cantidad <= 0:
return # a un muerto no se le cura: es una regla, y vive aquí
actual = min(maximo, actual + cantidad)
cambio.emit(actual, maximo)
func esta_vivo() -> bool:
return actual > 0
extends CharacterBody2D
signal murio_en(posicion: Vector2) # hacia fuera: el mundo decide qué hacer
var _salud: Salud
func _ready() -> void:
_salud = Salud.new(100)
# El nodo escucha a su propio dominio; el dominio no sabe que hay un nodo.
_salud.murio.connect(_on_murio)
func recibir_golpe(dano: int) -> void:
_salud.recibir(dano)
func _on_murio() -> void:
murio_en.emit(global_position)
queue_free()
func salud() -> Salud:
return _salud # para que la presentación pueda suscribirse
extends ProgressBar
func observar(salud: Salud) -> void:
max_value = salud.maximo
value = salud.actual
salud.cambio.connect(_on_cambio)
func _on_cambio(actual: int, maximo: int) -> void:
max_value = maximo
value = actual
extends Node2D
func _ready() -> void:
var enemigo := $Enemigo
$HUD/BarraVida.observar(enemigo.salud())
enemigo.murio_en.connect(_soltar_loot)
print("Nivel construido: 1 enemigo, sistemas conectados")
func _soltar_loot(posicion: Vector2) -> void:
# El mundo decide el loot; el enemigo solo informa de que ha muerto.
pass
class_name Servicios
extends RefCounted
var _mapa := {}
func registrar(clave: StringName, servicio: Object) -> void:
assert(not _mapa.has(clave), "servicio duplicado: %s" % clave)
_mapa[clave] = servicio
func obtener(clave: StringName) -> Object:
# Fallar aquí y con nombre es mucho mejor que un null tres capas más abajo.
assert(_mapa.has(clave), "servicio no registrado: %s" % clave)
return _mapa[clave]
# res://arranque.gd — el único sitio donde se decide qué implementación se usa.
func _ready() -> void:
var servicios := Servicios.new()
servicios.registrar(&"rng", RngDeterminista.new(12345))
servicios.registrar(&"guardado", GuardadoEnDisco.new())
$Mundo.setup(servicios) # inyección: el mundo recibe, no busca
La diferencia con un Autoload no es de comodidad, es de honestidad: aquí la dependencia está escrita en la firma de setup(). Con un Autoload, cualquier archivo puede empezar a depender de Guardado sin que se note en ninguna parte, y lo descubres el día que intentas probar algo.
Salud es un RefCounted, esto corre en milisegundos:extends SceneTree # se ejecuta con: godot --headless --script res://pruebas/salud_test.gd
func _init() -> void:
var s := Salud.new(30)
s.recibir(10)
assert(s.actual == 20, "el daño no se aplicó")
s.recibir(999)
assert(s.actual == 0, "la vida no se ha limitado a 0")
s.curar(50)
assert(s.actual == 0, "se ha curado a un muerto")
print("== 3 comprobaciones, 0 fallos ==")
quit()
preload/class_name y dibuja las flechas. Si encuentras un ciclo —Inventario → UI → Inventario— hay dos salidas: invertir una flecha con una señal (la UI escucha al inventario) o extraer el concepto que falta. Nunca se arregla añadiendo un tercer acceso global.Monedero con señal cambio y conéctale el HUD.get_node("/root/...")) de tus proyectos por una inyección en setup().Servicios un método intentar_obtener() que devuelva null en vez de fallar, y razona en qué casos conviene cada uno.curar() nunca supera el máximo.Resource y comprueba que puedes cambiarla sin tocar código.Refactoriza un mini-juego monolítico —puedes partir de labs/gameplay-systems/inicio/ o de tu lab de plataformas— separándolo en las cuatro capas, con al menos tres sistemas de dominio (por ejemplo salud, monedero y temporizador de partida) que no extiendan Node, comunicación por señales hacia la presentación y un contenedor Servicios inyectado desde el arranque.
Criterio de aceptación: (a) ningún archivo de dominio/ contiene la cadena get_node, $ ni preload de una escena; (b) existe un script de pruebas headless que instancia los tres sistemas de dominio, ejecuta al menos 8 aserciones y termina imprimiendo == N comprobaciones, 0 fallos ==; (c) el juego sigue siendo jugable y arranca imprimiendo Nivel construido:; (d) borrar el nodo del HUD de la escena no produce ningún error en el Output — la lógica sigue funcionando sin su presentación.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| "Invalid get index 'BarraVida' on base 'null instance'" al cambiar la escena de sitio | Búsqueda por ruta absoluta desde gameplay. Inyecta la referencia en setup() o usa una señal. |
| Cambiar una regla obliga a tocar 6 archivos | La regla está repetida en la presentación y en la lógica. Muévela al dominio y que los demás la consulten. |
El dominio necesita get_tree() para un temporizador |
Estás metiendo tiempo del motor en el dominio. Pásale delta desde gameplay o inyecta un servicio de reloj. |
| Todo el juego depende de 9 Autoloads | Se han usado singletons como cajón de dependencias. Agrúpalos en un contenedor de servicios inyectado. |
"Cyclic reference" al usar class_name |
Dependencia circular real entre dos scripts. Invierte una dirección con una señal o extrae la parte común. |
| La UI reacciona tarde o dos veces | Se conectó la misma señal en dos sitios, o se emite antes de actualizar el estado. Emite después de mutar y conecta en un único punto. |
| Las pruebas tardan minutos porque cargan escenas | Estás probando gameplay, no dominio. Extrae la regla a un RefCounted y prueba eso. |
❓ ¿No es esto sobre-ingeniería para un juego pequeño? Para un juego de una jam, sí. El umbral práctico es este: en cuanto dos sistemas distintos necesiten leer o modificar el mismo estado, sepáralo. Antes de eso, un script monolítico es la decisión correcta.
❓ ¿Los Autoloads son malos entonces? No. Un Autoload es una herramienta razonable para un puñado de servicios de verdad globales (audio, ajustes, escena actual). Lo que hace daño es usarlos como atajo para que cualquier script hable con cualquier otro, porque las dependencias dejan de verse.
❓ ¿Señales para todo? No. Una señal es la respuesta cuando el emisor no debe conocer al receptor. Si A tiene que saber que B existe de todas formas —un jugador y su inventario—, una llamada directa es más simple y más fácil de seguir en un depurador.
❓ ¿Esto es "arquitectura limpia" / hexagonal? Comparte la idea central (la regla de dependencia) pero sin su ceremonia. En gameplay no hace falta una interfaz por cada colaborador: bastan carpetas con una regla clara y disciplina para respetarla.
❓ ¿Y el ECS de la Parte 14? Es una respuesta distinta al mismo problema, orientada al rendimiento (verás su versión avanzada en la clase 339). Las capas organizan el conocimiento; el ECS organiza los datos en memoria. Se pueden combinar.
RefCounted: https://docs.godotengine.org/en/4.3/classes/class_refcounted.html · uso: se instala o se consulta en la preparaciónClase 292 - Capstone final: publica tu juego y tu portfolio