目录
一、 五子棋对战平台项目简介
该项目是一个基于 前后端分离架构 的网页版五子棋对战平台,使用 Spring Boot 作为后端框架,结合 WebSocket 通信协议和 MyBatis 数据持久化框架,部署于云服务器,实现了用户注册登录、实时匹配对战、强制登录等核心功能。
✅ 项目技术栈
-
后端:Spring Boot + WebSocket + MyBatis + MySQL
-
前端:原生 HTML/CSS/JavaScript
-
通信:WebSocket 双向通信实现实时对战
-
部署环境:Linux 云服务器
✅ 核心功能
-
用户管理模块
-
注册与登录
-
强制登录:防止多端同时登录同一账号
-
用户信息显示(简洁展示用户名、分数等基础信息)
-
-
对战管理模块
-
游戏大厅:支持实时玩家匹配
-
游戏房间:实现双人对弈流程
-
落子同步:基于 WebSocket 协议实现消息实时推送,第一时间同步双方落子
-
胜负判定:自动判断五子连珠胜利,结束当前对局
-
-
系统能力
-
支持多人同时匹配并游戏,互不干扰
-
项目部署在云服务器,可供外网访问体验
-
实现了对战连接断开处理和异常登录的基本保护机制
-
🧩 项目特点
-
实现了最基础、最核心的五子棋对战逻辑,整体逻辑清晰、模块划分合理
-
虽然玩家信息展示较为简洁,但重点功能完整可靠
-
项目适合作为入门级 WebSocket 对战游戏的教学/实战案例
二、测试方法
1.功能测试(手工测试)
测试用例
功能测试结果:经手工测试后测试用例100%通过。
部分截图:
2.自动化测试
自动化测试用例和上面功能测试的测试用例一样,不再进行编写了。
编写自动化脚本
1)引入依赖
安装 Selenium 库(Maven 依赖):
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.0.0</version>
</dependency>
安装 WebDriverManager(自动驱动管理)
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
2)创建对应的目录文件和类
在编写自动化测试中除了实现人机下棋的情况比较复杂(这里的机器下棋只是简单的使用了随机下棋的方法,并没有用ai算法),其他都很好实现。
3)自动化实现结果(视频)
见下视频:
五子棋自动化测试
补充:视频上的流程有几个没有覆盖完整比如对战的异常掉线等,可以调整启动类RunTest中执行的顺序来实现,这里就不再实现了。
4)具体实现代码
启动类:
/*
启动类
*/
public class RunTest {
public static void main(String[] args) throws InterruptedException {
//注册部分
SignUp signUp = new SignUp();
WebDriver driver1 = Utils.driver;
signUp.Fail();
signUp.Success();
//登录部分
SignIn signIn = new SignIn();
signIn.Suceess();
//进入匹配部分进行单人匹配并开始游戏
Match match = new Match();
match.SingleMatch();
match.startAutoPlay();
Thread.sleep(2000);
//对战房间强制登录部分
GameRoom gameRoom = new GameRoom();
gameRoom.WithoutSginIn();
Thread.sleep(1000);
//强制登录时已经删掉cookie了,需要进行重新登录
signIn.Suceess();
Thread.sleep(1000);
//匹配房间强制登录部分
match.withoutLogin();
//关闭浏览器
driver1.quit();
}
}
common包下的IP类:
public class IP {
//存储服务器IP
public static final String ip = "8.138.45.51:9091";
}
common包下的Utils类:
public class Utils {
public static WebDriver driver;
public static WebDriver createDriver() {
//判断是否存在driver,不存在就创建,存在就返回已经有的
if (driver == null) {
//设置
WebDriverManager.chromedriver().setup();
//对浏览器进行设置
ChromeOptions options = new ChromeOptions();
//允许访问浏览器所有的资源
options.addArguments("--remote-allow-origins=*");
driver = new ChromeDriver(options);
//全局查找元素进行显式等待
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(3));
}
return driver;
}
public Utils(String url){
//赋值
driver = createDriver();
//访问url
driver.get(url);
}
}
tests包下的GameRoom类:
public class GameRoom extends Utils {
public static String url="http://"+ IP.ip+"/game_room.html";
public GameRoom(){
super(url);
}
//封装弹窗操作
public void WaitAlert(WebDriver driver){
//显式等待弹窗的出现
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
Alert alert =null;
try {
//检测弹窗的存在,存在会显式返回一个有效的alert对象
alert = wait.until(ExpectedConditions.alertIsPresent());
alert.accept();
}catch (Exception e){
System.out.println("MatchFail");
}
}
//游戏房间强制登录
public void WithoutSginIn() throws InterruptedException {
//删除所有cookie
driver.manage().deleteAllCookies();
//刷新
driver.navigate().refresh();
WaitAlert(driver);
Thread.sleep(2000);
}
}
tests包下的Match类(最复杂):
public class Match extends Utils {
public static String url="http://"+ IP.ip+"/game_hall.html";
private JavascriptExecutor js;
private boolean isFirstPlayer = false;
public Match(){
super(url);
js = (JavascriptExecutor) driver;
}
//封装弹窗操作
public void WaitAlert(WebDriver driver){
//显式等待弹窗的出现
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
Alert alert =null;
try {
//检测弹窗的存在,存在会显式返回一个有效的alert对象
alert = wait.until(ExpectedConditions.alertIsPresent());
alert.accept();
}catch (Exception e){
System.out.println("MatchFail");
}
}
/*
删除cookie强制登录
*/
public void withoutLogin() throws InterruptedException {
//删除所有cookie
driver.manage().deleteAllCookies();
//刷新
driver.navigate().refresh();
WaitAlert(driver);
Thread.sleep(2000);
}
/*
匹配并开始游戏,并判断哪方是先手
*/
public void SingleMatch() throws InterruptedException {
//谷歌了浏览器的弹窗会阻碍自动化进行,所以进行强制等待
Thread.sleep(6000);
//获取标签信息
WebElement element = driver.findElement(By.cssSelector("#match-button"));
String text = element.getText();
assert text.equals("开始匹配");
//点击匹配
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.findElement(By.cssSelector("#match-button")).click();
// wait.until(ExpectedConditions.textToBe(By.cssSelector("#match-button"),"匹配中...(点击停止)"));
// 等待进入游戏房间(等待#screen元素出现)
wait = new WebDriverWait(driver, Duration.ofSeconds(30)); // 增加等待时间,因为匹配可能需要一段时间
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("#screen")));
// 等待一下,确保页面完全加载
Thread.sleep(2000);
// 判断是否为先手 - 修改判断逻辑
try {
// 检查是否有"轮到你落子"的提示,同时确认棋盘上没有任何棋子
String checkFirstScript =
"var screen = document.querySelector('#screen');" +
"var pieces = document.querySelectorAll('#chess div.piece');" +
"return {" +
" message: screen ? screen.textContent : ''," +
" pieceCount: pieces.length" +
"};";
Map<String, Object> result = (Map<String, Object>) js.executeScript(checkFirstScript);
String screenMessage = (String) result.get("message");
Long pieceCount = (Long) result.get("pieceCount");
// 只有当提示出现且棋盘上没有棋子时才是先手
isFirstPlayer = screenMessage.contains("轮到你落子") && pieceCount == 0;
System.out.println("屏幕提示: " + screenMessage);
System.out.println("棋盘上的棋子数: " + pieceCount);
System.out.println("是否为先手: " + isFirstPlayer);
} catch (Exception e) {
System.out.println("无法判断先后手: " + e.getMessage());
isFirstPlayer = false; // 默认为后手
}
}
private void playChess(int row, int col) {
try {
// 等待棋盘元素加载
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("#chess")));
// 注入JavaScript函数来模拟点击
String script = String.format(
"const chess = document.querySelector('#chess');" +
"const rect = chess.getBoundingClientRect();" +
"const x = rect.left + (%d * 30) + 15;" +
"const y = rect.top + (%d * 30) + 15;" +
"const clickEvent = new MouseEvent('click', {" +
" bubbles: true," +
" cancelable: true," +
" view: window," +
" clientX: x," +
" clientY: y," +
" offsetX: (%d * 30) + 15," +
" offsetY: (%d * 30) + 15" +
"});" +
"chess.dispatchEvent(clickEvent);",
col, row, col, row
);
js.executeScript(script);
// 等待一下,确保落子动作完成
Thread.sleep(1000);
} catch (Exception e) {
System.out.println("落子失败: " + e.getMessage());
}
}
private boolean isFirstMove(ArrayList<ArrayList<Long>> board) {
int pieceCount = 0;
for (ArrayList<Long> row : board) {
for (Long cell : row) {
if (cell != 0) pieceCount++;
}
}
return pieceCount <= 1; // 只有一个或没有棋子时是第一步
}
private boolean isMyTurn() {
try {
// 获取提示信息
String screenText = driver.findElement(By.cssSelector("#screen")).getText();
System.out.println("当前提示: " + screenText);
// 首先检查是否是游戏结束的消息
if (screenText.contains("你输了!") ||
screenText.contains("你赢了!") ||
screenText.contains("对方掉线,你获胜!")) {
return false; // 游戏结束时返回false,让主循环去处理结束逻辑
}
// 根据提示判断是否轮到我方下棋
if (screenText.contains("轮到你落子")) {
return true;
} else if (screenText.contains("轮到对方落子")) {
return false;
} else {
// 如果提示不明确,保持当前状态
System.out.println("无法判断当前轮次,等待下一次检查");
return false;
}
} catch (Exception e) {
System.out.println("检查轮次出错: " + e.getMessage());
return false;
}
}
public void startAutoPlay() throws InterruptedException {
System.out.println("开始自动对弈");
while (true) {
try {
Thread.sleep(1000); // 每秒检查一次
// 获取当前屏幕文本
String screenText = driver.findElement(By.cssSelector("#screen")).getText();
// 首先检查是否是游戏结束的情况
if (screenText.contains("你输了!") ||
screenText.contains("你赢了!") ||
screenText.contains("对方掉线,你获胜!")) {
System.out.println("游戏结束,原因: " + screenText);
// 等待返回大厅按钮出现并可点击
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
try {
WebElement returnButton = wait.until(ExpectedConditions.elementToBeClickable(
By.cssSelector("body > div.container > div > button")));
returnButton.click();
// 等待页面跳转并验证标题
wait.until(ExpectedConditions.titleIs("游戏大厅"));
String title = driver.getTitle();
assert title.equals("游戏大厅");
System.out.println("成功返回游戏大厅");
break;
} catch (Exception e) {
System.out.println("等待返回按钮出现或点击失败: " + e.getMessage());
}
continue; // 继续检查,直到可以返回大厅
}
// 如果游戏未结束,继续正常的游戏流程
if (isMyTurn()) {
System.out.println("轮到我方下棋");
autoPlay();
Thread.sleep(500); // 下棋后稍等片刻
} else {
System.out.println("等待对方下棋");
}
// 检查其他结束条件
String gameEndScript =
"return document.querySelector('.win-info') !== null || " +
"document.querySelector('.lose-info') !== null || " +
"document.querySelector('.tie-info') !== null;";
Boolean hasEndInfo = (Boolean) js.executeScript(gameEndScript);
if (hasEndInfo) {
System.out.println("检测到游戏结束信息");
// 等待返回大厅按钮出现并可点击
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
try {
WebElement returnButton = wait.until(ExpectedConditions.elementToBeClickable(
By.cssSelector("body > div.container > div > button")));
returnButton.click();
// 等待页面跳转并验证标题
wait.until(ExpectedConditions.titleIs("游戏大厅"));
String title = driver.getTitle();
assert title.equals("游戏大厅");
System.out.println("成功返回游戏大厅");
break;
} catch (Exception e) {
System.out.println("等待返回按钮出现或点击失败: " + e.getMessage());
}
}
} catch (InterruptedException e) {
e.printStackTrace();
break;
} catch (Exception e) {
System.out.println("自动下棋过程出错: " + e.getMessage());
Thread.sleep(1000); // 出错后等待一秒再继续
}
}
}
private void autoPlay() {
try {
// 随机选择一个位置下棋
int row = (int) (Math.random() * 15);
int col = (int) (Math.random() * 15);
// 验证位置是否已被占用
String verifyScript = String.format(
"var piece = document.querySelector('#chess div.piece[data-row=\"%d\"][data-col=\"%d\"]');\n" +
"return !piece;", // 返回true表示位置为空
row, col
);
Boolean isEmpty = (Boolean) js.executeScript(verifyScript);
if (isEmpty) {
System.out.println("准备在位置 [" + row + "][" + col + "] 下棋");
playChess(row, col);
} else {
System.out.println("位置 [" + row + "][" + col + "] 已被占用,重新尝试");
autoPlay(); // 递归尝试新的位置
}
} catch (Exception e) {
System.out.println("下棋出错: " + e.getMessage());
}
}
}
tests包下的SignIn类:
public static String url="http://"+ IP.ip+"/login.html";
public SignIn() {
super(url);
}
//封装弹窗操作
public void WaitAlert(WebDriver driver){
//显式等待弹窗的出现
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
Alert alert =null;
try {
//检测弹窗的存在,存在会显式返回一个有效的alert对象
alert = wait.until(ExpectedConditions.alertIsPresent());
// driver.switchTo().alert();
alert.accept();
}catch (Exception e){
System.out.println("SignUpFail");
}
}
/*
失败案例
*/
public void Fail(){
//通过刷新清空输入框
driver.navigate().refresh();
//1.用户名和密码未输入
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
//2.输入用户名不输入密码
driver.findElement(By.cssSelector("#userName")).sendKeys("zhangsan");
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
//3.输入密码不输入用户名
driver.navigate().refresh();
driver.findElement(By.cssSelector("#password")).sendKeys("123456");
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
//4.输入的用户名和密码错误
driver.navigate().refresh();
driver.findElement(By.cssSelector("#userName")).sendKeys("zhangsand");
driver.findElement(By.cssSelector("#password")).sendKeys("123456d");
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
//5.输入的用户名错误
driver.navigate().refresh();
driver.findElement(By.cssSelector("#userName")).sendKeys("zhangsand");
driver.findElement(By.cssSelector("#password")).sendKeys("123456");
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
//6.密码出错误
driver.navigate().refresh();
driver.findElement(By.cssSelector("#userName")).sendKeys("zhangsan");
driver.findElement(By.cssSelector("#password")).sendKeys("12345611");
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
// driver.findElement(By.cssSelector("#userName"));
}
/*
成功登录
*/
public void Suceess(){
driver.navigate().refresh();
driver.findElement(By.cssSelector("#userName")).sendKeys("zhangsan");
driver.findElement(By.cssSelector("#password")).sendKeys("123456");
driver.findElement(By.cssSelector("#submit")).click();
WaitAlert(driver);
//登录成功后页面跳转,判断页面元素是否存在
driver.findElement(By.cssSelector("body > div.nav"));
//获取页面标题
String title = driver.getTitle();
//字符串断言
assert title.equals("游戏大厅");
System.out.println(title);
}
}
tests包下的SignUp类:
public class SignUp extends Utils {
public static String url="http://"+IP.ip+"/register.html";
public SignUp(){
super(url);
}
//封装弹窗操作
public void WaitAlert(WebDriver driver){
//显式等待弹窗的出现
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
Alert alert =null;
try {
//检测弹窗的存在,存在会显式返回一个有效的alert对象
alert = wait.until(ExpectedConditions.alertIsPresent());
// driver.switchTo().alert();
alert.accept();
}catch (Exception e){
System.out.println("SignUpFail");
}
}
/*
失败注册的情况
*/
public void Fail(){
//通过刷新清空输入框
driver.navigate().refresh();
//1.账号和密码都不输入
driver.findElement(By.cssSelector("#register")).click();
WaitAlert(driver); //点击弹窗
//2.输入用户名不输入密码
driver.findElement(By.cssSelector("#userName")).sendKeys("cccc");
driver.findElement(By.cssSelector("#register")).click();
WaitAlert(driver);
//通过刷新清空输入框
driver.navigate().refresh();
//3.输入密码不输入用户名
driver.findElement(By.cssSelector("#password")).sendKeys("123456");
driver.findElement(By.cssSelector("#register")).click();
WaitAlert(driver);
//通过刷新清空输入框
driver.navigate().refresh();
//4.输入的用户名和密码并且用户名已经存在
driver.findElement(By.cssSelector("#userName")).sendKeys("zhangsan");
driver.findElement(By.cssSelector("#password")).sendKeys("123456");
driver.findElement(By.cssSelector("#register")).click();
WaitAlert(driver);
}
/*
正常注册
*/
public void Success(){
//通过刷新清空输入框
driver.navigate().refresh();
//4.输入的用户名和密码并且用户名已经存在
driver.findElement(By.cssSelector("#userName")).sendKeys("qqq");
driver.findElement(By.cssSelector("#password")).sendKeys("123456");
driver.findElement(By.cssSelector("#register")).click();
WaitAlert(driver);
}
}
对于自动化的完整代码可以参考码云:
3.性能测试
测试目标:利用梯度线程组,不断改变线程总数,对登录,匹配和用户信息的功能进行性能测试,最后得到这些功能的最大负载与服务器崩溃最大线程数。
1)请求与参数设置
其中梯度线程组与其他几个监视器需要通过插件来下载,这里就不过多赘述。
梯度线程参数设置:
解释: 此配置将 25 个线程分 5 批,每秒上线 5 个线程,每批线程在 1 秒内均匀启动,然后保持并发运行 5秒,最后 每秒关闭 5 个线程,总共运行时长大约 15 秒。
WebSocket请求响应采样器
WebSocket请求响应采样器需要在插件中搜索 WebSocket Samplers by Peter Doornbosch 就可以下载了。下面是参数设置:
其他的取样器,配置原件等就不展示了。
2)负载测试
经过多轮测试,得到了不同线程数的情况:
并发线程数 | 平均响应时间 | 错误率 | 吞吐量 | 稳定性评估 |
---|---|---|---|---|
115 | 260 ms | 0.03% | 203/sec | ✅ 稳定优 |
130 | 330 ms | 0.32% | 203/sec | ✅ 稳定 |
140 | 556 ms | 0.87% | 138/sec | ⚠ 临界不稳 |
150+ | 600ms~6s+ | >1.5% | <100 | ❌ 超载明显 |
170 | 1543 ms | 2.28% | 60.2/sec | ❌ 进入不可用状态 |
当并发线程数增加到170时,状态明显不稳。
结论:
当前系统在并发 140 时,已表现出吞吐下降、错误率升高、尾部请求严重挂起等现象,说明已超过稳定承载区间,进入“不可持续负载”状态。系统最大稳定负载建议为:130 并发线程,140 已进入性能下降区域,170 明显超载。
注:可能并发数达到170也能达到很好的状态,但经过多轮测试,这也只是少数的情况。