news 2026/9/14 1:42:09

Python+TkInter实现国际象棋:棋盘逻辑与GUI分离的完整实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python+TkInter实现国际象棋:棋盘逻辑与GUI分离的完整实战

简介:一个简单的Python国际象棋游戏实现,整体代码简洁,非常适合刚接触面向对象编程、希望从零走通一套完整游戏流程的学习者。项目刻意保持模块化设计,棋盘表示、棋子的移动校验、命令行输出与图形界面各自独立,方便逐步理解或改造。压缩包内共20个文件,包含6个Python源码、12张用于黑白双方棋子的PNG图片、1份README说明和1份开源许可证;源码层面,棋盘采用字典结构保存棋子坐标,移动规则覆盖王、后、车、象、马、兵六种棋子,并提供两套交互方式——基于控制台的Unicode字符界面与带图形的TkInter界面,均可在启动时切换选择。整个资源仅55KB,目录结构清晰,下载解压后参照README即可运行。目前已有433人学习下载,适合作为课程设计、Python进阶练习或棋类游戏开发的入门参考;就学习场景而言,它既可用于课堂示例,也可作为个人项目的改造蓝本。

1. 先写一个不依赖界面的国际象棋棋盘,再让 TkInter GUI 接上去

很多入门代码习惯把 state 和 View 混在一起,最后 bug 都藏在回调里。下面就用一个简单的 python 国际象棋游戏,带单独的棋盘表示/验证和 TkInter GUI,把最小可运行路径走通:8×8 数组表示棋子、合法性验证、将军/将死检测,最后在 TkInter 上点击落子。对想学 TkInter 布局,又不想把游戏逻辑写成意大利面的开发者,这套分离方法可以直接复制到猜数字、黑白棋等小项目里。核心原则是:Board 完全不 import tkinter,GUI 只做两件事——把 grid 画到 Canvas,把鼠标坐标换算成 row, col 交给 Board。

2. 棋盘表示与验证:先写纯逻辑,不碰 TkInter

2.1 为什么把棋盘状态和界面拆开

国际象棋的规则远比界面复杂。如果在一个 TkInter 类里既画棋子又判断走法,每次改规则都要手动启动窗口,点几步棋,才能确认有没有改坏。把棋盘表示和验证拆成独立模块后,验证逻辑可以脱离 GUI 做单测,回归成本几乎为零。这也是标题里“单独的棋盘表示/验证”的实际含义:不是分两个文件那么简单,而是让Board不知道界面存在。

站在维护角度,这个拆分让代码还能复用到命令行版本。以后想加一个“电脑自动走子”的逻辑,只需要 importboard.py,在 ChessGUI 外面写搜索就行;TkInter 只是被动把Board.grid画出来。即使想换 PyQt 或 pygame,board.py一行都不用动,这就是分离带来的可替换性。

2.2 用8×8二维列表表示棋盘,并写出合法的棋子移动

常见做法是位棋盘,但入门项目用位棋盘容易把自己绕晕。8×8二维列表直观,每个元素是一个字符:大写字母表示白棋,小写字母表示黑棋,.表示空格。棋盘索引grid[row][col]row=0是黑方底线,row=7是白方底线,这个方向后面 GUI 里会用到。

