العودة إلى المدونة

خطأ Multiplayer Sync في Unreal Engine الذي يفسد World States (وكيفية إصلاحه)

نُشر في 23 فبراير 2026
خطأ Multiplayer Sync في Unreal Engine الذي يفسد World States (وكيفية إصلاحه)

تقضي شهورًا في بناء تحول عالم سينمائي ضخم. في طور single-player، يتم التنفيذ بشكل مثالي. المنزل المسكون القديم يغرق تحت التضاريس، والنسخة الجديدة النقية ترتفع من الأعماق تمامًا في الوقت المحدد. ولكن في اللحظة التي يتصل فيها لاعب ثاني بالخادم، تتحول تحفتك الفنية إلى كابوس غير قابل للعب من التداخلات. تندمج المنازل. ينكسر الـ Collision. ويصبح لاعبوك عالقين في مطهر الهندسة.

كل مطور ألعاب indie multiplayer يصطدم في النهاية بجدار حيث تصطدم المنطق المرئي من جانب العميل (client-side) بعنف مع واقع Server-Authoritative. إذا كنت تحاول تحريك مئات الأصول باستخدام Cinematic Sequence Device أو رسوم متحركة timeline يتم تشغيلها بواسطة أحداث اللاعب المحلية، فأنت عمليًا تستدعي Unreal Engine Multiplayer Sync Bug.

في هذا البرنامج التعليمي، سنقوم بتحليل سبب تسبب تحريك كميات هائلة من الـ actors في حدوث desyncs كارثية، ولماذا تفشل الـ Cinematic Sequences مع الـ late-joiners، وكيفية تصميم World State Manager بنظام Server-Authoritative لا يقهر باستخدام C++ و Data Layers في Unreal Engine 5.

تشريح الـ Desync: لماذا تندمج منازلك

لإصلاح المشكلة، تحتاج أولاً إلى فهم الرياضيات وراء سبب اختناق نظام الـ Replication في Unreal Engine بسبب تسلسل التحول الخاص بك.

دعنا نفترض أن التسلسل الخاص بك يحرك ما يقرب من 450 أصلًا فرديًا (جدران، props، إضاءة) لتبديل "المنزل 1" بـ "المنزل 2". عندما تقوم بتحريك actor تم عمل replication له، يستخدم Unreal Engine هيكل FRepMovement لمزامنة موقعه ودورانه وسرعته عبر الشبكة.

تحديث الحركة المضغوط القياسي يكلف حوالي 40 إلى 50 بايت لكل actor.

إذا كان 450 actor يتحركون في وقت واحد خلال تسلسل سينمائي مدته 5 ثوانٍ، مع التحديث بمعدل متواضع يبلغ 30 مرة في الثانية، فإن الحساب يبدو كالتالي: 450 actors × 50 bytes × 30 updates/sec = 675,000 bytes per second (675 KB/s).

الـ MaxClientRate الافتراضي في Unreal Engine (الحد الأقصى للنطاق الترددي الذي يُسمح للخادم بإرساله إلى عميل واحد) عادة ما يكون محدودًا بين 15,000 و 100,000 بايت في الثانية.

يتطلب التسلسل الخاص بك ما يقرب من 7 أضعاف النطاق الترددي المتاح. تتشبع قناة الشبكة على الفور. يبدأ الخادم في تقييد التحديثات بقوة، وإسقاط الحزم، وإعطاء الأولوية لـ actors آخرين بناءً على NetPriority. ونتيجة لذلك، تتوقف نصف أصول المنزل 1 عن الحركة في منتصف الطريق تحت الأرض، ونصف أصول المنزل 2 لا تصل أبدًا إلى السطح. تترك مع فوضى مندمجة وغير متزامنة بشكل دائم.

