Skip to content

Chess

最近迷上了两样事情,国际象棋和德州扑克。

德州扑克的项目暂时先放在后头,因为她的复杂程度会更加高,涉及的场景非常多,规则非常庞大,比如Flush大于同花,同花大于顺子,顺子大于三条,三条大于Two Pair等等这些内容,所以现在暂时还不愿意去写。

所以在简单学习了国际象棋的基本规则之后,准备搭建一个Web版本的国际象棋。

国际象棋的规则

国际象棋真的是一件很有意思的事情。

国际象棋的核心规则可以分为棋盘棋子、行棋规则、胜负判定三大类,简单梳理如下:

  1. 棋盘与棋子
    • 棋盘:8×8 共 64 格,由深色(黑格)和浅色(白格)相间组成,摆放时棋盘右下角必须是白格。
    • 棋子:双方各16枚,包括 1王、1后、2车、2象、2马、8兵。白方先行,之后双方轮流走棋。
  2. 各棋子行棋规则
    棋子走法规则特殊说明
    横、竖、斜向每次只能走1格不能主动走入被对方攻击的格子;不能与己方王处于相邻格
    横、竖、斜向可走任意格数,无阻挡时是棋盘上威力最强的棋子
    横、竖方向可走任意格数,无阻挡时参与王车易位(见下文特殊规则)
    斜向可走任意格数,无阻挡时只能在同色格子移动,双象分别走黑格和白格
    走“日”字(先横/竖走2格,再斜走1格;或先横/竖走1格,再斜走2格)可以跳过其他棋子;不受阻挡
    前进规则:
    1. 未移动过的兵,第一步可选择走1格或2格
    2. 移动过的兵,每次只能走1格
    吃子规则:斜向前1格吃对方棋子
    到达对方底线必须升变(可变为后、车、象、马中的一种)
  3. 特殊行棋规则
    • 王车易位:王与未移动过的车之间无其他棋子,且王未被将军、易位路径不被攻击时,王向车方向移动2格,车越过王移动到相邻格。分为短易位(王与王翼车)和长易位(王与后翼车)。
    • 吃过路兵:当一方兵从初始位置走2格,恰好与对方兵横向相邻时,对方兵可立即斜向前吃掉这个兵,且停在该兵原本走1格的位置。此操作必须在对方兵走2格的下一步立即执行,过期失效。
    • 兵的升变:兵走到对方底线(白兵到第8格,黑兵到第1格)时,必须替换为后、车、象、马中的一种(不能不变,也不能变王),通常优先升变为后。
  4. 胜负与和棋判定
    • 胜负判定
      1. 将死:一方王被攻击(将军),且无法通过走王、挡子、吃对方攻击棋子的方式解除将军,即为将死,对方获胜。
      2. 超时负:在规定时间内未走满规定步数,判负。
      3. 认输负:一方主动认输,对方获胜。
      4. 违规负:多次违规(如走禁着、触摸棋子后不走该棋子等),判负。
    • 和棋判定
      1. 逼和:一方王未被将军,但所有合法走法都会让王被将军,或无任何合法走法,判和。
      2. 三次重复局面:同一局面(棋子位置、轮走方、王车易位/吃过路兵权利均相同)重复出现三次,任一方可申请和棋。
      3. 五十步规则:连续50步内没有吃子和兵的移动,任一方可申请和棋。
      4. 双方同意和棋:对局中双方协商一致,可判和。
      5. 无子可胜:双方剩余棋子都无法将死对方(如只剩双王、王+象vs王、王+马vs王),自动和棋。

技术部分

位置技术栈
EngineStockfish
FrontendReact.JS
BackendPython Tornado
  • 项目的结构非常简单,采用Python Tornado 开发后端部分的内容,其实换成其他任何一个语言和框架都是可以的。

  • 但要我来做,首先排除的就是SpringBoot和.NET以及Go等等这种静态语言的技术栈选型。

  • 因为对于当前的这一个项目,我需要的是一个能够比较便捷去实现国际象棋规则判断逻辑的工具,SpringBoot和.NET确实也有相应的.jar文件和.ddl文件,可是他们的项目依赖管理工具,我就非常不喜欢,每次修改等待编译就要半天,这样的开发效率太慢了。

  • 于是,摆在面前的就是Python和Node.JS两个技术选型。

  • 我觉得还是使用Python会方便一点,因为有很多与我一样的无聊人士,封装了大量的Python Chess库,Node.JS相对会少一些。

  • 而且我已经决定使用一个独立的框架去实现前端部分的内容,换一个语言去实现后端,也是挺好的,让整个项目多一点多样性,这样会更好玩一些。

其技术流程图大致如此: chess-tech-structure-img.png

后端部分

后端部分逻辑,我是直接采用Restful API的方式,包括几个可用接口,如下所示:

python
# 应用入口
import tornado.web
import tornado.ioloop
from config import SERVER_HOST, SERVER_PORT

# 导入所有Handler
from handlers.board_handlers import BoardStateHandler, GameStatusAggregateHandler
from handlers.game_handlers import (
    MakeMoveHandler, AIHandler, ResetHandler, 
    SetGameModeHandler, GetAllHistoryHandler, UndoMoveHandler
)
from handlers.analysis_handlers import GameAnalysisHandler
from handlers.game_analysis_pgn_handler import GameAnalysisPGNHandler
from handlers.websocket_handlers import GameWebSocketHandler

def make_app():
    """创建Tornado应用"""
    return tornado.web.Application([
        # 棋盘状态接口
        (r"/api/board", BoardStateHandler),
        (r"/api/game_status", GameStatusAggregateHandler),

        # 游戏操作接口
        (r"/api/move", MakeMoveHandler),
        (r"/api/ai", AIHandler),
        (r"/api/reset", ResetHandler),
        (r"/api/set_mode", SetGameModeHandler),
        (r"/api/get_all_history", GetAllHistoryHandler),
        (r"/api/undo", UndoMoveHandler),

        # 当前棋局分析接口
        (r"/api/game_analysis", GameAnalysisHandler),

        # WebSocket接口
        (r"/ws/game", GameWebSocketHandler),

        # PGN 分析接口
        (r"/api/analyse_pgn", GameAnalysisPGNHandler),
    ])

if __name__ == "__main__":
    """启动服务器"""
    app = make_app()
    app.listen(SERVER_PORT, address=SERVER_HOST)
    print(f"Tornado server running on http://{SERVER_HOST}:{SERVER_PORT}")
    print(f"WebSocket server running on ws://{SERVER_HOST}:{SERVER_PORT}/ws/game")
    tornado.ioloop.IOLoop.current().start()

其中,包括着Python Tornado通过chess这一个Python集成工具库把数据传递给stockfish,stockfish返回预测的状态给到Tornado,然后Tornado通过Restful API的方式把数据传递给Web方。

这就是大致的一整条接入AI的思路。

python
class AIHandler(BaseHandler):
    def post(self):
        try:
            data = json.loads(self.request.body)
            ai_level = data.get("ai_level", "normal")
            
            board = global_state["board"]
            current_game = global_state["current_game"]
            current_game["ai_level"] = ai_level
            
            ai_config = AI_CONFIG.get(ai_level, AI_CONFIG["normal"])
            
            with chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH) as engine:
                result = engine.play(
                    board, 
                    chess.engine.Limit(
                        time=ai_config["time_limit"], 
                        depth=ai_config["depth_limit"]
                    )
                )
                ai_move = str(result.move)
                board.push(result.move)
                
                move_number = len(current_game["moves"]) + 1
                color = "white" if not board.turn else "black"
                move_info = {
                    "move_number": move_number,
                    "color": color,
                    "move": ai_move,
                    "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                    "type": "ai"
                }
                current_game["moves"].append(move_info)
                current_game["result"] = get_game_result(board)
                save_game_data(current_game)
                
                broadcast_game_update()
                
                response = {
                    "success": True,
                    "ai_move": ai_move,
                    "fen": board.fen(),
                    "ai_level": ai_level,
                    "move_info": move_info
                }
                self.write(json.dumps(response))
        except Exception as e:
            self.write(json.dumps({"success": False, "error": str(e)}))
  • 当然过程中,还可以接入一些其他有趣的功能,比如在对弈的过程中,可以在结束的时候,把棋谱记录下来,方便之后的复盘。
