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
进行跳转。
通过如下方式实时计算当前页面位于哪个部分,以便于高亮导航栏中的对应部分:
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 \times 14 13×14 的布局,并确保其是中心对称的。
设计成 13 × 14 13 \times 14 13×14 主要是为了避免两条蛇头能同时到达一个点的情况(平局),避免造成对优势方不利的情况。
解释:若设计为 13 × 13 13 \times 13 13×13,则刚开始两条蛇的蛇头坐标为(1, 13), (13, 1)
,双方每走一步,横纵坐标之和的变化都是相同的(偶、奇、偶、奇……),设计成 13 × 14 13 \times 14 13×14 则刚开始两蛇头的坐标为(1, 14), (13, 1)
,则双方每走一步横纵坐标之和不可能相等,意味着双方的蛇头不可能在同一时间进入同一个格子。
3.3 创建蛇类
在创建蛇类前,先要创建蛇的身体类(即Cell
类):
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
有圆弧,看起来会比较正常。
在移动时,通过蛇类中的函数设置当前步的前进方向,然后在地图类中进行监听用户操作(后续接入代码操作后,也将调用此函数设置蛇蛇的移动方向):
set_direction(d) {
this.direction = d;
}
然后判断蛇的存活状态(在地图类中进行判断,而不是在蛇类中判断):
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
文件中添加如下依赖:
<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
相关配置如下:
# 配置数据库连接
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
文件中引入一下依赖:
<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
请求。
@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
中。
localStorage.setItem("jwt", resp.token)
然后在每次路由跳转到登录页时,闲取出localStorage
中的jwt
信息,然后发送获取用户信息的请求以校验当前的jwt
是否过期。
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
用于控制当前是否处于获取用户信息状态(避免此时登录页会闪一下的问题),当用户信息拉取完毕时,成功就跳转首页,失败就正常显示登录页。
updatePullingInfo(state, pulling_info) {
state.pulling_info = pulling_info
}
5. 个人中心(我的Bot)
5.1 Bot的CRUD(后端)
Bot
的CRUD
是一些比较重复性的工作,此处以add
举例。
@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页面:
- 创建Bot的
Modal
框:
- 修改Bot的
Modal
框:
注意:在引入
ace
代码编辑器时,在调试时可能会因为浏览器的问题而导致代码高亮、自动提示等出现不符合预期的问题,切换浏览器尝试一下。
6. 微服务:实现匹配系统
6.1 后端(backend)集成WebSocket
- 在
pom.xml
文件中添加依赖:
<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
配置类:
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
类(核心功能):
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();
}
}
- 修改
SecurityConfig
放行"/websocket/**"
请求:
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
- 将前端生成地图的逻辑放到后端:
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 前端实现匹配界面
- 前端实现
pk
类,在其中维护当前一次的pk
需要的变量,其中利用status
变量动态切换匹配和游戏界面(status: "matching", // matching 表示匹配界面,playing 表示对战界面
)。
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:
- 玩家2:
- 匹配成功:
- 实现
PkIndexView
页面。之前该页面只有游戏地图,现在开始时需要先匹配,匹配成功后,需要将匹配界面关掉,切换为游戏地图页面。
<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
函数调用匹配系统进行匹配:
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
类核心代码:
@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
线程:
@SpringBootApplication
public class MatchingSystemApplication {
public static void main(String[] args) {
MatchingServiceImpl.matchingPool.start();
SpringApplication.run(MatchingSystemApplication.class, args);
}
}
- 一旦匹配成功一对玩家,就会调用
MatchingPool
的sendResult
方法通知backend
以创建当前游戏:
// 返回匹配成功结果
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
以便匹配系统完成匹配时进行调用以开始游戏:
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
中的核心代码如下:
@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)
- 添加依赖:
<dependency>
<groupId>org.jooq</groupId>
<artifactId>joor-java-8</artifactId>
<version>0.9.14</version>
</dependency>
- 配置
restTemplate
与security
:
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate getRestTemplate() {
return new RestTemplate();
}
}
@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
:
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
的核心代码(这里相当于手动实现了一个消息队列):
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
的执行时间:
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
中的核心代码:
@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
自带的分页工具即可。
分页配置:
@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
并返回前端,如果不存在,则说明是第一次申请user_info
,再存入数据库并生成jwt
返回。
官方授权流程图(官方教程):
9.1 前往腾讯开放平台完成资料审核
- 先前往腾讯开放平台进行开发者人脸识别校验(腾讯开放平台)
需要先QQ登录,然后点击右上角的账户管理,根据提示微信扫码完成人脸识别校验。
- QQ互联进行开发者资料审核(开发者资料审核)
注:手持照片得用后置摄像头拍摄,前置摄像头有镜像功能,手指不能遮挡证件信息,一定要拍清楚,否则 会被驳回!
9.2 在腾讯开放平台创建应用
- 创建应用
- 填写应用资料
网站回调地址填写前端页面地址,再由前端页面请求后端进行账户注册或
jwt
生成。在这里需要处理一下前端页面的地址,因为这一栏腾讯要求不能以'/'
结尾。所以前端页面也不能以'/'
结尾。
{
path: "/user/account/qq/web/receiveCode",
name: "user_account_qq_receive_code",
component: ()=>import('@/views/user/account/UserAccountQQReceiveCodeView'),
meta:{
requestAuth : false
}
},
所以我的地址(后端的redirect_uri
与此保持一致):
https://app5163.acapp.acwing.com.cn/user/account/qq/web/receiveCode
- 提供方和网站地址备案号可在:
[https://icp.chinaz.com/](https://icp.chinaz.com/)
查询。- 提交审核后一定要先将
未摆放QQ登录按钮
审批不通过。
- 审核通过
审核通过可能需要两三天的时间。
9.3 代码实现
9.3.1 前端
- 在登录页合适位置添加
QQ
登录按钮:
<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>
const qq_login = () => {
$.ajax({
url: "https://app5163.acapp.acwing.com.cn/api/user/account/qq/web/applyCode/",
type: "GET",
success: resp => {
if (resp.result === "success") {
window.location.replace(resp.apply_code_url);
}
}
})
}
- 添加路由(
其实上面已经写过)
{
path: "/user/account/qq/web/receiveCode",
name: "user_account_qq_receive_code",
component: ()=>import('@/views/user/account/UserAccountQQReceiveCodeView'),
meta:{
requestAuth : false
}
},
- 处理
QQ
登录的页面
<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://app5163.acapp.acwing.com.cn/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/")
@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/")
@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");
// 40 * 40 的头像
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. 项目上线
-
上线流程参考:
项目上线基本流程 -
上线成果:
King of Bots 网页版 -
项目
github
地址:
King of Bots github