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

كيفية تصميم بنية نموذج أولي للعبة إطلاق نار تعاونية محلية في Unreal Engine (خطوة بخطوة)

نُشر في 20 فبراير 2026
كيفية تصميم بنية نموذج أولي للعبة إطلاق نار تعاونية محلية في Unreal Engine (خطوة بخطوة)

تعد النمذجة الأولية للعبة متعددة اللاعبين تعاونية محلية واحدة من أسرع الطرق للتحقق من حلقة اللعب الأساسية (core gameplay loop) الخاصة بك. عندما يكون لديك لاعبان على نفس الأريكة، يتشاركان نفس الشاشة، ستعرف على الفور ما إذا كانت آليات إطلاق النار لديك ذات تأثير قوي وما إذا كان تصميم المستوى الخاص بك يشجع على العمل الجماعي.

ومع ذلك، فإن بناء لعبة إطلاق نار محلية متعددة اللاعبين في Unreal Engine محفوف بالفخاخ المعمارية الخفية. إذا قمت ببرمجة مدخلاتك بشكل ثابت (hardcode)، أو ربطت واجهة المستخدم (UI) الخاصة بك بـ "Player 0"، أو تجاهلت مبادئ النسخ المتماثل (replication) من اليوم الأول، فإن نموذجك الأولي السريع الذي بنيته في عطلة نهاية الأسبوع سيصبح فوضى غير قابلة للتطوير تتطلب مئات الساعات من إعادة الهيكلة (refactoring) عندما تنتقل في النهاية إلى اللعب الجماعي عبر الإنترنت.

مستوحى من برنامج تعليمي مجتمعي حديث حول بناء نموذج أولي للعبة إطلاق نار تعاونية في بضع ساعات، يحلل هذا الدليل الخطوات التقنية الدقيقة لتصميم أساس قوي للعب الجماعي المحلي في Unreal Engine. سنغطي التوليد البرمجي للاعبين (programmatic player spawning)، والكاميرات المشتركة الديناميكية، وكيفية هيكلة بياناتك بحيث يمكنك التوسع بنظافة من اللعب التعاوني المحلي إلى اللعب الجماعي المستمر عبر الإنترنت.

Step 1: فهم بنية اللعب الجماعي المحلي في Unreal Engine

قبل كتابة أي كود، يجب أن تفهم كيف يتعامل Unreal Engine مع لاعبين متعددين على جهاز واحد.

في لعبة اللاعب الفردي القياسية، لديك UGameInstance واحد، والذي يحمل UWorld واحد، والذي يحتوي على ULocalPlayer واحد. هذا اللاعب المحلي يتم التحكم فيه (possessed) بواسطة APlayerController، والذي بدوره يتحكم في شخصيتك APawn.

في اللعب الجماعي المحلي، يتغير التسلسل الهرمي. يظل UGameInstance كـ singleton، ولكنه الآن يدير مصفوفة من كائنات ULocalPlayer. يحصل كل ULocalPlayer على APlayerController الخاص به.

أكبر خطأ يرتكبه المطورون هو افتراض أن GetWorld()->GetFirstPlayerController() سيعمل مع منطق اللعبة. في اللعب التعاوني المحلي، الاعتماد على الفهرس 0 يعني أن Player 2 سيتم تجاهله تمامًا بواسطة حالة اللعبة (game state)، وتحديثات واجهة المستخدم، والمشغلات البيئية (environmental triggers).

Step 2: توليد اللاعبين المحليين برمجيًا

بينما يمكنك تمكين الشاشة المنقسمة (split-screen) في Project Settings الخاصة بـ Unreal والسماح للمحرك بتوليد اللاعبين تلقائيًا عند توصيل لوحة ألعاب ثانية، فإن الاعتماد على هذا السلوك يمنحك تحكمًا صفريًا في عملية التوليد، أو اختيار الشخصية، أو تعيين العتاد (loadout).

بدلاً من ذلك، يجب عليك التعامل مع إنشاء اللاعبين يدويًا داخل AGameModeBase الخاص بك.

إليك تنفيذ C++ قوي لتوليد لاعب محلي ثانٍ ديناميكيًا عندما يضغطون على زر "Start" في لوحة ألعاب ثانية:

void ACoopGameMode::SpawnSecondPlayer()
{
    // Ensure we are running on the server/authority
    if (!HasAuthority())
    {
        return;
    }

    UGameInstance* GameInstance = GetWorld()->GetGameInstance();
    if (!GameInstance)
    {
        return;
    }

    FString ErrorMessage;
    // Create a new local player at index 1 (Player 2)
    // The 'true' boolean tells the engine to spawn a PlayerController automatically
    ULocalPlayer* NewLocalPlayer = GameInstance->CreateLocalPlayer(1, ErrorMessage, true);

    if (NewLocalPlayer)
    {
        UE_LOG(LogTemp, Log, TEXT("Successfully spawned Player 2. Controller ID: %d"), NewLocalPlayer->GetControllerId());
        
        // Optional: Force a specific spawn point for Player 2
        APlayerController* PC = NewLocalPlayer->GetPlayerController(GetWorld());
        if (PC && PC->GetPawn())
        {
            FVector P2SpawnLocation = FVector(100.0f, -100.0f, 50.0f);
            PC->GetPawn()->SetActorLocation(P2SpawnLocation);
        }
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to spawn Player 2: %s"), *ErrorMessage);
    }
}

