上一篇記錄了一下websocket通信的學習內容,這次希望能夠綜合所學習到的知識,來打造一套簡單的游戲內的聊天窗口。
根據我自己這么多年的網游經驗,猜測了一下一般游戲服務器的分類情況,給自己的這個小的練手項目分了一下幾個需要的服務端口。
首先是登錄功能,使用REST來實現即可。
然后游戲中,每張地圖獨立為一個socket服務端口,在該張地圖上所有的角色行為數據,統一由這個服務器來處理。
當然,網游中還少不了聊天功能。聊天功能在不同的地圖,都能夠有統一的數據收發,所以聊天功能也需要一個獨立的服務端口來處理。
所以在上一篇最后提到的聊天室功能,在這里就直接演化成一個簡單的聊天界面(雖然和聊天室沒什么區別)。
在基於原有的項目基礎上,創建一個新的游戲場景,就叫它ChatScene吧。

Scene中Canvas的尺寸,在這里我選擇了1920*1080,當然有別的喜好或者需求也可以改,這里僅僅是因為比較普遍。

在場景中創建一個layout,用來承載整個聊天窗口,在內部再分別創建三個layout。分別用來承載消息窗口,輸入窗口和聊天頻道選擇窗口。

圖:聊天界面layout和三個功能layout

圖:聊天界面layout的相關尺寸

圖:聊天消息內容層是用了一個SrcollView來實現的可滾動內容區。

圖:頻道選擇使用了一個單選按鈕容器,里面包含三個單選按鈕

圖:輸入層是一個普通的layout,里面有一個Input用來輸入內容,一個Text用來顯示當前所選擇的頻道

圖:整體效果如圖所示(因為項目在比較早期先完成了,這篇隨筆誕生在項目完成后很久,所以截圖中看到的已經不是隨着開發過程記錄的內容)
這里,這個聊天窗口的具體UI實現就不詳細描述了,可以根據自己的喜好來創建自己所想要的聊天界面樣式,本隨筆的目的,是着重於實現業務層面的邏輯。
然后我們創建一個ChatScript.js的腳本,用來處理整個聊天系統的邏輯。

在代碼中,添加好所有需要操作的節點。
properties: {
chatLayout: {
default: null,
type: cc.Layout
},
worldChannelButton: {
default: null,
type: cc.Toggle
},
teamChannelButton: {
default: null,
type: cc.Toggle
},
personalChannelButton: {
default: null,
type: cc.Toggle
},
channelTip: {
default: null,
type: cc.Label
},
channelState: {
default: Channel.WORLD_CHANNEL,
type: cc.Enum(Channel)
},
chatInput: {
default: null,
type: cc.EditBox
},
chatItemPrefab: {
default: null,
type: cc.Prefab
},
chatContent: {
default: null,
type: cc.Node
}
}
其中,chatItemPrefab是每條消息的承載節點預制體,服務器發給客戶端的每條消息,就把這個節點添加到chatContent中,展示在UI上。


圖:chatItemPrefab預制體,可以看到就是一個Label節點而已。
將對應的節點拖拽分配好后,開始編寫相關的業務邏輯代碼。

