从零实现一个单词对战游戏 (十) 实战篇6: 实现好友单词对战

1,998 阅读10分钟

对战的实现,核心在于对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.gradeSumright.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进行开源(代码 + 设计图),关注不要错过哦 ~

公众号

同系列文章,可以到老包同学的掘金主页食用