はじめに
マルチプレイ可能な VR ゲームを作ることに興味が湧いてきたので、
C# の勉強も兼ねて MagicOnion を使用して開発していくことにしました
MagicOnion を調査したところ、どうやら StreamingHub を使うことで、
サーバークライアント間でのリアルタイム通信を簡単に実装出来そうなことが分かりました。
そこで MagicOnion の README にある StreamingHub のサンプル を動かしてみました ↓
余談ですが、今まで C# でサーバサイドのプログラミング経験が無く、先入観で、
「C# で作ったプログラムは Windows OS 環境でないと気軽に動かせないのでは?」と思ってしまっていました。。。
しかし、MagicOnion の開発環境を構築していく過程で、全然 Mac でも気軽に開発を進めていけることが分かりました。
そのため、例え Mac 使ってても C# の開発に億劫になる必要は全く無いよと伝えたく記事を書くことにしました。(めっちゃ今更感あるかもしれませんが。。)
記事の中で間違っている部分/表現等あれば、有識者の方々にご指摘いただけると非常にありがたいです (特に記事内で紹介しているソースコード内のコメントアウトの内容とか。。。)
ちなみに MagicOnion + Unity + VRM でマルチプレイを実現するためのやりかたは、
既に素晴らしい記事が存在していますので @simplestar さんの こちらの記事 を見たほうが圧倒的に良いです。
本記事では初心者も詰まらずシンプルに StreamingHub のサンプルが動かせて、
とにかくマルチプレイのサンプルが動かせるようにすることを目指しています。
動作環境
今回は ↓ の環境で作業を行いました。
- StreamingHub サーバ
- .NET Core 2.2.300
- MagicOnion 2.1.2
- MagicOnion.Hosting 2.1.2
- MessagePack.UnityShims 1.7.3.7
- StreamingHub クライアント
- Unity 2019.1.4f1
- MagicOnion 2.1.2
- gRPC 1.22.0
- MessagePack 1.7.3.5
C# の開発環境を構築する
まずは ↓ のリンクから .NET SDK をダウンロードしてきます。
https://dotnet.microsoft.com/download
ダウンロードが完了したら、dotnet-sdk-X.X.XXX-osx-x64.pkg
を開いて .NET SDK をインストールします。
正常にインストールが完了したら、ターミナルを開き dotnet
コマンドが実行可能な状態か確認します
# .NET の SDK バージョンを出力
dotnet --version
2.2.300
上記の通りバージョン情報が標準出力されれば、開発のための準備は完了です。
.NET のプロジェクトで StreamingHub サーバを作る
@rookx さんの .NET CoreでMagicOnionを動かしてみた を参考にプロジェクトのセットアップを行います。
mkdir Server
cd Server
# .NET のコンソールアプリケーションを作成
dotnet new console
# 本記事の内容を動かすのに必要な MagicOnion 関連パッケージを追加
dotnet add package MagicOnion
dotnet add package MagicOnion.Hosting
# MessagePack で Unity の Vector3 や Quaternion を扱う際に必要となるパッケージを追加
dotnet add package MessagePack.UnityShims
これだけで今回のプロジェクトのセットアップは完了です。
あとは MagicOnion 関連のソースコードを書きながら、
都度 dotnet run
コマンドで動作検証を進めていける状態です。
現状の Server フォルダの中身は ↓ の構成になっているはずです。
.
└── Server
├── Program.cs
├── Server.csproj
├── Server.sln
├── bin
└── obj
まずは Program.cs の中身を MagicOnion の README の内容 に従って書き換えます。
using Microsoft.Extensions.Hosting;
using System.Threading.Tasks;
using MagicOnion.Hosting;
using MagicOnion.Server;
using Grpc.Core;
class Program
{
// MagicOnion を await で起動するため、非同期 Main で定義
// await でサーバーを起動しないと、即 Main 関数が終了してしまうため
static async Task Main(string[] args)
{
// gRPC のログをコンソールに出力するよう設定
GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());
// isReturnExceptionStackTraceInErrorDetail に true を設定して
// エラー発生時のメッセージがコンソールに出力されるようにする
// MagicOnion サーバーが localhost:12345 で Listen する
await MagicOnionHost.CreateDefaultBuilder()
.UseMagicOnion(
new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true),
new ServerPort("localhost", 12345, ServerCredentials.Insecure))
.RunConsoleAsync();
}
}
あとは実際にサーバークライアント間でリアルタイム通信を行うために必要なソースコードを追加していきます。
まずは StreamingHub の通信周りの定義を記述した IGamingHub.cs
を追加します。
using MagicOnion;
// MessagePack.UnityShims を利用することで、
// Unity の Vector3 や Quaternion を通信でやり取りすることが可能になる
using UnityEngine;
using MessagePack;
using System.Threading.Tasks;
// サーバで gRPC が実行された際に、
// 実行結果をクライアントに返すためのコールバック関数を定義する
public interface IGamingHubReceiver
{
void OnJoin(Player player);
void OnLeave(Player player);
void OnMove(Player player);
}
// クライアントがサーバ側で gRPC 実行可能な関数を定義する
public interface IGamingHub : IStreamingHub<IGamingHub, IGamingHubReceiver>
{
Task<Player[]> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation);
Task LeaveAsync();
Task MoveAsync(Vector3 position, Quaternion rotation);
}
// gRPC 通信で独自に定義したメッセージ Player を扱うための宣言
// Player はプレイヤー名, 位置(Vector3), 回転(Quaternion) の変数を所持している
[MessagePackObject]
public class Player
{
[Key(0)]
public string Name { get; set; }
[Key(1)]
public Vector3 Position { get; set; }
[Key(2)]
public Quaternion Rotation { get; set; }
}
次に IGamingHub.cs
の定義内容のサーバ側を実装した GamingHub.cs
を追加します。
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using UnityEngine;
using MagicOnion.Server.Hubs;
// IGamingHub の実装が記述された GamingHub クラス
public class GamingHub : StreamingHubBase<IGamingHub, IGamingHubReceiver>, IGamingHub
{
// IGroup を使用することで同一のグループに所属している他ユーザ全員に対して
// 一斉にブロードキャスト送信を行うことが出来る (オンラインゲームで言うルームの概念)
IGroup room;
// ルーム内での自分の情報 (IGamingHub.cs で定義した Player の情報)
Player self;
// ルームに入室しているユーザ全員(自分も含む)の情報を保持して扱うための変数
IInMemoryStorage<Player> storage;
// 指定したルームに入室するための関数
// 入室するルーム名及び、ユーザ自身の情報(ユーザ名,位置(Vector3),回転(Quaternion)) を引数に取る
public async Task<Player[]> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation)
{
self = new Player() { Name = userName, Position = position, Rotation = rotation };
// ルームにユーザが入室する
(room, storage) = await Group.AddAsync(roomName, self);
// ルームに入室している他ユーザ全員に
// 入室したユーザの情報をブロードキャスト送信する
Broadcast(room).OnJoin(self);
// ルームに入室している他ユーザ全員の情報を配列で取得する
return storage.AllValues.ToArray();
}
// ユーザがルームから退出する
public async Task LeaveAsync()
{
await room.RemoveAsync(this.Context);
// ルームに入室している他ユーザ全員に
// ルームから退出したことをユーザの情報と共にブロードキャスト送信する
Broadcast(room).OnLeave(self);
}
// ユーザがルームの中で動く
public async Task MoveAsync(Vector3 position, Quaternion rotation)
{
// 動いたユーザの位置(xyz) と回転(quaternion) を設定する
self.Position = position;
self.Rotation = rotation;
// 動いたユーザの最新の位置(Vector3)と回転(Quaternion) を
// ルームに入室している他ユーザ全員にユーザの最新情報 (Player) をブロードキャスト送信する
Broadcast(room).OnMove(self);
}
}
現状の Server フォルダの中身は ↓ の構成になっているはずです。
.
└── Server
├── GamingHub.cs
├── IGamingHub.cs
├── Program.cs
├── Server.csproj
├── Server.sln
├── bin
└── obj
この状態で Server フォルダ上で dotnet run
を実行してみます。
dotnet run
GamingHub.cs(37,23): warning CS1998: この非同期メソッドには 'await' 演算子がないため、同期的に実行されます。'await' 演算子を使用して非ブロッキング API 呼び出しを待機するか、'await Task.Run(...)' を使用してバックグラウンドのスレッドに対して CPU 主体の処理を実行することを検討してください。 [/Users/nika/Desktop/MagicOnion/Server/Server.csproj]
D0618 19:33:16.355504 Grpc.Core.Internal.UnmanagedLibrary Attempting to load native library "/Users/nika/.nuget/packages/grpc.core/1.20.1/lib/netstandard2.0/../../runtimes/osx/native/libgrpc_csharp_ext.x64.dylib"
D0618 19:33:16.400266 Grpc.Core.Internal.NativeExtension gRPC native library loaded successfully.
Application started. Press Ctrl+C to shut down.
Hosting environment: Production
Content root path: /Users/nika/.nuget/packages/magiconion.hosting/2.1.2/lib/netstandard2.0
警告が出てきましたが、これは無視で大丈夫です
正常に実行されればエラーは発生せず、ターミナルが待機状態になるはずです。
Unity プロジェクトで StreamingHub クライアントを作る
@mitchydeath さんの Unity+MagicOnionで超絶手軽にリアルタイム通信を実装してみた に沿って Unity プロジェクトに MagicOnion をセットアップしておきます。
セットアップ完了次第、Unity プロジェクトの Assets/Scripts フォルダに StreamingHub クライアント用のスクリプトを追加していきます。IGamingHub.cs
は Unity 側でもクライアント側の実装を行うために必要になるので、Assets/Scripts フォルダに追加しておきます。
また IGamingHub.cs
の定義内容のクライアント側を実装した GamingHubClient.cs
を追加します。
using Grpc.Core;
using System.Collections.Generic;
using UnityEngine;
using System.Threading.Tasks;
using MagicOnion.Client;
// IGamingHubReceiver の実装が記述された GamingHubClient クラス
public class GamingHubClient : IGamingHubReceiver
{
// 部屋に参加しているユーザ全員の GameObject (アバター)を保持する
Dictionary<string, GameObject> players = new Dictionary<string, GameObject>();
// サーバ側の関数を実行するための変数
IGamingHub client;
// 指定したルームに入室するための関数
// StreamingHubClient で使用する gRPC チャネル及び、参加したい部屋名、使用するユーザ名を引数に指定する
public async Task<GameObject> ConnectAsync(Channel grpcChannel, string roomName, string playerName)
{
// サーバ側の関数を実行するための StreamingHubClient を生成する
client = StreamingHubClient.Connect<IGamingHub, IGamingHubReceiver>(grpcChannel, this);
// JoinAsync 関数を実行して部屋に入室すると同時に、
// 既に入室済みのユーザ全員の情報を配列で取得する
var roomPlayers = await client.JoinAsync(roomName, playerName, Vector3.zero, Quaternion.identity);
// 自ユーザ以外を OnJoin 関数に渡して、
// this.players に部屋の他ユーザ全員の情報をセットする
// 自ユーザの情報は await で JoinAsync を実行した段階で、
// OnJoin がコールバックで呼ばれているためセット済みの状態となっている
foreach (var player in roomPlayers)
{
if (player.Name != playerName)
{
(this as IGamingHubReceiver).OnJoin(player);
}
}
// 自ユーザの情報を返却する
return players[playerName];
}
// 部屋から退出し、部屋の他ユーザ全員に退出したことをブロードキャスト送信する
public Task LeaveAsync()
{
return client.LeaveAsync();
}
// 自ユーザの位置(Vector3) と回転(Quaternion) を更新すると同時に
// 部屋の他ユーザ全員にブロードキャスト送信する
public Task MoveAsync(Vector3 position, Quaternion rotation)
{
return client.MoveAsync(position, rotation);
}
// StreamingHubClient の解放処理
// gRPC のチャネルを破棄する前に実行する必要がある
public Task DisposeAsync()
{
return client.DisposeAsync();
}
// 部屋に新しいユーザが入室したときに呼び出される関数
// または ConnectAsync 関数を実行したときに呼び出される関数
void IGamingHubReceiver.OnJoin(Player player)
{
// ユーザの GameObject (アバター)を Player 情報を元に生成して
// this.players に player.Name をキーにして保持する
// 部屋に入室しているユーザの数だけワールド上にキューブを出現する
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.name = player.Name;
cube.transform.SetPositionAndRotation(player.Position, player.Rotation);
players[player.Name] = cube;
}
// 他ユーザが部屋から退出した際に呼び出される関数
void IGamingHubReceiver.OnLeave(Player player)
{
// this.players に保持していた GameObject (アバター)を破棄する
// ワールド上から該当する GameObject (アバター)のキューブが消滅する
if (players.TryGetValue(player.Name, out var cube))
{
GameObject.Destroy(cube);
}
}
// 部屋の中でいずれかのユーザが動いたときに呼び出される関数
void IGamingHubReceiver.OnMove(Player player)
{
// 引数の player の Name を元に this.players 内から GameObject を取得する
// ワールド上の該当する GameObject (アバター)の位置(Vector3)と回転(Quaternion) の値を最新のものに更新する
if (players.TryGetValue(player.Name, out var cube))
{
cube.transform.SetPositionAndRotation(player.Position, player.Rotation);
}
}
}
最後に GamingHubClient.cs
の StreamingHub クライアントを使用することで、StreamingHub サーバ経由でルームに参加したユーザ同士交流が出来るようにします。
そのために GameClient.cs
を追加します。
using System.Threading.Tasks;
using Grpc.Core;
using UnityEngine;
public class GameClient : MonoBehaviour
{
// プレイヤーの Transform (今回はメインカメラの Transform を指定)
[SerializeField]
Transform m_PlayerTransform;
// 部屋に参加するときに使用するユーザ名 (何でも設定可)
[SerializeField]
string m_UserName;
// 参加したい部屋のルーム名
// (StreamingHub クライアント同士で交流したい場合は、
// 各クライアントで同一の名前を設定する必要がある)
[SerializeField]
string m_RoomName;
// StreamingHub クライアントで使用する gRPC チャネルを生成
private Channel channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
// StreamingHub サーバと通信を行うためのクライアント生成
private GamingHubClient client = new GamingHubClient();
async Task Start()
{
// ゲーム起動時に設定した部屋名のルームに設定したユーザ名で入室する。
await this.client.ConnectAsync(this.channel, this.m_RoomName, this.m_UserName);
}
// Update is called once per frame
void Update()
{
// 毎フレームプレイヤーの位置(Vector3) と回転(Quaternion) を更新し、
// ルームに入室している他ユーザ全員にブロードキャスト送信する
client.MoveAsync(m_PlayerTransform.position, m_PlayerTransform.rotation);
}
async Task OnDestroy() {
// GameClient が破棄される際の StreamingHub クライアント及び gRPC チャネルの解放処理
await this.client.LeaveAsync();
await this.client.DisposeAsync();
await this.channel.ShutdownAsync();
}
}
あとは GameClient.cs
をメインカメラにアタッチして SerializeField
の中身をお好みで設定していただければ完成です! 1
.NET で作成した StreamingHub サーバを動かした状態で、
Unity プロジェクトを複製して、
元の Unity プロジェクトと複製後の Unity プロジェクトを開いて両方 Run すると、
はじめに
で貼った gif のように動作するはずです。
おわりに
C# は Unity アプリケーションを作成するときには触っていたのですが、
C# でサーバサイドを書いた経験は初めてでした。(MagicOnion に完全に乗っかった形ですが。。)
感想としては、環境構築も楽でクロスプラットフォームでライブラリも充実している印象を受けたので、引き続き勉強を続けていきたいと思います。(少なくともマルチプレイの VR ゲームを作り終えるまでは。。)
この記事が誰かのお役に立てれば幸いです。
余談: C# ライブラリの探し方
C# で開発中に自分が使用したいライブラリが出てきたら、
dotnet add package <パッケージ名>
でプロジェクトに追加していくことが可能です。
使用したいライブラリを検索する際は nuget を使用します。
Search for packages...
の検索欄からライブラリを検索することが可能です。
実際にプロジェクトに追加する際の <パッケージ名>
はライブラリを選択後に出てくる、
.NET CLI
タブをクリックすることで確認出来ます。↓ は MagicOnion の例。
参考リンク
https://qiita.com/rookx/items/6086b9426f3138a4b700
http://ryuichi111std.hatenablog.com/entry/2017/08/15/153804
https://simplestar-tech.hatenablog.com/entry/2019/05/19/192801
http://tech.cygames.co.jp/archives/3181/
https://qiita.com/mitchydeath/items/cecf01493d1efeb4ae55
-
具体的に言うと StreamingHub クライアント同士で m_RoomName は同一のものを設定します。m_UserName には好きな名前を設定していただいて構いません。m_PlayerTransform にはメインカメラで無くてもプレイヤーとして振る舞う GameObject のTransform を設定していただければ構いません。 ↩