卓球ゲーム

Screenshot from 2014-08-28 02:36:17

日経ソフトウェア2014年10月号「HTML5でゲームを作ろう(第11回)」に掲載されたオンライン対戦型の「卓球ゲーム」です。

右の画像をクリックするとゲームページを表示します。

今回も、プログラムソースには手を加えていません。

2014/08/30
gameClient.js の33〜38行目を追加。
画面を閉じたメッセージをサーバーに送信。

オンライン対戦ゲームなので、相手がゲームページを開いてくれないとゲームができません。

・・・「対戦相手が来るまで待ってね」と表示されます。

2人がゲームページを開いていると、対戦可能です。3番目以降にゲームページを開いた方は、現在プレイ中の方が画面を閉じるまで(=サーバとの接続を切るまで)プレイできません。

・・・3番目以降に接続した方には「サーバから切断されました」と表示されます。


現在、プレイ中の方が、画面を閉じたとき、サーバー上のgameServer.jsがエラーを吐き停止してしまいます。

   ・・・今回、自宅サーバー上に構築したが、インターネット上では使い物になりませんでした・・・。
   ・・・ローカル環境(LAN内)では正常に動くのですが・・・。
      (Nodeモジュールの WebSocket.js にバグがあるのかも???)

   ・・・client.sendを実行する部分でエラーになるようです。

   ・・・WAN側で2人接続後の切断時のみNG。
    ・・・単独(1人)接続時は画面を閉じてもgameServer.jsは停止しない。
     と言う事は、→initGame()→gameLoop()に入ってから、クライアントが切断した際に起こるのか。

   ・・・う〜ん。よくわからん。

   ・・・ローカル環境(LAN環境)では問題なく動いたので、ルーター周りの設定ミスだろうか???
      (でも、画面を閉じる前まではプレイできるので、それも考えにくい・・・。)

   ・・・Windows系のサーバーでは確認していないので、そちらの環境の方は設置して試してみて下さい。

遊び方は、画面下部に情報が表示されているように、Spaceキーでボールを打ちます。

赤色のラケットが自分のもので、カーソルキーの上下で動かすことができます。

3ポイント先取したほうが勝ちです。(打ち返せず3ポイント先取されたら負けです。)

ポイントは画面上部に表示されます。

勝敗が決まると画面下部に結果が表示されます。もう一度勝負したい場合は、Enterキーを押します。

(キー操作が必要なので、PCでないとプレイできません。)

卓球ゲームと言っても、ボールの軌跡は放物線は描かず(直線で)、ボールが上下の壁でバウンドするので、実際の卓球とは異なります。このようなゲームは「ポンゲーム」と呼ばれるそうです。



今回は、自宅サーバー機(CentOS 6.5)に、以下のコマンドで、Node.js、winsocket.ioを導入しました。

$ su -
# rpm -ivh http://ftp.riken.jp/Linux/fedora/epel/6/x86_64/epel-release-6-8.noarch.rpm
# yum install nodejs npm --enablerepo=epel
# npm install websocket.io

サーバー機側では、以下のコマンドを実行して、JavaScriptをサーバサイドで動かしています。

# node /var/www/html/game/takkyu/gameServer.js

また、ファイヤーウォール(iptables)と光ルータの設定を変更し、ポートを開放しました。



ソースはこちら。

<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>TAKKYU</title>
		<style>
			* {
				margin: 0;
				padding: 0;
			}
			body {
				background-color: black;
				color: white;
				text-align: center;
			}
			#game {
				margin: 10px;
			}
		</style>
		<script type="text/javascript" 
			src="gameClient.js"></script>
	</head>
	<body>
		<h3>* T A K K Y U *</h3>
		<div id="score"></div>
		<div id="game">
			<canvas id="mainCV" width="600" height="300">
			</canvas>
		</div>
		<div id="info"></div>
	</body>
</html>
// クライアント側の卓球ゲームプログラム
// グローバル変数
var SERVER_URI = "ws://www.a21-hp.com:8888";
var DISP_W = 600, DISP_H = 300;	// 画面サイズ
var ws;						// WebSocketのオブジェクト
var playerNo;				// プレイヤーID
var pNo = 0;
var cv, ctx;				// <canvas>と描画コンテキスト
var ball, players;		// ボールとプレヤーの情報
var resetAnime = -1;		// 画面を光らせるためのカウンター
// 初期化イベント
window.onload = function () {
	// 描画コンテキストの取得
	cv = $("mainCV");
	ctx = cv.getContext("2d");
	// キーイベントの設定
	window.onkeydown = keyDownHandler;
	window.onkeyup   = keyUpHandler;
	// サーバーへ接続する
	connectToServer();
};
// WebSocketサーバーへ接続する
function connectToServer() {
	ws = new WebSocket(SERVER_URI);
	ws.onopen = function () {
		console.log("サーバーに接続");
	};
	ws.onclose = function (e) {
		info("サーバーから切断されました");
	};
	ws.onmessage = commandFromServer;

	// サーバーから切断(ブラウザのタブを閉じる)
	window.addEventListener("beforeunload", function() {
		pNo = Number(playerNo) + 1;
		ws.send("disconnect=" + pNo); // WebSocket close
		ws.onclose();
	});
}

