The core of the realization of battle lies in the application of Watch. The cloud development data set is based on the encapsulation of webSocket to monitor the update event of the data that meets the query conditions in the set. When the monitored DOC data changes, the onChange event callback is triggered, and the corresponding scene is switched through the callback data. That is, monitor the current room records, when both users choose words, the corresponding business can be displayed

Words every day bucket – friends versus specific implementation

Create a room (owner)

Friends vs. Friends, we start with the house owner creating a room, and users create a new room by clicking the event of the friend vs. button on the home page.

Events trigger

  <button open-type="getUserInfo" bindgetuserinfo="onChallengeFriend">Friends vs. Friends</button>
Copy the code
    onChallengeFriend: throttle(async function(e) { // Click to get user information, update the information, and create a room
      const { detail: { userInfo } } = e
      if (userInfo) {
        await userModel.updateInfo(userInfo) // Update user information
        this.triggerEvent('onChallengeFriend') // Triggers the create room operation of the parent component
      } else {
        this.selectComponent('#authFailMessage').show() // Authorization failed, prompting the user to authorize}},1000),
Copy the code

Function throttling is used to optimize the experience, as a knowledge point. When the user clicks the create room button many times quickly, we want it to trigger once if it is triggered many times within 1s, so we use throttle to package it as follows:

export function throttle(fn, gapTime = 500) {
  let _lastTime = null
  return function() {
    const _nowTime = +new Date(a)if(_nowTime - _lastTime > gapTime || ! _lastTime) { fn.apply(this.arguments) // Call (fn); // Call (fn); call (fn)
      _lastTime = _nowTime
    }
  }
}
Copy the code

The complete process of creating a room

Creating a room is divided into four small steps. The code below is the complete process

  /** * Create word PK room * 1 when no room is available. Get the number of words in the match, and take the random words of the corresponding class * 2. Format the word list * 3. Create room (write room information to database store) * 4. Jump to the battle room page */
  async createCombatRoom(isFriend = true) {
    try {
      $.loading('Generate random words in... ')
      const { data: { userInfo: { bookId, bookDesc, bookName } } } = this
      
      // 1. Get the number of matched words and random words of the corresponding class
      const number = getCombatSubjectNumber()
      const { list: randomList } = await wordModel.getRandomWords(bookId, number * SUBJECT_HAS_OPTIONS_NUMBER)
      
      // 2. Format the word list
      const wordList = formatList(randomList, SUBJECT_HAS_OPTIONS_NUMBER)
      
      $.loading('Create room... ')
      
      // create room (write room information to database)
      const roomId = await roomModel.create(wordList, isFriend, bookDesc, bookName)
      $.hideLoading()
      
      // 4. Jump to the battle room page
      router.push('combat', { roomId })
    } catch (error) {
      $.hideLoading()
      this.selectComponent('#createRoomFail').show()
    }
  },
Copy the code

Details: Random words taken out

First get the configuration item for how many words the room is to create

/** * Get the number of words per game, from localStorage, users can modify the number of words per game in the Settings */
export const getCombatSubjectNumber = function() {
  const number = $.storage.get(SUBJECT_NUMBER) // Read the data in the cache
  if (typeofnumber ! = ='number'| |! PK_SUBJECTS_NUMBER.includes(number)) {// If the read data is invalid, use the default data
    setCombatSubjectNumber(DEFAULT_PK_SUBJECT_NUMBER) // Use the default data to modify the original data in the cache
    return DEFAULT_PK_SUBJECT_NUMBER
  }
  return number
}
Copy the code

The number of words to be retrieved is equal to the number configured in the cache * the number of options

The number * SUBJECT_HAS_OPTIONS_NUMBER, for example, will be 10(the current room requires 10 matches) * 4(each subject has 4 options) = 40 words, and then all the words will be randomly combined to generate a list of the words to be played


// 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'

/** * permissions: all users can read */
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 }) // Select random data from sample
        .end()
    } catch (error) {
      log.error(error)
      throw error
    }
  }
}

export default new WordModel()

Copy the code

Details: Combine to generate a list of battle words

// const wordList = formatList(randomList, SUBJECT_HAS_OPTIONS_NUMBER)

