Por que modificar Blueprint Defaults em runtime falha: Arquitetando uma loja de upgrades segura na Unreal Engine
Em resumo
Este artigo explica por que a modificação de Blueprint Defaults em runtime falha na Unreal Engine e como superar essa limitação adotando uma arquitetura centrada em dados. Ao desacoplar as estatísticas da arma (estado) do actor visual que a representa (apresentação), os desenvolvedores conseguem garantir a persistência dos upgrades no Player State mesmo após a destruição do actor. Além disso, o tutorial demonstra boas práticas de validação no servidor para evitar exploits e discute a integração com bancos de dados no backend para persistência a longo prazo.
Você passa semanas construindo um sistema de armas modular na Unreal Engine, instancia uma child blueprint para o seu rifle, configura um menu de loja em UMG elegante para fazer upgrade na sua velocidade de recarga, apenas para descobrir que os upgrades desaparecem no instante em que o jogador troca de armas ou reinicia o level. Pior ainda, quando você tenta fazer o cast para a classe Blueprint no seu menu widget e modificar as variáveis, nada acontece. Esse é um gargalo notório para desenvolvedores que constroem sistemas de progressão.
Neste tutorial, vamos detalhar por que modificações de Blueprint em runtime falham, como arquitetar um sistema de armas persistente e como armazenar esses upgrades de forma segura em um banco de dados.
O problema central: Por que modificar Blueprint Class Defaults em runtime falha
Quando você edita variáveis em uma Blueprint dentro do Unreal Editor, você está modificando o Class Default Object (CDO). O CDO funciona como o template mestre para cada instância daquela classe que é spawnada no mundo do jogo. Em runtime, no entanto, modificar o CDO diretamente é altamente restrito, e por um bom motivo. Se você alterar uma variável do CDO, corre o risco de modificar o valor padrão para todos os spawns futuros, causando problemas de serialização e quebrando a replicação.
O erro mais comum é tentar fazer o cast de uma referência de classe (TSubclassOf<AActor>) para uma instância da classe. Se o seu menu widget contém uma variável do tipo "Weapon Class" (por exemplo, BP_Rifle_Child) e você tenta definir suas variáveis diretamente, você está visando o template da classe, não o actor ativo nas mãos do jogador. Mesmo que você consiga fazer o cast com sucesso para a instância do actor ativo (por exemplo, o rifle atualmente equipado) e altere seu BaseDamage de 25 para 50, essa mudança é temporária.
No momento em que o jogador guarda o rifle, muda para uma pistola e equipa o rifle novamente, o jogo destrói o actor do rifle antigo e spawna um novo. O novo actor é spawnado do zero a partir do template do CDO, resetando seu BaseDamage com upgrade de volta para o padrão de 25. Para fazer com que os upgrades persistam, você deve desacoplar as estatísticas da arma do actor visual spawnado no mundo.
A arquitetura de um sistema de armas persistente
Para resolver isso, devemos separar o Estado (as estatísticas da arma) da Apresentação (o actor que renderiza a arma e lida com os spawns de projéteis). Em vez de armazenar o dano autoritativo, a velocidade de recarga e a capacidade de munição dentro do próprio actor da arma, nós os armazenamos em uma estrutura de dados persistente. Essa estrutura deve residir em uma classe que persista mesmo com a destruição do actor, como o APlayerState, AGameState ou um componente de inventário customizado.
Para jogos multiplayer, armazenar essas variáveis em um componente customizado exige um gerenciamento cuidadoso de ownership. Se você armazena estados de armas em um ActorComponent customizado, certifique-se de não cair em pesadelos de inventário multiplayer causados por ownership incompatível de actor components durante a replicação. Ao hospedar os dados autoritativos no APlayerState, você garante que os dados sejam preservados mesmo quando o jogador morre, muda de level ou troca suas armas.
Quando o jogador abre a loja de upgrades, o widget de UI interage diretamente com o estado de dados do jogador. Comprar um upgrade modifica a struct persistente, não o actor da arma. Quando o jogador equipa uma arma, a classe do personagem spawna o actor da arma e o inicializa imediatamente usando a struct de dados do estado do jogador. Isso garante que cada nova arma spawnada herde os stats corretos e com upgrade.
Implementação passo a passo: Desacoplando dados e lógica de Actor
Vamos escrever uma implementação limpa em C++ dessa arquitetura desacoplada. Definiremos uma struct FWeaponStats que contém os valores atualizáveis e uma classe de arma base que pode ser configurada dinamicamente.
1. Definindo a Struct de Weapon Stats
Primeiro, definimos nossa estrutura de dados. Essa estrutura é acessível via Blueprint, permitindo que seus widgets de UI e child blueprints voltados para designers leiam e escrevam stats facilmente.
#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;
};
Ao encapsular os stats da arma em uma única struct FWeaponStats, tornamos trivial serializar, replicar e trafegar esses dados. Em vez de gerenciar cinco variáveis float replicadas separadas, replicamos uma única struct, reduzindo o overhead de replicação.
2. Criando a Classe de Arma Base
Em seguida, criamos a classe de arma base AWeaponBase. Essa classe representa o actor físico no mundo e contém uma função para se inicializar com os novos stats.
#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);
}
Na função de inicialização, atribuímos os stats passados à nossa variável WeaponStats replicada. Em um cenário real, você também dispararia ajustes visuais ou funcionais aqui — como redimensionar a mesh do carregador, alterar os timers de cadência de tiro ou modificar as escalas do sistema de partículas com base nos novos stats. Em vez de recarregar um asset de blueprint de 20 MB toda vez que um stat muda, passar uma struct C++ de 48 bytes economiza um enorme overhead de memória.
3. Integrando o Widget da Loja de Upgrades com o Player State
Para gerenciar upgrades de forma autoritativa, o menu da loja na UI deve interagir com o PlayerState em vez de tentar fazer o cast direto para instâncias transitórias de actors. Vamos projetar a classe AShooterPlayerState para gerenciar os stats do jogador e validar os upgrades.
#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);
}
A classe AShooterPlayerState gerencia a moeda do jogador e seu inventário de stats de armas. Ao utilizar RPCs (Server_UpgradeWeapon) e replicação, o servidor permanece como a única fonte de verdade para os stats do jogador.
Observe a função Server_UpgradeWeapon_Validate. O sistema de validação de RPC da Unreal expulsa automaticamente clientes que enviam requisições inválidas, como tentar fazer o upgrade de uma arma sem ouro suficiente.
Assim que o servidor processa o upgrade, ele deduz o ouro, atualiza os stats e replica as alterações de volta para o cliente. Essa replicação dispara atualizações de UI no client-side automaticamente por meio de callbacks de replicação.
Prevenindo exploits no Client-Side na sua loja de upgrades
Se o seu jogo for multiplayer, ou se você quiser proteger a economia do seu single-player contra editores de memória, você não pode confiar no cliente para gerenciar seus próprios upgrades. Se o widget de UI do client-side tiver permissão para chamar Server_UpgradeWeapon(int32 NewDamage), um hacker pode facilmente interceptar os pacotes de rede ou usar ferramentas como o Cheat Engine para enviar um pacote alegando que o dano de sua arma é 999.999.
Sem uma validação rígida autoritativa no servidor, seu jogo sofrerá com desyncs de estado no multiplayer que quebram o jogo, onde a UI local do cliente acha que ele tem uma arma com upgrade, mas o servidor está processando o dano com base nos stats originais de nível 1. Para evitar isso, o cliente deve enviar apenas uma intenção de upgrade, como Server_RequestUpgrade(FName WeaponID). O servidor então executa a transação de forma autoritativa.
O fluxo de validação do servidor deve seguir estes passos:
- Verificar recursos: Verifique se o jogador realmente tem ouro ou sucata suficiente para comprar o upgrade.
- Validar caminho de upgrade: Confirme se o upgrade solicitado é o próximo na sequência (por exemplo, Nível 2 para o Nível 3, e não pulando para o Nível 10).
- Deduzir custo e aplicar: Deduza a moeda no servidor e atualize a struct de stats persistente do jogador.
- Replicar e sincronizar: Replique a struct atualizada de volta para o cliente, o que atualiza automaticamente a arma equipada.
Persistindo upgrades no banco de dados do Backend
Embora manter os stats no PlayerState funcione durante uma única partida, esses stats são apagados no momento em que o jogador fecha o jogo ou o servidor reinicia. Para criar um loop de progressão real, você deve salvar essas alterações dinâmicas de variáveis em um banco de dados persistente no backend.
Construir isso manualmente por conta própria é uma tarefa colossal. Você precisaria provisionar um banco de dados SQL ou NoSQL, configurar um API gateway com autenticação OAuth2, implementar lógica de servidor customizada para ler payloads JSON e lidar com casos extremos como timeouts de conexão e sharding de banco de dados. Essa configuração de infraestrutura pode facilmente levar de 4 a 6 semanas de desenvolvimento dedicado, tirando o foco do polimento do seu core gameplay loop.
É aqui que o horizOn entra, permitindo que você armazene dados de progressão do jogador com segurança na nuvem sem precisar escrever código de banco de dados no backend. Você pode usar o SDK do backend para serializar sua struct FWeaponStats em JSON e salvá-la diretamente no perfil de nuvem do jogador com regras de segurança autoritativas no servidor. Um payload JSON típico de stats de arma tem menos de 500 bytes (cerca de 320 bytes para um loadout padrão), o que significa que as operações de banco de dados são executadas em menos de 15ms. Essa velocidade mantém as transições de UI rápidas e garante que os jogadores não sofram stuttering ao abrir menus de loja ou finalizar transações.
Por exemplo, você pode escrever uma função segura de Cloud Code no backend que valida a compra do upgrade. Quando o jogador clica em "Buy Upgrade" no menu do seu widget, o jogo envia uma requisição de API segura. O banco de dados verifica o inventário do jogador, deduz a moeda, grava o novo nível da arma e retorna o payload de stats atualizado. Isso garante que, mesmo que um jogador hackeie sua memória local, o banco de dados autoritativo na nuvem permaneça seguro.
Boas práticas para sistemas de upgrade na Unreal Engine
Para garantir que sua loja de upgrades seja estável e performática, siga estes princípios fundamentais:
- Nunca edite Class Default Objects (CDOs) em Runtime: Trate CDOs como blueprints apenas de leitura. Use-os exclusivamente para spawnar as meshes visuais padrão e templates iniciais.
- Desacople stats de actors: Armazene stats autoritativos em uma classe persistente como
APlayerStateorGameInstance, e passe-os para o actor ao spawnar. - Imponha autoridade do servidor: Nunca permita que os clientes ditem alterações de stats diretamente. Os clientes solicitam os upgrades; o servidor valida os recursos e aplica a alteração.
- Use structs para serialização: Agrupe stats atualizáveis em USTRUCTs. Isso torna a serialização para saves locais ou chamadas de banco de dados no backend integrada.
- Otimize payloads de banco de dados: Mantenha os perfis dos jogadores enxutos. Armazene apenas os níveis de upgrade (por exemplo,
WeaponLevel: 3) no banco de dados e reconstrua os floats reais (por exemplo,Damage: 45.0f) no servidor do jogo.
Resumo e próximos passos
Resolver atualizações dinâmicas de variáveis em child blueprints exige uma mudança de um design centrado em actor para um design centrado em dados. Ao armazenar stats em structs persistentes no PlayerState e inicializar suas armas dinamicamente, você evita que os upgrades desapareçam quando as armas são trocadas. Quando estiver pronto para levar esse sistema de progressão para o multiplayer e protegê-lo na nuvem, integrá-lo com um banco de dados no backend é essencial.
Pronto para escalar seus sistemas de progressão e proteger os inventários dos jogadores? Experimente o horizOn gratuitamente ou confira a documentação da API para começar a construir seu backend hoje mesmo.