Powrót do Bloga

Jak zaprojektować architekturę prototypu lokalnej strzelanki co-op w Unreal Engine (Krok po kroku)

Opublikowano 20 lutego 2026
Jak zaprojektować architekturę prototypu lokalnej strzelanki co-op w Unreal Engine (Krok po kroku)

Tworzenie prototypu lokalnej gry wieloosobowej w trybie co-op to jeden z najszybszych sposobów na walidację głównej pętli rozgrywki (core gameplay loop). Kiedy masz dwóch graczy na tej samej kanapie, dzielących ten sam ekran, od razu wiesz, czy mechanika strzelania daje satysfakcję i czy projekt poziomów zachęca do współpracy.

Jednak budowa lokalnej strzelanki wieloosobowej w Unreal Engine jest pełna ukrytych pułapek architektonicznych. Jeśli zahardkodujesz swoje wejścia (inputs), powiążesz UI z "Player 0" lub zignorujesz zasady replikacji od pierwszego dnia, twój szybki weekendowy prototyp stanie się nieskalowalnym bałaganem, który będzie wymagał setek godzin refaktoryzacji, gdy ostatecznie przejdziesz na tryb wieloosobowy online.

Zainspirowany niedawnym samouczkiem społecznościowym na temat budowy prototypu strzelanki co-op w kilka godzin, ten przewodnik rozkłada na czynniki pierwsze dokładne kroki techniczne, aby zaprojektować solidne fundamenty lokalnego trybu wieloosobowego w Unreal Engine. Omówimy programistyczne spawnowanie graczy, dynamiczne współdzielone kamery oraz sposób strukturyzacji danych, aby móc płynnie skalować grę z kanapowego co-opa do trwałego trybu wieloosobowego online.

Step 1: Zrozumienie architektury lokalnego trybu wieloosobowego w Unreal Engine

Zanim napiszesz jakikolwiek kod, musisz zrozumieć, jak Unreal Engine obsługuje wielu graczy na jednej maszynie.

W standardowej grze dla jednego gracza masz jedną UGameInstance, która przechowuje jeden UWorld, który zawiera jednego ULocalPlayer. Ten lokalny gracz jest kontrolowany przez APlayerController, który z kolei kontroluje twoją postać APawn.

W lokalnym trybie wieloosobowym hierarchia ulega zmianie. UGameInstance pozostaje singletonem, ale teraz zarządza tablicą obiektów ULocalPlayer. Każdy ULocalPlayer otrzymuje swój własny APlayerController.

Największym błędem popełnianym przez programistów jest zakładanie, że GetWorld()->GetFirstPlayerController() zadziała dla logiki gry. W lokalnym co-opie poleganie na indeksie 0 oznacza, że Player 2 zostanie całkowicie zignorowany przez stan gry, aktualizacje UI i wyzwalacze środowiskowe.

Step 2: Programistyczne spawnowanie lokalnych graczy

Chociaż możesz włączyć split-screen w Project Settings Unreala i pozwolić silnikowi na automatyczne spawnowanie graczy po podłączeniu drugiego gamepada, poleganie na tym zachowaniu daje ci zerową kontrolę nad procesem spawnowania, wyborem postaci czy przypisaniem ekwipunku.

Zamiast tego powinieneś obsługiwać tworzenie instancji graczy ręcznie w swoim AGameModeBase.

Oto solidna implementacja w C++ do dynamicznego spawnowania drugiego lokalnego gracza, gdy naciśnie przycisk "Start" na drugim gamepadzie:

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

Kontrolując tworzenie instancji za pomocą CreateLocalPlayer, możesz przechwycić proces spawnowania, aby przypisać unikalne modele postaci lub broń startową na podstawie ekranu wyboru postaci.

Step 3: Opanowanie matematyki współdzielonej kamery ekranowej

Dla strzelanki co-op z widokiem z góry lub izometrycznym, split-screen często psuje wierność wizualną i ogranicza obszar gry. Dynamiczna współdzielona kamera — spopularyzowana przez gry takie jak Helldivers czy Diablo — utrzymuje wszystkich graczy na jednym ekranie, obliczając ich średnią pozycję i dynamicznie oddalając widok.

Aby to zbudować, potrzebujesz dedykowanego ACameraActor, który nie jest przypisany do żadnego konkretnego gracza. Zamiast tego kamera ta aktualizuje się (ticks) co klatkę, znajdując bounding box wszystkich aktywnych graczy.

Oto jak obliczyć punkt centralny i dynamiczną długość przybliżenia:

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

Ta logika zapewnia, że kamera płynnie śledzi akcję. Funkcje VInterpTo i FInterpTo są tutaj kluczowe; bez nich kamera będzie agresywnie przeskakiwać, gdy gracz zginie lub się zrespawnuje, powodując u graczy poważną chorobę lokomocyjną.

Step 4: Przetrwanie pułapki UI "Player 0"

Jeden z najbardziej frustrujących błędów w rozwoju lokalnego trybu wieloosobowego dotyczy interfejsów użytkownika.