# board.py WHITE = "white" BLACK = "black" class Board: DIFFS = { "N": [(-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1)], "R": [(-1, 0), (1, 0), (0, -1), (0, 1)], "B": [(-1, -1), (-1, 1), (1, -1), (1, 1)], "Q": [(-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1)], "K": [(-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1)], } def __init__(self): self.turn = WHITE self.grid = [ ["r", "n", "b", "q", "k", "b", "n", "r"], ["p"] * 8, ["."] * 8, ["."] * 8, ["."] * 8, ["."] * 8, ["P"] * 8, ["R", "N", "B", "Q", "K", "B", "N", "R"], ] def color_at(self, row, col): piece = self.grid[row][col] if piece == ".": return None return WHITE if piece.isupper() else BLACK def in_board(self, row, col): return 0 <= row < 8 and 0 <= col < 8 def moves_from(self, row, col): piece = self.grid[row][col] side = self.color_at(row, col) if piece == ".": return [] moves = [] kind = piece.upper() if kind == "P": # 兵:前进、初始两步、斜吃 dr = -1 if side == WHITE else 1 start_row = 6 if side == WHITE else 1 nr = row + dr if self.in_board(nr, col) and self.grid[nr][col] == ".": moves.append((nr, col)) if row == start_row and self.grid[row + 2 * dr][col] == ".": moves.append((row + 2 * dr, col)) for dc in (-1, 1): nr, nc = row + dr, col + dc if self.in_board(nr, nc) and self.color_at(nr, nc) == (BLACK if side == WHITE else WHITE): moves.append((nr, nc)) return moves if kind == "N": # 马:固定 L 形 for dr, dc in self.DIFFS["N"]: nr, nc = row + dr, col + dc if self.in_board(nr, nc) and self.color_at(nr, nc) != side: moves.append((nr, nc)) return moves if kind == "K": # 王:周围一格,先不管易位 for dr, dc in self.DIFFS["K"]: nr, nc = row + dr, col + dc if self.in_board(nr, nc) and self.color_at(nr, nc) != side: moves.append((nr, nc)) return moves # 车、象、后:沿方向滑动,遇己方棋子停,遇对方棋子吃并停 for dr, dc in self.DIFFS[kind]: nr, nc = row + dr, col + dc while self.in_board(nr, nc): target = self.color_at(nr, nc) if target is None: moves.append((nr, nc)) elif target == side: break else: moves.append((nr, nc)) break nr += dr nc += dc return moves

DIFFSN是马的八个方向,R/B/Q/K用方向向量驱动。兵单独处理,因为它的行动和吃子是两套规则。color_at用来判断格子上的棋子属于哪一方,None表示空格。moves_from返回伪合法走法,还没有过滤“王会不会因此被将军”,这一步放到下一章做,否则这个函数会为了判断将军而不断回溯,代码反而难读。

接着给出最核心的验证方法,它不修改棋盘,只判断“能不能走”:

def is_legal_move(self, src, dst): r1, c1 = src r2, c2 = dst if not (self.in_board(r1, c1) and self.in_board(r2, c2)): return False if self.grid[r1][c1] == ".": return False if self.color_at(r1, c1) != self.turn: return False if self.color_at(r2, c2) == self.turn: return False return dst in self.moves_from(r1, c1)

srcdst统一用(row, col)元组,例如白方 e2 是(6, 4),e4 是(4, 4)。第一层检查范围,第二层检查起点有子,第三层检查棋权,第四层检查不能吃自己的子。最后查目标是否在moves_from结果里。这个方法只做验证,不负责移动。

为了让 GUI 能落子,还要加一个move方法:

def move(self, src, dst): r1, c1 = src r2, c2 = dst self.grid[r2][c2] = self.grid[r1][c1] self.grid[r1][c1] = "." self.turn = BLACK if self.turn == WHITE else WHITE

move默认调用方已经做过is_legal_move校验。如果要在命令行直接玩,可以在move开头加一句if not self.is_legal_move(src, dst): return False,让逻辑层自己兜底。

2.3 验证层不依赖界面:一行 pytest 跑通基本走法

单独棋盘表示最大的受益者是测试。写下面这个测试文件,不需要启动 TkInter 窗口,一条命令就能覆盖关键规则:

# test_board.py from board import Board def test_white_pawn_two_squares_first_move(): b = Board() assert b.is_legal_move((6, 4), (4, 4)) # e2-e4 assert not b.is_legal_move((6, 4), (3, 4)) # 一次不能三格 def test_black_pawn_moves_down(): b = Board() b.turn = "black" assert b.is_legal_move((1, 4), (3, 4)) # e7-e5 def test_knight_jumps_over_own_pieces(): b = Board() assert b.is_legal_move((7, 6), (5, 5)) # Ng1-f3 def test_cannot_capture_own_piece(): b = Board() b.grid[6][3] = "P" assert not b.is_legal_move((7, 2), (6, 3)) # 象不能吃自己的兵

测试里包含“初始两步、黑兵方向、马跳过兵、不能吃己方子”四类情况。跑测试的命令是python -m pytest -q test_board.py,需要先pip install pytest。这里的关键是Board构造完全不碰 tkinter,几百个用例也能秒级跑完,界面层永远不需要参与规则验证。

3. 走法生成与将军/将死检测:让验证层覆盖完整对局

