尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
第3篇 · 使用 PyQt5 实现 2048 桌面版游戏:MVC 架构与撤销和重做
第3篇 · 使用 PyQt5 实现 2048 桌面版游戏MVC 架构与撤销和重做前两篇为纯 Python 实现第 1 篇的数学模型、第 2 篇的带历史记录的控制台版。控制台版适合验证逻辑但交互体验并不好每执行一步都要cls清屏、光标闪烁且只能使用键盘。这一篇将其迁移到桌面端用PyQt5实现图形界面。同样是把游戏显示出来写法上存在一个根本分岔逻辑与渲染是耦合在一起还是分为三层。耦合在一起最为简单但测试一次合并需先创建一个窗口修改一次配色也要查阅业务代码分为 MVC 之后Model 只处理棋盘、View 只处理像素、Controller 只做事件分发三者互不干扰。另一项工程实践是撤销和重做。它有两种典型实现命令模式记录每一步操作并反向执行与状态快照直接保存棋盘深拷贝。前者内存占用较小但需为每种操作编写反向逻辑后者实现简单且可靠代价是内存。本项目棋盘仅为 4×4快照成本可忽略因此选用状态快照栈。下文围绕MVC 分层、棋盘渲染、键盘控制、状态快照式撤销和重做、与历史记录打通、视觉规范与高 DPI 适配等方面说明 PyQt5 桌面版 2048 的实现。1. 为什么需要 MVC在控制台版中_move、display_grid、get_key都集中在同一个类中。一旦换为 GUI若继续沿用这种写法就会出现典型问题逻辑与渲染耦合测试一次合并是否正确需先创建一个窗口修改一次配色则需查阅大量业务代码。因此按MVCModel–View–Controller分层层类职责ModelGameCore棋盘状态、合并规则、得分、历史持久化、撤销和重做不含任何界面代码ViewTileLabel/BoardView只负责把 Model 的网格渲染成方块不处理任何业务逻辑ControllerMainWindow捕获键盘/按钮事件调用 Model再通知 View 刷新分层的直接收益是GameCore不依赖 PyQt可以像第 1、2 篇那样单独导入与测试界面若更换为 Tkinter、Web 或命令行核心逻辑均无需改动。2. Model复用规则与方向归约GameCore承载了前两篇的全部逻辑棋盘、随机方块、移动合并、胜负判断并在第 2 篇的基础上继续做历史记录持久化。合并规则压缩非零、相邻相同合并、右侧补零、每个方块最多合并一次与第 1 篇完全一致这里不再重复代码。GUI 版只在此基础上保留两项内容方向抽象上下先转置、左右按需翻转行四个方向复用同一套逐行逻辑历史持久化沿用第 2 篇的save_game_state/update_indexrecord_move按 GUI 版需要改成「步数 / 操作类型 / 当前分数 / 网格状态」四列每次成功移动写两行方向与补块。def_slide(self,direction):movedFalseifdirectionin(up,down):self.grid[list(row)forrowinzip(*self.grid)]# 转置foriinrange(self.grid_size):rowself.grid[i][::-1]ifdirectionin(right,down)elseself.grid[i]new_row,has_movedself._merge_line(row)ifdirectionin(right,down):new_row.reverse()self.grid[i]new_row movedmovedorhas_movedifdirectionin(up,down):self.grid[list(row)forrowinzip(*self.grid)]# 还原returnmoved3. View用 QGridLayout QLabel 绘制棋盘PyQt 中绘制棋盘有两条常见路线QGraphicsView/QGraphicsScene或自定义QWidgetpaintEventQPainter。本系列先采用更简单、更易读的方案QGridLayout布局 16 个QLabel。它足以表达静态棋盘也为第 4 篇的动画改造留出清晰边界。3.1 关键点方块是格子每个TileLabel是一格用固定尺寸的QLabel 样式表绘制圆角背景与文字颜色映射不同数值对应 2048 官方配色空格用统一的底板色View 只渲染BoardView.render(grid)接收一个二维网格逐格刷新不做任何判断。3.2 代码实现方块渲染classTileLabel(QLabel):单个方块View 的最小元素# 经典 2048 配色空格底色单独用 COLOR_EMPTYCOLORS{2:(238,228,218),4:(237,224,200),8:(242,177,121),16:(245,149,99),32:(246,124,95),64:(246,94,59),128:(237,207,114),256:(237,204,97),512:(237,200,80),1024:(237,197,63),2048:(237,194,46),}# 4096 起的深琥珀色阶与 2048 拉开明度差同时保证白字可读HIGH_COLORS{4096:(237,176,32),8192:(227,159,22),16384:(214,143,15),}TEXT_DARK#776e65TEXT_LIGHT#f9f6f2def__init__(self,value0,parentNone):super().__init__(parent)self.setAlignment(Qt.AlignCenter)self.setFixedSize(TILE_SIZE,TILE_SIZE)# 固定尺寸保证严格正方形self.set_value(value)defset_value(self,value):根据数值刷新圆角背景、字色与字号ifvalue0:bgQColor(COLOR_EMPTY)elifvalue2048:bgQColor(*self.HIGH_COLORS.get(value,(198,128,12)))else:bgQColor(*self.COLORS.get(value,(60,58,50)))colorself.TEXT_DARKifvalue4elseself.TEXT_LIGHT# 字号随位数自适应2–64 常规 36px逐级递减保证大数值不溢出ifvalue0:size1elifvalue100:size36elifvalue1000:size30elifvalue10000:size24else:size17self.setStyleSheet(fQLabel {{ background-color:{bg.name()}; color:{color};f border-radius:{TILE_RADIUS}px; font-size:{size}px; font-weight: bold; }})self.setText(ifvalue0elsestr(value))棋盘视图管理一个 4×4 的TileLabel矩阵classBoardView(QWidget):def__init__(self,grid_size4,parentNone):super().__init__(parent)self.grid_sizegrid_size self.setAttribute(Qt.WA_StyledBackground,True)self.setStyleSheet(fbackground-color:{COLOR_BOARD}; border-radius:{BOARD_RADIUS}px;)sideBOARD_PADDING*2TILE_SIZE*grid_sizeTILE_GAP*(grid_size-1)self.setFixedSize(side,side)# grid_size4 时为 434 × 434layoutQGridLayout(self)layout.setSpacing(TILE_GAP)layout.setContentsMargins(BOARD_PADDING,BOARD_PADDING,BOARD_PADDING,BOARD_PADDING)self.tiles[]foriinrange(grid_size):row[]forjinrange(grid_size):tileTileLabel()layout.addWidget(tile,i,j)row.append(tile)self.tiles.append(row)defrender(self,grid):按当前网格刷新所有方块foriinrange(self.grid_size):forjinrange(self.grid_size):self.tiles[i][j].set_value(grid[i][j])注意事项用QPalette改背景无法设置圆角还必须调用setAutoFillBackground(True)才生效改用setStyleSheet设置background-colorborder-radius更为简便。方块用setFixedSize固定尺寸是保证16 格严格等大、棋盘正方形的稳妥方式。3.3 界面效果V3 游戏页QGridLayout QLabel 棋盘与底部撤销/重做按钮┌───────────────────────────────────────┐ │ 2048 分数 最高分 │ │ 128 4096 │ ├───────────────────────────────────────┤ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │ 2 │ │ 4 │ │ │ │ │ │ │ └────┘ └────┘ └────┘ └────┘ │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │ 8 │ │ 16 │ │ │ │ 2 │ │ │ └────┘ └────┘ └────┘ └────┘ │ │ ... │ ├───────────────────────────────────────┤ │ 撤销 重做 新游戏 │ └───────────────────────────────────────┘3.4 一次方向键操作的完整数据流Qt KeyPress → MainWindow.keyPressEvent / 事件路由 → Controller.on_move(direction) → GameCore.apply_move(direction) → 记录移动前快照、执行合并与随机生成 → 保存历史 / 更新分数 → Controller.refresh() → QLabel 棋盘与撤销/重做按钮刷新这里的路径表示本篇 V3 的控制关系代码调用的具体先后顺序应以本篇随后展示的on_move()、apply_move()与refresh()为准。撤销时恢复历史快照最高分仍保持历史最大值V3 的快照不含完整 RNG因此“恢复同一棋盘”不等于“恢复未来每一步的随机序列”。4. Controller键盘事件与按钮Controller 是MainWindow。它在keyPressEvent中把按键映射为意图再驱动 Model、刷新 View。4.1 关键点方向键 / WASD四个方向移动CtrlZ / CtrlY撤销和重做在窗口层捕获按键本项目选择由主窗口持有焦点避免出现点击按钮后方向键失效这不是唯一可行方案也可以在子控件层做事件过滤或统一路由第 5 篇采用后一种。4.2 键盘事件defkeyPressEvent(self,event):keyevent.key()ctrlbool(event.modifiers()Qt.ControlModifier)ifctrlandkeyQt.Key_Z:self.on_undo()returnifctrlandkeyQt.Key_Y:self.on_redo()returndirection{Qt.Key_Up:up,Qt.Key_W:up,Qt.Key_Down:down,Qt.Key_S:down,Qt.Key_Left:left,Qt.Key_A:left,Qt.Key_Right:right,Qt.Key_D:right,}.get(key)ifdirectionisNone:super().keyPressEvent(event)returnself.on_move(direction)关键注意点QPushButton默认可获取焦点。一旦某个按钮获得焦点方向键会被 Qt 的焦点导航拦截无法传递到窗口的keyPressEvent现象即字母键 WASD 有效、方向键无效。解决办法是让按钮不获取焦点并让主窗口自身持有焦点button.setFocusPolicy(Qt.NoFocus)# 创建按钮时self.setFocusPolicy(Qt.StrongFocus)# 窗口本身4.3 状态刷新移动、撤销、重做最终都收敛到改 Model → 刷新 View这一条链路defon_move(self,direction):ifself.model.game_over:returnifnotself.model.apply_move(direction):returnself.refresh()ifself.model.game_over:self.show_game_over()elifself.model.wonandnotself.win_announced:self.win_announcedTrue# 只弹一次恭喜胜利self.show_game_won()def_sync_history_buttons(self):撤销和重做按钮的视觉状态只跟随历史栈与第 4 版同名同职责self.undo_button.setEnabled(self.model.can_undo())self.redo_button.setEnabled(self.model.can_redo())defrefresh(self):把 Model 的最新状态同步到 View 与分数/按钮状态self.board_view.render(self.model.grid)self._render_score(self.score_label,分数,self.model.score)self._render_score(self.best_label,最高分,self.model.best_score)self._sync_history_buttons()refresh()负责维持 MVC 一致性任何一次状态变化都只通过它更新界面避免界面出现一半新一半旧的中间态。按钮的启用状态被单独抽取为_sync_history_buttons()仅由历史栈决定第 4 版同名同职责便于两版一致维护。一次方向键按下的完整调用链顺序调用所在层职责1keyPressEvent(Key_Left)Controller把按键解析为意图directionleft2on_move(left)Controller检查game_over拦截非法移动3model.apply_move(left)Model滑行/合并、计分、写历史、生成新块、判胜负4refresh()Controller触发一次统一刷新5board_view.render(grid)View把 16 个格子的值写入QLabel6_render_score(...)/_sync_history_buttons()View/Controller更新分数与撤销/重做按钮7show_game_over()/show_game_won()Controller终局提示仅一次这条链路体现了 MVC 的边界View 只接收网格并渲染从不反向调用 ModelController 负责把输入翻译成 Model 调用再统一驱动 View 刷新。5. 撤销和重做基于状态快照栈博客大纲中提出用历史记录栈实现 undo/redo。实现方式有多种记录每一步的(动作, 随机种子)再回放或直接保存状态快照。此处选择后者实现简单且可靠。5.1 关键点每次合法移动前先把当前(grid, score, game_over, won, current_move, history_count)深拷贝压入undo_stackundo()弹出undo_stack顶部恢复到棋盘同时把当前状态压入redo_stackredo()反向操作发生新移动时清空redo_stack经典的分支被覆盖语义。5.2 代码实现defapply_move(self,direction):执行一次移动记录快照 - 合并 - 写历史 - 生成新方块 - 判断胜负snapshotself._snapshot()ifnotself._slide(direction):returnFalse# 非法移动不产生快照self.undo_stack.append(snapshot)self.redo_stack.clear()self._sync_best_score()# 分数刷新纪录时同步最高分self.record_move(DIRECTION_NAMES[direction])self.add_new_tile()self.record_move(添加新方块)self.check_win()self.check_game_over()returnTruedef_snapshot(self):当前状态快照深拷贝避免后续修改污染历史return{grid:deepcopy(self.grid),score:self.score,game_over:self.game_over,won:self.won,current_move:self.current_move,history_count:self.history_count,# 当前生效的历史行数undo/redo 时据此回退 history.csv}defundo(self):ifnotself.undo_stack:returnFalseself.redo_stack.append(self._snapshot())self._restore(self.undo_stack.pop())self._sync_best_score()self.save_game_state()# 撤销后同步持久化self.update_index()returnTruedefredo(self):ifnotself.redo_stack:returnFalseself.undo_stack.append(self._snapshot())self._restore(self.redo_stack.pop())self._sync_best_score()self.save_game_state()self.update_index()returnTrue四个细节_snapshot必须使用deepcopy棋盘是嵌套列表浅拷贝会让撤销栈与当前棋盘共享同一块内存导致撤销失效。非法移动不生成快照_slide返回False时直接返回否则连续按下数次无效方向键会挤掉有效历史。快照中额外保存current_move与history_count_restore()会据此重写history.csv使撤销和重做后的历史文件与棋盘状态保持一致撤销后再执行新的一步时会丢弃被撤销掉的那些未来行。快照不包含 RNG 状态。撤销恢复的是棋盘、分数与历史行恢复之后的新方块仍由当前全局随机数状态生成因此“撤销后再走同一方向”不保证与第一次走子产生相同的随机方块。若需要逐位复现必须像第 6 篇那样显式保存random.getstate()。best_score在撤销/重做路径上只升不降_sync_best_score()只做max更新_restore()也不会把最高分改小因此撤销不会被用来“刷低”历史纪录。6. 启动页设计V3 启动页继续上一局 / 开始新游戏Model 完整保留了第 2 篇的持久化能力因此 GUI 版自然支持继续上一局。为避免每次启动都弹出确认框采用一个启动页QStackedWidget的第 0 页承载该选择startup()读取index.json有未完成的对局就停在启动页展示上一局的分数否则直接进入游戏页启动页用「继续上一局」与「开始新游戏」两个按钮完成选择取代「弹QMessageBox询问是否续玩」的做法游戏结束 / 达成 2048 的提示仍使用QMessageBox每走一步写history.csv与game_state.json通过is_completedgame_over or won判断对局状态。这样第 2 篇积累的game_history/数据在 GUI 版中继续积累为后续的训练数据或离线分析准备样本。7 界面视觉规范与高 DPI 适配MVC 打通之后对界面进行了一轮纯视觉优化只调整布局、配色、字号、圆角、间距与控件状态不改动任何游戏逻辑、存档、分数计算与键盘规则。7.1 把样式收敛成常量颜色与尺寸集中定义在文件顶部按钮 QSS 用 f-string 复用这些常量避免硬编码分散在各处COLOR_BG#faf8ef;COLOR_TEXT#776e65;COLOR_SUBTITLE#8f8175COLOR_BOARD#bbada0;COLOR_EMPTY#cdc1b4COLOR_CARD_LABEL#f5efe6;COLOR_CARD_VALUE#ffffffTILE_SIZE,TILE_GAP,BOARD_PADDING96,10,10TILE_RADIUS,BOARD_RADIUS6,9BOARD_SIDEBOARD_PADDING*2TILE_SIZE*4TILE_GAP*3# 4347.2 两个页面的布局与尺寸启动页标题(70px) → 副标题(13px) → 摘要卡(330×110) → 主按钮(330×48) → 文字按钮(330×44)整体同轴居中摘要卡内是「上一局 大数字 下方标签分数 / 最高分」。游戏页标题 48px两张 96×62 的统计卡与标题顶边对齐棋盘恒为 434×434padding/gap各 10、格圆角 6、棋盘圆角 9底部「撤销和重做」居左、「新游戏」居右三键统一 100×44且左右边缘与棋盘对齐。按钮按「主 / 次 / 文字」三套 QSS 拉开层级每套都带hover / pressed / focus / disabled四态border恒为 2px默认透明这样状态切换时不会发生尺寸跳动。7.3 键盘可达又踩了一次方向键失效启动页的两个按钮需要键盘可达Tab聚焦、回车 / 空格触发。此处再次遇到焦点问题QPushButton默认可获取焦点一旦按钮获得焦点方向键会被 Qt 的焦点导航拦截无法传递到窗口的keyPressEvent。因此策略是启动页按钮可聚焦StrongFocus并额外支持回车触发游戏页按钮显式NoFocus保证游戏中的方向键始终直达窗口。/* 启动页按钮带可见 focus 态 */QPushButton:focus{border-color:#8f7a66;}classEnterButton(QPushButton):可聚焦按钮额外支持回车触发QPushButton 原生只响应空格defkeyPressEvent(self,event):ifevent.key()in(Qt.Key_Return,Qt.Key_Enter):self.click()event.accept()returnsuper().keyPressEvent(event)7.4 高 DPI 适配在创建QApplication之前开启缩放让逻辑像素随系统缩放整体放大避免 125% / 150% 下的错位与截断QApplication.setAttribute(Qt.AA_EnableHighDpiScaling,True)QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps,True)随后可在 100% / 125% / 150% 三档缩放下检查窗口尺寸、棋盘正方形与控件边界。需要说明当前仓库保留了 V4 的 DPI 对照截图但V3 没有单独的 DPI 截图或自动化断言因此本节的“三档视觉无裁切”只能作为适配建议V3 的实际渲染效果标记为UNVERIFIED可复用的证据见第 4 篇。8. 运行说明8.1 环境依赖pipinstallPyQt58.2 运行与操作python-mv3_pyqt_2048.v3_pyqt_2048_with_history方向键 /WASD移动方块CtrlZ/CtrlY撤销和重做底部按钮撤销、重做、新游戏启动页继续上一局 / 开始新游戏Tab切换焦点回车/空格触发。9. 小结这一篇把 2048 从控制台迁移到桌面端重点不在让方块好看而在三项工程实践MVC 分层GameCoreModel不依赖任何 UI可独立测试BoardViewView只渲染MainWindowController只做事件分发与状态同步。三层之间通过refresh()单向收敛。状态快照式撤销和重做undo_stack/redo_stack保存deepcopy的状态快照语义清晰、实现简单。界面规范化把颜色 / 尺寸 / 圆角收敛成常量启动页与游戏页统一视觉并补齐了高 DPI 适配与键盘焦点可达。当前实现每次移动都是整盘重绘动画表现为瞬移。下一篇将针对性能与观感做优化把棋盘拆分为静态背景层 动画层用定时器对每个方块的起止位置做插值实现平滑的移动与合并动画。这套 MVC 快照式撤销和重做适合规则复杂、需要独立测试与替换渲染的桌面应用若棋盘或数据体积很大快照成本会随时间线性增长届时需改用命令模式。第 4 篇的动画版正是直接复用GameCore、仅替换 View。上一篇第 2 篇 · 如何实现带历史记录功能的控制台2048游戏下一篇第 4 篇 · 使用 PyQt5 实现 2048平滑动画与性能优化
RELATED

