学 Tkinter 最常见的困境是:文档读完了,控件也认了,但拼不出一个完整程序。井字棋(Tic-Tac-Toe)恰好是一个大小合适的练手项目——九个格子、两方轮流、胜负判定清晰,能把 Tkinter 的布局、事件绑定、状态管理全部串起来,还能顺带把 MVC 模式落地成代码结构。
MVC 在小游戏里怎么拆
MVC 不是 Web 项目的专属。一个井字棋同样可以拆成三层:
- Model:棋盘状态(九个格子的归属)、当前轮次、胜负判定逻辑。纯数据与规则,不依赖任何 GUI 对象。
- View:Tkinter 窗口、按钮、标签。只负责渲染 Model 的状态,不自己决定下一步该谁走。
- Controller:把用户点击翻译成 Model 的操作,再让 View 刷新。是 Model 和 View 之间的桥梁。
拆开的好处是:改棋盘逻辑不用碰按钮代码,换界面风格不用改胜负判定。哪怕以后想换成 Pygame 渲染,Model 层一行不用动。
棋盘布局:用 Grid 而不是 Pack
井字棋天然是 3×3 的网格,Tkinter 的 grid() 布局正好对应。每个格子用一个 Button,行列索引直接映射到 grid(row, column):
for row in range(3):
for col in range(3):
button = tk.Button(
window, text="", width=8, height=4,
command=lambda r=row, c=col: on_click(r, c)
)
button.grid(row=row, column=col, padx=2, pady=2)
注意 lambda r=row, c=col: on_click(r, c) 的写法——r=row, c=col 是闭包捕获的默认参数,避免了循环变量延迟绑定导致的"所有按钮都指向最后一行最后一列"的经典坑。
事件处理与状态流转
点击一个格子时,Controller 需要做三件事:
- 检查该格子是否为空,非空则忽略。
- 在 Model 中写入当前玩家的标记(X 或 O)。
- 判定胜负或平局,更新 View。
判定胜负的逻辑可以写成 Model 的方法,遍历八条线(三行、三列、两条对角线):
WIN_LINES = [
[(0, 0), (0, 1), (0, 2)], # 行
[(1, 0), (1, 1), (1, 2)],
[(2, 0), (2, 1), (2, 2)],
[(0, 0), (1, 0), (2, 0)], # 列
[(0, 1), (1, 1), (2, 1)],
[(0, 2), (1, 2), (2, 2)],
[(0, 0), (1, 1), (2, 2)], # 对角
[(0, 2), (1, 1), (2, 0)],
]
def check_winner(board):
for line in WIN_LINES:
marks = [board[r][c] for r, c in line]
if marks[0] and marks[0] == marks[1] == marks[2]:
return marks[0]
return None
完整可运行示例
下面是一个把 MVC 拆开、可以直接运行的井字棋。保存为 tictactoe.py,运行 python tictactoe.py 即可:
import tkinter as tk
# ── Model ──
class TicTacToeModel:
def __init__(self):
self.board = [["" for _ in range(3)] for _ in range(3)]
self.current_player = "X"
self.winner = None
self.draw = False
def play(self, row, col):
if self.board[row][col] or self.winner or self.draw:
return False
self.board[row][col] = self.current_player
self.winner = self._check_winner()
if not self.winner and all(self.board[r][c] for r in range(3) for c in range(3)):
self.draw = True
if not self.winner and not self.draw:
self.current_player = "O" if self.current_player == "X" else "X"
return True
def _check_winner(self):
lines = [
[(0,0),(0,1),(0,2)], [(1,0),(1,1),(1,2)], [(2,0),(2,1),(2,2)],
[(0,0),(1,0),(2,0)], [(0,1),(1,1),(2,1)], [(0,2),(1,2),(2,2)],
[(0,0),(1,1),(2,2)], [(0,2),(1,1),(2,0)],
]
for line in lines:
marks = [self.board[r][c] for r, c in line]
if marks[0] and marks[0] == marks[1] == marks[2]:
return marks[0]
return None
def reset(self):
self.board = [["" for _ in range(3)] for _ in range(3)]
self.current_player = "X"
self.winner = None
self.draw = False
# ── View ──
class TicTacToeView:
def __init__(self, window):
self.window = window
self.window.title("井字棋")
self.buttons = {}
for r in range(3):
for c in range(3):
btn = tk.Button(window, text="", width=8, height=4,
font=("Arial", 18, "bold"))
btn.grid(row=r, column=c, padx=4, pady=4)
self.buttons[(r, c)] = btn
self.status = tk.Label(window, text="当前: X", font=("Arial", 14))
self.status.grid(row=3, column=0, columnspan=3, pady=8)
self.reset_btn = tk.Button(window, text="重新开始", command=self._on_reset)
self.reset_btn.grid(row=4, column=0, columnspan=3, pady=4)
def update(self, model):
for r in range(3):
for c in range(3):
self.buttons[(r, c)].config(text=model.board[r][c])
if model.winner:
self.status.config(text=f"{model.winner} 赢了!")
elif model.draw:
self.status.config(text="平局!")
else:
self.status.config(text=f"当前: {model.current_player}")
def bind_click(self, handler):
for (r, c), btn in self.buttons.items():
btn.config(command=lambda row=r, col=c: handler(row, col))
def _on_reset(self):
if hasattr(self, 'reset_handler'):
self.reset_handler()
# ── Controller ──
class TicTacToeController:
def __init__(self, model, view):
self.model = model
self.view = view
self.view.bind_click(self.on_click)
self.view.reset_handler = self.on_reset
self.view.update(self.model)
def on_click(self, row, col):
if self.model.play(row, col):
self.view.update(self.model)
def on_reset(self):
self.model.reset()
self.view.update(self.model)
# ── 启动 ──
if __name__ == "__main__":
root = tk.Tk()
model = TicTacToeModel()
view = TicTacToeView(root)
controller = TicTacToeController(model, view)
root.mainloop()
运行后你会看到一个 3×3 的按钮网格,点击格子轮流落子,胜负或平局时状态栏自动更新,"重新开始"按钮清空棋盘。
几个值得注意的细节:
play()返回bool,Controller 只在成功落子时才刷新 View,避免了无效点击触发多余渲染。- View 的
bind_click把所有按钮统一绑定到同一个 handler,通过闭包参数区分行列——后续如果想加右键菜单或键盘快捷键,只需在这里扩展。 reset_handler用属性挂载而非硬编码,Controller 在初始化时注入,View 不需要知道 Controller 的类名。
拿这个项目继续练的方向
这个版本是最小可运行骨架,几个方向可以往上叠:
- 加 AI 对手:在 Model 层实现 minimax 算法,Controller 在 O 的回合自动调用,View 不变——这就是 MVC 拆分的回报。
- 加动画与配色:X 用蓝色、O 用红色,赢棋线高亮。这些全在 View 里改,Model 和 Controller 不碰。
- 加计分板:多局累计胜场,Model 加一个
scores字典,View 加一行 Label。 - 换成自定义 Canvas 绘制:把
Button换成Canvas画十字和圆圈,布局逻辑不变,渲染更灵活。
MVC 不是银弹,对小游戏来说三层拆分有时显得"过度设计"。但它的价值在于:当你从九格井字棋走向更复杂的 GUI 时,这套分离习惯已经刻进了代码结构里,改起来不会一团乱。