Por qué falla la modificación de Blueprint Defaults en tiempo de ejecución: Arquitectura de una tienda de mejoras segura en Unreal Engine
En resumen
Este tutorial aborda por qué la modificación directa de Blueprint Defaults (CDO) en tiempo de ejecución falla al implementar sistemas de progresión en Unreal Engine. Se propone una arquitectura desacoplada donde el estado y las estadísticas de las armas se gestionan en estructuras de datos persistentes dentro de `APlayerState`. Además, se detallan las mejores prácticas de replicación y validación autoritativa en el servidor para evitar exploits, junto con la persistencia en bases de datos backend.
Pasas semanas construyendo un sistema de armas modular en Unreal Engine, instancias un child blueprint para tu rifle, configuras un elegante menú de tienda en UMG para mejorar su velocidad de recarga, solo para descubrir que las mejoras desaparecen en el instante en que el jugador cambia de arma o reinicia el nivel. Peor aún, cuando intentas hacer un cast a la clase Blueprint en tu menú widget y modificar las variables, no pasa nada. Este es un cuello de botella muy conocido para los desarrolladores que construyen sistemas de progresión.
En este tutorial, analizaremos por qué fallan las modificaciones de Blueprints en tiempo de ejecución, cómo diseñar la arquitectura de un sistema de armas persistente y cómo almacenar esas mejoras de forma segura en una base de datos del backend.
El problema central: por qué falla la modificación de Blueprint Class Defaults en tiempo de ejecución
Cuando editas variables en un Blueprint dentro de Unreal Editor, estás modificando el Class Default Object (CDO). El CDO actúa como la plantilla maestra para cada instancia de esa clase que se spawnea en el mundo del juego. Sin embargo, en tiempo de ejecución, modificar el CDO directamente está muy restringido, y por una buena razón. Si cambias una variable del CDO, corres el riesgo de modificar el valor por defecto para todos los futuros spawns, lo que causa problemas de serialización y rompe la replicación.
El error más común es intentar hacer un cast de una referencia de clase (TSubclassOf<AActor>) a una instancia de la clase. Si tu menú widget contiene una variable del tipo "Weapon Class" (por ejemplo, BP_Rifle_Child) e intentas configurar sus variables directamente, estás apuntando a la plantilla de la clase, no al actor activo en las manos del jugador. Incluso si logras hacer un cast con éxito a la instancia del actor activo (por ejemplo, el rifle equipado actualmente) y cambias su BaseDamage de 25 a 50, ese cambio es transitorio.
En el momento en que el jugador guarda el rifle, cambia a una pistola y vuelve a equipar el rifle, el juego destruye el actor del rifle anterior y spawnea uno nuevo. El nuevo actor se spawnea limpio desde la plantilla del CDO, lo que restablece tu BaseDamage mejorado al valor por defecto de 25. Para que las mejoras persistan, debes desacoplar las estadísticas del arma del actor visual que se spawnea en el mundo.
La arquitectura de un sistema de armas persistente
Para resolver esto, debemos separar el Estado (las estadísticas del arma) de la Presentación (el actor que renderiza el arma y maneja el spawn de proyectiles). En lugar de almacenar el daño autoritativo, la velocidad de recarga y la capacidad de munición dentro del propio actor del arma, los almacenamos en una estructura de datos persistente. Esta estructura debe residir en una clase que persista a la destrucción de los actores, como APlayerState, AGameState o un componente de inventario personalizado.
Para juegos multiplayer, almacenar estas variables en un componente personalizado requiere una gestión cuidadosa del ownership. Si almacenas los estados de las armas en un ActorComponent personalizado, asegúrate de no tener problemas de multiplayer inventory nightmares causados por un ownership incorrecto del componente de actor durante la replicación. Al alojar los datos autoritativos en el APlayerState, garantizas que la información se preserve incluso cuando el jugador muere, cambia de nivel o intercambia sus armas.
Cuando el jugador abre la tienda de mejoras, el widget de la interfaz de usuario interactúa directamente con el estado de datos del jugador. Comprar una mejora modifica la struct persistente, no el actor del arma. Cuando el jugador equipa un arma, la clase de personaje spawnea el actor de la misma e inmediatamente lo inicializa utilizando la struct de datos del estado del jugador. Esto garantiza que cada arma recién spawneada herede las estadísticas correctas y mejoradas.
Implementación paso a paso: desacoplamiento de datos y lógica de actor
Escribamos una implementación limpia en C++ de esta arquitectura desacoplada. Definiremos una struct FWeaponStats que contenga los valores mejorables y una clase de arma base que se pueda configurar dinámicamente.
1. Definición de la struct de estadísticas de armas
Primero, definimos nuestra estructura de datos. Esta estructura es accesible desde Blueprints, lo que permite que tus widgets de UI y los child blueprints orientados al diseñador lean y escriban estadísticas fácilmente.
#pragma once
#include "CoreMinimal.h"
#include "WeaponStats.generated.h"
USTRUCT(BlueprintType)
struct FWeaponStats
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float BaseDamage = 25.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float ReloadSpeedModifier = 1.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 MaxAmmo = 30;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 CurrentUpgradeLevel = 0;
};
Al encapsular las estadísticas de las armas en una sola struct FWeaponStats, hacemos que sea muy sencillo serializarla, replicarla y compartirla. En lugar de gestionar cinco variables float replicadas por separado, replicamos una única struct, reduciendo el overhead de replicación.
2. Creación de la clase de arma base
A continuación, creamos la clase de arma base AWeaponBase. Esta clase representa al actor físico en el mundo y contiene una función para inicializarse a sí mismo con nuevas estadísticas.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "WeaponStats.h"
#include "WeaponBase.generated.h"
UCLASS()
class SHOOTER_API AWeaponBase : public AActor
{
GENERATED_BODY()
public:
AWeaponBase();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Weapon")
USkeletalMeshComponent* WeaponMesh;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Weapon")
FWeaponStats WeaponStats;
UFUNCTION(BlueprintCallable, Category = "Weapon")
void InitializeWeapon(const FWeaponStats& NewStats);
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
#include "WeaponBase.h"
#include "Net/UnrealNetwork.h"
AWeaponBase::AWeaponBase()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true;
WeaponMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponMesh"));
RootComponent = WeaponMesh;
}
void AWeaponBase::InitializeWeapon(const FWeaponStats& NewStats)
{
WeaponStats = NewStats;
// Apply changes dynamically to the active actor
// e.g., Adjust weapon mesh scale, update firing rate variables, or UI indicators
}
void AWeaponBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AWeaponBase, WeaponStats);
}
En la función de inicialización, asignamos las estadísticas pasadas a nuestra variable replicada WeaponStats. En un escenario del mundo real, también activarías ajustes visuales o funcionales aquí, como cambiar el tamaño de la malla del cargador, alterar los temporizadores de cadencia de fuego o modificar las escalas del sistema de partículas según las nuevas estadísticas. En lugar de recargar un asset blueprint de 20 MB cada vez que cambia una estadística, pasar una struct de C++ de 48 bytes ahorra un overhead de memoria masivo.
3. Integración del widget de la tienda de mejoras con el Player State
Para gestionar las mejoras de forma autoritativa, el menú de la tienda de la interfaz de usuario debe interactuar con el PlayerState en lugar de intentar hacer un cast directo a instancias de actores transitorios. Diseñemos la clase AShooterPlayerState para gestionar las estadísticas del jugador y validar las mejoras.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "WeaponStats.h"
#include "ShooterPlayerState.generated.h"
UCLASS()
class SHOOTER_API AShooterPlayerState : public APlayerState
{
GENERATED_BODY()
public:
AShooterPlayerState();
UPROPERTY(ReplicatedUsing = OnRep_WeaponInventory, BlueprintReadOnly, Category = "Inventory")
TArray<FWeaponStats> WeaponInventory;
UFUNCTION(BlueprintCallable, Category = "Inventory")
FWeaponStats GetWeaponStats(int32 WeaponIndex) const;
UFUNCTION(Server, Reliable, WithValidation, BlueprintCallable, Category = "Inventory")
void Server_UpgradeWeapon(int32 WeaponIndex);
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Economy")
int32 PlayerGold = 500;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
UFUNCTION()
void OnRep_WeaponInventory();
};
#include "ShooterPlayerState.h"
#include "Net/UnrealNetwork.h"
AShooterPlayerState::AShooterPlayerState()
{
bReplicates = true;
}
FWeaponStats AShooterPlayerState::GetWeaponStats(int32 WeaponIndex) const
{
if (WeaponInventory.IsValidIndex(WeaponIndex))
{
return WeaponInventory[WeaponIndex];
}
return FWeaponStats();
}
bool AShooterPlayerState::Server_UpgradeWeapon_Validate(int32 WeaponIndex)
{
if (!WeaponInventory.IsValidIndex(WeaponIndex)) return false;
int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
return PlayerGold >= UpgradeCost;
}
void AShooterPlayerState::Server_UpgradeWeapon_Implementation(int32 WeaponIndex)
{
int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
PlayerGold -= UpgradeCost;
FWeaponStats& Stats = WeaponInventory[WeaponIndex];
Stats.CurrentUpgradeLevel++;
Stats.BaseDamage += 10.0f;
Stats.ReloadSpeedModifier *= 0.9f;
}
void AShooterPlayerState::OnRep_WeaponInventory()
{
// Update local UI representation or bind to UI delegates
}
void AShooterPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AShooterPlayerState, WeaponInventory);
DOREPLIFETIME(AShooterPlayerState, PlayerGold);
}
La clase AShooterPlayerState gestiona la moneda del jugador y su inventario de estadísticas de armas. Al utilizar RPCs (Server_UpgradeWeapon) y la replicación, el servidor sigue siendo la única fuente de verdad para las estadísticas del jugador.
Observa la función Server_UpgradeWeapon_Validate. El sistema de validación de RPC de Unreal expulsa automáticamente a los clientes que envían solicitudes inválidas, como intentar mejorar un arma sin suficiente oro.
Una vez que el servidor procesa la mejora, descuenta el oro, actualiza las estadísticas y replica los cambios de vuelta al cliente. Esta replicación activa las actualizaciones de la UI del lado del cliente de forma automática a través de callbacks de replicación.
Prevención de exploits del lado del cliente en tu tienda de mejoras
Si tu juego es multiplayer, o si deseas proteger la economía de tu juego single-player de los editores de memoria, no puedes confiar en el cliente para gestionar sus propias mejoras. Si se permite que el widget de la UI del lado del cliente llame a Server_UpgradeWeapon(int32 NewDamage), un hacker puede interceptar fácilmente los paquetes de red o usar herramientas como Cheat Engine para enviar un paquete que afirme que el daño de su arma es de 999,999.
Sin una validación autoritativa estricta por parte del servidor, tu juego sufrirá desincronizaciones de estado que arruinarán la experiencia (multiplayer state desyncs), donde la UI local del cliente cree que tiene un arma mejorada, pero el servidor está procesando el daño basándose en las estadísticas originales de nivel 1. Para evitar esto, el cliente solo debe enviar una intención de mejora, como Server_RequestUpgrade(FName WeaponID). Luego, el servidor ejecuta la transacción de forma autoritativa.
El flujo de validación del servidor debe seguir estos pasos:
- Verificar recursos: Comprobar si el jugador realmente tiene suficiente oro o chatarra para comprar la mejora.
- Validar la ruta de mejora: Confirmar que la mejora solicitada es la siguiente en la secuencia (por ejemplo, del nivel 2 al nivel 3, no saltar al nivel 10).
- Deducir costo y aplicar: Descontar la moneda en el servidor y actualizar la struct de estadísticas persistentes del jugador.
- Replicar y sincronizar: Replicar la struct actualizada de vuelta al cliente, lo que actualiza automáticamente el arma equipada.
Persistencia de mejoras en la base de datos del Backend
Aunque mantener las estadísticas en el PlayerState funciona durante una sola partida, esas estadísticas se pierden en el momento en que el jugador cierra el juego o se reinicia el servidor. Para crear un ciclo de progresión real, debes guardar estos cambios de variables dinámicas en una base de datos de backend persistente.
Construir esto tú mismo manualmente es una tarea enorme. Necesitarías aprovisionar una base de datos SQL o NoSQL, configurar una API gateway con autenticación OAuth2, implementar lógica de servidor personalizada para analizar payloads JSON y manejar casos límite como tiempos de espera de conexión y sharding de bases de datos. Configurar esta infraestructura puede llevar fácilmente de 4 a 6 semanas de desarrollo dedicado, restando atención al pulido del bucle de juego principal.
Aquí es donde entra horizOn, que te permite almacenar los datos de progresión del jugador de forma segura en la nube sin tener que escribir código para la base de datos del backend. Puedes usar el SDK del backend para serializar tu struct FWeaponStats en un JSON y guardarlo directamente en el perfil en la nube del jugador con reglas de seguridad autoritativas en el servidor. Un payload JSON típico de estadísticas de armas pesa menos de 500 bytes (alrededor de 320 bytes para un loadout estándar), lo que significa que las operaciones de la base de datos se ejecutan en menos de 15 ms. Esta velocidad mantiene las transiciones de la UI fluidas y garantiza que los jugadores no experimenten stuttering al abrir los menús de la tienda o finalizar transacciones.
Por ejemplo, puedes escribir una función de Cloud Code segura en el backend que valide la compra de la mejora. Cuando el jugador hace clic en "Buy Upgrade" en tu menú widget, el juego envía una solicitud API segura. La base de datos verifica el inventario del jugador, descuenta la moneda, registra el nuevo nivel del arma y devuelve el payload de estadísticas actualizado. Esto garantiza que incluso si un jugador hackea su memoria local, la base de datos autoritativa en la nube permanezca segura.
Buenas prácticas para sistemas de mejoras en Unreal Engine
Para garantizar que tu tienda de mejoras sea tanto estable como de alto rendimiento, sigue estos principios fundamentales:
- Nunca edites Class Default Objects (CDOs) en tiempo de ejecución: Trata los CDO como blueprints de solo lectura. Úsalos únicamente para spawnear las mallas visuales por defecto y las plantillas iniciales.
- Desacopla las estadísticas de los actores: Almacena las estadísticas autoritativas en una clase persistente como
APlayerStateoGameInstance, y pásalas al actor al spawnearlo. - Aplica Server Authority: Nunca permitas que los clientes dicten los cambios de estadísticas directamente. Los clientes solicitan las mejoras; el servidor valida los recursos y aplica el cambio.
- Usa Structs para la serialización: Agrupa las estadísticas mejorables en USTRUCTs. Esto facilita enormemente la serialización para guardados locales o llamadas a la base de datos del backend.
- Optimiza los payloads de la base de datos: Mantén los perfiles de los jugadores ligeros. Almacena únicamente los niveles de mejora (por ejemplo,
WeaponLevel: 3) en la base de datos y reconstruye los floats reales (por ejemplo,Damage: 45.0f) en el servidor del juego.
Resumen y próximos pasos
Resolver las actualizaciones de variables dinámicas en child blueprints requiere cambiar de un diseño centrado en actores a un diseño centrado en datos. Al almacenar estadísticas en structs persistentes en el PlayerState e inicializar tus armas dinámicamente, evitas que las mejoras desaparezcan cuando se cambian las armas. Cuando estés listo para llevar este sistema de progresión al multiplayer y asegurarlo en la nube, respaldarlo con una base de datos de backend es esencial.
¿Listo para escalar tus sistemas de progresión y asegurar los inventarios de tus jugadores? Prueba horizOn gratis o consulta la documentación de la API para comenzar a construir tu backend hoy mismo.