はじめに

今年もQiita Unreal Engine Advent Calendarの季節がやってきました。毎年、様々な知見を公開してくださっている方々には感謝しております。

さて、今回のアドカレですが、スマホのWebブラウザをUnrealEngineのコントローラーとして使う方法を解説させて頂きます。

これを作ってみようと思った理由は2つありまして、1つ目は「気軽にゲームに乱入できる仕組み」を作ってみたかったという理由になります。例えばゲーム実況者が生配信でゲームをプレイしているところに視聴者が前準備なしでゲームに何等かの影響を与えることができたら面白そうだな!と思いました。

2つ目は(例えば)立体視ディスプレイとUnrealEngineを組み合わせて商品の展示などを考慮した場合、キーボード・マウス・ゲームパッドを操作できるとは限らない利用者が想定されることがあります。このような利用者に向けて様々な方法でコントロールできる仕組みを用意する必要があった為、その一環としてネットワーク経由で操作できる仕組みを確立しておきたいという考えがありました。

まずは動作しているところをご覧ください

動画を見ていただくと、スマホのブラウザに表示されたコントローラーを操作する事でキャラクターが動作していることがわかると思います。

基幹技術はWebSocket

WebSocketとは「TCPコネクション上に双方向通信のチャンネルを提供する、コンピュータの通信プロトコル」のことで、簡単に言えばWEBの技術を使って双方向通信を確立する為の仕組みです。

コントローラーとなるスマホからも、UnrealEngineからもWebSocketサーバに対して接続しておくことで、スマホで押したボタンの情報をUnrealEngineに即座に転送することが可能となり、UnrealEngineはその通信を受信してキャラクターの動作に反映させるという仕組みとなります。

WebサーバとWebSocketサーバ構築

スマホ用のコントローラーとなるHTMLを配信する為のWebサーバと、WebSocket通信を管理するWebSocketサーバをUnrealEngineが動作しているPCに構築する必要があります。

でも、安心してください!サーバの準備なんて面倒という方向けに、Dockerを使って環境を構築できるようにしておきました。まだDockerを導入していない方は、お手元の環境に合わせたDockerだけインストールしておいてください。

Dockerの準備ができたら、下記ファイルをダウンロード後に適当なフォルダに解凍し、コマンドプロンプトを開きdocker-compose.ymlが存在しているフォルダまで移動してから、以下のコマンドを実行してください。

docker-compose up -d

これだけでサーバの構築が完了です。

なお、Dockerを使用しない方も、配布しているファイルから必要なソースコードを抜き出してサーバ構築するだけで環境構築は可能です。(ちょっと大変ですが)

Dockerファイル一式としてスマホブラウザで表示するコントローラー部分のHTMLと、WebSocketサーバ処理を含んでいるのですが、WebSocketサーバ処理については手抜きでブロードキャスト通信しているだけなので説明は割愛させ頂き、HTMLのみ解説いたします。

HTMLの中で重要なのはbuttonsという連想配列で”UpDown”・”LeftRight”・”Jump”をキーとして、方向キーについては-1~1を、ジャンプボタンについてはTrue/Falseが格納されるようになっていて、キー入力が発生する度にWebSocketサーバに対してデータを送信しています。この処理はUE側の解説でも重要になってくるので覚えておいてください。

<!DOCTYPE html>
<html lang="ja">
	<head>
		<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
		<meta charset="UTF-8" />
		<title>UE5 WebController</title>
		<style>
			div.cross button { width: 120px;height:80px; }
			div.button button { width: 180px;height:130px; }
		</style>
	</head>
	<body>
		<div class="cross" style="position:absolute;left:20px;top:50%;transform:translate(0%, -50.1%);text-align:center;">
			<button ontouchstart="touch('UpDown', 1);"      ontouchend="touch('UpDown', 0)">↑</button><br />
			<button ontouchstart="touch('LeftRight', -1);"  ontouchend="touch('LeftRight', 0)">←</button>
			<button ontouchstart="touch('LeftRight', 1);"   ontouchend="touch('LeftRight', 0)">→</button><br />
			<button ontouchstart="touch('UpDown', -1);"     ontouchend="touch('UpDown', 0)">↓</button>
		</div>
		<div class="button" style="position:absolute;right:20px;top:50%;transform:translate(0%, -50.1%);">
			<button ontouchstart="touch('Jump', true);"     ontouchend="touch('Jump', false)">JUMP</button>
		</div>
	</body>
