上一篇记录了一下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