我在 React 中构建国际象棋应用程序的经验

嗨,我叫 Fred,我是一名国际象棋选手,在过去的 10 个月里,我一直在学习使用The Odin Project进行编码。在熟悉了 React 之后,我认为尝试使用 React 构建一个国际象棋应用程序将是一个有趣的挑战。我也有兴趣找到我作为入门级开发人员的第一份工作,并且很想与任何正在招聘或有进入该领域的建议的人聊天。

  • 复制链接:https://replit.com/@FredLitt/Chess-Engine#src/chessBoard.js

  • Github 链接:https://github.com/FredLitt/Chess-Engine

  • 电子邮件:fredolitt@gmail.com

应用程序做什么

1\。支持国际象棋的所有基本规则

  1. 棋子能够执行所有合法的移动,并且可能的移动在可能的移动方块上以圆形突出显示。最后播放的棋子也被突出显示。

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--FQGTbU92--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/vlldxxc9cqld93qys3tm.PNG)

湾。 Castling 在任一方向都受到支持,如果国王或相应的车已经移动,或者如果国王正在检查或将通过检查移动,则无法进行。

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--xX5R5_w5--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/l4673dqm89qdjjozm0tr.gif)

C。En passant,由于必须满足的条件数量,这被证明是游戏编程中最具挑战性的方面之一。

根据 Wiki 链接:

  • 俘虏必须在第五阶;

  • 被捕获的棋子必须在相邻的文件上,并且必须在一次移动中刚刚移动了两个方格(即双步移动);

  • 只能在敌兵两步走后立即在走中进行擒拿;否则,将失去捕获它的权利_en passant_。

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--u2xmMmud--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/agg3b51wcambp5c9gqwp.gif)

d。将死:当被攻击的国王的军队无法挽救他们的领袖时。

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--TIXI-4zR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev- to-uploads.s3.amazonaws.com/uploads/articles/02p357igxzj15rhm3crg.gif)

2\。应用功能

一个。移动符号和捕获的片段跟踪器

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--feAxnKJP--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/183xg3z7nizzx42jg21m.gif)

湾。典当推广

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--qID1m2e2--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/1v5w9dulkqz5gz3dzgbe.gif)

C。游戏结束检测。当前游戏识别将死和相持,并相应地创建一个新的游戏弹出窗口。

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--mPYIpvmR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/ikpdiajarxcoehaqcnlp.gif)

d。更改板主题:看看那些漂亮的颜色

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--czkOHvov--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/fmo0lezpk3mcytob1gdk.gif)

e.收回按钮

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--MCsQIHvs--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/93faqqirpleul61li7kz.gif)

应用程序是如何构建的

1\。游戏逻辑

一个。董事会班

棋盘以“方形”对象的 2d 数组表示,每个对象都有一个唯一的坐标以及是否存在一块(它们本身就是对象)。

export class Board {
  constructor() {
    this.squares = []
    for (let row = 0; row < 8; row++) {
      const boardRow = []
      for (let col = 0; col < 8; col ++){
        const square = {
          piece: null,
          coordinate: [row, col]
          }
        boardRow.push(square)
        }
      this.squares.push(boardRow)
    }

进入全屏模式 退出全屏模式

董事会有各种各样的方法来操纵自己并收集有关当前董事会位置的信息......

getPossibleMoves(pieceToMove, fromSquare){
    const searchOptions = {
      board: this,
      fromSquare: fromSquare,
      squaresToFind: "possible moves"
    }
    this.selectedPiece.possibleMoves = pieceToMove.findSquares
    (searchOptions)
    this.markPossibleMoveSquares()
  }

updateBoard(startSquare, endSquare){
    startSquare.piece = null
    endSquare.piece = this.selectedPiece.piece
  }

进入全屏模式 退出全屏模式

湾。件类

每种类型的作品都有自己的类别,能够

  • 查找当前控制的方块

  • 找到它可能移动到的所有方格

直到我开始编写确定国王移动的逻辑时,我才意识到这两件事有多么不同。例如:

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--_R4viviH--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/27639n7jkf4rire8bv78.png)

黑方无法将马移动到 X 格,因为这会暴露黑王,但该格仍然是受控方格,因为白王也无法移动到那里

因此,每件作品都有针对每种情况的独特方法。在任何一种情况下,都会返回一个坐标数组。

findSquares({board, fromSquare, squaresToFind}) {
    const [fromRow, fromCol] = fromSquare
    const knightMoves = {
      "NorthOneEastTwo": [fromRow - 1, fromCol + 2],
      "NorthTwoEastOne": [fromRow - 2, fromCol + 1],
      "SouthOneEastTwo": [fromRow + 1, fromCol + 2],
      "SouthTwoEastOne": [fromRow + 2, fromCol + 1],
      "NorthOneWestTwo": [fromRow - 1, fromCol - 2],
      "NorthTwoWestOne": [fromRow - 2, fromCol - 1],
      "SouthOneWestTwo": [fromRow + 1, fromCol - 2],
      "SouthTwoWestOne": [fromRow + 2, fromCol - 1]
    }
    if (squaresToFind === "controlled squares") {
      return this.findControlledSquares(board, fromSquare, knightMoves)
    }
    if (squaresToFind === "possible moves") {
      return this.findPossibleMoves(board, fromSquare, knightMoves)
    }
  }...

进入全屏模式 退出全屏模式

远程片段的共享搜索方法:

我发现 Queen、Rook 和 Bishop 在寻找可能的和受控的方格方面有相似的模式。它们都能够在给定方向上移动尽可能多的方格,直到:

  • 到达敌方棋子(此时可以捕获)

  • 到达友方棋子前的方格

  • 到达板的边缘

这些片段中的每一个都从它们给定的起始坐标沿每个可能的方向进行迭代,并继续迭代直到满足其中一个条件。这使我能够编写一个通用的方法,可以被这些部分中的每一个使用。

const findSquaresForLongRange = 
  ({piece, board, fromSquare, squaresToFind, pieceDirections}) => {
  const possibleSquares = []
  const [fromRow, fromCol] = fromSquare
  const completedDirections = []

    for (let i = 1; i < 8; i++) {
      const allDirections = {
        "North": [fromRow - i, fromCol],
        "South": [fromRow + i, fromCol],
        "East": [fromRow, fromCol + i],
        "West": [fromRow, fromCol - i],
        "NorthWest": [fromRow - i, fromCol - i],
        "NorthEast": [fromRow - i, fromCol + i],
        "SouthWest": [fromRow + i, fromCol - i],
        "SouthEast": [fromRow + i, fromCol + i]
      }

进入全屏模式 退出全屏模式

每件作品只需要朝着他们能够做到的方向前进......

class Bishop {
  constructor(color) {
    this.type = "bishop"
    this.color = color
    if (color === "white") {
      this.symbol = pieceSymbols.whiteBishop
    } else if (color === "black") {
      this.symbol = pieceSymbols.blackBishop
    }
  }
  findSquares({board, fromSquare, squaresToFind}) {
    return findSquaresForLongRange({
      piece: this,
      pieceDirections: ["NorthWest", "NorthEast", "SouthWest", "SouthEast"],
      board,
      fromSquare,
      squaresToFind
    })
  }
}

进入全屏模式 退出全屏模式

未包含的路线将立即跳过

for (const direction in allDirections) {

        if (!pieceDirections.includes(direction) || completedDirections.includes(direction)){
          continue;
        }

进入全屏模式 退出全屏模式

C。游戏结束检测

目前,该游戏能够检测将死和相持。

游戏通过运行一个确定玩家所有可能移动的函数来检测游戏结束。检查检测方法返回一个布尔值,判断国王的方格是否包含在对方玩家的攻击方格中。

  • 如果玩家有可能的移动 → gameOver ≠ true

  • 如果玩家没有可能的移动并且处于检查状态→“其他玩家获胜”

  • 如果玩家没有可能的移动但不在检查中→“僵局”

2\。用户界面

App 函数包含以下组件,所有这些组件都依赖于 Board Object 的数据来确定要渲染的内容。

  • 开始新游戏的有条件出现的模式(游戏结束时出现)

  • 显示棋盘的 BoardUI 组件,包含典当促销的弹出窗口并包含游戏的选项按钮

  • 用于白色棋子和黑色棋子的 CapturedPieceContainer 组件

  • 呈现当前游戏的国际象棋符号的 MoveList 组件

棋盘由 BoardUI 组件包含,该组件使用来自 Board 类 2d 正方形数组的数据来呈现当前位置。

<table 
        id="board"
        cellSpacing="0">
        <tbody>
        {gameDisplay.boardPosition.map((row, index) =>
          <tr 
            className="board-row"
            key={index}>
            {row.map((square) => 
              <td 
                className={getSquaresClass(square)}
                coordinate={square.coordinate}
                piece={square.piece}
                key={square.coordinate} 
                style={{
                  backgroundColor: isLightSquare(square.coordinate) ? lightSquareColor : darkSquareColor,
                  opacity: square.isLastPlayedMove ? 0.6 : 1.0
                  }}
                onClick={(e) => move(e)}>
                  {square.piece !== null && square.piece.symbol}   
                  {square.isPossibleMove && 
                    <span className="possible-move"></span>}       </td>)}
            </tr>)}
        </tbody>
      </table>

进入全屏模式 退出全屏模式

该板使用 HTML 表格显示。 Squares containing a piece display the piece’s symbol and when a piece to move is selected, its possible move squares are given a colored element to highlight them.

可能的改进...

我在代码中遇到的一个问题涉及 React 如何知道何时更新界面的性质。尽管 Board 对象非常擅长自我变异,但 React 不会知道要更新,因为被引用的对象是相同的。这迫使我在 Board 上创建一个返回自身副本的方法......

clone(){
    let newBoard = new Board()
    for (const property in this){
      newBoard[property] = this[property]
    }
    return newBoard
  }

进入全屏模式 退出全屏模式

然后可以将其传递给状态更改...

setBoard(board.clone())

进入全屏模式 退出全屏模式

然而,这个额外的步骤并没有真正充分利用 React。采用更实用的方法来编写 Board 类中的方法可以消除对此的需求。如果我最终对这个项目进行大规模重构,我相信这将是一个很好的改进机会,也是充分利用 React 功能的机会。

BoardUI 中的嵌套条件组件...

BoardUI 组件还包含一个有条件渲染的 PromotionModal 组件,它依赖于 BoardUI 的状态将适当颜色的片段渲染为弹出窗口

const [pawnPromotion, setPawnPromotion] = 
    useState({
      pawnIsPromoting: false,
      color: null,
      promotionSquare: null})

进入全屏模式 退出全屏模式

[图像描述](https://res.cloudinary.com/practicaldev/image/fetch/s--JO4dx5rW--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/1w97y6o19g6yuk2d3r3t.gif)

按照我想要的方式定位它需要一些努力,最终我决定使用 CSS calc() 函数和 CSS 变量来实现我想要的效果。

.promotion-pieces {
  ...
  position: fixed;
  top: 50%;
  left: calc(0.5 * (100vw - var(--board-length) - var(--move-list-width)) + 0.5 * var(--board-length));
  transform: translate(-50%, -50%);
  ...
}

进入全屏模式 退出全屏模式

3\。游戏选项

一个。新游戏:将游戏设置为初始游戏设置,然后将应用程序的状态设置为该棋盘的副本

const createNewGame = () => {
    board.startNewGame()
    setBoard(board.clone())
  }

进入全屏模式 退出全屏模式

湾。翻转板:检查当前位于屏幕底部的玩家并以相反的顺序重新排列游戏的方块:

const flipBoard = () => {
    const updatedPosition = {}
    const boardToFlip = board.squares
    const flippedBoard = []

    if (gameDisplay.playerPerspective === "black"){
      for (let row = 7; row >= 0; row--){
        const boardRow = []
        for (let col = 7; col >= 0; col --){
          boardRow.push(boardToFlip[row][col])
        }
        flippedBoard.push(boardRow)
      }
      updatedPosition.playerPerspective = "white"
      updatedPosition.boardPosition = flippedBoard
      setGameDisplay(updatedPosition)
      return
    }

    if(gameDisplay.playerPerspective === "white"){
      for (let row = 0; row <= 7; row++){
        const boardRow = []
        for (let col = 0; col <= 7; col++){
          boardRow.push(boardToFlip[row][col])
        }
        flippedBoard.push(boardRow)
      }
      updatedPosition.playerPerspective = "black"
      updatedPosition.boardPosition = flippedBoard
      setGameDisplay(updatedPosition)
      return
    }
  }

进入全屏模式 退出全屏模式

C。拿回来:

const takeback = () => {
// Create list of moves equal to the current game minus the last
    const movesToPlayBack = board.playedMoveList.slice(0, -1)

// Reset game
    createNewGame()

// Plays through the list of moves
    for (let i = 0; i < movesToPlayBack.length; i++){
      board.selectPieceToMove(movesToPlayBack[i].fromSquare)
      const targetSquare = movesToPlayBack[i].toSquare
      if (movesToPlayBack[i].moveData.promotionChoice){
        const pieceType = movesToPlayBack[i].moveData.promotionChoice
        const pieceColor = movesToPlayBack[i].piece.color
        const promotionChoice = findPiece(pieceColor, pieceType)
        return board.movePiece(targetSquare, promotionChoice)
      }
      board.movePiece(targetSquare)
    }
  }

进入全屏模式 退出全屏模式

d。板主题:将颜色的 CSS 变量设置为各种颜色方案

  const changeTheme = (lightSquareChoice, darkSquareChoice, highlightChoice) => {
    document.documentElement.style.setProperty("--light-square", lightSquareChoice)
    document.documentElement.style.setProperty("--dark-square", darkSquareChoice)
    document.documentElement.style.setProperty("--highlight", highlightChoice)
  }

进入全屏模式 退出全屏模式

最后的想法

这是迄今为止我最喜欢的编码项目。我个人对国际象棋的热爱与解释游戏所有复杂性和细微差别的挑战相结合是困难的,但同样有益。我现在考虑添加的一些内容是:

  • 2人网络棋

  • 五十步规则的游戏结束检测和三重重复

  • 不同的国际象棋设置选项

  • 移动列表上的前进和后退按钮以浏览游戏

  • 可拖动而不是可点击的动作

  • 将代码库更新为 TypeScript

  • 重构更多的是功能性而非面向对象的风格

如果我在我的编码之旅中回到过去,我想我会尝试比我更早地开始这个项目。从我在这个项目中犯下的错误中学习帮助我取得了巨大的成长,我很高兴能够继续构建并看到我在此过程中学到的东西。如果您想聘请新的开发人员,请随时给我发电子邮件!

Logo

React社区为您提供最前沿的新闻资讯和知识内容

更多推荐