对战五子棋——网页版

目录

一、项目简介

二、用户模块 

1、创建用户实体类

2、编写userMapper接口文件 

3、实现userMapper.xml文件 

4、对用户密码进行加密

5、实现用户登录功能

6、实现用户注册功能 

三、实现用户匹配模块

1、展示用户个人信息 

2、匹配请求类 

3、匹配响应类 

4、创建用户管理类 

5、创建房间类 

6、实现房间管理类 

5、实现匹配器类 

6、实现匹配处理类 

处理连接请求 

处理开始匹配/取消匹配请求

处理连接关闭

处理连接异常 

四、实现五子棋对战模块 

1、前端绘制棋盘和棋子

2、前端初始化 websocket

3、处理落子请求

4、处理落子响应 

5、定义落子请求类

6、定义落子响应类 

7、实现对战功能 

8、实现胜负判定 

9、实现游戏处理类 

处理连接成功

通知玩家就绪 

处理落子请求 

通知另一个玩家获胜

处理玩家下线 以及 连接出错

五、页面展示 


一、项目简介

实现一个网页版的五子棋对战程序,用户可以进行注册,注册完成后登录进入游戏大厅,会显示用户的天梯分数记录以及比赛场次的记录,会根据用户的天梯分数实现匹配机制,实现两个玩家在网页端进行五子棋的对战。

二、用户模块 

1、创建用户实体类

@Data
public class User {
    private int userId; //用户id
    private String username; //用户名
    private String password; //用户密码
    private int score; //用户分数
    private int totalCount; //用户的比赛场次
    private int winCount; //用户的获胜场次
}

2、编写userMapper接口文件 

此处主要提供四个方法:

  • selectByName: 根据用户名查找用户信息. 用于实现登录.
  • insert: 新增用户. 用户实现注册.
  • userWin: 用于给获胜玩家修改分数.
  • userLose: 用户给失败玩家修改分数.
@Mapper
public interface UserMapper {
    // 插入用户. 用于注册功能,返回受影响的行数
    int insert(User user);
    // 根据用户名, 来查询用户的详细信息. 用于登录功能
    User selectByName(String username);
    // 总比赛场数 + 1, 获胜场数 + 1, 天梯分数 + 30
    void userWin(int userId);
    // 总比赛场数 + 1, 获胜场数 不变, 天梯分数 - 30
    void userLose(int userId);
}

3、实现userMapper.xml文件 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.gobang.mapper.UserMapper">
    <insert id="insert">
        insert into user(username,password,score,totalCount,winCount) values(#{username},#{password},1000,0,0);
    </insert>
    <update id="userWin">
        update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30
        where userId = #{userId}
    </update>

    <update id="userLose">
        update user set totalCount = totalCount + 1, score = score - 30
        where userId = #{userId}
    </update>
    <select id="selectByName" resultType="com.example.gobang.model.User">
        select * from user where username = #{username}
    </select>
</mapper>

4、对用户密码进行加密

为了保证用户登录的安全性,使用MD5加盐的方式对用户的的密码进行加密和解密操作。

public class PasswordUtil {
    /*加密操作*/
    public static String encryption(String password){
        String salt = IdUtil.simpleUUID();//生成随机的32位盐值
        String midpwd = SecureUtil.md5(salt+password);
        return salt+"#"+midpwd;//方便解密
    }
    /*解密:判断密码是否相同,并不能得到解密后的密码*/
    public static boolean decrypt(String password,String truePassword){
        if(StringUtils.hasLength(password) && StringUtils.hasLength(truePassword)){
            if(truePassword.length() == 65 && truePassword.contains("#")){
                String[] pwd = truePassword.split("#");
                String salt = pwd[0];//得到盐值
                String midPassword = pwd[1];//得到盐值+密码使用md5加密后的密码
                password = SecureUtil.md5(salt+password);
                if(password.equals(midPassword)){
                    return true;
                }
            }
        }
        return false;
    }
}

5、实现用户登录功能

实现用户登录功能,首先需要确定好前后端交互的接口:

请求:

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=Jake&password=123

响应:

HTTP/1.1 200 OK
Content-Type: application/json

{
    userId: 1,
    username: 'Jake',
    score: 1000,
    totalCount: 10,
    winCount: 5
}    
如果登录失败, 返回的是一个 userId 为 0 的对象

后端代码实现:

@RequestMapping("/login")
    public Object login(String username, String password, HttpServletRequest req) {
        if(!StringUtils.hasLength(username)){
            return null;
        }
        // 根据username找到匹配的用户, 并且密码也一致, 就认为登录成功
        User user = userService.selectByName(username);
        if (user == null || !PasswordUtil.decrypt(password, user.getPassword())) {
            // 登录失败;
            return null;
        }
        HttpSession httpSession = req.getSession(true);
        httpSession.setAttribute("user", user);
        return user;
    }

前端代码实现:

<script>
    function login(){
        //对登录名和密码进行非空校验
        var username = jQuery("#username");
        var password = jQuery("#password");
        //对首部去空格后进行非空校验
        if(jQuery.trim(username.val()) === ""){
            alert("请输入登录名");
            //清除原有数据,将光标定位到输入框起始位置
            username.focus();
            return;
        }
        if(jQuery.trim(password.val()) === ""){
            alert("请输入密码");
            password.focus();
            return;
        }
        jQuery.ajax({
            url:"login",
            type:"GET",
            data:{"username":username.val(),"password":password.val()},
            success:function (user){
                if(user && user.userId > 0){
                    //登录成功,进入游戏大厅页面
                    location.href = "game_hall.html";
                }else{
                    alert("用户名或密码输入错误");
                }
            }

        });
    }
</script>

6、实现用户注册功能 

 对于用户注册功能,同样也需要确定好前后端交互的接口:

请求:

POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=zhangsan&password=123

响应:

HTTP/1.1 200 OK
Content-Type: application/json

{
    userId: 1,
    username: 'zhangsan',
    score: 1000,
    totalCount: 10,
    winCount: 5
}    
如果注册失败(比如用户名重复), 返回的是一个 userId 为 0 的对象.

后端代码实现: 

@RequestMapping("/register")
    public int register(String username, String password) {
        if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)){
            return -1;
        }
        if(userService.selectByName(username) != null){
            return 0;
        }
        //将存入数据库中的密码进行加密
        password = PasswordUtil.encryption(password);
        User user = new User();
        user.setUsername(username);
        user.setPassword(password);
        //将用户存入到数据库
        if(userService.insert(user) > 0){
            return 1;
        }
        return -1;
    }

