阅读 241

从零实现一个单词对战游戏 (九) 实战篇5: 对战页结构解析和场景切换

本篇带大家进入对战页的开发,包含对战所需要的几个场景的介绍,以及切换的实现,后面几章在对每一个场景的业务做解析

对战页的框架实现

对战页由各个组件拼装而成,根据不同的状态对不同的组件进行显示,下面对各组件模板做解析,具体业务后序章节介绍,本章对具体模板有一个印象即可

// 房间存在以下几个状态

export const ROOM_STATE = {
  IS_OK: 'OK', // 房间状态正常,非房主用户可以加入房间
  IS_PK: 'PK', // 对战中
  IS_READY: 'READY', // 非房主用户已经准备
  IS_FINISH: 'FINISH', // 对战结束
  IS_USER_LEAVE: 'LEAVE' // 对战中有用户离开
}
复制代码
<!-- combat.wxml -->
<!-- 组件根据roomInfo.state判断显示与否,实现场景切换 -->

<header>
  <view slot="content" class="header">
  
    <image class="go-back touch" catchtap="onBack" src="./../../images/combat-back.png" />
    
    <!-- title标题 -->
    <text wx:if="{{(roomInfo.state==='OK'||roomInfo.state==='READY')}}" class="header-title">{{roomInfo.bookName}} ({{roomInfo.listLength}}词/局)</text>
    <text wx:elif="{{roomInfo.state==='PK'}}" class="header-title">{{roomInfo.bookDesc}} 『 <text class="list-index">{{listIndex+1}}</text> / {{roomInfo.listLength}} 』</text>
    <text wx:else class="header-title">单词天天斗</text>
    
    <!-- 背景音乐 -->
    <image bindtap="onBgmChange" hidden="{{!(roomInfo.state==='PK')}}" data-action="pause" wx:if="{{bgmState}}" class="header-bgm-setting" src="./../../images/combat-bgm-open.png" />
    <image bindtap="onBgmChange" hidden="{{!(roomInfo.state==='PK')}}" data-action="start" wx:else class="header-bgm-setting" src="./../../images/combat-bgm-close.png" />
    
  </view>
</header>

<!-- 好友对战的准备场景 -->
<block wx:if="{{roomInfo.isFriend&&(roomInfo.state==='OK'||roomInfo.state==='READY')}}">
  <!-- 双方用户的头像等基本信息的显示 -->
  <center-userInfo users="{{users}}" />
  
  <!-- 邀请好友 + 开始对战 -->
  <friend-pk-buttons roomState="{{roomInfo.state}}" isHouseOwner="{{roomInfo.isHouseOwner}}" roomId="{{roomInfo.roomId}}" />
</block>

 <!-- 随机匹配 -->
<random-matching wx:if="{{!roomInfo.isFriend&&(roomInfo.state==='OK'||roomInfo.state==='READY')}}"
  roomState="{{roomInfo.state}}" roomId="{{roomInfo.roomId}}"/>

<!-- 对战过程中的用户信息显示 -->

<header-userInfo wx:if="{{roomInfo.state==='PK' || roomInfo.state==='FINISH' || roomInfo.state==='LEAVE'}}" users="{{users}}" />

<!-- 单词对战场景 -->
<combat-place wx:if="{{roomInfo.state==='PK'}}" 
  id="combatComponent"
  listIndex="{{listIndex}}" wordObj="{{wordList[listIndex]}}"
  roomId="{{roomInfo.roomId}}" isHouseOwner="{{roomInfo.isHouseOwner}}"
  left="{{left}}" right="{{right}}" isNpcCombat="{{roomInfo.isNPC}}"
  listLength="{{roomInfo.listLength}}" tipNumber="{{tipNumber}}"
  bind:useTip="useTip"
/>

<!-- 对战结束场景 -->
<combat-finish wx:if="{{roomInfo.state==='FINISH'}}" id="combatFinish"
  roomId="{{roomInfo.roomId}}"  wordList="{{wordList}}" isHouseOwner="{{roomInfo.isHouseOwner}}"
  left="{{left}}" right="{{right}}" nextRoomId="{{nextRoomId}}" isNpcCombat="{{roomInfo.isNPC}}"
  houseOwnerInfo="{{users[0]}}" rightUserInfo="{{users[1]}}"
