From zero to achieve a word to fight game practice Chapter 6: to achieve friend word to fight

The core of the realization of the war is to watch Based on the encapsulation of webSocket, the cloud development data set listens to the update events of the data in the set that meet the query criteria. When the monitored doc changes data, the onChange event callback is triggered, and the corresponding scenario is switched through the callback data. That is to listen to the records of the current room. When users of both sides select words, the corresponding business display is enough

Words fight every day - specific implementation of friend fight

Create room (owner)

Friends fight, we start from the owner to create a room, and users create a new room by clicking the friends fight button on the home page.

Event triggered

  < button open type = "getUserInfo" bindgetuserinfo = "onchallengefriend" > friend to friend battle < / button >
Copy code
    onChallengeFriend: throttle(async function(e) { // Click to get user information, update information and create room
      const { detail: { userInfo } } = e
      if (userInfo) {
        await userModel.updateInfo(userInfo) // Update user information
        this.triggerEvent('onChallengeFriend') // Trigger create room action for parent component
      } else {
        this.selectComponent('#authFailMessage').show() // The pop-up display of authorization failure prompts the user to authorize
      }
    }, 1000),
Copy code

Function throttling is used to optimize the experience, as a knowledge point. When the user clicks the button to create a room several times quickly, if we want to trigger it multiple times in 1s, it will only trigger once, so we use the throttle function to do the following package, and the implementation is as follows:

export function throttle(fn, gapTime = 500) {
  let _lastTime = null
  return function() {
    const _nowTime = +new Date()
    if (_nowTime - _lastTime > gapTime || !_lastTime) {
      fn.apply(this, arguments) // Note: if you use apply and call here, you need to handle the parameter passing. Otherwise, there are problems in this and event in fn function
      _lastTime = _nowTime
    }
  }
}
Copy code

Create a complete process for rooms

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

  /**
   * When there is no room for friends to fight or randomly match, create the word PK room
   * 1. Get the number of matching words and the random words of corresponding category
   * 2. Format word list
   * 3. Create room (write room information to database for storage)
   * 4. Go to war room page
   */
  async createCombatRoom(isFriend = true) {
    try {
      $.loading('Generating random words...')
      const { data: { userInfo: { bookId, bookDesc, bookName } } } = this
      
      // 1. Get the number of matching words, and take the random words of the corresponding category
      const number = getCombatSubjectNumber()
      const { list: randomList } = await wordModel.getRandomWords(bookId, number * SUBJECT_HAS_OPTIONS_NUMBER)
      
      // 2. Format word list
      const wordList = formatList(randomList, SUBJECT_HAS_OPTIONS_NUMBER)
      
      $.loading('Creating rooms...')
      
      // 3. Create room (write room information to database for storage)
      const roomId = await roomModel.create(wordList, isFriend, bookDesc, bookName)
      $.hideLoading()
      
      // 4. Jump to the war room page
      router.push('combat', { roomId })
    } catch (error) {
      $.hideLoading()
      this.selectComponent('#createRoomFail').show()
    }
  },
Copy code

Details: random words taken out

First, get the configuration item of how many words the room needs to create

/**
 * Get the number of words in each game, get it from localStorage, and the user can modify the number of words in each game in the settings
 */
export const getCombatSubjectNumber = function() {
  const number = $.storage.get(SUBJECT_NUMBER) // Read data in cache
  if (typeof number !== 'number' || !PK_SUBJECTS_NUMBER.includes(number)) { // If the read data is illegal, the default data is used
    setCombatSubjectNumber(DEFAULT_PK_SUBJECT_NUMBER) // Use default data to modify the original data in the cache
    return DEFAULT_PK_SUBJECT_NUMBER
  }
  return number
}
Copy code

According to the number obtained, randomly take the corresponding number of words from the word data set. The number of words to be obtained is equal to the number configured in the cache * the number of options

