Tkinter 4x4井字棋行列填充后单元格塌陷问题求助
问题:4x4 Tkinter井字棋单元格尺寸塌陷问题
我和朋友基于RealPython的Tkinter井字棋教程开发了4x4版本,将默认的X/O替换为自定义图片。目前已实现点击棋盘单元格插入对应图片的功能,但遇到行列填满后单元格尺寸塌陷的问题,希望找到能始终保持所有单元格尺寸一致的解决方法。
代码实现
import tkinter as tk #Currently 8.6 from tkinter import font from itertools import cycle from typing import NamedTuple class Player(NamedTuple): # How to do as a 3D shape rather than flat shape label: str image: tk.PhotoImage color: str class Move(NamedTuple): row: int col: int label: str = "" #root = tk.Tk() BOARD_SIZE = 4 #Need to change default player - technically one person doesn't have label class TicTacToeGame: def __init__(self, board_size=BOARD_SIZE): self.board_size = board_size self.winner_combo = [] self._current_moves = [] self._has_winner = False self._winning_combos = [] self._setup_board() def _setup_board(self): self._current_moves = [ [Move(row, col) for col in range(self.board_size)] for row in range(self.board_size) ] self._winning_combos = self._get_winning_combos() def _get_winning_combos(self): rows = [ [(move.row, move.col) for move in row] for row in self._current_moves ] columns = [list(col) for col in zip(*rows)] first_diagonal = [row[i] for i, row in enumerate(rows)] second_diagonal = [col[j] for j, col in enumerate(reversed(columns))] return rows + columns + [first_diagonal, second_diagonal] def is_valid_move(self, move): """Return True if move is valid, and False otherwise.""" row, col = move.row, move.col move_was_not_played = self._current_moves[row][col].label == "" no_winner = not self._has_winner return no_winner and move_was_not_played def process_move(self, move): """Process the current move and check if it's a win.""" row, col = move.row, move.col self._current_moves[row][col] = move for combo in self._winning_combos: #Set logic may not work for ours # [1,1,0,0], [0,1,0,1],... # Sum the arrays in the line # Check for any element being 0 or 4 (min/max statement on array) # Pascal 1,4,6,4,1 in terms of shared attributes (0 up to 4) results = set( self._current_moves[n][m].label for n, m in combo ) is_win = (len(results) == 1) and ("" not in results) if is_win: self._has_winner = True self.winner_combo = combo break def has_winner(self): """Return True if the game has a winner, and False otherwise.""" return self._has_winner def is_tied(self): """Return True if the game is tied, and False otherwise.""" no_winner = not self._has_winner played_moves = ( # Has the label of every cell been updated from empty i.e. piece played move.label for row in self._current_moves for move in row ) return no_winner and all(played_moves) def reset_game(self): """Reset the game state to play again.""" for row, row_content in enumerate(self._current_moves): for col, _ in enumerate(row_content): row_content[col] = Move(row, col) self._has_winner = False self.winner_combo = [] class TicTacToeBoard(tk.Tk): def __init__(self, game): super().__init__() self.title("TicTacToe Game") img = tk.PhotoImage(file="pieces/cyan_cuboid_small_striped.png").subsample(5) img2 = tk.PhotoImage(file="pieces/orange_cuboid_small_striped.png").subsample(5) players = ( Player(label="X", image = img, color="blue"), Player(label="O", image = img2, color="green"), ) self._players = cycle(players) self._cells = {} self._game = game self.current_player = next(self._players) self._create_menu() self._create_board_display() self._create_board_grid() def _create_board_display(self): display_frame = tk.Frame(master=self) display_frame.pack(fill=tk.X) #frame fills width if window resized self.display = tk.Label( master=display_frame, #label needs to live in frame text="Ready?", font=font.Font(size=28, weight="bold"), ) self.display.pack() def _create_board_grid(self): grid_frame = tk.Frame(master=self) #Hold the game grid cells grid_frame.pack() for row in range(self._game.board_size): self.rowconfigure(row, weight=1, minsize=50) self.columnconfigure(row, weight=1, minsize=75) for col in range(self._game.board_size): button = tk.Button( master=grid_frame, text="", font=font.Font(size=36, weight="bold"), fg="black", width=3, height=2, highlightbackground="lightblue", ) self._cells[button] = (row, col) button.bind("<ButtonPress-1>", self.play) button.grid( row=row, column=col, padx=5, pady=5, sticky="nsew" ) def toggle_player(self): """Return a toggled player.""" self.current_player = next(self._players) # The .mainloop() method on the Tk class runs what’s known as the application’s # main loop or event loop. This is an infinite loop in which all the GUI events happen. def play(self, event): """Handle a player's move.""" clicked_btn = event.widget row, col = self._cells[clicked_btn] move = Move(row, col, self.current_player.label) if self._game.is_valid_move(move): # Maybe show error message to user if False? self._update_button(clicked_btn) self._game.process_move(move) # Maybe check winner first rather than tied # Maybe only check winner after row/col/diagonal was filled # Technically first board_size - 1 moves don't need check if self._game.is_tied(): self._update_display(msg="Tied game!", color="red") elif self._game.has_winner(): self._highlight_cells() msg = f'Player "{self.current_player.label}" won!' color = self.current_player.color self._update_display(msg, color) else: self.toggle_player() msg = f"{self.current_player.label}'s turn" self._update_display(msg) def _update_button(self, clicked_btn): # Need to set to a 3D image for us clicked_btn.config(image=self.current_player.image) #clicked_btn.config(text=self.current_player.label) #clicked_btn.config(fg=self.current_player.color) def _update_display(self, msg, color="black"): self.display["text"] = msg self.display["fg"] = color def _highlight_cells(self): for button, coordinates in self._cells.items(): if coordinates in self._game.winner_combo: button.config(highlightbackground="red") def _create_menu(self): menu_bar = tk.Menu(master=self) self.config(menu=menu_bar) file_menu = tk.Menu(master=menu_bar) file_menu.add_command( label="Play Again", command=self.reset_board ) file_menu.add_separator() file_menu.add_command(label="Exit", command=quit) menu_bar.add_cascade(label="File", menu=file_menu) def reset_board(self): """Reset the game's board to play again.""" self._game.reset_game() self._update_display(msg="Ready?") # Reset all the buttons for button in self._cells.keys(): button.config(highlightbackground="lightblue") button.config(text="") button.config(fg="black") def main(): """Create the game's board and run its main loop.""" game = TicTacToeGame() board = TicTacToeBoard(game) board.mainloop() if __name__ == "__main__": main()
问题截图


