返回 AI 项目
AI 项目
12 分钟阅读

国际象棋 Web 项目技术实现

React + Python Tornado + Stockfish 搭建离线国际象棋 Web 应用的技术细节。

这篇是 Chess 的技术实现细节,从主文中拆出,方便单独查阅代码与架构。

技术部分

位置技术栈
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

chess-tech-structure-img-comic.png 漫画版由ChatGPT生成,箭头可能有误,图一乐就好。

后端部分

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

# 应用入口
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的思路。

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)}))
  • 当然过程中,还可以接入一些其他有趣的功能,比如在对弈的过程中,可以在结束的时候,把棋谱记录下来,方便之后的复盘。
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等功能模块

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;

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

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

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

逻辑部分

  • 因为为了能够及时响应更快的变动,需要现在Web端判断像一些棋子走法的合法性,然后走子合法,才进入到后端进行交互行为。 因此,此处React项目中存在一个moveUtils.js的文件处理该部分的逻辑
// 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中拿到最新的数据,给到前端去做响应。

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

相关文章

AI 项目
公开
8 分钟
Chess
最近迷上了两样事情,国际象棋和德州扑克。