Number * subject ﹣ has ﹣ options ﹣ number, for example, 10 (indicating that the current room needs 10 questions in a fight). * 4 (each question has 4 options) = 40 words, and then all the words will be randomly combined to generate a list of words in a fight


// 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: readable by all users
 */
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 }) // Sampling random data
        .end()
    } catch (error) {
      log.error(error)
      throw error
    }
  }
}

export default new WordModel()

Copy code

Details: combining to generate a list of war words

// const wordList = formatList(randomList, SUBJECT_HAS_OPTIONS_NUMBER)

/**
 * Turn the random word list into a list that matches the selected words of the match
 * @param {Array} list Random word list
 * @param {Number} len How many options are there for each 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(() => Math.random() - 0.5)[0]
      let trans = tranCn
      if (pos) {
        trans = `${pos}.${tranCn}`
      }
      obj.options.push(trans)
    })
    return obj
  })
}

Copy code

The combined data format is shown in the figure:

Details: create the room formally and write it to the database

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


// Model / room.js (encapsulation of room data collection operations)
// ...

  async create(list, isFriend, bookDesc, bookName) {
    try {
      const { _id = '' } = await this.model.add({ data: {
        list, // Generated battle word list
        isFriend, // Fight for friends or not
        createTime: this.date,
        bookDesc, // Description of war word book
        bookName, // Name of the word book
        left: { // Homeowner information
          openid: '{openid}', // Homeowner openid
          gradeSum: 0, // Homeowner's total score
          grades: {} // Match score and selected index of each topic
        },
        right: { // Information for invited users
          openid: '', // User openid
          gradeSum: 0, // Total user score
          grades: {} // Match score and selected index of each topic
        },
        state: ROOM_STATE.IS_OK, // Room status, which can be added after creation
        nextRoomId: '', // Room id for another round
        isNPC: false // Whether it's robot versus war
      } })
      if (_id !== '') { return _id }
      throw new Error('roomId get fail')
    } catch (error) {
      log.error(error)
      throw error
    }
  }

// ...
Copy code

Details: jump to the battle page

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

// Router. Push ('combat ', {roomId}) / / jump to the battle page and pass the parameter roomId

//A simple encapsulation of routing operations

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()

Copy code

So far, the owner has entered the war page

watch monitor room data change

After entering the war room, you need to monitor the records of the current room, realize friend preparation, start the war, and the whole process of the war. This is the core part of the whole article

  // miniprogram/pages/combat/combat.js
  
  onLoad(options) {
    const { roomId } = options
    this.init(roomId) // Initialize room monitoring
  },
  async init(roomId) {
    $.loading('Get room information...')
    /**
     * 1. Get the user's openid
     */
    const openid = $.store.get('openid')
    if (!openid) {
      await userModel.getOwnInfo() // Perform a login operation to get openid
      return this.init(roomId) // Recursive call (because there is no user information, the user may directly enter the battle page through the echo) - Non Homeowner users directly enter the battle page
    }

    /**
     * 2. Create a monitor and initialize the room data with the server data obtained by creating a monitor
     */
    this.messageListener = await roomModel.model.doc(roomId).watch({
      onChange: handleWatch.bind(this), // When the database data changes, execute the callback operation
      onError: e => {
        log.error(e)
        this.selectComponent('#errorMessage').show('Server connection exception...', 2000, () => { router.reLaunch() })
      }
    })
  },
Copy code

The specific implementation of handleWatch is explained in detail in the following points

Room information initialization