// サーバーから届くメッセージを処理
function commandFromServer(e) {
	var data = e.data;
	var ps = data.split(">", 2);
	var cmd = ps.shift();
	switch (cmd) {
		case "msg":
			info(ps[0]);
			break;
		case "you":
			playerNo = ps[0];
			break;
		case "start":
			resetAnime = 5;
			info(ps[0]);
			break;
		case "draw":
			var o = JSON.parse(ps[0]);
			console.log(o.ball.x, o.ball.y);
			ball = o.ball;
			players = o.players;
			draw();
			break;
		case "close":
			ws.close();
			info(ps[0]);
			break;
		case "gameover":
			info("勝負あり!" + 
				((ps[0] == playerNo) ? "勝ち" : "負け") + 
				"...再勝負はEnterキー");
			break;
		default:
			console.log(data);
	}
}
// 画面の描画
function draw() {
	ctx.clearRect(0,0,DISP_W,DISP_H);
	if (resetAnime-- > 0) {
		ctx.fillStyle
			= (resetAnime % 2 == 0) ? "blue" : "purple";
		ctx.fillRect(0,0,DISP_W,DISP_H);
	}
	ctx.strokeStyle = "gray";
	ctx.lineWidth = 1;
	ctx.strokeRect(0,0,DISP_W,DISP_H);
	// 中央に線
	var w = DISP_W / 2;
	ctx.beginPath();
	ctx.moveTo(w, 0);
	ctx.lineTo(w, DISP_H);
	ctx.stroke();
	// プレイヤーのラケットを表示
	for (var i = 0; i <= 1; i++) {
		var p = players[i];
		ctx.fillStyle = (playerNo == i) ? "red" : "gray";
		var w2 = p.w /2;
		ctx.fillRect(p.x - w2, p.y, p.w, p.h);
	}
	// ボールを描画
	ctx.beginPath();
	ctx.fillStyle = "white";
	ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
	ctx.fill();
	// スコア表示
	$("score").innerHTML = players[0].score + " : " + players[1].score;
}

// キーの処理
function keyDownHandler(e) {
	var code = e.keyCode;
	if (code == 38) {		// up
		ws.send("key>" + playerNo + ">-1");
	}
	else if (code == 40) {	// down
		ws.send("key>" + playerNo + ">1");
	}
	else if (code == 13) {	// Enter
		ws.send("enter>" + playerNo);
	}
	else if (code == 32) {	// Space
		ws.send("space>" + playerNo);
	}
}
function keyUpHandler(e) {
	ws.send("key>" + playerNo + ">0");
}
function info(msg) {
	$("info").innerHTML = msg;
}
function $(id) {
	return document.getElementById(id);
}
// WebSocketによるゲームサーバー
var ws = require("websocket.io");
// グローバル変数の宣言
var WS_PORT = 8888;	// Socketのポート番号
var DISP_W = 600, DISP_H = 300;	// 画面サイズ
var BALL_R = 8; BALL_SPEED = 20;	// ボールの情報
// ラケットの情報
var BAR_W = 16, BAR_H = 70, BAR_SPEED = 15;
var WIN_POINT = 3;				// 勝ち点の指定
var timerId = 0;					// タイマーID
var ball = {};						// ボール情報を管理
// プレイヤーとキー情報を管理
var players = [], keys = [];
var isStop = false, startPlayer = 0, isGameOver = true;

// WebSocketサーバーの起動
var server = ws.listen(WS_PORT, function () {
	console.log("起動しました!\nport=" + WS_PORT);
});
// サーバーへの接続イベント
server.on("connection", function (client) {
	console.log("clients=" + server.clientsCount);
	// 最大接続数の確認
	if (server.clientsCount > 2) {
		client.send("close>人数オーバー");
		client.close();
		return;
	}
	if (server.clientsCount == 1) {
		client.send("msg>対戦相手が来るまで待ってね");
		client.send("you>0");
	}
	else if (server.clientsCount == 2) {
		client.send("you>1");
		initGame();
	}
	console.log("connect");
	client.on('message', onMessage);
	client.on('error', function (err) {
		console.log('error:' + err);
	});
});

