この記事に関わる記事一覧
はじめに
Node.jsを使ってみたく、実際に手を動かして何か作ってみるのが一番だと思い、
自分なりにプレイヤーのマッチングや同期の取り方を考えてやってみようと思いました。
Node.js(サーバー側)よりもUnity(クライアント側)で苦戦しました...
私はNode.js,リアルタイム通信の知識がそこまであるわけではないので、
素人なりにどう考えて実装していったかの記録を残していきたいと思い記事を書き始めました。
コードの書き方に正解はないと思うので、これから書いていく記事を通して
今回作成したゲームの作り方の概念だけ書いていこうかなと思います。
成果物
ソースコード公開しました!
クライアントプロジェクト(Unity)
サーバープロジェクト(Node.js)
実行方法
1.対戦するのには2つクライアントが必要なので、あらかじめビルドしておきます
2.サーバープロジェクトのindex.jsをnodeで実行
3.クライアントプロジェクトのMenuシーンと1でビルドしたアプリを実行
4.両アプリでマッチング開始
操作方法
ドラッグ:移動
タップ :自機が向いている方向に弾を発射
スクリーンショット
プロジェクトのディレクトリ構成
今回私が作成したNode.jsのプロジェクトの構成は以下のようになっております。
ProjectRoot
|-node_modules
|- 省略
|-src
|- inputRelay.js
|- MatchingRoom.js
|- MessageSwitcher.js
|- Utils.js
|- index.js
|- package-lock.json
|- package.json
Node.jsでUDP通信
これから載せていくコードは実際にサーバーで動いているものを載せていきます。
Node.jsのインストール方法は他の記事をご参考ください。
const PORT = 33333
const dgram = require('dgram');
const server = dgram.createSocket('udp4');
var MessageSwitcher = require('./src/MessageSwitcher.js');
var switcher = new MessageSwitcher(server);
server.on('error', (err) => {
console.log(`server error:\n${err.stack}`);
server.close();
});
server.on('message', (msg, rinfo) => {
switcher.switchMessage(msg,rinfo.port,rinfo.address);
});
server.on('listening', () => {
const address = server.address();
console.log(`server listening ${address.address}:${address.port}`);
});
server.bind(PORT);
Node.jsでUDP通信を扱うためにはdgramというクラスを使います。
MessageSwitcherはこの後解説します。
この形はほとんどお約束だと思いますので、
dgram.createSocket('udp4');
で何が返ってきているとか細かいことは調べなくていいです。
以下のことを抑えられれば、大丈夫だと思います。
ポート番号
const PORT = 33333
pcやスマホではipアドレスが割り当てられていますが、
通信したいときipアドレスを指定しただけではアプリケーションに情報が届きません。
pcでは色々なアプリが起動していると思いますが、もしipアドレスだけだったら
全部のアプリケーションに同じ情報が送られてしまいます。
これを防ぐのがポート番号です。
Webブラウザで使う80番ポートの他にも色々ありますが、すでに予約されている番号と
使われていない番号があります。
ここでは使われていない番号を指定します。
server.on
これはコールバック関数で、何か起こった時自動的に呼ばれる関数です。
第一引数の文字列でその何かと関連づけるか指定します。
()=>{}
第二引数はC#でいうラムダ関数です。(JavaScriptだとこの書き方はアロー関数っていうみたいです)
私のイメージですが、関数を変数にして扱えるような感じです。
処理を関数に渡したいときに引数に入れられたり、処理を変数に保持しておきたいときに使えます。
以下第一引数、第二引数のアロー関数の中の引数の説明です。
第一引数 | 説明 |
---|---|
error | サーバーでエラーが出たときに呼ばれる |
message | サーバーに何か届いたときに呼ばれる |
listening | サーバーの受付が開始したときに呼ばれる |
※まとめて書くので、上記のコードの引数の名前と照らし合わせてご確認ください。
第二引数 | 説明 |
---|---|
err | どんなエラーが出たかの情報が入る |
msg | 送られてきた情報の文字列。今回はJSON形式で扱っている |
rinfo | どこから送られてきたかという情報が入ってくる(ipアドレス、ポート番号) |
Node.jsでクラスを使う
以下のように書けばクラスとして使えます。
//コンストラクタ
var ClassName = function(){
}
//以下の形で関数を定義
ClassName.prototype.functionName = function(){
}
ClassName.prototype.functionName2 = function(hoge,fuga){
}
module.exports = ClassName;
そして使いたいファイルの中で
var ClassName = require('ClassName.js');
var class = new ClassName();
class.functionName();
var hoge = 1;
var fuga = 2;
class.functionName2(hoge,fuga);
requireの引数にはファイルの場所を入れます。
ルーティング
導入編で書いたJSONに共通で入れているtypeキーによって、処理を分岐します。
var Utils = require('./Utils.js');
var utils = new Utils();
var MatchingRoom = require('./MatchingRoom.js');
var InputRelay = require('./InputRelay.js');
var MessageSwitcher = function(server){
this.server = server;
this.matchingRoom = new MatchingRoom(this.server);
this.inputRelay = new InputRelay(this.server);
}
MessageSwitcher.prototype.switchMessage = function(msg,port,address){
try{
var json = JSON.parse(msg);
if(!json['type']){
utils.sendErrorJson("it is not include type",port,address,this.server);
return false;
}
switch (json['type']) {
case "match":
{
utils.writeLogWithDate(`server got: ${msg} from ${address}:${port}`);
this.matchingRoom.join(json,port,address);
break;
}
case "input":
{
//ログの容量が大きくなってしまうから出力しない
//utils.writeLogWithDate(`server got: ${msg} from ${address}:${port}`);
this.inputRelay.relay(json,port,address);
break;
}
case "hit-bullet":
{
//ログの容量が大きくなってしまうから出力しない
//utils.writeLogWithDate(`server got: ${msg} from ${address}:${port}`);
this.inputRelay.hitInfoRelay(json,port,address);
break;
}
default:
{
utils.writeLogWithDate(`server got: ${msg} from ${address}:${port}`);
utils.sendErrorJson("unknown type",port,address,this.server);
break;
}
}
}catch(e){
utils.sendErrorJson(e.message,port,address,this.server);
return false;
}
return true;
}
module.exports = MessageSwitcher;
細かいことは省きますが、基本的にswitch文で分岐させているだけです。
もっといいやり方はあるとは思いますが、単純で分かりやすいと思います。
ただ、分岐が複雑になるとこの方法じゃダメですね。
マッチング
今回作成したアプリのマッチングは、
- 基本的に1対1のマッチング
- ルーム作成などはしていない
- 最初にマッチング開始したユーザーの5秒以内にほかのユーザーがマッチング開始した場合にマッチングが成立する
という仕様で作成いたしました。
var Utils = require('./Utils.js');
var utils = new Utils();
//現在の書き方だと2以外指定できない
const MATCH_NUM = 2;
var MatchingRoom = function(server){
this.server = server;
this.waitingPlayer = [];
}
MatchingRoom.prototype.join = function(json,port,address){
//マッチング開始したユーザー返すユーザー情報
var data = {};
data['type'] = 'playerInfo';
data['id'] = utils.getUniqueStr();
data['name'] = json['name'];
utils.sendJsonAndWriteLog(data,port,address,this.server);
data['port'] = port.toString();
data['address'] = address;
this.waitingPlayer.push(data);
if(this.waitingPlayer.length >= MATCH_NUM){
//マッチング成立
for(var i = 0; i < MATCH_NUM; i++){
var otherIndex;
if(i==0){
otherIndex = 1;
}else if(i==1){
otherIndex = 0;
}
//成立したマッチング情報を送る
var matchData = {};
matchData['type'] = "success-match";
matchData['rival'] = this.waitingPlayer[otherIndex];
var own = this.waitingPlayer[i];
utils.sendJsonAndWriteLog(matchData,own['port'],own['address'],this.server);
}
//マッチングが成立しなかった場合のタイムアウト処理を削除する
if(this.waitingPlayerTimeOut)
clearTimeout(this.waitingPlayerTimeOut);
this.waitingPlayer = [];
}else{
// マッチング不成立(5秒後)
//5秒以内にマッチングが成立しなかった場合に成立しなかった旨の情報を送信
this.waitingPlayerTimeOut = setTimeout((d) => {
if(this.waitingPlayer.length >= MATCH_NUM)
return;
//マッチング不成立情報
var notMatch = {};
notMatch['type'] = "not-match";
notMatch['msg'] = "sorry not matching";
utils.sendJsonAndWriteLog(notMatch,d['port'],d['address'],this.server);
this.waitingPlayer.pop();
}, 5000,data);
}
}
module.exports = MatchingRoom;
waitingPlayerを配列にしておりますが、2つまでしか入ることはありません。
3つ以上入る場合はまた、処理を変更する必要が出てきます。
設計がよくない...
コードを読んでいただければわかると思いますが、setTimeoutが分かりにくいと思いますので補足します。
処理を省略するとこんな感じになります。
this.waitingPlayerTimeOut = setTimeout((d) => {/*省略*/}, 5000,data);
setTimeout関数は指定ミリ秒以降に関数を一回だけ実行します。
引数は
setTimeout(処理,何ミリ秒に処理を実行するか,処理の引数に渡す値)
になっています。
上の書き方だとdataがdに渡ることになります。
処理の引数に渡す値は2個以上も書けます。
例
setTimeout((h,f)=>{},5000,hoge,fuga);
中継
マッチング後の入力情報などを端末同士で送りあうために、サーバーを中継して送っております。
サーバーに負荷はかかってしまいますが、クライアント同士の接続処理を書かなくてよくなるので扱いは楽になると思います。
var Utils = require('./Utils.js');
var utils = new Utils();
var InputRelay = function(server){
this.server = server;
}
InputRelay.prototype.relay = function(json,port,address){
var to = JSON.parse(json['rival']);
var sendData = {};
sendData['type'] = "rival-input";
sendData['requireNextFrame'] = json['requireNextFrame'];
sendData['inputObjects'] = json['inputObjects'];
utils.sendJsonAndWriteLog(sendData,to['port'],to['address'],this.server,false);
}
InputRelay.prototype.hitInfoRelay = function(json,port,address){
var to = JSON.parse(json['rival']);
var sendData = {};
sendData['type'] = "hit-bullet";
sendData['bulletType'] = json['bulletType'];
sendData['fireFrame'] = json['fireFrame'];
sendData['own'] = json['rival'];
sendData['rival'] = json['own'];
utils.sendJsonAndWriteLog(sendData,to['port'],to['address'],this.server,false);
}
module.exports = InputRelay;
名前の付け方がよくなかったのですが、
relayが入力情報の中継
hitInfoRelayが弾のヒット情報の中継になっています。
そもそもInputRelayというクラス名がよくないかなと後々思いました。
クラス名はRelayでよかったと思います。
そしてrelay関数をinputRelayとかにしても良いような気がします。
relay関数もhitInfoRelay関数も送られてきた情報を別のユーザーに送信しているだけです。
relay関数では、
typeを変更して、必要な情報だけにしたオブジェクトを作成して送信
typeがinputで来た入力情報をrival-inputにして送信しております。
hitInfoRelay関数では、
own(自分自身)とrival(相手)のユーザー情報を入れ替えて送信
送受信しているJSONについては導入編でご確認ください。
Utility
共通で使う処理をまとめたクラスです
require('date-utils');
var Utils = function(){
}
//エラーがあった場合、クライアントにエラーメッセージを送信するための関数
Utils.prototype.sendErrorJson = function(errMsg,port,address,server){
var data = {};
data['type'] = "error";
data['msg'] = errMsg;
server.send(JSON.stringify(data),port,address);
console.log(this.getDateStr() + `: server send: ${JSON.stringify(data)} to ${address}:${port}`);
}
//JSONの送信とログの書き込みを行う
//ログの書き込みが不要な場合はisWriteLogをfalseにする
Utils.prototype.sendJsonAndWriteLog = function(json,port,address,server,isWriteLog=true){
var jsonStr = JSON.stringify(json);
server.send(jsonStr,port,address);
if(isWriteLog)
console.log(this.getDateStr() + `: server send: ${jsonStr} to ${address}:${port}`);
}
//ユニークな文字列を返す
Utils.prototype.getUniqueStr = function(myStrong){
var strong = 1000;
if (myStrong) strong = myStrong;
return new Date().getTime().toString(16) + Math.floor(strong*Math.random()).toString(16)
}
//現在の日時を文字列で返す
Utils.prototype.getDateStr = function(){
var dt = new Date();
var formatted = dt.toFormat("YYYY/MM/DD HH24hMImSSs");
return formatted;
}
//日時と連結してログを出力する
Utils.prototype.writeLogWithDate = function(str){
console.log(this.getDateStr() + ': ' + str);
}
module.exports = Utils;
date-utilsを使うために以下のコマンドをプロジェクトルート直下で行う必要があります。
npm install date-utils
サーバーでの起動
私がレンタルしているサーバーでは他にも起動しているアプリケーションがあるので、
バックグラウンドで実行するデーモンで今回のプロジェクトを実行する必要がありました。
調べてみるとforeverモジュールというものがありました。
デーモンで起動できるという点以外に便利だと思ったのが、
- ログをファイルに残してくれる
- エラーでアプリが落ちたときに起動しなおしてくれる
- 設定した時間で再起動してくれる
ここでは簡単に使い方を書きたいと思います。
※以下は私の環境(Mac)の方法です
インストール(プロジェクト直下で)
npm install forever
しかし、パーミッションエラーが出てインストールができないので、
ProjectRoot/node_modules/forever/ディレクトリのパーミッションを以下のコマンドで変更します。
sudo chmod 775 node_modules/forever/
再びインストールコマンドを打てばインストールできると思います。
開始
node_modules/forever/bin/forever start index.js
起動中のアプリを確認
node_modules/forever/bin/forever list
停止
起動中のアプリを確認し、表示されるpidを確認して
node_modules/forever/bin/forever stop 確認したpid
ログ確認
node_modules/forever/bin/forever logs index.js
または起動中のアプリを確認するときに、ログファイルの場所を知ることができます
パッケージマネージャー
この際なのでパッケージマネージャーについて書いときたいと思います。
上記でも出てきたnpmがパッケージマネージャーにあたります。
npm = Node Package Manager
これでNode.jsの開発で必要な機能をインストールして開発を進めていくことができます。
しかし、パッケージマネージャーの機能はインストールだけではありません。
複数人で開発を行う際、全員同じモジュールをインストールしなければなりません。
一つ一つインストールするのは面倒です。
ここでパッケージマネージャーの力が発揮されます。
今回の記事でも出てきましたが
npm install date-utils
を実行するとpackage.jsonにも、date-utilsがインストールされたことが記述されます。
それぞれの環境下で
npm install package.json
を実行すれば同じ環境で、開発を進めていくことができます。
node_modulesディレクトリは、インストールされたものが入るのでこちらは共有しなくても良いディレクトリです。
gitを使っている場合.gitignoreではじきましょう。
最後に
次回はUnityの実装について書いていきたいと思います。
自分のやったことをドキュメント化することで振り返りにもなり、
反省点も見えてくるなと今回の記事を書いてて感じました。
最近ドキュメント化していくことを大切にしようと思っていて、
次に開発するときに前つまずいたところでつまずかないようにできるように書いていきたいと思います。