对战的实现,核心在于对watch的应用,云开发数据集基于webSocket的封装,监听集合中符合查询条件的数据的更新事件,当所监听的doc
发生数据变化,触发onChange
事件回调,通过回调的数据切换相应的场景。也就是监听当前的房间的记录,当双方用户选词的时候,相应的业务显示即可
单词天天斗-好友对战具体实现
创建房间(房主)
好友对战,我们从房主创建房间开始,用户通过首页好友对战
按钮的点击事件来创建新房间。
事件触发
<button open-type="getUserInfo" bindgetuserinfo="onChallengeFriend">好友对战</button>
onChallengeFriend: throttle(async function(e) { // 点击后获取用户信息,同时更新信息,且创建房间
const { detail: { userInfo } } = e
if (userInfo) {
await userModel.updateInfo(userInfo) // 更新用户信息
this.triggerEvent('onChallengeFriend') // 触发父组件的创建房间操作
} else {
this.selectComponent('#authFailMessage').show() // 授权失败的弹窗显示,提示用户授权
}
}, 1000),
其中用到了函数节流来优化体验,作为一个知识点介绍一下。当用户快速多次点击创建房间的按钮,我们希望在1s内多次触发的话,只触发一次,所以使用节流函数(throttle)做一下包裹,实现如下:
export function throttle(fn, gapTime = 500) {
let _lastTime = null
return function() {
const _nowTime = +new Date()
if (_nowTime - _lastTime > gapTime || !_lastTime) {
fn.apply(this, arguments) // 注意:此处应用apply,用call的话,需要处理传参,不然fn函数中的this和event等存在问题
_lastTime = _nowTime
}
}
}
创建房间的完整流程
创建房间又分为4个小步骤,下面代码为完整流程
/**
* 好友对战或随机匹配没有房间的时候,创建单词PK房间
* 1. 获取对局单词数目,且取对应类目的随机单词
* 2. 格式化单词列表
* 3. 创建房间(把房间信息写数据库存储)
* 4. 跳转至对战房间页面
*/
async createCombatRoom(isFriend = true) {
try {
$.loading('生成随机词汇中...')
const { data: { userInfo: { bookId, bookDesc, bookName } } } = this
// 1. 获取对局单词数目,且取对应类目的随机单词
const number = getCombatSubjectNumber()
const { list: randomList } = await wordModel.getRandomWords(bookId, number * SUBJECT_HAS_OPTIONS_NUMBER)
// 2. 格式化单词列表
const wordList = formatList(randomList, SUBJECT_HAS_OPTIONS_NUMBER)
$.loading('创建房间中...')
// 3. 创建房间(把房间信息写数据库存储)
const roomId = await roomModel.create(wordList, isFriend, bookDesc, bookName)
$.hideLoading()
// 4. 跳转至对战房间页面
router.push('combat', { roomId })
} catch (error) {
$.hideLoading()
this.selectComponent('#createRoomFail').show()
}
},
细节:取出的随机单词
首先获取房间要创建多少单词数目的配置项
/**
* 获取每局对战的词汇数量,从localStorage中获取,用户可以在设置中修改每局对战词的数目
*/
export const getCombatSubjectNumber = function() {
const number = $.storage.get(SUBJECT_NUMBER) // 读缓存中的数据
if (typeof number !== 'number' || !PK_SUBJECTS_NUMBER.includes(number)) { // 如果读出来的数据非法,则使用默认数据
setCombatSubjectNumber(DEFAULT_PK_SUBJECT_NUMBER) // 使用默认数据来修改缓存中的原数据
return DEFAULT_PK_SUBJECT_NUMBER
}
return number
}
根据获取到的数量,从word
数据集合中随机取对应数目的单词数量,要获取的单词数量等于缓存中配置的数量
* 选项个数
number * SUBJECT_HAS_OPTIONS_NUMBER
比如为 10(表示当前房间需要对战10题) * 4(每题有4个选项) = 40个单词,之后要将取出来的所有单词做随机组合,生成对战单词的列表
// const number = getCombatSubjectNumber()
// const { list: randomList } = await wordModel.getRandomWords(bookId, number * SUBJECT_HAS_OPTIONS_NUMBER)
import Base from './base'
import $ from './../utils/Tool'
import log from './../utils/log'
const collectionName = 'word'
/**
* 权限: 所有用户可读
*/
class WordModel extends Base {
constructor() {
super(collectionName)
}
getRandomWords(bookId, size) {
const where = bookId === 'random' ? {} : { bookId }
try {
return this.model.aggregate()
.match(where)
.limit(999999)
.sample({ size }) // 通过sample取随机数据
.end()
} catch (error) {
log.error(error)
throw error
}
}
}
export default new WordModel()
细节:组合生成对战单词列表
// const wordList = formatList(randomList, SUBJECT_HAS_OPTIONS_NUMBER)
/**
* 随机单词列表转成符合对战选词的列表
* @param {Array} list 随机单词列表
* @param {Number} len 每一个题目有多少个选项
*/
export const formatList = (list, len) => {
const lists = chunk(list, len)
return lists.map(option => {
const obj = { options: [] }
const randomIndex = Math.floor(Math.random() * len)
option.forEach((word, index) => {
if (index === randomIndex) {
obj['correctIndex'] = randomIndex
obj['word'] = word.word
obj['wordId'] = word._id
obj['usphone'] = word.usphone
}
const { pos, tranCn } = word.trans.sort(() => Math.random() - 0.5)[0]
let trans = tranCn
if (pos) {
trans = `${pos}.${tranCn}`
}
obj.options.push(trans)
})
return obj
})
}
组合后的数据格式如图:
细节:正式创建房间,写入数据库
// const roomId = await roomModel.create(wordList, isFriend, bookDesc, bookName)
// model/room.js (room数据集合操作的封装)
// ...
async create(list, isFriend, bookDesc, bookName) {
try {
const { _id = '' } = await this.model.add({ data: {
list, // 生成的对战单词列表
isFriend, // 是否为好友对战
createTime: this.date,
bookDesc, // 对战单词书的描述
bookName, // 对战单词书名称
left: { // 房主的信息
openid: '{openid}', // 房主openid
gradeSum: 0, // 房主的对战总得分
grades: {} // 每一道题目的对战得分和所选index
},
right: { // 受邀请用户的信息
openid: '', // 用户openid
gradeSum: 0, // 用户总得分
grades: {} // 每一道题目的对战得分和所选index
},
state: ROOM_STATE.IS_OK, // 房间状态,创建之后为可加入
nextRoomId: '', // 再来一局的房间id
isNPC: false // 是否为机器人对战局
} })
if (_id !== '') { return _id }
throw new Error('roomId get fail')
} catch (error) {
log.error(error)
throw error
}
}
// ...
细节:跳转至对战页面
房间创建成功之后,就可以进入对战页面,邀请好友来对战了
// router.push('combat', { roomId }) // 跳转至对战页面,传递参数roomId
对路由操作做了一个简单封装
const pages = {
home: '/pages/home/home',
combat: '/pages/combat/combat',
wordChallenge: '/pages/wordChallenge/wordChallenge',
userWords: '/pages/userWords/userWords',
ranking: '/pages/ranking/ranking',
setting: '/pages/setting/setting',
sign: '/pages/sign/sign'
}
function to(page, data) {
if (!pages[page]) { throw new Error(`${page} is not exist!`) }
const _result = []
for (const key in data) {
const value = data[key]
if (['', undefined, null].includes(value)) {
continue
}
if (value.constructor === Array) {
value.forEach(_value => {
_result.push(encodeURIComponent(key) + '[]=' + encodeURIComponent(_value))
})
} else {
_result.push(encodeURIComponent(key) + '=' + encodeURIComponent(value))
}
}
const url = pages[page] + (_result.length ? `?${_result.join('&')}` : '')
return url
}
class Router {
push(page, param = {}, events = {}, callback = () => {}) {
wx.navigateTo({
url: to(page, param),
events,
success: callback
})
}
pop(delta) {
wx.navigateBack({ delta })
}
redirectTo(page, param) {
wx.redirectTo({ url: to(page, param) })
}
reLaunch() {
wx.reLaunch({ url: pages.home })
}
toHome() {
if (getCurrentPages().length > 1) { this.pop() } else { this.reLaunch() }
}
}
export default new Router()
至此,房主已经进入对战页面
watch监听房间数据变化
进入对战房间后,需要对当前房间的记录进行监听,实现好友准备,开始对战,以及对战的完整过程,本点为整篇的核心部分
// miniprogram/pages/combat/combat.js
onLoad(options) {
const { roomId } = options
this.init(roomId) // 初始化房间监听
},
async init(roomId) {
$.loading('获取房间信息...')
/**
* 1. 获取用户的openid
*/
const openid = $.store.get('openid')
if (!openid) {
await userModel.getOwnInfo() // 执行一次登录获取openid的操作
return this.init(roomId) // 递归调用(因为没有用户信息, 用户可能是通过回话直接进入到对战页面) - 非房主用户直接进入对战页
}
/**
* 2. 创建监听,用创建监听获取的服务端数据初始化房间数据
*/
this.messageListener = await roomModel.model.doc(roomId).watch({
onChange: handleWatch.bind(this), // 当数据库数据有变化,执行回调操作
onError: e => {
log.error(e)
this.selectComponent('#errorMessage').show('服务器连接异常...', 2000, () => { router.reLaunch() })
}
})
},
handleWatch的具体实现,后面几点做详解
房间信息初始化
async function initRoomInfo(data) {
$.loading('初始化房间配置...')
if (data) {
const { _id, isFriend, bookDesc, bookName, state, _openid, list, isNPC } = data
if (roomStateHandle.call(this, state)) { // 判断当前房间是否合法,函数详细代码见下
const isHouseOwner = _openid === $.store.get('openid')
this.setData({ // 为房间基本信息赋值,当这些值赋值成功后,初始化工作也就完成了
roomInfo: {
roomId: _id,
isFriend,
bookDesc,
bookName,
state,
isHouseOwner,
isNPC,
listLength: list.length
},
wordList: list
})
// 无论是不是好友对战,都先初始化房主的用户信息,获取房主的头像和昵称还有战绩等。
const { data } = await userModel.getUserInfo(_openid)
const users = centerUserInfoHandle.call(this, data[0])
this.setData({ users })
// 随机匹配的业务
if (!isHouseOwner && !isFriend) { // 如果是随机匹配且不是房主 => 自动准备
await roomModel.userReady(_id)
}
}
$.hideLoading()
} else {
$.hideLoading()
this.selectComponent('#errorMessage').show('对战已被解散 ~', 2000, () => { router.reLaunch() })
}
}
const watchMap = new Map()
watchMap.set('initRoomInfo', initRoomInfo)
export async function handleWatch(snapshot) {
const { type, docs } = snapshot
if (type === 'init') { // 首次创建监听,type为init
watchMap.get('initRoomInfo').call(this, docs[0]) // 获取房间的详细信息
} else {
// 其他:当数据update或remove的操作
}
}
初始化房间之前,判断当前房间是否合法
/**
* 处理初始化监听时候的房间状态
* @param {String} state 房间状态
*/
export function roomStateHandle(state) {
let errText = ''
switch (state) {
case ROOM_STATE.IS_OK:
return true
case ROOM_STATE.IS_PK:
case ROOM_STATE.IS_READY:
errText = '房间处于对战中, 人数已满!'
break
case ROOM_STATE.IS_FINISH:
case ROOM_STATE.IS_USER_LEAVE:
errText = '该房间对战已结束'
break
default:
errText = '房间发生错误, 请重试'
break
}
this.selectComponent('#errorMessage').show(errText, 2000, () => { router.reLaunch() })
return false
}
顺利初始化成功之后的UI显示如图:
用户加入,准备(普通用户)
当房主创建好房间之后,就可以邀请好友加入对战了
onShareAppMessage({ from }) {
const { data: { roomInfo: { isHouseOwner, state, roomId, bookName } } } = this
if (from === 'button' && isHouseOwner && state === ROOM_STATE.IS_OK) { // 点击邀请好友后触发分享操作
return {
title: `❤ @你, 来一起pk[${bookName}]吖,点我进入`,
path: `/pages/combat/combat?roomId=${roomId}`, // 普通用户进入小程序后,直接进入对战页
imageUrl: './../../images/share-pk-bg.png'
}
}
},
好友加入对战之后,也进行上述提到的房间初始化步骤的操作,监听上房间信息
用户点击加入房间
,触发准备操作,修改数据库普通用户的信息,执行watch回调
onUserReady: throttle(async function(e) {
$.loading('正在加入...')
const { detail: { userInfo } } = e
if (userInfo) {
await userModel.updateInfo(userInfo) // 更新用户信息
const { properties: { roomId } } = this
const { stats: { updated = 0 } } = await roomModel.userReady(roomId) // 修改房间状态
if (updated !== 1) {
this.selectComponent('#errorMessage').show('加入失败, 可能房间已满!')
}
$.hideLoading()
} else {
$.hideLoading()
this.selectComponent('#authFailMessage').show()
}
}, 1500),
// miniprogram/model/room.js room数据集合的封装,前面几篇已经多次讲解如何封装的,可以到前面的篇章学习
userReady(roomId, isNPC = false, openid = $.store.get('openid')) {
return this.model.where({
_id: roomId,
'right.openid': '',
state: ROOM_STATE.IS_OK
}).update({
data: {
right: { openid }, // 修改普通用户的openid
state: ROOM_STATE.IS_READY, // 房间状态变为准备
isNPC // 是否为人机对战
}
})
}
当房间数据集合发生变化,就会触发watch中的操作,实现房主知道用户已经加入房间(用户自己也就知道),watch代码如下,不管是房主还是普通用户,都会触发watch
const watchMap = new Map()
watchMap.set(`update.state`, handleRoomStateChange)
export async function handleWatch(snapshot) {
const { type, docs } = snapshot
if (type === 'init') { watchMap.get('initRoomInfo').call(this, docs[0]) } else {
const { queueType = '', updatedFields = {} } = snapshot.docChanges[0]
Object.keys(updatedFields).forEach(field => { // 遍历被修改的集合字段,做遍历执行
const key = `${queueType}.${field}` // 当用户准备的时候,会执行`update.state`
watchMap.has(key) && watchMap.get(key).call(this, updatedFields, snapshot.docs[0])
})
}
}
async function handleRoomStateChange(updatedFields, doc) {
const { state } = updatedFields
const { isNPC } = doc
console.log('log => : onRoomStateChange -> state', state)
switch (state) {
case ROOM_STATE.IS_READY: // 用户准备
const { right: { openid } } = doc
const { data } = await userModel.getUserInfo(openid)
const users = centerUserInfoHandle.call(this, data[0]) // 获取普通用户的基本信息,头像和昵称还有胜率
this.setData({ 'roomInfo.state': state, users, 'roomInfo.isNPC': isNPC })
// 随机匹配业务逻辑,判断当前用户如果是房主 且 模式为随机匹配 => 800ms后开始对战
const { data: { roomInfo: { isHouseOwner, isFriend, roomId } } } = this
if (!isFriend && isHouseOwner) {
setTimeout(async () => {
await roomModel.startPK(roomId)
}, 800)
}
break
// case ...
}
}
开始对战(房主)
在用户准备之后,房主的邀请好友
按钮将隐藏,显示开始对战
按钮,当点击开始对战
触发房间状态修改为PK中
// 点击事件
onStartPk: throttle(async function() {
$.loading('开始对战...')
const { properties: { roomId } } = this
const { stats: { updated = 0 } } = await roomModel.startPK(roomId) // 修改数据集合,又会触发watch
$.hideLoading()
if (updated !== 1) { this.selectComponent('#errorMessage').show('开始失败...请重试') }
}, 1500)
// room.js数据集合中的操作(roomModel)
// ...
startPK(roomId) {
return this.model.where({
_id: roomId,
'right.openid': this._.neq(''),
state: ROOM_STATE.IS_READY
}).update({
data: {
state: ROOM_STATE.IS_PK
}
})
}
// ...
watcher的操作同用户准备,将触发update.state
的回调函数,即handleRoomStateChange
async function handleRoomStateChange(updatedFields, doc) {
const { state } = updatedFields
const { isNPC } = doc
console.log('log => : onRoomStateChange -> state', state)
switch (state) {
case ROOM_STATE.IS_READY: // 用户准备
// 用户准备的回调业务...
case ROOM_STATE.IS_PK: // 开始对战
this.initTipNumber() // 初始化提示卡数量
this.setData({ 'roomInfo.state': state }) // 修改房间状态,实现切换场景
this.playBgm() // bgm
// 人机对战的业务逻辑,自动选择(后序章节介绍)
isNPC && npcSelect.call(this.selectComponent('#combatComponent'))
break
}
}
对战过程
对战过程由listIndex
控制目前是第几题,由前面创建房间生成的随机单词列表作为斗词列表,当双方都已经选择选项之后,listIndex++即可实现切换下一题,当所有题目结束,结算对战
// 用户点击选项,选择单词含义
onSelectOption: throttle(async function(e) {
if (!this._isSelected) {
const { currentTarget: { dataset: { index, byTip = false } } } = e
this.setData({ showAnswer: true, selectIndex: index })
const { properties: { roomId, isHouseOwner, listIndex, isNpcCombat, wordObj: { correctIndex, wordId } } } = this
let score = WRONG_CUT_SCORE
const key = isHouseOwner ? 'leftResult' : 'rightResult' // 用于显示选项上的√或×
// correctIndex 在生成的随机单词列表中有做标记,index为当前选择的选项
if (correctIndex === index) { // 选择正确
playAudio(CORRECT_AUDIO)
this.setData({ [key]: 'true' })
score = this.getScore()
if (byTip) { // 通过提示卡选择的选项
userModel.changeTipNumber(-1) // 提示卡-1
userWordModel.insert(wordId) // 生词表插入生词
this.triggerEvent('useTip') // 本地提示卡显示-1
}
} else { // 选择错误
playAudio(WRONG_AUDIO)
wx.vibrateShort()
this.setData({ [key]: 'false' })
userWordModel.insert(wordId) // 生词表插入单词
}
const { stats: { updated = 0 } } = await roomModel.selectOption(roomId, index, score, listIndex, isHouseOwner) // 修改数据集合的当前用户的选择项
if (updated === 1) { this._isSelected = true } else {
this.setData({ showAnswer: false, selectIndex: -1 }) //
}
// 人机对战业务
isNpcCombat && this.npcSelect()
} else {
wx.showToast({
title: '此题已选, 不要点击太快哦',
icon: 'none',
duration: 2000
})
}
}, 1000),
// room集合实例
// roomModel.selectOption
selectOption(roomId, index, score, listIndex, isHouseOwner) {
const position = isHouseOwner ? 'left' : 'right'
return this.model.doc(roomId).update({ // 更新之后触发watch
data: {
[position]: {
gradeSum: this._.inc(score),
grades: {
[listIndex]: {
index,
score
}
}
}
}
})
}
当left.gradeSum
或right.gradeSum
的值变化时,执行watcher
watcher回调函数中,如果是自己选择的本题,显示自己的选择,如果双方都选择了,显示对方的结果(不能在自己选择之前显示),当双方最后一题都选择结束,进入结算流程
import $ from './../../utils/Tool'
import { userModel, roomModel } from './../../model/index'
import { roomStateHandle, centerUserInfoHandle } from './utils'
import { ROOM_STATE } from '../../model/room'
import { sleep } from '../../utils/util'
import router from './../../utils/router'
const LEFT_SELECT = 'left.gradeSum'
const RIGHT_SELECT = 'right.gradeSum'
async function handleOptionSelection(updatedFields, doc) {
const { left, right, isNPC } = doc
this.setData({
left,
right
}, async () => {
this.selectComponent('#combatComponent') && this.selectComponent('#combatComponent').getProcessGrade()
const re = /^(left|right)\.grades\.(\d+)\.index$/ // left.grades.1.index
let updateIndex = -1
for (const key of Object.keys(updatedFields)) {
if (re.test(key)) {
updateIndex = key.match(re)[2] // 当前选择是的第几个题目的index(选择的是第几题的答案)
break
}
}
if (updateIndex !== -1 && typeof left.grades[updateIndex] !== 'undefined' &&
typeof right.grades[updateIndex] !== 'undefined') { // 两方的这个题目都选择完了,需要切换下一题
this.selectComponent('#combatComponent').changeBtnFace(updateIndex) // 显示对方的选择结果
const { data: { listIndex: index, roomInfo: { listLength } } } = this
await sleep(1200)
if (listLength !== index + 1) { // 题目还没结束,切换下一题
this.selectComponent('#combatComponent').init()
this.setData({ listIndex: index + 1 }, () => {
this.selectComponent('#combatComponent').playWordPronunciation()
})
isNPC && npcSelect.call(this.selectComponent('#combatComponent'))
} else {
this.setData({ 'roomInfo.state': ROOM_STATE.IS_FINISH }, () => {
this.bgm && this.bgm.pause()
this.selectComponent('#combatFinish').calcResultData()
this.showAD()
})
}
}
})
}
const watchMap = new Map()
watchMap.set(`update.${LEFT_SELECT}`, handleOptionSelection)
watchMap.set(`update.${RIGHT_SELECT}`, handleOptionSelection)
export async function handleWatch(snapshot) {
const { type, docs } = snapshot
Object.keys(updatedFields).forEach(field => {
const key = `${queueType}.${field}`
watchMap.has(key) && watchMap.get(key).call(this, updatedFields, snapshot.docs[0])
})
}
至此,实现对战过程,对战结束的结算,我们在之后的文章中分享
本书是一个实战系列的分享,会持续更新、修正,感谢同学们来一起学习 ~
项目开源
由于本项目参加了大学的比赛,所以暂时不做开源,比赛结束在微信公众号:Join-FE
进行开源(代码 + 设计图),关注不要错过哦 ~
同系列文章,可以到老包同学的掘金主页食用