前端代码实现:

<script>
    //进行注册操作
    function reg(){
        var username = jQuery("#username");
        var password = jQuery("#password");
        var password2 = jQuery("#password2");
        //非空校验
        if(jQuery.trim(username.val()) === ""){
            alert("请先输入用户名");
            username.focus();
            return false;
        }

        if(jQuery.trim(password.val()) === ""){
            alert("请先输入密码");
            password.focus();
            return false;
        }
        if(jQuery.trim(password2.val()) === ""){
            alert("请先输入确认密码");
            password2.focus();
            return false;
        }
        if(password.val() !== password2.val()){
            alert("两次密码输入不一致,请重新输入");
            password.focus();
            password2.focus();
            return false;
        }
        jQuery.ajax({
            url:"register",
            type:"POST",
            data:{
                "username":username.val(),
                "password":password.val(),
            },
            success:function(result){
                if(result === 1){
                    alert("注册成功!");
                    location.href = "login.html";
                }else if(result === 0){
                    alert("用户名已存在")
                }else if (result === -1) {
                    alert("注册失败")
                }
            }
        });
    }
</script>

三、实现用户匹配模块

对于用户匹配模块,首先也要确定好前后交互的接口:

请求:

{
    message: 'startMatch' / 'stopMatch',
}

响应: (匹配成功后的响应)

{
    ok: true,                // 是否成功. 比如用户 id 不存在, 则返回 false
    reason: '',                // 错误原因
    message: 'matchSuccess',    
}

注意:

  • 页面这端拿到匹配响应之后, 就跳转到游戏房间.
  • 如果返回的响应 ok 为 false, 则弹框的方式显示错误原因, 并跳转到登录页面.

1、展示用户个人信息 

用户登录成功之后才会跳转到用户匹配模块 ,在用户匹配模块首先需要展示用户的个人信息。

前端代码实现:

<script>
    $.ajax({
        method: 'get',
        url: '/userInfo',
        success: function(data) {
            let screen = document.querySelector('#screen');
            screen.innerHTML = '玩家: ' + data.username + ', 分数: ' + data.score + "<br> 比赛场次: " + data.totalCount + ", 获胜场次: " + data.winCount;
        }
    });
</script>

后端代码实现: 

@RequestMapping("/userInfo")
    public Object getUserInfo(HttpServletRequest req) {
        HttpSession httpSession = req.getSession(false);
        User user = (User) httpSession.getAttribute("user");
        // 根据username来查询
        User newUser = userService.selectByName(user.getUsername());
        return newUser;
    }

2、匹配请求类 

@Data
public class MatchRequest {
    private String message = ""; //匹配请求信息

}

3、匹配响应类 

@Data
public class MatchResponse {
    private boolean ok; //是否响应成功
    private String reason; //响应失败原因
    private String message; //响应信息
}

4、创建用户管理类 

创建用户管理类, 用于管理当前用户的在线状态. 本质上是 哈希表 的结构. key 为用户 id, value 为用户的 WebSocketSession.

借助这个类, 一方面可以判定用户是否是在线, 同时也可以进行方便的获取到 Session 从而给客户端回话.

  • 当玩家建立好 websocket 连接, 则将键值对加入 OnlineUserManager 中.
  • 当玩家断开 websocket 连接, 则将键值对从 OnlineUserManager 中删除.
  • 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客户端返回数据.

由于在游戏大厅以及游戏房间页面都需要用到用户管理,所以使用两个哈希表 来分别存储两部分的会话.