من خلال التحكم في الإنشاء عبر CreateLocalPlayer، يمكنك اعتراض عملية التوليد لتعيين شبكات شخصيات (meshes) فريدة أو أسلحة بداية بناءً على شاشة اختيار الشخصية.

Step 3: إتقان رياضيات كاميرا الشاشة المشتركة

بالنسبة للعبة إطلاق نار تعاونية من أعلى إلى أسفل (top-down) أو متساوية القياس (isometric)، غالبًا ما تدمر الشاشة المنقسمة الدقة البصرية وتقيد منطقة اللعب. الكاميرا المشتركة الديناميكية — التي اشتهرت بألعاب مثل Helldivers أو Diablo — تبقي جميع اللاعبين على شاشة واحدة عن طريق حساب متوسط موقعهم والتصغير ديناميكيًا.

لبناء هذا، تحتاج إلى ACameraActor مخصص غير متصل بأي لاعب معين. بدلاً من ذلك، تقوم هذه الكاميرا بالتحديث (ticks) كل إطار، للعثور على المربع المحيط (bounding box) لجميع اللاعبين النشطين.

إليك كيفية حساب نقطة المركز وطول التكبير الديناميكي:

void ASharedCameraController::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    
    FVector AverageLocation = FVector::ZeroVector;
    float MaxDistance = 0.0f;
    int32 PlayerCount = 0;

    // Iterate through all active player controllers
    for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
    {
        APlayerController* PC = Iterator->Get();
        if (PC && PC->GetPawn())
        {
            FVector PlayerLoc = PC->GetPawn()->GetActorLocation();
            AverageLocation += PlayerLoc;
            PlayerCount++;

            // Calculate distance to find the farthest player from the center
            // (Requires a second pass in a real scenario, but simplified here for distance from origin)
            float DistFromOrigin = PlayerLoc.Size(); 
            if (DistFromOrigin > MaxDistance)
            {
                MaxDistance = DistFromOrigin;
            }
        }
    }

    if (PlayerCount > 0)
    {
        // Find the midpoint
        AverageLocation /= PlayerCount;
        
        // Smoothly interpolate the camera's target location
        FVector NewLocation = FMath::VInterpTo(GetActorLocation(), AverageLocation, DeltaTime, 5.0f);
        SetActorLocation(NewLocation);

        // Dynamically adjust the SpringArm length based on player spread
        // Assuming 'CameraSpringArm' is a valid USpringArmComponent pointer
        float TargetZoom = FMath::Clamp(MaxDistance * 1.5f, 1000.0f, 3000.0f);
        CameraSpringArm->TargetArmLength = FMath::FInterpTo(CameraSpringArm->TargetArmLength, TargetZoom, DeltaTime, 3.0f);
    }
}

يضمن هذا المنطق أن الكاميرا تتتبع الحركة بسلاسة. وظائف VInterpTo و FInterpTo بالغة الأهمية هنا؛ بدونها، ستنتقل الكاميرا بقوة عندما يموت لاعب أو يولد من جديد، مما يسبب دوار حركة شديد للاعبيك.

Step 4: النجاة من فخ واجهة المستخدم "Player 0"

واحدة من أكثر الأخطاء المحبطة في تطوير اللعب الجماعي المحلي تتعلق بواجهات المستخدم.

عندما تقوم بإنشاء عنصر واجهة مستخدم (widget) باستخدام عقدة Blueprint القياسية Create Widget (أو CreateWidget<UUserWidget>(GetWorld(), WidgetClass) في C++)، يقوم Unreal افتراضيًا بتعيين الملكية للاعب المحلي الأول (الفهرس 0).

إذا التقط Player 2 ذخيرة، وقام منطق واجهة المستخدم الخاص بك بتحديث الـ HUD المملوك لـ Player 0، فسيومض عداد الذخيرة الخاطئ. والأسوأ من ذلك، إذا كنت تستخدم AddToViewport()، يتم عرض عنصر واجهة المستخدم عالميًا، وغالبًا ما يتداخل أو يتجاهل حدود الشاشة المنقسمة.

لإصلاح ذلك، قم دائمًا بتمرير Player Controller المحدد ككائن مالك عند إنشاء عناصر واجهة المستخدم:

// CORRECT: Assigning ownership to the specific player
UUserWidget* PlayerHUD = CreateWidget<UUserWidget>(SpecificPlayerController, HUDWidgetClass);

// Use AddToPlayerScreen instead of AddToViewport for local multiplayer
PlayerHUD->AddToPlayerScreen();