/>

<image wx:if="{{roomInfo.isFriend&&roomInfo.state!=='FINISH'}}"  class="combat-pk-bg" src="./../../images/combat-pk.png" />
<image wx:if="{{!roomInfo.isFriend&&roomInfo.state==='PK'}}"  class="combat-pk-bg" src="./../../images/combat-pk.png" />

<!-- 对战设置 -->
<combat-setting wx:if="{{(roomInfo.state==='OK'||roomInfo.state==='READY')}}"></combat-setting>

<loading id="loading"/>
<message id="errorMessage" />


复制代码

随机匹配(匹配场景)

随机匹配场景的场景,主要为random-matching组件

 <!-- 随机匹配 -->
<random-matching wx:if="{{!roomInfo.isFriend&&(roomInfo.state==='OK'||roomInfo.state==='READY')}}"
  roomState="{{roomInfo.state}}" roomId="{{roomInfo.roomId}}"/>
复制代码
<!-- 随机匹配的业务组件 -->
<!-- miniprogram/pages/combat/components/random-matching/random-matching.wxml -->
<view class="wrap">
  <view class="userinfo">
    <image class="circle-in rotate time-36s" src="./../../../../images/combat-random-in.png" />
    <image class="circle-out rotate-reverse time-36s" src="./../../../../images/combat-random-out.png" />
    <open-data class="circle-avatar" type="userAvatarUrl"></open-data>
  </view>

  <view class="matching" wx:if="{{roomState==='OK'}}">
    <text class="matching-title animated fadeIn infinite slower">匹配中…</text>
    <text class="matching-desc">如果一直匹配不到,可以选择人机对战哦 ~</text>
    <button hover-class="btn-hover" class="btn shadow-lg" catchtap="onStartNPC">开始人机对战</button>
  </view>
  <text wx:if="{{roomState==='READY'}}" class="matched animated flipInX faster">匹配成功</text>
</view>
复制代码
import { userModel, roomModel } from '../../../../model/index'
import $ from './../../../../utils/Tool'
import { ROOM_STATE } from '../../../../model/room'
import { throttle } from '../../../../utils/util'

Component({
  options: {
    addGlobalClass: true
  },
  properties: {
    roomState: {
      type: String
    },
    roomId: {
      type: String
    }
  },
  data: {

  },
  lifetimes: {
    attached() {
      this.callNpcTimer = setTimeout(() => {
        const { properties: { roomState } } = this
        if (roomState === ROOM_STATE.IS_OK) {
          this.onStartNPC()
        }
      }, 110 * 1000) // 110s => 1分50s如果还没有匹配到就开始NPC对局
    },
    detached() {
      this.callNpcTimer && clearTimeout(this.callNpcTimer)
    }
  },
  methods: {
    onStartNPC: throttle(async function() {
      $.loading('call npc...')
      const { _openid: openid } = (await userModel.getNPCUser()).list[0]
      const { properties: { roomId } } = this
      await roomModel.userReady(roomId, true, openid)
      $.hideLoading()
    }, 1000)
  }
})

复制代码

好友对战(等待场景)

好友对战场景主要由用户信息组件操作按钮(邀请好友、准备、开始对战)两个业务组件构成

<!-- miniprogram/pages/combat/components/center-userInfo/center-userInfo.wxml -->
<view class="wrap">
  <view class="userinfo" wx:for="{{usersInfo}}" wx:key="index" wx:for-index="index" wx:for-item="user">
    <image class="userinfo-avatar" src="{{user.avatarUrl}}" />
    <text class="userinfo-nickname">{{user.nickName}}</text>
    <text class="userinfo-grade">词力值:{{user.grade}}</text>
    <text class="userinfo-winRate">斗胜率: {{user.winRate}}%</text>
  </view>
</view>
复制代码