</html>
<script type="text/javascript">

    //WebSocket接続
    var uri = new URL(window.location.href);
    var connection = new WebSocket("ws://" + uri.hostname + ":5000");

	// 接続状況
	var isWSCon = false;

	// ボタン押下フラグ
	var buttons = {};
	buttons["UpDown"]    = 0;
	buttons["LeftRight"]  = 0;
	buttons["Jump"]  = false;

    //接続通知
    connection.onopen = function(event) {
		isWSCon = true;
    };

    //エラー発生
    connection.onerror = function(error) {
    };

    //メッセージ受信
    connection.onmessage = function(event) {
    };

    //切断
    connection.onclose = function() {
		isWSCon = false;
    };

	// コントローラー情報送信
	function send(){
		if (isWSCon){
			connection.send(JSON.stringify({data: buttons}));
		}
	}

	// ボタン押下情報を更新して最新情報を送信
	function touch(button, val){
		buttons[button] = val;
		send();
	}
</script>

UnrealEngineで新規プロジェクト作成

今回の解説ではUnrealEngine 5.3.2を使用しますがバージョンが異なっても大きな違いは無いと思います。

親の顔よりも見た「サードパーソン」で新規プロジェクトを作成します。後でC++を触る事になりますが、現時点ではブループリントのまま新規プロジェクトを作成してください。(C++で作るとキャラクタークラスを気軽に編集できない為)

プロジェクトが開いたら、[ツール]→[新規C++クラス]を選択

[全てのクラス]を選択し、検索欄に”GameInstanceSubsystem”と入力し、検索結果に表示された[GameInstanceSubsystem]を選択し、[次へ]をクリック

ファイル名が”MyGameInstanceSubsystem”となっているので、そのままの状態で[クラスを作成]をクリック

IDEからビルドして起動しましょう。

無事にMyGameInstanceSubsystem.hとMyGameInstanceSubsystem.cppが追加されました。

Build.csの編集

[プロジェクト名].Build.csというファイルを開き、WebSocket/JSON関連のモジュールを追加します。

PublicDependencyModuleNames.AddRangeの行末に追加した”WebSockets”, “Json”, “JsonUtilities”がWebSocketやJSON関連のモジュールとなります。

// Fill out your copyright notice in the Description page of Project Settings.

using UnrealBuildTool;

public class QiitaAC2023Test : ModuleRules
{
	public QiitaAC2023Test(ReadOnlyTargetRules Target) : base(Target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
	
		PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "WebSockets", "Json", "JsonUtilities" });

		PrivateDependencyModuleNames.AddRange(new string[] {  });

		// Uncomment if you are using Slate UI
		// PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
		
		// Uncomment if you are using online features
		// PrivateDependencyModuleNames.Add("OnlineSubsystem");

		// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
	}
}

MyGameInstanceSubsystem.h(ヘッダーファイル)

まずはMyGameInstanceSubsystem.hの全体像は以下の通りです。とりあえず動けばいい場合は以下のソースをコピペしてみてください。重要な部分の解説は、全体像の後で解説します。

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "MyGameInstanceSubsystem.generated.h"

class IWebSocket;

DECLARE_LOG_CATEGORY_EXTERN(LogMyGameInstance, Log, All);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FSockMsg, float, UpDown, float, LeftRight, bool, Jump);

/**
 * 
 */
UCLASS()
class UMyGameInstanceSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()
		
