联机对战平台


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不唯一

整个匹配系统

Bot代码运行系统

整体架构图

608c69b9dc7f8d616e5546bc45db894.jpg

整个项目的思路:

简单来说就是我们在前端把匹配信息传到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.匹配系统

  1. 前后端通信
  • 两个用户的一局游戏单独开一个线程实现: 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, 优化用户体验
    • 获取下一步成功
      • 将前端**judge函数**放到后端
      • judge后,
        • 两条蛇运动都是正常的: sendMove函数
        • 否则, sendResult函数
    • 获取下一步失败
      • 结束游戏: 修改游戏状态
      • 通过nextStep判断平局, A输, B输
@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 : 发送请求需要的类
    • 添加addPlayer, removePlayer方法 : 使用lock.lock try{ .. }finally{lock.unlock} 控制

    • 实现匹配策略:

      通过分值 + 时间匹配, 每增加一秒分值差距提升10
      • increaseWaitingTime : 增加所有人的等待时间
      • matchPlayers: 匹配玩家
  • 匹配池run函数

    • while(true) + sleep(1000): 实现每隔一秒匹配一次
    • lock.lock : increaseWaitingTime与matchPlayers都对player有操作, 需要加锁
@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进行控制
    • 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服务
  • 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服务
        • 判断输入合法性
          • 合法: 发送信息给前端, 继续获取下一步输入
          • 不合法: 结束游戏, 判断输赢

实现第三方登录(qq)

qq登录网址链接

在线项目链接


  目录