King of Bots项目笔记

1. 项目规划

  • 使用前后端分离方式完成本项目。其中,前端使用Vue3完成,后端使用SpringBoot2完成。

  • 技术栈:
技术 说明
SpringBoot 容器+MVC框架
mysql 关系型数据库
JWT 登录支持
SpringSecurity 验证和授权框架
Redis 缓存数据库
Lombok 简化对象封装工具
MyBatisPlus ORM框架
MicroService(SpringCloud) 微服务
  • 项目(游戏)设计逻辑:


2. 环境配置与项目创建

2.1 项目设计

  • 名称:King Of Bots
  • 项目包含的模块
  • PK模块:匹配界面(微服务)、实况直播界面(WebSocket协议)
    • 对局列表模块:对局列表界面、对局录像界面
    • 排行榜模块:Bot排行榜界面
    • 用户中心模块:注册界面、登录界面、我的Bot界面、每个Bot的详情界面
  • 前后端分离模式
    • SpringBoot实现后端
    • Vue3实现Web端和AcApp

2.2 配置git环境

  1. 安装Git Bashhttps://gitforwindows.org/
  2. 进入家目录生成秘钥:执行命令ssh-keygen
  3. id_rsa.pub的内容复制到github

2.3 创建项目前后端

  • 后端:使用Spring Initilizr创建后端,使用2.3.7REALESE版本,添加SpringBoot Web Starter插件。
  • 前端:使用Vue cli脚手架创建项目,添加Vue RouterVueX插件,添加BootStrapjquery依赖。(Vue ui创建前端有点奇怪的bug,选择位置时不选择默认盘符下的位置就报错,先在该盘符创建再移动到目标盘符去)

3. 创建前端基础页面

3.1 创建导航栏及页面

image.png
通过创建各个页面的view,主要包括error, pk, ranklist, record, user-bot页面。然后在导航栏中通过router进行跳转。
通过如下方式实时计算当前页面位于哪个部分,以便于高亮导航栏中的对应部分:

1
2
3
4
5
const route = useRoute();
let route_name = computed(() => route.name)
return {
route_name
}

3.2 创建游戏地图

image.png
首先创建游戏地图基类AcGameObject,然后通过继承该类实现GameMap游戏地图渲染类。

  • AcGameObject中,通过requestAnimationFrame(step)递归实现每60帧(因显示器而异)的实时渲染游戏画面。
  • GameMap中,先将游戏背景(绿布)渲染出来,然后创建四周墙壁,再随机生成内部墙体障碍物,每生成一种方案,通过Flood Fill算法检验左下角与右上角的连通性,如果不连通则重新生成。

注意: 在后期会将生成地图的逻辑放到后端(前端只负责渲染,暂时放在前端便于当前调试),避免两名用户中有人修改前端代码造成不公平的情况。与此同时,生成的地图为13×1413 \times 14的布局,并确保其是中心对称的。

设计成13×1413 \times 14主要是为了避免两条蛇头能同时到达一个点的情况(平局),避免造成对优势方不利的情况。
解释:若设计为13×1313 \times 13,则刚开始两条蛇的蛇头坐标为(1, 13), (13, 1),双方每走一步,横纵坐标之和的变化都是相同的(偶、奇、偶、奇……),设计成13×1413 \times 14则刚开始两蛇头的坐标为(1, 14), (13, 1),则双方每走一步横纵坐标之和不可能相等,意味着双方的蛇头不可能在同一时间进入同一个格子。

3.3 创建蛇类

image.png 在创建蛇类前,先要创建蛇的身体类(即Cell类):

1
2
3
4
5
6
7
8
9
10
export class Cell {
constructor(r, c) {
// 格子坐标
this.r = r;
this.c = c;

this.x = c + 0.5; // 圆心横坐标
this.y = r + 0.5; // 圆心纵坐标
}
}

