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: 共有画面カメラの計算をマスターする
トップダウンまたはアイソメトリックな協力シューターの場合、スプリットスクリーンはしばしば視覚的な忠実度を損ない、プレイエリアを制限します。Helldivers や Diablo のようなゲームで普及した動的共有カメラは、すべてのアクティブなプレイヤーの平均位置を計算し、動的にズームアウトすることで、すべてのプレイヤーを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);
}
}
このロジックにより、カメラがアクションをスムーズに追跡することが保証されます。ここでは VInterpTo と FInterpTo 関数が重要です。これらがないと、プレイヤーが死亡またはリスポーンしたときにカメラが激しくスナップし、プレイヤーに深刻な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プロトタイピングのベストプラクティス
プロトタイプがスケーラブルでパフォーマンスを維持できるように、最初から以下のアーキテクチャルールに従ってください。
- オンラインであるかのように振る舞う: ローカルプロトタイプを構築しているだけであっても、常にUnreal Engineのレプリケーションフレームワーク(
HasAuthority()、Server_RPC、およびUPROPERTY(Replicated))を使用してください。最初からローカルマシンをListen Serverとして扱うことで、後でのマルチプレイヤーリファクタリングの時間を最大80%削減できます。 - Input Actionsを分離する: Enhanced Input Systemを使用して、
UInputActionアセットをハードウェアボタンではなく、論理的なゲームプレイの意図(例:「FireWeapon」)にマッピングします。これにより、インデックスをハードコードすることなく、キーボード/マウスをPlayer 1に、ゲームパッドをPlayer 2に動的にリマップできます。 - コントローラーの切断を適切に処理する: 常に
FCoreDelegates::OnControllerConnectionChangeにバインドしてください。Player 2のコントローラーの電源が切れた場合、キャラクターを銃撃戦の中で棒立ちにさせるのではなく、ゲームを自動的に一時停止し、再接続を促す必要があります。 - 発射物にInstanced Static Meshesを使用する: 協力シューターでは、2人のプレイヤーが連射速度の高い武器を撃つと、1秒間に数百の発射物がスポーンする可能性があります。標準のActorベースの発射物を
UInstancedStaticMeshComponentまたはNiagaraパーティクルシステムに置き換えることで、激しい戦闘シーンでのドローコール(draw calls)を約2000から約400に減らすことができます。
ローカル協力シューターの構築は、非常にやりがいのある技術的課題です。最初からプレイヤースポーン、カメラの計算、データの永続性を正しく構造化することで、プロトタイプが本格的なリリースへとスケールする準備が整います。
Source: Community Tutorial: Coop Local Multiplayer Shooter Prototype