3.1 在伪合法走法外再套一层“王安全”过滤

上一章的moves_from只是“几何上能走到”,没有考虑国际象棋最重要的一条约束:不允许走完一步后自己的王在将军状态。所以需要把“走子后局面”做一次模拟,再判断对方能否攻击到王。先写is_in_check(side),然后让合法走法过滤掉所有把王送进将军的走法。

把下面代码继续写进Board类:

def find_king(self, side): for r in range(8): for c in range(8): if self.grid[r][c].upper() == "K" and self.color_at(r, c) == side: return (r, c) return None def is_in_check(self, side=None): side = side or self.turn king = self.find_king(side) if king is None: return True enemy = BLACK if side == WHITE else WHITE for r in range(8): for c in range(8): if self.color_at(r, c) == enemy: if king in self.moves_from(r, c): return True return False

is_in_check遍历所有敌方棋子,看它们的伪合法走法是否包含自己的王。判断攻击时用moves_from而不是完整的合法走法,因为“攻击”只需要几何走势,对方棋子是否走出被将军局面在这里不影响攻击判断。如果王不在棋盘上,直接当成被将军处理,这样可以兜住 FEN 空王棋盘的异常。

有了将军判断,就可以做“王安全过滤”。常见做法是保存现场、模拟走子、判断、还原:

def legal_moves_from_square(self, row, col): if self.color_at(row, col) != self.turn: return [] result = [] for nr, nc in self.moves_from(row, col): captured = self.grid[nr][nc] moved = self.grid[row][col] self.grid[nr][nc] = moved self.grid[row][col] = "." if not self.is_in_check(self.turn): result.append((nr, nc)) self.grid[row][col] = moved self.grid[nr][nc] = captured return result

这段代码用“保存-修改-判断-还原”的方式模拟走子,不真正占用棋盘。对这个项目规模完全够用;哪天想提速,再把撤销走法做成栈操作或者棋盘状态快照。

此时is_legal_move应该改成基于legal_moves_from_square,避免用户走一步让王暴露在将军里的棋:

def is_legal_move(self, src, dst): r, c = src if self.grid[r][c] == "." or self.color_at(r, c) != self.turn: return False return dst in self.legal_moves_from_square(r, c)

注意顺序:legal_moves_from_square内部已经做了棋权判断,外层不用重复。不过保留棋权判断也无妨,便于单独调用。

3.2 检测将军、将死和逼和

有了is_in_check和合法走法集合,终局判断就很容易。把“当前方没有任何合法走法”作为公共条件,区别只在“王是否正在被将军”:

def legal_moves_for_side(self, side=None): side = side or self.turn all_moves = [] for r in range(8): for c in range(8): if self.color_at(r, c) == side: for target in self.legal_moves_from_square(r, c): all_moves.append(((r, c), target)) return all_moves def is_checkmate(self): if not self.is_in_check(self.turn): return False return len(self.legal_moves_for_side(self.turn)) == 0 def is_stalemate(self): if self.is_in_check(self.turn): return False return len(self.legal_moves_for_side(self.turn)) == 0

is_checkmateis_stalemate的区别只有一行:前者先检查将军,后者先排除将军。把legal_moves_for_side的返回值缓存起来可以避免每次判断重复计算,但 60 个回合内的棋局,直接每步算一遍也完全撑得住。TkInter 界面每秒最多点击一两次,性能瓶颈不在这里。

3.3 用 FEN 加载测试局面,验证将军判断

手动摆棋盘测试很慢,常见做法是加一个load_fen。FEN 第一段按行描述棋盘,数字表示连续空格,/分隔八行。很多网上棋题都直接给 FEN,有了这个方法就能把将死题拿来当回归用例:

def load_fen(self, fen): parts = fen.split() rows = parts[0].split("/") self.grid = [] for part in rows: line = [] for ch in part: if ch.isdigit(): line.extend(["."] * int(ch)) else: line.append(ch) self.grid.append(line) self.turn = WHITE if parts[1] == "w" else BLACK

配合一个必杀局面测试:

def test_back_rank_mate(): b = Board() b.load_fen("7k/6Q1/6K1/8/8/8/8/8 b - - 0 1") assert b.is_in_check("black") assert b.is_checkmate()

FEN 中各部分含义如下:

FEN 片段含义示例值
7k7 个连续空格 + 黑王第 8 行
6Q16 个空格 + 白后 + 1 个空格第 7 行
6K16 个空格 + 白王 + 1 个空格第 6 行
b轮到黑方走第二个字段

白后 g7 正将军黑王 h8,黑王能逃的 h7、g8 都在白后控制下,所以is_checkmate()应该返回True。把这个测试文件加进 pytest,以后改规则就不会误伤终局判断。

4. TkInter GUI:把棋局画出来并响应鼠标点击

4.1 为什么选用 Canvas 而不是 64 个按钮

TkInter 画棋盘有两种常见做法:用tk.Button摆一个 8×8 网格,或者用tk.Canvas画矩形和文字。按钮方案在点击响应上更简单,但每走一步都要更新 64 个控件的text,高亮可选子时还要改bg,代码量反而大。Canvas 方案则是每次局面变化只调用一次delete("all"),再重新绘制 64 个格子,逻辑集中在一处。想加侧边栏、记谱列表时,可以用tkinter.ttkFrame包在 Canvas 旁边,界面逻辑不变。如果想把按钮做成圆角效果,TkInter 也没有原生圆角按钮,通常也是用 Canvas 画圆角矩形模拟,这个项目里不涉及。

4.2 最小 GUI 代码:棋盘、棋子和坐标换算

下面是能直接运行的gui.pyChessGUI持有Board实例,所有鼠标事件都转成(row, col)交给 Board:

import tkinter as tk from board import Board class ChessGUI: TILE = 64 def __init__(self): self.root = tk.Tk() self.root.title("Python 国际象棋 - TkInter GUI") self.board = Board() self.selected = None self.canvas = tk.Canvas(self.root, width=self.TILE * 8, height=self.TILE * 8) self.canvas.pack() self.canvas.bind("<Button-1>", self.on_click) self.redraw() def redraw(self): self.canvas.delete("all") for r in range(8): for c in range(8): x1, y1 = c * self.TILE, r * self.TILE if (r + c) % 2 == 0: self.canvas.create_rectangle(x1, y1, x1 + self.TILE, y1 + self.TILE, fill="#f0d9b5", outline="") else: self.canvas.create_rectangle(x1, y1, x1 + self.TILE, y1 + self.TILE, fill="#b58863", outline="") piece = self.board.grid[r][c] if piece != ".": symbol = { "K": "♔", "Q": "♕", "R": "♖", "B": "♗", "N": "♘", "P": "♙", "k": "♚", "q": "♛", "r": "♜", "b": "♝", "n": "♞", "p": "♟", }[piece] color = "#ffffff" if piece.isupper() else "#000000" self.canvas.create_text(x1 + self.TILE / 2, y1 + self.TILE / 2, text=symbol, font=("Segoe UI Symbol", 36), fill=color)

#f0d9b5#b58863是国际象棋网站常用的深浅格配色,分别对应浅格和深格。字体名Segoe UI Symbol在 Windows 上能显示 Unicode 棋子;Linux 下换成DejaVu Sans,macOS 换成Arial Unicode MS。如果只看到方框,就把字体改成系统支持的任意字体。

4.3 点击选子、高亮可走位置和执行走子

鼠标回调只需要三步:第一次点击选中己方棋子并高亮目标,第二次点击若合法就执行move,否则清空选择或改选。坐标换算用整除即可:

def square_from_event(self, event): row = event.y // self.TILE col = event.x // self.TILE return row, col def on_click(self, event): pos = self.square_from_event(event) if self.selected is None: if self.board.color_at(*pos) == self.board.turn: self.selected = pos self.redraw() self.highlight_moves() else: if pos == self.selected: self.selected = None self.redraw() return if self.board.is_legal_move(self.selected, pos): self.board.move(self.selected, pos) self.selected = None self.redraw() self.after_move() else: self.selected = pos if self.board.color_at(*pos) == self.board.turn else None self.redraw() self.highlight_moves() def highlight_moves(self): if self.selected is None: return for row, col in self.board.legal_moves_from_square(*self.selected): x, y = col * self.TILE, row * self.TILE self.canvas.create_rectangle(x, y, x + self.TILE, y + self.TILE, outline="#00aa00", width=4)

