从0到1开发网页版五子棋:我的Java实战之旅

目录

一、项⽬背景

二、关键技术

三、WebSocket

1.引入

2.websocket握手过程(建立连接的过程)

四、需求分析和概要设计

1.用户模块

2.匹配模块

3.对战模块

五、项目实现

1.创建项目

2.用户模块

设计数据库

[配置 MyBatis](#配置 MyBatis)

创建实体类

[创建 UserMapper](#创建 UserMapper)

前后端交互接⼝

客户端开发

服务器开发

3.匹配模块

前后端交互接口

客户端开发

服务器开发

[1.创建并注册 MatchAPI 类](#1.创建并注册 MatchAPI 类)

2.实现⽤户管理器

3.创建匹配请求/响应对象

4.处理连接成功

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

6.创建房间类

7.创建房间管理器

8.处理连接关闭

9.处理连接异常

4.对战模块

前后端交互接口

客户端开发

服务器开发

[1.创建并注册 GameAPI 类](#1.创建并注册 GameAPI 类)

2.创建落⼦请求/响应对象

3.处理连接成功

4.玩家下线的处理

[5.修改 Room 类](#5.修改 Room 类)

6.处理落⼦请求

7.实现对弈功能

8.实现打印棋盘的逻辑

9.实现胜负判定

10.处理玩家中途退出

六、总结

一、项⽬背景

为了实现五子棋在线对战功能,我使用 Java 开发了一款低延迟、易上手的网页版五子棋游戏。目标是让用户打开浏览器即可秒匹配对手,享受流畅的对战体验,并能够记录战绩,在不断对弈中提升棋艺。

⽀持以下核⼼功能:

• ⽤户模块: ⽤户注册, ⽤户登录, ⽤户天梯分数记录, ⽤户⽐赛场次记录。

• 匹配模块: 按照⽤户的天梯分数实现匹配机制。

• 对战模块: 实现两个玩家在⽹⻚端进⾏五⼦棋对战的功能。

二、关键技术

Java,Spring/Spring Boot/Spring MVC,HTML/CSS/JS/AJAX,MySQL/MyBatis,WebSocket

三、WebSocket

1.引入

之前学的服务器开发模型大部分:客户端主动向服务器发送请求,服务器收到之后返回一个响应,如果客户端不主动发起请求,服务器不能主动联系客户端。我们也需要服务器主动给客户端发消息这样的场景-------"消息推送" (++WebSocket++)。

当前已有的知识,主要是HTTP,HTTP自身难以实现这种消息推送的效果的, HTTP想要实现这种效果,就需要基于"轮询"的机制。

很明显,像这样的轮询操作,开销是比较大的,成本也是比较高的。 如果轮询间隔时间长,玩家1落子之后,玩家2不能及时的拿到结果。如果轮询间隔时间短,虽然即时性得到改善,但是玩家2不得不浪费更多的机器资源(尤其是带宽)。

所以我引入了WebSocket协议,它就像在客户端和服务器之间架了一条「专用高速路」:

  • 一次连接,持续通信 :连接建立后可以双向实时传消息,延迟轻松控制在100ms内。

  • 支持主动推送 :服务器能直接给客户端发消息(比如对手落子了),不用等客户端来问。

2.websocket握手过程(建立连接的过程)

在网页端尝试与服务器建立 WebSocket 连接时,首先会向服务器发送一个 HTTP 请求。这个请求中包含两个特殊的请求头:

复制代码
Connection: Upgrade
Upgrade: WebSocket

这两个请求头的作用是告知服务器:客户端希望将当前连接从 HTTP 协议升级为 WebSocket 协议。

如果服务器支持 WebSocket,就会返回一个状态码为 101 Switching Protocols 的响应,表示同意协议切换。自此,客户端与服务器之间便通过 WebSocket 进行双向通信,实现实时数据传输。

四、需求分析和概要设计

整个项⽬分成以下模块:⽤户模块、匹配模块、对战模块

1.用户模块

用户模块主要负责用户的注册、登录和分数记录功能。客户端提供一个统一的登录与注册页面,方便用户进行身份验证和信息管理。服务器端基于 Spring + MyBatis 技术栈实现数据库的增删改查操作,并使用 MySQL 数据库存储用户数据,确保用户信息的安全性和完整性。

2.匹配模块

匹配模块在用户成功登录后启动,用户将进入游戏大厅页面,在这里可以看到自己的名字、天梯分数、比赛场数和获胜场数等信息。页面上有一个"匹配按钮",点击该按钮后,用户会被加入匹配队列,界面上显示为"取消匹配"。再次点击则从匹配队列中移除。如果匹配成功,用户将被跳转至游戏房间页面。页面加载时会与服务器建立 WebSocket 连接,双方通过 WebSocket 传输"开始匹配"、"取消匹配"、"匹配成功"等信息,确保实时通信的顺畅。

3.对战模块

对战模块在玩家匹配成功后启动,用户将进入游戏房间页面,每两个玩家共享同一个游戏房间。在游戏房间页面中,能够显示五子棋棋盘,玩家通过点击棋盘上的位置实现落子功能。当出现五子连珠时,系统自动触发胜负判定,并显示"你赢了"或"你输了"的提示信息。页面加载时同样与服务器建立 WebSocket 连接,双方通过 WebSocket 传输"准备就绪"、"落子位置"、"胜负"等信息,确保对局过程中的实时同步和流畅体验。

五、项目实现

1.创建项目

使⽤ IDEA 创建 SpringBoot 项⽬。引⼊依赖如下:依赖都是常规的 SpringBoot / Spring MVC / MyBatis 等, 没啥特别的依赖。

2.用户模块

设计数据库

用户模块的数据库设计主要围绕 user 表展开,用于存储用户的基本信息和战绩数据。表中包含用户的唯一标识 userId(主键,自增),用户名 username(唯一)、密码 password,以及天梯分数 score、比赛总场次 totalCount 和获胜场次 winCount。这些字段能够支持登录注册、匹配积分、胜负统计等核心功能,结构清晰、扩展性强,为后续实现排行榜等功能打下良好基础。

复制代码
CREATE TABLE user (
    userId     INT PRIMARY KEY AUTO_INCREMENT,
    username   VARCHAR(50) UNIQUE,
    password   VARCHAR(50),
    score      INT,          -- 天梯分数
    totalCount INT,          -- 比赛总场次
    winCount   INT           -- 获胜场次
);
配置 MyBatis

连接并且操作数据库,修改Spring的配置文件,使得数据库可以被连接上。

创建实体类
复制代码
public class User {
    private int userId;
    private String userName;
    private String password;
    private int score;
    private int totalCount;
    private int winCount;
}
创建 UserMapper

创建 model.UserMapper 接⼝。

此处主要提供四个⽅法:

• selectByName: 根据⽤户名查找⽤户信息. ⽤于实现登录

• insert: 新增⽤户. ⽤户实现注册

• userWin: ⽤于给获胜玩家修改分数

• userLose: ⽤户给失败玩家修改分数

复制代码
@Mapper
public interface UserMapper {
    User selectByName(String username);
    int insert(User user);
    void userWin(User user);    
    void userLose(User user);
}

根据此创建UserMapper.xml,实现具体的数据库的相关操作。

前后端交互接⼝

需要明确⽤户模块的前后端交互接⼝.。这⾥主要涉及到三个部分,登录接口,注册接口,获取用户信息接口。

以登录接口为例

复制代码
请求:post/login HTTP/1.1

Content-Type:application/x-www-form-urlencoded

username=zhangsan&password=123

响应:HTTP/1.1 200 OK   //如果登录失败,就返回一个无效的user对象,

{                      //比如,这里的每个属性都是空着的,像userId

usrId:1,

username:'zhangsan',

score:1000,

totalCount:0,

winCount:0

}

客户端向服务器发送 POST 请求至 /login 接口,请求头中指定了 Content-Type: application/x-www-form-urlencoded,表示以表单形式提交数据,请求体为 username=zhangsan&password=123,用于用户登录验证。服务器接收到请求后会校验用户名和密码,若验证成功,则返回状态码 200 和包含用户信息的 JSON 数据,如用户 ID、用户名、天梯分数、比赛总场次和获胜场次等;如果登录失败,则同样返回 200 状态码,但在响应的 JSON 中返回一个"无效"的 User 对象,所有字段为空或默认值,表示登录未成功。

这个前后端交互的接口,在约定的时候,是有多种交互方式的,这里约定好了之后,后续的后端/前端代码,都要严格遵守这个约定来写代码。

客户端开发

登录界面

注册界面

服务器开发

主要实现三个⽅法:

• login: ⽤来实现登录逻辑

复制代码
public Object login(String username, String password, HttpServletRequest req) {
        User user = userMapper.selectByName(username);
        System.out.println("login! user=" + user);
        if (user == null || !user.getPassword().equals(password)) {
            return new User();
        }
        HttpSession session = req.getSession(true);
        session.setAttribute("user", user);
        return user;
    }

• register: ⽤来实现注册逻辑

复制代码
public Object register(String username, String password) {
        User user = null;
        try {
            user = new User();
            user.setUsername(username);
            user.setPassword(password);
            System.out.println("register! user=" + user);
            int ret = userMapper.insert(user);
            System.out.println("ret: " + ret);
        } catch (org.springframework.dao.DuplicateKeyException e) {
            user = new User();
        }
        return user;
    }

• getUserInfo: ⽤来实现登录成功后显⽰⽤⼾分数的信息

复制代码
 public Object getUserInfo(HttpServletRequest req) {
        // 从 session 中拿到用户信息
        HttpSession session = req.getSession(false);
        if (session == null) {
            return new User();
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            return new User();
        }
        return user;
    }

3.匹配模块

让多个用户在游戏大厅内进行匹配,系统会把实力相近的两个玩家凑成一桌,进行对战。

前后端交互接口

匹配这样的功能,也是依赖消息推送机制的。

当玩家点击匹配按钮时,客户端会立即向服务器发送匹配请求。由于匹配成功的时间不确定,服务器无法在请求发送后立即返回结果,因此需要依赖 WebSocket 建立的实时通信机制,由服务器在匹配成功后主动推送消息给客户端。整个过程采用 JSON 格式的文本数据通过 WebSocket 传输,前后端交互清晰高效,确保了匹配结果的实时通知和良好的用户体验。

复制代码
匹配请求:
客户端通过websocket给服务器发送一个json格式的文本数据
ws://127.0.0.1:8080/findMatch
{
    message:'startMatch'/'stopMatch',//开始/结束匹配
}
/*在通过websocket传输请求数据时,数据中是不必带有用户身份信息,当前用户的身份信息,在前面登录完成之后,就已经保存到HttpSession中了,websocket里,也是能拿到之前登录好的Httpsession中的信息的*/
​
匹配响应1:
ws://127.0.0.1:8080/findMatch
{
    OK:true,//匹配成功
    reason:'',//匹配如果失败,失败原因的信息
    message:'startMatch'/'stopMatch',
}
/*这个响应是客户端给服务器发送服务匹配请求后,服务器立刻返回的匹配响应*/
​
匹配响应2:
ws://127.0.0.1:8080/findMatch
{
    OK:true,//匹配成功
    reason:'',//匹配如果失败,失败原因的信息
    message:'matchSuccess',
}
/*这个是真正匹配到对手之后,服务器主动推送回来的消息
匹配到的对手不需要在这个响应中体现,仍然都放到服务器这边保存即可*/
客户端开发

游戏大厅

实现匹配功能

• 点击匹配按钮,就会进⼊匹配逻辑.。同时按钮上提⽰ "匹配中...(点击取消)" 字样。

• 再次点击匹配按钮,则会取消匹配。

• 当匹配成功后,服务器会返回匹配成功响应,⻚⾯跳转到 游戏房间 。

服务器开发
1.创建并注册 MatchAPI 类

创建 api.MatchAPI,继承⾃ TextWebSocketHandler 作为处理 websocket 请求的⼊⼝类。同时准备好⼀个 ObjectMapper,后续⽤来处理 JSON 数据。

复制代码
@Component
public class MatchAPI extends TextWebSocketHandler {
    private ObjectMapper objectMapper = new ObjectMapper();
    @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 {
    }
}
2.实现⽤户管理器

⽤于管理当前⽤户的在线状态。本质上是 哈希表 的结构。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 session) {
        gameHall.put(userId, session);
    }
​
    // 只有当前页面退出的时候,能销毁自己的 session
    // 避免当一个 userId 打开两次游戏页面,错误的删掉之前的会话的问题.
    public void exitGameHall(int userId) {
        gameHall.remove(userId);
    }
​
    public WebSocketSession getSessionFromGameHall(int userId) {
        return gameHall.get(userId);
    }
​
    public void enterGameRoom(int userId, WebSocketSession session) {
        gameRoom.put(userId, session);
    }
​
    public void exitGameRoom(int userId) {
        gameRoom.remove(userId);
    }
​
    public WebSocketSession getSessionFromGameRoom(int userId) {
        return gameRoom.get(userId);
    }
}
​
// 给 MatchAPI 注入 OnlineUserManager
@Component
public class MatchAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserManager onlineUserManager;
}
3.创建匹配请求/响应对象
复制代码
//创建 game.MatchRequest 类
public class MatchRequest {
    private String message = "";
}
​
// 创建 game.MatchResponse 类
​
public class MatchResponse {
    private boolean ok = true;
    private String reason = "";
    private String message = "";
}
4.处理连接成功

• 通过参数中的 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());
}
5.处理开始匹配/取消匹配请求

