ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Tkinter实战:古诗词填字游戏图形界面开发

Tkinter实战:古诗词填字游戏图形界面开发 做古诗词填字游戏这个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, height20, width20): 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(bgBG_COLOR) self.canvas tk.Canvas( self.root, widthboard.width * CELL_SIZE, heightboard.height * CELL_SIZE, bgBG_COLOR, highlightthickness0 ) self.canvas.pack(sidetk.LEFT, padx30, pady30) 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, fillfill, outlineGRID_COLOR, width2 ) self.cell_rects[(r, c)] rect_id if cell.char: # 已填的字符在格子中间绘制 self.canvas.create_text( (x1 x2) // 2, (y1 y2) // 2, textcell.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(size24, boldTrue): 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, textch) # 自动跳到当前词的下一个空格 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(fprogress_{level_id}.json, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse, indent2)加载时先重新生成棋盘和词库然后把filled_cells里记录的字逐个填回格子再跑一遍整词校验让那些完整的词自动变成correct状态def load_progress(self, level_id): path fprogress_{level_id}.json if not os.path.exists(path): return None with open(path, r, encodingutf-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自带输入法在拼拼音的阶段英文字母会直接触发事件。比如用户想打“床”字先按了cc不是汉字被我的代码忽略掉了接着按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)], fillfill) 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, textcell.char if cell.char else )关于输入法这件事我现在回头看真正的教训不是“怎么拦截更多事件”而是不要试图在KeyPress阶段区分“输入法拼音”和“最终字符”因为不同系统的输入法行为差异很大。稳妥的做法就是只用KeyRelease收尾再用短时间窗口过滤重复事件。这个方法不完美但胜在兼容性和可维护性都好我在Windows和Linux下都测过基本够用。三篇写完这个项目从“黑框里能跑通逻辑”变成了“可以直接拿给同学朋友玩的图形界面程序”。对我来说最有价值的不是界面好看而是通过重构数据模型把填字游戏从“一次性脚本”变成了“可扩展的小应用”——现在加新关卡、换诗词库、做关卡解锁都只是配置的事情。如果你也想做类似的文字类小游戏我的建议是先别急着画界面花点时间把“格子”和“词”的关系理清楚后面你会有完全不一样的开发体验。
返回列表