square_from_event用整除拿到格子编号,TILE=64时鼠标 x=191 会得到 col=2。选中状态用self.selected保存,第一次点击时颜色等于self.board.turn才允许选择;第二次点击如果目标是合法走法,调用board.move,否则取消选中或改选另一颗己方棋子。highlight_movesredraw之后绘制一圈绿色边框,不覆盖棋子,所以不需要调整 Canvas 的层级。

after_move负责终局和将军提示:

def after_move(self): if self.board.is_checkmate(): self.canvas.create_text(256, 256, text="Checkmate", fill="#ff0000", font=("Segoe UI", 40)) elif self.board.is_stalemate(): self.canvas.create_text(256, 256, text="Stalemate", fill="#666666", font=("Segoe UI", 40)) elif self.board.is_in_check(self.board.turn): self.canvas.create_text(256, 256, text="Check", fill="#cc4400", font=("Segoe UI", 30))

再补上主循环:

def run(self): self.root.mainloop() if __name__ == "__main__": ChessGUI().run()

这样运行python gui.py就能开始下棋。注意mainloop会阻塞当前线程,调试时不要放在 Jupyter Notebook 的执行块里反复启动,用普通脚本文件运行更稳定。

5. 验证走法正确性与几个常见坑

5.1 用必杀局面做回归测试,别只测开局

开局走法测试只能证明“能走”,不代表“将军过滤”和“终局判断”是对的。下面这个测试构造白王 a1、黑车 a8 的局面,黑车沿直线控制 a 线,白王如果走到 a2 仍然处于将军状态,is_legal_move必须返回False

def test_king_cannot_move_into_check(): b = Board() b.load_fen("r7/8/8/8/8/8/8/K7 w - - 0 1") assert not b.is_legal_move((7, 0), (6, 0))

这里的 FEN 没有写黑王,但验证层只关心白王是否被攻击,所以也能跑。把这类“送将”场景和前面第 3 章的将死测试放在一起,就能形成一个最小回归集:将军、将死、逼和、送将、基本走法、不能吃己方子。以后给 Board 加功能时,先跑一遍 pytest,再开 GUI。

5.2 自己扩展时的三个坑

第一,棋盘翻转不要改Board.grid。很多人在做“黑方视角”时直接把存储数组倒过来,导致走法验证和 FEN 加载全错。正确做法是只在 GUI 的绘制循环里做行列映射,逻辑层永远保持 row=0 在黑方底线。

第二,Unicode 棋子符号在不同系统差异很大。Windows 上Segoe UI Symbol显示正常,Linux 换DejaVu Sans可能字符宽度不一样。调试期先降级用字母K/Q/R/B/N/P,等布局稳定后再换图形符号。

第三,legal_moves_from_square里的模拟恢复用的是“改回原值”,如果目标格是空格,captured = "."没问题;但如果以后加兵升变、王车易位、吃过路兵,这种简单恢复就会漏状态。建议把moveundo改成栈式操作,每次模拟压入(from, to, captured),恢复时按栈回放。

highlight_moves()画出的目标列表和moves_from()的输出对照一遍,你会发现能快速定位是不是漏了“王安全”过滤,这一步是整份代码里最容易出错也最值得留测试的位置。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 1:41:26

多页面网站前端实战:以南昌地铁官网20个页面为例

简介&#xff1a;面向大学生与前端初学者的企业官网网页设计成品&#xff0c;以南昌地铁为主题&#xff0c;完整实现20个页面&#xff0c;涵盖HTML5结构、CSS3样式与JavaScript交互&#xff0c;适用于HTML5期末作业、Web前端课程设计及企业官网实战练习。压缩包共170个文件&…

作者头像 李华
网站建设 2026/9/14 1:40:31

计算机视觉数据标注工具:labelimg与labelme对比指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 1:40:22

SpringBoot实现企业级Wiki系统的RBAC权限管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 1:39:39

NiceGUI 可编辑 AG Grid 实战:构建支持增、删、改行的数据表格

NiceGUI 可编辑 AG Grid 实战&#xff1a;构建支持增、删、改行的数据表格 【免费下载链接】nicegui Create web-based user interfaces with Python. The nice way. 项目地址: https://gitcode.com/GitHub_Trending/ni/nicegui 导读 本指南以 NiceGUI 仓库中的 exampl…

作者头像 李华