返回博客

为什么在 Runtime 修改 Blueprint 默认值会失败:构建安全的 Unreal Engine 升级商店

发布于 2026年7月13日
为什么在 Runtime 修改 Blueprint 默认值会失败:构建安全的 Unreal Engine 升级商店

概要

本文深入探讨了在 Unreal Engine 运行时修改 Blueprint 默认值(CDO)失败的根本原因,并提出了数据与表现分离的架构设计。通过在 PlayerState 中使用持久化的 FWeaponStats 结构体,并结合 Server-Authoritative 验证,可以有效避免因武器切换或 Actor 销毁导致的升级数据丢失与客户端作弊。最后,文章介绍了如何利用 horizOn 将玩家 Progression 数据安全地存储到云端数据库中,从而精简网络 Payload 并提升系统稳定性。

你花了数周时间在 Unreal Engine 中构建了一个模块化武器系统,为步枪实例化了一个子 Blueprint,并设置了一个精美的 UMG 商店菜单来升级其换弹速度,却发现一旦玩家切换武器或重启关卡,这些升级就消失得无影无踪。更糟糕的是,当你尝试在 Widget 菜单中 Cast 到 Blueprint 类并修改变量时,什么都没有发生。对于开发 Progression System 的开发者来说,这是一个臭名昭著的瓶颈。

在本教程中,我们将深入剖析为什么在 Runtime 修改 Blueprint 会失败,如何架构一个持久化的武器系统,以及如何将这些升级安全地存储在数据库中。

核心问题:为什么在 Runtime 修改 Blueprint 类默认值会失败

当你在 Unreal Editor 中编辑 Blueprint 中的变量时,你修改的是 Class Default Object (CDO)。CDO 作为该类在游戏世界中 Spawn 的所有 Instance 的主模板。然而,在 Runtime 直接修改 CDO 是受到严格限制的,而且这有充分的理由。如果你修改了 CDO 变量,你将面临修改所有后续 Spawn 实例的默认值的风险,从而导致序列化(Serialization)问题并破坏 Replication。

最常见的错误是尝试将类引用(TSubclassOf<AActor>)Cast 到该类的 Instance。如果你的 Widget 菜单包含一个 "Weapon Class" 类型的变量(例如 BP_Rifle_Child),并且你尝试直接设置其变量,那么你的目标是类模板,而不是玩家手中处于激活状态的 Actor。即使你成功 Cast 到激活的 Actor 实例(例如当前装备的步枪)并将其 BaseDamage 从 25 修改为 50,该修改也是临时的(Transient)。

当玩家收起步枪、切换到手枪并再次装备步枪时,游戏会销毁旧的步枪 Actor 并 Spawn 一个新的 Actor。新 Actor 是直接从 CDO 模板全新 Spawn 出来的,这会将你升级后的 BaseDamage 重置回默认值 25。要让升级持久化,你必须将武器的属性数据(Stats)与在世界中 Spawn 的视觉 Actor 进行解耦。

持久化武器系统的架构设计

为了解决这个问题,我们必须将 State(武器的属性数据)与 Presentation(渲染武器并处理弹道生成的 Actor)进行分离。我们不再将权威的伤害、换弹速度和弹药容量存储在武器 Actor 自身内部,而是将它们存储在一个持久化的数据结构中。该结构应当存在于一个生命周期跨越 Actor 销毁的类中,例如 APlayerStateAGameState 或自定义的 Inventory 组件。

对于 Multiplayer 游戏,在自定义组件中存储 these 变量需要仔细管理 Ownership。如果你将武器 State 存储在自定义的 ActorComponent 中,请确保不会因为 Replication 过程中 ActorComponent 的 Ownership 不匹配,从而陷入 Multiplayer Inventory 噩梦。通过将权威数据保留在 APlayerState 中,可以确保即使玩家死亡、切换关卡或更换武器,数据也能得到妥善保存。

当玩家打开升级商店时,UI Widget 会直接与玩家的数据 State 进行交互。购买升级会修改持久化 Struct,而不是武器 Actor。当玩家装备武器时,Character 类会 Spawn 武器 Actor,并立即使用玩家 State 中的数据 Struct 对其进行初始化。这保证了每个新 Spawn 的武器都能继承正确且已升级的属性(Stats)。

分步实现:解耦数据与 Actor 逻辑

让我们为这种解耦架构编写一个干净的 C++ 实现。我们将定义一个用于保存可升级数值的 FWeaponStats Struct,以及一个可以动态配置的基础武器类。

1. 定义 Weapon Stats Struct

首先,我们定义我们的数据结构。该结构对 Blueprint 开放,允许你的 UI Widget 和面向策划的子 Blueprint 轻松读取和写入属性(Stats)。

#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;
};

通过将武器属性封装在单个 FWeaponStats Struct 中,我们能非常方便地对其进行序列化、Replicate 和传递。与其管理五个独立的 Replicated float 变量,我们只需要 Replicate 单个 Struct,从而降低了 Replication 开销。

2. 创建 Base Weapon 类

接下来,我们创建基础武器类 AWeaponBase。该类代表游戏世界中的物理 Actor,并包含一个使用新属性初始化自身的函数。

#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);
}

在初始化函数中,我们将传入的属性赋值给 Replicated 的 WeaponStats 变量。在实际开发中,你还可以在此处触发视觉或功能上的调整——例如调整弹匣网格体(Magazine Mesh)的大小、修改射速定时器,或者根据新属性修改粒子系统的缩放。相比于每次属性变化都重新加载一个 20MB 的 Blueprint 资产,传递一个 48 字节的 C++ Struct 可以节省海量的内存开销。

