返回博客

如何在 Unreal Engine 中构建本地双人合作射击游戏原型(分步指南)

发布于 2026年2月20日
如何在 Unreal Engine 中构建本地双人合作射击游戏原型(分步指南)

制作本地合作多人游戏原型是验证核心玩法循环最快的方法之一。当两名玩家坐在同一张沙发上,共享同一个屏幕时,你立刻就能知道你的射击机制是否具有打击感,以及你的关卡设计是否鼓励团队合作。

然而,在 Unreal Engine 中构建本地多人射击游戏充满了隐藏的架构陷阱。如果你硬编码你的输入,将你的 UI 绑定到“Player 0”,或者从第一天起就忽略网络同步(replication)原则,你快速做出的周末原型将变成一个无法扩展的烂摊子,当你最终转向在线多人游戏时,需要数百小时来重构。

受最近一个关于在几小时内构建合作射击游戏原型的社区教程的启发,本指南详细分解了在 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: 程序化生成本地玩家

虽然你可以在 Unreal 的 Project Settings 中启用分屏(split-screen),并让引擎在连接第二个手柄时自动生成玩家,但依赖这种行为会让你对生成过程、角色选择或装备分配失去控制。

相反,你应该在你的 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: 掌握共享屏幕摄像机数学

对于自上而下或等距视角的合作射击游戏,分屏通常会破坏视觉保真度并限制游玩区域。动态共享摄像机——由《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);
    }
}

此逻辑确保摄像机平滑地跟踪动作。VInterpToFInterpTo 函数在这里至关重要;如果没有它们,当玩家死亡或重生时,摄像机会剧烈跳动,导致玩家产生严重的晕动症。

Step 4: 在“Player 0” UI 陷阱中生存

本地多人游戏开发中最令人沮丧的错误之一涉及用户界面。

当你使用标准 Blueprint 节点 Create Widget(或 C++ 中的 CreateWidget<UUserWidget>(GetWorld(), WidgetClass))创建小部件时,Unreal 默认将所有权分配给第一个本地玩家(索引 0)。

如果 Player 2 捡起弹药,而你的 UI 逻辑更新了属于 Player 0 的 HUD,错误的弹药计数器将会闪烁。更糟糕的是,如果你使用 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() 确保如果你从共享摄像机切换到分屏,UI 将正确地将自身限制在显示器上该特定玩家的象限内。

Step 5: 痛点 —— 将本地状态扩展到在线持久化

本地多人游戏原型极具欺骗性。因为两个玩家都存在于同一台机器的同一内存空间中,你不需要担心网络延迟、丢包或服务器权限。你可以直接通过 Player 1 的子弹修改 Player 2 的生命值。

然而,当你决定将这个原型上线,或者只是想在不同的游戏会话中保存玩家进度(如解锁的武器或高分)时,架构就会崩溃。

如果你使用 USaveGame 对象在本地保存玩家数据,该数据将绑定到物理机器上。如果 Player 2 回家并购买了你的游戏,他们的进度就没了。为了解决这个问题,你需要将玩家状态与本地机器解耦,并将其移动到云端后端。

自己构建这个需要设置负载均衡器、数据库分片和 SSL 证书管理——仅仅为了让安全的玩家登录和库存系统运行起来,就很容易需要 4-6 周的工作。使用 horizOn,这些 Backend-as-a-Service 服务是预先配置好的,让你能够发布你的游戏而不是你的基础设施。

通过在开发早期通过后端 API 路由你的玩家个人资料、装备和会话数据,你可以确保“Player 2”是一个具有持久化数据的经过身份验证的用户,而不仅仅是一个短暂的本地访客。当你准备好实现在线匹配时,horizOn 提供了开箱即用的大厅系统,可以将你的本地合作玩家无缝过渡到更广泛的在线会话中。

合作游戏原型的最佳实践

为了确保你的原型保持可扩展性和高性能,从第一天起就遵守这些架构规则:

  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