python
def generate_pgn_from_game_data(game_data):
    try:
        moves = game_data.get("moves", [])
        if not moves:
            print(f"警告:游戏{game_data['game_id']}无走法记录,跳过PGN生成")
            return
        
        move_types = [m.get("type") for m in moves]
        is_human_vs_human = all(t == "human" for t in move_types)
        is_human_vs_ai = "human" in move_types and "ai" in move_types
        is_ai_vs_ai = all(t == "ai" for t in move_types)

        white_player = "Human"
        black_player = "Human"
        if is_human_vs_ai:
            white_type = next(m.get("type") for m in moves if m.get("color") == "white")
            black_type = next(m.get("type") for m in moves if m.get("color") == "black")
            white_player = "Human" if white_type == "human" else f"AI ({game_data['ai_level']})"
            black_player = "Human" if black_type == "human" else f"AI ({game_data['ai_level']})"
        elif is_ai_vs_ai:
            white_player = f"AI ({game_data['ai_level']})"
            black_player = f"AI ({game_data['ai_level']})"

        game_mode = game_data.get("mode", "").strip()
        if not game_mode:
            if is_human_vs_human:
                game_mode = "human_vs_human"
            elif is_human_vs_ai:
                game_mode = "human_vs_ai"
            else:
                game_mode = "ai_vs_ai"

        end_time = game_data.get("end_time", "").strip()
        if not end_time:
            end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            game_data["end_time"] = end_time
            # 同步保存到JSON文件
            with open(get_game_file_path(game_data["game_id"]), 'w', encoding='utf-8') as f:
                json.dump(game_data, f, ensure_ascii=False, indent=2)

        game = chess.pgn.Game()
        game.headers.update({
            "Event": f"Chess Game ({game_mode} mode)",
            "Site": "Local Game",
            "Date": game_data["start_time"].split()[0].replace("-", "."),
            "Round": "1",
            "White": white_player,
            "Black": black_player,
            "Result": convert_result_to_pgn_format(game_data["result"]),
            "StartTime": game_data["start_time"],
            "EndTime": end_time,
            "AILevel": game_data.get("ai_level", "normal"),
            "GameID": game_data["game_id"]
        })

        board = chess.Board()
        node = game
        for move_info in moves:
            uci_move = move_info.get("move", "").strip()
            if not uci_move:
                continue
            try:
                move = board.parse_uci(uci_move)
                if move in board.legal_moves:
                    node = node.add_variation(move)
                    board.push(move)
            except ValueError as e:
                print(f"跳过无效走法 {uci_move}{e}")
                continue

        pgn_path = os.path.join(PGN_SAVE_DIR, f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.pgn")
        with open(pgn_path, 'w', encoding='utf-8') as f:
            exporter = chess.pgn.FileExporter(f)
            game.accept(exporter)

        print(f"PGN文件生成成功:{pgn_path}")
    except Exception as e:
        print(f"生成PGN失败:{str(e)}")

前端部分

前端部分,采用React去实现,是直接创建一个React项目,没有使用类似于Next.JS那种工具去操作这一块的逻辑。接入了React-Route等功能模块

javascript
import { Routes, Route, Link } from 'react-router-dom';
import Game from './game/Game.js';
import './App.css'
import GameContainer from './game_container/GameContainer.jsx';
import PGNReplay from './pgn_replay/PGNReplay.jsx';

function App() {
  return (
    <div className="App">

      <Routes>

        {/* 游戏板块 */}
        <Route path="/" element={<Game />} />

        {/* 热力图板块 */}
        <Route path="/flight-charts" element={<GameContainer />} />
          
        {/* 棋谱演变板块 */}
        <Route path='/pgn-replay' element={<PGNReplay/>} />

      </Routes>

    </div>
  );
}

export default App;

为了避免这篇文章太长了,这里像具体的板块Game、热力图板块GameContainer、PGN棋谱推演板块PGNReplay中的具体实现细节,就不论述了。

主要也就是编写jsx和css两部分的内容,然后确定要展示的内容,使用axios连接Python Tornado的项目,实现数据的整合。

然后还有就是根据React的设计思路,尽可能采用Widget的方式去添加每一个元素,而不是所有的东西都all-in在了一起.

这种写法有点像Android开发里面的Fragment容器化替代Activity all-in化吧。

逻辑部分

  • 因为为了能够及时响应更快的变动,需要现在Web端判断像一些棋子走法的合法性,然后走子合法,才进入到后端进行交互行为。 因此,此处React项目中存在一个moveUtils.js的文件处理该部分的逻辑
javascript
// utils/moveUtils.js
import { PROMOTION_NAMES, PIECE_RULES } from '../constants/chess_config.js';
import { toChessNotation } from './boardUtils.js';

// 分析走法无效原因
export const getInvalidMoveReason = (board, fromRow, fromCol, toRow, toCol, currentTurn) => {
  const piece = board[fromRow][fromCol];
  if (!piece) return '该位置无棋子,请选择有棋子的格子';

  const isWhitePiece = piece === piece.toUpperCase();
  const isCurrentTurnPiece = (currentTurn === 'white' && isWhitePiece) || (currentTurn === 'black' && !isWhitePiece);
  if (!isCurrentTurnPiece) {
    return `当前是${currentTurn === 'white' ? '白' : '黑'}棋回合,该棋子是${isWhitePiece ? '白' : '黑'}棋,无法移动`;
  }

  const targetPiece = board[toRow][toCol];
  if (targetPiece) {
    const targetIsWhite = targetPiece === targetPiece.toUpperCase();
    if ((isWhitePiece && targetIsWhite) || (!isWhitePiece && !targetIsWhite)) {
      return `目标位置有己方${targetIsWhite ? '白' : '黑'}棋(${targetPiece.toLowerCase() === 'p' ? '兵' : PROMOTION_NAMES[targetPiece] || '王'}),无法移动`;
    }
  }

  return `该走法违反${isWhitePiece ? '白' : '黑'}${piece.toLowerCase() === 'p' ? '兵' : PROMOTION_NAMES[piece] || '王'}的走法规则:${PIECE_RULES[piece]}`;
};

// 判断是否是升变走法(位置+棋子类型判断)
export const isPromotion = (fromRow, fromCol, toRow, toCol, board) => {
  const piece = board[fromRow][fromCol];
  if (!piece || piece.toLowerCase() !== 'p') return false; // 不是兵则不升变
  const isWhitePawn = piece === 'P';
  // 白兵走到第0行(8线)、黑兵走到第7行(1线)
  return (isWhitePawn && toRow === 0) || (!isWhitePawn && toRow === 7);
};

// 校验升变走法是否合法(兼容升变后缀)
export const isValidPromotionMove = (fromRow, fromCol, toRow, toCol, board, legalMoves) => {
  // 1. 先判断是否是升变位置
  if (!isPromotion(fromRow, fromCol, toRow, toCol, board)) return false;
  
  // 2. 校验走法是否合法(兼容升变后缀:如 e7e8q 以 e7e8 开头)
  const fromNotation = toChessNotation(fromRow, fromCol);
  const toNotation = toChessNotation(toRow, toCol);
  const baseMove = fromNotation + toNotation;
  
  // 检查 legalMoves 中是否有以 baseMove 开头的走法
  return legalMoves.some(move => move.startsWith(baseMove));
};

// 解析合法走棋位置(用于高亮可走位置)
export const parseLegalMovePositions = (selected, legalMoves, fromChessNotation) => {
  if (!selected) return [];
  const selectedNotation = toChessNotation(selected.row, selected.col);
  const filteredMoves = legalMoves.filter(move => move.startsWith(selectedNotation));
  return filteredMoves.map(move => {
    const toNotation = move.substring(2, 4);
    return fromChessNotation(toNotation);
  });
};

// 格式化走棋记录文本
export const formatMoveText = (moveInfo) => {
  const colorText = moveInfo.color === 'white' ? '白棋' : '黑棋';
  const typeText = moveInfo.type === 'ai' ? '(AI)' : '(玩家)';
  return `${moveInfo.move_number}. ${colorText} ${typeText}: ${moveInfo.move}`;
};

而在后端的角度来讲,主要就是从一个global_state中去维护一盘棋的运转,然后通过不同的API调用,去改变里面的参数,设置stockfish的最新参数,或者从stockfish中拿到最新的数据,给到前端去做响应。

python
global_state = {
    "board": chess.Board(),
    "current_game": {
        "game_id": str(uuid.uuid4()),
        "start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "mode": "",
        "player_color": "",
        "ai_level": "normal",
        "moves": [],
        "result": "ongoing",
        "end_time": ""
    }
}
  • 当然过程中,也还涉及到一些其他的内容,比如WebSocket的接入能够让实时性做得更加全面等等,另外这个项目当前只考虑个人自己去玩,可以通过Docker部署的方式进行运行,它不是一个中心化的App,让所有人一起在线对弈
  • 中心化的App,也从来不是我的兴趣之点,我个人兴趣的点在于去玩一些离线化的应用,像玩单机游戏
  • 就像玩Switch一样,下载好旷野之息、王国之泪,你就自己在家里、在高铁上,一个人安安静静地去玩就行了,没有那种你无法预测到:是什么水平的队友摆弄着你的情绪,所有的成与败、得与失,全看自己的操作
  • 我想,这种游戏就是最好的
  • 所以,我知道这个global_state作为全局变量去管控每一把的游戏,它绝对不是一个合理的方案去支撑整个项目的发展,它很难去拓展出去同时运行多场游戏的场景
  • 只是,我不在乎那种需求而已

示例图

  • Game 游戏主要界面 chess-game

  • Game 热力图分析 chess-game-flight-charts

  • 复盘 倒入PGN格式的棋谱,即可复盘棋子变化 img.png

商业价值部分

这个东西的商业价值几乎为零,现有的国际象棋APP,做得再牛逼,像国象联盟这个级别的App,也几乎就是以贩卖周边来作为赢利点。比如卖一下国际象棋的棋盘和棋子等等。

App内购买(IAP)的商业模式,是非常难做起来的,至少对于一个更愿意从技术角度思考问题的人来说,这是难于登天的。

后续烧钱部分

直接花了80多块买了一副39cm * 39cm棋子大小的国际象棋,棋盘也是非常大。不舍得花更多的钱,去买那个橡脂材质的,那个确实是牛逼的,手感看起来就很不错。

还有一些宝可梦周边的国际象棋,收藏类型的玩具,2600块,感觉这也是一条烧钱的路。

随便写写的,喜欢就好。