做古诗词填字游戏这个Python项目,前两篇分别解决了诗词库构建和棋盘自动生成,命令行版本已经能跑通完整流程。但说句实话,终端里那种输入方式,让我自己测试几局都觉得憋屈,更别提给别人演示。所以这篇我决定动真格:用Tkinter把图形界面做出来,同时在界面化的过程中,把CLI版遗留的数据结构问题一并收拾干净。
这篇内容对正在做Python课程设计、毕业设计,或者想学Tkinter实际项目写法的朋友来说应该比较有参考价值。我不会只贴代码,而是把每个设计选择背后的原因和我在开发中踩过的坑一起说出来。涉及的点包括棋盘数据模型改造、Canvas绘制、键盘交互、中文输入法兼容、校验反馈、进度保存等。
1. 接手前两篇的代码:先理清CLI版留下哪些坑
前两篇里用的核心数据结构其实非常朴素。诗词库是一份JSON文件,每首诗拆成若干填写单位,棋盘生成时通过贪心算法把这些单位摆进一个二维网格里。
{ "id": 1, "title": "静夜思", "author": "李白", "words": [ {"text": "床前", "hint": "地点状语,卧室之中", "line_index": 1}, {"text": "明月光", "hint": "夜晚看到的自然景象", "line_index": 1} ] }棋盘则是一个二维字符数组,加上一份词位置列表。大致长这样:
board = [ ['床', '前', '', ''], ['明', '月', '光', ''], ['', ' ', '', ''] ] placed_words = [ {"text": "床前", "row": 0, "col": 0, "direction": "across"}, {"text": "明月光", "row": 1, "col": 0, "direction": "across"} ]这个结构对CLI版完全够用。循环打印、逐个读入用户输入、比对字符,逻辑非常直接。但当我开始构思GUI的时候,发现有三个明显的问题不解决,后面会越写越痛苦。
第一个问题:格子无法反查所属单词。用户在界面上点击某个格子,我需要立刻告诉他“这个格子属于哪一句、提示是什么”,但二维数组里只有字符本身,没有索引关系。要从board[r][c]反推它属于哪个placed_words条目,得写一段双重循环去遍历所有词的位置,性能倒不是问题,问题是代码很丑,而且将来要支持“点击格子高亮整条词”这类功能时会反复写同样的查找逻辑。
第二个问题:判断逻辑和打印逻辑耦合得太死。CLI版的print_board()和check_input()混在一个主循环里,界面一改动,这两部分都得跟着改。GUI和CLI完全不是一种交互模式,CLI是“等用户输入,输入完再判断”,GUI是“用户每按一个键就要实时反馈”,原来的框架根本套不进去。
第三个问题:空格的缺省值很不讲究。CLI里空格用' '表示,判断“这个格子有没有填过”只要查board[r][c] != ' '就行。但到了GUI,我需要区分三种状态:未填、填了等校验、已确认正确。一个裸字符数组表达不了这些状态,必须在外面再维护一套平行标记,或者干脆改造数据结构。
所以第三篇的第一步,不是画界面,而是先把数据模型改成适合交互的形式。
2. 数据模型改造:让每个格子知道自己属于哪句诗
重构的方向很简单:把“裸字符数组”升级成“对象网格”,让每个格子自己携带信息。我定义了两个核心类,Word和Cell。
class Word: def __init__(self, text, row, col, direction, hint, title, author): self.text = text self.row = row self.col = col self.direction = direction # 'across' 横向 / 'down' 纵向 self.hint = hint self.title = title self.author = author self.length = len(text) def cell_positions(self): positions = [] for i in range(self.length): r = self.row + (i if self.direction == 'down' else 0) c = self.col + (i if self.direction == 'across' else 0) positions.append((r, c)) return positions class Cell: def __init__(self, row, col): self.row = row self.col = col self.char = '' self.state = 'empty' # 'empty' / 'filled' / 'correct' / 'wrong' self.words = [] # 经过这个格子的Word对象列表 def belongs_to(self, word): return word in self.wordsBoard则在初始化时接收一份Word列表,把网格建出来,同时把每个格子和词的关联关系填好:
class Board: def __init__(self, words, height=20, width=20): self.height = height self.width = width self.grid = [[Cell(r, c) for c in range(width)] for r in range(height)] self.words = words self._build_index() def _build_index(self): for word in self.words: for r, c in word.cell_positions(): if 0 <= r < self.height and 0 <= c < self.width: self.grid[r][c].words.append(word) if not self.grid[r][c].char: self.grid[r][c].char = word.text[word.cell_positions().index((r, c))] def get_cell(self, row, col): if 0 <= row < self.height and 0 <= col < self.width: return self.grid[row][col] return None def find_word_by_position(self, row, col, direction): """从某个格子出发,找到包含它且方向匹配的那个词""" cell = self.get_cell(row, col) if not cell: return None for word in cell.words: if word.direction == direction: return word return None def active_words_at(self, row, col): cell = self.get_cell(row, col) return cell.words if cell else []这个改造带来的直接收益,可以从一个问题上看出来:用户点了一个格子,想看到提示。
CLI版写法大概是:拿这个格子作为锚点,遍历全部的placed_words,判断它是否落在某个词的路径范围里,找到后再去词库里翻提示。三层嵌套循环,看一次头晕一次。
改造后只需一行:
words = board.active_words_at(row, col) # 如果该格子同时属于横向词和纵向词,就展示两个提示这背后其实就是“空间换时间”的思想,在构建棋盘时顺手维护好索引关系,后面所有交互逻辑都变得非常清爽。很多人写小游戏觉得越写越乱,根子往往不在算法难,而是在最初的数据结构没有考虑到后续交互需求。
3. Tkinter画面搭建:Canvas绘制的不是方块,是交互入口
数据模型准备好之后,开始搭界面。Tkinter里画这种格子棋盘,通常有三种方案,我先把对比摆出来。
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| Frame + Label网格 | 组件现成,写起来直观 | 每个格子都是一个组件,内存开销大,大量交互事件绑定麻烦 | 棋盘超过10x10就会觉得卡顿 |
| 按钮网格 | 点击事件天然支持 | 样式统一,但想自定义高亮、颜色变化、选中态,要维护每个按钮的状态 | 做原型可以,做成品不舒服 |
| Canvas绘制 | 所有格子画在一个画布上,性能好,坐标定位精确 | 需要自己计算每个格子的矩形位置 | 最终选择 |
我最终选择Canvas。原因很实际:填字游戏的格子数量通常二三十个,不算多,但每个格子有四种状态(空白、填充、正确、错误),还要支持整词高亮、提示框、操作反馈。用组件按钮做,意味着要把界面状态同步到几十个组件上,稍不注意状态就乱了。Canvas把所有绘制统一管理,重绘一次就能反映全部状态变化。
绘制本身不复杂:
import tkinter as tk CELL_SIZE = 52 BG_COLOR = "#FDF6E3" GRID_COLOR = "#4A4238" HIGHLIGHT_COLOR = "#FFE082" CORRECT_COLOR = "#C8E6C9" WRONG_COLOR = "#FFCDD2" class CrosswordGUI: def __init__(self, board): self.board = board self.root = tk.Tk() self.root.title("古诗词填字游戏") self.root.geometry("1080x720") self.root.configure(bg=BG_COLOR) self.canvas = tk.Canvas( self.root, width=board.width * CELL_SIZE, height=board.height * CELL_SIZE, bg=BG_COLOR, highlightthickness=0 ) self.canvas.pack(side=tk.LEFT, padx=30, pady=30) self.current_word = None self.current_direction = 'across' self.canvas.bind("<Button-1>", self.on_canvas_click) self.root.bind("<KeyPress>", self.on_key_press) self.draw_grid()绘制一个词的所有格子,核心逻辑是把Word对象里的位置信息翻译成画布坐标:
def draw_grid(self): self.canvas.delete("all") self.cell_rects = {} for word in self.board.words: positions = word.cell_positions() for i, (r, c) in enumerate(positions): x1 = c * CELL_SIZE + 5 y1 = r * CELL_SIZE + 5 x2 = x1 + CELL_SIZE - 10 y2 = y1 + CELL_SIZE - 10 cell = self.board.grid[r][c] fill = BG_COLOR if self.current_word and cell in self.current_word.cell_positions(): fill = HIGHLIGHT_COLOR if cell.state == 'correct': fill = CORRECT_COLOR elif cell.state == 'wrong': fill = WRONG_COLOR rect_id = self.canvas.create_rectangle( x1, y1, x2, y2, fill=fill, outline=GRID_COLOR, width=2 ) self.cell_rects[(r, c)] = rect_id if cell.char: # 已填的字符,在格子中间绘制 self.canvas.create_text( (x1 + x2) // 2, (y1 + y2) // 2, text=cell.char, font=("Microsoft YaHei", 24, "bold"), fill="#2D2A26" ) else: text_id = self.canvas.create_text( (x1 + x2) // 2, (y1 + y2) // 2, text="", font=("Microsoft YaHei", 24, "bold"), fill="#2D2A26" ) self.empty_cells[(r, c)] = text_id这里有个容易忽略的细节:所有已填写和未填写的字,都用create_text创建文本对象。区别在于未填时文本是空字符串。为什么要这样做?因为后续更新某个格子时,我只需要调用canvas.itemconfig(text_id, text="某字")就能改内容,不用删掉矩形重建,既避免了闪烁,也减少了Canvas对象的数量。
字体的选择也值得多说一句。Windows上中文显示推荐Microsoft YaHei(微软雅黑),显示汉字清晰,粗体效果也好。macOS上可以改成“PingFang SC”,Linux上常见的是WenQuanYi Micro Hei。我会在代码开头做一个简单的平台判断:
import platform def get_chinese_font(size=24, bold=True): system = platform.system() if system == "Windows": family = "Microsoft YaHei" elif system == "Darwin": family = "PingFang SC" else: family = "WenQuanYi Micro Hei" return (family, size, "bold" if bold else "normal")这一步很多人会忽略,结果就是代码在自己电脑上显示正常,换台电脑字全变成方框。别问我怎么知道的,都是眼泪。
4. 点击与键盘输入:填字游戏最核心的交互闭环
界面画出来之后,交互就是重头戏。填字游戏的交互链路是:点击格子 -> 选中当前词 -> 键盘输入字 -> 自动跳下一个空格 -> 填完自动校验。
点击事件绑定在Canvas上,核心是坐标换算:
def on_canvas_click(self, event): col = event.x // CELL_SIZE row = event.y // CELL_SIZE cell = self.board.get_cell(row, col) if not cell or not cell.words: return # 如果当前选中的词依然包含这个格子,保持方向不变 if self.current_word and (row, col) in self.current_word.cell_positions(): pass else: # 否则优先选横向词,其次选纵向词 self.current_word = ( self.board.find_word_by_position(row, col, self.current_direction) or self.board.find_word_by_position(row, col, 'across') or self.board.find_word_by_position(row, col, 'down') ) if self.current_word: self.current_direction = self.current_word.direction self.refresh_highlight() self.refresh_hints()这里有个交互细节值得反复斟酌:用户点击一个交叉格子时,到底应该选中横向词还是纵向词?我的策略是“沿用当前方向优先”。换句话说,如果用户一直在填横向的词,点到一个交叉格子时,程序默认还是横向;如果用户明确用空格切换了方向,那下次点击就默认纵向。这个设计比较贴合填字游戏玩家的心理预期——大部分情况下用户是在顺着某一句话连续填写,而不是频繁切换方向。
方向切换我用空格键实现:
def on_key_press(self, event): if event.keysym == "space": self.switch_direction() return if event.keysym == "BackSpace": self.delete_last_char() return # 只处理汉字输入 ch = event.char if len(ch) == 1 and '\u4e00' <= ch <= '\u9fff': self.fill_current_cell(ch)switch_direction()的逻辑是这样的:当前格子可能同时属于横向词和纵向词,切换时拿到另一个方向的词,如果没有则不做任何事。
def switch_direction(self): if not self.current_word: return cell = self.board.get_cell(*self.current_word.cell_positions()[0]) for word in cell.words: if word.direction != self.current_word.direction: self.current_word = word self.current_direction = word.direction break self.refresh_highlight() self.refresh_hints()输入汉字后自动跳到下一个空格,这个动作听起来简单,但实现时要注意“跳过已填正确的格子”和“跳到最后要回到开头”两个边界情况:
def get_active_cell(self): """返回当前词的当前活动格子位置""" if not self.current_word: return None, None for r, c in self.current_word.cell_positions(): cell = self.board.grid[r][c] if cell.state != 'correct': return r, c return None, None def fill_current_cell(self, ch): active_r, active_c = self.get_active_cell() if active_r is None or active_c is None: return cell = self.board.grid[active_r][active_c] cell.char = ch cell.state = 'filled' # 更新画布对应位置的文字 text_id = self.empty_cells.get((active_r, active_c)) if text_id is not None: self.canvas.itemconfig(text_id, text=ch) # 自动跳到当前词的下一个空格 self.move_to_next_empty() self.check_current_word() def move_to_next_empty(self): if not self.current_word: return pos_list = self.current_word.cell_positions() current_idx = 0 for i, (r, c) in enumerate(pos_list): if self.board.grid[r][c].state != 'correct': current_idx = i break for offset in range(1, self.current_word.length + 1): idx = (current_idx + offset) % self.current_word.length r, c = pos_list[idx] cell = self.board.grid[r][c] if cell.state != 'correct': self.current_index = idx self.refresh_highlight() return我这样处理的用意是:填字游戏里,用户可能回过去修改某个格子。如果已经填对的格子是“锁定”状态,自动跳格就应跳过它;但如果只是普通填写状态,用户可以再点回去改。所以在move_to_next_empty里,我只跳过correct状态的格子,不跳过filled状态。这样用户第一次填错时能顺路填完,整词校验不通过后,又可以通过点击回到错字位置修改。
5. 校验反馈与通关判定:从“单个字对”到“整局完成”
校验逻辑我分成了三个层级,分别处理不同粒度的反馈。
第一个层级是单字实时校验。用户每填入一个字,立刻和正确答案比对,如果正确,就把格子背景染成浅绿色;如果错误,先不急着标红,因为填字游戏里经常出现“当前字看起来是错的,但整句话还没填完”的情况——过早标红会影响玩家的心态。所以我只在整词提交时统一标记错误字。
单字反馈用一句话实现:
def check_single_char(self, r, c): cell = self.board.grid[r][c] target = self.board.solution[r][c] # 完整答案矩阵 if cell.char == target: cell.state = 'correct' return True return False第二个层级是整词校验。当前词的所有空格都填满之后,触发check_current_word():
def check_current_word(self): if not self.current_word: return is_full = True all_correct = True for r, c in self.current_word.cell_positions(): cell = self.board.grid[r][c] if cell.char == '': is_full = False if cell.char != self.board.solution[r][c]: all_correct = False if not is_full: return if all_correct: for r, c in self.current_word.cell_positions(): self.board.grid[r][c].state = 'correct' self.show_feedback("这句填对了!") else: for r, c in self.current_word.cell_positions(): cell = self.board.grid[r][c] if cell.char != self.board.solution[r][c] and cell.state != 'correct': cell.state = 'wrong' self.show_feedback("有几个字不对,再改改") self.draw_grid()这里有个设计取舍:已经确认正确的格子,即使后来整词校验不通过,也不标红。因为正确的答案不应该被“连坐”。玩家在修改过程中可能改错了其他字,但已经答对的部分保持绿色,帮助他缩小排查范围。
第三个层级是整局完成判定。每次整词校验通过后,检查所有词的全部格子是否都已进入correct状态:
def check_game_complete(self): for word in self.board.words: for r, c in word.cell_positions(): if self.board.grid[r][c].state != 'correct': return False return True通关后的反馈,我加了一个简单的结算面板,显示本局用时和填对总字数,并自动把下一关解锁状态写入配置:
def finish_game(self): elapsed = int(time.time() - self.start_time) message = f"恭喜完成《{self.board.title}》!\n用时 {elapsed} 秒" tk.messagebox.showinfo("闯关成功", message) self.save_unlock_next_level()校验逻辑里有一个我自己踩过的坑:最初的版本在整词校验时,判断条件是cell.char == self.board.solution[r][c],但我为了效率,用了“当前词的字符拼接后整体对比”的策略,即把格子里的字符串起来,和word.text比。初期看起来没错,但遇到交叉词时,用户可能在某条横线上填了正确字符,那条竖线也借用了同一批格子,横竖两边的word.text都匹配。问题是中间过程里,用户先填了横线,竖线还没填完,此时横线的“整体对比”依然正确,但竖线因为缺字返回失败——这没问题。真正的问题出现在玩家填完竖线后,横线和竖线都通过校验了,但界面显示的“正确格子”出现了两次重复标记,导致统计通关字数时翻倍。所以最终改成逐格比对,配合correct状态去重,彻底解决了重复计数的问题。
6. 进度保存:我最终选择的不是保存整个棋盘
GUI版可以玩到一半退出,下次继续。为了实现这个需求,进度保存是必须的。我最开始很自然地想到:把整个棋盘二维数组存进JSON不就行了。
试过之后发现这是一个愚蠢的方案。二维数组里包含了所有格子的字符和状态,但它没有保存“当前正在编辑哪个词”“当前方向是什么”“已解锁到第几关”这些交互状态。而且棋盘尺寸如果固定是20x20,每次保存就是400个格子的状态,里面大量格子是空的,纯属浪费空间。
后来我换了个思路:只保存“玩家填过哪些格子”和“这些格子填了什么”,其他状态全部在加载时重建。
def save_progress(self, level_id, board, current_direction, unlocked_levels): progress = { "level_id": level_id, "filled_cells": [], "current_direction": current_direction, "unlocked_levels": unlocked_levels, "timestamp": time.time() } for row in board.grid: for cell in row: if cell.char and cell.state in ('filled', 'correct'): progress["filled_cells"].append({ "row": cell.row, "col": cell.col, "char": cell.char }) with open(f"progress_{level_id}.json", "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2)加载时,先重新生成棋盘和词库,然后把filled_cells里记录的字逐个填回格子,再跑一遍整词校验,让那些完整的词自动变成correct状态:
def load_progress(self, level_id): path = f"progress_{level_id}.json" if not os.path.exists(path): return None with open(path, "r", encoding="utf-8") as f: progress = json.load(f) return progress def restore_board(self, board, progress): for item in progress["filled_cells"]: cell = board.grid[item["row"]][item["col"]] cell.char = item["char"] cell.state = 'filled' # 重新跑一遍整体校验,把已经完整的词标记为正确 for word in board.words: all_correct = all( board.grid[r][c].char == board.solution[r][c] for r, c in word.cell_positions() ) if all_correct: for r, c in word.cell_positions(): board.grid[r][c].state = 'correct'这个方案最明显的好处,是存档文件非常小,一个关卡撑死也就几十条记录。而且因为存档只存“填写结果”不存“界面状态”,哪怕以后我改了棋盘尺寸、改了界面逻辑,老存档依然能加载。这也算是数据与表现分离带来的一点红利。
我还在存档里加了timestamp字段。实测中我发现,玩家经常玩到一半切出去查资料,再回来可能导致程序的内部计时不准。有timestamp后,可以通过时间差校准计时器,避免“明明玩了半小时,结算却显示1分钟”的窘境。
7. 开发中印象最深的三个坑:输入法、缩放与重绘
Tkinter做小游戏本身不难,真正的坑都在细节里。我挑三个最有代表性的说一下。
第一个坑是中文输入法导致的乱入字符。最初所有键盘事件都绑定<KeyPress>,结果Windows自带输入法在拼拼音的阶段,英文字母会直接触发事件。比如用户想打“床”字,先按了c,c不是汉字,被我的代码忽略掉了;接着按h,同样忽略;最后按空格选字,event.char变成空格,event.keysym变成了space,还是被忽略。到这里都正常,但用户选完字上屏后,汉字床会再次触发一次<KeyPress>,这次能正常捕获。
表面看起来没问题,实际有个诡异场景:输入法在“中文模式”下,用户直接按句号、逗号,上屏的可能是。或,,这些字符的Unicode范围落在\u3000-\u303f,不属于汉字范围,会被忽略。但用户如果按数字选词,event.keysym是1、2之类,event.char也是数字,同样会被忽略。问题不大。真正的问题是当输入法处于“英文模式”时,用户输入英文字母虽然被忽略,但如果他填入的拼音恰好触发了某个快捷键组合,有极小概率会让Tkinter窗口失焦。
我的解决思路是:绑定<KeyRelease>而不是<KeyPress>,同时用event.char判断,确保只有最终上屏的汉字才会进入逻辑:
self.root.bind("<KeyRelease>", self.on_key_release) def on_key_release(self, event): if len(event.char) == 1 and '\u4e00' <= event.char <= '\u9fff': self.fill_current_cell(event.char)但<KeyRelease>也有一个副作用:长按键盘时,系统会自动重复触发多次KeyRelease事件,导致一个字被填进多个空位里。我加了一个last_key_time时间戳,两次事件间隔小于80毫秒的直接忽略:
def on_key_release(self, event): now = time.time() if now - self.last_key_time < 0.08: return self.last_key_time = now # ... 原有逻辑第二个坑是窗口缩放后的点击偏移。Tkinter的Canvas如果允许窗口缩放,Canvas的坐标和实际像素坐标会不一致,但事件对象的event.x和event.y仍然返回逻辑坐标。听起来没什么问题,对吧?问题出在我用了canvas.scale()方法去调整已经绘制的图形时,所有create_rectangle的坐标会跟着缩放,但find_overlapping()、itemconfig()这类方法又只认Canvas当前的逻辑坐标。一旦执行了scale(),后续通过event.x // CELL_SIZE反推格子位置就会对不上。
解决办法是:不启用canvas.scale(),而是监听窗口尺寸变化事件,销毁全部图形后按新尺寸重新绘制。虽然“重建”听起来不如“缩放”优雅,但在格子数量不多的场景下,重建足够快,反而避免了坐标混乱:
def on_resize(self, event): if event.width <= 1 and event.height <= 1: return self.draw_grid()第三个坑是重绘时的顺序问题。最初我把格子背景填充和文字绘制混在同一个双层循环里,导致两个问题:一是改一个字要重画整个棋盘,闪烁严重;二是文字和背景的绘制顺序偶尔会颠倒,出现“文字被背景盖住”的怪现象。后来我把绘制拆成两层:第一层只画所有格子的背景色(根据状态决定颜色),第二层只画所有格子的文字。这样每次改动时先更新格子的状态字段,然后一次性调用两个绘制函数,整个界面刷新稳定不闪:
def draw_backgrounds(self): for r in range(self.board.height): for c in range(self.board.width): cell = self.board.grid[r][c] fill = self.compute_cell_color(cell) self.canvas.itemconfig(self.cell_rects[(r, c)], fill=fill) def draw_texts(self): for r in range(self.board.height): for c in range(self.board.width): cell = self.board.grid[r][c] text_id = self.cell_texts.get((r, c)) if text_id is not None: self.canvas.itemconfig(text_id, text=cell.char if cell.char else "")关于输入法这件事,我现在回头看,真正的教训不是“怎么拦截更多事件”,而是不要试图在KeyPress阶段区分“输入法拼音”和“最终字符”,因为不同系统的输入法行为差异很大。稳妥的做法就是:只用KeyRelease收尾,再用短时间窗口过滤重复事件。这个方法不完美,但胜在兼容性和可维护性都好,我在Windows和Linux下都测过,基本够用。
三篇写完,这个项目从“黑框里能跑通逻辑”变成了“可以直接拿给同学朋友玩的图形界面程序”。对我来说,最有价值的不是界面好看,而是通过重构数据模型,把填字游戏从“一次性脚本”变成了“可扩展的小应用”——现在加新关卡、换诗词库、做关卡解锁都只是配置的事情。如果你也想做类似的文字类小游戏,我的建议是:先别急着画界面,花点时间把“格子”和“词”的关系理清楚,后面你会有完全不一样的开发体验。