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 codeonChallengeFriend: 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 codeCreate 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 codeDetails: 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 codeDetails: 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 list Random word list * @param 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 = `$.$` } 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: '', // 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 codeDetails: 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 ', ) / / 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(`$ 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 ? `?$` : '') 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 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[$]Ah, point me in`, path: `/pages/combat/combat?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 = `$.$` // 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.$`, handleOptionSelection) watchMap.set(`update.$`, handleOptionSelection) export async function handleWatch(snapshot) { const { type, docs } = snapshot Object.keys(updatedFields).forEach(field => { const key = `$.$` 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