【Node JS】Node js 實作課程:小遊戲製作
0429 Facebook 小遊戲
簡介
這次的專案使用到了 Facebook Graph API,還不熟悉的讀者,可以參考我們先前的文章,或是到 Facebook 開發人員中心查看說明。
WebSocket
在這個範例中,我們使用了 WebSocket 作為伺服器和瀏覽器的溝通方式,它可以讓我們維持連線,不像一般讀取網頁一樣送完資料就結束連線,這剛好符合了我們的需求(我們必須不斷傳送題目、接收答案、公布解答等等)
,有關這個技術的詳細資訊在網路上已經有很多解說,在開始專案之前,建議讀者可以先去看看。
線上範例
這次的範例我們特別放到 Azure 上展示,實作前不妨先看看成果,讓你更有看到結尾的決心吧。
https://nodec4.azurewebsites.net/
第一部分-配對
第一部分我們先來做配對的部分,我們給第一位玩家專用的 URL ,等到第二位玩家存取這個網址後,檢查好友關係,並送出訊息讓網頁導向遊戲畫面。
流程圖
為了方便,我們在每次的通訊都會加上 type 這個值,用來說明這次的通訊目的,下圖中左、右方的文字就是 type 。
- 第一位玩家必須由1.建立遊戲來取得遊戲ID,並讓自己加入。
- 第二位玩家則需要提供遊戲ID來加入。
- 兩個人都加入後,伺服器會發送 MATCH 訊息給所有人。
登入流程
Facebook 的登入流程相信大家已經很熟悉了,這裡就不多做解釋,程式部分如下。
app.get('/api/code', (req, res) => {
request('https://graph.facebook.com/v2.8/oauth/access_token?client_id=' + process.env.appID + '&redirect_uri=' + process.env.redirect + '/api/code?id=' + req.query.id + '&client_secret=' + process.env.appKEY + '&code=' + req.query.code, (error, response, body) => {
var userdata = JSON.parse(body);
req.session.key = userdata.access_token;
/* */
getUser(userdata.access_token).then((data) => {
req.session.name = data.name;
req.session.fbid = data.id;
// #27
var url = (req.query.id != 'undefined')? '../?id=' + req.query.id : '../';
res.redirect(url);
});
});
});
function getUser(key) {
return new Promise((resolve, reject) => {
request('https://graph.facebook.com/v2.8/me?fields=id%2Cname&access_token=' + key, (error, response, body) => {
resolve(JSON.parse(body));
});
});
}
在 27 行我們判斷網址中是不是有 id 這個值,若有的話我們得一起傳到下個網頁去,否則使用者登入之後會遺失遊戲 id 。
WebSocket 中如何使用 Session
還記得接收到 Code 之後我們做的事情嗎? 我們把使用者的 id,name,access_token 存到 Session 中方便後續使用,這時候就會有個小問題出現囉,WebSocket (下方簡稱WS) 收到訊息後,是沒有辦法直接存取到 Session 的。但我們可以使用 SessionParser 取得資料內容後傳給 WS,方法有很多種,程式碼如下
const wss = new WebSocket.Server({ server, path: "/ws" });
wss.on('connection', function connection(ws) {
sessionParser(ws.upgradeReq, {}, function () {
var session = ws.upgradeReq.session;
ws.session = session;
});
ws.on('message', function incoming(data) {
// 處理接收到的訊息
});
});
我們在 WS 連接 (on connection) 時,存取 session ,並把讀取到的資料傳給 ws.session 中。
我們是在 WS 連接時存取資料,所以之後若 session 有更新,已連線的 WS 是沒有辦法獲得新資料的,但在這個範例我們不在乎這點,因為我們要的資料只有 Facebook 資訊,這些資料早在 WS 連接前就寫入了。
Module
在前幾個範例中,我們把所有的程式都寫在 app.js 中,這不是件好事情,這次我們分成 4 個檔案。
檔案名稱 | 說明 |
---|---|
app.js | 程式進入點,開啟伺服器,route設定 |
wsConnect.js | 處理 WebSocket 通訊(接收和傳送) |
gameManager.js | 遊戲流程,建立遊戲房間,要求題目,評分 |
question.js | 負責出題目 |
這麼一來程式架構就比較清楚了,那不同檔案之間要怎麼溝通呢?
我們來看看下面這個例子,1.js 中呼叫 2.js 的 Function
1.js
var secondJs = require('./2')
secondJs.hello();
2.js
function printHelloWord(){
console.log("Hello Word");
}
module.exports = {
hello: printHelloWord
}
4 ~ 6 行中,我們定義了 2.js 的輸出,這麼一來,在 1.js 中呼叫 secondJs.hell 就會對應到 2.js 的 printHelloWord 了。
在接下來的程式中,為了方便,我們會將輸出的名稱和真正的名稱設為相同,像這樣
module.exports = {
printHelloWord: printHelloWord
}
// 名稱相同時,我們可以簡寫成下面這個樣子
module.exports = {
printHelloWord
}
後端程式
建立 WebSocket 伺服器
在這個範例中,我們會使用 Express 傳送靜態檔案,使用 WebSocket 來進行資料傳輸,這邊主要講解 WS 的部分。
//#41
const server = http.createServer(app);
//#42
const wss = new WebSocket.Server({ server, path: "/ws" });
wss.on('connection', function connection(ws) {
sessionParser(ws.upgradeReq, {}, function () {
var session = ws.upgradeReq.session;
ws.session = session;
});
//#48
ws.on('message', function incoming(data) {
var msg = { type: null };
try {
msg = JSON.parse(data);
} catch (e) { }
if (msg.type == null) {
ws.close();
}
else {
wsc.route(ws, msg);
}
});
});
41 行 createServer(app):載入 Express 的設定
42 行 WebSocket.Server:在路徑 /ws 下建立 WS 伺服器,我們使用 ws 模組。
var WebSocket = require('ws');
48 ws.on(‘message’…):行定義了接收到訊息的動作,前面有提到為了方便,我們每次通訊都會加上 type 屬性,在接收到訊息後我們先檢查這個屬性,關閉不相容的通訊,接把訊息交給 wsConnection.js 處理,當然我們也先引用我們自己的模組。
var wsc = require('./wsConnect');
若您使用 Azure Web Service 作為平台,要先到設定面板中開啟 Websocket 功能才可以使用。
Azure Web Service 會限制 WebSocket 的連線數量,詳細資訊可以在這裡找到,免費的方案只有 5 個連線數,所以關閉不使用的連線是個聰明的選擇。
下載檔案
前面有提到,關於遊戲管理的部分,都由 gameManager.js 處理,我們沒有辦法在這裡教學這部分,麻煩讀者到 GitHub 下載這個檔案,並放在和 app.js 同一個目錄的位置。
處理通訊
首先,我們得先引用 gameManager ,並定義好 route 函式(用來處理接收到的訊息)以及輸出,另外我們 WS 訊息會以 json 格式傳送,為了方便,我們先寫好傳送 JSON 的功能。
const gm = require('./gameManager');
route = (ws, msg) => {
switch (msg.type) {
//依照不同類別處理訊息
}
}
// 傳送JSON格式資料
sendJson = (ws, msg) => {
ws.send(JSON.stringify(msg));
}
// 定義輸出
module.exports = {
route
}
在配對的頁面中,我們會接到三種訊息,分別是 LOGIN 檢查登入狀態、CREATE 建立遊戲房間以及 JOIN 加入遊戲。
檢查登入狀態和以前相通,有登入就回傳名子,沒有就給授權網址,指示改為 WS 發送而已。但在回呼網址中,我們加上了 id 參數,這是為了讓有遊戲 ID 但還未登入的使用者保留 ID,而不會在登入後(網頁重新導向)遺失這個參數。
case "LOGIN":
if (ws.session.name)
sendJson(ws, { type: msg.type, status: 'ok', name: ws.session.name });
else
sendJson(ws, { type: msg.type, status: 'not login', url: 'https://www.facebook.com/v2.8/dialog/oauth?client_id=' + process.env.appID + '&redirect_uri=' + process.env.redirect + '/api/code?id=' + msg.game + '&scope=user_friends,user_birthday,user_photos' });
break;
接著是建立遊戲 ID 的部分了,用法很簡單,呼叫 createNewGame 即可
case "CREATE":
if (ws.session.name)
sendJson(ws, { type: msg.type, status: 'ok', id: gm.createNewGame(ws), name: ws.session.name });
else
sendJson(ws, { type: msg.type, status: "not login" });
break;
建立的遊戲 ID 在十分鐘後會自動刪除
最後是加入遊戲的部分了,值得注意的是兩個人(不管是不是建立遊戲的人)都需要執行才能開始遊戲。
用法為 joinGame([Websocket],[Game ID]) ,為 promise,會回傳 status (是否成功加入) 及 todo,若 todo 為 notify 時,表示已經有兩個人加入,可以開始遊戲,這時候我們必須傳送 MATCH 指令到前端。
case "JOIN":
if (msg.game && ws.session.name) {
gm.joinGame(ws, msg.game).then((d) => {
ws.session.game = msg.game;
sendJson(ws, { type: msg.type, status: d.status, id: msg.game });
if (d.todo == 'notify') {
d.notify.forEach((wsToNotify) => {
sendJson(wsToNotify, { type: "MATCH", players: d.players });
})
}
});
} else {
sendJson(ws, { type: msg.type, status: "not login or no game id" });
}
break;
前端程式
因為這次很多和前端的互動,我們得知道前後端怎麼運作的,所以這次我們會講解一些前端的東西。
開啟 WebSocket
前端開啟 ws 很簡單
new WebSocket("ws://[url]");
但考慮到我們可能在 HTTPS 連線下使用(這時須使用 wss://),所以我們把程式改寫成這樣
var protocol = (window.location.protocol == "https:") ? "wss://" : "ws://";
ws = new WebSocket(protocol + window.location.host + "/ws");
後面的 /ws 是我們在伺服器設定的路徑
接著我們定義 onopen(開啟連線)、onmessage(收到訊息)、onclose(關閉連線)的動作
self.ws.onopen = function () {
// Web Socket is connected, send data using send()
console.log("[ws] connected.");
self.sendJson(self.ws, {
type: "LOGIN"
});
};
self.ws.onmessage = function (evt) {
var msg = JSON.parse(evt.data);
console.log(msg);
switch (msg.type) {
case "LOGIN":
loginStatus(msg);
break;
case "CREATE":
newgame(msg);
break;
case "JOIN":
checkJoin(msg);
break;
case "MATCH":
match(msg);
break;
}
};
this.ws.onclose = function () {
console.log("ws closed.");
};
this.sendJson = (ws, msg) => {
ws.send(JSON.stringify(msg));
}
首先,我們一樣定義了 sendJson 來傳送資料,我們把所有的內容包在 WsManager 中,方便我們使用,並且在最上方定義了 self = WsManager,這只是我們的習慣,讀者可以自行決定。
建立完成連線後,我們馬上檢查登入,並等待伺服器回傳資訊。登入之後,我們先試試看能不能利用 ID 加入遊戲
loginStatus = (msg, text, url) => {
if (msg.status == "ok") {
self.sendJson(self.ws, {
type: "JOIN",
game: getUrlParameter("id")
});
} else {
setAlert("<a href='" + msg.url + "' class=\"btn btn-primary btn-lg\" role=button>玩家FB登入</a>");
}
}
若沒辦法加入,我們自己建立一個
checkJoin = (msg) => {
if (msg.status != "ok") {
setAlert("無法加入", "ID 無效,產生新的房間...", "");
setTimeout(() => self.sendJson(self.ws, {
type: "CREATE"
}), 1000);
} else
self.gid = msg.id;
}
加入完成後,就等待伺服器回傳 MATCH 指令就可以了!
這部分的完整程式在這裡
小節
到這裡我們已經完成了配對,接著就遊戲的內容了,配對完成後,前端要把兩位使用者導向 room.html 這個網頁,並附上遊戲 ID
原諒我們沒有時間美化介面^^
配對的流程需要兩個人(廢話),若您只有一個人…可以開兩個瀏覽器或兩頁分頁
第二部分-遊戲
流程圖
- 使用者加入後會傳送 INFO 要求取得遊戲資訊(兩位玩家的名稱和照片)
- 當兩位玩家都傳送要求後,伺服器會開始製作題目,製作完成時發送 COMPUTING (finish: true)
- 接著前端必須發送 READY 要求(當使用者按下準備)
- 伺服器會發送 START 訊息,五秒後會發送題目
- 伺服器發送題目,前端回傳答案,伺服器公布結果,重複執行值到沒有題目
- 結束遊戲
取得題目
INFO
取得遊戲資訊,使用方式很為 info([WebSocket],[Game ID])
回傳 data (遊戲資訊內容)、todo (當todo為notify時,表示有兩位玩家要求 info ,開始準備題目)
case "INFO":
if (ws.session.name && msg.game) {
gm.info(ws, msg.game).then((d) => {
d.data.type = msg.type;
ws.gameid = msg.game;
sendJson(ws, d.data);
if (d.todo == "notify") {
sendJson(d.ws[0], { type: "COMPUTING" });
sendJson(d.ws[1], { type: "COMPUTING" });
gm.createQuestions(msg.game).then(notifyGetReady);
}
});
}
break;
題目開始準備和準備完成我們都會發送訊息給前端
notifyGetReady = (data) => {
var s = (data.status == "ok") ? { type: "COMPUTING", finish: true } : { type: "COMPUTING", finish: false };
sendJson(data.game.players[0].ws, s);
sendJson(data.game.players[1].ws, s);
}
題目
要取得題目,只需要呼叫 getQuestion([Gamd ID]) 即可,會回傳 que (題目內容) 或 null (沒有剩下的題目了),傳送題目的程式如下,若沒有剩下題目,我們則傳送 END 訊息到前端。
sendQuestion = (gameid) => {
gm.getQuestion(gameid).then((data) => {
if (data != null) {
var s = {
type: "QUESTION",
time: data.que.time,
que: data.que.description,
ans: data.que.ans,
id: data.que.id
};
sendJson(data.d[0], s);
sendJson(data.d[1], s);
var g = gm.getGameByID(gameid);
setTimeout(sendResult, s.time * 1000, g, g.nowquestion);
}
else {
var game = gm.getGameByID(gameid);
var re = { type: "END", data: gm.getEndResult(game) };
for (var i = 0; i < 2; i++) {
re.id = i;
sendJson(game.players[i].ws, re);
}
}
});
}
我們在送完題目後馬上開啟計時器(setTimeout),這是為了在使用者超過時間還沒有回傳答案時,公布結果。
計分
回答問題
回答問題時,使用 solveQuestion([WS],[Answer])
程式會判斷是哪位使用者回答(相同Facebook ID可能會出錯)
使用者回答後,我們隔 1 秒公布答案。
gm.solveQuestion(ws, msg.choose).then((data) => {
if (data) {
var g = gm.getGameByID(ws.gameid);
setTimeout(sendResult, 1000, g, g.nowquestion);
}
}).catch((e) => { });
公布答案
getResult([Game]) 可取得答案及計分,我們在程式中判斷 nq 是否等於 nowquestion ,這樣可以避免回答到其他題目的答案。
送出結果後,我們隔 2 秒,進行下一題。
sendResult = (game, nq) => {
if (game.nowquestion == nq) {
var re = { type: "RESULT", data: gm.getResult(game) };
for (var i = 0; i < 2; i++) {
re.id = i;
sendJson(game.players[i].ws, re);
}
setTimeout(sendQuestion, 2000, game.id);
}
}
總結
目前題目只會有兩種(猜生日和按讚),讀者可以自行修改 question.js 中的程式來增加不同的題目!
您可以到 Github 上瀏覽完整的專案和教學
謝謝您把文章看完,我們的系列課程也到一段落了,希望你們會喜歡。有任何其他問題,歡迎聯絡我們。
第十一屆微軟學生大使 技術組 蔡臻平、詹鈞婷、盧俊言、鄭薇、王采楓、何天與 撰寫