用 Tkinter 和 MVC 模式写一个井字棋:从布局到事件处理的完整实践

2026-05-22 32 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:8 分钟

学 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 需要做三件事:

  1. 检查该格子是否为空,非空则忽略。
  2. 在 Model 中写入当前玩家的标记(X 或 O)。
  3. 判定胜负或平局,更新 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 时,这套分离习惯已经刻进了代码结构里,改起来不会一团乱。


相关推荐