a.实现 handleTextMessage

• 先从会话中拿到当前玩家的信息。

• 解析客⼾端发来的请求。

• 判定请求的类型,如果是 startMatch,则把⽤⼾对象加⼊到匹配队列。如果是 stopMatch,则把⽤⼾对象从匹配队列中删除。

• 此处需要实现⼀个 匹配器 对象,来处理匹配的实际逻辑。

复制代码
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //实现处理开始匹配请求和处理停止匹配请求
        User user = (User) session.getAttributes().get("user");
        //获取到客户端给服务器发送的数据
        String payload = message.getPayload();
        MatchRequset requset = objectMapper.readValue(payload, MatchRequset.class);
        MatchResponse response = new MatchResponse();
        if (requset.getMessage().equals("startMatch")) {
            //进入匹配队列
            //TODO 先创建一个类表示匹配队列,把当前用户加进去
            matcher.add(user);
            //把玩家信息放入匹配队列之后,就可以返回一个响应给客户端了
            response.setOk(true);
            response.setMessage("startMatch");
        } else if (requset.getMessage().equals("stopMatch")) {
            //退出匹配队列
            //TODO 先创建一个类表示匹配队列,把当前用户移除
            matcher.remove(user);
            //把玩家信息放入匹配队列之后,就可以返回一个响应给客户端了
            response.setOk(true);
            response.setMessage("stopMatch");
        } else {
            //非法情况
            response.setOk(false);
            response.setReason("非法的匹配请求");
        }
        String jsonString = objectMapper.writeValueAsString(response);
        session.sendMessage(new TextMessage(jsonString));
    }

