Назад к блогу

Как спроектировать архитектуру прототипа локального кооперативного шутера в Unreal Engine (Шаг за шагом)

Опубликовано 20 февраля 2026 г.
Как спроектировать архитектуру прототипа локального кооперативного шутера в Unreal Engine (Шаг за шагом)

Прототипирование локальной кооперативной многопользовательской игры — один из самых быстрых способов проверить ваш основной игровой цикл (core gameplay loop). Когда два игрока сидят на одном диване и делят один экран, вы сразу понимаете, ощущается ли механика стрельбы эффектной и поощряет ли дизайн уровней командную работу.

Однако создание локального многопользовательского шутера в Unreal Engine таит в себе скрытые архитектурные ловушки. Если вы жестко закодируете (hardcode) ввод, привяжете UI к «Player 0» или проигнорируете принципы репликации с первого дня, ваш быстрый прототип, собранный за выходные, превратится в немасштабируемый хаос, требующий сотен часов рефакторинга при переходе к онлайн-мультиплееру.

Вдохновленное недавним туториалом от сообщества по созданию прототипа кооперативного шутера за несколько часов, это руководство подробно описывает точные технические шаги для проектирования надежного фундамента локального мультиплеера в Unreal Engine. Мы рассмотрим программный спавн игроков, динамические общие камеры и то, как структурировать данные, чтобы вы могли легко масштабироваться от диванного кооператива до постоянного онлайн-мультиплеера.

Step 1: Понимание архитектуры локального мультиплеера Unreal Engine

Прежде чем писать какой-либо код, вы должны понять, как Unreal Engine обрабатывает нескольких игроков на одной машине.

В стандартной однопользовательской игре у вас есть один UGameInstance, который содержит один UWorld, в котором находится один ULocalPlayer. Этим локальным игроком управляет APlayerController, который, в свою очередь, управляет вашим персонажем APawn.

В локальном мультиплеере иерархия меняется. UGameInstance остается синглтоном, но теперь он управляет массивом объектов ULocalPlayer. Каждый ULocalPlayer получает свой собственный APlayerController.

Самая большая ошибка, которую совершают разработчики, — это предположение, что GetWorld()->GetFirstPlayerController() будет работать для игровой логики. В локальном кооперативе опора на индекс 0 означает, что Player 2 будет полностью игнорироваться состоянием вашей игры, обновлениями UI и триггерами окружения.

Step 2: Программный спавн локальных игроков

Хотя вы можете включить split-screen в Project Settings Unreal и позволить движку автоматически спавнить игроков при подключении второго геймпада, опора на это поведение дает вам нулевой контроль над процессом спавна, выбором персонажа или назначением снаряжения.

Вместо этого вам следует обрабатывать создание экземпляров игроков вручную внутри вашего 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, вы можете перехватить процесс спавна, чтобы назначить уникальные меши персонажей или стартовое оружие на основе экрана выбора персонажа.

Step 3: Освоение математики общей камеры экрана

Для кооперативного шутера с видом сверху или изометрией split-screen часто портит визуальную точность и ограничивает игровую зону. Динамическая общая камера — популяризированная такими играми, как 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: Выживание в ловушке UI «Player 0»

Один из самых неприятных багов в разработке локального мультиплеера связан с пользовательскими интерфейсами.

Когда вы создаете виджет с помощью стандартного узла Blueprint Create Widget (или CreateWidget<UUserWidget>(GetWorld(), WidgetClass) в C++), Unreal по умолчанию назначает владение первому локальному игроку (Индекс 0).

Если Player 2 подбирает патроны, а ваша логика UI обновляет HUD, принадлежащий Player 0, замигает неправильный счетчик патронов. Хуже того, если вы используете AddToViewport(), виджет рендерится глобально, часто перекрывая или игнорируя границы split-screen.

Чтобы исправить это, всегда передавайте конкретный 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() гарантирует, что если вы когда-нибудь переключитесь с общей камеры на split-screen, UI корректно ограничит себя квадрантом этого конкретного игрока на мониторе.

Step 5: Болевая точка — Масштабирование локального состояния до онлайн-персистентности

Прототипы локального мультиплеера невероятно обманчивы. Поскольку оба игрока существуют в одном и том же пространстве памяти на одной машине, вам не нужно беспокоиться о задержке сети, потере пакетов или авторитете сервера (server authority). Вы можете напрямую изменять здоровье Player 2 от снаряда Player 1.

Однако в тот момент, когда вы решите перевести этот прототип в онлайн или просто захотите сохранять прогресс игрока (например, разблокированное оружие или рекорды) между различными игровыми сессиями, архитектура рушится.

Если вы сохраняете данные игрока локально с помощью объектов USaveGame, эти данные привязаны к физической машине. Если Player 2 пойдет домой и купит вашу игру, его прогресс исчезнет. Чтобы решить эту проблему, вам нужно отвязать состояние вашего игрока от локальной машины и переместить его в облачный бэкенд.

Создание этого самостоятельно требует настройки балансировщиков нагрузки, шардинга баз данных и управления SSL-сертификатами — это легко 4-6 недель работы только для того, чтобы запустить безопасный вход игрока и систему инвентаря. С horizOn эти услуги Backend-as-a-Service поставляются предварительно настроенными, позволяя вам выпускать свою игру, а не инфраструктуру.

Маршрутизируя профили игроков, снаряжение и данные сессий через бэкенд API на ранних этапах разработки, вы гарантируете, что «Player 2» является аутентифицированным пользователем с постоянными данными, а не просто временным локальным гостем. Когда вы будете готовы внедрить онлайн-матчмейкинг, horizOn предоставит готовые системы лобби, которые плавно переведут ваших локальных кооперативных игроков в более широкие онлайн-сессии.

Лучшие практики для кооперативного прототипирования

Чтобы ваш прототип оставался масштабируемым и производительным, придерживайтесь этих архитектурных правил с первого дня:

  1. Притворитесь, что это онлайн: Всегда используйте фреймворк репликации Unreal Engine (HasAuthority(), RPC Server_ и 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