علاوة على ذلك، إذا قمت بتشغيل هذا التسلسل محليًا عبر حدث من جانب العميل (مثل دخول لاعب في trigger box)، فإن اللاعب الذي ينضم إلى الخادم بعد 10 دقائق لن ينفذ التسلسل أبدًا. سيرى حالة الخريطة الافتراضية، بينما يرى اللاعب الأول الحالة المتحولة.

الخطوة 1: تخلص من التلاعب بالـ Transform واستخدم Data Layers

تحريك 450 actor هو نهج brute-force يهدر دورات وحدة المعالجة المركزية ونطاق الشبكة الترددي. في Unreal Engine 5، النهج المعماري الصحيح لتغييرات العالم الضخمة هو Data Layers (تطور Level Streaming).

بدلاً من تحريك "المنزل 1" تحت الأرض، تقوم بتعيين جميع أصول المنزل 1 لـ House1_DataLayer وجميع أصول المنزل 2 لـ House2_DataLayer. عندما يتغير الجدول الزمني، تقوم ببساطة بإلغاء تحميل الطبقة الأولى وتحميل الثانية.

هذا يزيل تمامًا عنق زجاجة النطاق الترددي. بدلاً من بث 675 كيلوبايت/ثانية من بيانات الحركة المستمرة، يرسل الخادم تحديث حالة واحدًا وصغيرًا: "Data Layer 2 نشطة الآن." يتولى المحرك المحلي للعميل عملية التحميل بسلاسة من القرص.

الخطوة 2: تصميم الـ Server-Authoritative State Manager

لضمان رؤية كل لاعب — بما في ذلك أولئك الذين ينضمون متأخرًا — لنفس الـ World State تمامًا، نحتاج إلى مصدر مركزي للحقيقة. سنقوم بإنشاء actor باسم WorldStateManager في C++ يستخدم متغير RepNotify لتتبع العصر الحالي للمنزل.

ملف الرأس (WorldStateManager.h)

نحتاج إلى Enum لتعريف حالاتنا، ومتغير Replicated مع شرط ReplicatedUsing.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Info.h"
#include "WorldDataLayers/WorldDataLayers.h"
#include "WorldStateManager.generated."

UENUM(BlueprintType)
enum class EWorldEraState : uint8
{
    Past_House1 UMETA(DisplayName = "Past (House 1)"),
    Future_House2 UMETA(DisplayName = "Future (House 2)")
};

UCLASS()
class MYGAME_API AWorldStateManager : public AInfo
{
    GENERATED_BODY()

public:
    AWorldStateManager();

    // The server-side function to trigger the transformation
    UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "World State")
    void AdvanceWorldEra();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    // The replicated variable tracking our current state
    UPROPERTY(ReplicatedUsing = OnRep_CurrentEra, Transient)
    EWorldEraState CurrentEra;

    // The RepNotify function that fires on clients when CurrentEra changes
    UFUNCTION()
    void OnRep_CurrentEra();

    // Helper to toggle Data Layers
    void UpdateDataLayers(EWorldEraState NewState);
};

ملف التنفيذ (WorldStateManager.cpp)

هنا يحدث السحر. لاحظ كيف نستخدم DOREPLIFETIME لتسجيل المتغير، وكيف تضمن وظيفة OnRep تطابق الحالة المرئية مع الحالة المنطقية.

#include "WorldStateManager.h"
#include "Net/UnrealNetwork.h"
#include "Engine/World.h"
#include "WorldPartition/DataLayer/DataLayerSubsystem.h"

AWorldStateManager::AWorldStateManager()
{
    bReplicates = true;
    bAlwaysRelevant = true; // Ensure all players always receive updates for this actor
    CurrentEra = EWorldEraState::Past_House1;
}

void AWorldStateManager::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
    // Replicate to all clients
    DOREPLIFETIME(AWorldStateManager, CurrentEra);
}

void AWorldStateManager::BeginPlay()
{
    Super::BeginPlay();
    
    // Ensure the initial state is set correctly on the server
    if (HasAuthority())
    { 
        UpdateDataLayers(CurrentEra);
    }
}