b.实现匹配器

• 在 Matcher 中创建三个队列 (队列中存储 User 对象),分别表⽰不同的段位的玩家。(此处约定 <2000⼀档、2000-3000⼀档、3000⼀档>)。

复制代码
//创建三个匹配队列
    private Queue<User> normalQueue = new LinkedList<>();
    private Queue<User> highQueue = new LinkedList<>();
    private Queue<User> veryHighQueue = new LinkedList<>();

• 提供 add ⽅法,供 MatchAPI 类来调⽤,⽤来把玩家加⼊匹配队列。

复制代码
//操作匹配队列的方法
    //把玩家放到匹配队列中
    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 中!");
        }
    }

• 提供 remove ⽅法,供 MatchAPI 类来调⽤,⽤来把玩家移出匹配队列。

复制代码
//当玩家点击停止匹配是,就需要将玩家从匹配队列中删除
    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 中删除!");
        }
    }

• 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session。

• 在 Matcher 的构造⽅法中,创建⼀个线程,使⽤该线程扫描每个队列,把每个队列的头两个元素取出来,匹配到⼀组中。

复制代码
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() {
                //扫描highQueue
                while (true) {
                    handlermatch(highQueue);
                }
            }
        };
        t2.start();
        Thread t3 = new Thread() {
            @Override
            public void run() {
                //扫描veryHighQueue
                while (true) {
                    handlermatch(veryHighQueue);
                }
            }
        };
        t3.start();
    }

