Chess
最近迷上了两样事情,国际象棋和德州扑克。
德州扑克的项目暂时先放在后头,因为她的复杂程度会更加高,涉及的场景非常多,规则非常庞大,比如Flush大于同花,同花大于顺子,顺子大于三条,三条大于Two Pair等等这些内容,所以现在暂时还不愿意去写。
所以在简单学习了国际象棋的基本规则之后,准备搭建一个Web版本的国际象棋。
国际象棋的规则
国际象棋真的是一件很有意思的事情。
国际象棋的核心规则可以分为棋盘棋子、行棋规则、胜负判定三大类,简单梳理如下:
- 棋盘与棋子
- 棋盘:8×8 共 64 格,由深色(黑格)和浅色(白格)相间组成,摆放时棋盘右下角必须是白格。
- 棋子:双方各16枚,包括 1王、1后、2车、2象、2马、8兵。白方先行,之后双方轮流走棋。
- 各棋子行棋规则
棋子 走法规则 特殊说明 王 横、竖、斜向每次只能走1格 不能主动走入被对方攻击的格子;不能与己方王处于相邻格 后 横、竖、斜向可走任意格数,无阻挡时 是棋盘上威力最强的棋子 车 横、竖方向可走任意格数,无阻挡时 参与王车易位(见下文特殊规则) 象 斜向可走任意格数,无阻挡时 只能在同色格子移动,双象分别走黑格和白格 马 走“日”字(先横/竖走2格,再斜走1格;或先横/竖走1格,再斜走2格) 可以跳过其他棋子;不受阻挡 兵 前进规则:
1. 未移动过的兵,第一步可选择走1格或2格
2. 移动过的兵,每次只能走1格
吃子规则:斜向前1格吃对方棋子到达对方底线必须升变(可变为后、车、象、马中的一种) - 特殊行棋规则
- 王车易位:王与未移动过的车之间无其他棋子,且王未被将军、易位路径不被攻击时,王向车方向移动2格,车越过王移动到相邻格。分为短易位(王与王翼车)和长易位(王与后翼车)。
- 吃过路兵:当一方兵从初始位置走2格,恰好与对方兵横向相邻时,对方兵可立即斜向前吃掉这个兵,且停在该兵原本走1格的位置。此操作必须在对方兵走2格的下一步立即执行,过期失效。
- 兵的升变:兵走到对方底线(白兵到第8格,黑兵到第1格)时,必须替换为后、车、象、马中的一种(不能不变,也不能变王),通常优先升变为后。
- 胜负与和棋判定
- 胜负判定
- 将死:一方王被攻击(将军),且无法通过走王、挡子、吃对方攻击棋子的方式解除将军,即为将死,对方获胜。
- 超时负:在规定时间内未走满规定步数,判负。
- 认输负:一方主动认输,对方获胜。
- 违规负:多次违规(如走禁着、触摸棋子后不走该棋子等),判负。
- 和棋判定
- 逼和:一方王未被将军,但所有合法走法都会让王被将军,或无任何合法走法,判和。
- 三次重复局面:同一局面(棋子位置、轮走方、王车易位/吃过路兵权利均相同)重复出现三次,任一方可申请和棋。
- 五十步规则:连续50步内没有吃子和兵的移动,任一方可申请和棋。
- 双方同意和棋:对局中双方协商一致,可判和。
- 无子可胜:双方剩余棋子都无法将死对方(如只剩双王、王+象vs王、王+马vs王),自动和棋。
- 胜负判定
技术部分
| 位置 | 技术栈 |
|---|---|
| Engine | Stockfish |
| Frontend | React.JS |
| Backend | Python Tornado |
项目的结构非常简单,采用Python Tornado 开发后端部分的内容,其实换成其他任何一个语言和框架都是可以的。
但要我来做,首先排除的就是SpringBoot和.NET以及Go等等这种静态语言的技术栈选型。
因为对于当前的这一个项目,我需要的是一个能够比较便捷去实现国际象棋规则判断逻辑的工具,SpringBoot和.NET确实也有相应的.jar文件和.ddl文件,可是他们的项目依赖管理工具,我就非常不喜欢,每次修改等待编译就要半天,这样的开发效率太慢了。
于是,摆在面前的就是Python和Node.JS两个技术选型。
我觉得还是使用Python会方便一点,因为有很多与我一样的无聊人士,封装了大量的Python Chess库,Node.JS相对会少一些。
而且我已经决定使用一个独立的框架去实现前端部分的内容,换一个语言去实现后端,也是挺好的,让整个项目多一点多样性,这样会更好玩一些。
其技术流程图大致如此: 
后端部分
后端部分逻辑,我是直接采用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;为了避免这篇文章太长了,这里像具体的板块Game、热力图板块GameContainer、PGN棋谱推演板块PGNReplay中的具体实现细节,就不论述了。
主要也就是编写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 游戏主要界面

Game 热力图分析

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

商业价值部分
这个东西的商业价值几乎为零,现有的国际象棋APP,做得再牛逼,像国象联盟这个级别的App,也几乎就是以贩卖周边来作为赢利点。比如卖一下国际象棋的棋盘和棋子等等。
App内购买(IAP)的商业模式,是非常难做起来的,至少对于一个更愿意从技术角度思考问题的人来说,这是难于登天的。
后续烧钱部分
直接花了80多块买了一副39cm * 39cm棋子大小的国际象棋,棋盘也是非常大。不舍得花更多的钱,去买那个橡脂材质的,那个确实是牛逼的,手感看起来就很不错。
还有一些宝可梦周边的国际象棋,收藏类型的玩具,2600块,感觉这也是一条烧钱的路。