Kiedy tworzysz widget za pomocą standardowego węzła Blueprint Create Widget (lub CreateWidget<UUserWidget>(GetWorld(), WidgetClass) w C++), Unreal domyślnie przypisuje własność pierwszemu lokalnemu graczowi (Indeks 0).

Jeśli Player 2 podniesie amunicję, a twoja logika UI zaktualizuje HUD należący do Player 0, zamiga zły licznik amunicji. Co gorsza, jeśli użyjesz AddToViewport(), widget jest renderowany globalnie, często nakładając się na siebie lub ignorując granice split-screena.

Aby to naprawić, zawsze przekazuj konkretny Player Controller jako obiekt posiadający podczas tworzenia widgetów:

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

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

AddToPlayerScreen() zapewnia, że jeśli kiedykolwiek przełączysz się ze współdzielonej kamery na split-screen, UI poprawnie ograniczy się do kwadrantu tego konkretnego gracza na monitorze.

Step 5: Punkt bólu — Skalowanie stanu lokalnego do trwałości online

Prototypy lokalnego trybu wieloosobowego są niezwykle zwodnicze. Ponieważ obaj gracze istnieją w tej samej przestrzeni pamięci na tej samej maszynie, nie musisz martwić się o opóźnienia sieci, utratę pakietów czy autorytet serwera (server authority). Możesz bezpośrednio modyfikować zdrowie Player 2 za pomocą pocisku Player 1.

Jednak w momencie, gdy zdecydujesz się przenieść ten prototyp do sieci lub po prostu chcesz zapisać postępy gracza (takie jak odblokowane bronie lub wysokie wyniki) w różnych sesjach gry, architektura się załamuje.

Jeśli zapisujesz dane gracza lokalnie za pomocą obiektów USaveGame, dane te są powiązane z fizyczną maszyną. Jeśli Player 2 pójdzie do domu i kupi twoją grę, jego postępy znikną. Aby to rozwiązać, musisz oddzielić stan gracza od lokalnej maszyny i przenieść go do chmurowego backendu.

Zbudowanie tego samemu wymaga skonfigurowania load balancerów, shardingu bazy danych i zarządzania certyfikatami SSL — to łatwo 4-6 tygodni pracy tylko po to, aby uruchomić bezpieczne logowanie gracza i system ekwipunku. Dzięki horizOn te usługi Backend-as-a-Service są wstępnie skonfigurowane, co pozwala ci wydać grę zamiast infrastruktury.

Kierując profile graczy, ekwipunki i dane sesji przez backendowe API na wczesnym etapie rozwoju, upewniasz się, że "Player 2" jest uwierzytelnionym użytkownikiem z trwałymi danymi, a nie tylko przejściowym lokalnym gościem. Kiedy będziesz gotowy do wdrożenia matchmakingu online, horizOn zapewnia gotowe systemy lobby, które płynnie przenoszą twoich lokalnych graczy co-op do szerszych sesji online.

Najlepsze praktyki dla prototypowania Co-Op

Aby upewnić się, że twój prototyp pozostanie skalowalny i wydajny, przestrzegaj tych zasad architektonicznych od pierwszego dnia:

  1. Udawaj, że to online: Zawsze używaj frameworka replikacji Unreal Engine (HasAuthority(), RPC Server_ i UPROPERTY(Replicated)), nawet jeśli budujesz tylko lokalny prototyp. Traktowanie lokalnej maszyny jako Listen Server od pierwszego dnia skraca czas refaktoryzacji trybu wieloosobowego nawet o 80% w późniejszym czasie.
  2. Izoluj Input Actions: Używając Enhanced Input System, mapuj swoje zasoby UInputAction na logiczne intencje rozgrywki (np. "FireWeapon"), a nie na przyciski sprzętowe. Pozwala to na dynamiczne przemapowanie Klawiatury/Myszy na Player 1 i Gamepada na Player 2 bez hardkodowania indeksów.
  3. Z gracją obsługuj odłączenia kontrolera: Zawsze binduj do FCoreDelegates::OnControllerConnectionChange. Jeśli kontroler Player 2 padnie, twoja gra powinna automatycznie się zatrzymać i poprosić o ponowne połączenie, zamiast zostawiać jego postać bezczynnie stojącą w trakcie strzelaniny.
  4. Używaj Instanced Static Meshes dla pocisków: W strzelance co-op dwóch graczy strzelających z broni o dużej szybkostrzelności może spawnować setki pocisków na sekundę. Zastąp standardowe pociski oparte na Actorach systemami cząsteczkowymi Niagara lub UInstancedStaticMeshComponent, aby zmniejszyć wywołania rysowania (draw calls) z ~2000 do ~400 w ciężkich scenach walki.

Budowa lokalnej strzelanki co-op to niezwykle satysfakcjonujące wyzwanie techniczne. Prawidłowo strukturyzując spawnowanie graczy, matematykę kamery i trwałość danych od samego początku, upewniasz się, że twój prototyp jest gotowy do skalowania do pełnoprawnego wydania.


Source: Community Tutorial: Coop Local Multiplayer Shooter Prototype