@Component
public class OnlineUserManager {
    // 表示当前用户在游戏大厅在线状态.
    private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
    // 表示当前用户在游戏房间的在线状态.
    private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();

    public void enterGameHall(int userId, WebSocketSession webSocketSession) {
        gameHall.put(userId, webSocketSession);
    }

    public void exitGameHall(int userId) {
        gameHall.remove(userId);
    }

    public WebSocketSession getFromGameHall(int userId) {
        return gameHall.get(userId);
    }

    public void enterGameRoom(int userId, WebSocketSession webSocketSession) {
        gameRoom.put(userId, webSocketSession);
    }

    public void exitGameRoom(int userId) {
        gameRoom.remove(userId);
    }

    public WebSocketSession getFromGameRoom(int userId) {
        return gameRoom.get(userId);
    }
}

5、创建房间类 

在匹配成功之后, 需要把对战的两个玩家放到同一个房间对象中.

  • 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识.
  • 房间内要记录对弈的玩家双方信息.
  • 记录先手方的 ID
  • 记录一个 二维数组 , 作为对弈的棋盘.
public class Room {
    private String roomId;
    // 玩家1
    private User user1;
    // 玩家2
    private User user2;
    // 先手方的用户 id
    private int whiteUserId = 0;
    // 棋盘, 数字 0 表示未落子位置. 数字 1 表示玩家 1 的落子. 数字 2 表示玩家 2 的落子
    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;
    private int[][] chessBoard = new int[MAX_ROW][MAX_COL];

    private ObjectMapper objectMapper = new ObjectMapper();

    private OnlineUserManager onlineUserManager;

    public Room() {
        // 使用 uuid 作为唯一身份标识
        roomId = UUID.randomUUID().toString();
    }
}

6、实现房间管理类 

Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象,所以需要一个管理器对象来管理所有的 Room.

  • 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
  • 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.
  • 提供增, 删, 查方法. (包含基于房间 ID 的查询和基于用户 ID 的查询).
@Component
public class RoomManager {
    // key 为 roomId, value 为一个 Room 对象
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();

    public void addRoom(Room room, int userId1, int userId2) {
        rooms.put(room.getRoomId(), room);
        userIdToRoomId.put(userId1, room.getRoomId());
        userIdToRoomId.put(userId2, room.getRoomId());
    }

    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }

    public Room getRoomByUserId(int userId) {
        String roomId = userIdToRoomId.get(userId);
        if (roomId == null) {
            return null;
        }
        return getRoomByRoomId(roomId);
    }

    public void removeRoom(String roomId, int userId1, int userId2) {
        rooms.remove(roomId);
        userIdToRoomId.remove(userId1);
        userIdToRoomId.remove(userId2);
    }
}

5、实现匹配器类 

  • 在 Matcher 中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家. (此处约定 <2000 一档, 2000-3000 一档, >3000 一档)
  • 提供 add 方法, 供 MatchController类来调用, 用来把玩家加入匹配队列.
  • 提供 remove 方法, 供 MatchController类来调用, 用来把玩家移出匹配队列.
  • 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session

在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.

@Component
public class Matcher {
    // 创建三个匹配队列
    private Queue<User> normalQueue = new LinkedList<>();
    private Queue<User> highQueue = new LinkedList<>();
    private Queue<User> veryHighQueue = new LinkedList<>();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private RoomManager roomManager;

    private ObjectMapper objectMapper = new ObjectMapper();

    // 操作匹配队列的方法.
    // 把玩家放到匹配队列中
    public void add(User user) {
        if (user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.offer(user);
                normalQueue.notify();
            }
            System.out.println("把玩家 " + user.getUsername() + " 加入到了 normalQueue 中!");
        } else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.offer(user);
                highQueue.notify();
            }
            System.out.println("把玩家 " + user.getUsername() + " 加入到了 highQueue 中!");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.offer(user);
                veryHighQueue.notify();
            }
            System.out.println("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue 中!");
        }
        System.out.println(normalQueue.size());
    }

    // 当玩家点击停止匹配的时候, 就需要把玩家从匹配队列中删除
    public void remove(User user) {
        if (user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.remove(user);
            }
            System.out.println("把玩家 " + user.getUsername() + " 移除了 normalQueue!");
        } else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.remove(user);
            }
            System.out.println("把玩家 " + user.getUsername() + " 移除了 highQueue!");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.remove(user);
            }
            System.out.println("把玩家 " + user.getUsername() + " 移除了 veryHighQueue!");
        }
    }

    public Matcher() {
        // 创建三个线程, 分别针对这三个匹配队列, 进行操作.
        Thread t1 = new Thread() {
            @Override
            public void run() {
                // 扫描 normalQueue
                while (true) {
                    handlerMatch(normalQueue);
                }
            }
        };
        t1.start();

        Thread t2 = new Thread(){
            @Override
            public void run() {
                while (true) {
                    handlerMatch(highQueue);
                }
            }
        };
        t2.start();

        Thread t3 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(veryHighQueue);
                }
            }
        };
        t3.start();
    }
}

