头脑王者答题对战源码分析教程
接到业务需求:用微信小程序开发一个答题对战类的游戏,借此机会呢把小程序好好研究一下(小程序出来很长时间了现在才看)。前段时间超级火爆的“头脑王者”就是我最好的研究对象。在研究阶段呢,没有配置前端、设计。所以样式基本是仿照的。废话不多说,进入正题。
我们实现最核心的玩法就行:单人答题、双人pk答题、排行榜(好友/世界)。
知识储备:涉及到实时通讯,在nodejs和workman之间,选择了workman。需要过一遍微信小程序官方文档。如果有vue基础那么上手就更快了。最重要的一点:ES6语法要熟悉。
一、用户信息
在小程序内,因为多个页面都需要用户信息,所以用户信息的获取放在app.js里,做一个本地存储。小程序在启动后是有两个不同步的线程:view thread 和 appservice thread。为了解决线程不同步造成用户信息获取出现的问题,用promise+callback进行了改造。获取用户openid的过程我们放在了第一步。获取用户信息:要判断是否有缓存、是否有授权等情况。
1 const promise = new Promise(function (resolve, reject) {
2 var openid = wx.getStorageSync('openid');
3 if (openid == "" || openid == null) {
4 wx.login({
5 success: res => {
6 wx.request({
7 url: 'https://fotonpickup.risingad.com/wxapi2.php',
8 data: { 'code': res.code },
9 success(res) {
10 wx.setStorage({
11 key: "openid",
12 data: res.data.openid
13 });
14 resolve(res.data.openid);
15 },
16 fail(res) {
17 reject(res);
18 }
19 });
20 }
21 })
22 } else {
23 resolve(openid);
24 }
25 });
26 promise.then((openid) => {
27 return new Promise((resolve, reject) => {
28 //先从本地取用户信息
29 var wxinfo = wx.getStorageSync('wxinfo');
30 if (wxinfo) {
31 resolve(wxinfo);
32 } else {
33 wx.getSetting({
34 success: res => {
35 if (res.authSetting['scope.userInfo']) {
36 // 已经授权,可以直接调用 getUserInfo 获取头像昵称,不会弹框
37 wx.getUserInfo({
38 success: res => {
39 resolve(res.userInfo);
40 }
41 })
42 } else {
43 //没有授权的情况,不知道正式版本是什么样子的。
44 wx.getUserInfo({
45 success: res => {
46 resolve(res.userInfo);
47 },fail:res=>{
48 //resolve(false);
49 //没有授权的预览版本,用open-type获取,并跳转统一页面,其他页面默认为有用户授权的状态
50 wx.navigateTo({
51 url: '/pages/scope/scope'
52 });
53 }
54 })
55 }
56 }
57 })
58 }
59 }).then((wxinfo) => {
60 if (wxinfo) {
61 //将用户信息上传服务器保存一份
62 this.uploadUserInfo(wxinfo);
63 //本地存储用户cookie
64 wx.setStorage({
65 key: "wxinfo",
66 data: wxinfo
67 });
68 }
69 this.globalData.userInfo=wxinfo;
70 if(this.userInfoReadyCallback){
71 this.userInfoReadyCallback(wxinfo);
72 }
73 })
74 })
75 },
View Code
注意看,第74行。此处注入了一个回调函数。是为了解决view thread 和app thread不同步的时候出现的问题。我们看一下怎么使用app中获取的用户信息
1 onLoad: function () {
2 wx.showLoading({
3 title: '加载中',
4 });
5 if (app.globalData.userInfo) {
6 this.setData({
7 userInfo: app.globalData.userInfo
8 });
9 wx.hideLoading();
10
11 } else {
12 app.userInfoReadyCallback = res => {
13 if (res) {
14 this.setData({
15 userInfo: res
16 });
17 wx.hideLoading();
18 }
19 };
20 }
21
22 }
View Code
注意:本地调试的,小程序不再提供弹框的方式授权。需要使用button组件,让用户点击弹出授权。我在获取用户信息的时候,如果没有授权,会跳转到一个专门的授权页面。
1
二、workman实时通讯
这个workman可以查看官方文档,配置起来很简单。不过需要注意的一点就是:小程序只支持wss的协议,必须是带证书的https的请求。我们需要改一下workman中的代码。进入start_gateway.php这个文件,配置下证书参数。transport改为ssl
1 // gateway 进程,这里使用Text协议,可以用telnet测试
2 // $gateway = new Gateway("tcp://0.0.0.0:8282");
3 $context = array(
4 // 更多ssl选项请参考手册 http://php.net/manual/zh/context.ssl.php
5 'ssl' => array(
6 // 请使用绝对路径
7 'local_cert' => 'E:/www/GatewayWorker-for-win/1535601_wxapp.x-nev.com.pem', // 也可以是crt文件
8 'local_pk' => 'E:/www/GatewayWorker-for-win/1535601_wxapp.x-nev.com.key',
9 'verify_peer' => false,
10 // 'allow_self_signed' => true, //如果是自签名证书需要开启此选项
11 )
12 );
13 $gateway = new Gateway("Websocket://0.0.0.0:1451",$context);
14 $gateway->transport = 'ssl';
在小程序的开发工具中要配置,wss域名:1451 。这个端口是在workman中配置,可以试任意端口。至此workman和小程序的连接打通了。
邀请好友pk,就是开一个房间(在workman中对应组的概念),然后在分享的链接中加入roomid这个参数。在用户登入之后,通过workman通知对方。
分享按钮对应的代码:
1 onShareAppMessage: function (res) {
2 if (res.from === 'button') {
3 var timestamp = new Date().getTime();
4 var r = parseInt(Math.random() * (100000 - 5000 + 1) + 5000, 10);
5 var roomid = timestamp + '' + r;
6 var s_endtime = timestamp + 600000;
7 var s_openid = wx.getStorageSync('openid');
8 return {
9 title: '来啊,狗蛋!对战吧',
10 path: '/pages/dz/dz?pkroom=' + roomid+'&endtime='+s_endtime+'&fqzid='+s_openid,
11 success: function () {
12 var datastr= {pkroom: roomid, endtime: s_endtime, fqzid: s_openid};
13 wx.setStorage({
14 key: 'pkinfo',
15 data:datastr
16 })
17 wx.navigateTo({
18 url: '/pages/dz/dz'
19 });
20 }
21
22 }
23 } else {
24 return {
25 title: '来啊,狗蛋!',
26 path: '/pages/index/index',
27 imageUrl: '/imgs/yang.jpg'
28 }
29 }
30 }
View Code
用户通过链接进入对战页面初始化代码:
1 onLoad: function (options) {
2
3 inituserinfo = new Promise(function (resolve, reject) {
4 if (app.globalData.userInfo) {
5 resolve(options);
6 } else {
7 app.userInfoReadyCallback = res => {
8 resolve(options);
9 };
10 }
11 });
12 inituserinfo.then(options => {
13 if (options.pkroom) {
14 //有参数,判断是否有效期内,判断pkinfo,对比openid
15 if (options.endtime > new Date().getTime()) {
16 //有效期内。
17 var pkinfo = wx.getStorageSync('pkinfo') || {};
18 pkinfo.endtime = options.endtime;
19 pkinfo.fqzid = options.fqzid;
20 pkinfo.pkroom = options.pkroom;
21 wx.setStorage({
22 key: 'pkinfo',
23 data: { 'pkroom': pkinfo.pkroom, 'endtime': pkinfo.endtime, 'fqzid': pkinfo.fqzid }
24 })
25 this.intime(pkinfo);
26 } else {
27 //url上房间过期
28 this.overtime();
29 }
30 } else {
31 //没有参数,则判断有无pkinfo;
32 var pkinfo = wx.getStorageSync('pkinfo');
33 console.log("没有参数:取pkinfo:" + pkinfo.endtime);
34 console.log("没有参数:当前时间:" + new Date().getTime());
35 if (pkinfo && (pkinfo.endtime > new Date().getTime())) {
36 //在有效期内,更新计时器。对比openid,加载用户信息
37 this.intime(pkinfo);
38 //开始监听
39 } else {
40 //没有,或则过期,清空cookie
41 this.overtime();
42 }
43 }
44 });
45 },
View Code
拿到url参数,我们要判断,这个房间谁是房主,谁是挑战者。 要判断房间是否在有效期内,房主是否关闭房间。因为用户进入挑战页的方式太多,不同方式对应这不同的情况。
1 //有效期内
2 intime: function (pkinfo) {
3 //在有效期内,更新计时器。对比openid,加载用户信息
4 //计算剩余时间
5 var stime = pkinfo.endtime - new Date().getTime();
6 endtime = parseInt(stime / 1000);
7 var s_openid = wx.getStorageSync('openid');
8 var userInfo = {
9 'name': app.globalData.userInfo.nickName,
10 'img': app.globalData.userInfo.avatarUrl,
11 'openid': s_openid
12 };
13 if (s_openid == pkinfo.fqzid) {
14 this.setData({
15 fqz: userInfo,
16 type: 'fqz',
17 pkroom: pkinfo.pkroom
18 })
19 } else {
20 this.setData({
21 tzz: userInfo,
22 type: 'tzz',
23 pkroom: pkinfo.pkroom
24 })
25 }
26 },
27 //房间过期
28 overtime: function () {
29 wx.showToast({
30 title: '对战房间已过期',
31 icon: 'info',
32 duration: 1000
33 });
34 wx.removeStorage({
35 key: 'pkinfo'
36 })
37 setTimeout(() => {
38 wx.redirectTo({
39 url: '/pages/index/index'
40 })
41 }, 1800);
42 },
View Code
现在最关键的地方来了:创建socket连接及监听!!!!!
小程序只允许同时创建不超过两个socket连接。在页面创建连接的时候尤其得注意,页面跳转、退出、隐藏、显示的过程中,要保护好线程。所以我们统一在onunload的时候,主动关闭socket连接。下次进来的时候根据储存到本地的房间信息重新创建连接。我封装了一个sockethelper.js,因为这个init/open的过程都是异步的,所以使用了promise封装了一下。(class、constructor是es6的语法,不清楚的可以先看一下es6的语法)
1 class webSocket {
2
3 constructor(obj = {}) {
4 this.hasConn = false
5 this.hasOpen = false
6 this.msghandle = obj;
7 this.msghandle['ping'] = function (e, _this) {
8 var data = { 'type': 'pong' };
9 _this.SendMsg(data);
10 }
11 var _this = this;
12 this.SocketTask = wx.connectSocket({
13 url: 'wss://wxapp.x-nev.com:1451',
14 header: {
15 'content-type': 'application/json'
16 },
17 method: 'post',
18 success: function (res) {
19 _this.hasConn = true;
20 },
21 fail: function (err) {
22 wx.showToast({
23 title: '网络异常!',
24 })
25 },
26 });
27 }
28 StartListen() {
29 var _this = this;
30 return new Promise((resolve, reject) => {
31
32 this.SocketTask.onOpen(function (res) {
33 console.log("page:socket:open");
34 _this.hasOpen = true;
35 resolve(res);
36 })
37 this.SocketTask.onClose(function (res) {
38 console.log("page:socket:close")
39 })
40
41 this.SocketTask.onError(function (res) {
42 console.log("page:socket:error-");
43 // reject(res);
44 })
45
46 this.SocketTask.onMessage(function (onMessage) {
47 var data = JSON.parse(onMessage.data);
48 var msgtype = data['type'];
49 if (msgtype in _this.msghandle) {
50 _this.msghandle[msgtype](data, _this);
51 }
52 })
53
54 });
55
56 }
57 SendMsg(msg, callback) {
58 if (this.hasOpen && this.hasConn) {
59 this.SocketTask.send({
60 data: JSON.stringify(msg),
61 success: (e) => { if (callback) { callback(e) } }
62 })
63 } else {
64 console.log("没有open,调用一下");
65 this.StartListen().then(() => {
66 this.SocketTask.send({
67 data: JSON.stringify(msg),
68 success: (e) => { if (callback) { callback(e) } }
69 })
70 });
71 }
72
73 }
74 }
75
76 module.exports = { webSocket };
View Code
用户进入房间后,初始化参数后,要通知对方。
1 onReady: function () {
2
3 var info = {};
4 var s_openid = wx.getStorageSync('openid');
5 info.openid = s_openid;
6 info.pkroom = this.data.pkroom;
7 if (this.data.type == "fqz") {
8 info.mark = "fqz";
9 info.name = this.data.fqz.name;
10 info.img = this.data.fqz.img;
11 } else {
12 info.mark = "tzz"
13 info.name = this.data.tzz.name;
14 info.img = this.data.tzz.img;
15 }
16 //如果从分享挑战页面进来,要注册获取用户信息的回调,注册socket
17 this.registResponse(info);
18 app.globalData.stask.SendMsg({
19 'type': 'pk',
20 'mark': info.mark,
21 'openid': info.openid,
22 'name': info.name,
23 'img': info.img,
24 'pkroom': info.pkroom
25 });
26 this.formattime();
27 t2 = setInterval(() => {
28 this.formattime();
29 }, 1000);
30 },
31 formattime: function () {
32 endtime--;
33 var m = parseInt(endtime / 60);
34 var s = parseInt(endtime % 60);
35 m = m.toString().length > 1 ? m : '0' + m;
36 s = s.toString().length > 1 ? s : '0' + s;
37 var tem = `${m}:${s}`;
38 this.setData({
39 time: tem
40 });
41 },
View Code
当双方都进入房间后。房主出发start事件,开始答题。start事件请求后台分配对应等级的题目,并通知对方题目
1 start: function () {
2 //请求对应等级的题目,
3 wx.request({
4 url: 'https://fotonpickup.risingad.com/wxxldev/index.php?c=WXSignApi&a=Question',
5 data: { 'roomid': this.data.pkroom, 'one': this.data.fqz.openid, 'two': this.data.tzz.openid },
6 success: (result) => {
7 this.setData({
8 qlist: result.data.row,
9 recordid: result.data.flag,
10 current: 0
11 });
12 //开始答题通知workman,提供题目id。跳转对战页面。
13 app.globalData.stask.SendMsg({
14 'type': 'ready',
15 'recordid': result.data.flag,
16 'uid': this.data.tzz.openid
17 });
18 }
19 });
20
21 },
View Code
双方开始答题,配置响应事件。这个就不一一列举了,上代码
1 registResponse: function (pkinfo) {
2 var obj = {
3 'pk': (res, _this) => {
4 //拿到用户信息,绑定用户头像。
5 if (res.content.length == 1) {
6 if (res.content[0].mark == "fqz") {
7 this.setData({
8 'fqz': res.content[0]
9 });
10 } else {
11 this.setData({
12 'tzz': res.content[0]
13 });
14 }
15 } else {
16 if (res.content[0].mark == "fqz") {
17 this.setData({
18 'fqz': res.content[0],
19 'tzz': res.content[1]
20 });
21 } else {
22 this.setData({
23 'fqz': res.content[1],
24 'tzz': res.content[0]
25 });
26 }
27 }
28 },
29 'ready': (res, _this) => {
30 //此处注册的监听,只有tzz可以捕获到。后端是单独推送的
31 //挑战者请求加载相应的题目,并准备好界面。
32 console.log("拉取题:" + res.id);
33 wx.request({
34 url: 'https://fotonpickup.risingad.com/wxxldev/index.php?c=WXSignApi&a=GetQuestion',
35 data: { 'id': res.id },
36 success: (result) => {
37 this.setData({
38 qlist: result.data,
39 current: 0
40 });
41 //通知服务器tzz准备好了,
42 app.globalData.stask.SendMsg({ 'type': 'start' });
43 }
44 });
45 },
46 'start': (res, _this) => {
47 //开始各自准备pk界面。
48 this.setData({
49 start: true
50 });
51 //在这里开启当前计时器。
52 console.log("1.开启计时器");
53 t1 = setInterval(() => {
54
55 if (this.data.time2 > 0) {
56 this.setData({
57 time2: this.data.time2 - 1
58 });
59 if (this.data.myself && this.data.opposite) {
60 //都已答题,如果不是最后一题,进入下一题。并积分,初始化状态
61 console.log("2.都已答题");
62 if (this.data.current == this.data.qlist.length - 1) {
63 //结束,出成绩。
64 clearInterval(t1);
65 console.log("9.结束计时器");
66 this.setData({
67 isend: true
68 });
69 this.next(true);
70 } else {
71 console.log("3.调用下一题");
72 this.next();
73 }
74 }
75 } else {
76 //时间到了,未答题。展示正确答案。进入下一题。
77 //初始化 答题状态、时间、样式。
78 console.log("4.时间到了有人未答题");
79 if (this.data.current == this.data.qlist.length - 1) {
80 //结束,出成绩。
81 clearInterval(t1);
82 this.setData({
83 isend: true
84 });
85 this.next(true);
86 console.log("10.结束计时器");
87 } else {
88 this.data.classlist[this.data.qlist[this.data.current].ropt - 1] = 'right';
89 setTimeout(() => {
90 console.log("5.时间到了有人未答题,显示正确答案,进入下一题");
91 this.next();
92 }, 1000);
93
94 }
95
96 }
97 }, 1000);
98 },
99 'dt': (res, _this) => {
100 console.log("7." + res.mark + "选择了" + res.opts);
101 var isright = res.opts == this.data.qlist[this.data.current].ropt ? true : false;
102 if (res.mark != this.data.type) {
103 this.setData({
104 opposite: true,//对方已答题。
105 oppositeright: isright,
106 oppositeopt: res.opts
107 })
108 } else {
109 this.setData({
110 myself: true,//自己已答题
111 myselfright: isright
112 });
113 if (isright) {
114 //答对
115 this.data.classlist[res.opts - 1] = 'right';
116 } else {
117 this.data.classlist[res.opts - 1] = 'error';
118 }
119 }
120 if (this.data.myself && this.data.opposite) {
121 //都已答题
122 this.data.classlist[this.data.qlist[this.data.current].ropt - 1] = 'right';
123 this.data.classlist[this.data.oppositeopt - 1] = this.data.oppositeright ? 'right' : 'error';
124 }
125 this.setData({
126 'classlist': this.data.classlist
127 });
128 },
129 'gameover': (res, _this) => {
130 wx.showToast({
131 title: '房主关闭房间',
132 icon: 'info',
133 duration: 1000
134 });
135 wx.removeStorage({
136 key: 'pkinfo',
137 complete: function (e) {
138 console.log(e);
139 }
140 });
141 setTimeout(() => {
142 wx.redirectTo({
143 url: '/pages/index/index'
144 })
145 }, 2000);
146 }
147 }
148 //开启监听
149 app.globalData.stask = new webSocket();
150 //注册监听响应事件
151 Object.assign(app.globalData.stask.msghandle, obj);
152
153 },
View Code
在上一下workman都监听的代码
1 /**
2 * 当客户端发来消息时触发
3 * @param int $client_id 连接id
4 * @param mixed $message 具体消息
5 */
6 public static function onMessage($client_id, $message)
7 {
8 // 向所有人发送
9
10 $message_data = json_decode($message, true);
11 $now = date('y-m-d H:i:s', time());
12 $filepath="E:\\www\\GatewayWorker-for-win\\msg.txt";
13 file_put_contents($filepath, "time:$now,client_id:".$client_id.':msg:'.$message.PHP_EOL, FILE_APPEND);
14 if (!$message_data) {
15 return ;
16 }
17
18 // 根据类型执行不同的业务
19 switch ($message_data['type']) {
20
21 // 客户端回应服务端的心跳
22 case 'pong':
23 return;
24 // 客户端登录 message格式: {type:login, name:xx, room_id:1} ,添加到客户端,广播给所有客户端xx进入聊天室
25 case 'login':
26 // 判断是否有房间号
27 if (!isset($message_data['room_id'])) {
28 return '';
29 }
30 // 把房间号昵称放到session中
31 $room_id = $message_data['room_id'];
32 //昵称
33 $client_name = htmlspecialchars($message_data['client_name']);
34 //头像
35 $client_img= $message_data['client_img'];
36 // 设置当前用户的sesion可直接设置。等同于 Gateway::setSession(string $client_id, array $session);
37 $_SESSION['room_id'] = $room_id;
38 $_SESSION['client_name'] = $client_name;
39 $_SESSION['client_img'] = $client_img;
40 // 转播给当前房间的所有客户端,xx进入聊天室 message {type:login, client_id:xx, name:xx}
41 $new_message = array('type'=>$message_data['type'], 'client_id'=>$client_id, 'client_name'=>htmlspecialchars($client_name), 'time'=>date('Y-m-d H:i:s'));
42 Gateway::sendToGroup($room_id, json_encode($new_message));
43 Gateway::joinGroup($client_id, $room_id);
44 // 获取房间内所有用户列表
45 $clients_list = Gateway::getClientSessionsByGroup($room_id);
46 foreach ($clients_list as $tmp_client_id=>$item) {
47 $clients_list[$tmp_client_id] = $item['client_name'];
48 }
49 // 给当前用户发送用户列表
50 $new_message['client_list'] = $clients_list;
51 Gateway::sendToCurrentClient(json_encode($new_message));
52 return;
53
54 // 客户端发言 message: {type:say, to_client_id:xx, content:xx}
55 case 'say':
56 // 非法请求
57 if (!isset($_SESSION['room_id'])) {
58 throw new \Exception("\$_SESSION['room_id'] not set. client_ip:{$_SERVER['REMOTE_ADDR']}");
59 }
60 $room_id = $_SESSION['room_id'];
61 $client_name = $_SESSION['client_name'];
62 $client_img = $_SESSION['client_img'];
63 // 私聊
64 if ($message_data['to_client_id'] != 'all') {
65 $new_message = array(
66 'type'=>'say',
67 'from_client_id'=>$client_id,
68 'from_client_name' =>$client_name,
69 'to_client_id'=>$message_data['to_client_id'],
70 'content'=>nl2br(htmlspecialchars($message_data['content'])),
71 'time'=>date('Y-m-d H:i:s'),
72 );
73 Gateway::sendToClient($message_data['to_client_id'], json_encode($new_message));
74 return Gateway::sendToCurrentClient(json_encode($new_message));
75 }
76
77 $new_message = array(
78 'type'=>'say',
79 'from_client_id'=>$client_id,
80 'from_client_name' =>$client_name,
81 'to_client_id'=>'all',
82 'client_img'=> $client_img ,
83 'content'=>nl2br(htmlspecialchars($message_data['content'])),
84 'time'=>date('Y-m-d H:i:s'),
85 );
86 return Gateway::sendToGroup($room_id, json_encode($new_message));
87 case 'pk':
88 //{type:pk, pkroom:xx,name:'xxx',img:'xxx'}
89 $pkroom= $message_data['pkroom'];
90 $_SESSION['pkroom']= $pkroom;
91 $_SESSION['name']= $message_data['name'];
92 $_SESSION['img']= $message_data['img'];
93 $_SESSION['mark']= $message_data['mark'];
94 $_SESSION['openid']= $message_data['openid'];
95 //绑定openid到client_id;
96 Gateway::bindUid($client_id, $message_data['openid']);
97 //开一个新房间
98 Gateway::joinGroup($client_id, $pkroom);
99 //返回当前对战房间的人员
100
101 $clients_list = Gateway::getClientSessionsByGroup($pkroom);
102 foreach ($clients_list as $tmp_client_id=>$item) {
103 $pklist[]=$item;
104 }
105 $new_message = array(
106 'type'=>'pk',
107 'content'=>$pklist
108 );
109 return Gateway::sendToGroup($pkroom, json_encode($new_message));
110 case 'dt':
111 $opts=$message_data['opts'];
112 $mark= $_SESSION['mark'];
113 $pkroom= $_SESSION['pkroom'];
114 $new_message = array(
115 'type'=>'dt',
116 'opts'=>$opts,
117 'mark'=>$mark
118 );
119 return Gateway::sendToGroup($pkroom, json_encode($new_message));
120 case 'ready':
121 $recordid= $message_data['recordid'];
122 $uid= $message_data['uid'];//tzz的uid
123 $new_message = array(
124 'type'=>'ready',
125 'id'=>$recordid
126 );
127 //只给tzz响应
128 return Gateway::sendToUid($uid, json_encode($new_message));
129 case 'start':
130 $pkroom= $_SESSION['pkroom'];
131 $new_message = array(
132 'type'=>'start'
133 );
134 return Gateway::sendToGroup($pkroom, json_encode($new_message));
135 case 'gameover':
136 $pkroom= $_SESSION['pkroom'];
137 $new_message = array(
138 'type'=>'gameover'
139 );
140 Gateway::sendToGroup($pkroom, json_encode($new_message));
141 return Gateway::ungroup($pkroom);
142 }
143 }
View Code
答题基本的思路就是这样。里面有个聊天室也是基于workman开发的,见图5,样式是模仿微信。有空会重新补充一下细节。现在上一下图。本人是后台程序,前端不太擅长,一些细节没有优化,请见谅。有问题的可以联系我:mian_wu@qq.com