c.实现 handlerMatch

• 由于 handlerMatch 在单独的线程中调⽤。因此要考虑到访问队列的线程安全问题。需要加上锁。

• 每个队列分别使⽤队列对象本⾝作为锁即可。

• 在⼊⼝处使⽤ wait 来等待,直到队列中达到 2 个元素及其以上,才唤醒线程消费队列。

复制代码
private void handlermatch(Queue<User> matchQueue) {
        synchronized (matchQueue) {
            try {
                //1.检测队列中元素个数是否达到2
                //队列的初始情况可能是空。
                // 如果往队列中添加一个元素,这个时候,仍然是不能进行后续匹配操作的。
                // 因此在这里使用while循环检查是更合理的~
                while (matchQueue.size() < 2) {
                    matchQueue.wait();
                }
                //2.尝试从队列中取出两个玩家
                User player1 = matchQueue.poll();
                User player2 = matchQueue.poll();
                System.out.println("匹配出两个玩家: " + player1.getUserName() + "," + player2.getUserName());
​
                //3.获取到玩家的websocket的会话
                //获取到会话的目的是为了告诉玩家,你排到了
                WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
                //理伦上来说,匹配队列中的元素一定处于在线的状态
                //我们前面的逻辑已经判断过,当玩家断开连接的时候就已经把他从匹配队列移除了
                //但是仍然进行一次判定
                if (session1 == null) {
                    //如果玩家1现在不在线,就把玩家2重新放回到匹配队列
                    matchQueue.offer(player2);
                    return;
                }
                if (session2 == null) {
                    //如果玩家2现在不在线,就把玩家1重新放回到匹配队列
                    matchQueue.offer(player1);
                    return;
                }
                //当前能否排到两个玩家是同一个用户的情况嘛?一个玩家入队列了两次?理论上也不会存在~~
                //1)如果玩家下线,就会对玩家移出匹配队列。
                //2)又禁止写玩家多开
                //但是仍然这里多进行一次判定,以免前面的逻辑出现bug是带来严重的后果
                if (session1 == session2) {
                    //把其中的一个玩家返回匹配队列
                    matchQueue.offer(player1);
                    return;
                }
                //4. 把这两个玩家放到一个游戏房间中
                Room room = new Room();
                roomManager.add(room, player1.getUserId(), player2.getUserId());
                //5.给玩家反馈信息,通过websocket返回一个message为'matchSuccess'这样的响应
                //此处是要给两个玩家都返回"匹配成功"这样的信息,需要返回两次
                MatchResponse response1 = new MatchResponse();
                response1.setOk(true);
                response1.setMessage("matchSuccess");
                session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response1)));