在定义蛇类时,需要定义其状态、当前移动方向、眼睛的偏移方向。蛇的移动方式为每移动一步,蛇头向前一步,蛇尾砍掉,身体保持不同(前10步,蛇尾不用移动,每次移动只蛇头向前移动即可,长度加1,之后每3步增长一格)。

蛇的身体为一个个圆形Cell组成,因此,每两个Cell间用长方形进行填充,这样就只有头和尾的Cell有圆弧,看起来会比较正常。

在移动时,通过蛇类中的函数设置当前步的前进方向,然后在地图类中进行监听用户操作(后续接入代码操作后,也将调用此函数设置蛇蛇的移动方向):

1
2
3
set_direction(d) {
this.direction = d;
}

然后判断蛇的存活状态(在地图类中进行判断,而不是在蛇类中判断):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
check_valid(cell) {  // 检测目标位置是否合法:没有撞到两条蛇的身体和障碍物
for (const wall of this.walls) {
if (wall.r === cell.r && wall.c === cell.c)
return false;
}
for (const snake of this.snakes) {
let k = snake.cells.length;
if (!snake.check_tail_increasing()) { // 当蛇尾会前进的时候,蛇尾不用判断
k -- ;
}
for (let i = 0; i < k; i ++ ) {
if (snake.cells[i].r === cell.r && snake.cells[i].c === cell.c)
return false;
}
}
return true;
}

4. 配置MySql与实现注册登录模块

pom.xml文件中添加如下依赖:

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
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>3.1.5</version>
</dependency>

4.1 数据库配置

数据库使用MySql8.0版本,并创建user表。YAML相关配置如下:

1
2
3
4
5
6
7
# 配置数据库连接
spring:
datasource:
username: root
password: xxxxxxx
url: jdbc:mysql://localhost:3306/kob?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8
driver-class-name: com.mysql.cj.jdbc.Driver

4.2 登录校验模块

4.2.1 基本登录功能

登录模块利用SpringSecurity自带的功能即可完成。通过配置SecurityConfig配置类、再实现UserDetailsService, UserDetails两个接口即可。在UserDetailsServiceImpl中先校验用户是否存在,再返回一个包含用户信息UserDetailsImpl对象用于校验用户合法性(包括密码校验、用户是否失效、用户是否被锁等等)。

4.2.2 引入JWT认证

为了使用jwt认证,先在pom.xml文件中引入一下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
  1. 实现JwtUtil类,用于生成和解析jwt
  2. 实现JwtAuthenticationTokenFilter类,用于验证用户传递过来的jwt,验证成功后,当前用户的信息将会被注入上下文中。
  3. 修改SecurityConfig放行tokenregister请求。
1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/token/", "/user/account/register/").permitAll()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
  • 再分别创建三个接口/user/account/token/, /user/account/info/, /user/account/register/用于获取token、获取用户信息、用户注册。

在调试过程中,发现一个需要注意的细节,在浏览器中复制生成的token时,需要将其点开再复制,因为当位置不够时,中间很长一部分会以省略号形式展示,复制后根本无法发出请求。

4.3 前端注册页面实现

注册页面与登录页面极其类似,直接复制过来修改一下即可。
image.png

4.4 将jwt信息存进localStorage

每当用户登录成功时,我们就将其获取到的jwt存进localStorage中。

1
localStorage.setItem("jwt", resp.token)

然后在每次路由跳转到登录页时,先取出localStorage中的jwt信息,然后发送获取用户信息的请求以校验当前的jwt是否过期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const jwt = localStorage.getItem("jwt")
if (jwt) {
store.commit("updateToken", jwt)
store.dispatch("getInfo", {
success() {
router.push({name: "home"})
store.commit("updatePullingInfo", false)
},
error() {
store.commit("updatePullingInfo", false)
}
})
} else {
store.commit("updatePullingInfo", false)
}

如果成功,则路由直接跳转到首页,否则显示登录页让用户重新登录。

其中,updatePullingInfouser.js中的用于修改全局变量pulling_info的函数,pulling_info用于控制当前是否处于获取用户信息状态(避免此时登录页会闪一下的问题),当用户信息拉取完毕时,成功就跳转首页,失败就正常显示登录页。