3. 将升级商店 Widget 与 Player State 进行集成

为了权威地管理升级,UI 商店菜单应该与 PlayerState 进行交互,而不是尝试直接 Cast 到临时的 Actor 实例。让我们设计 AShooterPlayerState 类来管理玩家属性并对升级进行验证。

#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);
}

AShooterPlayerState 类管理玩家的货币和他们的武器属性 Inventory。通过利用 RPC(Server_UpgradeWeapon)和 Replication,服务器将作为玩家属性的唯一权威信源(Single Source of Truth)。

请注意 Server_UpgradeWeapon_Validate 函数。Unreal 的 RPC 验证系统会自动踢出发送无效请求的 Client,例如尝试在金币不足的情况下升级武器。

一旦服务器处理了升级,它就会扣除金币、更新属性,并将更改 Replicate 回 Client。这种 Replication 会通过 Replication 回调自动触发 Client 端的 UI 更新。

防止升级商店中的 Client 端漏洞利用

如果你的游戏是 Multiplayer 游戏,或者你希望保护单人游戏经济系统免受内存修改器的侵害,你就不能信任 Client 端来管理其自身的升级。如果允许 Client 端的 UI Widget 调用 Server_UpgradeWeapon(int32 NewDamage),黑客可以轻易拦截网络数据包,或使用类似 Cheat Engine 的工具发送数据包,声称其武器伤害为 999,999。

如果没有严格的服务器权威验证,你的游戏将面临破坏游戏体验的 Multiplayer 状态 desync问题,即 Client 端的本地 UI 认为自己拥有了升级后的武器,但服务器仍在根据原始的 1 级属性计算伤害。为了防止这种情况,Client 端必须仅发送升级的意图(Intent),例如 Server_RequestUpgrade(FName WeaponID),然后由服务器权威地执行该交易。

服务器验证流程必须遵循以下步骤:

  1. 验证资源:检查玩家是否确实有足够的金币或材料来购买升级。
  2. 验证升级路径:确认所请求的升级是否按顺序进行(例如,从 2 级升级到 3 级,而不是直接跳到 10 级)。
  3. 扣除消耗并应用:在服务器上扣除货币并更新玩家的持久化属性 Struct。
  4. Replicate 与同步:将更新后的 Struct Replicate 回 Client,这会自动更新已装备的武器。

将升级持久化到 Backend 数据库

虽然在单场比赛中将属性保存在 PlayerState 中可行,但一旦玩家关闭游戏或服务器重启,这些属性就会被清空。为了构建真正的 Progression 循环,你必须将这些动态变量的修改保存到持久化的 Backend 数据库中。

手动构建这套系统是一项巨大的工程。你需要配置 SQL 或 NoSQL 数据库、使用 OAuth2 认证建立 API 网关、实现自定义服务器逻辑以解析 JSON Payload,并处理连接超时和数据库分片等边缘情况。这种基础设施的搭建很容易耗费 4 到 6 周的专属开发时间,从而分散你打磨核心 Gameplay 循环的精力。

这正是 horizOn 的用武之地,它让你无需编写 Backend 数据库代码,即可将玩家的 Progression 数据安全地存储在云端。你可以使用 Backend SDK 将你的 FWeaponStats Struct 序列化为 JSON,并利用服务器权威的安全规则将其直接保存到玩家的云端 Profile 中。一个典型的武器属性 JSON Payload 小于 500 字节(标准配置下约为 320 字节),这意味着数据库操作可在 15 毫秒内执行完毕。这一速度能够保持 UI 切换的流畅性,确保玩家在打开商店菜单或完成交易时不会遇到卡顿。

例如,你可以在 Backend 编写一个安全的 Cloud Code 函数来验证升级购买。当玩家在 Widget 菜单中点击“购买升级”时,游戏会发送一个安全的 API 请求。数据库会验证玩家的 Inventory、扣除代币、写入新的武器等级,并返回更新后的属性 Payload。这确保了即使玩家修改了本地内存,权威的云端数据库依然安全无虞。

Unreal Engine 升级系统的 Best Practices

为了确保你的升级商店既稳定又高效,请遵循以下核心原则:

  1. 切勿在 Runtime 修改 Class Default Objects (CDOs):将 CDO 视为只读的 Blueprint。仅将其用于 Spawn 默认的视觉 Mesh 和初始模板。
  2. 将属性与 Actor 解耦:将权威的属性存储在持久化类(如 APlayerStateGameInstance)中,并在 Spawn 时将其传递给 Actor。
  3. 强制 Server Authority:切勿让 Client 直接决定属性变更。应由 Client 请求升级,由服务器验证资源并应用更改。
  4. 使用 Struct 进行序列化:将可升级的属性归类到 USTRUCT 中。这使得本地保存或 Backend 数据库调用的序列化变得天衣无缝。
  5. 优化数据库 Payload:保持玩家 Profile 的精简。数据库中仅存储升级等级(例如 WeaponLevel: 3),并在游戏服务器上重构实际的 float 数值(例如 Damage: 45.0f)。

总结与后续步骤

解决子 Blueprint 上的动态变量更新问题,需要将设计思路从“以 Actor 为中心”转变为“以数据为中心”。通过将属性存储在 PlayerState 的持久化 Struct 中并动态初始化武器,可以防止武器切换时升级消失。当你准备将该 Progression 系统推进至 Multiplayer 并进行云端保护时,使用 Backend 数据库作为支撑是必不可少的。

准备好扩展你的 Progression 系统并保护玩家的 Inventory 了吗?免费试用 horizOn 或查阅 API 文档,即刻开始构建你的 Backend 吧。


来源:How to edit the values of a blueprint class that is a child and that is already in use by the player?