首先還是發起到websocket的連接。
onLoad() {
this.chatItems = [];
this.userID = this.makeUUID();
this.chatWS = new WebSocket("ws://127.0.0.1:8182");
this.chatWS.onmessage = event => {
this.getChatMessageFromServer(event.data);
};
this.chatItemNodePool = new cc.NodePool();
},
makeUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
},
代碼使用了一個節點池chatItemNodePool,是用來保存chatItemPrefab使用后的節點信息的,使用節點池可以管理重復利用在游戲中動態創建的節點,達到降低消耗的目的,相關具體的內容可以查閱CocosCreator開發文檔中的內容,這里不多做介紹。
代碼中還有一段 this.userID = this.makeUUID() 的代碼,是用來給每個打開客戶端的用戶隨機分配一個用戶ID以作區分(因為還沒有開發登錄系統)
changeToWorldChannel() {
this.channelState = Channel.WORLD_CHANNEL;
this.channelTip.string = "世";
},
changeToTeamChannel() {
this.channelState = Channel.TEAM_CHANNEL;
this.channelTip.string = "團";
},
changeToPersonalannel() {
this.channelState = Channel.PERSONAL_CHANNEL;
this.channelTip.string = "密";
},
這段代碼是綁定在頻道選擇的單選按鈕上的,用來處理頻道選擇的邏輯,這里在頻道顯示中使用顏色來區分在哪個頻道發了言(密聊系統是針對特定用戶ID的行為,本項目中暫時先不實現功能。)
//頻道預制體的申明
let Channel = cc.Enum({
WORLD_CHANNEL: 0,
TEAM_CHANNEL: 1,
PERSONAL_CHANNEL: 2
});
//頻道發言后顯示在聊天界面的消息顏色
getChatItemColor(channel) {
switch(channel) {
case Channel.WORLD_CHANNEL:
return cc.Color.GREEN;
case Channel.TEAM_CHANNEL:
return cc.Color.BLUE;
case Channel.PERSONAL_CHANNEL:
return cc.Color.RED;
}
},
//接受到服務端返回的消息的處理
getChatMessageFromServer(msg) {
let msgJson = JSON.parse(msg);
//通過節點池或者預制體創建一個消息節點並放入到聊天消息界面節點中
let chatItem = this.chatItemNodePool.size > 0 ? this.chatItemNodePool.get() : cc.instantiate(this.chatItemPrefab);
chatItem.getComponent(cc.Label).string = msgJson.content;
this.chatContent.addChild(chatItem);
//因為節點要加入到場景中,才能設置生效其中的屬性,因為布局的關系,這里設置錨點和坐標如代碼中
chatItem.color = this.getChatItemColor(msgJson.channel);
chatItem.anchor = cc.v2(0, 0);
chatItem.setPosition(cc.v2(0, 0));
//因為消息窗口中,最新的消息總是顯示在最下方,所以歷史消息要根據新消息的高度向上移動
let contentHeight = 0;
for(let i in this.chatItems) {
let oriPosition = this.chatItems[i].getPosition();
this.chatItems[i].setPosition(cc.v2(oriPosition.x, oriPosition.y + chatItem.height));
contentHeight += this.chatItems[i].height;
}
//給聊天消息窗口的ScrollView設置新的高度,並限定最大高度,免得消息窗口被撐到太高翻起來麻煩
contentHeight += chatItem.height;
if(contentHeight > 1000) {
contentHeight = 1000;
}
//不知道什么原因,每次改變高度后坐標會變動,這里重新修改坐標
this.chatItems.push(chatItem);
this.chatContent.height = contentHeight;
this.chatContent.setPosition(cc.v2(0, 0));
//遍歷一下所有聊天消息節點,如果有超過聊天窗口高度的,從節點中移除,節約邏輯消耗
for(let i in this.chatItems) {
let oriPosition = this.chatItems[i].getPosition();
if(this.chatItems[i].position.y > 1500){
this.chatItemNodePool.put(this.chatItems[i]);
this.chatItems.splice(index, 1);
}
}
},
這段代碼是項目中最重要的地方,涉及到了如果將服務器返回的消息顯示在消息窗口中,並能操作UI界面正確的顯示我們想要的內容。
sendChatMessageToServer() {
if (this.chatWS.readyState === WebSocket.OPEN) {
let chatData = {
userID: this.userID,
channel: this.channelState,
content: this.chatInput.string
};
this.chatInput.string = '';
this.chatWS.send(JSON.stringify(chatData));
}
}
最后一段代碼就比較簡單,將客戶端輸入的聊天消息(包括userid,聊天頻道,聊天內容),打包成json格式發送給服務器即可。
接下來看一下服務器代碼。
因為服務器的鏈接處理模塊是通用的,在上一篇基礎上,把連接處理的功能抽離出來。修改websocketServer.js成如下所示。並放在modules文件夾下。
const ws = require("nodejs-websocket");
module.exports = createServer = (port, callbacks) => {
let server = ws.createServer(connection => {
//客戶端向服務器發送字符串時的監聽函數
connection.on("text", result => {
console.log("connection.on -> text", result);
//在這里,接收到某一個客戶端發來的消息,然后統一發送給所有連接到websocket的客戶端
if(callbacks.textCallback){
callbacks.textCallback(server, result);
}
// server.connections.forEach((client) => {
// client.sendText(result);
// });
});
//客戶端向服務器發送二進制時的監聽函數
connection.on("binary", result => {
console.log("connection.on -> binary", result);
});
//客戶端連接到服務器時的監聽函數
connection.on("connect", result => {
console.log("connection.on -> connect", result);
});
//客戶端斷開與服務器連接時的監聽函數
connection.on("close", result => {
console.log("connection.on -> close", result);
});
//客戶端與服務器連接異常時的監聽函數
connection.on("error", result => {
console.log("connection.on -> error", result);
});
}).listen(port);
return server;
};

