功能
- 默认有一个房间,房间允许有多人进入
- 房间里有两个座位可以坐下,其余没坐下的人可以在房间观战
- 座位一:执黑棋先行;座位二:执白棋
- 一局对局没结束时,座位上的人可以站起来,由房间里观战的人坐下接着下
- 有重开、悔棋等操作
最终效果
开了三个窗口模拟三个用户,从左往右依次代表:执黑棋 -> 执白棋 -> 旁观者
方案设计
nestjs服务端
- 建立一个map,用来存储客户端的连接信息
- key -> 用户唯一id:userId
- value -> client (看后面代码就能理解)
- handleConnection(有客户端连接时会触发)触发时,将map.set(userId, client)
- handleDisconnect(有客户端连接断开时触发)触发时,map.delete(userId)
- @SubscribeMessage(接收到客户端的消息时触发)触发时,遍历map,将接收到的消息推送给所有连接中的客户端
web客户端
- 绘制一个 rows * cols 的棋盘
- 维护一个数组,按顺序存储每一步棋的坐标,悔棋、获胜等通过判断该数组做判断
- 每下一步棋的时候做判断
- 判断棋子是否能在该位置落下
- 落子后,判断水平、垂直、左斜线、右斜线之中的其中一条线上是否满足同色五子相连获胜
- 如果未结束,提醒下一步的棋手落子;如果结束,则不能再落子
websocket消息格式
interface WsMessage {
type: "fallon" | "seats" | "end";
chessPieces: ChessPiece[]; // 落子数组
seats: Seat[]; // 座位情况
winnerColor: Color; // 获胜棋子
}
搭建项目
nestjs服务端
- 创建项目
npm i -g @nestjs/cli
nest new server
cd server
npm i
npm i @nestjs/platform-ws @nestjs/websockets // websocket所需插件
项目中启动websocket服务
- 切到src目录下,执行命令创建gateway文件
nest g gateway gobang-ws
- 生成文件目录如下
- 修改main.ts文件,在bootstrap方法中写入:
app.useWebSocketAdapter(new WsAdapter(app));
- 修改gobang-ws.gateway.ts文件,自定义websocket端口
@WebSocketGateway(5501)
OK,到这一个nestjs服务已经启动成功了
写入五子棋逻辑
import {
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
@WebSocketGateway(5501)
export class GobangWsGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
/**
* 存储棋局信息,用于在有客户端连接时,同步当前棋局信息
*/
private chessPieces;
private winner;
private seats
/**
* 存储所有连接中的客户端
*/
private clientMap = new Map();
/**
* 有客户端连接时会触发
*
* @param client
* @returns
*/
handleConnection(client: any) {
// 客户端连接时,将client信息set到clientMap;并同步最新棋局信息
}
/**
* 客户端断开连接时会触发
*
* @param client
*/
handleDisconnect(client: any) {
// 断开连接将该用户在clientMap中移除
}
/**
* 订阅客户端发送的message类型的消息
*
* @param client
* @param data 消息内容
*/
@SubscribeMessage('message')
handleMessage(client: any, data: any) {
// 收到客户端的消息后:1、将消息推送给房间内的其他客户端;2、更新棋局信息
}
}
web客户端
绘制棋盘
<script setup lang="ts">
// 棋盘纵横格子数
const ROWS = 20
const COLS = 20
</script>
<template>
<!-- 棋盘盒子 -->
<div
class="w-[500px] h-[500px] mt-[50px] mx-auto relative"
style="border-top: 1px solid #d4d4d4; border-left: 1px solid #d4d4d4"
>
<!-- 绘制ROWS * COLS个棋盘小格子 -->
<div v-for="y in ROWS" :key="y" class="flex">
<div
v-for="x in COLS"
:key="x"
class="w-[25px] h-[25px] cursor-pointer"
style="
border-right: 1px solid #d4d4d4;
border-bottom: 1px solid #d4d4d4;
"
@click="fallOn(y, x)"
></div>
</div>
</div>
</template>
<style scoped></style>
绘制棋子
enum Color {
white = "white",
black = "black",
}
interface ChessPiece {
color: Color;
position: [number, number]; // 棋子坐标
}
// 通过一个数组维护已经落下的棋子
const chessPieces = ref<ChessPiece[]>([]);
// mock一个已经落下的棋子数组看效果
chessPieces.value = [
{
color: Color.white,
position: [3, 5],
},
{
color: Color.black,
position: [3, 4],
},
{
color: Color.white,
position: [7, 5],
},
];
<template>
<!-- 棋盘盒子 -->
<div
class="w-[500px] h-[500px] mt-[50px] mx-auto relative"
style="border-top: 1px solid #d4d4d4; border-left: 1px solid #d4d4d4"
>
<!-- 绘制ROWS * COLS个棋盘小格子 -->
<!-- 绘制落下的棋子 -->
<div
v-for="(piece, index) in chessPieces"
:key="index"
class="w-[25px] h-[25px] rounded-[100%] absolute"
:style="{
left: `${(piece.position[1] - 1) * 25}px`,
top: `${(piece.position[0] - 1) * 25}px`,
background: piece.color,
boxShadow:
index === chessPieces.length - 1
? 'red 0px 0px 10px'
: 'gray 0px 0px 10px',
}"
></div>
</div>
</template>
- 最后落下的一个棋子给了个红色阴影(非常有必要的,就不细说了)
落子 + 获胜逻辑
- 有棋子落下的时候,将落子坐标push到chessPieses数组中
- 同时检查当前棋子落下后,是否有形成五子相连;如果有,则棋局结束,并显示获胜方
interface CheckResOptions {
y: number;
x: number;
color: Color;
chessPieces: ChessPiece[];
direction: any;
}
/**
* 落子
*
* @param y
* @param x
*/
const fallOn = (y: number, x: number) => {
// 做落子判断,判断是否能落子,在后面websocket章节会详细说明
// 通过chessPieces数组中的最后一个元素,判断当前落下的棋子颜色,然后push到chessPieces数组中
checkDirections();
};
/**
* 从四个方向检查是否有同色棋子五子相连
*/
const checkDirections = (): void => {
// 横向
const direction1 = [
[0, 1],
[0, -1],
];
// 斜向右上
const direction2 = [
[-1, 1],
[1, -1],
];
// 斜向左上
const direction3 = [
[-1, -1],
[1, 1],
];
// 纵向
const direction4 = [
[-1, 0],
[1, 0],
];
const directions = [direction1, direction2, direction3, direction4];
const lastPiece = chessPieces.value[chessPieces.value.length - 1];
const { color, position } = lastPiece;
const [y, x] = position;
// 从四个方向判断是否有同色棋子五子相连
for (let i = 0; i < directions.length; i++) {
checkRes(
{
y,
x,
color,
chessPieces: chessPieces.value.slice(0, chessPieces.value.length - 1),
direction: directions[i],
},
1
);
}
};
/**
* 从某个方向递归判断,是否有形成五子相连,如果有,则对局结束
*
* @param options
* @param continuous 相连的棋子数量
*/
const checkRes = (options: CheckResOptions, continuous: number) => {
const { y, x, color, chessPieces, direction } = options;
if (continuous >= 5) {
winner.value = color;
ElMessage({
message: `${colorText[color]}胜利`,
type: "warning",
});
return;
}
for (let i = 0; i < direction.length; i++) {
const newY = y + direction[i][0];
const newX = x + direction[i][1];
if (newY < 1 || newX < 1 || newY > ROWS || newX > ROWS) {
continue;
}
const nextPiece = chessPieces.find(
(piece) =>
piece.position[0] === newY &&
piece.position[1] === newX &&
piece.color === color
);
if (nextPiece) {
const newPieces = chessPieces.filter(
(piece) => !(piece.position[0] === y && piece.position[1] === x)
);
checkRes(
{ y: newY, x: newX, color, chessPieces: newPieces, direction },
continuous + 1
);
}
}
};
/**
* 下一局
* 清空棋子数组,清空获胜者信息
*/
const reopen = () =>{
chessPieces.value = []
winner.value = null
}
<template>
<div class="flex justify-center mt-[50px]">
<el-button @click="reopen" type="primary">下一局</el-button>
</div>
<!-- 棋盘盒子 -->
</template>
引入websocket,实现双人对战,以及允许多人观战逻辑
- 定义相关变量
const ws = ref<any>(null); // websocket连接实例
const role = ref<Color | "">(); // Color:为棋手,""为旁观者
const userId = ref<string>('') // 账号
// 房间固定两个座位,userId没值,代表该座位没人
const seats = ref<Seat[]>([
{
desc: "座位1",
color: Color.black,
userId: "",
},
{
desc: "座位2",
color: Color.white,
userId: "",
},
]);
- 建立websocket连接
- websocket断开或异常后自动重连
- onmessage收到服务端消息后根据type做对应处理
const checkUserId = (): any => {
userId.value = localStorage.getItem("userId") || '';
if (userId.value) {
return
}
ElMessageBox.prompt("请输入您的用户名称", "登录", {
confirmButtonText: "确认",
inputPlaceholder: "只能包含数字和字母",
inputPattern: /[0-9a-zA-Z]/,
inputErrorMessage: "请输入包含数字或字母的用户名称",
showCancelButton: false,
showClose: false,
}).then(({ value }) => {
const suffix = new Date().getTime();
const userId = `${value}_${suffix}`;
localStorage.setItem("userId", userId);
initWs();
});
};
const initWs = () => {
checkUserId()
if (!userId.value) {
return;
}
ws.value = new WebSocket("ws://127.0.0.1:5501", [userId.value]);
ws.value.onopen = () => {
console.log("连接成功");
};
ws.value.onmessage = (res: any) => {
try {
const wsMessage: WsMessage = JSON.parse(res.data);
if (wsMessage.type === "fallon") {
chessPieces.value = wsMessage.chessPieces || [];
}
if (wsMessage.type === "seats") {
seats.value = wsMessage.seats || [];
seats.value.forEach(item => {
if (item.userId === userId.value) {
role.value = item.color
}
})
}
if (wsMessage.type === "end") {
if (wsMessage.winnerColor) {
ElMessage({
message: `${colorText[wsMessage.winnerColor]}胜利`,
type: "warning",
});
}
winner.value = wsMessage.winnerColor;
}
} catch (error) {}
};
ws.value.onclose = () => {
console.log("连接关闭");
ws.value = null;
oncloseTimeout = setTimeout(() => {
onerrorTimeout && clearTimeout(onerrorTimeout);
initWs();
}, 3000);
};
ws.value.onerror = () => {
console.log("连接异常");
ws.value = null;
onerrorTimeout = setTimeout(() => {
oncloseTimeout && clearTimeout(oncloseTimeout);
initWs();
}, 3000);
};
};
烂尾
本来想把逻辑思路写更详细一点的,发现写着写着,又好像没那必要,大致都是一些简单的逻辑配合websocket的使用。
仓库地址
感兴趣的小伙伴可以直接去gitee上下载来看看,最后觉得还行的话,再来个一键三连也未尝不可(>-<)
gitee地址:gitee.com/jun96/goban…