​
                MatchResponse response2 = new MatchResponse();
                response2.setOk(true);
                response2.setMessage("matchSuccess");
                session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response2)));
            } catch (IOException  | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

注意:需要给上⾯的插⼊队列元素,删除队列元素等也加上锁,插⼊成功后要通知唤醒上⾯的等待逻辑。

6.创建房间类

UUID表示"世界上唯一的身份标识"。通过一系列的算法,能够生成一串字符串(一组十六进制表示的数字)。两次调用这个算法,生成的这个字符串都是不相同的。任意次调用,每次得到的结果都不相同,UUID内部具体如何实现的(算法实现细节)不去深究,Java中直接有现成的类,可以帮我们一下就生成一个 UUID。

复制代码
//这个类就表示一个游戏房间
public class Room {
    //使用字符串类型来表示,方便生成唯一值.
    private String roomId;
    private User user1;
    private User user2;
    public Room() {
        //构造room得时候生成唯一字符串来表示房间id
        //使用UUID来作为房间id
        roomId = UUID.randomUUID().toString();
    }
}
7.创建房间管理器

Room 对象会存在很多,每两个对弈的玩家,都对应⼀个 Room 对象。需要⼀个管理器对象来管理所有的 Room,创建 game.RoomManager。

• 使⽤⼀个 Hash 表,保存所有的房间对象,key 为 roomId,value 为 Room 对象。

• 再使⽤⼀个 Hash 表,保存 userId -> roomId 的映射,⽅便根据玩家来查找所在的房间。

• 提供增、删、查的 API。(查包含两个版本,基于房间 ID 的查询和基于⽤⼾ ID 的查询)。

复制代码
//房间管理器类,这个类也希望有唯一实例
@Component
public class RoomManager {
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();
    //添加
    public void add(Room room,int userId1,int userId2) {
        rooms.put(room.getRoomId(), room);
        userIdToRoomId.put(userId1,room.getRoomId());
        userIdToRoomId.put(userId2,room.getRoomId());
    }
    //删除
    public void remove(String roomId,int userId1,int userId2) {
        rooms.remove(roomId);
        userIdToRoomId.remove(userId1);
        userIdToRoomId.remove(userId2);
    }
    //查找roomid获取room
    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }
    //查找userid获取room
    public Room getRoomByUserId(int userId) {
        String roomId = userIdToRoomId.get(userId);
        if (roomId == null) {
            //userid->roomid映射关系不存在,直接返回null
            return null;
        }
        return rooms.get(roomId);
    }
}
8.处理连接关闭

实现 afterConnectionClosed

• 主要的⼯作就是把玩家从 onlineUserManager 中退出。

• 退出的时候要注意判定,当前玩家是否是多开的情况(⼀个userId,对应到两个 websocket 连接)。 如果⼀个玩家开启了第⼆个 websocket 连接,那么这第⼆个 websocket 连接不会影响到玩家从OnlineUserManager 中退出。

• 如果玩家当前在匹配队列中,则直接从匹配队列⾥移除。

复制代码
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        //玩家下线,从onlineUserManager中删除
        try {
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            //如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配对列
            matcher.remove(user);
            //System.out.println("玩家 " + user.getUserName() + " 退出了游戏大厅!");
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");
            //e.printStackTrace();
            //出现空指针异常,说明当前用户的身份信息为空,用户未登录
            //返回信息,用户尚未登录
            //以下代码不应该在连接关闭之后,还尝试发送消息给客户端
            //MatchResponse response = new MatchResponse();
            //response.setOk(false);
            //response.setReason("您尚未登录,不能进行后续的匹配功能!");
            //session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    }
9.处理连接异常
复制代码
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        //玩家下线,从onlineUserManager中删除
        try {
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            //如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配对列
            matcher.remove(user);
            //System.out.println("玩家 " + user.getUserName() + " 退出了游戏大厅!");
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");
            //e.printStackTrace();
            //出现空指针异常,说明当前用户的身份信息为空,用户未登录
            //返回信息,用户尚未登录
            //MatchResponse response = new MatchResponse();
            //response.setOk(false);
            //response.setReason("您尚未登录,不能进行后续的匹配功能!");
            //session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    }

4.对战模块

前后端交互接口

1.建立连接响应

服务器要生成一些游戏的初始信息,通过这个响应告诉客户端。

2.针对落子的请求和响应

复制代码
请求:

{        //建议大家使用 行 和 列 而不要用 x 和 y

message:'putChess', row => y

userId:1, col => x

row:0,   //后面的代码中需要使用二维数组

col:0,   //来表示这个棋盘,通过下标取二维数组

(row,col)//如果使用x,y就变成了(y,x)

}

响应:

{

message:'putChess',

userId:1,

row:0,

col:0,

winner:0

}
客户端开发

对战房间

其中的棋盘代码基于 canvas API(找资料所得)。其中的发送落子请求,处理落子响应等在这里不做过多介绍。

服务器开发
1.创建并注册 GameAPI 类

创建 api.GameAPI,处理 websocket 请求。

• 这⾥准备好⼀个 ObjectMapper

• 同时注⼊⼀个 RoomManager 和 OnlineUserMananger

复制代码
@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 {
    }
}
2.创建落⼦请求/响应对象

这部分内容要和约定的前后端交互接⼝匹配。

GameReadyResponse 类

复制代码
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;
}

