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环境
- 安装
Git Bash
:https://gitforwindows.org/
- 进入家目录生成秘钥:执行命令
ssh-keygen
- 将
id_rsa.pub
的内容复制到github
上
2.3 创建项目前后端
- 后端:使用
Spring Initilizr
创建后端,使用2.3.7REALESE
版本,添加SpringBoot Web Starter
插件。
- 前端:使用
Vue cli
脚手架创建项目,添加Vue Router
和VueX
插件,添加BootStrap
与jquery
依赖。(Vue ui
创建前端有点奇怪的bug
,选择位置时不选择默认盘符下的位置就报错,先在该盘符创建再移动到目标盘符去)
3. 创建前端基础页面
3.1 创建导航栏及页面
通过创建各个页面的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 创建游戏地图
首先创建游戏地图基类AcGameObject
,然后通过继承该类实现GameMap
游戏地图渲染类。
- 在
AcGameObject
中,通过requestAnimationFrame(step)
递归实现每60
帧(因显示器而异)的实时渲染游戏画面。
- 在
GameMap
中,先将游戏背景(绿布)渲染出来,然后创建四周墙壁,再随机生成内部墙体障碍物,每生成一种方案,通过Flood Fill
算法检验左下角与右上角的连通性,如果不连通则重新生成。
注意: 在后期会将生成地图的逻辑放到后端(前端只负责渲染,暂时放在前端便于当前调试),避免两名用户中有人修改前端代码造成不公平的情况。与此同时,生成的地图为13×14的布局,并确保其是中心对称的。
设计成13×14主要是为了避免两条蛇头能同时到达一个点的情况(平局),避免造成对优势方不利的情况。
解释:若设计为13×13,则刚开始两条蛇的蛇头坐标为(1, 13), (13, 1)
,双方每走一步,横纵坐标之和的变化都是相同的(偶、奇、偶、奇……),设计成13×14则刚开始两蛇头的坐标为(1, 14), (13, 1)
,则双方每走一步横纵坐标之和不可能相等,意味着双方的蛇头不可能在同一时间进入同一个格子。
3.3 创建蛇类
在创建蛇类前,先要创建蛇的身体类(即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>
|
- 实现
JwtUtil
类,用于生成和解析jwt
。
- 实现
JwtAuthenticationTokenFilter
类,用于验证用户传递过来的jwt
,验证成功后,当前用户的信息将会被注入上下文中。
- 修改
SecurityConfig
放行token
及register
请求。
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 前端注册页面实现
注册页面与登录页面极其类似,直接复制过来修改一下即可。
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) }
|
如果成功,则路由直接跳转到首页,否则显示登录页让用户重新登录。
其中,updatePullingInfo
是user.js
中的用于修改全局变量pulling_info
的函数,pulling_info
用于控制当前是否处于获取用户信息状态(避免此时登录页会闪一下的问题),当用户信息拉取完毕时,成功就跳转首页,失败就正常显示登录页。
1 2 3
| updatePullingInfo(state, pulling_info) { state.pulling_info = pulling_info }
|
5. 个人中心(我的Bot)
5.1 Bot的CRUD(后端)
Bot
的CRUD
是一些比较重复性的工作,此处以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; }
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
代码编辑器。
注意:在引入ace
代码编辑器时,在调试时可能会因为浏览器的问题而导致代码高亮、自动提示等出现不符合预期的问题,切换浏览器尝试一下。
6. 微服务:实现匹配系统
6.1 后端(backend)集成WebSocket
- 在
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>
|
- 添加
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(); } }
|
- 添加
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) { 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) { synchronized (this.session) { try { this.session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }
|
- 修改
SecurityConfig
放行"/websocket/**"
请求:
1 2 3 4
| @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/websocket/**"); }
|
- 将前端生成地图的逻辑放到后端:
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;
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; }
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() { for (int i = 0; i < 1000; i ++) { if (draw()) { break; } } } }
|
6.2 前端实现匹配界面
- 前端实现
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", 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:
玩家2:
匹配成功:
- 实现
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
即可。
匹配成功后,双方的地图均是从后端获取,因此实现了两名玩家的地图一致性。
6.3 实现匹配系统的微服务(matchingsystem)
新建一个backendCloud
maven
项目,引入springcloud
依赖,在该项目下面新增两个模块(backend, matchingsystem
),将先前的backend
复制过来,引入并配置restTemplate
。
匹配系统思路: 每次有匹配请求,backend
发送添加用户请求到matchingsystem
,matchingsystem
将该用户添加到待匹配列表中。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());
restTemplate.postForObject(ADD_PLAYER_URL, playerData, String.class); }
|
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); } }
|
- 一旦匹配成功一对玩家,就会调用
MatchingPool
的sendResult
方法通知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()); 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); 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); 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>
|
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)); 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 { condition.await(); } catch (InterruptedException e) { e.printStackTrace(); break; } finally { lock.unlock(); } } else { Bot bot = bots.poll(); lock.unlock(); 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 { 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(); 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); } Integer direction = botInterface.get(); 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; } }
|
其中,在对战列表中的查看录像
功能,实现原理为:将数据库中记录的地图信息、用户双方的操作信息取出,在游戏地图中重新模拟一遍即可。
对局列表:
排行榜:
9. 实现QQ三方登录
基本思路: 通过访问后端的@GetMapping("/applyCode/")
接口获取apply_code
,同时后端生成了state
存入redis
中。然后前端再调用后端的@GetMapping("/receiveCode/")
接口。在该接口中,会拿着state
与redis
中的进行校验,然后获取access_token
,拿到access_token
之后获取用户的openid
,然后判断该openid
是否已经存在,如果存在直接生成jwt
并返回前端,如果不存在,则说明是第一次申请QQ
登录,需要获取user_info
,再存入数据库并生成jwt
返回。
官方授权流程图(官方教程):
9.1 前往腾讯开放平台完成资料审核
需要先QQ登录,然后点击右上角的账户管理,根据提示微信扫码完成人脸识别校验。
注:手持照片得用后置摄像头拍摄,前置摄像头有镜像功能,手指不能遮挡证件信息,一定要拍清楚,否则 会被驳回!
9.2 在腾讯开放平台创建应用
网站回调地址填写前端页面地址,再由前端页面请求后端进行账户注册或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
|
- 提供方和网站地址备案号可在:
[https://icp.chinaz.com/](https://icp.chinaz.com/)
查询。
- 提交审核后一定要先将
QQ
登录按钮部署到正式环境,否则会以未摆放QQ登录按钮
审批不通过。
9.3 代码实现
9.3.1 前端
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 } },
|
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; }
StringBuilder state = new StringBuilder(); for (int i = 0; i < 10; i ++) { state.append((char)(random.nextInt(10) + '0')); } 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); 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");
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); 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"); String photo = getResp.getString("figureurl_1");
if (null == username || null == photo) return resp;
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); 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/