实现 handlerMatch方法:由于 handlerMatch 在单独的线程中调用. 因此要考虑到访问队列的线程安全问题. 需要加上锁,每个队列分别使用队列对象本身作为锁即可,在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列。

private void handlerMatch(Queue<User> matchQueue) {
    synchronized (matchQueue) {
        try {
            // 保证只有一个玩家在队列的时候, 不会被出队列. 从而能支持取消功能.
            while (matchQueue.size() < 2) {
                matchQueue.wait();
            }
            // 1. 尝试获取两个元素
            User player1 = matchQueue.poll();
            User player2 = matchQueue.poll();
            System.out.println("匹配出两个玩家: " + player1.getUserId() + ", " + player2.getUserId());
            // 2. 检查玩家在线状态(可能在匹配中玩家突然关闭页面)
            WebSocketSession session1 = onlineUserManager.getSessionFromGameHall(player1.getUserId());
            WebSocketSession session2 = onlineUserManager.getSessionFromGameHall(player2.getUserId());
            if (session1 == null) {
                // 如果玩家1 下线, 则把玩家2 放回匹配队列
                matchQueue.offer(player2);
                return;
            }
            if (session2 == null) {
                // 如果玩家2 下线, 则把玩家1 放回匹配队列
                matchQueue.offer(player1);
                return;
            }
            if (session1 == session2) {
                // 如果得到的两个 session 相同, 说明是同一个玩家两次进入匹配队列
                // 例如玩家点击开始匹配后, 刷新页面, 重新再点开始匹配
                // 此时也把玩家放回匹配队列
                matchQueue.offer(player1);
                return;
            }

            // 3. 将这两个玩家加入到游戏房间中.
              // TODO 一会再写

            // 4. 给玩家1 发回响应数据
            MatchResponse response1 = new MatchResponse();
            response1.setMessage("matchSuccess");
            session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response1)));
            // 5. 给玩家2 发回响应数据
            MatchResponse response2 = new MatchResponse();
            response2.setMessage("matchSuccess");
            session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response2)));
        } catch (InterruptedException | IOException e) {
            e.printStackTrace();
        }
    }
}

6、实现匹配处理类 

 创建匹配处理类, 继承自 TextWebSocketHandler 作为处理 websocket 请求的入口类.

需要用到 ObjectMapper, 后续用来处理 JSON 数据.

@Component
public class MatchAPI extends TextWebSocketHandler {
    private ObjectMapper objectMapper = new ObjectMapper();
    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

    @Component
    public class MatchAPI extends TextWebSocketHandler {
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    }
}

处理连接请求 

实现 afterConnectionEstablished 方法.

  • 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息.
  • 使用 onlineUserManager 来管理用户的在线状态.
  • 先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开).
  • 设置玩家的上线状态.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    // 1. 拿到用户信息.
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        // 拿不到用户的登录信息, 说明玩家未登录就进入游戏大厅了.
        // 则返回错误信息并关闭连接
        MatchResponse response = new MatchResponse();
        response.setOk(false);
        response.setReason("玩家尚未登录!");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        return;
    }
    // 2. 检查玩家的上线状态
    if (onlineUserManager.getSessionFromGameHall(user.getUserId()) != null
        || onlineUserManager.getSessionFromGameRoom(user.getUserId()) != null) {
        MatchResponse response = new MatchResponse();
        response.setOk(false);
        response.setReason("禁止多开游戏大厅页面!");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        return;
    }
    // 3. 设置玩家上线状态
    onlineUserManager.enterGameHall(user.getUserId(), session);
    System.out.println("玩家进入匹配页面: " + user.getUserId());
}

处理开始匹配/取消匹配请求

实现 handleTextMessage

  • 先从会话中拿到当前玩家的信息.
  • 解析客户端发来的请求
  • 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除.
  • 此处需要实现一个 匹配器 对象, 来处理匹配的实际逻辑.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    // 1. 拿到用户信息.
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        System.out.println("[onMessage] 玩家尚未登录!");
        return;
    }
    System.out.println("开始匹配: " + user.getUserId() + " message: " + message.toString());
    // 2. 解析读到的数据为 json 对象
    MatchRequest request = objectMapper.readValue(message.getPayload(), MatchRequest.class);
    MatchResponse response = new MatchResponse();
    if (request.getMessage().equals("startMatch")) {
        matcher.add(user);
        response.setMessage("startMatch");
    } else if (request.getMessage().equals("stopMatch")) {
        matcher.remove(user);
        response.setMessage("stopMatch");
    } else {
        // 匹配失败
        response.setOk(false);
        response.setReason("非法的匹配请求!");
    }
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}

处理连接关闭