public:
	virtual void Initialize(FSubsystemCollectionBase& Collection)override;
	virtual void Deinitialize()override;

	UPROPERTY(BlueprintAssignable)
	FSockMsg OnReceiveMessage;

private:
	TSharedPtr<IWebSocket> WebSocket = nullptr;
	FString message;

	void OnSocketConnected()const;
	void OnSocketConnectioinError(const FString& err)const;
	void OnSocketClosed(const int statusCode, const FString& reason, const bool wasClean)const;
	void OnSocketMessage(const FString& msg);
};

ここからはヘッダーファイルの重要な部分を解説していきます

まずはデリゲートの定義です。簡単に言えばWebSocket通信の受信イベントの度にBP側のイベントを呼び出す処理の定義となります。FSockMsgという定義名で3つのパラメータを持つデリゲートを定義しており、1つ目はFloat型のUpDown値(コントローラーの上下)、2つ目はFloat型のLeftRight値(コントローラーの左右)、3つ目はbool型のJump値(ジャンプボタンを押したか否か)を定義しています。C++側でこれを定義しておくと、以下のようにBP側でWebSocketの受信イベントを拾えるようになります。

DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FSockMsg, float, UpDown, float, LeftRight, bool, Jump);
public:
 ・・・
	UPROPERTY(BlueprintAssignable)
	FSockMsg OnReceiveMessage;

次に初期処理・終了処理です。初期処理でWebSocketサーバに接続し、終了処理でWebSocketを切断する処理を書く為の定義です。

public:
	virtual void Initialize(FSubsystemCollectionBase& Collection)override;
	virtual void Deinitialize()override;

最後にWebSocket系の定義となります。messageはWebSocketから受信した内容を蓄積しておく変数で、OnSocket****という関数はWebSocketの接続・接続エラー・クローズ・メッセージ受信のイベント処理を書く為の定義となります。

private:
	TSharedPtr<IWebSocket> WebSocket = nullptr;
	FString message;

	void OnSocketConnected()const;
	void OnSocketConnectioinError(const FString& err)const;
	void OnSocketClosed(const int statusCode, const FString& reason, const bool wasClean)const;
	void OnSocketMessage(const FString& msg);

ヘッダーファイルの解説は以上となります。

MyGameInstanceSubsystem.cpp(ソースファイル)

MyGameInstanceSubsystem.cppの全体像は以下の通りです。こちらも、とりあえず動けばいい場合は以下のソースをコピペしてみてください。重要な部分の解説は、全体像の後で解説します。若干ソース量が多く見えますが重要な点は少ないので怖がることはありません。

// Fill out your copyright notice in the Description page of Project Settings.


#include "MyGameInstanceSubsystem.h"
#include "WebSocketsModule.h"
#include "IWebSocket.h"

DEFINE_LOG_CATEGORY(LogMyGameInstance);

/**
 * @brief 
 *  初期処理
 * @param Collection 
 */
void UMyGameInstanceSubsystem::Initialize(FSubsystemCollectionBase& Collection) {

    // Azure Web PubSubの接続情報を記述
	const FString ServerUri = TEXT("ws://localhost:5000");
	const FString ServerPrt = TEXT("ws");

    // WebSocket生成
	WebSocket = FWebSocketsModule::Get().CreateWebSocket(ServerUri, ServerPrt);

    // イベントハンドラー
	WebSocket->OnConnected().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketConnected);
	WebSocket->OnConnectionError().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketConnectioinError);
	WebSocket->OnClosed().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketClosed);
	WebSocket->OnMessage().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketMessage);

    // WebSocket接続
	WebSocket->Connect();
}

/**
 * @brief 
 *  終了処理
 */
void UMyGameInstanceSubsystem::Deinitialize() {
	WebSocket->Close();
	WebSocket = nullptr;
}

/**
 * @brief 
 *  WebSocketに接続した時に呼び出される処理
 */
void UMyGameInstanceSubsystem::OnSocketConnected() const {
	UE_LOG(LogMyGameInstance, Log, TEXT("WebSocket Connected"));
}