相关推荐

Linux进程篇(六):穿过底层看本质,深入理解底层进程切换与 O(1) 调度算法

Linux进程篇(六):穿过底层看本质,深入理解底层进程切换与 O(1) 调度算法

目录 一、 进程切换与 CPU 寄存器原理 1.1 死循环进程如何运行?——深入理解时间片 1.1.1 现代操作系统的公平调度 1.2 聊聊 CPU 寄存器与临时数据 1.2.1 寄存器的诞生背景与核心作用 1.2.2 常见的核心寄存器分类 1.3 核心结论:区分“空间”与“内…

📅 2026/10/22 21:00:26
人机协同:用 interrupt 把控制权交给你

人机协同:用 interrupt 把控制权交给你

个人主页&#xff1a;> for_ever_love__ <&#xff08;欢迎各位大佬莅临&#x1f60a;&#xff09; 其他栏目: > 大模型开发从0到1 < 其他栏目: > iOS项目总结大全 < 其他栏目: > 我想学python了 < 其他栏目: > iOS UI < 文章目录人机协同&#…

📅 2026/10/22 20:55:26
Checkpointer 基础:把图的状态存下来

Checkpointer 基础:把图的状态存下来

个人主页&#xff1a;> for_ever_love__ <&#xff08;欢迎各位大佬莅临&#x1f60a;&#xff09; 其他栏目: > 大模型开发从0到1 < 其他栏目: > iOS项目总结大全 < 其他栏目: > 我想学python了 < 其他栏目: > iOS UI < 文章目录Checkpointer 基…