@param {Array} list of random words * @param {Number} len How many options per topic */
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((a)= > Math.random() - 0.5) [0]
      let trans = tranCn
      if (pos) {
        trans = `${pos}.${tranCn}`
      }
      obj.options.push(trans)
    })
    return obj
  })
}

Copy the code

The combined data format is shown as follows:

Details: Formally create room, write to database

// const roomId = await roomModel.create(wordList, isFriend, bookDesc, bookName)


// model/room.js (encapsulate room data set operations)
// ...

  async create(list, isFriend, bookDesc, bookName) {
    try {
      const { _id = ' ' } = await this.model.add({ data: {
        list, // Generate a list of battle words
        isFriend, // Whether to play as friends
        createTime: this.date,
        bookDesc, // The description of the battle word book
        bookName, // Fight the word book name
        left: { // The owner's information
          openid: '{openid}'./ / homeowners openid
          gradeSum: 0.// Total score of the owner versus
          grades: {} // Match score and selected index for each title
        },
        right: { // Information about invited users
          openid: ' './ / users openid
          gradeSum: 0.// Total user score
          grades: {} // Match score and selected index for each title
        },
        state: ROOM_STATE.IS_OK, // The room is available to join after being created
        nextRoomId: ' '.// Room id for the next game
        isNPC: false // Whether it is robot versus battle}})if(_id ! = =' ') { return _id }
      throw new Error('roomId get fail')}catch (error) {
      log.error(error)
      throw error
    }
  }

// ...
Copy the code

Details: Jump to the battle page

After the room is created successfully, you can enter the battle page and invite friends to fight

// router.push('combat', {roomId}) // Jump to the battle page and pass roomIdA simple encapsulation of routing operations is doneconst 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 = (a)= > {}) {
    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()

Copy the code

So far, the owner has entered the battle page

Watch monitors room data changes

After entering the battle room, you need to monitor the records of the current room to prepare friends, start fighting, and the complete process of fighting. This point is the core part of the whole chapter

  // miniprogram/pages/combat/combat.js
  
  onLoad(options) {
    const { roomId } = options
    this.init(roomId) // Initialize the room listener
  },
  async init(roomId) {
    $.loading('Get room information... ')
    /** * 1. Obtain the user openID */
    const openid = $.store.get('openid')
    if(! openid) {await userModel.getOwnInfo() // Perform a login to obtain the OpenID
      return this.init(roomId) // Recursive call (since there is no user information, the user may call back and go directly to the battle page) - non-homeowner users go directly to the battle page
    }

    /** * 2. Create a listener to initialize room data */
    this.messageListener = await roomModel.model.doc(roomId).watch({
      onChange: handleWatch.bind(this), // Perform a callback when database data changes
      onError: e= > {
        log.error(e)
        this.selectComponent('#errorMessage').show('Server connection is abnormal... '.2000, () => { router.reLaunch() })
      }
    })
  },
Copy the code

HandleWatch concrete implementation, the following points to do a detailed explanation

The room information is initialized

async function initRoomInfo(data) {
  $.loading('Initialize the room configuration... ')
  if (data) {
    const { _id, isFriend, bookDesc, bookName, state, _openid, list, isNPC } = data
    if (roomStateHandle.call(this, state)) { // Check whether the current room is valid
      const isHouseOwner = _openid === $.store.get('openid')
      this.setData({ // Assign values to the basic room information. When these values are successfully assigned, the initialization is complete
        roomInfo: {
          roomId: _id,
          isFriend,
          bookDesc,
          bookName,
          state,
          isHouseOwner,
          isNPC,
          listLength: list.length
        },
        wordList: list
      })
      // Whether it is a friend or not, the user information of the owner is initialized first, and the owner's avatar and nickname, as well as the achievements and so on.
      const { data } = await userModel.getUserInfo(_openid)
      const users = centerUserInfoHandle.call(this, data[0])
      this.setData({ users })
      
      // Random matching business
      if(! isHouseOwner && ! isFriend) {// If the match is random and not the owner => Automatic preparation
        await roomModel.userReady(_id)
      }
    }
    $.hideLoading()
  } else {
    $.hideLoading()
    this.selectComponent('#errorMessage').show('Battle has been disbanded.'.2000, () => { router.reLaunch() })
  }
}

const watchMap = new Map()
watchMap.set('initRoomInfo', initRoomInfo)

export async function handleWatch(snapshot) {
  const { type, docs } = snapshot
  if (type === 'init') { // Create a listener for the first time with type init
    watchMap.get('initRoomInfo').call(this, docs[0])  // Get room details
  } else {
    // Others: when data update or remove operations}}Copy the code

Before initializing the room, check whether the current room is valid


@param {String} state Specifies the state of the room
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 = 'Room in battle, full! '
      break
    case ROOM_STATE.IS_FINISH:
    case ROOM_STATE.IS_USER_LEAVE:
      errText = 'This room battle is over.'
      break
    default:
      errText = 'An error occurred in the room, please try again'
      break
  }
  this.selectComponent('#errorMessage').show(errText, 2000, () => { router.reLaunch() })
  return false
}
Copy the code

After successful initialization, the UI will appear as shown below:

User join, ready (common user)

Once the owner has created the room, he can invite his friends to join him

  onShareAppMessage({ from{})const { data: { roomInfo: { isHouseOwner, state, roomId, bookName } } } = this
    if (from= = ='button' && isHouseOwner && state === ROOM_STATE.IS_OK) { // Click invite friend to trigger share operation
      return {
        title: ❤ @ You, come pk[${bookName}[a, click me into '.path: `/pages/combat/combat? roomId=${roomId}`.// Ordinary users enter the small program, directly into the battle page
        imageUrl: '. /.. /.. /images/share-pk-bg.png'}}},Copy the code

After a friend joins the fight, he/she also performs the room initialization steps mentioned above and listens for room information

The user clicks To join the room, triggers the prepare operation, modifies the information of common users in the database, and executes the watch callback

onUserReady: throttle(async function(e) {
  $.loading('Joining... ')
  const { detail: { userInfo } } = e
  if (userInfo) {
    await userModel.updateInfo(userInfo) // Update user information
    const { properties: { roomId } } = this
    const { stats: { updated = 0}} =await roomModel.userReady(roomId) // Change the room status
    if(updated ! = =1) {
      this.selectComponent('#errorMessage').show('Join failed, room may be full! ')
    }
    $.hideLoading()
  } else {
    $.hideLoading()
    this.selectComponent('#authFailMessage').show()
  }
}, 1500),
Copy the code
  / / miniprogram/model/room. Js room collection of data encapsulation, the previous article has repeatedly explain how encapsulation, can learn to the previous chapter

  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 }, // Change the openID of a common user
        state: ROOM_STATE.IS_READY, // The room state changes to ready
        isNPC // Whether it is man-machine versus}})}Copy the code

When the data set of the room changes, the operation in watch will be triggered to realize that the owner knows that the user has joined the room (and the user himself knows). The watch code is as follows. Both the owner and the ordinary user will trigger the 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= > { // Iterate over the modified collection field
      const key = `${queueType}.${field}` // When the user is ready, 'update.state' is executed
      watchMap.has(key) && watchMap.get(key).call(this, updatedFields, snapshot.docs[0])}}}Copy the code

async function handleRoomStateChange(updatedFields, doc) {
  const { state } = updatedFields
  const { isNPC } = doc
  console.log('log => : onRoomStateChange -> state', state)
  switch (state) {
    case ROOM_STATE.IS_READY: // The user is ready
      const { right: { openid } } = doc
      const { data } = await userModel.getUserInfo(openid)
      const users = centerUserInfoHandle.call(this, data[0]) // Get the basic information, avatar, nickname, and win rate of ordinary users
      this.setData({ 'roomInfo.state': state, users, 'roomInfo.isNPC': isNPC })
      
      If the current user is the owner of the house and the mode is random match => 800ms later, the battle starts
      const { data: { roomInfo: { isHouseOwner, isFriend, roomId } } } = this
      if(! isFriend && isHouseOwner) { setTimeout(async() = > {await roomModel.startPK(roomId)
        }, 800)}break
    // case ...}}Copy the code

Start the battle (homeowner)

After the user is ready, the host’s invite friend button will be hidden, showing the start battle button, and when the click to start battle triggers the room state to be changed to PK

// Click the event
onStartPk: throttle(async function() {
  $.loading('Start fighting... ')
  const { properties: { roomId } } = this
  const { stats: { updated = 0}} =await roomModel.startPK(roomId) // Modify the data set to trigger the watch
  $.hideLoading()
  if(updated ! = =1) { this.selectComponent('#errorMessage').show('Start to fail... Please try again ')}},1500)


// Operations in room.js data set (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
      }
    })
}
// ...
Copy the code