然后再Server文件夾下新建一個chatServer.js,里面用來啟動websocket服務和處理消息內容。
const wsServer = require('../modules/websocketServer');
const textCallback = (server, result) => {
let resJson = JSON.parse(result);
//消息的處理,根據客戶端傳來的數據結構,拼接成完整的消息字段再返回給客戶端
let channel = '';
if(resJson.channel === 0) {
channel = '世界';
}else if(resJson.channel === 1){
channel = '團隊';
}else{
channel = '密聊';
}
let date = new Date();
let hour = date.getHours();
let minute = date.getMinutes();
let second = date.getSeconds();
server.connections.forEach((client) => {
let content = `[${hour < 10 ? '0' + hour : hour}:${minute < 10 ? '0' + minute : minute}:${second < 10 ? '0' + second : second}][${channel}][${resJson.userID}]:${resJson.content}`;
let chatChannel = resJson.channel;
let msg = {
content: content,
channel: chatChannel
}
client.sendText(JSON.stringify(msg));
});
}
const connectCallback = (server, result) => {
}
module.exports = ChatServer = (port) => {
let callbacks = {
textCallback: (server, result) => {
textCallback(server, result);
},
connectCallback: (server, result) => {
connectCallback(server, result);
},
};
const chatServer = wsServer(port, callbacks);
};
最后在根目錄的indexl.js中,添加這個服務端口。
const http = require('http');
const url = require('url');
const chatServer = require('./Server/chatServer');
// const wsServer = require('./websocketServer');
http.createServer(function(req, res){
var request = url.parse(req.url, true).query
var response = {
info: request.input ? request.input + ', hello world' : 'hello world'
};
res.setHeader("Access-Control-Allow-Origin", "*");//跨域
res.write(JSON.stringify(response));
res.end();
}).listen(8181);
const chat = chatServer(8183);
因為在游戲客戶端中,連接的聊天服務器端口是8183,這里我們服務器啟動的端口也設置成8183。
接下來啟動服務器和客戶端:

可以看到一個簡陋的聊天界面就呈現在了眼前。。
接下來我們輸入一些內容看看效,比如輸入 “這里是A在發消息”

可以看到,聊天消息窗口出現了 [發消息的時間][用戶uuid]:這里是A在發消息 的信息,證明聊天框輸入的文字經過服務器處理並成功返回給客戶端顯示在聊天界面上了。
我們再新開一個頁面,輸入 “這里是B在發消息”

因為新的頁面是在發送A消息后才打開的,這里只能看到打開后的消息了,但是切回到A的頁面,可以看到此時的B頁面

B在發送消息后,A同樣收到了消息,證明不同的客戶端,連接同一個websocket服務器,是可以互通數據的。
我們在B頁面再發一條消息 “這里B又發了一條消息”,並選擇團隊頻道

同時切換到A頁面,可以看到收到了B在團隊頻道發的消息(因為世界頻道消息也使用了綠顏色字體,和上面的顏色重疊了看不見了。。。)

我們在A頁面再發一條消息 “這里A又發了一條消息”,並選擇團隊頻道 ,A頁面如圖所示

B頁面如圖所示

可以看到,不管是A發送的消息,還是B發送的消息,兩個同時運行的客戶端都能夠看到消息了。那么我們再A中多輸入一些內容

A頁面截圖

A頁面截圖

B頁面截圖
可以發現,出現滾動條可以翻看以前的消息了。至此,一個簡單的客戶端聊天界面和聊天系統服務器就算完成了。
下次,先暫時告別服務器端的工作,搭建一個簡單的游戲場景,有人物有背景有搖桿操作(大概一兩篇隨筆的樣子)。在這部分內容完成后,來搭建一個地圖服務器。
文章中的代碼:
客戶端: https://github.com/MythosMa/CocosCreator_ClientTest.git
服務端: https://github.com/MythosMa/NodeJS_GameServerTest.git
