Real-time multiplayer Facebook piškvorky – real-time multiplayer

Pokračujeme ve tvorbě hry pro Facebook v Node.js. V prvním díle jsme vytvořili fungující herní plochu. Dnes zapojíme více hráčů.
Seriál: Real-time multiplayer Facebook piškvorky (3 díly)
- Real-time multiplayer Facebook piškvorky 6. 2. 2013
- Real-time multiplayer Facebook piškvorky – real-time multiplayer 11. 2. 2013
- Real-time multiplayer Facebook piškvorky – Facebook 20. 2. 2013
Nálepky:
Teď se dostane ke slovu Socket.IO a konečně budeme moci hrát po síti. Naše piškvorky budou používat jednoduchý protokol. První hráč se představí serveru svým ID uživatele (událost uid
) a pošle zprávu, že čeká na druhého hráče (waitingFor
). Až se druhý hráč představí serveru, server vytvoří novou hru a pošle její ID oběma hráčům (game
). Ti následně budou posílat své tahy (move
). Stav hry si budou udržovat klienti sami a sami také budou rozhodovat, jaké tahy jsou přípustné a kdy je mohou poslat, server je jen bude přeposílat. Server bude pouze po každém tahu čekat 30 sekund a pokud do té doby nedostane další tah, hru zruší.
Pozn. Hráči mohou podvádět, můžete spoluhráči poslat tah na již obsazené pole ;)
Zdrojové kódy
Stáhnětě si zdrojové kódy 1. dílu:
$ git clone https://github.com/jakubkulhan/xo.git
$ git checkout dil1
Server
Nejdříve se podíváme na server, přidáme kód pro obsluhu Socket.IO:
var waitingFor = {}, games = {};
waitingFor
udržuje seznam hráčů čekajících na spoluhráče, kde klíčem je ID uživatele spoluhráče a hodnota je hráčův socket. games
jsou aktuálně hrané hry.
io.sockets.on('connection', function (socket) {
var myUid, opponentUid, gameId;
Událost connection
znamená, že se otevřel nový socket. Každý socket si bude pamatovat ID hráče, ID spoluhráče a ID hry.
socket.on('uid', function (uid) {
myUid = uid;
if (waitingFor[myUid]) {
var opponentSocket = waitingFor[myUid];
delete waitingFor[myUid];
games[gameId = String(Date.now() * Math.random())] = {
X: opponentSocket,
O: socket,
timeout: undefined
};
opponentSocket.setGameId(gameId);
opponentSocket.emit('game', gameId, myUid, 'X');
socket.emit('game', gameId, opponentSocket.getUid(), 'O');
restart();
}
});
Při přijmutí zprávy uid
nastavíme hráčovo ID a pokud na něj někdo čeká s hrou, tak ji vytvoříme a oběma pošleme zprávu, že hra byla zahájena.
socket.getUid = function () {
return myUid;
};
socket.setGameId = function (gid) {
gameId = gid;
};
function restart() {
stop();
if (games[gameId]) {
games[gameId].timeout = setTimeout(function(game) {
stop();
game.X.emit('timeout');
game.O.emit('timeout');
delete games[gameId];
}, 30000, games[gameId]);
}
}
function stop() {
if (games[gameId] && games[gameId].timeout) {
clearTimeout(games[gameId].timeout);
games[gameId].timeout = undefined;
}
}
setGameId()
umožňuje na socketu nastavit ID hry zvenčí. restart()
a stop()
zapíná, resp. vypíná, odpočítávání. Pokud odpočet vyprší, pošleme oběma hráčům zprávu timeout
.
socket.on('waitingFor', function (uid) {
opponentUid = uid;
waitingFor[opponentUid] = socket;
});
socket.on('move', function (gameId, row, col, color) {
if (games[gameId]) {
games[gameId][color === 'X' ? 'O' : 'X'].emit('move', row, col, color);
restart();
}
});
Při waitingFor
uložíme potřebné informace. move
se pouze přepošle soupeři.
socket.on('end', function () {
stop();
delete games[gameId];
});
socket.on('disconnect', function () {
if (waitingFor[opponentUid] === socket) {
delete waitingFor[opponentUid];
}
stop();
delete games[gameId];
});
});
end
hráč pošle, pokud je hra skončena (někdo vyhrál). disconnect
je rezervovaná událost Socket.IO. Podle názvu je jasné, že je vyvolána, když se socket odpojí, zameteme tedy za sebou.
Úpravy na klientu
Předtím jsme vůbec neřešili, zdalipak náhodou jeden z hráčů nevyhrál, přidáme tedy do modelu jednoduchou metodu, která to ověří:
XO.Model.prototype.checkWin = function (color) {
var relatives = [ [0, 1], [1, 1], [1, 0], [1, -1] ];
for (var i = 0; i < this.rows; ++i) {
for (var j = 0; j < this.cols; ++j) {
if (this.board[i][j] !== color) {
continue;
}
for (var r = 0; r < relatives.length; ++r) {
var relative = relatives[r], k = i, l = j, inARow = 0;
while (k < this.rows && l < this.cols && this.board[k][l] === color) {
++inARow;
k += relative[0];
l += relative[1];
}
if (inARow > 4) {
return {
from: { row: i, col: j },
to: { row: k - relative[0], col: l - relative[1] }
};
}
}
}
}
return null;
};
checkWin()
vrátí vítěznou řadu symbolů, kterou našla, a null
, když nic nenašla.
Dále budeme potřebovat tuhle vítěznou řadu označit, do vrstvy s vykreslováním křížků a koleček (XO.UI.Squares
) přidáme:
// …
var line = new Kinetic.Line({
points: [],
stroke: 'green',
strokeWidth: 3,
visible: false
});
squares.add(line);
squares.cross = function (win) {
line.setPoints([ win.from.col * a + a/2, win.from.row * a + a/2, win.to.col * a + a/2, win.to.row * a + a/2 ]);
line.setVisible(true);
};
squares.uncross = function () {
line.setVisible(false);
};
// …
Nejdříve nechceme, aby čára byla vidět, proto ji vytvoříme s visible: false
. cross()
vezme objekt vrácený z checkWin()
a podle toho vykreslí čáru. uncross()
ji zase zneviditelní.
Nakonec budeme muset upravit kód xo.html
. Pro načtení Socket.IO přidáme následující skript:
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
A změníme controller:
var backgroundImage = new Image();
backgroundImage.onload = function () {
var uid = prompt('Enter your user ID:'), opponentUid, gameId;
var model = new XO.Model(30, 38);
Na začátku se uživatele zeptáme na jeho ID.
function tryWin(color) {
var win = model.checkWin(color);
if (win) {
socket.emit('end');
ui.squares.cross(win);
ui.overlay.show(function () {
alert(color === model.myColor ? 'you won :)' : 'you lost :(');
});
}
}
tryWin()
vyzkouší, jestli předaná barva vyhrála. Jestli ano, ukončíme hru (socket.emit('end')
), zaškrtneme vítězné postavení a oznámíme to uživateli.
var ui = new XO.UI({
container: 'container',
width: 760,
height: 600,
model: model,
backgroundImage: backgroundImage,
onOverlayButtonClick: function () {
opponentUid = prompt('Enter opponent user ID:');
ui.overlay.setText('waiting…');
ui.overlay.hideButton();
socket.emit('waitingFor', opponentUid);
},
Po kliknutí na tlačítko play
uživatel zadá ID protihráče a pošleme serveru zprávu, že hráč čeká na hru.
onGridClick: function (row, col) {
if (model.lastMoveColor === model.myColor || model.board[row][col]) {
return;
}
socket.emit('move', gameId, row, col, model.myColor);
model.board[row][col] = model.myColor;
model.lastMoveColor = model.myColor;
tryWin(model.myColor);
ui.squares.draw();
}
});
ui.overlay.setText('five in a row');
ui.overlay.setButtonText('play');
Tah povolíme pouze tehdy, jestli je hráč na tahu (tzn. pokud netáhl minulý tah) a není-li políčko obsazeno. Jestliže je všechno v pořádku, pošleme tah na server, upravíme data v modelu a vyzkoušíme, jestli jsme nevyhráli (tryWin(model.myColor)
; v piškvorkách může po našem tahu těžko vyhrát soupeř).
Pozn. Schválně si zkuste podmínku zajišťující regulérnost hry odmazat – superpiškvorky! Nemůžete prohrát, musíte být pouze rychlejší než soupeř.
Nyní se dosteneme konečně ke komunikaci se serverem.
var socket = io.connect();
socket.emit('uid', uid);
Připojili jsme se a identifikovali se serveru.
socket.on('game', function (gid, oid, myColor) {
gameId = gid;
opponentUid = oid;
model.reset(myColor);
ui.squares.uncross();
ui.squares.draw();
ui.overlay.hide(function () {
ui.overlay.setText('five in a row');
ui.overlay.setButtonText('play');
ui.overlay.showButton();
});
});
socket.on('timeout', function () {
ui.overlay.show(function () {
alert('timeout :|');
});
});
Když začne nová hra, uložíme si její ID a zresetujeme model do výchozího stavu. V případě, že hra skončila, server pošle timeout
. Pouze to oznámíme uživateli a nabídneme možnost hrát novou hru.
socket.on('move', function (row, col, opponentColor) {
model.board[row][col] = model.opponentColor;
model.lastMoveColor = model.opponentColor;
tryWin(model.opponentColor);
ui.squares.draw();
});
};
backgroundImage.src = '/img/paper.png';
Když obdržíme tah od soupeře, zaznemenáme ho a vyzkoušíme, jestli soupeř nevyhrál.
Zatím můžeme hrát proti soupeři po síti. Jej! Ale zadávat pořád svoje ID a protihráčovo ID je hodně nepohodlné.
Závěr
Piškvorkoklienti zatím mají dost naivní přístup k ověřování správnosti tahů. Určitě by se toto ověření mělo přesunout do modelu a probíhat nejen při posílání tahu, ale taky při přijímání od soupeře.
Použití Socket.IO je velmi jednoduché, prakticky se stačí připojit, nastavit handlery přijímaných zpráv a emit()
ovat odchozí zprávy. O dalších šikovných věcech se můžete dočíst zde:
Pokud aplikaci se Socket.IO budete chtít hostovat na Heroku nebo AppFogu, ti bohužel nepodporují WebSocket. Aby se Socket.IO zbytečně nepokoušelo spojit přes WebSocket, je nejlepší použít spojení přes xhr-polling
:
io.configure(function () {
io.set('transports', [ 'xhr-polling' ]);
io.set('polling duration', 10);
});
Kompletní kód hry po tomto díle:
$ git checkout dil2
V příštím článku se podíváme na to, jak začlenit hru jako aplikaci na Facebook.