用户信息组件其实就是把两个用户的信息数组传入,如果只有一个用户,则用默认信息填充第二个用户(没有好友加入的情况)

// miniprogram/pages/combat/components/center-userInfo/center-userInfo.js
const defaultUserInfo = {
  avatarUrl: './../../../../images/combat-default-avatar.png',
  gender: 0,
  nickName: '神秘嘉宾',
  grade: 0,
  winRate: 0
}

Component({
  data: {
    usersInfo: []
  },
  properties: {
    users: {
      type: Array,
      value: [],
      observer([...usersInfo]) {
        if (usersInfo.length === 1) {
          usersInfo.push(defaultUserInfo)
        }
        this.setData({ usersInfo })
      }
    }
  },
  methods: {

  }
})

复制代码

对战场景

对战场景为所有场景中最复杂的,主要由combat-place组件构成,本组件业务后序介绍,先了解节点组成

<!--miniprogram/pages/combat/components/combat-place/combat-place.wxml-->

<view class="main">
  <!-- 左边房主的分数条,旋转90度实现的竖向进度条 -->
  <progress class="progress-left" duration="50" border-radius="18rpx" stroke-width="36rpx" percent="{{gradeShow.leftGradeProcess}}" activeColor="#7ECDF7" backgroundColor="#F5F5F5" active="true" active-mode="forwards" />
  
  <text class="main-word">{{wordObj.word}}</text>
  
  <!-- 对战选词区域 -->
  <view class="main-options">
    <block wx:for="{{wordObj.options}}" wx:key="index" wx:for-index="index" wx:for-item="option">
      <view animation="{{btnAnimation}}" class="btn shadow-lg" data-index="{{index}}" catchtap="onSelectOption">
        <view class="btn-left" hidden="{{!((isHouseOwner && selectIndex===index) || (!isHouseOwner && anotherSelectIndex===index))}}">
          <image
          hidden="{{!(leftResult==='false')}}" 
          class="btn-left__icon" src="./../../../../images/combat-x.png" />
          <image 
          hidden="{{!(leftResult==='true')}}" 
          class="btn-left__icon" src="./../../../../images/combat-g.png" />
        </view>
        <text class="btn-text one-line-text {{(showAnswer && (index===wordObj.correctIndex))?'btn-text__sign':''}}">{{option}}</text>
        <view class="btn-right" hidden="{{!((!isHouseOwner && selectIndex===index) || (isHouseOwner && anotherSelectIndex===index))}}">
          <image 
          hidden="{{!(rightResult==='false')}}"
          class="btn-right__icon" src="./../../../../images/combat-x.png" />
          <image 
          hidden="{{!(rightResult==='true')}}" 
          class="btn-right__icon" src="./../../../../images/combat-g.png" />
        </view>
      </view>
    </block>
  </view>
  
  <!-- 右侧分数条 -->
  <progress class="progress-right" duration="50" border-radius="18rpx" stroke-width="36rpx" percent="{{gradeShow.rightGradeProcess}}" activeColor="#7ECDF7" backgroundColor="#F5F5F5" active="true" active-mode="forwards" />
  
  <view class="grade">
    <view class="grade-add-left" wx-if="{{left.grades[listIndex].score}}">
      <image class="grade-add__img" src="./../../../../images/combat-pk-getGrade.png" />
      <text class="grade-info__tag" style="margin-left:16rpx;">{{left.grades[listIndex].score}}</text>
    </view>
    <view class="grade-add-right" wx-if="{{right.grades[listIndex].score}}">
      <text class="grade-info__tag" style="margin-right:16rpx;">{{right.grades[listIndex].score}}</text>
      <image class="grade-add__img" src="./../../../../images/combat-pk-getGrade.png" />
    </view>
    <view class="grade-text">
      <text class="grade-text__left">{{gradeShow.leftGrade}}</text>
      <text class="grade-text__right">{{gradeShow.rightGrade}}</text>
    </view>
  </view>
  
  <!-- 提示卡 -->
  <view class="pk-tip touch" catchtap="onGetTip">
    <image class="pk-tip__img" mode="widthFix" src="./../../../../images/combat-tip.png" />
    <text class="pk-tip__title">提示 x {{tipNumber}}</text>
  </view>