GameRequest 类

复制代码
public class GameRequest {
    private String message = "putChess";
    private int userId;
    private int row;
    private int col;
}

GameResponse 类

复制代码
public class GameResponse {
    private String message = "putChess";
    private int userId;
    private int row;
    private int col;
    private int winner; // 胜利玩家的 userId
}

注意,为了使 message 字段能够被 Jackson 正确序列化,需要为它提供相应的 getter 和 setter 方法。

3.处理连接成功

实现 GameAPI 的 afterConnectionEstablished ⽅法

• ⾸先需要检测⽤⼾的登录状态,从 Session 中拿到当前⽤⼾信息。

• 然后要判定当前玩家是否是在房间中。

• 接下来进⾏多开判定,如果玩家已经在游戏中,则不能再次连接。

• 把两个玩家放到对应的房间对象中,当两个玩家都建⽴了连接,房间就放满了.这个时候通知两个玩家双⽅都准备就绪。

• 如果有第三个玩家尝试也想加⼊房间,则给出⼀个提⽰,房间已经满了。

复制代码
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        GameReadResponse resp = new GameReadResponse();
        //1.先获取到用户的身份信息(从HttpSession里拿到当前用户的对象)
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            resp.setOk(false);
            resp.setReason("用户尚未登录");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            return;
        }
        //2.当前用户是否已经在房间(拿着房间管理器进行查询)
        Room room = roomManager.getRoomByUserId(user.getUserId());
        if (room == null) {
            //如果为空说明当前没有对应的房间,该玩家还没有匹配
            resp.setOk(false);
            resp.setReason("该用户尚未匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            return;
        }
        //3.判定当前是不是多开(用户是不是已经在其他页面)
        //前面准备了一个OnlineUserManager
        if (onlineUserManager.getFromGameHall(user.getUserId()) != null
                || onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
            //如果一个账号,一个在游戏大厅,一个在游戏房间,也是为多开
            resp.setOk(true);
            resp.setReason("禁止多开游戏页面");
            resp.setMessage("repeatConnection");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            return;
        }
        //4.设置当前玩家上线
        onlineUserManager.enterGameRoom(user.getUserId(), session);
        //5.把两个玩家加入到匹配队列中
        //当前这个逻辑是在game_room.html页面加载的时候进行的
        //前面的创建房间匹配过程,是在game_hall.html页面完成的
        //因此前面在匹配上队手之后,需要经过页面跳转,来到game_room.html才算正式进入游戏房间
        //才算玩家准备就绪
        //执行到当前逻辑,说明玩家已经跳转成功了
        //页面跳转,很有可能出现失败的情况
        synchronized (room) {
            if (room.getUser1() == null) {
                //第一个玩家还尚未加入房间
                //就把当前连上的websocket的玩家作为玩家1,加入到房间中
                room.setUser1(user);
                //先连接进入房间的玩家作为先手
                room.setWhiteUser(user.getUserId());
                System.out.println("玩家 " + user.getUserName() + " 已经准备就绪 作为玩家1");
                return;
            }
            if (room.getUser2() == null) {
                //第二个玩家还尚未加入房间
                //就把当前连上的websocket的玩家作为玩家2,加入到房间中
                room.setUser2(user);
                System.out.println("玩家 " + user.getUserName() + " 已经准备就绪 作为玩家2");
                //当两个玩家都加入成功之后,就让服务器,给这两个玩家返回websocket的响应数据
                //通知这两个玩家游戏双方都已经准备好了
                //通知玩家1
                noticeGameReady(room,room.getUser1(),room.getUser2());
                //通知玩家2
                noticeGameReady(room,room.getUser2(),room.getUser1());
                return;
            }
        }
        //6.此时如果又用玩家尝试连接,就提示报错
        //这种情况理论上是不存在的,为了让程序更加健壮,还是给一个判定和提示
        resp.setOk(false);
        resp.setReason("当前房间已满,您不能加入");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
    }
4.玩家下线的处理

下线的时候要注意针对多开情况的判定