/**
 * @brief 
 *  WebSocketの接続に失敗したときに呼び出される処理
 * @param err 
 */
void UMyGameInstanceSubsystem::OnSocketConnectioinError(const FString& err) const {
	UE_LOG(LogMyGameInstance, Warning, TEXT("WebSocket Error: %s"), *err);
}

/**
 * @brief 
 *  WebSocketが閉じられた時に呼び出される処理
 * @param statusCode 
 * @param reason 
 * @param wasClean 
 */
void UMyGameInstanceSubsystem::OnSocketClosed(const int statusCode, const FString& reason, const bool wasClean) const {
	UE_LOG(LogMyGameInstance, Log, TEXT("WebSocket Closed: status %d, reason %s"), statusCode, *reason);
}

/**
 * @brief 
 *  WebSocketからメッセージを受信した時に呼び出される処理
 * @param msg 
 */
void UMyGameInstanceSubsystem::OnSocketMessage(const FString& msg) {

    // 受信メッセージ溜めこみ
	message += msg;

    // JSON変換
	TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(*message);
	TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject());

    // JSON変換ができて、dataフィールドを持っている場合は、受信したdataフィールドをBPに渡す
	if (FJsonSerializer::Deserialize(JsonReader, JsonObject)){
		if (JsonObject->HasField("data")){
			float UpDown    = (float)JsonObject->GetObjectField("data")->GetNumberField("UpDown");
			float LeftRight = (float)JsonObject->GetObjectField("data")->GetNumberField("LeftRight");
			bool Jump       = JsonObject->GetObjectField("data")->GetBoolField("Jump");
			OnReceiveMessage.Broadcast(UpDown, LeftRight, Jump);
		}
		message = "";
	}
}

ここからはソースファイルの重要な部分を解説していきます

まずは初期処理です。WebSocket接続&接続・接続エラー・クローズ・メッセージ受信のイベントの紐付けしているだけですね。(WebSocketサーバをUnrealEngineが動作しているPC以外に構築した場合はServerUriの”localhost”を適切なIPアドレスに書き換えてください)

/**
* @brief 
 *  初期処理
 * @param Collection 
 */
void UMyGameInstanceSubsystem::Initialize(FSubsystemCollectionBase& Collection) {

    // Azure Web PubSubの接続情報を記述
	const FString ServerUri = TEXT("ws://localhost:5000");
	const FString ServerPrt = TEXT("ws");

    // WebSocket生成
	WebSocket = FWebSocketsModule::Get().CreateWebSocket(ServerUri, ServerPrt);

    // イベントハンドラー
	WebSocket->OnConnected().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketConnected);
	WebSocket->OnConnectionError().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketConnectioinError);
	WebSocket->OnClosed().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketClosed);
	WebSocket->OnMessage().AddUObject(this, &UMyGameInstanceSubsystem::OnSocketMessage);

    // WebSocket接続
	WebSocket->Connect();
}

次に終了処理ですが、WebSocketをクローズしているだけです。

/**
 * @brief 
 *  終了処理
 */
void UMyGameInstanceSubsystem::Deinitialize() {
	WebSocket->Close();
	WebSocket = nullptr;
}

接続時・接続エラー時・切断時の関数の中身はログ出力しかしていないので説明は割愛し、受信処理のみ解説します。OnSocketMessage関数はWebSocketからメッセージを受信した際に呼び出される関数になります。送信されたメッセージが分割されて届くことがあった為、msgをmessageという変数にため込んでいき、JSONとして正しい形式になった&必要なデータ(今回で言えばコントローラーの入力情報)を保持していると判断した場合のみBP側にコントローラー情報を受信したことを伝えてmessageをクリアするような処理となっています。

/**
 * @brief 
 *  WebSocketからメッセージを受信した時に呼び出される処理
 * @param msg 
 */