解决方案
核心问题分析
- 按钮的
width和height参数基于字符尺寸计算,替换为图片后,按钮会优先适配图片尺寸,空按钮则保持字符尺寸,导致大小不一致。 - 错误地给主窗口设置行列配置,实际承载按钮的
grid_frame未被配置,无法正确分配空间。 - 图片未被实例变量引用,存在被垃圾回收的风险。
修复代码步骤
1. 修正网格容器的行列配置
将行列配置从主窗口转移到grid_frame,并让容器填满窗口:
def _create_board_grid(self): grid_frame = tk.Frame(master=self) grid_frame.pack(fill=tk.BOTH, expand=True) # 让容器随窗口缩放 for row in range(self._game.board_size): # 给grid_frame设置行列权重和最小尺寸 grid_frame.rowconfigure(row, weight=1, minsize=100) grid_frame.columnconfigure(row, weight=1, minsize=100) for col in range(self._game.board_size): button = tk.Button( master=grid_frame, text="", fg="black", highlightbackground="lightblue", # 移除width和height,由网格布局控制尺寸 ) self._cells[button] = (row, col) button.bind("<ButtonPress-1>", self.play) button.grid( row=row, column=col, padx=5, pady=5, sticky="nsew" )
2. 修复图片引用和重置逻辑
将图片存为实例变量防止回收,同时重置按钮时清空图片:
def __init__(self, game): super().__init__() self.title("TicTacToe Game") # 图片存为实例变量,避免被垃圾回收 self.img_cyan = tk.PhotoImage(file="pieces/cyan_cuboid_small_striped.png").subsample(5) self.img_orange = tk.PhotoImage(file="pieces/orange_cuboid_small_striped.png").subsample(5) players = ( Player(label="X", image=self.img_cyan, color="blue"), Player(label="O", image=self.img_orange, color="green"), ) self._players = cycle(players) self._cells = {} self._game = game self.current_player = next(self._players) self._create_menu() self._create_board_display() self._create_board_grid() # 重置按钮时补充清空图片 def reset_board(self): """Reset the game's board to play again.""" self._game.reset_game() self._update_display(msg="Ready?") for button in self._cells.keys(): button.config(highlightbackground="lightblue") button.config(text="") button.config(fg="black") button.config(image="") # 清空图片
3. 确保按钮尺寸统一
在更新按钮时强制保持网格分配的尺寸:
def _update_button(self, clicked_btn): clicked_btn.config(image=self.current_player.image) # 强制按钮不根据图片拉伸,保持网格尺寸 clicked_btn.config(width=0, height=0)
内容的提问来源于stack exchange,提问作者Ben Collister
相关产品推荐
相关产品推荐