实现 afterConnectionClosed

  • 主要的工作就是把玩家从 onlineUserManager 中退出.
  • 退出的时候要注意判定, 当前玩家是否是多开的情况(一个userId, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 OnlineUserManager 中退出.
  • 如果玩家当前在匹配队列中, 则直接从匹配队列里移除.
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        System.out.println("[onClose] 玩家尚未登录!");
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameHall(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("玩家离开匹配页面: " + user.getUserId());
    onlineUserManager.exitGameHall(user.getUserId());
    // 如果玩家在匹配中, 则关闭页面时把玩家移出匹配队列
    matcher.remove(user);
}

处理连接异常 

连接异常与连接关闭的逻辑类似。

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        System.out.println("[onError] 玩家尚未登录!");
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameHall(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("匹配页面连接出现异常! userId: " + user.getUserId() + ", message: " + exception.getMessage());
    onlineUserManager.exitGameHall(user.getUserId());
    // 如果玩家在匹配中, 则关闭页面时把玩家移出匹配队列
    matcher.remove(user);
}

前端代码实现: 

// 1. 和服务器建立连接
let websocket = new WebSocket('ws://127.0.0.1:8080/findMatch');
// 2. 点击开始匹配
let button = document.querySelector('#match-button');
button.onclick = function() {
    if (websocket.readyState == websocket.OPEN) {
        if (button.innerHTML == '开始匹配') {
            console.log('开始匹配!');
            websocket.send(JSON.stringify({
                message: 'startMatch',
            }));
        } else if (button.innerHTML == '匹配中...(点击取消)') {
            console.log('取消匹配!');
            websocket.send(JSON.stringify({
                message: 'stopMatch'
            }));
        }
    } else {
        alert('当前您连接断开! 请重新登录!');
        location.assign('/login.html');
    }
}

// 3. 处理服务器的响应
websocket.onmessage = function(e) {
    let resp = JSON.parse(e.data)
    if (!resp.ok) {
        console.log('游戏大厅中发生错误: ' + resp.reason);
        location.assign('/login.html');
        return;
    }
    if (resp.message == 'startMatch') {
        console.log('进入匹配队列成功!');
        button.innerHTML = '匹配中...(点击取消)';
    } else if (resp.message == 'stopMatch') {
        console.log('离开匹配队列成功!');
        button.innerHTML = '开始匹配';
    } else if (resp.message == 'matchSuccess') {
        console.log('匹配成功! 进入游戏页面!');
        location.assign('/game_room.html');
    } else {
        console.log('非法的 message: ' + resp.message);
    }
}

// 4. 监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
    websocket.close();
}

四、实现五子棋对战模块 

首先约定好前后端交互的接口:

连接:

ws://127.0.0.1:8080/game

连接响应:当两个玩家都连接好了, 则给双方都返回一个数据表示就绪

{
    message: 'gameReady',    // 游戏就绪
    ok: true,                // 是否成功. 
    reason: '',                // 错误原因
    roomId: 'abcdef',        // 房间号. 用来辅助调试. 
    thisUserId: 1,            // 玩家自己的 id
    thatUserId: 2,            // 对手的 id
    whiteUser: 1,            // 先手方的 id
}

落子请求:

{
    message: 'putChess',
    userId: 1,
    row: 0,
    col: 0
}

落子响应:

{
    message: 'putChess',
    userId: 1,    
    row: 0,
    col: 0, 
    winner: 0
}

1、前端绘制棋盘和棋子

这部分代码基于 canvas API,首先使用一个二维数组来表示棋盘. 虽然胜负是通过服务器判定的, 但是客户端的棋盘可以避免 "一个位置重复落子" 这样的情况;

oneStep 函数起到的效果是在一个指定的位置上绘制一个棋子. 可以区分出绘制白字还是黑子. 参数是横坐标和纵坐标, 分别对应列和行.

me 变量用来表示当前是否轮到我落子. over 变量用来表示游戏结束.

gameInfo = {
    roomId: null,
    thisUserId: null,
    thatUserId: null,
    isWhite: true,
}

function setScreenText(me) {
    let screen = document.querySelector('#screen');
    if (me) {
        screen.innerHTML = "轮到你落子了!";
    } else {
        screen.innerHTML = "轮到对方落子了!";
    }
}

//初始化游戏
function initGame() {
    // 是我下还是对方下. 根据服务器分配的先后手情况决定
    let me = gameInfo.isWhite;
    // 游戏是否结束
    let over = false;
    let chessBoard = [];
    //初始化chessBord数组(表示棋盘的数组)
    for (let i = 0; i < 15; i++) {
        chessBoard[i] = [];
        for (let j = 0; j < 15; j++) {
            chessBoard[i][j] = 0;
        }
    }
    let chess = document.querySelector('#chess');
    let context = chess.getContext('2d');
    context.strokeStyle = "#BFBFBF";
    // 背景图片
    let logo = new Image();
    logo.src = "image/sky.jpeg";
    logo.onload = function () {
        context.drawImage(logo, 0, 0, 450, 450);
        initChessBoard();
    }

    // 绘制棋盘网格
    function initChessBoard() {
        for (let i = 0; i < 15; i++) {
            context.moveTo(15 + i * 30, 15);
            context.lineTo(15 + i * 30, 430);
            context.stroke();
            context.moveTo(15, 15 + i * 30);
            context.lineTo(435, 15 + i * 30);
            context.stroke();
        }
    }

    // 绘制一个棋子, me 为 true
    function oneStep(i, j, isWhite) {
        context.beginPath();
        context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);
        context.closePath();
        var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);
        if (!isWhite) {
            gradient.addColorStop(0, "#0A0A0A");
            gradient.addColorStop(1, "#636766");
        } else {
            gradient.addColorStop(0, "#D1D1D1");
            gradient.addColorStop(1, "#F9F9F9");
        }
        context.fillStyle = gradient;
        context.fill();
    }

   
}

