ブログに戻る

Unreal Engineでローカル協力プレイシューターのプロトタイプを設計する方法(ステップバイステップ)

公開日 2026年2月20日
Unreal Engineでローカル協力プレイシューターのプロトタイプを設計する方法(ステップバイステップ)

ローカル協力マルチプレイヤーゲームのプロトタイピングは、コアゲームプレイループを検証するための最も速い方法の1つです。2人のプレイヤーが同じソファに座り、同じ画面を共有しているとき、射撃メカニクスにインパクトがあるか、レベルデザインがチームワークを促しているかがすぐにわかります。

しかし、Unreal Engineでローカルマルチプレイヤーシューターを構築することには、隠れたアーキテクチャの罠が潜んでいます。入力をハードコードしたり、UIを「Player 0」に結合させたり、最初からレプリケーションの原則を無視したりすると、週末に作った簡単なプロトタイプは、最終的にオンラインマルチプレイヤーに移行する際に数百時間のリファクタリングを必要とする、拡張不可能な混乱状態に陥ります。

数時間で協力シューターのプロトタイプを構築するという最近のコミュニティチュートリアルに触発され、このガイドでは、Unreal Engineで堅牢なローカルマルチプレイヤーの基盤を設計するための正確な技術的ステップを解説します。プログラムによるプレイヤーのスポーン、動的な共有カメラ、そしてカウチCo-Opから永続的なオンラインマルチプレイヤーへときれいにスケールできるようにデータを構造化する方法について説明します。

Step 1: Unreal Engineのローカルマルチプレイヤーアーキテクチャを理解する

コードを書く前に、Unreal Engineが単一のマシン上で複数のプレイヤーをどのように処理するかを理解する必要があります。

標準的なシングルプレイヤーゲームでは、1つの UGameInstance があり、それが1つの UWorld を保持し、その中に1つの ULocalPlayer が含まれます。そのローカルプレイヤーは APlayerController に憑依(Possess)され、それが今度はキャラクターである APawn に憑依します。

ローカルマルチプレイヤーでは、この階層が変わります。UGameInstance はシングルトンのままですが、ULocalPlayer オブジェクトの配列を管理するようになります。各 ULocalPlayer は独自の APlayerController を取得します。

開発者が犯す最大のミスは、GetWorld()->GetFirstPlayerController() がゲームロジックで機能すると想定することです。ローカルCo-Opにおいてインデックス 0 に依存することは、Player 2がゲームステート、UIの更新、および環境トリガーから完全に無視されることを意味します。

Step 2: プログラムによるローカルプレイヤーのスポーン

UnrealのProject Settingsでスプリットスクリーン(分割画面)を有効にし、2つ目のゲームパッドを接続したときにエンジンにプレイヤーを自動スポーンさせることは可能ですが、この動作に依存すると、スポーンプロセス、キャラクター選択、またはロードアウトの割り当てをまったく制御できなくなります。

代わりに、AGameModeBase 内で手動でプレイヤーのインスタンス化を処理する必要があります。

以下は、2つ目のゲームパッドで「Start」ボタンを押したときに、2人目のローカルプレイヤーを動的にスポーンさせるための堅牢なC++実装です。

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: 共有画面カメラの計算をマスターする

トップダウンまたはアイソメトリックな協力シューターの場合、スプリットスクリーンはしばしば視覚的な忠実度を損ない、プレイエリアを制限します。HelldiversDiablo のようなゲームで普及した動的共有カメラは、すべてのアクティブなプレイヤーの平均位置を計算し、動的にズームアウトすることで、すべてのプレイヤーを1つの画面に収めます。

これを構築するには、特定のプレイヤーにアタッチされていない専用の ACameraActor が必要です。代わりに、このカメラは毎フレームTick処理を行い、すべてのアクティブなプレイヤーのバウンディングボックスを見つけます。

中心点と動的なズームの長さを計算する方法は次のとおりです。

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 関数が重要です。これらがないと、プレイヤーが死亡またはリスポーンしたときにカメラが激しくスナップし、プレイヤーに深刻な3D酔いを引き起こします。

Step 4: 「Player 0」UIの罠を生き抜く

ローカルマルチプレイヤー開発で最もイライラするバグの1つは、ユーザーインターフェースに関するものです。

標準の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: 痛点 — ローカルステートからオンラインの永続性へのスケーリング

ローカルマルチプレイヤーのプロトタイプは非常に欺瞞的です。両方のプレイヤーが同じマシンの同じメモリ空間に存在するため、ネットワークの遅延、パケットロス、またはサーバーの権限(Server Authority)について心配する必要がありません。Player 1の発射物からPlayer 2の体力を直接変更することができます。

しかし、このプロトタイプをオンラインに移行しようと決定した瞬間、または異なるプレイセッション間でプレイヤーの進行状況(アンロックされた武器やハイスコアなど)を保存したいと思った瞬間に、アーキテクチャは破綻します。

USaveGame オブジェクトを使用してプレイヤーデータをローカルに保存した場合、そのデータは物理マシンに結び付けられます。Player 2が家に帰ってあなたのゲームを購入しても、彼らの進行状況は消えています。これを解決するには、プレイヤーステートをローカルマシンから切り離し、クラウドバックエンドに移行する必要があります。

これを自分で構築するには、ロードバランサー、データベースのシャーディング、SSL証明書の管理を設定する必要があり、安全なプレイヤーログインとインベントリシステムを稼働させるだけで簡単に4〜6週間の作業になります。horizOnを使用すると、これらのBackend-as-a-Serviceが事前構成されているため、インフラストラクチャではなくゲームのリリースに集中できます。

開発の初期段階でバックエンドAPIを介してプレイヤープロファイル、ロードアウト、セッションデータをルーティングすることで、「Player 2」が単なる一時的なローカルゲストではなく、永続的なデータを持つ認証済みユーザーであることを保証します。オンラインマッチメイキングを実装する準備ができたとき、horizOnはローカルCo-Opプレイヤーをより広いオンラインセッションにシームレスに移行させる、すぐに使えるロビーシステムを提供します。

Co-Opプロトタイピングのベストプラクティス

プロトタイプがスケーラブルでパフォーマンスを維持できるように、最初から以下のアーキテクチャルールに従ってください。

  1. オンラインであるかのように振る舞う: ローカルプロトタイプを構築しているだけであっても、常にUnreal Engineのレプリケーションフレームワーク(HasAuthority()Server_ RPC、および 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を使用する: 協力シューターでは、2人のプレイヤーが連射速度の高い武器を撃つと、1秒間に数百の発射物がスポーンする可能性があります。標準のActorベースの発射物を UInstancedStaticMeshComponent またはNiagaraパーティクルシステムに置き換えることで、激しい戦闘シーンでのドローコール(draw calls)を約2000から約400に減らすことができます。

ローカル協力シューターの構築は、非常にやりがいのある技術的課題です。最初からプレイヤースポーン、カメラの計算、データの永続性を正しく構造化することで、プロトタイプが本格的なリリースへとスケールする準備が整います。


Source: Community Tutorial: Coop Local Multiplayer Shooter Prototype

このダッシュボードは以下のチームによって愛情を込めて作られています Projectmakers

© 2026 projectmakers.de

unknown-v1.91.1 / unknown-v--