// ゲームの初期化
function initGame() {
	isGameOver = false;
	isStop = true;
	// 後から入った方が開始タイミングを決める
	startPlayer = 1;
	// ラケットの位置をセット
	var p0 = {no:0}, p1 = {no:1};
	p0.score = p1.score = 0;
	p0.x = BAR_W * 2;
	p1.x = DISP_W - BAR_W * 2;
	p0.y = p1.y = Math.floor((DISP_H - BAR_H) / 2);
	p0.w = p1.w = BAR_W;
	p0.h = p1.h = BAR_H;
	players = [p0, p1];
	keys = [0, 0];
	// ボールの位置をセット
	ball.x = p1.x - BAR_W;
	ball.y = p1.y + BAR_H / 2;
	ball.r = BALL_R;
	ball.dx = -1 * BALL_SPEED;
	ball.dy = randSelect([1,-1]) * rand(BALL_SPEED);
	// パラメータをクライアントに送信
	sendDrawInfo();
	// タイマーをセット
	if (timerId == 0) {
		timerId = setInterval(gameLoop, 100);	
	}
	// 開始を合図
	sendAll("start>ゲーム開始! Spaceキーで玉を打つ");
}
// ゲームループ
function gameLoop() {
	moveBall();
	movePlayer();
	sendDrawInfo();
}
// プレイヤーの移動
function movePlayer() {
	for (var i = 0; i < 2; i++) {
		if (keys[i] == 0) continue;
		var y = players[i].y + keys[i] * BAR_SPEED;
		if (0 <= y && y < DISP_H -BAR_H) {
			players[i].y = y;
			if (isStop && startPlayer == i) {
				ball.y = players[i].y + BAR_H / 2;
			}
		}
	}
}
// ボールの移動
function moveBall() {
	if (isStop || isGameOver) return;
	ball.x += ball.dx;
	ball.y += ball.dy;
	var x = ball.x, y = ball.y;
	// ボールの反射(Y軸)
	if (y < 0 || y > DISP_H) {
		ball.dy *= -1;
		ball.y += ball.dy;
	}
	// ボールの反射(X軸)
	var isHit = function (p, newPosX, dir, ep) {
		var r = ball.r;	// 当たり判定を少し甘く
		var isOK = (p.y-r <= y && y <= p.y+p.h+r);
		ball.x = newPosX;
		ball.dx = BAR_SPEED * dir;
		ball.dy = randSelect([-1,1]) * rand(BALL_SPEED);
		if (!isOK) {
			ep.score++;
			isStop = true;
			startPlayer = p.no;
			sendAll("start>Spaceキーでボールを打つ");
			ball.y = p.y + p.h /2;
			if (ep.score >= WIN_POINT) {
				gameOver(ep.no);			
			}
		}
		return isOK;
		};
		var p0 = players[0], p1 = players[1];
		if (x < p0.x) {
			isHit(p0, p0.x + p0.w, 1, p1);		
		}
		else if (x > p1.x) {
			isHit(p1, p1.x - p1.w, -1, p0);
		}
}
// クライアントからのメッセージが来たとき
function onMessage(msg) {
	var ps = msg.split(">");
	var cmd = ps.shift();
	if (cmd == "key") {
		var no = parseInt(ps[0]);
		var v  = parseInt(ps[1]);
		keys[no] = v;
	}
	else if (cmd == "enter") {
		if (isGameOver) {
			initGame();
		}
	}
	else if (cmd == "space") {
		if (ps[0] == startPlayer) isStop = false;
	}
	else {
		console.log(msg);
	}
}
//ゲームオーバー
function gameOver(winner) {
	sendDrawInfo();
	isStop = true;
	starPlayer = winner;
	isGameOver = true;
	sendAll("gameover>"+winner);
}
// プレイヤーとボールの位置をクライアントに送信
function sendDrawInfo() {
	var o = {"ball":ball,"players":players};
	sendAll("draw>" + JSON.stringify(o));
}
// すべてのクライアントにデータを送信
function sendAll(msg) {
	server.clients.forEach(function (client) {
		if (server.clientsCount != 2) return;
		if (client == null) return;
		client.send(msg);
	});
}
// 整数の乱数を発生する
function rand(c) {
	return Math.floor(Math.random() * c);
}
// 配列内の要素のいずれかをランダムに返す
function randSelect(a) {
	return a[rand(a.length)];
}