1
2
3
updatePullingInfo(state, pulling_info) {
state.pulling_info = pulling_info
}

5. 个人中心(我的Bot)

5.1 Bot的CRUD(后端)

BotCRUD是一些比较重复性的工作,此处以add举例。

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
56
57
58
59
60
61
62
63
64
@Override
public Map<String, String> add(Map<String, String> botData) {
UsernamePasswordAuthenticationToken authenticationToken =
(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl loginUser = (UserDetailsImpl) authenticationToken.getPrincipal();
User user = loginUser.getUser();

String title = botData.get("title");
String description = botData.get("description");
String content = botData.get("content");

Map<String, String> map = new HashMap<>();

if (null == title || title.length() == 0) {
map.put("error_massage", "Bot标题不能为空!");
return map;
}
if (title.length() > 50) {
map.put("error_message", "Bot标题长度不能超过50!");
return map;
}

// 描述可以为空
if (null == description || description.length() == 0) {
description = "这个用户很懒,什么也没写~";
}
if (description.length() > 200) {
map.put("error_message", "Bot描述长度不能超过200!");
return map;
}

if (null == content || content.length() == 0) {
map.put("error_message", "Bot代码不能为空!");
return map;
}
if (content.length() > 10000) {
map.put("error_message", "Bot代码长度不能超过10000!");
return map;
}

// 校验一下该 bot的标题和代码,是否已创建过
QueryWrapper<Bot> botQueryWrapper = new QueryWrapper<>();
botQueryWrapper.eq("user_id", user.getId());
botQueryWrapper.and(wrapper -> wrapper.eq("content", content).or().eq("title", title));
Long count = botMapper.selectCount(botQueryWrapper);
if (count > 0) {
map.put("error_message", "该Bot已经被你创建过啦,创建一个新的吧!");
return map;
}

Date now = new Date();
System.out.println(now);
Bot bot = new Bot(null, user.getId(), title, description, content, DEFAULT_RATING, now, now, null);
System.out.println(bot);

int state = botMapper.insert(bot);
if (state > 0) {
map.put("error_message", "success");
return map;
}

map.put("error_message", "创建失败!");
return map;
}

CRUD部分基本都类似,主要是一些限制条件要限制好,不然前端代码如果被修改,数据安全性得不到保证。

正如 yxc 所说,前端防君子,后端防小人!
这里创建Bot类时有个需要注意的地方,时区一定要通过@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")规定好,不然会出现前后端时间相差8小时的情况(具体可参考使用@JsonFormat注解前后端时间相差8小时)。

5.2 Bot的CRUD(前端)

前端实现CRUD比较容易,使用BootStrap中的样式即可。唯一麻烦点的是引入ace代码编辑器。

  • 我的Bot页面:

image.png

  • 创建Bot的Modal框:

image.png

  • 修改Bot的Modal框:

image.png

注意:在引入ace代码编辑器时,在调试时可能会因为浏览器的问题而导致代码高亮、自动提示等出现不符合预期的问题,切换浏览器尝试一下。

6. 微服务:实现匹配系统

6.1 后端(backend)集成WebSocket

  1. pom.xml文件中添加依赖:
1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.7.2</version>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.11</version>
</dependency>
  1. 添加WebSocketConfig配置类:
1
2
3
4
5
6
7
8
9
10
11
12
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();
}
}
  1. 添加WebSocketServer类(核心功能):
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package com.kob.backend.comsumer;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.kob.backend.comsumer.utils.Game;
import com.kob.backend.comsumer.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 Session session;
private User user;
// 记录全局的连接信息
private static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();

private static final CopyOnWriteArraySet<User> matchpool = new CopyOnWriteArraySet<>();

private static UserMapper userMapper;

@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}


@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
// 建立连接
this.session = session;
Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);

if (null != this.user) {
users.put(userId, this);
System.out.println("connected!");
} else {
this.session.close();
}

