Python+tkinter实战:古诗词填字游戏的三层架构开发

发布时间:2026/10/6 9:12:42
Python+tkinter实战:古诗词填字游戏的三层架构开发 系列前两篇写完不少读者已经跟着把古诗词数据的清洗和命令行版“出题-作答-判定”流程跑通了。这一篇是收尾篇目标很纯粹把之前散落的功能模块整合成一个真正能玩的图形界面小游戏——鼠标点击格子、键盘输入汉字、实时判断对错、提示要扣分、最后还能算个总分。适合两类人看一类是已经能写基础Python、想找个完整项目练手的中级学习者另一类是手上真有一批诗词内容、想做点教学工具或者文化类小应用的非技术背景朋友。这篇不讲花架子只讲怎么把一个脚本改造成一个可持续维护的小项目。很多人跟着教程写到第二篇就散了因为代码全摞在一个文件里想加一个功能就要改三处改完又冒出两个新问题。这篇我给自己定的规矩是数据、逻辑、界面三层分开宁可多拆几个函数也绝不为了省事把东西全塞进主循环。代码我会按模块给出你可以照着组合也可以直接拿去改。1. 先想清楚这一版到底要做什么样的填字游戏1.1 最终效果与功能清单先对着成品说需求。这一版的游戏界面大致分三个区域顶部关卡名称、当前得分、已填字数、当前关卡进度中间画布绘制的棋盘棋盘由若干格子组成横向词条和纵向词条会在某些格子交叉底部操作按钮区包括“检查”“提示”“重置”“下一关”。玩家用鼠标点选一个格子格子会高亮直接敲键盘输入汉字。程序立刻判断对错正确格子变成浅绿色并且被锁定防止玩家手误改掉错误格子变成浅红色但不会被锁定玩家可以继续改空格保持白色等待输入。功能对应的实现方式我整理成了一张表功能实现方式备注棋盘渲染tkinter Canvas 绘制方格不用控件堆格子性能好且易扩展输入汉字绑定Key事件配合正则过滤只接收单字中文其余一律忽略正确判定输入后立即和标准答案比较正确则锁定错误提示输入后状态置为错误格子变红不锁定允许重输检查按钮遍历所有格子汇总错误数只提示不自动改提示按钮自动填一个未填格每次扣 10 分重置按钮清空所有未锁定格子分数还原到当前关卡初始分关卡切换读取 JSON 关卡配置换关时重建棋盘和记分这个功能清单就是一个完整填字游戏的最小可用集再往下减就不像游戏了往上加可以后续扩展排行榜、倒计时、音效等等但基础版先守住这些功能。1.2 为什么第三篇还要重新梳理项目结构前两篇的代码偏脚本式一个文件从头写到尾变量、函数、入口混在一起。当时数据量小、逻辑简单这样写省事。但到了第三篇界面事件、关卡切换、计分逻辑、校验逻辑交织在一起如果还堆在一个文件里基本没法维护。所以我建议把项目拆成三个文件data/levels.json存放所有关卡配置game_logic.py纯逻辑层负责棋盘建模、输入校验、计分完全不知道界面的存在main_ui.py界面层负责画棋盘、处理鼠标键盘事件、调用逻辑层接口、刷新显示。这样拆最大的好处是逻辑层可以脱离界面单独测试。比如在命令行里直接创建一个PuzzleLogic对象用假数据调input_char就能验证功能是否正确不用每次打开图形窗口手动点。如果你后期想换成网页版只用重写界面层棋盘和计分规则原样搬过去就行。对个人项目来说这个投入产出比非常划算。从这一篇开始所有功能都围绕这三层结构展开。下面我把每一层的关键实现拆开讲。2. 数据层用 JSON 描述一个关卡2.1 关卡 JSON 的基本结构填字游戏的核心是“布局”哪个词条横向放、哪个词条纵向放、从哪个格子开始、交叉在哪个字上。我选择把布局写进 JSON而不是让程序自动生成理由后面会讲。一个关卡的最小配置长这样[ { name: 静夜思·月, rows: 5, cols: 5, words: [ {text: 床前明月光, row: 0, col: 0, dir: across}, {text: 月落乌啼霜, row: 0, col: 3, dir: down} ], hints: { across_0: 《静夜思》第一句, down_0: 《枫桥夜泊》第一句 } } ]字段说明name关卡名会显示在界面顶部rows/cols棋盘的行数和列数这里等于棋盘的坐标范围words词条列表每个词条有四要素内容、起始行、起始列、方向hints提示信息键名用across_0/down_0表示第几个横向或纵向词条值是给玩家的提示文字。坐标规则很简单row为 0 表示最上面一行col为 0 表示最左边一列。across方向从左往右写每写一字col加一down方向从上往下写每写一字row加一。上面这个关卡配置实际摆放出来就是一个 L 形床 前 明 月 光 落 乌 啼 霜第一行横向是“床前明月光”第 4 列下标 3向下是“月落乌啼霜”交叉点正好是“月”字。2.2 从词条列表生成棋盘矩阵布局数据写好了程序怎么把它变成二维矩阵这是逻辑层最基础的一个函数。我直接给出完整实现def build_grid(level): rows level[rows] cols level[cols] # 初始化空棋盘 grid [[ for _ in range(cols)] for _ in range(rows)] for word in level[words]: text word[text] r, c word[row], word[col] direction word[dir] for ch in text: if grid[r][c] ! and grid[r][c] ! ch: raise ValueError( f坐标冲突 {r},{c}已有 {grid[r][c]} f又要写入 {ch} ) grid[r][c] ch if direction across: c 1 elif direction down: r 1 else: raise ValueError(f未知方向: {direction}) return grid这个函数返回一个二维列表每一格存放该位置的正确答案。比如上面那个 L 形关卡的棋盘矩阵就是[ [床, 前, 明, 月, 光], [, , , 落, ], [, , , 乌, ], [, , , 啼, ], [, , , 霜, ] ]函数里做了坐标冲突检查这是关键。我在写第一版的时候没有加这个检查结果手工拼关卡数据时眼花两个词条在同一个格子里写了不同汉字程序一直等到用户输入到那个格子才发现问题非常难排查。现在加了ValueError任何布局不对都会在加载关卡时立刻爆出来。这一行防御代码能帮你省下大量调试时间。2.3 一个两难问题为什么不做全自动布局很多朋友拿到填字游戏的思路第一反应是“能不能自动生成整个棋盘自动匹配交叉点”。我一开始也是这么想的还花了几天试图写一个算法拿几百句诗去碰撞找到共享字再自动摆放位置。最后发现这条路短期走不通。原因有三条共享字命中率低五言诗里两句话能共享同一个字的组合比例不高。你拿到的几百句诗能自动配出十几组优质交叉就算很不错了剩下的全是勉强拼凑、毫无美感的布局。自动排版不可控就算匹配到了共享字词条互相挤在一起可能产生狭长棋盘或者大量空格新手玩家打开就懵了。关卡设计感为零填字游戏的乐趣在于难度曲线。第一关应该给一个明显的 L 形或者十字形让玩家快速体验到第一个交叉点带来的惊喜之后的关卡才逐步增加词条数量和交叉点。自动生成完全照顾不到这种体验节奏。所以最终方案是布局靠人工设计程序只负责加载和渲染。填字游戏本质是内容编排的艺术关卡编辑器的价值远大于随机生成器。当然如果你确实想做自动生成可以把“找共享字”做成辅助工具先把候选词条按共享字聚合再由你手工挑选和定位这个方向是可以推进的。3. 逻辑层棋盘建模、输入校验与计分3.1 一个格子需要记录哪些状态棋盘不只是“正确答案”和“当前答案”两张表那么简单。从交互角度看每个格子至少还要知道当前状态的类型、是否被锁定。我建议用一个字典或者一个小类来管理class Cell: def __init__(self, answer): self.answer answer # 标准答案 self.current # 玩家当前输入 self.locked False # 正确后锁定 self.status empty # empty / correct / wrong为什么answer由构造时传入而不是后面赋值因为逻辑层需要在你输入之前就知道这个格子是否有效。如果玩家点了一个不在任何词条上的空格answer就是空字符串程序可以直接忽略输入。status字段是界面层的“翻译器”。逻辑层不需要知道格子是什么颜色它只需要把correct或者wrong状态告诉界面层具体变绿还是变红由 UI 负责。这个解耦很重要以后想换主题色或者改成移动端样式逻辑层一行都不用动。3.2 输入事件的处理流程玩家每敲一个字程序就会走一遍下面的流程检查有没有选中格子如果没有直接忽略检查格子是否允许输入如果锁定或者是空白格忽略检查输入字符是不是单个汉字把字符写入格子的current和answer比较更新status如果正确把格子设为locked通知界面层刷新这个格子。对应逻辑层的核心方法import re HANZI_RE re.compile(r^[\u4e00-\u9fff]$) def input_char(self, row, col, ch): cell self.grid[row][col] if not cell.answer: return False if cell.locked: return False if not HANZI_RE.match(ch): return False cell.current ch if ch cell.answer: cell.locked True cell.status correct self.correct_count 1 else: cell.status wrong self.penalty(2) # 每次填错扣 2 分 return True这里有一个细节我用正则^[\u4e00-\u9fff]$校验单字汉字。这个范围覆盖了常用的 20000 多个汉字对古诗词素材来说足够用。注意不要用\w因为\w会把英文字母、数字、下划线都算进去玩家敲一个空格或者标点就全放行了。还有一个容易踩的坑古诗词里偶尔会有异体字或者生僻字比如“閒”这种不在基础汉字区的情况。为了保证正常显示你可以在预处理阶段把这些字映射成常用简体字或者把正则范围放宽到扩展 A 区\u3400-\u4dbf但那可能引入玩家输入法打不出来的字。我建议素材制作时统一用简体字在数据层面就规避这个问题。3.3 计分规则与提示系统基础的计分规则我设计得很简单每关初始得分 100 分填错一次扣 2 分使用一次提示扣 10 分全关完成后额外加 20 分分数下限是 0不会出现负分。计分模块单独抽一个类class ScoreBoard: def __init__(self, initial100): self.score initial def use_hint(self): self.score max(0, self.score - 10) def wrong_penalty(self, count1): self.score max(0, self.score - 2 * count) def complete_bonus(self): self.score 20这个设计的逻辑是错误扣分要轻因为填字游戏本身有试错成分太重容易让人不敢尝试提示扣分要重因为提示是直接给答案扣太少玩家会无脑提示过关整个游戏失去意义。分数下限设成 0免得玩家填错太多出现负分影响心情。提示功能本身很直接找到第一个还没有被正确填写的格子把正确答案填进去同时扣分。要注意的是如果所有格子已经填对提示按钮应该无效避免白扣分def hint(self): if self.is_complete(): return False for row in self.grid: for cell in row: if cell.answer and not cell.locked: cell.current cell.answer cell.locked True cell.status correct self.correct_count 1 self.score_board.use_hint() return (row_index, col_index, cell.answer) return None“检查”按钮的逻辑更简单遍历所有格子统计status wrong的数量弹个消息告诉玩家错了几处。这个功能不是为了替玩家改错而是给一个全局反馈让玩家知道还有哪些地方需要回头看。4. 界面层用 tkinter 画一个能玩的棋盘4.1 Canvas 画棋盘的基本框架tkinter 里画棋盘有两条路一是用Button或Label控件排布成网格二是在一个Canvas上画矩形和文字。我强烈建议用Canvas。理由很现实一个 7x7 的棋盘要放 49 个控件事件绑定和管理都很啰嗦而且控件多了之后界面刷新会掉帧Canvas这一张画布几十个绘图对象轻松搞定性能好代码也清爽。核心绘制函数CELL 64 def draw_grid(canvas, level, cell_sizeCELL): canvas.delete(all) rows, cols level[rows], level[cols] for r in range(rows): for c in range(cols): x0, y0 c * cell_size, r * cell_size x1, y1 x0 cell_size, y0 cell_size fill #efefef canvas.create_rectangle( x0, y0, x1, y1, outline#666, fillfill, width2 )注意一个问题关卡配置里可能会出现整行或整列都是空的情况也就是说某些格子不属于任何词条。这种格子画出来没有任何意义玩家点进去也没有反应。我建议在绘制时判断一下answer是否为空为空就把格子画成浅灰色并禁止点击。具体判断可以通过逻辑层暴露一个接口比如logic.is_cell_valid(r, c)。4.2 点击选中与键盘输入的事件绑定棋盘是画布但玩家要能选中格子、输入汉字。事件绑定分两步。第一步鼠标点击画布把点击坐标换算成格子坐标def on_canvas_click(self, event): col event.x // CELL row event.y // CELL if self.logic.is_cell_valid(row, col): self.selected (row, col) self._highlight_cell(row, col)这里有三个细节值得注意event.x // CELL是整除得到的是格子下标。如果距离超出棋盘范围需要再判断一次row rows and col cols。边界情况点击在两格交界线上时整除结果仍会落到某一格玩家不会感知到明显的选择偏差可以接受。点击空白格时不要高亮否则玩家会困惑“为什么选中了却没法输入”。第二步给根窗口绑定键盘事件root.bind(Key, self.on_key_press) def on_key_press(self, event): if self.selected is None: return row, col self.selected if event.keysym BackSpace: self.logic.clear_cell(row, col) else: self.logic.input_char(row, col, event.char) self._refresh_cell(row, col) self._update_progress()BackSpace用于清空格子这是一个易用性很强的设计很多玩家填错了第一反应是按退格而不是重新输入覆盖。注意clear_cell只作用于没有锁定的格子锁定格不能清除。4.3 中文输入法的一个大坑用bind(Key)直接捕获输入在英文输入法下很顺畅但在中文输入法下会遇到一个麻烦当你敲拼音时event.char可能拿到的是英文字母或空字符串等按下空格或者数字上屏后事件已经过去了程序什么都没收到。这个问题当年让我很崩溃。很长一段时间里我的方案是“点击格子后弹出一个输入小窗口”玩家在小窗口里用任意输入法输入回车确认后把字填进去。这个方案兼容性最好任何输入法都能正常工作。后来发现在 Windows 11 自带输入法和搜狗输入法下直接bind(KeyRelease)也能稳定拿到上屏汉字因为真实汉字是在KeyRelease阶段才进入事件队列的。但不同系统行为不一致Linux 下有的输入法还是拿不到。所以这一版我推荐一个更稳妥的方案在界面底部放一个隐藏的Entry玩家点击格子后自动焦点放到这个Entry上输入汉字后按回车确认。说它“隐藏”不是真的看不见而是平时不显示输入提示需要时给一点点视觉反馈。这样绕开输入法事件捕获的兼容性问题实现成本低而且玩家体验并不差。def _setup_entry(self): self.entry tk.Entry(self.root, font(微软雅黑, 14)) self.entry.place(x0, y0, width1, height1) # 极小不可见 self.entry.bind(Return, self._on_entry_confirm) def _on_entry_confirm(self, event): text self.entry.get().strip() if len(text) 1 and HANZI_RE.match(text): row, col self.selected self.logic.input_char(row, col, text) self._refresh_cell(row, col) self.entry.delete(0, tk.END) self.root.focus_set() # 把焦点还给主窗口避免误触玩过填字游戏的朋友都知道连续输入的感觉很重要。这里root.focus_set()是把焦点交还给主窗口这样玩家点下一个格子时不会碰到 Entry 还残留焦点的问题。4.4 按钮区与状态栏底部按钮区用tk.Frame和tk.Button实现“检查”调用逻辑层统计错误数用messagebox.showinfo显示结果“提示”调用logic.hint()如果返回坐标就刷新对应格子并刷新分数“重置”调用logic.reset_level()清空所有未锁定格子并重置分数“下一关”调用load_level(current 1)载入新关卡。状态栏用一个tk.Label每次输入、提示、重置后调用_update_progress()刷新以下内容def _update_progress(self): total self.logic.total_cells() done self.logic.correct_count text f{self.level[name]} 得分{self.logic.score} 进度{done}/{total} self.status_label.config(texttext)这里有个提示显示分数和进度不要用多个 Label 分散更新一个 Label 一次性config(text...)最省事也避免界面闪烁。5. 整合与打包从模块到可双击的桌面程序5.1 GameApp 类的完整结构下面把界面层的主角GameApp类完整搭出来它负责串联数据、逻辑和界面三部分。class GameApp: def __init__(self, root, levels): self.root root self.levels levels self.current_index 0 self.selected None self.canvas None self.status_label None self.logic None self._build_ui() self.load_level(0) def _build_ui(self): self.root.title(古诗词填字游戏) self.root.geometry(560x520) self.status_label tk.Label( self.root, text, font(微软雅黑, 12), pady10 ) self.status_label.pack(sidetk.TOP, filltk.X) self.canvas tk.Canvas( self.root, width500, height400, bg#fafafa, highlightthickness0 ) self.canvas.pack(pady10) btns tk.Frame(self.root) btns.pack(pady10) for text, cmd in [ (检查, self.check_all), (提示, self.use_hint), (重置, self.reset_level), (下一关, self.next_level), ]: tk.Button( btns, texttext, commandcmd, width8, font(微软雅黑, 11) ).pack(sidetk.LEFT, padx5) self.canvas.bind(Button-1, self.on_canvas_click) self.root.bind(Key, self.on_key_press)类初始化时先_build_ui()建好控件然后load_level(0)载入第一关。这里注意一个问题按钮和 Canvas 都建好之后才可以开始绘制棋盘所以load_level必须放在_build_ui之后调用顺序不能反。5.2 load_level 的职责边界load_level是整局的“初始化开关”它做四件事def load_level(self, index): if index 0 or index len(self.levels): messagebox.showinfo(提示, 已经是最后一关啦) return self.current_index index self.level self.levels[index] self.logic PuzzleLogic(self.level) self.selected None self._draw_board() self._update_progress()这里我故意让PuzzleLogic在构造时接收整个level字典并在内部完成build_grid、格子状态数组、得分板的初始化。这样界面层不关心棋盘矩阵怎么构建只负责调用logic.grid拿到格子数据来画图。关键点是每次换关都要创建一个全新的PuzzleLogic实例千万不要复用旧实例然后手动清空状态。因为build_grid内部会做坐标冲突检查新建实例本身就是一次校验任何布局错误都会在这里暴露。复用旧实例容易把上一关的残留状态带进新关卡这类 bug 非常隐蔽。5.3 主入口与 PyInstaller 打包入口文件很简单if __name__ __main__: with open(data/levels.json, r, encodingutf-8) as f: levels json.load(f) root tk.Tk() app GameApp(root, levels) root.mainloop()注意读取文件时一定要写encodingutf-8。我之前在 Windows 上遇到过 JSON 里汉字全都变成乱码的怪事就是因为系统默认编码不是 UTF-8json.load用了错误编码解析。打包成可执行文件我推荐 PyInstaller。一条命令搞定pyinstaller -F -w -n 古诗词填字游戏 main_ui.py参数含义-F生成单个可执行文件方便分发-w不显示命令行窗口避免黑框-n指定生成文件的名字。如果后续加了图标再加上--iconpuzzle.ico参数。打包完之后在dist目录里就会得到“古诗词填字游戏.exe”把这个文件发给朋友就能直接玩他们机器上不需要装 Python。一个打包实测经验如果程序里有用到tkinterPyInstaller 一般会自动带上不需要额外配数据文件。但如果你把关卡数据levels.json放在外部文件并且用相对路径读取打包后很容易出现找不到文件的问题。最简单的解决办法是直接用--add-data data/levels.json;data把它打进包里再把读取路径改成sys._MEIPASS兼容的写法。这个属于进阶话题基础版先不展开但你心里要有这根弦。6. 运行阶段的常见问题与避坑记录6.1 点击格子没反应或者输入时字符直接出现在控制台排查思路按顺序走一遍确认canvas.bind(Button-1)和root.bind(Key)都绑上了点击格子时有没有执行_highlight_cell如果没有说明selected没有设置成功输入时按键事件被 Entry 控件拦截了这是最常见的原因。如果焦点在按钮或者 Entry 上root.bind(Key)可能不触发最简单的办法是绑定窗口所有控件而不是只在 root 上绑或者每次点击格子后调用self.root.focus_set()强制抢回焦点。这个坑我在做按钮区时踩得很惨玩家用鼠标点完“提示”按钮后焦点落在按钮上再点棋盘输入键盘事件全被按钮吞了。最后加上focus_set()才解决。6.2 汉字输入框输入后没有反应如果你走了隐藏 Entry 的方案最常见的问题是玩家输入完汉字后没有按回车而是习惯性地点击了另一个格子。这时候 Entry 的Return事件没触发输入的内容卡在 Entry 里棋盘自然不变。一个体验优化在 Entry 获得焦点时给输入框一个明显的视觉位置或者把确认方式改成“输入一个汉字后自动提交”——这需要绑定KeyRelease而不是Return。我实测下来绑定KeyRelease后在 Windows 和主流 Linux 桌面环境下都能稳定拿到上屏汉字但稳定性不如回车确认。给你的建议是测试环境允许就用KeyRelease自动提交体验最顺要绝对可靠就回车确认代价是玩家多按一次回车。6.3 高分屏下窗口和字体发虚tkinter 在 Windows 高分屏下的表现一直很一般。处理办法有三步按重要性排序import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass这段代码要放在创建窗口之前。它告诉系统这个程序自己处理 DPI 缩放否则 tkinter 界面会被系统拉伸所有格子看起来都是糊的。字体方面尽量用(微软雅黑, 12)这类明确指定字族和字号的写法不要用 tkinter 的默认字体。如果你用的是 4K 屏幕CELL 64可能会显得格子偏小。可以把格子大小做成常量统一改一处就行。我的经验是格子边长在 56 到 72 之间观感最好小于 56 字会挤大于 72 棋盘会显得空。6.4 关卡数据出错坐标冲突、字多出棋盘这类问题本质是数据层的问题表现五花八门两个词条在同一个格子放了不同汉字程序直接ValueError崩溃词条太长写到棋盘外面去了运行时没有报错但棋盘显示不完整词条里夹了标点符号或者空格玩家永远无法输入。应对方法除了前面说的build_grid里检查冲突还可以在加载关卡后加一个校验函数专门检查每个词条是否越界def validate_level(level): rows, cols level[rows], level[cols] for word in level[words]: text_len len(word[text]) if word[dir] across and word[col] text_len cols: raise ValueError(f词条 {word[text]} 超出横向边界) if word[dir] down and word[row] text_len rows: raise ValueError(f词条 {word[text]} 超出纵向边界)把这个校验函数放在PuzzleLogic.__init__里每次加载关卡都自动执行。这类防御性检查虽然不起眼但在你以后往 JSON 里添加关卡时会救你很多次。6.5 重置后分数没有还原这个问题的根源在于你在界面层可能直接操作了逻辑层的内部属性比如在重置时写了logic.score 100但逻辑层的ScoreBoard内部还有一个自己的计分状态两边没同步。我建议所有分数状态统一封装在逻辑层内部界面层只通过接口访问def reset_level(self): self.grid self._build_grid_from_level(self.level) self.score_board ScoreBoard(initial100) self.correct_count 0这样每次重置都是新建一个ScoreBoard对象彻底避免旧数据残留。同样提示、检查、输入全走逻辑层的方法界面层不要直接改score字段。工程上这叫“最小暴露接口”前期可能觉得多写了几行代码后期真香。7. 想清楚然后把代码组合起来把所有模块组合完一个完整可玩的古诗词填字游戏就立住了。数据层提供关卡配置逻辑层管理棋盘和计分界面层处理交互和渲染。三层之间的接口就几个函数input_char、hint、check、reset_level、load_level。以后的扩展都可以沿着这个边界推进。最后分享一个我觉得最值得做的扩展方向在“提示”上做文章。现在的提示是随机填一格玩起来比较生硬。如果诗词库足够大可以基于当前棋盘已有的字反向匹配诗库把提示升级成“王维写过一句含‘月’的诗下一句是什么”这种更符合诗词主题的玩法游戏的文化含量一下就上去了。古诗词填字的乐趣一半在文字本身一半在出题的方式。你可以从这句开始把提示系统做成和诗词库深度联动的模块这个项目的水准会再上一个台阶。