2、前端初始化 websocket

在 前端页面中中, 加入 websocket 的连接代码, 实现前后端交互.

  • 在获取到服务器反馈的就绪响应之后, 再初始化棋盘.
  • 创建 websocket 对象, 并注册 onopen/onclose/onerror 函数. 其中在 onerror 中做一个跳转到游戏大厅的逻辑. 当网络异常断开, 则回到大厅.
  • 实现 onmessage 方法. onmessage 先处理游戏就绪响应.
websocket = new WebSocket("ws://127.0.0.1:8080/game");
//连接成功建立的回调方法
websocket.onopen = function (event) {
    console.log("open");
}
//连接关闭的回调方法
websocket.onclose = function () {
    console.log("close");
}
//连接发生错误的回调方法
websocket.onerror = function () {
    console.log("error");
    alert('和服务器连接断开! 返回游戏大厅!')
    location.assign('/game_hall.html')
};
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
    websocket.close();
}

websocket.onmessage = function (event) {
    console.log('handlerGameReady: ' + event.data);

    let response = JSON.parse(event.data);
    if (response.message != 'gameReady') {
        console.log('响应类型错误!');
        return;
    }
    if (!response.ok) {
        alert('连接游戏失败! reason: ' + response.reason);
        location.assign('/game_hall.html')
        return;
    }
    // 初始化游戏信息
    gameInfo.roomId = response.roomId;
    gameInfo.thisUserId = response.thisUserId;
    gameInfo.thatUserId = response.thatUserId;
    gameInfo.isWhite = (response.whiteUserId == gameInfo.thisUserId);
    console.log('[gameReady] ' + JSON.stringify(gameInfo));
    // 初始化棋盘
    initGame();
    // 设置 #screen 的显示
    setScreenText(gameInfo.isWhite);
}

3、处理落子请求

定义 onclick 函数, 在落子操作时加入发送请求的逻辑.

  • 实现 send , 通过 websocket 发送落子请求.
chess.onclick = function (e) {
    if (over) {
        return;
    }
    if (!me) {
        return;
    }
    let x = e.offsetX;
    let y = e.offsetY;
    // 注意, 横坐标是列, 纵坐标是行
    let col = Math.floor(x / 30);
    let row = Math.floor(y / 30);
    if (chessBoard[row][col] == 0) {
        send(row, col);
    }
}

function send(row, col) {
    console.log("send");
    let request = {
        message: "putChess",
        userId: gameInfo.thisUserId,
        row: row,
        col: col,
    }
    websocket.send(JSON.stringify(request));
}

4、处理落子响应 

在 initGame 中, 修改 websocket 的 onmessage

  • 在 initGame 之前, 处理的是游戏就绪响应, 在收到游戏响应之后, 就改为接收落子响应了.
  • 在处理落子响应中要处理比赛结果。
websocket.onmessage = function (event) {
    console.log('handlerPutChess: ' + event.data);

    let response = JSON.parse(event.data);
    if (response.message != 'putChess') {
        console.log('响应类型错误!');
        return;
    }

    // 1. 判断 userId 是自己的响应还是对方的响应, 
    //    以此决定当前这个子该画啥颜色的
    if (response.userId == gameInfo.thisUserId) {
        oneStep(response.col, response.row, gameInfo.isWhite);
    } else if (response.userId == gameInfo.thatUserId) {
        oneStep(response.col, response.row, !gameInfo.isWhite);
    } else {
        console.log('[putChess] response userId 错误! response=' + JSON.stringify(response));
        return;
    }
    chessBoard[response.row][response.col] = 1;
    me = !me; // 接下来该下个人落子了. 

    // 2. 判断游戏是否结束
    if (response.winner != 0) {
        // 胜负已分
        if (response.winner == gameInfo.thisUserId) {
            alert("你赢了!");
        } else {
            alert("你输了");
        }
        // 如果游戏结束, 则关闭房间, 回到游戏大厅. 
        location.assign('/game_hall.html')
    }

    // 3. 更新界面显示
    setScreenText(me);
}

5、定义落子请求类

@Data
public class GameReadyResponse {
    private String message = "gameReady";
    private boolean ok = true;
    private String reason = "";
    private String roomId = "";
    private int thisUserId = 0;
    private int thatUserId = 0;
    private int whiteUserId = 0;
}
@Data
public class GameRequest {
    private String message = "putChess";
    private int userId;
    private int row;
    private int col;
}

6、定义落子响应类 

@Data
public class GameResponse {
    private String message = "putChess";
    private int userId;
    private int row;
    private int col;
    private int winner; // 胜利玩家的 userId
}

