King of Bots项目总结

1. 项目规划

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

在这里插入图片描述

  • 技术栈:
技术说明
SpringBoot容器+MVC框架
mysql关系型数据库
JWT登录支持
SpringSecurity验证和授权框架
Redis缓存数据库
Lombok简化对象封装工具
MyBatisPlusORM框架
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进行跳转。
通过如下方式实时计算当前页面位于哪个部分,以便于高亮导航栏中的对应部分:

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 × 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 创建蛇类

image.png在创建蛇类前,先要创建蛇的身体类(即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>
  1. 实现JwtUtil类,用于生成和解析jwt
  2. 实现JwtAuthenticationTokenFilter类,用于验证用户传递过来的jwt,验证成功后,当前用户的信息将会被注入上下文中。
  3. 修改SecurityConfig放行tokenregister请求。
@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中。

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)
}

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

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

updatePullingInfo(state, pulling_info) {
    state.pulling_info = pulling_info
}

5. 个人中心(我的Bot)

5.1 Bot的CRUD(后端)

BotCRUD是一些比较重复性的工作,此处以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页面:

image.png

  • 创建Bot的Modal框:

image.png

  • 修改Bot的Modal框:

image.png

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

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

6.1 后端(backend)集成WebSocket

  1. 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>
  1. 添加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();
    }
}
  1. 添加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();
    }
}

  1. 修改SecurityConfig放行"/websocket/**"请求:
@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/websocket/**");
}
  1. 将前端生成地图的逻辑放到后端:
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 表示对战界面)。
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页面。之前该页面只有游戏地图,现在开始时需要先匹配,匹配成功后,需要将匹配界面关掉,切换为游戏地图页面。
<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.pngimage.png

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

新建一个backendCloud maven项目,引入springcloud依赖,在该项目下面新增两个模块(backend, matchingsystem),将先前的backend复制过来,引入并配置restTemplate
匹配系统思路: 每次有匹配请求,backend发送添加用户请求到matchingsystemmatchingsystem将该用户添加到待匹配列表中。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);
    }
}
  • 一旦匹配成功一对玩家,就会调用MatchingPoolsendResult方法通知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>
  • 配置restTemplatesecurity
@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;
    }
}

其中,在对战列表中的查看录像功能,实现原理为:将数据库中记录的地图信息、用户双方的操作信息取出,在游戏地图中重新模拟一遍即可。
对局列表:
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生成。在这里需要处理一下前端页面的地址,因为这一栏腾讯要求不能以'/'结尾。所以前端页面也不能以'/'结尾。

{
  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

image.png

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

审核通过可能需要两三天的时间。
image.png

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. 项目上线

  • 38
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值