本篇带大家进入对战页的开发,包含对战所需要的几个场景的介绍,以及切换的实现,后面几章在对每一个场景的业务做解析
对战页的框架实现
对战页由各个组件拼装而成,根据不同的状态对不同的组件进行显示,下面对各组件模板做解析,具体业务后序章节介绍,本章对具体模板有一个印象即可
// 房间存在以下几个状态
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进行开源(代码 + 设计图),关注不要错过哦 ~
同系列文章,可以到老包同学的掘金主页食用