</view>

<!-- 倒计时 -->
<text animation="{{countdownAnimation}}" style="top:{{countdownTop}}rpx;" class="count-down">{{countdown}}</text>

复制代码

结束对战结算场景

对战结算场景由combat-finish组件组成,用于显示对战输赢结果和错误的单词,还包含再来一局功能

<!-- miniprogram/pages/combat/components/combat-finish/combat-finish.wxml -->

<view class="finish-wrap" style="top:{{top}}rpx;" hidden="{{!resultData}}">
  <image wx:if="{{!resultData.selfWin}}" class="result-img" style="height: 136rpx;"
    src="./../../../../images/combat-finish-fail.png" />
  <image wx:else class="result-img" style="height: 157rpx;" src="./../../../../images/combat-finish-win.png" />
  <view class="result-bar">
    <view class="result-left">
      <text class="result-left__grade">{{resultData.left.gradeSum}}</text>
      <text class="result-left__pvp">词力值+{{resultData.left.grade}}</text>
    </view>
    <view class="result-right">
      <text class="result-right__grade">{{resultData.right.gradeSum}}</text>
      <text class="result-right__pvp">词力值+{{resultData.right.grade}}</text>
    </view>
  </view>

  <scroll-view class="result-words" scroll-y="{{true}}">
    <image class="result-words__bg" src="./../../../../images/combat-pk.png" />
    <block wx:for="{{resultData.wordList}}" wx:for-index="index" wx:key="index" wx:for-item="line">
      <view class="result-words__line">
        <image wx:if="{{line.leftCheck}}" class="line-icon" src="./../../../../images/combat-g.png" />
        <image wx:else class="line-icon" src="./../../../../images/combat-x.png" />
        <text class="line-text">{{line.text}}</text>
        <image wx:if="{{line.rightCheck}}" class="line-icon" src="./../../../../images/combat-g.png" />
        <image wx:else class="line-icon" src="./../../../../images/combat-x.png" />
      </view>
    </block>
  </scroll-view>

  <button wx:if="{{isHouseOwner || nextRoomId!==''}}" hover-class="btn-hover" class="btn bg-theme shadow-lg result-btn"
    catchtap="onCreateRoom">再来一局</button>
  <button wx:else class="btn bg-theme shadow-lg result-btn" style="background: #d7dae1;"
    catchtap="onWaitRoom">等待对方创房</button>
  <button hover-class="btn-hover" class="btn bg-theme shadow-lg result-btn" open-type="share">分享到群聊</button>
  <view class="result-tip">
    <image class="result-tip__icon" src="./../../../../images/combat-tip.png" />
    <text class="result-tip__text">分享可获得提示卡 * 5</text>
  </view>
  <view class="ad-wrap" hidden="{{!adState}}">
    <ad unit-id="adunit-97e0959580cc3e62"></ad>
  </view>
</view>

<message id="errorMessage" />
复制代码

场景切换

通过watch监听集合中符合查询条件的数据的更新事件,当所监听的doc发生数据变化,触发onChange事件回调,通过回调的数据切换相应的场景。

单词天天斗中,通过监听room集合中当前房间的记录,详细看下方代码中的const watchMap = new Map()变量,room集合数据结构看下方解释

场景切换代码

// miniprogram/pages/combat/watcher.js

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 ROOM_STATE_SERVER = 'state'
const LEFT_SELECT = 'left.gradeSum'
const RIGHT_SELECT = 'right.gradeSum'
const NEXT_ROOM = 'nextRoomId'

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

