
第22回 UE5ぷちコン 振り返り ネットワークランキング編
この記事は「第22回 UE5ぷちコン 振り返り こだわり編」の続編となります。事前に、第22回 UE5ぷちコン 振り返り こだわり編 読んでおいて頂けると、記事の内容をより一層楽しむことができると思います。
それでは、「超人アスリート遊園地」の技術的にアピールしたい部分の解説をさせて頂きます。
技術編はそれぞれの解説が長文になりそうなので、何回かに分けて解説していきます。
UE5側のネットワークランキング機能実装
本作品ではネットワークに接続した状態であれば、自身のスコアをネットワークランキングにアップロードすることができるように、Rest APIサーバを構築しました。構築時間短縮の為に、お手軽に使えるLaravelフレームワークを使用し、ランキング取得・登録のみの単純なAPIサーバとして構築しております。
UnrealEngineからAPIサーバへの接続は、HTTPBlueprintプラグインを使用していたのですが、そもそもBeta版であったり、接続タイムアウト値の指定ができないので作品提出まではHTTPBlueprintで実装しておき、ゲーム配布までにC++で作りなおす事にしました。


Unreal Engine C++でのHTTP通信の実装は、HTTP通信の基盤となるクラス、ネットワークランキング用のクラス、BPからアクセスする為のBlueprintFunctionLibraryの3層構成で作成しています。
まずは、HTTP通信の基盤となるクラスMyHttpUtilityです。
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"#include "MyHttpUtility.h"#include "HttpModule.h"#include "Interfaces/IHttpRequest.h"#include "Interfaces/IHttpResponse.h"#include "Http.h"
using OnHttpResponse = TFunction<void(const bool, const EHttpRequestStatus::Type, const int32, const FString&)>;
/** * */class PETITCON22TH_API MyHttpUtility{public:
// コンストラクタ MyHttpUtility();
// デストラクタ ~MyHttpUtility();
// ベースURLの設定 void SetBaseUrl(FString url);
// タイムアウト値の設定 void SetTimeout(float timeoutSec);
// ヘッダーの設定 void SetHeader(FString key, FString value); void ClearHeaders();
// Getリクエスト void Get(FString path, OnHttpResponse onHttpResponse);
// Postリクエスト void Post(FString path, FString body, OnHttpResponse onHttpResponse);
// Putリクエスト void Put(FString path, FString body, OnHttpResponse onHttpResponse);
private: FString baseUrl; // ベースURL float timeout; // タイムアウト値 TMap<FString, FString> headers; // ヘッダー};// Fill out your copyright notice in the Description page of Project Settings.
#include "MyHttpUtility.h"
/** * @brief Construct a new My Http Utility:: My Http Utility object * */MyHttpUtility::MyHttpUtility(){}
/** * @brief Destroy the My Http Utility:: My Http Utility object * */MyHttpUtility::~MyHttpUtility(){}
/** * @brief 基底URLの設定 * * @param url */void MyHttpUtility::SetBaseUrl(FString url){ this->baseUrl = url;}
/** * @brief タイムアウト値の設定 * * @param timeout */void MyHttpUtility::SetTimeout(float timeoutSec){ this->timeout = timeoutSec;}
/** * @brief ヘッダーの設定(追加) * * @param key * @param value */void MyHttpUtility::SetHeader(FString key, FString value){ this->headers.Add(key, value);}
/** * @brief ヘッダーのクリア * */void MyHttpUtility::ClearHeaders(){ this->headers.Empty();}
/** * @brief Getリクエスト送信 * * @param path * @param OnResponse */void MyHttpUtility::Get(FString path, OnHttpResponse onHttpResponse){ // リクエストの生成 TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest(); Request->SetURL(this->baseUrl + path); Request->SetVerb("GET"); for (auto& header : this->headers) { Request->SetHeader(header.Key, header.Value); } Request->SetTimeout(this->timeout);
// レスポンスの設定 Request->OnProcessRequestComplete().BindLambda([onHttpResponse](FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { if (Response.IsValid()) { onHttpResponse(bWasSuccessful, Request->GetStatus(), Response->GetResponseCode(), Response->GetContentAsString()); } else { onHttpResponse(bWasSuccessful, Request->GetStatus(), -1, ""); } });
// リクエストの送信 Request->ProcessRequest();}
/** * @brief Postリクエスト送信 * * @param path * @param body * @param OnResponse */void MyHttpUtility::Post(FString path, FString body, OnHttpResponse onHttpResponse){ // リクエストの生成 TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest(); Request->SetURL(this->baseUrl + path); Request->SetVerb("POST"); for (auto& header : this->headers) { Request->SetHeader(header.Key, header.Value); } Request->SetContentAsString(body); Request->SetTimeout(this->timeout);
// レスポンスの設定 Request->OnProcessRequestComplete().BindLambda([onHttpResponse](FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { if (Response.IsValid()) { onHttpResponse(bWasSuccessful, Request->GetStatus(), Response->GetResponseCode(), Response->GetContentAsString()); } else { onHttpResponse(bWasSuccessful, Request->GetStatus(), -1, ""); } });
// リクエストの送信 Request->ProcessRequest();}
/** * @brief Putリクエスト送信 * * @param path * @param body * @param OnResponse */void MyHttpUtility::Put(FString path, FString body, OnHttpResponse onHttpResponse){ // リクエストの生成 TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest(); Request->SetURL(this->baseUrl + path); Request->SetVerb("PUT"); for (auto& header : this->headers) { Request->SetHeader(header.Key, header.Value); } Request->SetContentAsString(body); Request->SetTimeout(this->timeout);
// レスポンスの設定 Request->OnProcessRequestComplete().BindLambda([onHttpResponse](FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { if (Response.IsValid()) { onHttpResponse(bWasSuccessful, Request->GetStatus(), Response->GetResponseCode(), Response->GetContentAsString()); } else { onHttpResponse(bWasSuccessful, Request->GetStatus(), -1, ""); } });
// リクエストの送信 Request->ProcessRequest();}次にネットワークランキングの取得・登録を管理するクラスです。サーバとの認証情報がある為、一部ボカしています。
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"#include "MyHttpUtility.h"#include "HttpModule.h"#include "Interfaces/IHttpRequest.h"#include "Interfaces/IHttpResponse.h"#include "Http.h"#include "MyHttpUtility.h"
using OnNetworkRankingResponse = TFunction<void(const int32, const FString&)>;using OnNetworkRankingConnectionError = TFunction<void()>;using OnNetworkRankingUnauthorized = TFunction<void()>;using OnNetworkRankingOtherError = TFunction<void()>;
/** * */class PETITCON22TH_API MyNetworkRankingUtility{public: MyNetworkRankingUtility(); ~MyNetworkRankingUtility();
static const FString baseUrl; static const float getTimeout; static const float putTimeout;
void GetNetworkRanking(FString UserID, FString attractionID, FString order, OnNetworkRankingResponse onResponse, OnNetworkRankingConnectionError onConnectionError, OnNetworkRankingUnauthorized onUnauthorized, OnNetworkRankingOtherError onOtherError); void PutNetworkRanking(FString UserID, FString attractionID, FString name, float score, int star, OnNetworkRankingResponse onResponse, OnNetworkRankingConnectionError onConnectionError, OnNetworkRankingUnauthorized onUnauthorized, OnNetworkRankingOtherError onOtherError);};// Fill out your copyright notice in the Description page of Project Settings.#include "MyNetworkRankingUtility.h"
const FString MyNetworkRankingUtility::baseUrl = "APIサーバのURL";const float MyNetworkRankingUtility::getTimeout = 10.0f;const float MyNetworkRankingUtility::putTimeout = 10.0f;
/** * @brief Construct a new My Network Ranking Utility:: My Network Ranking Utility object * */MyNetworkRankingUtility::MyNetworkRankingUtility(){}
/** * @brief Destroy the My Network Ranking Utility:: My Network Ranking Utility object * */MyNetworkRankingUtility::~MyNetworkRankingUtility(){}
/** * @brief ネットワークランキング取得 * * @param UserID * @param attractionID * @param order * @param onResponse * @param onConnectionError * @param onUnauthorized * @param onOtherError */void MyNetworkRankingUtility::GetNetworkRanking(FString UserID, FString attractionID, FString order, OnNetworkRankingResponse onResponse, OnNetworkRankingConnectionError onConnectionError, OnNetworkRankingUnauthorized onUnauthorized, OnNetworkRankingOtherError onOtherError){ // 認証情報の生成 // ここには認証情報作成処理が入ります
// リクエスト発行 MyHttpUtility httpUtility = MyHttpUtility(); httpUtility.SetBaseUrl(MyNetworkRankingUtility::baseUrl); httpUtility.SetHeader("Content-Type", "application/json"); httpUtility.SetHeader("ユーザーIDのヘッダー名", UserID); httpUtility.SetHeader("認証キーのヘッダー名", Authorization); httpUtility.SetTimeout(MyNetworkRankingUtility::getTimeout); httpUtility.Get("api/ranking/" + attractionID + "/" + order, [onResponse, onConnectionError, onUnauthorized, onOtherError](const bool bWasSuccessful, const EHttpRequestStatus::Type Status, const int32 code, const FString& Response) { // レスポンス処理 if (bWasSuccessful) { if (code <= 200 && code < 300) { onResponse(code, Response); } else if (code == 401) { onUnauthorized(); } else { onOtherError(); } } else { if (Status == EHttpRequestStatus::Failed_ConnectionError){ onConnectionError(); } else if (code == 401) { onUnauthorized(); } else { onOtherError(); } } });}
/** * @brief ネットワークランキング登録 * * @param UserID * @param attractionID * @param name * @param score * @param star * @param onResponse * @param onConnectionError * @param onUnauthorized * @param onOtherError */void MyNetworkRankingUtility::PutNetworkRanking(FString UserID, FString attractionID, FString name, float score, int star, OnNetworkRankingResponse onResponse, OnNetworkRankingConnectionError onConnectionError, OnNetworkRankingUnauthorized onUnauthorized, OnNetworkRankingOtherError onOtherError){ // 認証情報の生成 // ここには認証情報の作成処理が入ります
// リクエストヘッダー生成 MyHttpUtility httpUtility = MyHttpUtility(); httpUtility.SetBaseUrl(MyNetworkRankingUtility::baseUrl); httpUtility.SetHeader("Content-Type", "application/json"); httpUtility.SetHeader("ユーザーIDのヘッダー名", UserID); httpUtility.SetHeader("認証キーのヘッダー名", Authorization); httpUtility.SetTimeout(MyNetworkRankingUtility::putTimeout);
// リクエスト生成(Json) FString PutBodyJson = ""; TSharedPtr<FJsonObject> PutBodyJsonObj = MakeShareable(new FJsonObject()); PutBodyJsonObj->SetStringField("attraction_id", attractionID); PutBodyJsonObj->SetStringField("user_name", name); PutBodyJsonObj->SetStringField("score", FString::Printf(TEXT("%.2f"), score)); PutBodyJsonObj->SetStringField("star", FString::Printf(TEXT("%d"), star)); TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&PutBodyJson); FJsonSerializer::Serialize(PutBodyJsonObj.ToSharedRef(), Writer);
// リクエスト発行 httpUtility.Put("api/ranking", PutBodyJson, [onResponse, onConnectionError, onUnauthorized, onOtherError](const bool bWasSuccessful, const EHttpRequestStatus::Type Status, const int32 code, const FString& Response) { // レスポンス処理 if (bWasSuccessful) { if (code <= 200 && code < 300) { onResponse(code, Response); } else if (code == 401) { onUnauthorized(); } else { onOtherError(); } } else { if (Status == EHttpRequestStatus::Failed_ConnectionError){ onConnectionError(); } else if (code == 401) { onUnauthorized(); } else { onOtherError(); } } });}後はBlueprintFunctionLibraryからランキング管理クラス経由でHTTP通信クラスの処理を呼び出すだけです。
ランキング表示は以下のような感じで、結果を1件ずつ取得してWidgetに追加しているだけです。



最後にScrollWidgetIntoViewノードを呼び出すことで、特定のランキングを画面中央に表示する位置まで自動的にスクロールさせることができます。
ランキングが膨大になりそうならページングの処理などを入れていけばいいと思います。(API側の実装はページングに対応させているので、BPを少しいじるだけで実装できるはず)
なお、ランキング登録用のロジックは、登録データをノードに渡しているだけです。

サーバー側API処理の実装
次にサーバーサイドの実装を紹介いたします。
まずはDBの設計ですが、今回は簡易的な実装なのでテーブルも簡易的です。Laravelにはマイグレーションというテーブル生成機能がありますので、そちらでランキングに必要なデータを洗い出してテーブルを生成してもらいました。(user_idはUE側でランダムに生成されたUUIDを元に作るサーバーサイド管理用IDです)

ランキング情報の取得は単純なSelect文です。(fromとcount変数でページングにも対応)
/** * ランキング情報の取得 * @param Request $request * @param $attractionId * @return \Illuminate\Http\JsonResponse */ public function get(Request $request, $attractionId, $order, $from = null, $count = null) { // ソート順確定 $rankOrder = "asc"; if ($order == "asc"){ $rankOrder = "asc"; } else { $rankOrder = "desc"; }
// ランキング情報の取得 $rankings = \App\Models\TrnAttractionRanking::where("attraction_id", $attractionId)->orderBy("score", $rankOrder)->orderBy("created_at", "asc"); if ($from !== null){ $rankings->offset($from-1); } if ($count !== null){ $rankings->limit($count); } $rankings = $rankings->get();
// ランキング情報の整形 $rankingList = []; $rank = 1; foreach ($rankings as $ranking){ $rankingList[] = [ "attraction_id" => $ranking->attraction_id, "user_id" => $ranking->user_id, "user_name" => $ranking->user_name, "score" => number_format($ranking->score, 2), "star" => $ranking->star, "rank" => $rank ]; $rank++; }
return response()->json([ "ranking" => $rankingList ]); }ランキング登録処理も渡されたデータを登録しているだけです。
本来であれば入力値チェック等はしっかり作るべきですが、今回は簡易サーバなのでお許しを。
/** * ランキング情報の更新 * @param Request $request * @return \Illuminate\Http\JsonResponse */ public function put(Request $request) { // リクエストパラメータの取得 $content = $request->getContent(); $requestJson = json_decode($content, true) ?? []; if (!isset($requestJson["attraction_id"]) || !isset($requestJson["user_name"]) || !isset($requestJson["score"])){ return response()->json([ "result" => "NG" ]); } // リクエストパラメータの取得 $attractionId = $requestJson["attraction_id"]; $userName = $requestJson["user_name"]; $score = $requestJson["score"]; $star = $requestJson["star"];
// ランキング登録 try { // ランキング情報の更新 $ranking = new \App\Models\TrnAttractionRanking(); $ranking->attraction_id = $attractionId; $ranking->user_id = /* UE側のUUIDをサーバ管理IDに変換するロジック */; $ranking->user_name = $userName; $ranking->score = $score; $ranking->star = $star; $ranking->save(); } catch (\Exception $e){ // ランキング情報の更新に失敗した場合 return response()->json([ "result" => "NG" ]); }
// ランキング情報の更新に成功した場合 return response()->json([ "result" => "OK" ]); }なお、認証処理についてはLaravelのMiddlewareで作りこんでいる為、認証情報が正常に渡ってこないと実処理にはたどり着けないようになっています。
ネットワークランキング機能を実装してみて
今回、自前でネットワークランキング機能を実装してみましたが、APIサーバの実装が2時間程度、UE5側(クライアント側)もHTTPBlueprintプラグインを使用するだけなら、数時間で実装する事が出来ました。
ただ、それ以外にもネームエントリー画面を作ったり、ランキングのリストに自身のスコアを差し込む処理を作ったりと、細々した実装が増えてしまい、最終的に想像していたよりも実装時間が掛かってしまったのですが、それでも簡単にランキング機能を組み込むことができました。
プレイヤー同士が緩い繋がりで盛り上がることができるので、これだけの労力でネットワークランキング機能が実装できるなら、どんどん使っていきたいと思います。
注意点
ネットワークランキング機能を実装する上での注意点として、サーバが停止してもゲーム本編には影響がないようにしなければなりません。その為にはサーバと通信できなかった場合の処理は真面目に作っておく必要があります。
タイムアウト値等適切な値を設定したり、エラーの種類(サーバが止まっているのか、認証エラーなのか、サーバには繋がったけど処理でエラーが発生したのか等)を正確に判別する仕組みが必要となりますので実装の難易度は若干高いかと思います。
今回は様々な技術に挑戦できるUE5ぷちコンという土俵に甘えさせて頂き実装にチャレンジしてみましたが、色々と注意しなければならない点が多いという事に気づかされました。
この情報が皆様の参考になればと思います。