void UMyGameInstanceSubsystem::OnSocketMessage(const FString& msg) {

    // 受信メッセージ溜めこみ
	message += msg;

    // JSON変換
	TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(*message);
	TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject());

    // JSON変換ができて、dataフィールドを持っている場合は、受信したdataフィールドをBPに渡す
	if (FJsonSerializer::Deserialize(JsonReader, JsonObject)){
		if (JsonObject->HasField("data")){
			float UpDown    = (float)JsonObject->GetObjectField("data")->GetNumberField("UpDown");
			float LeftRight = (float)JsonObject->GetObjectField("data")->GetNumberField("LeftRight");
			bool Jump       = JsonObject->GetObjectField("data")->GetBoolField("Jump");
			OnReceiveMessage.Broadcast(UpDown, LeftRight, Jump);
		}
		message = "";
	}
}

コントローラーの入力でキャラクターを動かす処理をBPで実装

C++のビルドが完了したら、BP_ThirdPersonを開き、BeginPlayノードを以下のよう変更します。この時、変数”UpDown”と”LeftRight”をfloat型で追加しておいてください。

BeginPlayイベントでWebSocketサーバに接続し、WebSocketからコントロール情報を受信するとカスタムイベントが呼び出されます。カスタムイベントにはコントローラーの上下左右とジャンプボタンの情報が含まれているので、上下左右は変数にいったん保持し、ジャンプはJump・StopJumpingノードを呼び出しています。

更にBP_ThirdPerson内のTickノードを追加し、IA_Moveノードから処理をコピーしてきて、以下のように実装します。上記処理で変数に保持しておいた上下左右のコントローラー情報をここで反映しています。(コントローラーの情報はコントローラーになんらかの変化が無いとイベントが発生しない為、IA_MoveのTiggeredのようなイベントを発生させることができず、ここではTickで実装しています)

これでUnrealEngine側の準備は完了しましたので、プレイボタンをクリックしておいてください。

スマホのブラウザでコントローラー画面を表示

スマホのブラウザからコントローラーにアクセスします。これはDockerで立ち上げているWebサーバに対してスマホのブラウザからアクセスする必要があるので、UnrealEngineが動作しているPCと同一のネットワークにスマホを接続する必要がありますが、一般的に家庭内のWi-Fiに接続しているのであれば問題はないハズです。(ただし、必要に応じてPC側のFirewallの設定変更が必要な場合があります。その場合は8080ポートと5000ポートの受信を許可しておいてください。)

Webサーバが動作しているPCのIPアドレスを調べて、スマホのブラウザから以下のURLにアクセスしてみてください。

http://[Webサーバが動作しているPCのIPアドレス]:8080/

スマホ上にコントローラーが表示されればOKです。

こいつ…動くぞ!

スマホのコントローラーでUnrealEngine上のキャラクターを動かしてみてください。動かない場合はWebSocketサーバが正常に動作していない可能性がありますのでDockerでWebSocketサーバの再起動を行ってみてください。WebSocketサーバに接続できたか否かは、UnrealEngineの[ウインドウ]→[出力ログ]を表示しながプレイすることで、WebSocketサーバに接続できたか否かを確認することができます。

これで完成!

これでWebSocket経由のコントローラーを制作することができました。今回は仕組みを理解する為に最低限の実装しかしていませんので、WebSocketが切断されてしまった場合の再接続や、WebSocketサーバでのブロードキャストではないデータ送受信の仕組みなど、実装しなければならないことはまだまだありますが、技術的には面白そうなことができそうだと実感しています。

あと、思ったよりも操作感(レスポンス)が良かったという印象を受けました。リアルタイム性を求めるようなゲームだと厳しいかもしれませんし、今回の検証はイントラネット内での通信ということで、インターネット経由にするとネットワーク環境にも左右されてしまいますが、環境や作品によっては使いどころがあるような気がしています。

本記事の解説はここまでとなります。少しでも皆様の作品のお力になれれば幸いです。

No responses yet

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です