void AWorldStateManager::AdvanceWorldEra()
{
    // Only the server can change the era
    if (!HasAuthority()) return;

    CurrentEra = EWorldEraState::Future_House2;
    
    // The server updates its own local Data Layers immediately
    UpdateDataLayers(CurrentEra);
}

// This fires automatically on clients when the server changes CurrentEra
void AWorldStateManager::OnRep_CurrentEra()
{
    UpdateDataLayers(CurrentEra);
}

void AWorldStateManager::UpdateDataLayers(EWorldEraState NewState)
{
    UWorld* World = GetWorld();
    if (!World) return;

    UDataLayerSubsystem* DataLayerSubsystem = World->GetSubsystem<UDataLayerSubsystem>();
    if (!DataLayerSubsystem) return;

    // Pseudocode for Data Layer toggling - replace with your specific Data Layer Asset references
    if (NewState == EWorldEraState::Past_House1)
    {
        // Load House 1, Unload House 2
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Unloaded);
    }
    else if (NewState == EWorldEraState::Future_House2)
    {
        // Load House 2, Unload House 1
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Unloaded);
    }
}

الخطوة 3: حل مشكلة الـ Late-Joiner

أكبر خطأ يرتكبه المطورون عند محاولة إصلاح Unreal Engine Multiplayer Sync Bug هو استخدام Multicast RPCs (Remote Procedure Calls) لتشغيل أحداث العالم.

إذا استخدمت Multicast RPC لقول Multicast_PlayHouseTransformation()، فسيتم تنفيذه فقط على العملاء المتصلين حاليًا بالخادم في تلك الملي ثانية بالضبط. إذا تعطل جهاز لاعب وأعاد الاتصال بعد 30 ثانية، فقد فاته الـ RPC. سيقوم بتحميل الخريطة ويرى المنزل 1، بينما يرى الجميع المنزل 2.

باستخدام UPROPERTY(ReplicatedUsing = OnRep_CurrentEra)، نحل مشكلة الـ late-joiner تلقائيًا. عندما يتصل لاعب جديد، يرسل له الخادم القيمة الحالية لـ CurrentEra. نظرًا لأن القيمة التي يتلقاها (Future_House2) تختلف عن قيمته الافتراضية (Past_House1) ، يقوم Unreal Engine تلقائيًا بتشغيل OnRep_CurrentEra() لهذا العميل المحدد في اللحظة التي يتم فيها التحميل. يقومون على الفور بتحميل الـ Data Layer الصحيحة. لا حاجة لمنطق انضمام مخصص.

إذا كنت تبني نماذج أولية أصغر تعتمد على الجلسات، فراجع دليلنا حول How To Architect A Local Co Op Shooter Prototype In Unreal Engine Step By Step.

استمرار الـ World States بعد جلسة اللعبة

حل C++ أعلاه مثالي لمثيل خادم واحد قيد التشغيل. ولكن ماذا يحدث إذا تعطل خادمك؟ أو ماذا لو كنت تبني لعبة رعب بقاء مستمرة حيث يحتاج "العصر" إلى البقاء محفوظًا عبر أسابيع من اللعب، حتى عندما يسجل جميع اللاعبين خروجهم ويتوقف الخادم؟

هذا هو المكان الذي يفشل فيه الاعتماد فقط على الـ replication في الذاكرة الخاص بـ Unreal Engine. للاحتفاظ بـ World States العالمية، تحتاج إلى قاعدة بيانات backend.

يتطلب بناء هذا بنفسك إعداد قواعد بيانات PostgreSQL، وكتابة REST APIs للتعامل مع تسلسل الحالة، وإدارة مصادقة الخادم، وتكوين بنية تحتية لـ auto-scaling — بسهولة 4-6 أسابيع من العمل الممل في الـ backend.

