MatchingPool使用了ReentrantLock来保证线程安全
重写run方法,等待1s,时常+1,后面会优先匹配
解决突然取消匹配出现的bug(玩家为空),解决bug:加判断语句,每次需要判断userId是否为空即可解决异常.
匹配公式:
对于checkMatch判断两个玩家是否能成功匹配,还要考虑其等待时间,要判断分差能不能小于等于a与b的等待时间的最小值*10即 ratingDelta<=min(waitingTimea,waitingTimeb)∗10
private boolean checkMatched(Player a, Player b) { //判断两名玩家是否匹配
int ratingDelta = Math.abs(a.getRating() - b.getRating());
int waitingTime = Math.min(a.getWaitingTime(), b.getWaitingTime());
return ratingDelta <= waitingTime * 10;
}
实现微服务 Bot Running System
功能:不断接收用户的输入,当接收的代码比较多时,要把代码放到一个队列里(Bot Pool),用队列存储每一个代码任务信息。
本质:生产者消费者模型
生产者发送一个任务过来,我们会把他存到队列里面,
消费者是一个单独的线程,会不断等待任务的到来,每完成一个任务会检查任务队列是否为空,若不为空则从队头取一个任务过来执行,以此为例,循环往复。
特别的,虽然这里的Bot Pool与匹配系统里的Match Pool类似,都是一个单独的线程,但是实现方法与MatchingPool
有所不同。我们Match Pool每次去匹配的时候都是通过不断地sleep1s来让用户等待匹配,这是用户可以接受的。但是若我们Bot Pool里也按照这种方式,则用户在玩游戏的过程中延迟会太高,游戏体验不好,在游戏过程中让用户等待太长时间是无法接受的。因此,我们实现Bot Pool时要改用Condition Variable条件变量。如果空的话就阻塞线程,一旦有消息要处理则发一个信号量唤醒线程!
实现消费者线程Bot Pool
这是一个多线程任务,要继承自Thread
记得重写run函数
定义:锁,条件变量,队列(Bot类)
新建Bot类:userId,botCode,input
队列不需要定义成线程安全的队列,普通队列即可,我们可以通过加锁与解锁来维护他的安全性
涉及到读写冲突的都要先加锁再工作后面再解锁
Queue涉及到两边的操作,一边是生产者给他不断加入任务,另一边是消费者不断取出任务,因此要先上锁后解锁
有关Queue的都要想到锁
在启动springboot前启动线程BotPool: BotRunningServiceImpl.botPool.start();
线程有关:每次start()后会开一个新的线程执行run()里面的内容
使用joor包动态执行Java代码,上线到云端可以更改为docker容器,并且增加C++,python,java代码.
在执行遇到类名相同时的解决方案,解决方案:在每一个bot名称前面加上UUID,保证id不唯一
整体架构图
整个项目的思路:
简单来说就是我们在前端把匹配信息传到ws后端服务器——> 再传到Matching System服务器——>把玩家放到匹配池去匹配——>把匹配成功信息再返回给ws后端服务器——>ws后端服务器会调用Game——>Game里面会Create Map产生对战地图——>玩家可以开始玩游戏(bot or yourself)——>把每一步信息传到Next Step判断是否合法——>若是bot玩则把每一步信息传到微服务Bot Running System将代码跑一遍(放到Bot Pool里)——>consumer(bot)函数运行代码(通过joor)——> 返回结果给ws端——> 最后判断对局结果
Bot代码Java模板
package com.kob.botrunningsystem.utils;
import java.util.ArrayList;
import java.util.List;
public class Bot implements BotInterface {
static class Cell {
public int x, y;
public Cell(int x, int y) {
this.x = x;
this.y = y;
}
}
// 检查当前回合,蛇的长度是否会增加
private boolean check_tail_increasing(int step) {
if(step <= 10) return true;
return step % 3 == 1;
}
public List<Cell> getCells(int sx, int sy, String steps) {
steps = steps.substring(1, steps.length() - 1);
List<Cell> res = new ArrayList<>();
int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
int x = sx, y = sy;
int step = 0;
res.add(new Cell(x, y));
for(int i = 0; i < steps.length(); i++) {
int d = steps.charAt(i) - '0';
x += dx[d];
y += dy[d];
res.add(new Cell(x, y));
if(!check_tail_increasing(++step)) {
res.remove(0);
}
}
return res;
}
@Override
public Integer nextMove(String input) {
// 地图#my.sx#my.sy#(my操作)#you.sx#you.sy#(you操作)
String[] strs = input.split("#");
int[][] g = new int[13][14];
for(int i = 0, k = 0; i < 13; i++) {
for(int j = 0; j < 14; j++, k++) {
if(strs[0].charAt(k) == '1') {
g[i][j] = 1;
}
}
}
int aSx = Integer.parseInt(strs[1]), aSy = Integer.parseInt(strs[2]);
int bSx = Integer.parseInt(strs[4]), bSy = Integer.parseInt(strs[5]);
List<Cell> aCells = getCells(aSx, aSy, strs[3]);
List<Cell> bCells = getCells(bSx, bSy, strs[6]);
for(Cell c : aCells) g[c.x][c.y] = 1;
for(Cell c : bCells) g[c.x][c.y] = 1;
int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
for(int i = 0; i < 4; i++) {
int x = aCells.get(aCells.size() - 1).x + dx[i];
int y = aCells.get(aCells.size() - 1).y + dy[i];
if(x >= 0 && x < 13 && y >= 0 && y < 14 && g[x][y] == 0) {
return i;
}
}
return 0;
}
}
websocket:
1.地图需要在服务端生成地图,然后发送给两个玩家(解决同步问题)
2.判断蛇的输赢也需要在服务端同意完成,防止作弊。
3.每次启动一个websocke连接时,如果切换到其他页面需要自动关闭连接,否则就会建立多余的冗余链接
4.使用user.token的JWT验证
再来一局的业务逻辑:判断输赢的逻辑(none ,all,A,B)如果等于none就不显示页面,如果等于all,平局
增加录像功能:将对局的回放放在对局列表(查看回放)
2.登录注册模块 + bot增删改查
后端
- spring-security
- 配置
- 实现UserDetailsService类
- 实现UserDetails
- JwtAuthenticationTokenFilter
- SecurityConfig
- 配置
- Account 登录注册
- Bot 增删改查
- @Validated + 实体类传参 注意导入依赖
前端
- 集成登录注册页面
- 刷新不修改登录状态
3.匹配系统
- 前后端通信
- 两个用户的一局游戏单独开一个线程实现: Game
- 游戏玩法是回合制, 所以需要lock的信息就是两名玩家的下一步 nextStep
- Game中所有涉及nextStep变量的地方都需要加锁
lock.lock();
try{
// do someting
}finally{
lock.unlock();
}
从run方法进行
整体分析
- run方法中for循环1000次:
因为13 * 14的地图, 三步增长一次, 最多大概600步, 这里循环1000次保证正确
- nextStep函数, 返回一个boolean, 表示获取下一步成功或失败
- 进入直接sleep 200ms :
前端1s走5格. 200ms走1格, 这里保证了前端画完再去读取下一步操作
- for循环50次, 每次sleep100ms :
总时间5s, 如果5s内没有输入, 则判断获取下一步失败, 标记为finished
- sleep 100ms :
1s -> 100ms, 优化用户体验
- 进入直接sleep 200ms :
- 获取下一步成功
- 将前端**
judge函数
**放到后端 - judge后,
- 两条蛇运动都是正常的:
sendMove函数
- 否则,
sendResult函数
- 两条蛇运动都是正常的:
- 将前端**
- 获取下一步失败
- 结束游戏:
修改游戏状态
- 通过nextStep判断平局, A输, B输
- 结束游戏:
- run方法中for循环1000次:
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
if (nextStep()) { // 是否获取两条蛇下一操作
judge();
if (status.equals("playing")) {
sendMove();
} else {
sendResult();
break;
}
} else {
status = "finished";
lock.lock();
try {
if (nextStepA == null && nextStepB == null) {
loser = "all";
} else if (nextStepA == null) {
loser = "A";
} else {
loser = "B";
}
} finally {
lock.unlock();
}
sendResult();
break;
}
}
}
- 信息交互
- 发送
- sendMove : 获取到两名玩家的nextStep ->封装信息 -> 通过WebSocketServer中的sendMessage发送给客户端
- sendResult : 封装信息 -> 通过WebSocketServer中的sendMessage发送给客户端
- 接收信息
- onMessage: 通过event处理对应信息
- 发送
新建微服务 - MatchSystem : 实现通过分值匹配玩家
创建匹配池
包含参数
- players:
池中的玩家
- lock :
3000服务会通过路由向匹配池添加/删除玩家, 匹配池中也会对玩家进程读写操作, 所以需要加锁控制
- RestTemplate :
发送请求需要的类
- players:
添加addPlayer, removePlayer方法 :
使用lock.lock try{ .. }finally{lock.unlock} 控制
实现匹配策略:
通过分值 + 时间匹配, 每增加一秒分值差距提升10
- increaseWaitingTime :
增加所有人的等待时间
- matchPlayers:
匹配玩家
- increaseWaitingTime :
匹配池run函数
- while(true) + sleep(1000):
实现每隔一秒匹配一次
- lock.lock :
increaseWaitingTime与matchPlayers都对player有操作, 需要加锁
- while(true) + sleep(1000):
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
lock.lock();
try {
increaseWaitingTime();
matchPlayers();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
- MatchSystem添加API:
addPlayer与removePlayer
供3000服务添加与移除匹配玩家 - 3000服务接收匹配系统匹配玩家信息
public String startGame(Integer aId, Integer aBotId, Integer bId, Integer bBotId) {
System.out.println("start game: " + aId + " " + bId);
WebSocketServer.startGame(aId, aBotId, bId, bBotId);
return "start game success";
}
4.Bot代码执行
修改前端, 传递bot信息
- 传递路径:
前端选择人或Bot开始匹配 ->
3000服务websocket中startMatching函数 ->
匹配系统添加玩家 ->
匹配池添加玩家(Player类添加botId信息), 进行匹配 ->
匹配成功, 发送信息添加BotId ->
3000服务接收匹配系统传递的数据, 调用startGame(添加botId参数) ->
Game类中添加相应玩家的bot信息, 在nextStep中判断是人工操作还是机器人操作向BotRunning服务发送信息
添加BotRunning服务
设计:
Bot池:
单独的线程, 存储3000服务发送的bot信息, 使用自制消息队列控制池中bot
- run方法中循环方式:
只有当队列不为空时, 去执行相应方法, 其他时间阻塞; 使用Condition进行控制
- run方法中循环方式:
Consumer:
单独的线程, 用来执行Bot代码
Controller + Service :
提供相应的接口添加Bot信息
Bot池:
生产者消费者模型, 在对bot的操作时需要加锁, 因为涉及多个线程
addBot方法:
提供给外界添加任务的方法
- condition.signalAll(): 当有任务进来时, 唤醒所有线程即当前阻塞的BOT_POOL, 会自己释放锁
consume:
消费bot, 即开启线程去执行Bot代码
run方法:
- 池为空时:
condition.await(), 释放当前锁, 阻塞当前线程; 异常需要手动释放锁
- 不为空:
拿出bot并进行消费, consume; 先释放锁再去消费, 因为执行代码比较耗时
- 池为空时:
Consumer:
执行代码, 单独开启线程
startTimeout(timeout, bot):
设置代码执行最长时间对线程进行控制, 当超出时间或者执行完毕中断当前线程
- 进来开启线程this.start(), 设置bot信息
- 如何进行控制:
join(timeout)方法: 线程执行完毕或timeout时间后, 执行join后面的代码(this.interrpt())
run方法:
执行代码
- Reflect.compile(“package name”, “code”).create.get(); :
需要保证类名不一致, 即在类名后添加随机Id
- 生成的实例去执行接口响应的方法:
nextMove(当前局面)
, 将返回值发送给3000服务
- Reflect.compile(“package name”, “code”).create.get(); :
3000服务接收下一步信息
- 我们已经中断了从前端获取输入进行移动, 需要重新调用之前进行移动的方法
game.setNextStepA(direction);
- 我们已经中断了从前端获取输入进行移动, 需要重新调用之前进行移动的方法
游戏完整的流程
- client1, client2点击开始匹配
- 3000服务通过websocket接受玩家信息, 发送给matching服务
- matching服务匹配池接收3000服务发送的玩家信息, 通过相应的策略匹配两名玩家, 发送给3000服务
- 3000服务接收对战玩家信息, 开启游戏startGame
- startGame创建Game线程(创建地图即相关信息), 通过nextStep获取输入
- nextStep
- 用户手动输入
- 判断输入合法性
- 合法: 发送信息给前端, 继续获取下一步输入
- 不合法: 结束游戏, 判断输赢
- 判断输入合法性
- Bot执行
- 发送bot信息给BotRunning服务
- BotRunning服务通过BOT_POOL接收bot信息进行处理
- Consumer消费bot, 生成下一步走向
- 发送给3000服务
- 判断输入合法性
- 合法: 发送信息给前端, 继续获取下一步输入
- 不合法: 结束游戏, 判断输赢
- 判断输入合法性
- 用户手动输入