Parte: 18 — Arquitectura de gameplay y sistemas sistémicos · Fuente: Gregory, «Game Engine Architecture» · Charlas de GDC sobre sistemas de combate y hit detection ⏱️ Duración estimada: 130 min · Nivel: Avanzado
Construir el pipeline de daño: la tubería explícita por la que pasa todo golpe del juego, desde que una hitbox toca una hurtbox hasta que aparece el número, se aplica el retroceso y, si toca, muere alguien. En la mayoría de proyectos amateur ese recorrido está repartido en seis scripts y cada arma lo hace un poco distinto; el resultado es que nadie puede responder a "¿por qué este golpe ha hecho 34?".
Aquí lo vas a convertir en una secuencia de pasos con nombre —Attack → Hit → Cálculo → Resistencias → Modificadores → Salud → Efectos → Feedback → Muerte— donde cada paso recibe un objeto de daño, lo transforma y lo pasa. Separarás hitbox de hurtbox, implementarás armadura y resistencias con fórmulas que no se rompen en los extremos, críticos deterministas, invulnerabilidad tras impacto (i-frames), retroceso y una muerte que ocurre una sola vez.
Al finalizar, el alumno podrá:
Golpe que viaje por el pipeline acumulando su historia.| # | Tema | Por qué importa |
|---|---|---|
| 1 | Hitbox vs hurtbox | Sin separarlas, un enemigo se golpea a sí mismo o los golpes se anulan. |
| 2 | El objeto Golpe | Llevar el contexto entero evita 8 parámetros y permite auditar el resultado. |
| 3 | Pipeline por pasos | Cada paso se prueba y se cambia sin tocar los demás. |
| 4 | Tipos de daño | Físico, fuego, veneno: cada uno con su resistencia. |
| 5 | Armadura y reducción | La fórmula lineal se rompe: al 100 de armadura no puede haber inmunidad. |
| 6 | Críticos | Probabilidad y multiplicador, con aleatoriedad controlada. |
| 7 | Invulnerabilidad (i-frames) | Evita que una hitbox persistente vacíe la vida en tres frames. |
| 8 | Retroceso (knockback) | Es parte del golpe, no del movimiento: viaja en el mismo objeto. |
| 9 | Muerte única | El bug de "muere dos veces y suelta doble loot" es endémico. |
| 10 | Feedback separado | Números, sonido y screenshake son presentación, no reglas. |
A / (A + K) que se acerca a 1 sin llegar. Clave: mantiene el juego balanceable en valores altos.randf() global.Necesitas Salud (clase 293), Stats (clase 296) y Efectos (clase 298). Trabajaremos en res://dominio/combate/ para el cálculo y en res://gameplay/combate/ para las áreas de colisión. Configura tres capas de física: Hurtbox jugador, Hurtbox enemigo, Hitbox, de forma que una hitbox de jugador solo detecte hurtboxes de enemigo. Documentación: Area2D y capas y máscaras de colisión.
class_name Golpe
extends RefCounted
enum Tipo { FISICO, FUEGO, HIELO, VENENO, VERDADERO }
var id_ataque: int = 0 # identifica ESTE ataque, para no impactar dos veces
var atacante: StringName = &""
var base: float = 0.0
var tipo: Tipo = Tipo.FISICO
var direccion: Vector2 = Vector2.ZERO
var fuerza_knockback: float = 0.0
var prob_critico: float = 0.0
var mult_critico: float = 2.0
var ignora_armadura := false
var efectos: Array[Dictionary] = []
# Resultado del pipeline (lo rellenan los pasos).
var final: float = 0.0
var fue_critico := false
var absorbido := false # invulnerable, bloqueado o inmune
var traza: Array[String] = [] # "base 40" → "crítico ×2 = 80" → "armadura -21 = 59"
func anotar(paso: String) -> void:
traza.append(paso)
func explicar() -> String:
return " → ".join(traza)
Esa traza parece un lujo hasta el primer informe de "me ha hecho demasiado daño": entonces vale su peso en oro, y es el mismo dato que después alimentará la telemetría de balance (clase 317).
class_name Defensor
extends RefCounted
var salud: Salud
var stats: Stats
var efectos: Efectos
var invulnerable_hasta: float = 0.0 # en tiempo de juego
var golpes_recibidos := {} # id_ataque -> true
func es_invulnerable(ahora: float) -> bool:
return ahora < invulnerable_hasta
func ya_golpeado_por(id_ataque: int) -> bool:
return id_ataque != 0 and golpes_recibidos.has(id_ataque)
class_name PipelineDano
extends RefCounted
const K_ARMADURA := 100.0 # a 100 de armadura, reduce el 50 %
var _rng: RandomNumberGenerator
func _init(rng: RandomNumberGenerator) -> void:
# El RNG se INYECTA: con semilla fija, el combate es reproducible y se puede
# grabar en un replay (clase 308) y comparar en un test.
_rng = rng
func aplicar(g: Golpe, d: Defensor, ahora: float) -> Golpe:
g.anotar("base %.0f" % g.base)
g.final = g.base
# 1) Puertas: si no procede, se sale sin tocar nada más.
if not d.salud.esta_vivo():
g.absorbido = true
g.anotar("objetivo ya muerto")
return g
if d.ya_golpeado_por(g.id_ataque):
g.absorbido = true
g.anotar("mismo ataque, ya impactó")
return g
if d.es_invulnerable(ahora):
g.absorbido = true
g.anotar("invulnerable")
return g
# 2) Crítico.
if g.prob_critico > 0.0 and _rng.randf() < g.prob_critico:
g.fue_critico = true
g.final *= g.mult_critico
g.anotar("crítico ×%.1f = %.0f" % [g.mult_critico, g.final])
# 3) Modificadores ofensivos ya vienen en `base` (los aplicó el atacante).
# Aquí toca lo defensivo.
if g.tipo != Golpe.Tipo.VERDADERO:
g.final = _armadura(g, d)
g.final = _resistencia(g, d)
# 4) Redondeo y suelo: un golpe que conecta hace al menos 1.
g.final = maxf(1.0, roundf(g.final))
g.anotar("final %.0f" % g.final)
# 5) Aplicar.
d.salud.recibir(int(g.final))
if g.id_ataque != 0:
d.golpes_recibidos[g.id_ataque] = true
# 6) Efectos que trae el golpe (quemadura, sangrado…).
for e in g.efectos:
g.anotar("efecto %s" % str(e.get("estado", "?")))
return g
func _armadura(g: Golpe, d: Defensor) -> float:
if g.ignora_armadura or g.tipo != Golpe.Tipo.FISICO:
return g.final
var a := maxf(0.0, d.stats.valor(&"defensa"))
# Asintótica: nunca llega a reducir el 100 %, por muchísima armadura que haya.
var reduccion := a / (a + K_ARMADURA)
var r := g.final * (1.0 - reduccion)
g.anotar("armadura %.0f (-%.0f%%) = %.0f" % [a, reduccion * 100.0, r])
return r
func _resistencia(g: Golpe, d: Defensor) -> float:
var clave := StringName("res_" + Golpe.Tipo.keys()[g.tipo].to_lower())
# Acotamos: -1.0 = daño doble (vulnerable), 0.9 = 90 % resistido. Nunca 100 %.
var res := clampf(d.stats.valor(clave), -1.0, 0.9)
if is_zero_approx(res):
return g.final
var r := g.final * (1.0 - res)
g.anotar("resistencia %s %.0f%% = %.0f" % [clave, res * 100.0, r])
return r
# res://gameplay/combate/hitbox.gd
extends Area2D
@export var dano_base: float = 10.0
@export var tipo: Golpe.Tipo = Golpe.Tipo.FISICO
@export var knockback: float = 200.0
static var _siguiente_id := 1
var _id_ataque := 0
func activar() -> void:
# Un id NUEVO por swing: la misma hitbox puede golpear a varios enemigos,
# pero a cada uno una sola vez.
_id_ataque = _siguiente_id
_siguiente_id += 1
monitoring = true
func desactivar() -> void:
monitoring = false
func _on_area_entered(hurtbox: Area2D) -> void:
if not monitoring:
return
var g := Golpe.new()
g.id_ataque = _id_ataque
g.atacante = owner.name
g.base = dano_base
g.tipo = tipo
g.direccion = (hurtbox.global_position - global_position).normalized()
g.fuerza_knockback = knockback
hurtbox.recibir(g)
# res://gameplay/combate/hurtbox.gd
extends Area2D
signal golpeado(g: Golpe)
@export var iframes: float = 0.5
var defensor: Defensor # lo inyecta la entidad al construirse
func recibir(g: Golpe) -> void:
var ahora := float(Time.get_ticks_msec()) / 1000.0
var pipeline: PipelineDano = Servicios.actual.obtener(&"pipeline")
pipeline.aplicar(g, defensor, ahora)
if not g.absorbido:
defensor.invulnerable_hasta = ahora + iframes
golpeado.emit(g) # la presentación se entera por aquí
# res://presentacion/feedback_combate.gd
extends Node2D
func _on_golpeado(g: Golpe) -> void:
if g.absorbido:
return
_numero_flotante(str(int(g.final)), Color.RED if not g.fue_critico else Color.YELLOW)
if g.fue_critico:
_hitstop(0.08) # el hitstop escala con el impacto, no con el daño bruto
_sonido_impacto(g.tipo)
extends CharacterBody2D
var _muerto := false
func _on_salud_murio() -> void:
if _muerto:
return # dos golpes en el mismo frame no dan doble loot
_muerto = true
murio_en.emit(global_position, _ultimo_atacante)
$Hurtbox.monitoring = false # deja de recibir: si no, sigue "muriendo"
queue_free()
extends SceneTree
func _init() -> void:
var rng := RandomNumberGenerator.new(); rng.seed = 42
var pipe := PipelineDano.new(rng)
var d := Defensor.new()
d.stats = Stats.new()
d.salud = Salud.new(200)
d.stats.get_stat(&"defensa").base = 100.0 # K = 100 → 50 % de reducción
var g := Golpe.new(); g.id_ataque = 1; g.base = 40.0
pipe.aplicar(g, d, 0.0)
assert(g.final == 20.0, "la armadura no redujo el 50%%: %f" % g.final)
assert(d.salud.actual == 180)
# El MISMO ataque no golpea dos veces.
var g2 := Golpe.new(); g2.id_ataque = 1; g2.base = 40.0
pipe.aplicar(g2, d, 0.0)
assert(g2.absorbido and d.salud.actual == 180, "el mismo ataque golpeó dos veces")
# Daño verdadero: ignora armadura.
var g3 := Golpe.new(); g3.id_ataque = 2; g3.base = 40.0; g3.tipo = Golpe.Tipo.VERDADERO
pipe.aplicar(g3, d, 0.0)
assert(g3.final == 40.0, "el daño verdadero fue reducido")
# Invulnerabilidad.
d.invulnerable_hasta = 10.0
var g4 := Golpe.new(); g4.id_ataque = 3; g4.base = 999.0
pipe.aplicar(g4, d, 5.0)
assert(g4.absorbido, "los i-frames no absorbieron el golpe")
print(g.explicar())
print("== 5 comprobaciones, 0 fallos ==")
quit()
estado.bloqueando y el golpe viene de frente, reduce un 70 % y aplica el resto a una barra de aguante.falloff) y un id de ataque compartido.sobrecuración (curar por encima del máximo) como escudo temporal usando el sistema de efectos.prob_critico.hitstop proporcional a final / vida_max y compara la sensación con un valor fijo.Implementa el pipeline completo con hitbox y hurtbox separadas, tipos de daño, armadura asintótica, resistencias acotadas, críticos con RNG inyectado, i-frames, id de ataque, knockback y muerte única.
Criterio de aceptación: una prueba headless con al menos 18 aserciones demuestra que: (a) con defensa igual a K_ARMADURA el daño físico se reduce exactamente a la mitad; (b) con defensa 10.000 el daño sigue siendo mayor que 0; (c) el daño verdadero ignora armadura y resistencias; (d) el mismo id_ataque no aplica daño dos veces al mismo defensor pero sí golpea a dos defensores distintos; (e) durante los i-frames todos los golpes quedan absorbidos; (f) dos golpes letales en el mismo frame emiten la señal de muerte una sola vez; (g) con rng.seed fijo, dos ejecuciones producen la misma secuencia de críticos y el mismo daño total.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| El enemigo pierde toda la vida al tocar la espada | La hitbox está activa varios frames sin id de ataque ni i-frames. Añade ambos. |
| El jugador se golpea a sí mismo | Hitbox y hurtbox en la misma capa. Sepáralas y ajusta las máscaras. |
| Con mucha armadura los enemigos son inmortales | Fórmula lineal (dano - armadura). Usa la reducción asintótica. |
| El loot sale duplicado | La señal de muerte se emitió dos veces. Añade la bandera _muerto. |
| Los críticos "se sienten" mal distribuidos | Se usa randf() global compartido con efectos visuales. Inyecta un RNG propio del combate. |
| El daño no es reproducible en un replay | El RNG no está sembrado o se comparte. Un RNG por sistema, con semilla guardada. |
| El número flotante muestra un valor distinto al que baja la vida | El feedback recalcula el daño. Debe leer g.final, nunca recalcular. |
| Golpes que no registran a 144 fps | La hitbox se activa y desactiva en menos de un frame de física. Usa una ventana mínima en _physics_process. |
❓ ¿Por qué la armadura asintótica y no daño - armadura? Porque la resta se rompe en los dos extremos: con armadura alta el daño llega a 0 (inmortalidad) y con daño alto la armadura no importa. A/(A+K) da rendimientos decrecientes suaves, tiene un parámetro con significado claro (K es la armadura que reduce el 50 %) y nunca alcanza el 100 %.
❓ ¿i-frames y id de ataque no son lo mismo? No, y hacen falta los dos. El id de ataque impide que una misma hitbox golpee dos veces al mismo enemigo; los i-frames impiden que dos ataques distintos (o dos enemigos) encadenen golpes sin darte reaccionar. Sin id, una hitbox persistente pega cada frame; sin i-frames, tres enemigos rodeándote te matan al instante.
❓ ¿El pipeline no añade coste? Es una función con siete pasos y unas pocas multiplicaciones; el coste es despreciable frente a la detección de colisiones. Lo que sí añade es la posibilidad de auditar cada número, que es lo que hace balanceable el juego.
❓ ¿Dónde aplico los efectos que trae el golpe? El pipeline los anota y los pasa al gestor de efectos (clase 298) después de resolver el daño, para que un golpe letal no aplique un veneno a un cadáver.
❓ ¿Y si es multijugador? El pipeline corre en el servidor y punto. El cliente puede predecir el número para el feedback, pero la salud autoritativa es la del servidor; si difieren, manda el servidor. Es exactamente lo que hace el lab de multijugador de la Parte 7.
Area2D: https://docs.godotengine.org/en/4.3/classes/class_area2d.html · uso: se instala o se consulta en la preparaciónRandomNumberGenerator (semillas y determinismo): https://docs.godotengine.org/en/4.3/classes/class_randomnumbergenerator.html · uso: lectura de respaldo del objetivo; no se usa en el procedimientoClase 298 - Status effects, buffs y debuffs