مع horizOn، تأتي خدمات الـ backend هذه مسبقة التكوين. يمكنك دفع تغييرات حالة عالمك مباشرة إلى قاعدة بيانات Game State مدارة عبر SDK الخاص بنا. عندما يبدأ خادمك المخصص، فإنه ببساطة يستعلم من backend horizOn، ويسترجع {"CurrentEra": "Future_House2"}، ويقوم بتهيئة WorldStateManager ، ويستمر لاعبوك بسلاسة من حيث توقفوا تمامًا. يمكنك التركيز على تصميم لعبة الرعب الخاصة بك بدلاً من كتابة عمليات ترحيل قاعدة البيانات.

إذا كانت لعبتك تتطلب اتصالاً فوريًا ثنائي الاتجاه مع backend (على سبيل المثال، تشغيل أحداث live-ops التي تغير حالة العالم عالميًا دون الحاجة إلى تحديث)، فيجب عليك أيضًا قراءة تحليلنا حول Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends.

5 أفضل الممارسات لمزامنة حالة الـ Multiplayer

لضمان عدم مواجهة Unreal Engine Multiplayer Sync Bug كارثي مرة أخرى، قم بدمج هذه القواعد في معماريتك:

  1. لا تستخدم Sequences أبدًا للحالة المنطقية: يجب استخدام Cinematic Sequence Devices و Timelines بدقة للتأثيرات المرئية (VFX، اهتزاز الكاميرا، واجهة المستخدم المحلية). لا تعتمد أبدًا على انتهاء الجدول الزمني لتعيين متغير يؤثر على أسلوب اللعب.
  2. RPCs للأحداث، RepNotifies للحالة: استخدم Multicast RPCs للأحداث العابرة والمؤقتة (انفجار قنبلة، تشغيل صوت). استخدم متغيرات Replicated مع RepNotifies للحالات المستمرة والدائمة (باب مفتوح، منزل متحول، مولد يعمل).
  3. احترم حد النطاق الترددي: راقب network profiler الخاص بك (Stat Net). إذا كنت تقوم بعمل replication للـ transforms لأكثر من 50-100 actor في وقت واحد، فمن المحتمل أنك تشبع القناة. استخدم Network Dormancy (ENetDormancy::DORM_Initial) للأصول التي نادراً ما تتحرك.
  4. اضبط bAlwaysRelevant بدقة: لمديري الحالة العالميين (مثل AWorldStateManager الخاص بنا)، تأكد من أن bAlwaysRelevant = true. إذا وقع هذا الـ actor خارج مسافة الـ network cull للاعب، فسيتوقف عن تلقي التحديثات، مما يؤدي إلى desyncs محلية.
  5. سلطة الخادم (Server Authority) مطلقة: يجب على العملاء فقط إرسال "Requests" إلى الخادم (مثل Server_RequestInteract()). يقوم الخادم بالتحقق من صحة الطلب، وتحديث متغير Replicated، ويسمح لنظام الـ replication بنشر التغييرات المرئية لجميع العملاء.

توقف عن محاربة المحرك

تطوير ألعاب الـ Multiplayer صعب للغاية، ولكن 90% من أخطاء المزامنة تنبع من محاولة إجبار أدوات جانب العميل على القيام بمهام جانب الخادم. من خلال التبديل من التلاعب بالـ transform بطريقة brute-force إلى Data Layers، واستخدام RepNotifies بدلاً من المشغلات المحلية، فإنك توائم لعبتك مع بنية الشبكة المقصودة من Unreal Engine.

هل أنت مستعد لتوسيع نطاق الـ multiplayer backend الخاص بك والحفاظ على حالات عالمك دون صداع البنية التحتية؟ جرب horizOn مجانًا أو راجع API docs لمعرفة مدى سهولة دمج حالات السحاب المستمرة في مشروع Unreal الخاص بك.


المصدر: Houses Merged Weirdly HELPPPP