复制代码
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            //此处我们简单处理在断开连接的时候就不给客户端返回响应了
            return;
        }
        WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
        if (exitSession == session) {
            //加上这个判定,目的是为了避免再多开的情况下,第二个用户退出连接动作,导致我们第一个用户的会话被删除
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户 " + user.getUserName() + " 游戏房间连接异常!");
​
        //通知对手获胜了
        noticeThatUserWin(user);
    }
5.修改 Room 类

给 Room 类⾥加上 RoomManager 实例 和 UserMapper 实例

• Room 类内部要在游戏结束的时候销毁房间,需要⽤到 RoomManager。

• Room 类内部要修改玩家的分数,需要⽤到 UserMapper。

• 由于我们的 Room 并没有通过 Spring 来管理,因此内部就⽆法通过 @Autowired 来⾃动注⼊。需要⼿动的通过 SpringBoot 的启动类来获取⾥⾯的对象。

6.处理落⼦请求

实现 handleTextMessage

复制代码
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    //1.先从session里拿到当前用户的身份信息
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        System.out.println("[handleTextMessage] 当前玩家尚未登录! ");
        return;
    }
    //2.根据玩家id获取到房间对象
    Room room = roomManager.getRoomByUserId(user.getUserId());
    //3.通过room对象处理这次具体请求
    room.putChess(message.getPayload());
​
}
7.实现对弈功能

实现 room 中的 putChess ⽅法.

• 先把请求解析成请求对象。

• 根据请求对象中的信息,往棋盘上落⼦。

• 落⼦完毕之后,为了⽅便调试,可以打印出棋盘的当前状况。

• 检查游戏是否结束。

• 构造落⼦响应,写回给每个玩家。

• 写回的时候如果发现某个玩家掉线,则判定另⼀⽅为获胜。

• 如果游戏胜负已分,则修改玩家的分数,并销毁房间。

复制代码
//通过这个方法处理一次落子操作
    public void putChess(String reqJson) throws IOException {
        //1.记录当前落子位置
        GameRequest request = objectMapper.readValue(reqJson, GameRequest.class);
        GameResponse response = new GameResponse();
        //当前这个子是玩家1落的,还是玩家2落得,根据这个玩家一还是玩家二来决定数组中是填1还是2
​
        int chess = (request.getUserId() == user1.getUserId()) ? 1 : 2;
        int row = request.getRow();
        int col = request.getCol();
        if (board[row][col] != 0) {
            //在客户端针对重复落子已经进行过判定,此处为了代码更加健壮,在服务器在判定一次
            System.out.println("当前位置 (" + row + "," + col + ") 已经有子了");
            return;
        }
        board[row][col] = chess;
        //2打印出当前的棋盘信息,方便来观察局势,也方便后面验证胜负关系的判定
        printBoard();
        //3.进行胜负判定
        int winner = checkWinner(row, col, chess);
        //4.给房间中的所有客户端都返回响应
        response.setMessage("putChess");
        response.setUserId(request.getUserId());
        response.setRow(row);
        response.setCol(col);
        response.setWinner(winner);
​
        //要想给用户放送websocket数据,就需要获取到这个用户的WebSocketSession
        WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
        WebSocketSession session2 = onlineUserManager.getFromGameRoom(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 掉线!");
        }
        //把响应构成的json字符串,通过session进行传输
        String respJson = objectMapper.writeValueAsString(response);
        if (session1 != null) {
            session1.sendMessage(new TextMessage(respJson));
        }
        if (session2 != null) {
            session2.sendMessage(new TextMessage(respJson));
        }
​
        // 5. 如果玩家胜负已分, 就把 room 从管理器中销毁
        if (response.getWinner() != 0) {
            //胜负已分
            System.out.println("游戏结束, 房间即将销毁! roomId: " + roomId + " 获胜⽅为: " + response.getWinner());
            //更新获胜方和失败方的信息
            int winUserId = response.getWinner();
            int loseUserId = (response.getWinner() == user1.getUserId()) ? user2.getUserId() : user1.getUserId();
            userMapper.userWin(winUserId);
            userMapper.userLose(loseUserId);
​
            roomManager.remove(roomId, user1.getUserId(), user2.getUserId());
        }
    }
8.实现打印棋盘的逻辑
复制代码
private void printBoard() {
        System.out.println("打印棋盘信息: " + roomId);
        System.out.println("===========================");
        for (int r = 0; r < MAX_ROW; r++) {
            for (int c = 0; c < MAX_COL; c++) {
                //针对一行的若干列,不要打印换行
                System.out.print(board[r][c] + " ");
            }
            System.out.println();
        }
        System.out.println("===========================");
    }
9.实现胜负判定

• 如果游戏分出胜负,则返回玩家的 id。如果未分出胜负,则返回 0。

• 棋盘中值为 1 表⽰是玩家 1 的落⼦,值为 2 表⽰是玩家 2 的落⼦。

