引入

在实时双人PK答题应用中,前端主要负责与WebSocket服务器的交云通信,实现实时互动功能。通过JavaScript建立WebSocket连接后,前端将发送和接收消息以实现玩家匹配、题目显示、答题、分数更新和游戏结束等功能。在用户界面上,通过监听点击事件来触发匹配对手、提交答案和结束游戏的操作,同时动态更新UI以反映游戏状态的变化。例如,当用户匹配成功后,前端将渲染题目和答案选项,并在每次提交答案后更新双方的分数。在游戏结束时,前端会显示最终的胜负结果,并可选择重新匹配开始新的对战。整个过程中,前端需要处理来自WebSocket的消息,根据不同类型的消息(如匹配用户、游戏进行中和游戏结束)执行相应的UI更新和逻辑处理。

此文对应前文《使用Java + WebSocket实现简单实时双人协同pk答题》中的后端逻辑

二、WebSocket 前端

此处只介绍大致思路与相关js函数

0、记录用户id标识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let token = `${sessionStorage.getItem('sign1')}`

axios({
       method: 'GET',
       url: 'http://你说的对:3000/users',
       headers: {
           'token': `${token}`
      }
  })
  .then(res => { // 接口数据
       console.log(res.data);
       let { data } = res.data
       let { user } = data
       // 存储userId到 sessionStorage
       sessionStorage.setItem('userId', user.userId);
  })

.catch(error => { // 处理错误
   console.error(error);
});

将数据存储到sessionStorage

1、大概流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 点击汇总页面的pk,开始链接到websocket
const inner3 = document.querySelector('.panel:nth-of-type(3) .inner')
console.log(inner3)
inner3.addEventListener('click', function() {
       let userId = sessionStorage.getItem('userId')
       console.log(userId)
       connect(userId)
  })
   // 点击开始匹配,进行匹配,并且进入到答题界面
const switchs = document.querySelector('.pk input')
switchs.addEventListener('click', function() {
   // 开始随机匹配
   let userId = sessionStorage.getItem('userId')
   console.log(userId)
   matchUser(userId)
   const fight = document.querySelector('.fight')
   const pk = document.querySelector('.pk')
   let timer = setTimeout(function() {
           pk.style.display = 'none'
           fight.style.display = 'block'
      }, 1000)
       // 渲染自己的信息
   userInfo()
   const questionBox = document.querySelector('.questions>div')
   questionBox.innerHTML = '正在寻找对手...'
})

其中调用的connect函数是重点 相关逻辑主要靠其实现