📅 2026/10/22 20:55:26
MORE NEWS

更多资讯

📰

Ubuntu系统盘制作与安装全攻略:从镜像写入到双系统引导修复

1. 为什么值得花时间把系统盘制作这件事搞明白 很多人第一次接触 Ubuntu 安装&#xff0c;卡住的地方往往不是系统本身&#xff0c;而是"盘没做对"。我见过太多人下载完 ISO 镜像&#xff0c;双击打开&#xff0c;把里面的文件一股脑拖进 U 盘&#xff0c;然后插上电…

📰

交通标志识别数据集处理与YOLO多版本适配实战

做交通标志识别的朋友&#xff0c;估计都有过这种体验&#xff1a;模型在公开数据集上刷出很高的mAP&#xff0c;一换到自己的摄像头画面就各种漏检&#xff1b;或者数据集下载下来一大堆类别&#xff0c;标注格式还不统一&#xff0c;光是清洗转换就花掉两三天。这篇文章&…

📰

空芯光纤:突破瑞利散射极限,低延迟传输与工程应用解析

做传输的人都清楚一件事&#xff1a;普通单模光纤的损耗已经被压到 0.14 dB/km 附近&#xff0c;再往下几乎没有空间&#xff0c;因为那个数字根本不是工艺问题&#xff0c;而是石英材料在 1550nm 波长处的瑞利散射底线。这几年空芯光纤之所以频繁出现在行业技术讨论里&#xf…