• 检查胜负的时候,以当前落⼦位置为中⼼,检查所有相关的⾏、列、对⻆线即可。不必遍历整个棋盘。

复制代码
private int checkWinner(int row, int col, int chess) {
        //TODO 一会在实现,使用这个方法.
        //以row, col为中⼼
        // 1. 检查所有的⾏(循环五次)
        for (int c = col - 4; c <= col; c++) {
            //针对其中一种情况,来判定五子是不是连在一起了
            //不光这五个子得连着,颜色还得一致
            try {
                if (board[row][c] == chess
                        && board[row][c + 1] == chess
                        && board[row][c + 2] == chess
                        && board[row][c + 3] == chess
                        && board[row][c + 4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                //如果出现数组下标越界得情况,就在这里直接忽略这个异常
                continue;
            }
​
        }
​
        //2.检查所有列
        for (int r = row - 4; r <= row; r++) {
            //针对其中一种情况,来判定五子是不是连在一起了
            //不光这五个子得连着,颜色还得一致
            try {
                if (board[r][col] == chess
                        && board[r + 1][col] == chess
                        && board[r + 2][col] == chess
                        && board[r + 3][col] == chess
                        && board[r + 4][col] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                //如果出现数组下标越界得情况,就在这里直接忽略这个异常
                continue;
            }
​
        }
​
        //3.左对角线
        for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
            //针对其中一种情况,来判定五子是不是连在一起了
            //不光这五个子得连着,颜色还得一致
            try {
                if (board[r][c] == chess
                        && board[r + 1][c + 1] == chess
                        && board[r + 2][c + 2] == chess
                        && board[r + 3][c + 3] == chess
                        && board[r + 4][c + 4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                //如果出现数组下标越界得情况,就在这里直接忽略这个异常
                continue;
            }
​
        }
        //4.右对角线
        for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
            //针对其中一种情况,来判定五子是不是连在一起了
            //不光这五个子得连着,颜色还得一致
            try {
                if (board[r][c] == chess
                        && board[r + 1][c - 1] == chess
                        && board[r + 2][c - 2] == chess
                        && board[r + 3][c - 3] == chess
                        && board[r + 4][c - 4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                //如果出现数组下标越界得情况,就在这里直接忽略这个异常
                continue;
            }
​
        }
​
        //胜负未分,返回0
        return 0;
    }
10.处理玩家中途退出
复制代码
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            //此处我们简单处理在断开连接的时候就不给客户端返回响应了
            return;
        }
        WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
        if (exitSession == session) {
            //加上这个判定,目的是为了避免再多开的情况下,第二个用户退出连接动作,导致我们第一个用户的会话被删除
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户 " + user.getUserName() + " 游戏房间连接异常!");
​
        //通知对手获胜了
        noticeThatUserWin(user);
    }
    
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            //此处我们简单处理在断开连接的时候就不给客户端返回响应了
            return;
        }
        WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
        if (exitSession == session) {
            //加上这个判定,目的是为了避免再多开的情况下,第二个用户退出连接动作,导致我们第一个用户的会话被删除
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户 " + user.getUserName() + " 离开游戏房间!");
​
        //通知对手获胜了
        noticeThatUserWin(user);
    }

六、总结

本项目是一款基于 Java 的网页版五子棋在线对战游戏,实现了用户注册、登录、天梯匹配、实时对战和战绩记录等核心功能。后端采用 Spring Boot 框架整合 MyBatis 进行数据持久化管理,通过 WebSocket 实现低延迟的实时通信,保证了玩家在匹配和对战过程中的流畅体验。项目结构清晰、扩展性强,适合作为在线棋类游戏的技术基础。

相关推荐
喂完待续7 分钟前
【序列晋升】29 Spring Cloud Task 微服务架构下的轻量级任务调度框架
java·spring·spring cloud·云原生·架构·big data·序列晋升
benben04410 分钟前
ReAct模式解读
java·ai
轮到我狗叫了1 小时前
牛客.小红的子串牛客.kotori和抽卡牛客.循环汉诺塔牛客.ruby和薯条
java·开发语言·算法
yudiandian20141 小时前
【QT 5.12.12 下载 Windows 版本】
开发语言·qt
高山有多高1 小时前
详解文件操作
c语言·开发语言·数据库·c++·算法
狂奔的sherry2 小时前
单例模式(巨通俗易懂)普通单例,懒汉单例的实现和区别,依赖注入......
开发语言·c++·单例模式
Volunteer Technology2 小时前
三高项目-缓存设计
java·spring·缓存·高并发·高可用·高数据量
EnigmaCoder2 小时前
【C++】引用的本质与高效应用
开发语言·c++
栗子~~2 小时前
bat脚本- 将jar 包批量安装到 Maven 本地仓库
java·maven·jar
Mr.Entropy3 小时前
ecplise配置maven插件
java·maven