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

发布于:2025-07-22 ⋅ 阅读:(16) ⋅ 点赞:(0)

目录

一、项⽬背景

二、关键技术

三、WebSocket

1.引入

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

四、需求分析和概要设计

1.用户模块

2.匹配模块

3.对战模块

五、项目实现

1.创建项目

2.用户模块

设计数据库

配置 MyBatis

创建实体类

创建 UserMapper

前后端交互接⼝

客户端开发

服务器开发

3.匹配模块

前后端交互接口

客户端开发

服务器开发

1.创建并注册 MatchAPI 类

2.实现⽤户管理器

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

4.处理连接成功

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

6.创建房间类

7.创建房间管理器

8.处理连接关闭

9.处理连接异常

4.对战模块

前后端交互接口

客户端开发

服务器开发

1.创建并注册 GameAPI 类

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

3.处理连接成功

4.玩家下线的处理

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 实现低延迟的实时通信,保证了玩家在匹配和对战过程中的流畅体验。项目结构清晰、扩展性强,适合作为在线棋类游戏的技术基础。


网站公告

今日签到

点亮在社区的每一天
去签到