头脑王者——小程序核心功能开发
头脑王者答题对战源码分析教程
接到业务需求:用微信小程序开发一个答题对战类的游戏,借此机会呢把小程序好好研究一下(小程序出来很长时间了现在才看)。前段时间超级火爆的“头脑王者”就是我最好的研究对象。在研究阶段呢,没有配置前端、设计。所以样式基本是仿照的。废话不多说,进入正题。
我们实现最核心的玩法就行:单人答题、双人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 },
注意看,第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 }
注意:本地调试的,小程序不再提供弹框的方式授权。需要使用button组件,让用户点击弹出授权。我在获取用户信息的时候,如果没有授权,会跳转到一个专门的授权页面。
1 <button wx:if="{{!scopeUserinfo}}" open-type="getUserInfo"bindgetuserinfo="getUserInfo">测试版:请同意授权 2 </button>
二、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 }
用户通过链接进入对战页面初始化代码:
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 },
拿到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 },
现在最关键的地方来了:创建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 };
用户进入房间后,初始化参数后,要通知对方。
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 },
当双方都进入房间后。房主出发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 },
双方开始答题,配置响应事件。这个就不一一列举了,上代码
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 },
在上一下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 }
答题基本的思路就是这样。里面有个聊天室也是基于workman开发的,见图5,样式是模仿微信。有空会重新补充一下细节。现在上一下图。本人是后台程序,前端不太擅长,一些细节没有优化,请见谅。有问题的可以联系我:mian_wu@qq.com