System.out.println(users);

}

@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
if (null != this.user) {
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);

Game game = new Game(13, 14, 20);
game.createMap();

JSONObject respA = new JSONObject();
respA.put("event", "start-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("gameMap", game.getG());
users.get(a.getId()).sendMessage(respA.toJSONString());

JSONObject respB = new JSONObject();
respB.put("event", "start-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("gameMap", game.getG());
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}

private void stopMatching() {
System.out.println("stop_matching!");
matchpool.remove(this.user);
}

@OnMessage
public void onMessage(String message, Session session) {
// 从 Client 接收消息
System.out.println("receive message!");
JSONObject data = JSON.parseObject(message);
String event = data.getString("event");

if ("start-matching".equals(event)) {
startMatching();
} else if ("stop-matching".equals(event)) {
stopMatching();
}
}

public void sendMessage(String message) {
// 从 server 发送消息
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}

@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}

  1. 修改SecurityConfig放行"/websocket/**"请求:
1
2
3
4
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
  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
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.kob.backend.comsumer.utils;

import java.util.Arrays;
import java.util.Random;

public class Game {
/**
* 游戏地图行数
*/
private final Integer rows;
/**
* 游戏地图列数
*/
private final Integer cols;
/**
* 地图内部墙体障碍物数量
*/
private final Integer innerWallsCount;
/**
* 游戏地图(0 表示草地,1 表示墙体障碍物)
*/
private final int[][] g;

private final static int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};

public Game(Integer rows, Integer cols, Integer innerWallsCount) {
this.rows = rows;
this.cols = cols;
this.innerWallsCount = innerWallsCount;
this.g = new int[rows][cols];
}

public int[][] getG() {
return g;
}

// Flood Fill 校验地图连通性
public boolean checkConnectivity(int sx, int sy, int tx, int ty) {
if (sx == tx && sy == ty) return true;
// 表示已经走过
g[sx][sy] = 1;
for (int d = 0; d < 4; d ++) {
int a = sx + dx[d], b = sy + dy[d];
if (a >= 0 && a < this.rows && b >= 0 && b < this.cols && g[a][b] == 0) {
if (checkConnectivity(a, b, tx, ty)) {
// 恢复现场
g[sx][sy] = 0;
return true;
}
}
}
// 即使校验失败也要恢复现场
g[sx][sy] = 0;
return false;

}