matchUser是匹配对手的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function connect(userId){
var socketurl "ws://yourServerUrl:3000/competition/" + userId;
socket = new Websocket(socketurl);
//在此触发OnOpen
//打开事件
socket.onopen = function(){...};
//在此触发OnMessage
//获得消息事件
socket.onmessage = function(msg){...};
//在此触发onclose
//关闭事件
socket.onclose = function(){...};
//在此触发OnError
//发生了错误事件
socket.onerror = function(){...};

connect函数的大概架构如上

onMessage为主要逻辑实现 下一部分分析

matchUser内容见下一部分

2、OnOpen打开连接及OnError错误处理

1
2
3
4
5
6
socket.onopen = function() {
   console.log("websocket 已打开 userId: " + userId);
};
socket.onerror = function() {
   console.log("websocket 发生了错误 userId : " + userId);
}

此处不多赘述错误处理和连接,可在对应函数处记录日志或心跳响应等

3、OnMessage响应信息及对应请求函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    // 在此触发OnMessage
   //获得消息事件
   socket.onmessage = function(msg) {
       // 此处编写得到消息之后的具体逻辑
       // 提交每一题之后 需要更新玩家的分数和
       // chatMessage就是真正的类 响应数据
       let chatMessage = msg.data;
       chatMessage = JSON.parse(chatMessage);
       let message = chatMessage.chatMessage
       let data = message.data;
       let type = message.type;
           // 以上均为通用代码
           // 正确答案的数组
       let rightAnswer = []
       var serverMsg = "收到服务端信息: " + msg.data;
       console.log(serverMsg);
  };

onMessage 表示收到服务器消息之后的逻辑 上方步骤将数据转换并赋值给相关变量

(1). MATCH_USER

前端发送开始匹配信号

1
2
3
4
5
6
7
8
9
10
// 随机匹配
function matchUser(userId) {
   var chatMessage = {};
   var sender = userId;
   var type = "MATCH_USER";
   chatMessage.sender = sender;
   chatMessage.type = type;
   console.log("用户:" + sender + "开始匹配......");
   socket.send(JSON.stringify(chatMessage));
}

无需发送数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
            // 如果接收到的是匹配时 代表此时已经有用户匹配成功 后端发送题目以及 对应的用户信息
       if (type === "MATCH_USER") {
           let gameMatchInfo = message.data;
           console.log(gameMatchInfo)
               // gameMatchInfo对应chatMessage中的T data
           let questionList = gameMatchInfo.questions;
           console.log(questionList)
               // 获取到所有问题的题目及答案
               // questionList表示此次对战中的题库

           // 记录正确答案
           for (let i = 0; i < questionList.length; i++) {
               rightAnswer.push(questionList[i].rightAnswer)
          }
           console.log(rightAnswer)
           let userId = sessionStorage.getItem('userId')
               // 异步函数
           async function wait(questionList, rightAnswer, userId) {
               // 渲染题目到页面上
               await questionDisplay(questionList)
               choose(rightAnswer, userId)
               displayOver(questionList, rightAnswer)
          }
           // 调用异步函数
           wait(questionList, rightAnswer, userId)
               // 同时需要获取到对面的用户的信息
           let selfInfo = gameMatchInfo.selfInfo;

           // selfInfo是自己的信息
           // selfInfo则代表的是GameMatchInfo中selfInfo的属性
           let selfId = selfInfo.userId;
           if (selfId === userId) {
               selfUsername = gameMatchInfo.selfUsername;
               selfPicAvatar = gameMatchInfo.selfPicAvatar;
               let opponentInfo = gameMatchInfo.opponentInfo;
               // opponentInfo则是对面的信息
               // 获取到对面用户的id 头像 名字
               opponentUserId = opponentInfo.userId;
               opponentUsername = gameMatchInfo.opponentUsername;
               opponentPicAvatar = gameMatchInfo.opponentPicAvatar
               sessionStorage.setItem('opponentId', opponentUserId)

               // 渲染对手的信息
               opponentinfo(opponentPicAvatar, opponentUsername)
          } else {
               opponentUserId = selfId
               opponentUsername = gameMatchInfo.selfUsername;
               opponentPicAvatar = gameMatchInfo.selfPicAvatar;
               sessionStorage.setItem('opponentId', opponentUserId)

                   // 渲染对手的信息
               opponentinfo(opponentPicAvatar, opponentUsername)
          }

      }

这个阶段代表的是 匹配到用户了 未开始对局 初始化数据 (双方头像账户名id + 题目信息)

对应后端发送信息中的 GameMatchInfo

(2). PLAY_GAME

按照pk中的游戏进程 每一次交互产生在玩家确认题目答案并提交之后 服务器将新的分数变化告知客户端

即 提交答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function commitAnswer(score, userSelectedAnswerIndex, userId) {
   let chatMessage = {};
   let sender = userId;
   let type = "PLAY_GAME";

   // 如果答对了就更新分数 客户端向服务器发起请求
   // 如果没答对 也发送请求 此时也会执行代码 但是分数不会有变化
   // userScore是玩家的积分
   // 发送玩家的新得分以及自己的选项
   var data = {
       userScore: score,
       userSelectedAnswer: userSelectedAnswerIndex
  }
   chatMessage.sender = sender;
   chatMessage.data = data;
   chatMessage.type = type;
   console.log("用户:" + sender + "更新分数为" + data);
   console.log(chatMessage)
   socket.send(JSON.stringify(chatMessage));
   // 这里标记当前用户已经选择了
   isUserSelect = true;
}

此处需要发送用户的总分以及用户的选择的答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
        if (type === "PLAY_GAME") {
           let selfId = message.data.userMatchInfo.userId
           if (selfId === userId) {
               // 更新对方的分数
               const opponentScore = document.querySelector('.opponent-num')
               opponentnum = parseInt(opponentScore.innerHTML)
               let score = message.data.userMatchInfo.score
               opponentScore.innerHTML = score
               
          } else {
               option2 = true
               let opponentId = selfId
               const opponentScore = document.querySelector('.opponent-num')
               opponentnum = parseInt(opponentScore.innerHTML)
               let score = message.data.userMatchInfo.score
               opponentScore.innerHTML = score  
               
               if (option2 && option1) {
                   const questions = document.querySelector('.questions>div');
                   r++
                   questions.style.top = -520 * r + 'px'
                   
                   option1 = false
                   option2 = false
                   // 重置双方选或否
              }
          }

           // 这个data目前表示的是scoreSelectedInfo的匿名对象
           opponentSelectedAnswerIndex = data.userSelectedAnswerIndex;
           // 更新对面用户的分数
           opponentScore = data.userMatchInfo.score;
           isOpponentSelect = true;
           // 更新完分数了 这时候需要重新渲染出下一题 依次进行循环
      }

这个阶段代表的是 开始对局了之后所接受到的对方的信息

先进行对信息发送者 sender 的一个判断

本程序设计是在答题过程中 用户每回答一题 都将用户的回答情况广播给比赛的双方 所以在接收到状态为对战中的信息时

首先要判断信息的发出方是谁 (客户端执行) 换而言之 客户端需要判断自己是否此信息的发出方

如果不是 则说明对方答题情况有变化 渲染

如果是 则说明己方答题情况变化 渲染 (更建议将渲染己方的过程放置在己方答题之后即时进行)

(3). GAME_OVER

当题目遍历完了之后 由游戏中状态PLAY_GAME切换为GAME_OVER

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 按照游戏流程 写完所有的题之后 需要到结算页面
// 游戏结束
// 结束的时候 要告诉服务器谁是获胜者 把获胜者的id传过去吧
function gameover(userId) {
   let chatMessage = {};
   let data = null;

   data = userId;
   var sender = userId;
   var type = "GAME_OVER";
   chatMessage.sender = sender;
   chatMessage.type = type;
   chatMessage.data = data;
   console.log("用户:" + sender + "结束游戏");
   socket.send(JSON.stringify(chatMessage));
   userScore = 0;
   opponentScore = 0;
}

可知这里的data是 获胜者的id

服务器将胜者告诉客户端 后台对其决定积分增减etc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
        if (type === "GAME_OVER") {
           if (userId === data.receiver) {
               // 渲染结算页面的我的答案
               const userlist = document.querySelectorAll('.user-list li')
                   // 渲染对手的答案
               const opponentlist = document.querySelectorAll('.opponent-list li')
               for (let i = 0; i < userlist.length; i++) {
                   if (data.opponentAnswerSituations.selfAnswerSituations[i] !== rightAnswer[i]) {
                       userlist[i].backgroundColor = 'red'
                  } else {
                       userlist[i].backgroundColor = 'green'
                  }
                   if (data.selfAnswerSituations.selfAnswerSituations[i] !== rightAnswer[i]) {
                       opponentlist[i].backgroundColor = 'red'
                  } else {
                       opponentlist[i].backgroundColor = 'green'
                  }
                   userlist[i].innerHTML = data.opponentAnswerSituations.selfAnswerSituations[i]
                   opponentlist[i].innerHTML = data.selfAnswerSituations.selfAnswerSituations[i]
              }
          } else {
               // 渲染结算页面的我的答案
               const userlist = document.querySelectorAll('.user-list li')
                   // 渲染对手的答案
               const opponentlist = document.querySelectorAll('.opponent-list li')
               for (let i = 0; i < userlist.length; i++) {
                   opponentlist[i].innerHTML = data.opponentAnswerSituations.selfAnswerSituations[i]
                   userlist[i].innerHTML = data.selfAnswerSituations.selfAnswerSituations[i]
              }
          }

      }

本程序规定触发gameover状态是单个用户所决定的 当满足触发gameover的对应条件时 前端将gameover携带获胜者id返回后端用户A

后端以上方所返回用户Asender的返回双方答题情况

前端通过判断当前用户id是否sender 来确认用户的身份 并将自己以及对方的答题情况保存并渲染

4、OnClose断开连接

1
2
3
4
5
6
7
8
const turnon = document.querySelector('.turn-on')
turnon.addEventListener('click', function() {
   let userId = sessionStorage.getItem('userId')
       //关闭事件
   socket.close()
   socket.onclose = function() {
       console.log("websocket 已关闭 userId: " + userId);
  };

使用点击则关闭大法 至此断开websocket连接 一轮pk结束

issue

可实现再来一局功能 大致思路为

在响应完gameover函数后 重新matchUser或直接toPlay

前者直接跳转为匹配中的状态 相当于退出重进

后者跳转为游戏中状态 需要刷新重新发送题库

可优化当匹配队列中无其他用户时空等待的情况

将用户回答情况工具类结合redis进行优化