从零写了个五子棋,然后约着同时们一起摸鱼,vue3+nestjs+websocket

1,179 阅读5分钟

功能

  • 默认有一个房间,房间允许有多人进入
  • 房间里有两个座位可以坐下,其余没坐下的人可以在房间观战
    • 座位一:执黑棋先行;座位二:执白棋
  • 一局对局没结束时,座位上的人可以站起来,由房间里观战的人坐下接着下
  • 有重开、悔棋等操作

最终效果

开了三个窗口模拟三个用户,从左往右依次代表:执黑棋 -> 执白棋 -> 旁观者

五子棋终.gif

方案设计

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
  • 生成文件目录如下

image.png

  • 修改main.ts文件,在bootstrap方法中写入:
app.useWebSocketAdapter(new WsAdapter(app));

image.png

  • 修改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>

image.png

绘制棋子

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>

  • 最后落下的一个棋子给了个红色阴影(非常有必要的,就不细说了) image.png

落子 + 获胜逻辑

  • 有棋子落下的时候,将落子坐标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>

五子棋.gif

引入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…

image.png