// 画地图
private boolean draw() {
// 初始化地图
for (int i = 0; i < g.length; i ++) {
Arrays.fill(g[i], 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.innerWallsCount >> 1; i ++) {
for (int j = 0; j < 1000; j ++) {
int r = random.nextInt(this.rows);
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 checkConnectivity(this.rows - 2, 1, 1, this.cols - 2);
}

public void createMap() {
// 循环 1000 次,直到成功生成合法地图
for (int i = 0; i < 1000; i ++) {
if (draw()) {
break;
}
}
}
}

6.2 前端实现匹配界面

  1. 前端实现pk类,在其中维护当前一次的pk需要的变量,其中利用status变量动态切换匹配和游戏界面(status: "matching", // matching 表示匹配界面,playing 表示对战界面)。
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
export default {
state: {
status: "matching", // matching 表示匹配界面,playing 表示对战界面
socket: null,
opponent_username: "",
opponent_photo: "",
gameMap: null,
},
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;
},
updateGameMap(state, gameMap) {
state.gameMap = gameMap;
}
},
actions: {
},
modules: {
}
}

  1. 除了之前实现的游戏界面,还需要实现一个匹配界面。匹配界面相对简单,和游戏地图界面类似,两个头像,一个按钮。

玩家1:

image.png

玩家2:

image.png

匹配成功:

image.png

  1. 实现PkIndexView页面。之前该页面只有游戏地图,现在开始时需要先匹配,匹配成功后,需要将匹配界面关掉,切换为游戏地图页面。
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
56
57
58
59
60
61
62
<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:8090/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 === "start-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>

需要注意的是,与http协议类似,socketUrl的写法将http换为ws即可。

匹配成功后,双方的地图均是从后端获取,因此实现了两名玩家的地图一致性。
image.png image.png

6.3 实现匹配系统的微服务(matchingsystem)

新建一个backendCloud maven项目,引入springcloud依赖,在该项目下面新增两个模块(backend, matchingsystem),将先前的backend复制过来,引入并配置restTemplate
匹配系统思路: 每次有匹配请求,backend发送添加用户请求到matchingsystemmatchingsystem将该用户添加到待匹配列表中。matchingsystem会在项目启动时开启matchingPool线程,并每秒尝试进行匹配列表中的玩家,同时每秒会增加玩家等待时间,时间越大,那么匹配该玩家时,可接受的天梯分差也将越大。

  • webSocketServer中实现startMatching函数调用匹配系统进行匹配:
1
2
3
4
5
6
7
8
9
private void startMatching() {
System.out.println("start_matching!");
MultiValueMap<String, String> playerData = new LinkedMultiValueMap<>();
playerData.add("userId", this.user.getId().toString());
playerData.add("rating", this.user.getRating().toString());

// ADD_PLAYER_URL = "http://127.0.0.1:8089/player/add/"
restTemplate.postForObject(ADD_PLAYER_URL, playerData, String.class);
}
  • 匹配系统MatchingPool类核心代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
lock.lock();
try {
increaseWaitedTime();
matchPlayers();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
  • matchingsystem项目启动时开启MatchingPool线程:
1
2
3
4
5
6
7
@SpringBootApplication
public class MatchingSystemApplication {
public static void main(String[] args) {
MatchingServiceImpl.matchingPool.start();
SpringApplication.run(MatchingSystemApplication.class, args);
}
}
  • 一旦匹配成功一对玩家,就会调用MatchingPoolsendResult方法通知backend以创建当前游戏:
1
2
3
4
5
6
7
8
9
// 返回匹配成功结果
private void sendResult(Player a, Player b) {
System.out.println("matched: " + a + " " + b);
MultiValueMap<String, String> gameData = new LinkedMultiValueMap<>();
gameData.add("aId", a.getUserId().toString());
gameData.add("bId", b.getUserId().toString());
// START_GAME_URL = "http://127.0.0.1:8090/pk/startGame/"
restTemplate.postForObject(START_GAME_URL, gameData, String.class);
}

startGame函数会在下一部分介绍。

6.4 实现后端游戏逻辑

WebSocketServer中调用实现函数startGame以便匹配系统完成匹配时进行调用以开始游戏:

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
public static void startGame(Integer aId, Integer bId) {
User a = userMapper.selectById(aId);
User b = userMapper.selectById(bId);

Game game = new Game(13, 14, 20, aId, bId);
game.createMap();
game.start();
if (null != users.get(aId)) {
users.get(aId).game = game;
}
if (null != users.get(bId)) {
users.get(bId).game = game;
}

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

JSONObject respA = new JSONObject();
respA.put("event", "start-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
// 给玩家A客户端返回结果
if (null != users.get(aId)) {
users.get(aId).sendMessage(respA.toJSONString());
}

JSONObject respB = new JSONObject();
respB.put("event", "start-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
// 给玩家B客户端返回结果
if (null != users.get(bId)) {
users.get(bId).sendMessage(respB.toJSONString());
}
}

主要的游戏逻辑在Game类中,每次等待用户输入,然后校验操作时候合法,如果合法,则将两名玩家的操作广播给双方的客户端。如果有玩家操作不合法或者有玩家五秒内未进行输入,则游戏结束,向两名玩家广播游戏结果。
Game中的核心代码如下:

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
@Override
public void run() {
for (int i = 0; i < 1000; i ++) {
if (nextStep()) {
judge();
if ("playing".equals(status)) {
sendMove();
} else {
sendResult();
break;
}
} else {
status = "finished";
lock.lock();
try {
if (null == nextStepA && null == nextStepB) {
winner = "all";
} else if (null == nextStepB) {
winner = "A";
} else {
winner = "B";
}
} finally {
lock.unlock();
}
sendResult();
break;
}
}
}

7. 微服务:Bot代码的执行(botrunningsystem)

  • 添加依赖:
1
2
3
4
5
<dependency>
<groupId>org.jooq</groupId>
<artifactId>joor-java-8</artifactId>
<version>0.9.14</version>
</dependency>
  • 配置restTemplatesecurity
1
2
3
4
5
6
7
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate getRestTemplate() {
return new RestTemplate();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/bot/add/").hasIpAddress("127.0.0.1")
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
}
}

每次后端(backend)会调用bot/add/接口往bots队列中添加Bot

1
2
3
4
5
6
7
8
9
10
public void addBot(Integer userId, String botCode, String input) {
lock.lock();
try {
bots.add(new Bot(userId, botCode, input));
// 当 bot 添加结束,需要唤起其他线程进行消费
condition.signalAll();
} finally {
lock.unlock();
}
}

botPool的核心代码(这里相当于手动实现了一个消息队列):

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
private void consume(Bot bot) {
Consumer consumer = new Consumer();
consumer.startTimeout(2000, bot);
}

@Override
public void run() {
while (true) {
lock.lock();
if (bots.isEmpty()) {
try {
// 当队列中没有等待消费的Bot时,让线程等待,当有新添加的Bot时,会被condition.signalAll();唤醒
condition.await();
} catch (InterruptedException e) {
e.printStackTrace();
break;
} finally {
lock.unlock();
}
} else {
Bot bot = bots.poll();
lock.unlock();
// consume 可能会执行几秒钟,需要先解锁
consume(bot);
}
}
}

而在Consumer中,需要控制每个Bot的执行时间:

1
2
3
4
5
6
7
8
9
10
11
12
public void startTimeout(long timeout, Bot bot) {
this.bot = bot;
this.start();
try {
this.join(timeout);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 最多等待 timeout 秒,然后中断
this.interrupt();
}
}

Consumer中的核心代码:

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
@Override
public void run() {
UUID uuid = UUID.randomUUID();
String uid = uuid.toString().substring(0, 8);
// 需要保障每次的类名不一样,否则只编译一次
Supplier<Integer> botInterface = Reflect.compile(
"com.kob.botrunningsystem.utils.Bot" + uid,
addUid(bot.getBotCode(), uid)
).create().get();
// 将输入写入文件以便后续扩展(后期可以在docker中运行,就需要从文件中读取输入)
File file = new File("input.txt");
try (PrintWriter fout = new PrintWriter(file)) {
fout.println(bot.getInput());
fout.flush();
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
// 执行Bot代码,获取结果
Integer direction = botInterface.get();
// 将Bot执行结果返回
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.add("userId", bot.getUserId().toString());
data.add("direction", direction.toString());

restTemplate.postForObject(RECEIVE_BOT_MOVE_URL, data, String.class);
}

8. 创建对战列表与排行榜页面

这两部分主要的任务就是写好分页查询和分页展示。
分页查询直接利用MybatisPlus自带的分页工具即可。
分页配置:

1
2
3
4
5
6
7
8
9
@Configuration
public class MybatisConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

其中,在对战列表中的查看录像功能,实现原理为:将数据库中记录的地图信息、用户双方的操作信息取出,在游戏地图中重新模拟一遍即可。
对局列表:
image.png
排行榜:
image.png

9. 实现QQ三方登录

基本思路: 通过访问后端的@GetMapping("/applyCode/")接口获取apply_code,同时后端生成了state存入redis中。然后前端再调用后端的@GetMapping("/receiveCode/")接口。在该接口中,会拿着stateredis中的进行校验,然后获取access_token,拿到access_token之后获取用户的openid,然后判断该openid是否已经存在,如果存在直接生成jwt并返回前端,如果不存在,则说明是第一次申请QQ登录,需要获取user_info,再存入数据库并生成jwt返回。

官方授权流程图(官方教程):

9.1 前往腾讯开放平台完成资料审核

需要先QQ登录,然后点击右上角的账户管理,根据提示微信扫码完成人脸识别校验。

注:手持照片得用后置摄像头拍摄,前置摄像头有镜像功能,手指不能遮挡证件信息,一定要拍清楚,否则 会被驳回!

9.2 在腾讯开放平台创建应用

  • 创建应用

image.png

  • 填写应用资料

网站回调地址填写前端页面地址,再由前端页面请求后端进行账户注册或jwt生成。在这里需要处理一下前端页面的地址,因为这一栏腾讯要求不能以'/'结尾。所以前端页面也不能以'/'结尾。

1
2
3
4
5
6
7
8
{
path: "/user/account/qq/web/receiveCode",
name: "user_account_qq_receive_code",
component: ()=>import('@/views/user/account/UserAccountQQReceiveCodeView'),
meta:{
requestAuth : false
}
},

所以我的地址(后端的redirect_uri与此保持一致):

1
https://smallboat.games/user/account/qq/web/receiveCode

image.png

  • 提供方和网站地址备案号可在:[https://icp.chinaz.com/](https://icp.chinaz.com/)查询。
  • 提交审核后一定要先将QQ登录按钮部署到正式环境,否则会以未摆放QQ登录按钮审批不通过。
  • 审核通过

image.png

9.3 代码实现

9.3.1 前端

  • 在登录页合适位置添加QQ登录按钮:
1
2
3
4
5
6
7
8
9
<div @click="qq_login" style="cursor: pointer; text-align: center; margin-top: 10px;">
<img height="30"
src="https://wiki.connect.qq.com/wp-content/uploads/2013/10/03_qq_symbol-1-250x300.png"
alt="QQ官方图标"/>
<br>
<div style="color: #09e309">
QQ一键登录
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
const qq_login = () => {
$.ajax({
url: "https://smallboat.games/api/user/account/qq/web/applyCode/",
type: "GET",
success: resp => {
if (resp.result === "success") {
window.location.replace(resp.apply_code_url);
}
}
})
}
  • 添加路由(其实上面已经写过
1
2
3
4
5
6
7
8
{
path: "/user/account/qq/web/receiveCode",
name: "user_account_qq_receive_code",
component: ()=>import('@/views/user/account/UserAccountQQReceiveCodeView'),
meta:{
requestAuth : false
}
},
  • 处理QQ登录的页面
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
<template>
<div></div>
</template>

<script>
import router from "@/router/index";
import {useStore} from "vuex";
import {useRoute} from "vue-router";
import $ from 'jquery'
export default {
name: "UserAccountQQReceiveCodeView",
setup() {
const myRoute = useRoute();
const store = useStore();
$.ajax({
url: "https://smallboat.games/api/user/account/qq/web/receiveCode/",
type: "GET",
data: {
code: myRoute.query.code,
state: myRoute.query.state,
},
success: resp => {
if (resp.result === "success") {
localStorage.setItem("jwt", resp.jwt);
store.commit("updateToken", resp.jwt);
router.push({name: "home"});
store.commit("updatePullingInfo", false);
} else {
router.push({name: "user_account_login"});
}
}
})
}
}
</script>

<style scoped>

</style>

9.3.2 后端

后端主要需要实现两个接口:

记得在SecurityConfig中放行这两个接口,因为是在用户未获取jwt时进行访问。

  • @GetMapping("/applyCode/")
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
@Override
public JSONObject applyCode() {
JSONObject resp = new JSONObject();
String encodeUrl = "";
try {
encodeUrl = URLEncoder.encode(REDIRECT_URI, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
resp.put("result", "failed");
return resp;
}

// 随机字符串,防止 csrf 攻击
StringBuilder state = new StringBuilder();
for (int i = 0; i < 10; i ++) {
state.append((char)(random.nextInt(10) + '0'));
}
// 存到redis里,有效期设置为10分钟
resp.put("result", "success");
redisTemplate.opsForValue().set(state.toString(), "true");
redisTemplate.expire(state.toString(), Duration.ofMinutes(10));

String applyCodeUrl = "https://graph.qq.com/oauth2.0/authorize"
+ "?response_type="+"code"
+ "&client_id=" + APP_ID
+ "&redirect_uri=" + encodeUrl
+ "&state=" + state;
resp.put("apply_code_url", applyCodeUrl);

return resp;
}
  • @GetMapping("/receiveCode/")
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Override
public JSONObject receiveCode(String code, String state) {
JSONObject resp = new JSONObject();
resp.put("result", "failed");
if (null == code || null == state) return resp;
if (Boolean.FALSE.equals(redisTemplate.hasKey(state))) return resp;
redisTemplate.delete(state);
// 获取access_token
List<NameValuePair> nameValuePairs = new LinkedList<>();
nameValuePairs.add(new BasicNameValuePair("grant_type", "authorization_code"));
nameValuePairs.add(new BasicNameValuePair("client_id", APP_ID));
nameValuePairs.add(new BasicNameValuePair("client_secret", APP_SECRET));
nameValuePairs.add(new BasicNameValuePair("code", code));
nameValuePairs.add(new BasicNameValuePair("redirect_uri", REDIRECT_URI));
nameValuePairs.add(new BasicNameValuePair("fmt", "json"));

String getString = HttpClientUtil.get(APPLY_ACCESS_TOKEN_URL, nameValuePairs);
if (null == getString) return resp;
JSONObject getResp = JSONObject.parseObject(getString);
String accessToken = getResp.getString("access_token");

// 获取openid
nameValuePairs = new LinkedList<>();
nameValuePairs.add(new BasicNameValuePair("access_token", accessToken));
nameValuePairs.add(new BasicNameValuePair("fmt", "json"));

getString = HttpClientUtil.get(APPLY_USER_OPENID_URL, nameValuePairs);
if(null == getString) return resp;
getResp = JSONObject.parseObject(getString);
String openid = getResp.getString("openid");

if (accessToken == null || openid == null) return resp;

QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("openid_qq", openid);
List<User> users = userMapper.selectList(queryWrapper);

// 用户已经授权,自动登录
if (null != users && !users.isEmpty()) {
User user = users.get(0);
// 生成jwt
String jwt = JwtUtil.createJWT(user.getId().toString());

resp.put("result", "success");
resp.put("jwt", jwt);
return resp;
}

// 新用户授权,获取用户信息,并创建新用户
nameValuePairs = new LinkedList<>();
nameValuePairs.add(new BasicNameValuePair("access_token", accessToken));
nameValuePairs.add(new BasicNameValuePair("openid", openid));
nameValuePairs.add(new BasicNameValuePair("oauth_consumer_key", APP_ID));
getString = HttpClientUtil.get(APPLY_USER_INFO_URL, nameValuePairs);
if (null == getString) return resp;

getResp = JSONObject.parseObject(getString);
String username = getResp.getString("nickname");
// 50*50的头像
String photo = getResp.getString("figureurl_1");

if (null == username || null == photo) return resp;

// 每次循环,用户名重复的概率为上一次的1/10
for (int i = 0; i < 100; i ++) {
QueryWrapper<User> usernameQueryWrapper = new QueryWrapper<>();
usernameQueryWrapper.eq("username", username);
if (userMapper.selectCount(usernameQueryWrapper) == 0) break;
username += (char)(random.nextInt(10) + '0');
if (i == 99) return resp;
}
User user = new User(null, username, null, photo, 1500, null, openid, null);
userMapper.insert(user);
// 生成 jwt
String jwt = JwtUtil.createJWT(user.getId().toString());
resp.put("result", "success");
resp.put("jwt", jwt);
return resp;
}

10. 项目上线

上线流程参考:
项目上线基本流程-CSDN博客
上线成果:
King of Bots
项目github地址:
https://github.com/smallboatc/kob/