📰

Kafka 3.x 生产环境部署实战:KRaft模式配置、调优与避坑指南

1. 为什么部署 Kafka 这件事值得单独拿出来讲Kafka 这三个字在数据领域出现的频率太高了&#xff0c;高到很多人觉得它就是个“装一下就能跑”的中间件。但真正在生产环境部署过 Kafka 的人都知道&#xff0c;从零把一套集群拉起来&#xff0c;和把它跑稳、跑对、跑得能扛住业务…

📰

微信小程序图书管理系统实战:云开发源码与数据库设计全解析

简介&#xff1a;基于微信小程序的图书管理系统提供完整源码与数据库&#xff0c;适配小程序毕业设计场景&#xff0c;适合计算机相关专业学生或开发者快速搭建图书借阅管理项目。系统涵盖图书浏览、搜索、借阅、归还、预约、评论及用户注册登录、管理员后台等核心功能&#xf…

📰

轻量视觉注意力模型TVA如何破解3C外观检测量产难题

干3C外观检测这行&#xff0c;最怕的不是算法跑不动&#xff0c;而是产线换新机种时那声“这个外观瑕疵能不能两周内搞定量产”。手机中框、耳机仓、笔电外壳&#xff0c;每个新机种都有全新的缺陷形态&#xff0c;用老模板改来改去&#xff0c;参数调得人怀疑人生。这两年我把…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