يضمن AddToPlayerScreen() أنه إذا قمت بالتبديل من كاميرا مشتركة إلى شاشة منقسمة، فإن واجهة المستخدم ستقيد نفسها بشكل صحيح بالربع الخاص بذلك اللاعب المحدد على الشاشة.

Step 5: نقطة الألم — توسيع الحالة المحلية إلى الاستمرارية عبر الإنترنت

النماذج الأولية للعب الجماعي المحلي خادعة بشكل لا يصدق. نظرًا لأن كلا اللاعبين موجودان في نفس مساحة الذاكرة على نفس الجهاز، فلا داعي للقلق بشأن زمن انتقال الشبكة (network latency)، أو فقدان الحزم (packet loss)، أو سلطة الخادم (server authority). يمكنك تعديل صحة Player 2 مباشرة من مقذوف Player 1.

ومع ذلك، في اللحظة التي تقرر فيها أخذ هذا النموذج الأولي عبر الإنترنت، أو ترغب ببساطة في حفظ تقدم اللاعب (مثل الأسلحة غير المقفلة أو الدرجات العالية) عبر جلسات لعب مختلفة، تنهار البنية.

إذا قمت بحفظ بيانات اللاعب محليًا باستخدام كائنات USaveGame، فإن تلك البيانات مرتبطة بالجهاز المادي. إذا عاد Player 2 إلى المنزل واشترى لعبتك، فسيختفي تقدمه. لحل هذه المشكلة، تحتاج إلى فصل حالة اللاعب (player state) عن الجهاز المحلي ونقلها إلى واجهة خلفية سحابية (cloud backend).

يتطلب بناء هذا بنفسك إعداد موازنات الحمل (load balancers)، وتقسيم قواعد البيانات (database sharding)، وإدارة شهادات SSL — بسهولة 4-6 أسابيع من العمل فقط للحصول على تسجيل دخول آمن للاعب ونظام مخزون يعمل. مع horizOn، تأتي خدمات Backend-as-a-Service هذه مجهزة مسبقًا، مما يتيح لك شحن لعبتك بدلاً من بنيتك التحتية.

من خلال توجيه ملفات تعريف اللاعبين، والعتاد، وبيانات الجلسة عبر واجهة برمجة تطبيقات خلفية (backend API) في وقت مبكر من التطوير، فإنك تضمن أن "Player 2" هو مستخدم مصادق عليه ببيانات مستمرة، بدلاً من مجرد ضيف محلي عابر. عندما تكون مستعدًا لتنفيذ التوفيق عبر الإنترنت (online matchmaking)، يوفر horizOn أنظمة ردهة (lobby systems) جاهزة للاستخدام تنقل لاعبي التعاون المحلي بسلاسة إلى جلسات أوسع عبر الإنترنت.

أفضل الممارسات للنمذجة الأولية التعاونية

لضمان بقاء نموذجك الأولي قابلاً للتطوير وعالي الأداء، التزم بهذه القواعد المعمارية من اليوم الأول:

  1. تظاهر بأنه متصل بالإنترنت: استخدم دائمًا إطار عمل النسخ المتماثل الخاص بـ Unreal Engine (HasAuthority()، و Server_ RPCs، و UPROPERTY(Replicated))، حتى لو كنت تبني نموذجًا أوليًا محليًا فقط. إن التعامل مع الجهاز المحلي كـ Listen Server من اليوم الأول يقلل من وقت إعادة هيكلة اللعب الجماعي بنسبة تصل إلى 80% لاحقًا.
  2. عزل إجراءات الإدخال (Input Actions): باستخدام Enhanced Input System، قم بتعيين أصول UInputAction الخاصة بك إلى نوايا اللعب المنطقية (مثل "FireWeapon")، وليس أزرار الأجهزة. يتيح لك هذا إعادة تعيين لوحة المفاتيح/الماوس ديناميكيًا إلى Player 1 ولوحة الألعاب إلى Player 2 دون برمجة الفهارس بشكل ثابت.
  3. التعامل مع انقطاع اتصال وحدة التحكم بلباقة: قم دائمًا بالربط بـ FCoreDelegates::OnControllerConnectionChange. إذا نفدت بطارية وحدة تحكم Player 2، يجب أن تتوقف لعبتك مؤقتًا تلقائيًا وتطلب إعادة الاتصال، بدلاً من ترك شخصيته واقفة مكتوفة الأيدي في تبادل لإطلاق النار.
  4. استخدم Instanced Static Meshes للمقذوفات: في لعبة إطلاق نار تعاونية، يمكن للاعبين يطلقان أسلحة ذات معدل إطلاق نار مرتفع أن يولدا مئات المقذوفات في الثانية. استبدل المقذوفات القياسية القائمة على Actor بـ UInstancedStaticMeshComponent أو أنظمة جزيئات Niagara لتقليل استدعاءات الرسم (draw calls) من ~2000 إلى ~400 في مشاهد القتال العنيفة.

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


Source: Community Tutorial: Coop Local Multiplayer Shooter Prototype