function npcSelect() {
  this._npcSelect = false
  this._npcTimer = setTimeout(async () => { await this.npcSelect() }, 2300 + Math.random() * 1200)
}

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 ROOM_STATE.IS_OK:
      if (typeof (updatedFields['right.openid']) !== 'undefined') { // 用户取消准备,退出房间
        const usersCancel = centerUserInfoHandle.call(this, {})
        this.setData({ 'roomInfo.state': state, users: usersCancel })
      }
      break
    case ROOM_STATE.IS_PK:
      this.initTipNumber()
      this.setData({ 'roomInfo.state': state })
      this.playBgm()
      isNPC && npcSelect.call(this.selectComponent('#combatComponent'))
      break
    case ROOM_STATE.IS_USER_LEAVE:
      this.selectComponent('#errorMessage').show('对方逃离, 提前结束对战...', 2000, () => {
        this.setData({ 'roomInfo.state': ROOM_STATE.IS_FINISH }, () => {
          this.bgm && this.bgm.pause()
          this.selectComponent('#combatFinish').calcResultData()
          this.showAD()
        })
      })
      break
  }
}

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

function handleNextRoom(updatedFields) {
  const { nextRoomId } = updatedFields
  if (nextRoomId !== '') {
    this.setData({ nextRoomId })
  }
}

function removeRoom() {
  this.selectComponent('#errorMessage').show('房主逃离, 房间即将解散...', 2000, () => {
    router.toHome()
  })
}

const watchMap = new Map()
watchMap.set('initRoomInfo', initRoomInfo) // 房间初始化
watchMap.set(`update.${ROOM_STATE_SERVER}`, handleRoomStateChange) // 房间状态变更
watchMap.set(`update.${LEFT_SELECT}`, handleOptionSelection) // 房主斗词选择
watchMap.set(`update.${RIGHT_SELECT}`, handleOptionSelection) // 普通用户斗词选择
watchMap.set(`update.${NEXT_ROOM}`, handleNextRoom) // 创建新房间,再来一局

// 通过云开发数据库的watch方法的回调函数来触发:当监听的Doc发生数据变化,就会触发回调

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]
    if (queueType === 'dequeue') { return removeRoom.call(this) }
    Object.keys(updatedFields).forEach(field => {
      const key = `${queueType}.${field}`
      watchMap.has(key) && watchMap.get(key).call(this, updatedFields, snapshot.docs[0])
    })
  }
}

复制代码

room集合数据结构

下面为一条完整的对战数据

