匹配过程
匹配系统就是一个单独的程序就类似于MySQL
生成地图的过程在用户本地、两名玩家在本地实现地图
地图就大概率不一样、需要将生成地图过程放在服务器中
Game任务需要生成统一的地图、Game第一步先生成一个地图CreateMap
有很多逻辑都需要在服务器端完成、判断蛇输赢的逻辑
当我们撞的时候死亡、如果在客户端用户本地就可以作弊
所以说我们整个游戏的过程应该都放到服务器端、不止生成地图这个过程
同时蛇的移动、蛇的判定、都要在服务器端统一完成
服务器端判断完之后再把结果返还给前端、前端只是用来花动画的
前端不做任何判定逻辑、并不是所有的游戏判定逻辑都在云端
回合制通信量比较少、比如吃鸡各种fps游戏在本地操作非常频繁
如果都在云端的话延迟会非常高、判断是否击中对方的逻辑判断就是在本地
所以要在游戏体验在用户作弊之间做一个权衡、锁头挂之类的
炉石所有逻辑判断都在云端、很难作弊
整个我们在云端维护游戏过程它的整个流程
1、实现匹配系统的原理剖析
都点击匹配向服务器发出请求匹配系统不会立即返回结果,一般会匹配个几秒
整个游戏是异步的过程,计算量比较大的过程,所以我们就另外写一个进程
后端接收的请求,会将用户的请求,发送给我们的匹配系统,匹配系统维护了一堆用户的集合
匹配系统里有很多很多的用户,将当前用户中战斗力最相近的几个人匹配到一起
然后将我们的匹配结果返回给网站后端返回给serverSpringBoot
返回之后我们的后端就会把结果返回给前端、我们在前端就可以看到匹配的对手是谁
整个匹配的过程其实是一个异步的过程、匹配的过程会经过一段比较长的时间
什么时候有匹配结果我们是未知的
2、WebSocket协议原理剖析
一问一答式http
问一次返回多次中间还有间隔时间用websocket协议
这种情况下我们的https就不能满足要求了、websocket协议
不仅客户端可以主动像服务器发送请求、服务器端也可以主动向客户端发送请求
是两遍对称的一个通信方式
3、我们在云端维护游戏的整个流程
先生成一个地图,将两个地图传给两个客户端,传完之后等待用户输入
waiting、我们可以从代码端获取下一步操作也可以客户端返还
代码端要用微服务了,waiting可以写一个死循环每次循环前先sleep一秒钟
然后判断一下是否两条蛇的下一步操作都有了、如果有的话进行下一步
如果没有的话继续等待、当然我们可以设定一个最大时间最大5s
如果5s之内没有得到下一步操作的话、我们就判断没有输入操作的蛇输
如果超时就判断输赢、如果获得输入写一个judging程序、判断是否合法和撞墙、这个游戏的逻辑
4、WebSocket协议原理剖析
基本原理就是、每一个连接我们都会在后端维护起来
我们会把前端建立的每一个websocket连接在后端维护起来,
比如我们的Clint1连接到我们的服务器、其实一个连接就是一个类
其实就是一个websocketserver类,每来一个连接,其实就是new一个这个类的实例
先创建这个类,我们每次来一个连接的时候本质上就是new一个这个类的实例
每一个连接都是这个类的一个实列来维护的、所有和这个连接相关的信息
都会存到这个类里面、如果是每一个连接自己独有的信息、比如说维护这个连接对应的用户是谁
那可以存成私有变量、如果是维护所有连接的公共信息
比如我们想去维护一下当前哪些用户建立的连接、那么可以存成一个静态变量
WebSocket就是一个多线程、每来一个连接就会开一个新的线程来维护它这个websocket就是一个类
每来一个连接就会开一个线程创建一个类,去维护这个连接流程
用户开始匹配的时候向后端发送一个请求、就会在后端websocket里new一个新的类开一个线程
来维护这个链接那么接收到这个请求之后、我们会把我们的信息发送给我们的匹配系统
匹配系统是一个单独的额外的程序、匹配系统当接收到很多的用户之后随着时间的推移
出现两名玩家的战斗力比较接近匹配出来一局、匹配系统就会将信息返回给我们的后端服务器
也就是我们的websocket服务器、websocket服务器接受到这个信息之后就会将这个信息返回
给这局对战的两名玩家、根据两名玩家建立的链接返还到他们的前端的浏览器里面
同时在我们的服务器端创建一个游戏的过程、因为整个游戏的判断地图的生成都是在云端进行的
这就是websocket的基本原理
5、集成WebSocket
在pom.xml
文件中添加依赖:
spring-boot-starter-websocket
fastjson
java
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>3.2.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.43</version>
</dependency>
6.添加WebSocketConfig
配置类
java
config/WebSocketConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
7.放行WebSocket
协议
java
config/SecurityConfig
// 默认是不接受Websocket请求,需要放行
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
}
8.创建ws
类
在此之前先将判断用户是否存在的代码封装起来
代码的业务范围在哪就创建在哪里
java
backend/consumer/utils/JwtAuthentication
package com.kob.backend.consumer.utils;
import com.kob.backend.utils.JwtUtil;
import io.jsonwebtoken.Claims;
public class JwtAuthentication {
public static Integer getUserId(String token) {
int userId = -1;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}
java
backend/consumer/WebSocketServer.java
package com.kob.backend.consumer;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
@Component
// url链接:ws://127.0.0.1:3000/websocket/**
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
// ConcurrentHashMap是线程安全的哈希表
// 让每一个实例访问同一个users(存储目前所有的连接)
final private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
private User user; // 当前连接的用户
private Session session = null; // 每个连接用session来维护
private static UserMapper userMapper;
// 因为WebSocketServer不是单例的,因此需要用此方式注入UserMapper
@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
this.session = session;
System.out.println("connected!");
// JwtAuthentication是再consumer/utils中封装的类,用token判断用户是否存在
Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);
if(this.user != null) {
users.put(userId, this);
} else {
this.session.close();
}
System.out.println(users);
}
@OnClose
public void onClose() {
// 关闭连接
System.out.println("disconnected!");
if(this.user != null) {
users.remove(this.user.getId());
}
}
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
System.out.println("receive message: " + message);
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
public void sendMessage(String message) {
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
9.在store
中存储pk
信息
javascript
store/pk.js
export default{
state: {
status: "matching", // matching表示匹配界面,playing表示对战界面
socket: null, // 前端和后端建立的连接
opponent_username: "",
opponent_photo: "",
},
getters: {
},
mutations: {
updateSocket(state, socket) {
state.socket = socket;
},
updateOpponent(state, opponent) {
state.opponent_username = opponent.username;
state.opponent_photo = opponent.photo;
},
updateStatus(state, status) {
state.status = status;
},
},
actions: {
},
modules: {
}
}
引入pk模块
javascript
store/index.js
...
import ModulePk from './pk'
...
export default createStore({
...
modules: {
...
pk: ModulePk,
}
})
10在游戏界面中创建ws
连接
javascript
PkIndexView.vue
<template>
<PlayGround />
</template>
<script>
import PlayGround from '../../components/PlayGround.vue';
import { onMounted, onUnmounted } from 'vue';
import { useStore } from 'vuex';
export default {
components: {
PlayGround,
},
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;
let socket = null;
onMounted(() => {
socket = new WebSocket(socketUrl);
socket.onopen = () => {
console.log("connected!");
};
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
console.log(data);
}
socket.onclose = () => {
console.log("disconnected!");
}
});
onUnmounted(() => {
socket.close();
});
}
}
</script>
<style scoped>
</style>
11.实现匹配界面
html
components/MatchGround.vue
<template>
<div class="matchground">
<div class="row">
<div class="col-6">
<div class="user-photo">
<img :src="$store.state.user.photo" alt="" />
</div>
<div class="user-username">
{{ $store.state.user.username }}
</div>
</div>
<div class="col-6">
<div class="user-photo">
<img :src="$store.state.pk.opponent_photo" alt="" />
</div>
<div class="user-username">
{{ $store.state.pk.opponent_username }}
</div>
</div>
</div>
<div class="col-12" style="text-align: center; padding-top: 15vh">
<button
type="button"
class="btn btn-warning btn-lg"
@click="click_match_btn"
>
{{ match_btn_info }}
</button>
</div>
</div>
</template>
<script>
import { ref } from "vue";
import { useStore } from "vuex";
export default {
name: "MatchGround",
setup() {
const store = useStore();
let match_btn_info = ref("开始匹配");
const click_match_btn = () => {
if (match_btn_info.value === "开始匹配") {
match_btn_info.value = "取消";
store.state.pk.socket.send(
JSON.stringify({
event: "start-matching",
})
);
} else {
match_btn_info.value = "开始匹配";
store.state.pk.socket.send(
JSON.stringify({
event: "stop-matching",
})
);
}
};
return {
match_btn_info,
click_match_btn,
};
},
};
</script>
<style scoped>
div.matchground {
width: 60vw;
height: 70vh;
margin: 40px auto;
background-color: rgba(50, 50, 50, 0.5);
}
div.user-photo {
text-align: center;
padding-top: 10vh;
}
div.user-photo > img {
border-radius: 50%;
width: 20vh;
}
div.user-username {
text-align: center;
font-size: 24px;
font-weight: 600;
color: white;
padding-top: 2vh;
}
</style>
- 按钮点击后向后端发起相应的请求。
放入游戏界面,根据玩家status
来判断显示匹配界面还是游戏界面
html
PkIndexView.vue
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'" />
<MatchGround v-if="$store.state.pk.status === 'matching'" />
</template>
<script>
import PlayGround from "@/components/PlayGround.vue";
import MatchGround from "@/components/MatchGround.vue";
import { onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";
export default {
components: {
PlayGround,
MatchGround,
},
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;
let socket = null;
onMounted(() => {
store.commit("updateOpponent", {
username: "我的对手",
photo:
"https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
});
socket = new WebSocket(socketUrl);
socket.onopen = () => {
console.log("connected!");
store.commit("updateSocket", socket);
};
socket.onmessage = (msg) => {
const data = JSON.parse(msg.data);
if (data.event === "success-matching") {
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,
});
// 秒换地图看不见对手
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
store.commit("updateGamemap", data.gamemap);
}
};
socket.onclose = () => {
console.log("disconnected!");
};
});
// 卸载时
onUnmounted(() => {
socket.close();
store.commit("updateStatus", "matching");
});
},
};
</script>
<style scoped></style>
代码逻辑:
- 根据
pk.js
中的status
来判断生成的界面。 - 挂载后,建立连接。
- 接收后端信息,更新对手的
username
和photo
. - 卸载时,断开连接。
12.完成后端匹配逻辑
java
WebSocketServer.java
package com.kob.backend.consumer;
import com.alibaba.fastjson.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
...
private static UserMapper userMapper;
// 由于WebSocketServer不是单例的,需要用先定义static静态变量,再用类名接收
@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
...
}
@OnClose
public void onClose() {
// 关闭连接
System.out.println("disconnected!");
if(this.user != null) {
users.remove(this.user.getId());
matchpool.remove(this.user);
}
}
private void startMatching() {
System.out.println("start matching!");
matchpool.add(this.user);
while(matchpool.size() >= 2) {
Iterator<User> it = matchpool.iterator();
User a = it.next(), b = it.next();
matchpool.remove(a);
matchpool.remove(b);
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
users.get(a.getId()).sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}
private void stopMatching() {
System.out.println("stop matching!");
matchpool.remove(user);
}
@OnMessage
public void onMessage(String message, Session session) { // 一般当做路由,判断把任务交给谁处理
// 从Client接收消息
System.out.println("receive message!");
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
// 反过来调用equals(),可减少避免event为空时抛出异常
if("start-matching".equals(event)) {
startMatching();
} else if("stop-matching".equals(event)) {
stopMatching();
}
}
@OnError
public void onError(Session session, Throwable error) {
...
}
public void sendMessage(String message) {
...
}
}
逻辑:
- 建立连接后,会将当前连接实例存入
users
(注意存的时类的实例)中。 - 后端接收到请求后,
onMessage
根据请求类型调用不同的函数。 - 开始匹配时,将玩家
user
存入匹配池。简单的匹配匹池中相邻的玩家,将对手的信息发送给前端(通过userId
获取玩家的连接实例,从而调用sendMessage
),对手将当前玩家信息发送给前端。 - 取消匹配时,将玩家从匹配池退出来。
- 断开连接后,将玩家从匹配池退出来,将连接实例从
users
中移除。
13.实现后端生成地图
由于之前是前端生成地图,会导致两名玩家生成的地图不一样,影响游戏进行。因此,我们需要在后端将地图生成,返回给两名玩家。
和之前在前端写的逻辑一样
在后端实现生成地图代码
java
backend/consumer/utils/Game.java
package com.kob.backend.consumer.utils;
import java.util.Random;
public class Game {
final private Integer rows;
final private Integer cols;
final private Integer inner_walls_count;
final private int [][]g;
final private static int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
public Game(Integer rows, Integer cols, Integer inner_walls_count) {
this.rows = rows;
this.cols = cols;
this.inner_walls_count = inner_walls_count;
this.g = new int[rows][cols];
}
public int[][] getG() {
return g;
}
private boolean check_connectivity(int sx, int sy, int tx, int ty) {
if(sx == tx && sy == ty) return true;
g[sx][sy] = 1;
for(int i = 0; i < 4; i ++) {
int x = sx + dx[i], y = sy + dy[i];
if(x >= 0 && x < this.rows && y >= 0 && y < this.cols && g[x][y] == 0) {
if(check_connectivity(x, y, tx, ty)) {
g[sx][sy] = 0;
return true;
}
}
}
g[sx][sy] = 0;
return false;
}
private boolean draw() { // 画地图
for(int i = 0; i < this.rows; i ++) {
for(int j = 0; j < this.cols; j ++) {
g[i][j] = 0;
}
}
for(int r = 0; r < this.rows; r ++) {
g[r][0] = g[r][this.cols - 1] = 1;
}
for(int c = 0; c < this.cols; c ++) {
g[0][c] = g[this.rows - 1][c] = 1;
}
Random random = new Random();
for(int i = 0; i < this.inner_walls_count / 2; i ++) {
for(int j = 0; j < 1000; j ++) {
int r = random.nextInt(this.rows); // 返回0~this.rows - 1的随机值
int c = random.nextInt(this.cols);
if(g[r][c] == 1 || g[this.rows - 1 - r][this.cols - 1 - c] == 1) continue;
if(r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue;
g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = 1;
break;
}
}
return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
}
public void createMap() {
for(int i = 0; i < 1000; i ++) {
if(draw()) break;
}
}
}
后端向前端发送地图信息
java
WebSocketServer.java
private void startMatching() {
...
while(matchpool.size() >= 2) {
...
Game game = new Game(13, 14, 20);
game.createMap();
JSONObject respA = new JSONObject();
...
respA.put("gamemap", game.getG());
users.get(a.getId()).sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject();
...
respB.put("gamemap", game.getG());
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}
将地图存储在store
中
javascript
export default{
state: {
...
gamemap: null,
},
...
mutations: {
...
updateGamemap(state, gamemap) {
state.gamemap = gamemap;
}
},
...
前端接收地图信息
javascript
PkIndexView.vue
....
socket.onmessage = (msg) => {
const data = JSON.parse(msg.data);
if (data.event === "success-matching") {
...
store.commit("updateGamemap", data.gamemap);
}
};
...
画出地图
在创建GameMap
实例时,将store
也传进去
javascript
GameMap.vue
...
onMounted(() => {
new GameMap(canvas.value.getContext("2d"), parent.value, store);
});
...
注意将之前生成地图的代码删干净
javascript
GameMap.js
...
// 创建所有的墙
create_walls() {
const g = this.store.state.pk.gamemap;
for(let r = 0; r < this.rows; r ++) {
for(let c = 0; c < this.cols; c ++) {
if(g[r][c]) {
this.walls.push(new Wall(r, c, this));
}
}
}
return true;
}
...
start() {
this.create_walls();
this.add_listening_events();
}
...