async function initRoomInfo(data) {
  $.loading('Initialize room configuration...')
  if (data) {
    const { _id, isFriend, bookDesc, bookName, state, _openid, list, isNPC } = data
    if (roomStateHandle.call(this, state)) { // To determine whether the current room is legal, see the following for the detailed code of the function
      const isHouseOwner = _openid === $.store.get('openid')
      this.setData({ // Assign values to the basic room information. When the values are assigned successfully, the initialization is completed
        roomInfo: {
          roomId: _id,
          isFriend,
          bookDesc,
          bookName,
          state,
          isHouseOwner,
          isNPC,
          listLength: list.length
        },
        wordList: list
      })
      // Whether it's a friend fight or not, first initialize the owner's user information, get the owner's Avatar, nickname and achievements.
      const { data } = await userModel.getUserInfo(_openid)
      const users = centerUserInfoHandle.call(this, data[0])
      this.setData({ users })
      
      // Randomly matched services
      if (!isHouseOwner && !isFriend) { // If it's a random match and not a homeowner = > automatic preparation
        await roomModel.userReady(_id)
      }
    }
    $.hideLoading()
  } else {
    $.hideLoading()
    this.selectComponent('#errorMessage').show('The battle has been dissolved ~', 2000, () => { router.reLaunch() })
  }
}

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

export async function handleWatch(snapshot) {
  const { type, docs } = snapshot
  if (type === 'init') { // Create listening for the first time, type is init
    watchMap.get('initRoomInfo').call(this, docs[0])  // Get room details
  } else {
    // Other: when the data is update d or remove d
  }
}

Copy code

Before initializing the room, judge whether the current room is legal


/**
 * Handle the room state when initializing monitoring
 * @param {String} state Room status
 */
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 = 'The room is in a fight, The number of people is full!'
      break
    case ROOM_STATE.IS_FINISH:
    case ROOM_STATE.IS_USER_LEAVE:
      errText = 'The fight in this room is over'
      break
    default:
      errText = 'Room error, Please try again'
      break
  }
  this.selectComponent('#errorMessage').show(errText, 2000, () => { router.reLaunch() })
  return false
}
Copy code

The UI after successful initialization is shown as follows:

Users join, prepare (ordinary users)

When the homeowner has created a room, he can invite his friends to join the fight

  onShareAppMessage({ from }) {
    const { data: { roomInfo: { isHouseOwner, state, roomId, bookName } } } = this
    if (from === 'button' && isHouseOwner && state === ROOM_STATE.IS_OK) { // Click invite friends to trigger sharing
      return {
        title: `❤ @you, Come together pk[${bookName}]Ah, point me in`,
        path: `/pages/combat/combat?roomId=${roomId}`, // After ordinary users enter the applet, they can directly enter the battle page
        imageUrl: './../../images/share-pk-bg.png'
      }
    }
  },
Copy code

After friends join in the fight, they also carry out the operation of the room initialization steps mentioned above, and monitor the room information

Users click to join the room, trigger the preparation operation, modify the information of ordinary users in the database, and execute 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 room status
    if (updated !== 1) {
      this.selectComponent('#errorMessage').show('Join failed, Maybe the room is full!')
    }
    $.hideLoading()
  } else {
    $.hideLoading()
    this.selectComponent('#authFailMessage').show()
  }
}, 1500),
Copy code
  // miniprogram/model/room.js room data collection encapsulation. The previous several articles have explained how to encapsulate it for many times. You can learn from the previous chapters

  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 }, // Modify the openid of ordinary users
        state: ROOM_STATE.IS_READY, // Room status changes to ready
        isNPC // Is it a man-machine fight
      }
    })
  }
Copy code

When the room data set changes, the operation in the watch will be triggered to realize that the homeowner knows that the user has joined the room (as the user knows), and the watch code is as follows. Whether the homeowner or the ordinary user, the watch will be triggered

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 => { // Traverse the modified set field and execute the traversal
      const key = `${queueType}.${field}` // When the user is ready, it will execute 'update.state'`
      watchMap.has(key) && watchMap.get(key).call(this, updatedFields, snapshot.docs[0])
    })
  }
}

