لماذا يفشل تعديل Blueprint Defaults أثناء الـ Runtime: تصميم متجر ترقيات آمن في Unreal Engine
باختصار
يشرح هذا المقال أسباب فشل تعديل Blueprint class variables مباشرة أثناء الـ Runtime في Unreal Engine بسبب طبيعة الـ Class Default Object (CDO). ويقدم المقال حلاً معمارياً يعتمد على فصل إحصاءات الأسلحة عن الـ visual actors وتخزين البيانات بشكل مستمر في الـ `PlayerState` لضمان استمرار الترقيات عند تبديل الأسلحة. كما يستعرض المقال أفضل ممارسات التحقق من صحة الترقيات على جانب السيرفر لمنع ثغرات الـ client-side وحفظ تقدم اللاعبين بشكل آمن في قاعدة بيانات backend سحابية باستخدام [horizOn](https://horizon.pm).
تقضي أسابيع في بناء نظام أسلحة modular في Unreal Engine، وتقوم بإنشاء نسخة (instantiate) من child blueprint لبندقية الرشاش الخاصة بك، وتصمم واجهة متجر UMG أنيقة لترقية الـ reload speed الخاص بها، لتكتشف بعد كل هذا أن الترقيات تختفي تماماً بمجرد أن يقوم اللاعب بتبديل السلاح أو إعادة تشغيل المرحلة. والأسوأ من ذلك، عندما تحاول عمل cast لـ Blueprint class في الـ widget menu الخاصة بك وتعديل الـ variables، لا يحدث أي شيء. تعد هذه المشكلة عقبة شهيرة للمطورين الذين يبنون الـ progression systems.
في هذا البرنامج التعليمي، سنشرح بالتفصيل لماذا تفشل تعديلات الـ Blueprint أثناء الـ Runtime، وكيفية تصميم نظام أسلحة persistent، وكيفية تخزين تلك الترقيات بشكل آمن في قاعدة البيانات.
المشكلة الأساسية: لماذا يفشل تعديل Blueprint Class Defaults أثناء الـ Runtime
عندما تقوم بتعديل الـ variables في Blueprint داخل Unreal Editor، فإنك تقوم بتعديل الـ Class Default Object (CDO). يعمل الـ CDO كقالب أساسي (master template) لكل instance من ذلك الـ class يتم عمل spawn له في عالم اللعبة. ولكن أثناء الـ Runtime، يتم تقييد تعديل الـ CDO بشكل مباشر وصارم للغاية، وذلك لسبب وجيه. فإذا قمت بتغيير متغير في الـ CDO، فإنك تخاطر بتعديل القيمة الافتراضية لجميع عمليات الـ spawn المستقبلية، مما يسبب مشاكل في الـ serialization ويؤدي إلى تعطيل الـ replication.
الخطأ الأكثر شيوعاً هو محاولة عمل cast لمرجع class (TSubclassOf<AActor>) إلى instance من الـ class. فإذا كانت الـ widget menu الخاصة بك تحتوي على variable من نوع "Weapon Class" (مثل BP_Rifle_Child) وحاولت تعيين متغيراته مباشرة، فإنك تستهدف الـ class template، وليس الـ active actor النشط بين يدي اللاعب. وحتى لو نجحت في عمل cast لـ instance الـ actor النشط (على سبيل المثال، بندقية الرشاش المجهزة حالياً) وقمت بتغيير الـ BaseDamage من 25 إلى 50، فإن هذا التغيير سيكون مؤقتاً (transient).
في اللحظة التي يضع فيها اللاعب بندقية الرشاش جانباً، ويتحول إلى مسدس، ثم يجهز بندقية الرشاش مرة أخرى، تقوم اللعبة بتدمير (destroy) الـ rifle actor القديم وتقوم بعمل spawn لـ actor جديد. يتم إنشاء الـ actor الجديد نظيفاً من قالب الـ CDO، مما يعيد تعيين الـ BaseDamage المطوّر إلى القيمة الافتراضية 25. لجعل الترقيات مستمرة (persist)، يجب عليك فصل (decouple) إحصاءات السلاح عن الـ visual actor الذي يتم إنشاؤه في العالم.
بنية نظام أسلحة Persistent
لحل هذه المشكلة، يجب علينا فصل الـ State (إحصاءات السلاح) عن الـ Presentation (الـ actor الذي يعرض السلاح ويتعامل مع spawn المقذوفات). بدلاً من تخزين قيم الـ damage، والـ reload speed، وسعة الذخيرة (ammo capacity) المعتمدة داخل الـ weapon actor نفسه، نقوم بتخزينها في هيكل بيانات مستمر (persistent data structure). يجب أن يتواجد هذا الهيكل في class يستمر عبر تدمير الـ actors، مثل APlayerState أو AGameState أو custom inventory component.
بالنسبة لألعاب الـ multiplayer، فإن تخزين هذه الـ variables في custom component يتطلب إدارة دقيقة للـ ownership. فإذا قمت بتخزين الـ weapon states في ActorComponent مخصص، فتأكد من عدم مواجهة كوابيس المخزون في الـ multiplayer الناتجة عن عدم تطابق ملكية الـ actor component أثناء الـ replication. من خلال وضع البيانات المعتمدة (authoritative data) في الـ APlayerState، فإنك تضمن الحفاظ على البيانات حتى عندما يموت اللاعب، أو يغير المراحل، أو يستبدل أسلحته.
عندما يفتح اللاعب متجر الترقيات، تتفاعل واجهة الـ UI widget مباشرة مع الـ data state الخاصة باللاعب. يؤدي شراء الترقية إلى تعديل الـ persistent struct، وليس الـ weapon actor. وعندما يجهز اللاعب سلاحاً، يقوم الـ character class بإنشاء الـ weapon actor (spawn) وتهيئة السلاح فوراً باستخدام الـ data struct من الـ state الخاصة باللاعب. يضمن هذا أن يرث كل سلاح تم إنشاؤه حديثاً الإحصاءات الصحيحة والمطوّرة.
التنفيذ خطوة بخطوة: فصل البيانات عن منطق الـ Actor
لنكتب تنفيذاً نظيفاً بلغة C++ لهذه البنية المنفصلة (decoupled architecture). سنقوم بتعريف struct باسم FWeaponStats يحتفظ بالقيم القابلة للترقية، و class سلاح أساسي (base weapon class) يمكن تهيئته ديناميكياً.
1. تعريف الـ Weapon Stats Struct
أولاً، نقوم بتعريف هيكل البيانات الخاص بنا. هذا الهيكل يمكن الوصول إليه من الـ Blueprint (Blueprint-accessible)، مما يسمح لواجهات الـ UI widgets و الـ child blueprints المخصصة للمصممين بقراءة وكتابة الإحصاءات بسهولة.
#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;
};
من خلال تضمين (encapsulating) إحصاءات السلاح في struct واحد FWeaponStats، نجعل من السهل جداً عمل serialize، و replicate، وتمريره في الكود. بدلاً من إدارة خمسة متغيرات float منفصلة ومعمل لها replication، فإننا نقوم بعمل replication لـ struct واحد، مما يقلل من الـ replication overhead.
2. إنشاء الـ Base Weapon Class
بعد ذلك، نقوم بإنشاء الـ base weapon class AWeaponBase. يمثل هذا الـ class الـ physical actor في العالم ويحتوي على دالة (function) لتهيئة نفسه بإحصاءات جديدة.
#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);
}
في الـ initialization function، نقوم بتعيين الإحصاءات الممررة إلى متغير WeaponStats الذي تم عمل replication له. وفي سيناريوهات التطوير الفعلية، يمكنك أيضاً تشغيل تعديلات مرئية أو وظيفية هنا — مثل تغيير حجم الـ magazine mesh، أو تعديل الـ fire rate timers، أو تعديل أحجام الـ particle systems بناءً على الإحصاءات الجديدة. بدلاً من إعادة تحميل blueprint asset بحجم 20 ميجابايت في كل مرة تتغير فيها إحصائية ما، فإن تمرير C++ struct بحجم 48 بايت يوفر الـ memory overhead بشكل هائل.
3. ربط الـ Upgrade Shop Widget بالـ Player State
لإدارة الترقيات بشكل معتمد (authoritatively)، يجب أن تتفاعل واجهة متجر الترقيات (UI shop menu) مع الـ PlayerState بدلاً من محاولة عمل cast مباشرة لـ instances الـ actor المؤقتة (transient). لنقم بتصميم الـ AShooterPlayerState class لإدارة إحصاءات اللاعب والتحقق من صحة الترقيات.
#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 class عملة اللاعب ومخزون إحصاءات الأسلحة الخاص به. باستخدام الـ RPCs (Server_UpgradeWeapon) والـ replication، يظل السيرفر هو الـ source of truth الوحيد لإحصاءات اللاعب.
لاحظ دالة Server_UpgradeWeapon_Validate. يقوم نظام التحقق من صحة RPC في Unreal تلقائياً بطرد (kick) الـ clients الذين يرسلون طلبات غير صالحة، مثل محاولة ترقية سلاح دون وجود ذهب كافٍ.
بمجرد أن يعالج السيرفر الترقية، فإنه يخصم الذهب، ويحدث الإحصاءات، ويعيد عمل replication للتغييرات إلى الـ client. هذا الـ replication يقوم بتشغيل الـ client-side UI updates تلقائياً عبر الـ replication callbacks.
منع ثغرات الـ Client-Side في متجر الترقيات الخاص بك
إذا كانت لعبتك لعبة multiplayer، أو إذا كنت تريد حماية اقتصاد ألعاب الـ single-player من الـ memory editors، فلا يمكنك الوثوق بالـ client لإدارة الترقيات الخاصة به. فإذا سُمح لواجهة الـ client-side UI widget باستدعاء Server_UpgradeWeapon(int32 NewDamage)، يمكن للهاكر بسهولة اعتراض الـ network packets أو استخدام أدوات مثل Cheat Engine لإرسال حزمة تدعي أن ضرر السلاح هو 999,999.
دون وجود عملية تحقق صارمة ومعتمدة على السيرفر (server-authoritative validation)، ستعاني لعبتك من multiplayer state desyncs التي قد تؤدي إلى تعطل اللعبة تماماً، حيث تعتقد الـ local UI لدى الـ client أنه يمتلك سلاحاً مطوراً، بينما يقوم السيرفر بمعالجة الضرر بناءً على إحصاءات المستوى الأول الأصلية. لمنع ذلك، يجب على الـ client إرسال نية (intent) الترقية فقط، مثل Server_RequestUpgrade(FName WeaponID). بعد ذلك، يقوم السيرفر بتنفيذ المعاملة بشكل معتمد (authoritatively).
يجب أن يتبع الـ server validation flow الخطوات التالية:
- التحقق من الموارد (Verify Resources): التحقق مما إذا كان اللاعب يمتلك بالفعل ما يكفي من الذهب أو الـ scrap لشراء الترقية.
- التحقق من مسار الترقية (Validate Upgrade Path): التأكد من أن الترقية المطلوبة هي التالية في الترتيب (على سبيل المثال، من المستوى 2 إلى المستوى 3، وليس القفز مباشرة إلى المستوى 10).
- خصم التكلفة والتطبيق (Deduct Cost and Apply): خصم العملة على السيرفر وتحديث الـ persistent stats struct الخاص باللاعب.
- الـ Replication والمزامنة (Replicate and Sync): عمل replication للـ struct المحدث وإرساله مرة أخرى للـ client، مما يؤدي إلى تحديث السلاح المجهز تلقائياً.
حفظ الترقيات في قاعدة بيانات الـ Backend
بينما يعمل حفظ الإحصاءات في الـ PlayerState أثناء مباراة واحدة، إلا أن تلك الإحصاءات تختفي تماماً بمجرد إغلاق اللاعب للعبة أو إعادة تشغيل السيرفر. لإنشاء الـ progression loop الحقيقية، يجب عليك حفظ هذه التغييرات الديناميكية للمتغيرات في قاعدة بيانات backend مستمرة (persistent backend database).
إن بناء هذا بنفسك يدوياً هو مهمة ضخمة للغاية. ستحتاج إلى توفير قاعدة بيانات SQL أو NoSQL، وإعداد API gateway مع مصادقة OAuth2، وكتابة منطق سيرفر مخصص لمعالجة الـ JSON payloads، والتعامل مع حالات خاصة مثل الـ connection timeouts وتجزئة قاعدة البيانات (database sharding). يمكن أن يستغرق إعداد هذه البنية التحتية بسهولة من 4 إلى 6 أسابيع من التطوير المخصص، مما يبعد تركيزك عن تحسين وصقل الـ core gameplay loop الخاص بلعبتك.
وهنا يأتي دور منصة horizOn، حيث تتيح لك تخزين بيانات تقدم اللاعب بشكل آمن في السحابة دون الحاجة لكتابة كود لقاعدة بيانات الـ backend. يمكنك استخدام الـ SDK الخاص بالـ backend لعمل serialize للـ struct الخاص بك FWeaponStats إلى JSON وحفظه مباشرة في الـ cloud profile الخاص باللاعب مع قواعد أمان معتمدة على السيرفر (server-authoritative security rules). حجم الـ JSON payload لإحصاءات السلاح النموذجية أقل من 500 بايت (حوالي 320 بايت للتجهيز القياسي)، مما يعني أن عمليات قاعدة البيانات تنفذ في أقل من 15 مللي ثانية. تحافظ هذه السرعة على سلاسة انتقالات واجهة المستخدم (UI transitions) وتضمن عدم مواجهة اللاعبين لأي تقطيع (stuttering) عند فتح قوائم المتجر أو إتمام المعاملات.
على سبيل المثال، يمكنك كتابة دالة Cloud Code آمنة في الـ backend للتحقق من صحة عملية شراء الترقية. عندما ينقر اللاعب على "Buy Upgrade" في الـ widget menu الخاصة بك، ترسل اللعبة طلب API آمناً. تتحقق قاعدة البيانات من مخزون اللاعب، وتخصم العملة، وتكتب مستوى السلاح الجديد، وتُرجع الـ updated stat payload. يضمن ذلك أنه حتى لو قام لاعب باختراق الـ local memory الخاصة به، فإن الـ authoritative cloud database تظل آمنة.
أفضل الممارسات لأنظمة الترقيات في Unreal Engine
لضمان أن متجر الترقيات الخاص بك مستقر وذو أداء عالٍ، اتبع هذه المبادئ الأساسية:
- لا تقم أبداً بتعديل Class Default Objects (CDOs) أثناء الـ Runtime: تعامل مع الـ CDOs على أنها blueprints للقراءة فقط. استخدمها فقط لعمل spawn للـ visual meshes الافتراضية والقوالب الأولية.
- افصل الإحصاءات عن الـ Actors (Decouple Stats from Actors): قم بتخزين الإحصاءات المعتمدة في class مستمر مثل
APlayerStateأوGameInstance، ومررها إلى الـ actor عند الـ spawning. - فرض سلطة السيرفر (Enforce Server Authority): لا تسمح أبداً للـ clients بفرض تغييرات الإحصاءات مباشرة. يطلب الـ clients الترقيات، بينما يتحقق السيرفر من الموارد ويطبق التغيير.
- استخدم الـ Structs للـ Serialization: اجمع الإحصاءات القابلة للترقية في USTRUCTs. يجعل هذا عملية الـ serialization للحفظ المحلي أو استدعاءات قاعدة بيانات الـ backend سلسة تماماً.
- تحسين حمولات قاعدة البيانات (Optimize Database Payloads): حافظ على خفة الـ player profiles. قم بتخزين مستويات الترقيات فقط (مثل:
WeaponLevel: 3) في قاعدة البيانات، وقم بإعادة بناء قيم الـ floats الفعلية (مثل:Damage: 45.0f) على سيرفر اللعبة.
الخلاصة والخطوات التالية
يتطلب حل مشكلة تحديثات الـ variables الديناميكية على الـ child blueprints الانتقال من تصميم متمحور حول الـ actor إلى تصميم متمحور حول البيانات (data-centric design). من خلال تخزين الإحصاءات في persistent structs على الـ PlayerState وتهيئة أسلحتك ديناميكياً، فإنك تمنع اختفاء الترقيات عند تبديل الأسلحة. وعندما تكون مستعداً لنقل الـ progression system هذا لطور اللعب الجماعي (multiplayer) وتأمينه في السحاب، فإن دعمه بقاعدة بيانات backend يعد أمراً أساسياً.
هل أنت مستعد لتوسيع نطاق الـ progression systems وتأمين مخازن اللاعبين؟ جرب horizOn مجاناً أو راجع وثائق الـ API للبدء في بناء الـ backend الخاص بك اليوم.