Watcher’s action, as prepared by the user, will trigger the update.state callback, namely 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: // The user is ready
        // Callback service prepared by the user...
    case ROOM_STATE.IS_PK: // Start the battle
      this.initTipNumber() // Initialize the number of cue cards
      this.setData({ 'roomInfo.state': state }) // Change the room state to change the scene
      this.playBgm() // bgm
      
      // Man-machine versus business logic, automatic selection (later chapter introduction)
      isNPC && npcSelect.call(this.selectComponent('#combatComponent'))
      break}}Copy the code

dynamic

The battle process is controlled by listIndex. The random word list generated by the front room is used as the battle word list. When both sides have selected the option, listIndex++ can realize the switch to the next question

    // The user clicks on the options and selects the meaning of the word
    
    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' // Use to display √ or × on options
        
        // correctIndex is marked in the generated random word list. Index is the currently selected option
        if (correctIndex === index) { // Select the correct one
          playAudio(CORRECT_AUDIO)
          this.setData({ [key]: 'true' })
          score = this.getScore()
          if (byTip) { // Select the option via the cue card
            userModel.changeTipNumber(- 1) // Cue card-1
            userWordModel.insert(wordId) // Insert new words into the table
            this.triggerEvent('useTip') // The local cue card displays -1}}else { // Wrong selection
          playAudio(WRONG_AUDIO)
          wx.vibrateShort()
          this.setData({ [key]: 'false' })
          userWordModel.insert(wordId) // Insert words into the vocabulary table
        }
        
        const { stats: { updated = 0}} =await roomModel.selectOption(roomId, index, score, listIndex, isHouseOwner) // Modify the selection of the current user of the data set
        
        if (updated === 1) { this._isSelected = true } else {
          this.setData({ showAnswer: false.selectIndex: - 1 }) //
        }
        
        // Man-machine versus business
        isNpcCombat && this.npcSelect()
      } else {
        wx.showToast({
          title: 'This is selected. Don't click too fast.'.icon: 'none'.duration: 2000})}},1000),
Copy the code
  // room collection instance
  
  // roomModel.selectOption
  selectOption(roomId, index, score, listIndex, isHouseOwner) {
    const position = isHouseOwner ? 'left' : 'right'
    return this.model.doc(roomId).update({ // Trigger watch after update
      data: {
        [position]: {
          gradeSum: this._.inc(score),
          grades: {
            [listIndex]: {
              index,
              score
            }
          }
        }
      }
    })
  }
Copy the code

Execute watcher when the value of left.gradeSum or right.gradeSum changes

In the watcher callback function, if the question is chosen by oneself, the result of the other party is displayed if both parties have chosen (cannot be displayed before their choice). When both parties have chosen the last question, the settlement process enters

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 of the selected item (the answer to the selected item)
        break}}if(updateIndex ! = =- 1 && typeofleft.grades[updateIndex] ! = ='undefined' &&
    typeofright.grades[updateIndex] ! = ='undefined') { // Both sides have chosen this topic, need to switch to the next question
      this.selectComponent('#combatComponent').changeBtnFace(updateIndex) // Displays the result of the other party's selection
      const { data: { listIndex: index, roomInfo: { listLength } } } = this
      await sleep(1200)
      if(listLength ! == index +1) { // The question is not over, switch to the next question
        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])})}Copy the code

At this point, the realization of the battle process, the end of the battle settlement, we share in the following article

This book is a practical series to share, will continue to update, revision, thank the students to study together ~

The open source project

Since this project participated in the university competition, we will not open source for the time being. After the competition, we will open source (code + design drawing) on wechat official account: Join-fe. Please don’t miss it

With the series of articles, you can go to the old bag students digging gold homepage edible