Realtime Multiplayer Game with #Node.js
ก่อนอื่น ต้องทำความเข้าใจ node.js กันก่อน
node.js เสมือนเป็นภาษา javascript ที่ เพิ่มเติมด้วย asynchronous i/o library for file, socket and HTTP communication ซึ่งทำให้ node.js มีความสามารถเพียงพอที่จะทำให้ตัวเองเป็น web server
ด้วยเหตุผลว่า javascript run single-threaded นั้นคือ ในครั้งหนึ่งๆ จะมีเพียงคำสั่งเดียวเท่านั้นที่จะสามารถรันได้ ดังนั้นจึงไม่สามารถจองให้คนใดคนหนึ่งได้ หากทำเช่นนั้น คนอื่นจะต้องรอ ซึ่งทำให้ ระบบโดยรวม ช้าลงมาก แต่เพื่อแก้ปัญหานี้ nodeJs จึงเกิดเป็น non blocking IO model , Continuation Passing Style and Event Loop กลายเป็นว่า เร็วฟุดๆ หากเรา implement ได้อย่างถูกวิธี
What Makes Node.js Faster?
เพื่อความตื่นเต้น ลองมาดู ตัวอย่างหนึ่งของ multiplayer web: MMOsteroids
- Realtime Multiplayer Game with #Node.js
Get started
ในตัวอย่างนี้ เราจะแบ่ง code เป็น 2 ส่วน
- Server : จะเป็นตัวกลางในการสื่อการกับ client อื่นๆ
- Client : แสดงผลเกมส์
Requirements
Node.js
apt-get install nodejs
NPM
apt-get install npm
Socket.io
cd to/your/project
npm install socket.io
Step 1. Setting up Server file
เริ่มด้วย การ setting node.js server ของเรา
- สร้าง server.js ซึ่งจะต้องทำการ include libery util และ socket.io
- สร้างตัวแปรต่างๆที่จำเป็นต้องใช้
- สร้าง init function ซึ่งจะเป็น function ในการกำหนด ค่าต่างๆของ server และ เป็น bootstrap ของ server
- จากนั้นเริ่ม implement ใน init function ทำการตั้งค่า port ที่จะเป็นช่องทางในการติดต่อและวิธีการเชื่อมต่อของ socket
- และสุดท้ายคือ สั่งรัน init function
// server.js
var util = require("util"), io = require("socket.io");
var socket,players;
function init() {
players = [];
/* setting socket.io ใช้ในการส่งข้อมูล*/
socket = io.listen(8000); /* setting port */
socket.configure(function() {
socket.set("transports", ["websocket"]);
socket.set("log level", 2);
});
};
init(); /* start our server */
ทดสอบโดยลองรันคำสั่ง
node server.js
หากทำการลงครบถ้วน สมบูรณ์ จะได้
info – socket.io started
Step 2. Set Event Handlers for Socket.io
จากที่ทราบมาแล้วว่า node.js เป็น event-loop นั้นทำให้การ programming สำหรับ node.js จะต่างออกไปจากปกติ เรียกว่า Event-driven programming จะเป็นการกำหนดว่า เมื่อแต่ละ event เกิดขึ้น เราจะต้องตอบสนองยังไง? ซึ่ง main loop ของ node.js จะเป็นส่วนดักจับ event ต่างๆที่เกิดขึ้น [listens for events] เมื่อ event เกิดขึ้นจึงเรียก function ที่เรากำหนดไว้ เรียก function เหล่านี้ว่า callback function
- สร้าง function setEventHandlers เพื่อกำหนด callback ให้กับ event ต่างๆ
- event แรก ที่กำหนดคือ เมื่อ มีการ connect มาที่ socket.io ซึ่งมันจะทำการสร้างตัวแปร client ขึ้นมา ซึ่งจะเป็นตัวที่ใช้ติดต่อกับ browser ของผู้เล่นแต่ละคน หลังจาก socket.io สร้างตัวแปร client ขึ้นมาแล้ว จะทำการเรียก callback
function onSocketConnection
พร้อมส่งตัวแปร client เป็น argument - ใน onSocketConnection จะต้องทำการตั้งค่า event 3 อย่างให้กับตัวแปร client
- บอก client ว่า มีผู้เล่นตายไปแล้ว
- บอก client ว่า มีผู้เล่นใหม่
- บอก client ว่า มีผู้เล่นย้ายตำแหน่ง
// server.js
function init() {
/* ..... code จาก #1 ..... */
setEventHandlers();
};
var setEventHandlers = function() {
socket.sockets.on("connection", onSocketConnection);
};
function onSocketConnection(client) {
util.log("New player has connected: "+client.id);
client.on("disconnect", onClientDisconnect);
client.on("new player", onNewPlayer);
client.on("move player", onMovePlayer);
};
function onClientDisconnect() {
util.log("Player has disconnected: "+this.id);
};
function onNewPlayer(data) {
/* implement later*/
};
function onMovePlayer(data) {
/* implement later*/
};
Step 3. Create Player class on server
เราสามารถใช้ client ที่ socket.io สร้างมาแล้ว แต่ แต่ แต่ … แต่ละ browser [tab] จะเข้าถึงตัวแปร client ของตัวเองได้อย่างเดียว #กำ จึงสร้าง Player class ขึ้นมาเอง เพื่อใช้แทน client อื่นๆ เอาไว้สื่อสารกัน โดยกำหนดให้ Player แต่ละ instance จะต้องผูก ด้วย id กับ client หนึ่งเสมอ!!
- สร้าง Player.js และ setter-getter function ตามด้านล่างเบยยยย~
// Player.js
var Player = function(startX, startY) {
var x = startX,
y = startY,
id;
var getX = function() {
return x;
};
var getY = function() {
return y;
};
var setX = function(newX) {
x = newX;
};
var setY = function(newY) {
y = newY;
};
return {
getX: getX,
getY: getY,
setX: setX,
setY: setY,
id: id
}
};
/* export เพื่อให้ file อื่นสามารถใช้ได้ */
exports.Player = Player;
Step 4. Implement “onNewPlayer” event
หลังจากเราสร้าง Player class ก็ต้องกลับมาผูกแต่ละ Player กับตัวแปร client ซึ่งสามารถทำได้ขณะที่เกิด event new player มาที่ server เนืองจาก browser ส่งมา ซึ่งจะส่งมาหลังจาก connection กับ server เรียบร้อยแล้ว
- include class Player
- สร้าง Player instance ใน function onNewPlayer
- ส่งไปบอก browser ของเครื่องอื่นๆ ว่ามี new player
- ส่งไปบอก browser ของเครื่องตัวเองให้สร้าง player คนอื่นๆที่มีอยู่แล้ว
- เก็บ Player instance เข้า players
// server.js
/* ... code ก่อนๆ ... */
var Player = require("./Player").Player;
function onNewPlayer(data) {
var newPlayer = new Player(data.x, data.y);
newPlayer.id = this.id;
this.broadcast.emit("new player", { id: newPlayer.id,
x: newPlayer.getX(),
y: newPlayer.getY() });
var i, existingPlayer;
for (i = 0; i < players.length; i++) {
existingPlayer = players[i];
this.emit("new player", {id: existingPlayer.id,
x: existingPlayer.getX(),
y: existingPlayer.getY()});
};
players.push(newPlayer);
};
Step 5. Let’s be a Client
ทั้งหมดที่ผ่านมา คือการเตรียม server ซึ่งจะใช้เป็นตัวช่วยในการติดต่อกันระหว่างผู้เล่น ต่อมาเราจึงหันมา implement ฝั่ง client บ้าง แต่เราจะข้ามรายละเอียดของวิธีการสร้าง แต่เราจะมาทำให้มันเป็น multiplayer กัลลลลล ลล ล ล ~
- download single player game [DWL]
- unzip ออกมาจะเห็น copy ให้อยู่ที่เดียวกับ server.js และ Player.js
- ลอง open index.html จะเห็น จุดดำๆ วิ่งไปมาได้ โดยใช้ปุ่มลูกศร เหมือนรูปด้านล่าง
ก่อนจะเริ่ม Implement จะมาแนะนำไฟล์ต่างๆให้รู้จักกันก่อน
index.html
: เป็นหน้าที่เริ่มต้นรันเกมส์js/game.js
: เป็น js ควบคุมการดำเนินไปของเกมส์ เช่น รับ input , แสดงผลลัพย์ต่างๆjs/Player.js
: class ตัวแทนของผู้เล่นคนอื่นๆ
เริ่มต้นด้วย include socket.io ให้กับ ฝั่งผู้เล่น โดยแก้ไข index.html
<!-- index.html -->
<span style="color: slategray"> <!-- other --> </span>
<canvas id="gameCanvas"></canvas>
<span style="color:rgb(253, 215, 14)">
<!-- add this line -->
<script src="http://localhost:8000/socket.io/socket.io.js">
</script>
<!-- add this line -->
</span>
<script src="js/requestAnimationFrame.js"></script>
<script src="js/Keys.js"></script>
<span style="color: slategray"> <!-- other --> </span>
ต่อมา เราจะมาแก้ไขไฟล์ game.js เพื่อให้รองรับ #multiplayer โดย
- สร้างตัวแปร socket เพื่อใช้ในการติดต่อกับ server และตัวแปร remotePlayers เพื่อเก็บ Player instance ของผู้เล่นคนอื่นๆ
- ติดต่อกับ server
- สร้าง callback สำหรับ event ที่จะเกิดขึ้นเช่นเดียวกับ server แต่เป็นมุมมองของผู้เล่น
// js/game.js
function init() {
/* other code */
/* ............ */
/* add this */
// socket connect
socket = io.connect("http://localhost", {port: 8000, transports: ["websocket"]});
// Initial remote players
remotePlayers = [];
setEventHandlers();
};
var setEventHandlers = function() {
/* other code */
/* ............ */
/* add this*/
socket.on("connect", onSocketConnected);
socket.on("disconnect", onSocketDisconnect);
socket.on("new player", onNewPlayer);
socket.on("move player", onMovePlayer);
socket.on("remove player", onRemovePlayer);
};
function onSocketConnected() {
console.log("Connected to socket server");
};
function onSocketDisconnect() {
console.log("Disconnected from socket server");
};
function onNewPlayer(data) {
console.log("New player connected: "+data.id);
};
function onMovePlayer(data) {
/* implement later */
};
function onRemovePlayer(data) {
/* implement later*/
};
หากพยายามจนมาถึงขั้นนี้ เช็คควสามถูกต้องโดยพิมพ์ node server.js
เพื่อ รัน server
หาเป็นไปอย่างถูกต้องแล้ว จะเห็นข้อความในกล่องด้านล่าง ใน javascript console [[ หาใช้ chrome กด ctrl+shift+J ]]
Connected to socket server
ปล.ทั้งหมดนี้แค่เพื่อ connect กับ server คงหวังว่าจะเห็น multiplayer แล้วซินะ #ยังไม่เสร็จขอรับ
Step 6. Display another Player
หลังจาก connection เรียบร้อยแล้ว ต่อไปคือการแสดงผลผู้เล่นคนอื่นๆในฝั่ง client นั้นหมายความว่าเราต้องทำการแก้ไขไฟล์ public/js/Player.js
- ใส่ส่วนของตัวแปร id ลงไปเพื่อใช่ระบุตัวตนของผู้เล่น
- สร้าง getter-setter function เหมือนในส่วน server
// js/Player.js
var Player = function(startX, startY) {
/* other code*/
/* ............*/
var id;
// Getters and setters function
var getX = function() {
return x;
};
var getY = function() {
return y;
};
var setX = function(newX) {
x = newX;
};
var setY = function(newY) {
y = newY;
};
/* edit this */
return {
getX: getX,
getY: getY,
setX: setX,
setY: setY,
update: update,
draw: draw
}
};
แต่แค่นี้ยังไม่พอสำหรับการแสดงผลผู้เล่น สิ่งต่อไปคือ กลับไปแก้ไขไฟล์ game.js
- ส่งข้อมูลใน browser ของเครื่องตัวเอง ไปให้ผู้เล่นอื่นแสดง
- รับข้อมูลจาก broeser ของเครื่องผู้เล่นอื่น
- วาด
// js/game.js
/* ............*/
/* other code */
function onSocketConnected() {
console.log("Connected to socket server");
//ส่งข้อมูลในเครื่องเราไปให้ผู้เล่นอื่น
socket.emit("new player", {x: localPlayer.getX(), y: localPlayer.getY()});
};
function onNewPlayer(data) {
console.log("New player connected: "+data.id);
// Initialise the new player
var newPlayer = new Player(data.x, data.y);
newPlayer.id = data.id;
remotePlayers.push(newPlayer);
};
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
localPlayer.draw(ctx);
/* edit this */
// Draw the remote players
for (ver i = 0; i < remotePlayers.length; i++) {
remotePlayers[i].draw(ctx);
};
/* edit this */
};
/*other code*/
Step 7. Update it!!
ใกล้ถึงความจริงแล้ว หากลองรันตอนนี้จะเห็นว่า ทุกครั้ง refresh จะเกิดจุดใหม่ โดยของเก่าไม่หายไป และที่สำคัญคือ จุดอื่นๆไม่เลื่อน เพราะยังไม่ทำการส่ง update นั้นเอง
แต่แน่นอนว่าครั้งนี้จะเป็นการแก้ไขทั้ง ฝั่งผู้ใช้ และ ฝั่งเซิฟเวอร์
ฝั่งผู้ใช้
- สร้าง function playerById เพื่อ ใช้ในการหาผู้ใช้คนอื่น จาก Id
- ลบผุ้เล่นคนอื่นเมื่อเกิด event remove player แต่ไม่จำเป็นต้องส่งไปบอกฝั่งเซิฟเวอร์ขณะ disconnect !!
- ส่งข้อมูลตำแหน่งปัจจุบันไปยังเซิฟเวอร์
- เปลี่ยนตำแหน่งของจุด เมื่อเกิด event move player
// js/game.js
/*..........*/
/* other code*/
/* add this */
function playerById(id) {
for (ver i = 0; i < remotePlayers.length; i++) {
if (remotePlayers[i].id == id)
return remotePlayers[i];
};
return false;
};
// Remove player
function onRemovePlayer(data) {
var removePlayer = playerById(data.id);
if (!removePlayer) {
console.log("Player not found: "+data.id);
return;
};
remotePlayers.splice(remotePlayers.indexOf(removePlayer), 1);
};
// Move player
function onMovePlayer(data) {
var movePlayer = playerById(data.id);
if (!movePlayer) {
console.log("Player not found: "+data.id);
return;
};
movePlayer.setX(data.x);
movePlayer.setY(data.y);
};
/* edit this*/
function update() {
if (localPlayer.update(keys)) {
// ส่งข้อมูลไปบอก server
socket.emit("move player", {x: localPlayer.getX(), y: localPlayer.getY()});
};
};
ฝั่งเซิฟเวอร์
สำหรับฝั่งเซิฟเวอร์ จะมีการแก้ไขที่ไม่ต่างกัน นั้นคือ
- สร้าง function playerById
- ลบผุ้เล่นคนอื่นเมื่อเกิด disconnect และส่งต่อให้ผู้เล่นคืนอื่นด้วย evnet remove player
- สุดท้าย คือ หากข้อมูลจากผู้เล่นคนใดมีการเปลี่ยนแปลงจะทำการส่งข้อมูลต่อไปให้ผู้เล่นคนอื่นๆ
- เสร็จสมบูรณ์*
// server.js
/* other code*/
/* add this*/
function playerById(id) {
var i;
for (i = 0; i < players.length; i++) {
if (players[i].id == id)
return players[i];
};
return false;
};
function onClientDisconnect() {
util.log("Player has disconnected: "+this.id);
var removePlayer = playerById(this.id);
if (!removePlayer) {
util.log("Player not found: "+this.id);
return;
};
players.splice(players.indexOf(removePlayer), 1);
this.broadcast.emit("remove player", {id: this.id});
};
function onMovePlayer(data) {
var movePlayer = playerById(this.id);
if (!movePlayer) {
util.log("Player not found: "+this.id);
return;
};
movePlayer.setX(data.x);
movePlayer.setY(data.y);
this.broadcast.emit("move player", {id: movePlayer.id, x: movePlayer.getX(), y: movePlayer.getY()});
};