{
    "_id": "fddd30c55eac303c003933881bff46f3",
    "left": { // 房主的数据
        "grades": { // 所有词的得分
            "0": { // 当前是第一题
                "index": 1, // 选的index是第几个,从0开始
                "score": 95 // 本题得分
            },
            "1": {
                "index": 0,
                "score": 92
            },
            "2": {
                "index": 1,
                "score": 90
            },
            "3": {
                "index": 0,
                "score": 89
            },
            "4": {
                "index": 1,
                "score": 89
            },
            "5": {
                "index": 1,
                "score": 87
            },
            "6": {
                "index": 1,
                "score": 80
            },
            "7": {
                "index": 2,
                "score": -10
            },
            "8": {
                "index": 2,
                "score": -10
            },
            "9": {
                "index": 0,
                "score": 86
            }
        },
        "openid": "ovro24wfMpyynCSebVLU",
        "gradeSum": 688
    },
    "right": { // 普通用户的对战数据
        "gradeSum": 604, // 最终得分
        "grades": { // 对战成绩
            "0": {
                "index": 1,
                "score": 95
            },
            "1": {
                "index": 0,
                "score": 94
            },
            "2": {
                "index": 1,
                "score": 90
            },
            "3": {
                "index": 0,
                "score": 93
            },
            "4": {
                "index": 0,
                "score": -10
            },
            "5": {
                "index": 1,
                "score": 88
            },
            "6": {
                "index": 1,
                "score": 82
            },
            "7": {
                "index": 2,
                "score": -10
            },
            "8": {
                "index": 0,
                "score": -10
            },
            "9": {
                "index": 0,
                "score": 92
            }
        },
        "openid": "ovro240V-jyy4CZpbTF_48"
    },
    "state": "FINISH", // 房间状态
    "isNPC": false, // 是否为人机对战
    "list": [ // 本次对战的单词列表
        {
            "options": [
                "n.相对性;相对论",
                "钥匙",
                "adj.地方的,当地的;局部的",
                "n.满意,满足;赔偿;乐事;赎罪"
            ],
            "correctIndex": 1,
            "word": "key",
            "wordId": "primary_163"
        },
        {
            "correctIndex": 0,
            "word": "transport",
            "wordId": "CET4Full_576",
            "usphone": "'trænspɔrt",
            "options": [
                "v.运输",
                "n.大使;特使,(派驻国际组织的)代表",
                "v.使困窘;使局促不安(embarrass的过去分词形式)",
                "n.差别,不同,区分"
            ]
        },
        {
            "options": [
                "n.物主,所有人",
                "adj.永久的;四季开花的",
                "n.鱿鱼;乌贼;枪乌贼",
                "n.车库;加油站"
            ],
            "correctIndex": 1,
            "word": "perpetual",
            "wordId": "CET6Full_237",
            "usphone": "pɚ'pɛtʃuəl"
        },
        {
            "usphone": ",ænə'lɪtɪkl",
            "options": [
                "adj.分析的",
                "v.分开;派遣(军队)",
                "v.烹调,煮;烧菜",
                "n.(一套)家具;套房;组曲;(一批)随员,随从"
            ],
            "correctIndex": 0,
            "word": "analytical",
            "wordId": "CET6_120"
        },
        {
            "options": [
                "n.脸;表面;面子;面容;外观;威信",
                "prep.在…之中(among)",
                "v.进餐,用餐",
                "n.风景;景色;舞台布景"
            ],
            "correctIndex": 1,
            "word": "amongst",
            "wordId": "CET4Full_1629",
            "usphone": "ə'mʌŋst"
        },
        {
            "word": "immeasurable",
            "wordId": "CET4_221",
            "usphone": "ɪ'mɛʒərəbl",
            "options": [
                "n&v.耸肩",
                "adj.无法估量的",
                "n&v.喘气,气喘吁吁地说",
                "n.千克"
            ],
            "correctIndex": 1
        },
        {
            "word": "turnover",
            "wordId": "IELTS_1150",
            "usphone": "'tɝn'ovɚ",
            "options": [
                "adj.反抗的;造反的",
                "n.营业收入;营业额;人员调整,人员更替率",
                "n.工艺;手艺",
                "n.(战争、愤怒等)爆发"
            ],
            "correctIndex": 1
        },
        {
            "correctIndex": 0,
            "word": "pin",
            "wordId": "CET4Full_884",
            "usphone": "pɪn",
            "options": [
                "v.别住",
                "n.方面;方向;形势;外貌",
                "n.喧哗;激动,混乱;吵闹",
                "n&v.焦点,集中"
            ]
        },
        {
            "options": [
                "v.缺少",
                "adj.暴力的",
                "v.定做,按客户具体要求制造",
                "n.约会;集会,聚会之地点"
            ],
            "correctIndex": 3,
            "word": "rendezvous",
            "wordId": "IELTS_2825",
            "usphone": "'rɑndevʊ"
        },
        {
            "usphone": "list",
            "options": [
                "adv.最少",
                "v.像火炬一样燃烧",
                "n.手套",
                "adj.(for)足够的,充分的(比enough拘谨、正式)"
            ],
            "correctIndex": 0,
            "word": "least",
            "wordId": "CET4Full_2839"
        }
    ],
    "bookName": "随机所有词汇",
    "nextRoomId": "f10018335eac308b003a60662b212cf4", // 再来一局的房间
    "bookDesc": "随机",
    "createTime": {
        "$date": "2020-05-01T14:20:44.955Z"
    },
    "_openid": "ovro24wfMpy8AfYZ5",
    "isFriend": true // 是否为好友对战
}
复制代码

本书是一个实战系列的分享,会持续更新、修正,感谢同学们来一起学习 ~

项目开源

由于本项目参加了大学的比赛,所以暂时不做开源,比赛结束在微信公众号:Join-FE进行开源(代码 + 设计图),关注不要错过哦 ~

公众号

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