7、实现对战功能 

 在 room 类中定义 putChess 方法.

  • 先把请求解析成请求对象.
  • 根据请求对象中的信息, 往棋盘上落子.
  • 落子完毕之后, 为了方便调试, 可以打印出棋盘的当前状况.
  • 检查游戏是否结束.
  • 构造落子响应, 写回给每个玩家.
  • 写回的时候如果发现某个玩家掉线, 则判定另一方为获胜.
  • 如果游戏胜负已分, 则修改玩家的分数, 并销毁房间.
// 玩家落子
public void putChess(String message) throws IOException {
    GameRequest req = objectMapper.readValue(message, GameRequest.class);
    GameResponse response = new GameResponse();
    // 1. 进行落子
    int chess = req.getUserId() == user1.getUserId() ? 1 : 2;
    int row = req.getRow();
    int col = req.getCol();
    if (chessBoard[row][col] != 0) {
        System.out.println("落子位置有误! " + req);
        return;
    }
    chessBoard[row][col] = chess;
    printChessBoard();
    // 2. 检查游戏结束
    //    返回的 winner 为玩家的 userId
    int winner = checkWinner(chess, row, col);
    // 3. 把响应写回给玩家
    response.setUserId(req.getUserId());
    response.setRow(row);
    response.setCol(col);
    response.setWinner(winner);
    WebSocketSession session1 = onlineUserManager.getSessionFromGameRoom(user1.getUserId());
    WebSocketSession session2 = onlineUserManager.getSessionFromGameRoom(user2.getUserId());
    if (session1 == null) {
        // 玩家1 掉线, 直接认为玩家2 获胜
        response.setWinner(user2.getUserId());
        System.out.println("玩家1 掉线!");
    }
    if (session2 == null) {
        // 玩家2 掉线, 直接认为玩家1 获胜
        response.setWinner(user1.getUserId());
        System.out.println("玩家2 掉线!");
    }
    String responseJson = objectMapper.writeValueAsString(response);
    if (session1 != null) {
        session1.sendMessage(new TextMessage(responseJson));
    }
    if (session2 != null) {
        session2.sendMessage(new TextMessage(responseJson));
    }
    // 4. 如果玩家胜负已分, 就把 room 从管理器中销毁
    if (response.getWinner() != 0) {
        userMapper.userWin(response.getWinner() == user1.getUserId() ? user1 : user2);
        userMapper.userLose(response.getWinner() == user1.getUserId() ? user2 : user1);
        roomManager.removeRoom(roomId, user1.getUserId(), user2.getUserId());
        System.out.println("游戏结束, 房间已经销毁! roomId: " + roomId + " 获胜方为: " + response.getWinner());
    }
}

8、实现胜负判定 

  • 如果游戏分出胜负, 则返回玩家的 id. 如果未分出胜负,则返回 0.
  • 棋盘中值为 1 表示是玩家 1 的落子, 值为 2 表示是玩家 2 的落子.
  • 检查胜负的时候, 以当前落子位置为中心, 检查所有相关的行,列, 对角线即可. 不必遍历整个棋盘.
// 判定棋盘形式, 找出胜利的玩家.
// 如果游戏分出胜负, 则返回玩家的 id.
// 如果未分出胜负, 则返回 0
// chess 值为 1 表示玩家1 的落子. 为 2 表示玩家2 的落子
private int checkWinner(int chess, int row, int col) {
    // 以 row, col 为中心
    boolean done = false;
    // 1. 检查所有的行(循环五次)
    for (int c = col - 4; c <= col; c++) {
        if (c < 0 || c >= MAX_COL) {
            continue;
        }
        if (chessBoard[row][c] == chess
            && chessBoard[row][c + 1] == chess
            && chessBoard[row][c + 2] == chess
            && chessBoard[row][c + 3] == chess
            && chessBoard[row][c + 4] == chess) {
            done = true;
        }
    }
    // 2. 检查所有的列(循环五次)
    for (int r = row - 4; r <= row; r++) {
        if (r < 0 || r >= MAX_ROW) {
            continue;
        }
        if (chessBoard[r][col] == chess
            && chessBoard[r + 1][col] == chess
            && chessBoard[r + 2][col] == chess
            && chessBoard[r + 3][col] == chess
            && chessBoard[r + 4][col] == chess) {
            done = true;
        }
    }
    // 3. 检查左对角线
    for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
        if (r < 0 || r >= MAX_ROW || c < 0 || c >= MAX_COL) {
            continue;
        }
        if (chessBoard[r][c] == chess
            && chessBoard[r + 1][c + 1] == chess
            && chessBoard[r + 2][c + 2] == chess
            && chessBoard[r + 3][c + 3] == chess
            && chessBoard[r + 4][c + 4] == chess) {
            done = true;
        }
    }
    // 4. 检查右对角线
    for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
        if (r < 0 || r >= MAX_ROW || c < 0 || c >= MAX_COL) {
            continue;
        }
        if (chessBoard[r][c] == chess
            && chessBoard[r + 1][c - 1] == chess
            && chessBoard[r + 2][c - 2] == chess
            && chessBoard[r + 3][c - 3] == chess
            && chessBoard[r + 4][c - 4] == chess) {
            done = true;
        }
    }
    if (!done) {
        return 0;
    }
    return chess == 1 ? user1.getUserId() : user2.getUserId();
}