Copy 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: // User preparation
      const { right: { openid } } = doc
      const { data } = await userModel.getUserInfo(openid)
      const users = centerUserInfoHandle.call(this, data[0]) // Get basic information of ordinary users, avatars and nicknames, and the winning rate
      this.setData({ 'roomInfo.state': state, users, 'roomInfo.isNPC': isNPC })
      
      // Randomly match the business logic, judge if the current user is the homeowner and the mode is random match = > 800 ms, then start the fight
      const { data: { roomInfo: { isHouseOwner, isFriend, roomId } } } = this
      if (!isFriend && isHouseOwner) {
        setTimeout(async () => {
          await roomModel.startPK(roomId)
        }, 800)
      }
      break
    // case ...
  }
}

Copy code

Start the fight (homeowner)

After the user prepares, the owner's invite friends button will be hidden and the start fight button will be displayed. When the user clicks the start fight trigger room status, it will be changed to PK

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


// Operations in the room.js data collection (room model)
// ...
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 code

The operation of the watcher is prepared by the user and will trigger the callback function of update.state, 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: // User preparation
        // User prepared callback business
    case ROOM_STATE.IS_PK: // Start the fight
      this.initTipNumber() // Number of initialization prompt cards
      this.setData({ 'roomInfo.state': state }) // Modify the room state to realize scene switching
      this.playBgm() // bgm
      
      // Business logic and automatic selection of man-machine combat
      isNPC && npcSelect.call(this.selectComponent('#combatComponent'))
      break
  }
}
Copy code

Process of confrontation

At present, the battle process is controlled by listIndex. The random word list generated by the room created above is used as the fight word list. When both parties have selected the option, listIndex + + can switch to the next question. When all the questions are over, the battle will be settled

    // The user clicks the option 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' // Used to display √ or ×
        
        // correctIndex is marked in the generated random word list. index is the currently selected option
        if (correctIndex === index) { // Right choice
          playAudio(CORRECT_AUDIO)
          this.setData({ [key]: 'true' })
          score = this.getScore()
          if (byTip) { // Options selected by prompt card
            userModel.changeTipNumber(-1) // Prompt card-1
            userWordModel.insert(wordId) // Insert new words into new words list
            this.triggerEvent('useTip') // Local prompt card display-1
          }
        } else { // Selection error
          playAudio(WRONG_AUDIO)
          wx.vibrateShort()
          this.setData({ [key]: 'false' })
          userWordModel.insert(wordId) // New words table insert words
        }
        
        const { stats: { updated = 0 } } = await roomModel.selectOption(roomId, index, score, listIndex, isHouseOwner) // Modify the selection of the current user of the data collection
        
        if (updated === 1) { this._isSelected = true } else {
          this.setData({ showAnswer: false, selectIndex: -1 }) //
        }
        
        // Man machine combat business
        isNpcCombat && this.npcSelect()
      } else {
        wx.showToast({
          title: 'This question has been selected, Don't click too fast',
          icon: 'none',
          duration: 2000
        })
      }
    }, 1000),
Copy 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 code

When the value of left.gradeSum or right.gradeSum changes, the watcher is executed

In the watcher callback function, if it is the topic you choose, it will display your choice. If both parties choose, it will display the result of the other party (it can't be displayed before you choose). When both parties choose to end the last topic, enter the settlement process

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 first question selected currently
        break
      }
    }
    if (updateIndex !== -1 && typeof left.grades[updateIndex] !== 'undefined' &&
    typeof right.grades[updateIndex] !== 'undefined') { // Both sides have chosen this question. You need to switch to the next one
      this.selectComponent('#combatComponent').changeBtnFace(updateIndex) // Display the selection result of the other party
      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 code

So far, we have realized the settlement of the war process and the end of the war, which we will share in the following articles

This book is a practical series of sharing, will continue to update, revise, thank you students to learn together~

Project open source

Because this project has participated in the university competition, so it is not open source for the time being. The competition is ended in WeChat official account: Join-FE, open source (code + Design), and pay attention to not to miss it.

For the same series of articles, you can go to Lao Bao's gold digging Homepage edible

Tags: snapshot Database

Posted on Tue, 05 May 2020 06:13:47 -0400 by rks