ゲーム開発素人がゲーム開発素人のために、無料で、できるだけ簡単に、オンラインゲームを自作する方法を紹介したいと思います。
オンラインゲームを作るためには、基本的には、クライアントとサーバーが通信することが必要ですが、ググるのは骨が折れます。その時間を短縮するための情報を提供したいと思います。
基本的に、最小構成のアプリ、ソースコードそのものを紹介していきたいと思います。主に、Windows、Mac、Android、iOSで動くアプリを想定しています。なお、本記事をもとに行ったことで発生したことにつき、一切責任はとれませんので、ご了承下さい。
1.ゲームエンジン
ゲームはUnityで作ります。圧倒的に情報が多く、広く使われているからです。ゲームの作り方そのものは、ここでは詳しく説明しません。
なお、UnityではC#を使いますが、C#の書き方がわからない場合は、ゲームを作りながらC#の基礎から教えてくれるハンズオン的なものを見て、見た通りに作ってみれば、自然に理解できるようになるでしょう。
2.ゲームサーバー
オンラインゲームを作ろうとすると、Photonのような既存のサービスを使えば、基本無料で実現できると思います。恐らくは、これが効率よく高品質なものを作るベストな方法でしょう。しかし、あえて、ゲームサーバーを自作する方法をとりたいと思います。
理由は、サーバーの自作はそれ自体は簡単で、完全自由に作れるメリットがあるからです。また、サーバーのプログラムの修正が、そのままサーバー機上で行えるのが便利です。
デメリットは、通信の信頼性とか、サーバーの負荷とか、セキュリティとか、全て自分で管理する必要がありますし、電気代もかかりますし、サーバー機が必要になることで、普通に考えたらメリット<デメリットですw。
ゲームサーバー構築にあたっては、以下の項目に分けて説明します。前提として、普通のwindowsPCをサーバー機として使うこととします。
①IPアドレス固定する
ゲームサーバーは、クライアントから接続できるようにするためには、サーバーがどこあるか分かる必要があります。そのため、「IPアドレス」という番地があり、その番地を分かりやすい文字列に置き換えた「ドメイン」を割り当てます。ドメインはまずは、無料で入手できるものでいいでしょう。
せっかく、ドメインを割りあてても、IPアドレスが変わってしまうとおかしなことになるので、IPアドレスは固定されてることが望ましいです。が、普通のインターネットサービスプロバイダは、空いたIPアドレスを使いまわしているので、普通に提供するのは可変IPアドレスです。
固定IPをプロバイダーから割り当ててもらうのは、それほどコストもかかりませんが、とりあえずは、ダイナミックDNSサービスを利用する手があります。とにかく、まずはじめるために、MyDNSで無料のDDNSを割り当ててみるといいのではないでしょうか。
②ルーターを設定する
自分の家のパソコン上のサーバーソフトを世界に向かって公開しないと、クライアントがサーバーにアクセスできません。普通は、外からのアクセスは危険なため、ルーターが遮断します。そのためルーターの設定を変更する必要があります。この辺の設定は、セキュリティ上の危険性があり、設定を間違えるとインターネットそものにアクセスできなくなることもあるので、いままでの説明が意味不明な方はやってはいけません。最低限、サーバーを公開するということ、ポートを解放することくらいは分かり、自分でルーターを初期化、ネット接続設定ができるくらいは必要です。
さて、今回は、ウェブサーバーソフトを自作するため、自分で任意のポート番号を設定できます。ここでは9999としています。なお、ポートとは詳細な番地と考えておけばいいと思います。ドメインが大きい番地、ポートは更に細かい番地です。PC上のサーバーは、ポート9999を指定してクライアントを待ち受けします。
ルーターの設定変更ですが、セキュリティの脅威を最小限に抑えるために、ポート9999に対して要求があったときだけ、サーバーにメッセージを伝えるように設定し、それ以外の要求は、今まで通り、全て遮断するようにします。
ルーターの設定方法は、メーカーと機種により異なるため、各自自分でマニュアルを調べる必要があります。型番を調べて、まずはマニュアルをネット上から入手します。その後、192.168.0.1をブラウザのアドレスバーに打ち込むことでルーターの設定画面にアクセスします。そして、マニュアルに従ってポート9999へのWAN側からのアクセスをLAN側のサーバー機のIPアドレスの9999ポートに向かって繋ぐ設定を行います。「ポート開放」で検索すれば情報がたくさん出てくるので、自分のルーターのメーカーの情報とあわせて調べれば、素人でもできると思います。
前提として、PCには固定のプライベートIPを割りあててある必要があります。もし、グローバルIPとプライベートIPの違いが分からない場合は、ググって調べて下さい。要は、PCの内部IPがPC起動の度に変わるのを防ぎたいわけです。
③自分のサーバーをLet’s EncryptでSSL化する
これについては、私は「Let’s Encrypt 証明書を Windows 10 のローカル開発環境で発行する」を参考に行いました。ありがとうございました。これによって、SSLで通信ができるようになり、安全性が高まります。
ちょこちょこと、違う部分が出てきたりしますが、なんとかなると思います。
「12.(初めてのドメインのみ)ドメイン所有の確認が行われる。画面に表示された通りに TXT レコードを設定して Enter を押す。」の部分では、自分のドメインを提供しているサービスのDNS設定ページにアクセスして、TXTレコードを設定する必要がありますが、どこにどれをコピペすればいいか、ちょっと分かりにくいかもしれませんが、「自分のドメインを提供しているサービスのDNS設定ページ」がどれか分かれば、なんとかなるでしょう。
なお、TXTレコードを設定してから、30~60分くらい時間をあけてから、Enterを押すといいです。すぐ押すとエラーになります。TXTレコードの削除の場合も同様です。私は、すぐEnterを押して何度もエラーになり、先に進めなくなりましたが、翌日やり直そうとすると、完了していることになっていました。
④ゲームサーバーアプリケーションをNode.jsで作る
ソースコード例。冒頭でMySQLへ接続し、その後でウェブサーバーを立ち上げ、立ち上げ後は、クライアントから受信したメッセージをそのまま他のクライアントに転送します。これでネット上の他のプレイヤーの動作を反映すること等ができます。
テスト時は、自分自身にも転送するようにコメントアウトして、自分宛に送信し、受信確認するといいでしょう。
途中、Let’s Encryptで取得したファイルを指定する場所がありますが、自身のディレクトリ、ファイル名に変更して下さい。その他、自身の設定に変更すべき箇所は、ソースにコメントで記載しています。
key: fs.readFileSync(‘任意のディレクトリ/ファイル名.tk-key.pem’, ‘utf8’),
cert: fs.readFileSync(‘任意のディレクトリ/ファイル名.tk-crt.pem’, ‘utf8’)
- // mysql接続
- var mysql = require(‘mysql’);
- var connection = mysql.createConnection({
- host : ‘localhost’,//サーバー機にMySQLがインストールされている場合はこのまま
- user : ‘root’,//自分のMySQLのユーザー名
- password : ‘password’,//自分のMySQLのパスワード
- database : ‘database’//自分の使用するデータベース名
- });
- connection.connect(function(err) {
- if (err) {
- console.err(‘err connecting: ‘ + err.stack);
- return;
- }
- console.log(‘mysql connected as id ‘ + connection.threadId);
- });
- //サーバーを立ち上げる
- const https = require(‘https’);
- const fs = require(‘fs’);
- let WebSocketServer = require(‘ws’).Server;
- let port = 9999;//自分のルーターで許可したポート
- var server = https.createServer({
- key: fs.readFileSync(‘任意のディレクトリ/ファイル名.tk-key.pem’, ‘utf8’),
- cert: fs.readFileSync(‘任意のディレクトリ/ファイル名.tk-crt.pem’, ‘utf8’)
- });
- let wssServer = new WebSocketServer({server});
- server.listen(port);
- console.log(‘websocket server start. port=’ + port);
- //クライアントからサーバーに接続があったとき
- wssServer.on(‘connection’, function(ws) {
- //初回接続時,idを割り当てて管理してもよい
- console.log(‘connect’);
- //クライアントからメッセージを受信
- ws.on(‘message’, function(message) {
- try{
- var json = JSON.parse(message);
- //送信者以外にメッセージを転送
- wssServer.clients.forEach(function each(client) {
- if (client != ws) {
- client.send(message);
- }
- });
- //mySQLにアクセスする場合の例
- //sendScore(ws,json);
- }catch (e){
- console.log(e.message);
- }
- });
- //クライアントが切断されたとき
- ws.on(‘close’, () => {
- console.log(‘close’);
- });
- });
- //引数のjson.tableに定義されたテーブルからスコアと名前を取得して配列でクライアントに送信する例
- function sendScore(ws,json){
- connection.query(“SELECT score,name FROM ” + json.table + ” ORDER BY score ASC”, function (err, rows, fields) {
- if (err) throw err;
- if(rows.length > 0){
- var jsonData = JSON.stringify({“ranks”:rows});
- var array = {“type”:”rank”,”rowData”:jsonData}
- ws.send(JSON.stringify(array));
- }
- });
- }
3.通信手段
オンラインゲームでは、ゲーム参加者同士が通信する必要がありますが、通信手段にはネットに情報の多いWebSocket(ws)を基本に使います。また、サーバーを介さずに、1対1通信(P2P)をさせるときは、WebRTCを使うことにしました。また、WebRTCを使うために、greeさんが作ってくれているWebViewを使います。
WebRTCは、P2Pを実現する方法として、私がこれ以外を見つけられなかったため、採用していますが、きっともっといい方法があると思います。できれば、教えていただけるとありがたいです。
WebSocket(ws)を使ってサーバーと通信するUnityアプリを作る手順
①プラグインをUnityアプリに配置する
まずプラグインをダウンロードして、UnityのAsset/Plugineディレクトリに配置して下さい。Assetディレクトリ内にPlugineディレクトリが無い場合は、ディレクトリを作成して下さい。
なお、プラグインのライセンスは、かなり自由に使える「The MIT License (MIT)」で、権利は「Copyright (c) 2010-2022 sta.blockhead」さんに所属しています。また、プラグインについての詳細は、こちらのサイトを参考にさせて頂きました。どうもありがとうございます。
②Unityアプリに送受信のソースコードを書く
ソースは次のとおり。sslで通信するために、wsではなくwssを使っています。
以下のように定義し、start関数内で、
- Task.Run(() => WebSocketMethod());
として実行することで、サーバーに接続して双方向で通信できるようになります。publicかprivateとか、適当です。ws.OnMessageで、メッセージが受信できればOKです。
- using WebSocketSharp;//プラグインを使えるように
- public WebSocket ws;
- //wssのときだけ(wsはエラー出ない)ハンドシェイクでエラーが出るのでその対応
- private enum SslProtocolsHack {
- Tls = 192,
- Tls11 = 768,
- Tls12 = 3072
- }
- //別スレッドで実行しないと接続できるまでフリーズする
- void WebSocketMethod() {
- ws = new WebSocket(“wss://〇〇〇〇〇〇”);//自分のサーバー
- // 接続時に呼ばれる
- ws.OnOpen += (sender, e) => {
- Debug.Log(“open”);
- };
- // クローズ時に呼ばれる
- ws.OnClose += (sender, e) => {
- //wssのときだけ(wsはエラー出ない)ハンドシェイクでエラーが出るのでその対応
- var sslProtocolHack = (System.Security.Authentication.SslProtocols)(SslProtocolsHack.Tls12 | SslProtocolsHack.Tls11 | SslProtocolsHack.Tls);
- //TlsHandshakeFailure
- if (e.Code == 1015 && ws.SslConfiguration.EnabledSslProtocols != sslProtocolHack) {
- ws.SslConfiguration.EnabledSslProtocols = sslProtocolHack;
- ws.Connect();
- }
- return;
- };
- // エラー時に呼ばれる
- ws.OnError += (sender, e) => {
- Debug.Log(e.Message);
- };
- // 接続
- ws.SslConfiguration.EnabledSslProtocols = (System.Security.Authentication.SslProtocols)(SslProtocolsHack.Tls12 | SslProtocolsHack.Tls11 | SslProtocolsHack.Tls);
- ws.Connect();
- // サーバからのデータ受信時に呼ばれる
- ws.OnMessage += (sender, e) => {
- Debug.Log(e.Message);
- };
- }
クライアントからサーバーに送信するときは、以下のようにws.Send();で送信します。
ここでは、wsJsonクラスを定義しておいて、JsonUtility.ToJson関数で、wsJsonクラスのjson文字列に変換して、送信しています。Json文字列に送信したので、サーバー側で受け取ったとき、Json文字列として扱います。
wsJsonクラスは、2つのstringとfloatを持っていますが、必要に応じて必要な変数を定義すれば、クライアントからサーバーに必要な情報が送信できます。(例えば、クライアントが移動したときには、移動した位置を送信する等)
- ws.Send(JsonUtility.ToJson(new wsJson()));
- [Serializable]
- public class wsJson {
- public string type;
- public string id;
- public float posx;
- public float posy;
- }
greeさんのUnity-WebViewを使ってP2P通信するUnityアプリを作る(おまけ)
ここからは、実用性が疑わしい、かなりチャレンジングな内容です。また、ソースコードも環境にあわせて直す必要がありますので、ご了承下さい。WebRTCによるP2P通信を作るにあたって、何らかの参考になれば幸いです。
・WebRTCについて
WebRTCは、ブラウザ(今回はunity webview)のWebRTCにより、サーバーを介さずにpeer to peer (1対1)通信でウェブチャット等が簡単にできる機能です。サーバーを介さず、と言っても、通信の開始時に通信方法等をクライアント間でとりとりする(これをシグナリングと言います)ためにサーバーは最低限必要で、それを今回は自作します。
クライアントはサーバーに、対戦相手の検索を依頼し、サーバーが対戦相手を探しているクライアント同士に通信を開始するように指示を出します。指示を出されたクライアントは、シグナリングを開始し、サーバーを経由して互いのIPアドレスを取得し、使用可能な通信方法等を決めて、最終的にサーバーを経由せずに、直接通信を行います。
ですが、ケースにより(10%前後らしいですが、実証まで至っておりません。)、直接通信できない場合もあり、その場合は、あくまでサーバーを経由してデータをやり取りせざるを得ません。そのようなサーバーをTurnサーバーと言いますが、これは既に1対1通信ではなく、サーバーを経由する通信です。サーバーに負荷がかかりますし、サーバーの通信量が発生するため、対応する場合は、注意が必要です。
という具合に、不完全さが否めず、実証不足のため、用途は限られますが、一応実現はできましたので、記録として残しておこうと思います。なぜP2P通信の情報って少ないのでしょう。
・通信のフロー
さて、具体的な通信のフローですが、UnityクライアントがWebViewを起動します。WebViewにはJavaScriptでのWebRTCを利用するための関数を埋め込んでおきます。UnityからJavaScriptの関数を呼んでWebRTCのシグナリングを開始します。シグナリングは自作のサーバーを介して行われて、成功すると、WebRTCの機能によりクライアント間にデータチャンネルという通路が形成されて、そこを通してテキストデータを直接送受信できようになります。データチャンネルを通して受信したデータはWebViewに埋め込んでおいたjavaScriptの関数でUnityに渡されます。
UnityクライアントA(以降Aとする)→サーバー(以降Sとする)にマッチングを依頼
↓
UnityクライアントB(以降Bとする)→Sにマッチングを依頼
↓
SがA,Bにシグナリングの開始指示(今回はAからBへオファーを出すよう指示)
↓
AがBにシグナリングのオファーを出す(サーバー経由)
↓
BはAからのオファーを受けてアンサー返す(サーバー経由)
↓
Bはアンサーを出した後は、データチャンネルが開通するのを待つ
↓
Aはアンサーを受けた後は、データチャンネルの開通を待つ
↓
データチャンネルが開通したら、サーバーを介さずにデータチャンネルで直接通信を開始する
なお、Unity WebView を利用するためには、空のゲームオブジェクトに、WebViewObjectクラスをAddComponentで追加して、WebViewObjectクラスのインスタンスを作ります。
UnityからWebViewのJavaScriptを実行する方法は、WebViewObject.EvaluateJS(”任意のJavaScriptのソース”)できます。
逆に、JavaScriptからUnityの関数を呼ぶためには、JavaScriptのソースコードにUnity.call(“任意の文字列”)を書けば、後述するソースコード内のcb: (msg) =>{}を呼びだすことが出来る。
①gree unity webview を unityに読み込む
greeさんのUnity WebViewですが、unity内にでWebページを表示したりする用途に使うのが一般的だと思いますが、WebViewの機能であるWebRTCを使ってUnityゲーム間のP2P通信を実現します。
本来は映像と音声のリアルタイム通信を行うことが多いですが、今回は、WebRTCによるテキストデータのP2P通信を行います。
まず、Unity WebViewをUnityに設定します。下記より一式をZipでダウンロードして、distディレクトリ内の「unity-webview.unitypackage」をダブルクリックもしくはUnityにドラッグ&ドロップしてパッケージを読み込みます。
②Unity上でWebViewオブジェクトの生成とJavaScriptの読み込み、適用
Unityに埋め込むコードはこちら。
- void createWebView1() {
- try {
- webViewObject1 = (new GameObject(“WebViewObject”)).AddComponent<WebViewObject>();
- webViewObject1.Init(
- cb: (msg) => {
- Debug.Log(string.Format(“CallFromJS[{0}]”, msg));
- var json = JsonUtility.FromJson<wsJson>(msg);
- if (json.type == “dataChannelMsg”) {
- //dataChannel経由のメッセージ受信時
- } else if (json.type == “dataChannelClosed”) {
- //相手が落ちたとき
- } else if (json.type == “offer” || json.type == “answer”) {
- //シグナリングで相手に送りたいDSP。UNITYはサーバーへリレーする
- signalingFlg = false;//dsp生成中で次のdsp作成ができない間true
- if (json.type == “offer”) {
- mainDevice = true;
- webViewObject1.EvaluateJS(“offerReSendStop()”);//オファーのUnity.callの繰り返しを終了
- } else {
- mainDevice = false;
- webViewObject1.EvaluateJS(“answerReSendStop()”);//アンサーのUnity.callの繰り返しを終了
- }
- string str = iceReplace(msg);//なぜか+が半角スペースに置き換わるバグがあるので” “を”+”に変換する。
- ws.Send(str);
- } else if (json.type == “answerSetted”) {
- signalingFlg = false;//dsp生成中で次のdsp作成ができない間true
- webViewObject1.EvaluateJS(“answerSettedReSendStop()”);//アンサー設定終了のUnity.callの繰り返しを終了
- }
- },
- err: (msg) => {
- Debug.Log(string.Format(“CallOnError[{0}]”, msg));
- },
- started: (msg) => {
- Debug.Log(string.Format(“CallOnStarted[{0}]”, msg));
- },
- hooked: (msg) => {
- Debug.Log(string.Format(“CallOnHooked[{0}]”, msg));
- },
- ld: (msg) => {
- //WebViewオブジェクトが読み込まれたら実行される
- Debug.Log(string.Format(“CallOnLoaded[{0}]”, msg));
- }
- , enableWKWebView: true);//iOS
- #if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX
- webViewObject1.bitmapRefreshCycle = 1;
- #endif
- //アンドロイド用ここから
- #if UNITY_ANDROID
- webViewObject1.SetMargins(10, 5500, 10, 200);//マージンの設定だが見えないようにしているだけ
- webViewObject1.SetTextZoom(100); // android only. cf. https://stackoverflow.com/questions/21647641/android-webview-set-font-size-system-default/47017410#47017410
- webViewObject1.SetVisibility(true);//falseで見えなくしてもいいかも
- var m_filePath = Path.Combine(Application.persistentDataPath, “sample.html”);
- var textAsset = Resources.Load(“_jsAndroid”) as TextAsset;
- var writer = new StreamWriter(m_filePath, false);
- writer.Write(textAsset.text);
- writer.Close();
- webViewObject1.LoadURL(“file://” + m_filePath);//上書き(AndroidはこのLoadURL形式でないとシグナリングの途中でjsが謎のエラーで落ちるのでこれで!)
- return;
- #endif
- //アンドロイド用ここまで
- //MAC用
- #if !UNITY_EDITOR_OSX
- webViewObject1.EvaluateJS(“window.Unity = { call: function (msg) { window.webkit.messageHandlers.unityControl.postMessage(msg); } }”);
- //iOS用
- #else
- webViewObject1.EvaluateJS(“window.Unity = { call: function(msg) { window.location = ‘unity:’ + msg; } };”);
- #endif
- webViewObject1.SetMargins(5000, 5500, 0, 0);//マージンの設定だが見えないようにしているだけ
- webViewObject1.SetVisibility(true);//falseで見えなくしてもいいかも
- var textAsset2 = Resources.Load(“_js”) as TextAsset;//Mac,iOS
- webViewObject1.EvaluateJS(textAsset2.text);//JavaScriptを読み込み
- } catch (Exception e) {
- Debug.Log(e.ToString());
- }
- }
- string iceReplace(string _json) {
- int index1 = _json.IndexOf(“ice-ufrag”);
- int index2 = _json.IndexOf(“ice-options”);
- string str1 = _json.Substring(0, index1);
- string str2 = _json.Substring(index1, index2 – index1);
- string str3 = _json.Substring(index2);
- if (str2.IndexOf(” “) >= 0) {
- Debug.Log(“★★★ replace ‘ ‘ to ‘+’ ★★★ 1 : ” + str2);
- //Debug.Log(str2);
- str2 = str2.Replace(” “, “+”);//+に置き換える
- Debug.Log(“★★★ replace ‘ ‘ to ‘+’ ★★★ 2 : ” + str2);
- //Debug.Log(str2);
- }
- _json = str1 + str2 + str3;
- return _json;
- }
- [Serializable]
- public class wsJson {
- public string type;
- public sdpClass dsp;
- public string msg;
- }
- [Serializable]
- public class sdpClass {
- public string type;
- public string sdp;
- }
UnityのAsset/Resources ディレクトに以下のテキストファイルを設置する。ただし、iOSの場合は、HTMLの部分(1~11行、400行以降)を削除する。
※シグナリングは、WebViewのJavaScritp関数を実行して行われてる。UnityからJavaScritpをWebViewオブジェクト.Evaluate()で実行して、結果をJavaScriptからUnity.Call()してもらって受け取る。受け取った結果をサーバーを経由して相手のクライアントに送る。
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset=”UTF-8″>
- <meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
- <title>WebRTC P2P</title>
- </head>
- <body>
- <script type=”text/javascript” charset=”UTF-8″>
- window.Unity = { call: function (msg) { window.location = ‘unity:’ + msg; } };
- let localStream = null;
- let peerConnections = [];
- let dataChannel = [];
- let dataChannelOptions = {
- ordered: true,
- relaible: true,
- };
- var myId = 0;
- RTCPeerConnection = window.RTCPeerConnection;
- var dsp;
- var offerMsg;
- var answerMsg;
- var answerSettedMsg;
- var offerTimer;
- var answerTimer;
- var answerSettedTimer;
- function setMyId(id) {
- myId = id;
- let json = JSON.stringify({ type: ‘log’, msg: ‘set my id:’ + myId });
- Unity.call(json);
- };
- function sendOffer(id) {
- if (peerConnections[id] != null) {
- } else {
- makeOffer(id);
- };
- };
- function getOffer(id) {
- if (peerConnections[id] != null) {
- return;
- };
- let offer = new window.RTCSessionDescription(dsp);
- setOffer(id, offer);
- };
- function getAnswer(id) {
- if (peerConnections[id] == null) {
- let json2 = JSON.stringify({ type: ‘log’, msg: ‘getAnswer but peer is null id:’ + id });
- Unity.call(json2);
- return;
- };
- let answer = new window.RTCSessionDescription(dsp);
- setAnswer(id, answer);
- };
- function makeOffer(id) {
- if (peerConnections[id]) {
- let error1 = JSON.stringify({ type: ‘error’, msg: ‘peer not null 2’ });
- Unity.call(error1);
- };
- peerConnections[id] = prepareNewConnection(id);
- var peerConnection = peerConnections[id];
- // Data channel を生成;
- dataChannel[id] = peerConnection.createDataChannel(‘test-data-channel’, dataChannelOptions);
- setupDataChannel(dataChannel[id]);
- peerConnection.createOffer()
- .then(function (sessionDescription) {
- return peerConnection.setLocalDescription(sessionDescription);
- }).then(function () {
- }).catch(function (error) {
- let error2 = JSON.stringify({ type: ‘error’, msg: error.message });
- Unity.call(error2);
- });
- };
- function setAnswer(id, sessionDescription) {
- var peerConnection = peerConnections[id];
- if (!peerConnection) {
- let error1 = JSON.stringify({ type: ‘error’, msg: ‘peer null 1’ });
- Unity.call(error1);
- return;
- };
- peerConnection.setRemoteDescription(sessionDescription)
- .then(function () {
- answerSettedMsg = JSON.stringify({ type: ‘answerSetted’, msg: ‘remote answer setted from id:’ + id });
- Unity.call(answerSettedMsg);
- answerSettedTimer = setInterval(function(){answerSettedReSend()},50);
- }).catch(function (error) {
- let error2 = JSON.stringify({ type: ‘error’, msg: error.message });
- Unity.call(error2);
- });
- };
- function setOffer(id, sessionDescription) {
- if (peerConnections[id]) {
- let error1 = JSON.stringify({ type: ‘error’, msg: ‘peer not null 1’ });
- Unity.call(error1);
- };
- peerConnections[id] = prepareNewConnection(id);
- var peerConnection = peerConnections[id];
- peerConnection.setRemoteDescription(sessionDescription)
- .then(function () {
- makeAnswer(peerConnection);
- }).catch(function (error) {
- let error2 = JSON.stringify({ type: ‘error’, msg: error.message });
- Unity.call(error2);
- });
- };
- function makeAnswer(peerConnection) {
- if (!peerConnection) {
- let error1 = JSON.stringify({ type: ‘error’, msg: ‘peer null 2’ });
- Unity.call(error1);
- return;
- };
- peerConnection.createAnswer()
- .then(function (sessionDescription) {
- return peerConnection.setLocalDescription(sessionDescription);
- }).then(function () {
- }).catch(function (error) {
- let error2 = JSON.stringify({ type: ‘error’, msg: error.message });
- Unity.call(error2);
- });
- };
- function offerReSend(){
- Unity.call(offerMsg);
- };
- function offerReSendStop(){
- try{
- clearInterval(offerTimer);
- offerTimer = null;
- }catch{
- }
- };
- function answerReSend(){
- Unity.call(answerMsg);
- };
- function answerReSendStop(){
- try{
- clearInterval(answerTimer);
- answerTimer = null;
- }catch{
- }
- };
- function answerSettedReSend(){
- Unity.call(answerSettedMsg);
- };
- function answerSettedReSendStop(){
- try{
- clearInterval(answerSettedTimer);
- answerSettedTimer = null;
- }catch{
- }
- };
- function prepareNewConnection(id) {
- let pcConfig = {“iceServers”:[
- {“urls”: “stun:stun.l.google.com:19302”},
- {“urls”: “stun:stun1.l.google.com:19302”},
- {“urls”: “stun:stun2.l.google.com:19302”}
- ]};
- let peer = new RTCPeerConnection(pcConfig);
- peer.onicecandidate = function (event) {
- if (event.candidate) {
- } else {
- if (peer.localDescription.type == ‘offer’) {
- offerMsg = JSON.stringify({ type: ‘offer’, id: myId, toid: id, dsp: peer.localDescription });
- Unity.call(offerMsg);
- offerTimer = setInterval(function(){offerReSend()},50);
- } else {
- answerMsg = JSON.stringify({ type: ‘answer’, id: myId, toid: id, dsp: peer.localDescription });
- Unity.call(answerMsg);
- answerTimer = setInterval(function(){answerReSend()},50);
- };
- };
- };
- peer.oniceconnectionstatechange = function () {
- if (peer.iceConnectionState === ‘disconnected’) {
- hangUp(‘from other member’);
- };
- };
- peer.onremovestream = function (event) {
- remoteVideo.pause();
- remoteVideo.srcObject = null;
- };
- if (localStream) {
- peer.addStream(localStream);
- };
- peer.ondatachannel = function (event) {
- setupDataChannel(event.channel);
- dataChannel[id] = event.channel;
- };
- return peer;
- };
- function hangUp(str) {
- try {
- const keys = Object.keys(peerConnections);
- for (let i = 0; i < keys.length; i++) {
- let key = keys[i];
- let val = peerConnections[key];
- if (str == ‘from me’) {
- let json2 = JSON.stringify({ type: ‘log’, msg: ‘delete peer’ });
- Unity.call(json2);
- peerConnections[key].close();
- delete peerConnections[key];
- } else {
- if (peerConnections[key].iceConnectionState === ‘disconnected’) {
- peerConnections[key].close();
- delete peerConnections[key];
- };
- };
- };
- } catch {
- };
- };
- function hangUpAll() {
- const keys1 = Object.keys(peerConnections);
- for (let i = 0; i < keys1.length; i++) {
- let key = keys1[i];
- try {
- peerConnections[key].close();
- } catch {
- let json = JSON.stringify({ type: ‘error’, msg: ‘close peerConnections error’ });
- Unity.call(json);
- };
- try {
- delete peerConnections[key];
- } catch {
- let json = JSON.stringify({ type: ‘error’, msg: ‘delete peerConnections error’ });
- Unity.call(json);
- };
- };
- const keys2 = Object.keys(dataChannel);
- for (let i = 0; i < keys2.length; i++) {
- let key = keys2[i];
- try {
- dataChannel[key].close();
- } catch {
- let json = JSON.stringify({ type: ‘error’, msg: ‘close datachannel error’ });
- Unity.call(json);
- };
- try {
- delete dataChannel[key];
- } catch {
- let json = JSON.stringify({ type: ‘error’, msg: ‘delete datachannel error’ });
- Unity.call(json);
- };
- };
- };
- function setupDataChannel(dc) {
- dc.onerror = function (error) {
- };
- dc.onmessage = function (event) {
- Unity.call(event.data);
- };
- dc.onopen = function (event) {
- let json = JSON.stringify({ type: ‘log’, msg: ‘dataChannel open!! js’ });
- Unity.call(json);
- //データチャンネル接続を確立された側 コネクション確立時にキャラクターを送信してインスタンスを生成する;
- let json1 = JSON.stringify({ type: ‘dataChannelMsgFirst’, id: myId, charaNum: _charaNum, anim: _anim, finishFlg: _finishFlg, score: _score, fromx: _fromx, fromy: _fromy, fromz: _fromz, tox: _tox, toy: _toy, toz: _toz, speed: _speed, targetId: _targetId, name: _name, country: _country, best: _best, win: _win, lose: _lose, sound: _sound, env: _env });
- dc.send(json1);
- };
- dc.onclose = function () {
- try {
- const keys = Object.keys(dataChannel);
- for (let i = 0; i < keys.length; i++) {
- let key = keys[i];
- let val = dataChannel[key];
- if (val.readyState === ‘closed’) {
- delete dataChannel[key];
- let json = JSON.stringify({ type: ‘dataChannelClosed’, id: key });
- Unity.call(json);
- };
- };
- } catch {
- };
- };
- };
- function sendmessage() {
- try {
- for (let key in dataChannel) {
- if (dataChannel[key] != null) {
- let json1 = JSON.stringify({ type: ‘dataChannelMsg’, id: myId });
- dataChannel[key].send(json1);
- };
- };
- } catch {
- };
- };
- function getMember() {
- var member = ”;
- for (let key in peerConnections) {
- member += key + ‘,’;
- };
- let json = JSON.stringify({ type: ‘member’, msg: member });
- Unity.call(json);
- };
- function getDataChannel() {
- var member = ”;
- for (let key in dataChannel) {
- member += key + ‘,’;
- };
- let json = JSON.stringify({ type: ‘dataChannelMember’, msg: member });
- Unity.call(json);
- };
- function reload() {
- };
- </script>
- </body>
- </html>
③UnityのサーバーとのWebSocket通信用の処理
Unityに埋め込むコードはこちら。詳細は、WebSocket(ws)を使ってサーバーと通信するUnityアプリを作る手順を参照して下さい。ほぼ同じ内容です。
- void WebSocketMethod() {
- ws = new WebSocket(“wss://*********”);// 本番サーバー
- ws.WaitTime = TimeSpan.FromSeconds(2);
- // 接続時に呼ばれる
- ws.OnOpen += (sender, e) => {
- Debug.Log(“open”);
- };
- // クローズ時に呼ばれる
- ws.OnClose += (sender, e) => {
- //wssのときだけ(wsはエラー出ない)ハンドシェイクでエラーが出るようで、その対応
- var sslProtocolHack = (System.Security.Authentication.SslProtocols)(SslProtocolsHack.Tls12 | SslProtocolsHack.Tls11 | SslProtocolsHack.Tls);
- //TlsHandshakeFailure
- if (e.Code == 1015 && ws.SslConfiguration.EnabledSslProtocols != sslProtocolHack) {
- ws.SslConfiguration.EnabledSslProtocols = sslProtocolHack;
- ws.Connect();
- }
- return;
- };
- // エラー時に呼ばれる
- ws.OnError += (sender, e) => {
- Debug.Log(e.Message);
- };
- // 接続
- ws.SslConfiguration.EnabledSslProtocols = (System.Security.Authentication.SslProtocols)(SslProtocolsHack.Tls12 | SslProtocolsHack.Tls11 | SslProtocolsHack.Tls);
- ws.Connect();
- ws.OnMessage += (sender, e) => {
- var json = JsonUtility.FromJson<wsJson>(e.Data);
- //P2Pに失敗し、サーバー経由で通信するとき用
- if(json.type == “dataChannelMsg” || json.type == “dataChannelMsgFirst”) {
- webrtcErrorFlg = true;
- //dataChannel経由のメッセージ受信時
- dataBuf[dataWritePnt] = json;
- dataWritePnt++;
- if (dataWritePnt >= dataBuf.Length) {
- dataWritePnt = 0;
- }
- if (debugFlg1 && dataWritePnt == dataReadPnt) {
- Debug.Log(“▲▲▲▲▲▲ dataBuf オーバーフロー ▲▲▲▲▲▲▲”);
- }
- if (json.type == “dataChannelMsgFirst”) {
- Debug.Log(“receive ws dataChannelMsgFirst”);
- sendFirst(“dataChannelMsg”);//こちらのプレイヤーオブジェクト生成のため返事を折り返す
- }
- //通常、受信したメッセージはそのままメモリに格納してUpdateで読み込むことにした
- } else {
- wsBuf[wsWritePnt] = json;
- wsWritePnt++;
- if (wsWritePnt >= wsBuf.Length) {
- wsWritePnt = 0;
- }
- }
- };
- }
④Unityでのシグナリングの中継処理
コードはこちら。Updateで実行します。基本的な動きは、初回サーバー接続にサーバーからIDを受け取って記憶し、その後サーバーを中継してシグナリングを行います。結果として、データチャンネルが開通すると、データチャンネルからメッセージを受信できるようになります。こまかい部分は、調整して下さい。
- if (wsWritePnt != wsReadPnt && !signalingFlg) {//signalingFlg = dsp生成中で次のdsp作成ができない間true
- //サーバーから割り当てられたidを記憶する
- if (wsBuf[wsReadPnt].type == “id”) {
- myId = wsBuf[wsReadPnt].id;
- Debug.Log(“receive my id:” + myId);
- //サーバーからオファーを待つように指示を受けたとき
- } else if (wsBuf[wsReadPnt].type == “signaling start”) {
- Debug.Log(“From server singaling start”);
- signalingStartFlg = true;
- signalingTimer = 0f;
- //サーバーからオファーを送信するように指示をうけたとき
- } else if (wsBuf[wsReadPnt].type == “send offer”) {
- signalingStartFlg = true;
- signalingTimer = 0f;
- Debug.Log(“From server send offer, opponentId:” + opponentId);
- //マッチング成立時、サーバーからオファー送信指示(offer Descriptionを送信する)
- signalingFlg = true;//dsp生成中で次のdsp作成ができない間true
- Debug.Log(“receive send offer”);
- //インタースティシャル表示でオブジェクトを破壊した後の場合のために必要
- Debug.Log(“webViewObject1.EvaluateJS(myId = ‘” + myId);
- webViewObject1.EvaluateJS(“myId = ‘” + myId);
- webViewObject1.EvaluateJS(“sendOffer(” + wsBuf[wsReadPnt].id + “)”);//json.idは相手(オファー先)のidが入ってくる
- //クライアントからオファーを受けたとき(アンサーを返す)
- } else if (wsBuf[wsReadPnt].type == “offer”) {
- if (signalingStartFlg) {
- //他のプレイヤーからオファーを受領したとき(Answer Descriptionを返す)
- if (!peers.ContainsKey(wsBuf[wsReadPnt].id)) {
- peers[wsBuf[wsReadPnt].id] = “recieved”;
- peersCnt++;
- signalingFlg = true;//dsp生成中で次のdsp作成ができない間true
- Debug.Log(“receive offer”);
- var sdpJson = JsonUtility.ToJson(wsBuf[wsReadPnt].dsp);
- sdpJson = iceReplace(sdpJson);
- webViewObject1.EvaluateJS(“myId = ‘” + myId);
- Debug.Log(“receive offer2” + sdpJson);
- webViewObject1.EvaluateJS(“dsp = ” + sdpJson + “;getOffer(” + wsBuf[wsReadPnt].id + “)”);//json.idは相手のid(オファーしてきた相手のid)が入ってくる
- }
- }
- //オファーした相手からアンサーを受けたとき
- } else if (wsBuf[wsReadPnt].type == “answer”) {
- if (signalingStartFlg) {
- if (!peers.ContainsKey(wsBuf[wsReadPnt].id)) {
- peers[wsBuf[wsReadPnt].id] = “recieved”;
- peersCnt++;
- signalingFlg = true;//dsp生成中で次のdsp作成ができない間true
- Debug.Log(“receive answer”);
- var sdpJson = JsonUtility.ToJson(wsBuf[wsReadPnt].dsp);
- sdpJson = iceReplace(sdpJson);
- webViewObject1.EvaluateJS(“dsp = ” + sdpJson + “;getAnswer(” + wsBuf[wsReadPnt].id + “)”);//json.idは相手のid(アンサーしてきた相手のid)が入ってくる
- }
- }
- }
- wsReadPnt++;
- if (wsReadPnt >= wsBuf.Length) {
- wsReadPnt = 0;
- }
⑤WindowsPC上のNode.jsによるシグナリングサーバーの例
コードはこちら。シグナリングの中継と、ルームの作成・マッチングを行う一例です。
- “use strict”;
- const https = require(‘https’);
- const fs = require(‘fs’);
- let WebSocketServer = require(‘ws’).Server;
- let port = ****;//環境にあわせる
- var server = https.createServer({
- key: fs.readFileSync(‘********.pem’, ‘utf8’),//環境にあわせる
- cert: fs.readFileSync(‘********.pem’, ‘utf8’)//環境にあわせる
- });
- let wssServer = new WebSocketServer({server});
- server.listen(port);
- console.log(‘websocket server start. port=’ + port);
- //var count = 0;
- var arr = Array(1000).fill(0);
- var rooms = [];
- //クライアントの生存確認、インターバル中にaliveがtrueにならないとき(pingがないとき)クローズする
- var fn = function(ws){
- if(ws.alive == false){
- console.log(“タイムアウト:” + ws.id)
- clearInterval(ws.timer)
- ws.close();
- return;
- }
- ws.alive = false;
- console.log(“set alive false”);
- }
- //データベースを更新する処理はループ処理で定期的に実行する(複数の処理を同時に実行しない)
- setInterval(function(){insertDB()},1000);//@@@
- //クライアントの生存確認、インターバル中にaliveがtrueにならないとき(pingがないとき)クローズする
- var insertDB = function(){
- if(dbque11.length > 0 && !dbflg){
- console.log(“dbflg = true”);
- dbflg = true;
- var ws1 = dbque11.shift();
- var json1 = dbque12.shift();
- setTimeout(()=>sendScoreDesc(ws1,json1), 1);//セットタイムアウトで1ms後(非同期にしたいだけ)
- }else if(dbque21.length > 0 && !dbflg){
- console.log(“dbflg = true”);
- dbflg = true;
- var ws1 = dbque21.shift();
- var json1 = dbque22.shift();
- setTimeout(()=>sendScoreAsc(ws1,json1), 1);//セットタイムアウトで1ms後(非同期にしたいだけ)
- }
- }
- wssServer.on(‘connection’, function(ws) {
- //count++;
- for (let i = 0; i < arr.length; i++) {
- if(arr[i] != 1){
- arr[i] = 1;
- ws.id = i;
- break;
- }
- }
- //初回接続時,id通知
- console.log(‘connect id:’ + ws.id + ‘ count:’ + wssServer.clients.size);
- let msg = JSON.stringify({type:’id’,id:ws.id,msg:wssServer.clients.size,country:ws.country});
- ws.send(msg);
- //キープアライブ(クライアントの生存確認)タイマーを設定
- ws.alive = true;
- ws.timer = setInterval(function(){fn(ws)},10000);//@@@
- ws.on(‘message’, function(message) {
- try{
- var json = JSON.parse(message);
- //キープアライブ(クライアントの生存確認)のアライブを設定
- if(json.type == “ping”){//@@@
- ws.alive = true;
- //console.log(‘ping!’);
- let msg = JSON.stringify({type:’pong’,msg:wssServer.clients.size});
- ws.send(msg);
- return;
- }
- if(json.type == “debug”){
- console.log(“receive debug”);
- wssServer.clients.forEach(function each(client) {
- if (isSame(ws, client)) {
- } else {
- client.send(message);
- }
- });
- }else if(json.type == “dataChannelMsg”){//そのまま転送
- console.log(“get dataChannelMsg”);
- wssServer.clients.forEach(function each(client) {
- if (isSame(ws, client)) {
- } else {
- client.send(message);
- }
- });
- }else if(json.type == “join”){
- console.log(“receive join”)
- ws.game = json.game;
- var max = json.max;
- var serverid = ws.id;
- if(!(json.game in rooms)){
- //ルームを作成する
- console.log(“room make id:” + ws.id);
- var room = {};
- room[serverid] = ws;//
- rooms[json.game] = room;
- //参加成功通知を返す
- let msg1 = JSON.stringify({‘type’:’join success’,’id’:ws.id});
- ws.send(msg1);
- return;
- }else{
- var room = rooms[json.game];//
- room[serverid] = ws;//
- if(Object.keys(room).length != max){
- //ルームの最大数でない場合は、ルームに追加して更新する
- console.log(“room update member:” + Object.keys(room).length + ” id:” + ws.id);
- rooms[json.game] = room;//
- //参加成功通知を返す
- let msg1 = JSON.stringify({‘type’:’join success’,’id’:ws.id});
- ws.send(msg1);
- return;
- }else{
- //ルームの最大数に到達した場合は、ルームを削除して、ゲーム開始の通知をする
- console.log(“room max > send > delete”);
- const keys = Object.keys(room);
- var msg = “”;
- if(max == 2){
- let ws1 = room[keys[0]];
- let ws2 = room[keys[1]];
- msg = JSON.stringify({‘type’:’send offer’,’id’:ws2.id});
- ws1.send(msg);
- msg = JSON.stringify({type:’signaling start’});
- ws2.send(msg);
- }else if(max == 3){
- let ws1 = room[keys[0]];
- let ws2 = room[keys[1]];
- let ws3 = room[keys[2]];
- msg = JSON.stringify({type:’send offer’,id:ws2.id});
- ws1.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws3.id});
- ws1.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws3.id});
- ws2.send(msg);
- msg = JSON.stringify({type:’signaling start’});
- ws3.send(msg);
- }else if(max == 4){
- let ws1 = room[keys[0]];
- let ws2 = room[keys[1]];
- let ws3 = room[keys[2]];
- let ws4 = room[keys[3]];
- msg = JSON.stringify({type:’send offer’,id:ws2.id});
- ws1.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws3.id});
- ws1.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws4.id});
- ws1.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws3.id});
- ws2.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws4.id});
- ws2.send(msg);
- msg = JSON.stringify({type:’send offer’,id:ws4.id});
- ws3.send(msg);
- msg = JSON.stringify({type:’signaling start’});
- ws4.send(msg);
- }
- //ルームを削除
- for(let key in rooms[json.game]){
- delete rooms[json.game][key];
- }
- delete rooms[json.game];
- //クライアントにルームクローズを通知
- wssServer.clients.forEach(function each(client) {
- var msg = JSON.stringify({type:’room close’,msg:json.game});
- client.send(msg);
- console.log(“send room close to ” + client.id);
- });
- }
- }
- }else if(json.type == “break”){
- //退出したクライアントをルームから削除
- deleteRoomMember(json.id,json.game);
- }else if(json.type == “offer” || json.type == “answer”){
- //クライアント間のシグナリングをリレーするだけ
- wssServer.clients.forEach(function each(client) {
- if (client.id == json.toid) {
- console.log(“send ” + json.type + ” ” + ws.id + ” to:” + json.toid);
- client.send(message);
- }
- });
- }
- }catch (e){
- console.log(e.name + “:” + e.message);
- }
- });
- ws.on(‘close’, () => {
- try{
- connectClose(ws);
- clearInterval(ws.timer)
- }catch (e){
- console.log(“error:”,e.message);
- }
- });
- });
- function connectClose(_ws){
- try{
- //count -= 1
- console.log(‘close id:’ + _ws.id + ‘ count:’ + wssServer.clients.size);
- arr[_ws.id] = 0;
- if(_ws.game && _ws.game in rooms){
- deleteRoomMember(_ws.id,_ws.game);
- }
- }catch (e){
- console.log(“error:”,e.message);
- };
- }
- function deleteRoomMember(id,game){
- var room = rooms[game];//
- if(room == null){
- return;
- }
- if(id in room){
- if(Object.keys(room).length == 1){
- delete rooms[game];//
- console.log(“delete room for game:” + game);
- //クライアントにルームクローズを通知
- wssServer.clients.forEach(function each(client) {
- var msg = JSON.stringify({type:’room close’,msg:game});
- client.send(msg);
- console.log(“send room close to ” + client.id);
- });
- }else{
- delete room[id];//
- rooms[game] = room;//
- console.log(“update rooms (delete id:” + id + “)”);
- }
- }
- }
- function isSame(ws1, ws2) {
- return (ws1 === ws2);
- }
4.データベース
データベースをサーバーPC上に作成し、サーバーとクライアントから利用する方法です。ソフトはMySQL(MariaDB)を使います。とりあえず、データベースを無料で作成して、利用するだけであればとても簡単です。
comment