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