9、实现游戏处理类 

创建 gameController类 , 处理 websocket 请求.

@Component
public class GameAPI extends TextWebSocketHandler {
    private ObjectMapper objectMapper = new ObjectMapper();
    @Autowired
    private RoomManager roomManager;
    // 这个是管理 game 页面的会话
    @Autowired
    private OnlineUserManager onlineUserManager;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    }
}

处理连接成功

实现 GameController类的 afterConnectionEstablished 方法.

  • 首先需要检测用户的登录状态. 从 Session 中拿到当前用户信息.
  • 然后要判定当前玩家是否是在房间中.
  • 接下来进行多开判定.如果玩家已经在游戏中, 则不能再次连接.
  • 把两个玩家放到对应的房间对象中. 当两个玩家都建立了连接, 房间就放满了.这个时候通知两个玩家双方都准备就绪.
  • 如果有第三个玩家尝试也想加入房间, 则给出一个提示, 房间已经满了.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    GameReadyResponse resp = new GameReadyResponse();

    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        resp.setOk(false);
        resp.setReason("用户尚未登录!");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
        return;
    }
    Room room = roomManager.getRoomByUserId(user.getUserId());
    if (room == null) {
        resp.setOk(false);
        resp.setReason("用户并未匹配成功! 不能开始游戏!");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
        return;
    }
    System.out.println("连接游戏! roomId=" + room.getRoomId() + ", userId=" + user.getUserId());

    // 先判定用户是不是已经在游戏中了.
    if (onlineUserManager.getSessionFromGameHall(user.getUserId()) != null
        || onlineUserManager.getSessionFromGameRoom(user.getUserId()) != null) {
        resp.setOk(false);
        resp.setReason("禁止多开游戏页面!");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
        return;
    }
    // 更新会话
    onlineUserManager.enterGameRoom(user.getUserId(), session);

    // 同一个房间的两个玩家, 同时连接时要考虑线程安全问题.
    synchronized (room) {
        if (room.getUser1() == null) {
            room.setUser1(user);
            // 设置 userId1 为先手方
            room.setWhiteUserId(user.getUserId());
            System.out.println("userId=" + user.getUserId() + " 玩家1准备就绪!");
            return;
        }
        if (room.getUser2() == null) {
            room.setUser2(user);
            System.out.println("userId=" + user.getUserId() + " 玩家2准备就绪!");

            // 通知玩家1 就绪
            noticeGameReady(room, room.getUser1().getUserId(), room.getUser2().getUserId());
            // 通知玩家2 就绪
            noticeGameReady(room, room.getUser2().getUserId(), room.getUser1().getUserId());
            return;
        }
    }
    // 房间已经满了!
    resp.setOk(false);
    String log = "roomId=" + room.getRoomId() + " 已经满了! 连接游戏失败!";
    resp.setReason(log);
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
    System.out.println(log);
}

通知玩家就绪 

private void noticeGameReady(Room room, int thisUserId, int thatUserId) throws IOException {
    GameReadyResponse resp = new GameReadyResponse();
    resp.setRoomId(room.getRoomId());
    resp.setThisUserId(thisUserId);
    resp.setThatUserId(thatUserId);
    resp.setWhiteUserId(room.getWhiteUserId());
    WebSocketSession session1 = onlineUserManager.getSessionFromGameRoom(thisUserId);
    session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}

处理落子请求 

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        return;
    }
    Room room = roomManager.getRoomByUserId(user.getUserId());
    room.putChess(message.getPayload());
}

通知另一个玩家获胜

// 通知另外一个玩家直接获胜!
private void noticeThatUserWin(User user) throws IOException {
    Room room = roomManager.getRoomByUserId(user.getUserId());
    if (room == null) {
        System.out.println("房间已经释放, 无需通知!");
        return;
    }
    User thatUser = (user == room.getUser1() ? room.getUser2() : room.getUser1());
    WebSocketSession session = onlineUserManager.getSessionFromGameRoom(thatUser.getUserId());
    if (session == null) {
        System.out.println(thatUser.getUserId() + " 该玩家已经下线, 无需通知!");
        return;
    }
    GameResponse resp = new GameResponse();
    resp.setUserId(thatUser.getUserId());
    resp.setWinner(thatUser.getUserId());
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}

处理玩家下线 以及 连接出错

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameRoom(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("连接出错! userId=" + user.getUserId());
    onlineUserManager.exitGameRoom(user.getUserId());

    noticeThatUserWin(user);
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameRoom(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("用户退出! userId=" + user.getUserId());
    onlineUserManager.exitGameRoom(user.getUserId());
    
    noticeThatUserWin(user);
}

五、页面展示 

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

过✪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值