资讯详情

第4篇 · 使用 PyQt5 实现 2048:平滑动画与性能优化

📅 2026/10/22 7:35:48 | 华诺云谱 👁 阅读
第4篇 · 使用 PyQt5 实现 2048:平滑动画与性能优化
第4篇 · 使用 PyQt5 实现 2048平滑动画与性能优化第 3 篇用 PyQt5 将 2048 迁移到桌面端但界面是整盘重绘、方块瞬移的一次移动就是把新网格直接写入 16 个QLabel方块从 A 格跳到 B 格中间没有过程。动画的本质是为状态变化补上时间轴先计算每个方块的起点与终点再由定时器逐帧插值。它类似于舞台调度背景布景搭建完成后不再变化只有演员在移动对应到代码就是把棋盘拆分为静态背景层只画一次缓存成QPixmap 动画层每帧按过渡信息重绘方块。实现上有两条常见路线QPropertyAnimation面向单个属性、写法简洁但需为每块各建一个动画对象QTimer 手动插值更为直接能精确控制每帧绘制的内容并把刷新范围收敛到棋盘控件。在 4×4、16 个方块的规模下可控性更有价值。下文围绕过渡信息的生成、分层渲染、逐帧插值、输入节流、性能取舍等方面说明移动动画与渲染优化的实现。1. 总思路Model 不动换掉 View实现动画需先回答一个问题一次移动中哪个方块从哪里来、到哪里去依靠对比移动前后的两个棋盘反推并不可靠数字会重复。例如一行[2, 2, 2, 2]向左合并后为[4, 4, 0, 0]无法从一个静态网格判断出哪个 4 来自哪两个 2。正确做法是在合并发生的那一刻记录过渡信息。于是分层这样安排层类变化ModelGameCore第 3 篇一行不改 AnimatedGameCore子类只额外记录每块的起点 → 终点与新生方块位置ViewBoardViewQWidgetQPainter自绘从 16 个QLabel换成静态背景层缓存 动画层逐帧插值ControllerMainWindow动画进行中忽略输入动画结束后再做胜负提示Model 直接复用第 3 篇from v3_pyqt_2048_with_history import GameCore。也就是说这一篇没有改动任何一条合并规则、存档或撤销重做的逻辑再次印证了 MVC 分层的价值。2. Model只补过渡信息AnimatedGameCore继承GameCore把两件事记下来last_transitions每块的去向和last_spawn新方块坐标。2.1 逐行合并时输出去向def_merge_line_tracked(self,line): 返回 (新行, ops)。 ops: [(源下标, 目标下标, 该块当前显示的数值, 是否合并)] sizelen(line)items[(v,p)forp,vinenumerate(line)ifv!0]# (数值, 源下标)result[]# (最终值, [源下标...], 是否合并, 滑动时显示的数值)i0whileilen(items):ifi1len(items)anditems[i][0]items[i1][0]:vitems[i][0]self.scorev*2result.append((v*2,[items[i][1],items[i1][1]],True,v))i2else:result.append((items[i][0],[items[i][1]],False,items[i][0]))i1new_line[r[0]forrinresult][0]*(size-len(result))ops[]fordst_p,(_final,srcs,merged,tile_v)inenumerate(result):forspinsrcs:ops.append((sp,dst_p,tile_v,merged))returnnew_line,ops关键细节合并时两块携带各自的原值例如两个 2一起滑向同一格落定后才显示合并结果 4。若把两块都当作 4 来绘制滑行过程中就会出现凭空多出一个 4的错觉。2.2 覆盖_slide行内下标 → 棋盘坐标_slide仍然沿用第 3 篇那套上下先转置、左右按需翻转的方向抽象只是额外记录了过渡信息def_slide(self,direction):sizeself.grid_size self.last_transitions[]movedFalseifdirectionin(up,down):self.grid[list(row)forrowinzip(*self.grid)]foriinrange(size):originalself.grid[i]roworiginal[::-1]ifdirectionin(right,down)elseoriginal new_row,opsself._merge_line_tracked(row)ifdirectionin(right,down):new_rownew_row[::-1]ifnew_row!original:# 与原行而非翻转后的行比较movedTrueself.grid[i]new_rowforsrc_p,dst_p,value,mergedinops:# 行内下标 - 棋盘坐标注意翻转与转置ifdirectionin(right,down):src_p,dst_psize-1-src_p,size-1-dst_pifdirectionin(up,down):src,dst(src_p,i),(dst_p,i)else:src,dst(i,src_p),(i,dst_p)self.last_transitions.append((src,dst,value,merged))ifdirectionin(up,down):self.grid[list(row)forrowinzip(*self.grid)]returnmoved再记录新生方块的位置动画即可确定哪个格子需要放大出现defadd_new_tile(self):empty[(i,j)foriinrange(self.grid_size)forjinrange(self.grid_size)ifself.grid[i][j]0]ifnotempty:self.last_spawnNonereturnFalsei,jrandom.choice(empty)self.grid[i][j]2ifrandom.random()0.9else4self.last_spawn(i,j)returnTrue已遇到的问题moved必须与翻转前的原行比较。早期版本与翻转后的row比较导致向右/向下方向在实际上未移动时也返回True按下一次方向键就会多出一个新方块。后来用随机棋盘将AnimatedGameCore._slide与第 3 篇的GameCore._slide逐一对比才定位到该偏差。3. View分层渲染 逐帧插值BoardView不再使用QLabel而是继承QWidget自行使用QPainter绘制。帧结构很简单先绘制背景再绘制方块。3.1 静态背景层只画一次缓存成 pixmap背景棋盘底板 16 个空格恒定不变无需每帧重绘并重复圆角抗锯齿计算。构造时烘焙为一张QPixmapdef_build_background(self):pmQPixmap(BOARD_SIDE,BOARD_SIDE)pm.fill(Qt.transparent)pQPainter(pm)p.setRenderHint(QPainter.Antialiasing,True)p.setPen(Qt.NoPen)p.setBrush(QColor(COLOR_BOARD))p.drawRoundedRect(QRectF(0,0,BOARD_SIDE,BOARD_SIDE),BOARD_RADIUS,BOARD_RADIUS)p.setBrush(QColor(COLOR_EMPTY))foriinrange(self.grid_size):forjinrange(self.grid_size):p.drawRoundedRect(self._cell_rect(i,j),TILE_RADIUS,TILE_RADIUS)p.end()returnpmpaintEvent中一句p.drawPixmap(0, 0, self._background)即可绘制整块底板其余按过渡信息绘制全部方块含原地不动的方块。动画渲染分层静态背景 QPixmap棋盘底板 16 个空格 ↓ 每次 paintEvent 复制背景 动态方块 QPainter普通方块 正在插值的方块 ↓ 合并落定后触发 粒子覆盖层 ParticleOverlay合并碎片 / 交互反馈缓存背景减少了重复绘制底板的成本但BoardView.update()仍会请求重绘整个棋盘控件不能将其称为“逐瓦片脏矩形优化”。粒子反馈是另一层视觉效果不能与搜索计算耗时混作同一种性能指标。3.2 动画层滑行 → 合并/新生一次移动分两个阶段用一个 16ms 目标 tick 的QTimer推进名义 62.5Hz这是调度间隔不是实测帧率move所有方块从起点插值滑到终点pop落定后合并的格子做一次回弹新生格子放大出现。def_tick(self):ifself.animisNone:self.timer.stop()returnself.anim[t]self.timer.interval()/1000.0ifself.anim[phase]move:ifself.anim[t]MOVE_DURATION:ifself.anim[mode]reverse:# 回退只有“滑回”一个阶段见 §4self._finish_animation()returnself.anim[phase]popself.anim[t]0.0self._emit_merge_fragments()# 合并落定与弹性动画同步释放碎片else:ifself.anim[t]POP_DURATION:self._finish_animation()returnself.update()def_finish_animation(self):self.gridself.anim[target]self.animNoneself.timer.stop()self.update()self.animationFinished.emit()时间口径的限制_tick用t timer.interval() / 1000.0累加而不是读取perf_counter()的墙钟时间。它能保证在定时器按时触发时动画时长稳定但主线程一旦被阻塞定时器会丢 tick累加值会比真实时间慢表现为动画“变慢”或跳帧。16ms 间隔只是目标调度周期没有对实际帧率做测量若需要可证明的稳定帧率应记录每帧墙钟时间并统计 p50/p95而不是由 interval 推导。动画落定后的游戏页棋盘、分数与按钮恢复稳定布局滑行阶段按进度在起点/终点之间插值缓动用ease_out_cubic起步快、收尾稳progease_out_cubic(min(1.0,self.anim[t]/MOVE_DURATION))forvalue,sx,sy,tx,tyinself.anim[moving]:xsx(tx-sx)*prog ysy(ty-sy)*prog self._draw_tile(p,value,x,y,1.0)pop 阶段只对两类格子做缩放合并处做一次回弹新生格从 0 放大if(r,c)inpop_cells:scale1.00.16*math.sin(math.pi*prog)# 合并回弹elifspawnisnotNoneand(r,c)spawn:scaleease_out_back(prog)# 新生放大出现_draw_tile以格子中心为锚点缩放字号也随scale一起放大因此方块呈现从中心弹出的效果def_draw_tile(self,p,value,x,y,scale):sizeTILE_SIZE*scale cx,cyxTILE_SIZE/2.0,yTILE_SIZE/2.0rectQRectF(cx-size/2.0,cy-size/2.0,size,size)p.setPen(Qt.NoPen)p.setBrush(self._tile_color(value))p.drawRoundedRect(rect,TILE_RADIUS*scale,TILE_RADIUS*scale)# ... 居中绘制数字字号 基准字号 * scale ...在非动画状态下新游戏 / 继续上一局render(grid)直接绘制当前网格即可。移动动画有明确的时间顺序先在move阶段按起点和终点插值滑动完成时触发合并碎片随后在pop阶段完成合并回弹和新方块出现最后更新目标棋盘并发出animationFinished信号。撤销动画只执行反向滑动不应被解释为完整重播“生成随机方块”的过程。阶段可见变化数据职责结束条件滑动move旧方块沿轨迹移动使用移动前的过渡信息达到MOVE_DURATION弹出pop合并格回弹、新块出现仍由 View 负责过渡显示达到POP_DURATION完成渲染目标棋盘发出完成信号Controller 做收尾animationFinished以上是现有代码的阶段划分不代表实际帧率经过测量。3.3 时间轴、对象生命周期与取消一次动画的全部状态集中在一个字典self.anim中字段含义phasemove滑行或pop回弹/新生modeforward正常移动或reverse撤销回放t当前阶段已累计的秒数moving(值, 起点x, 起点y, 终点x, 终点y)列表pop/spawn/spawn_value合并回弹的格子、新块坐标与数值target动画结束后要落定的网格生命周期只有一条主线animate_move/animate_undo填充self.anim启动唯一的QTimer_tick累加tmove阶段到达MOVE_DURATION0.12s后切到poppop到达POP_DURATION0.14s后结束_finish_animation把grid落定为target、清空anim、停止定时器、update()并发出animationFinishedreverse模式只有move阶段滑回起点后直接结束不进入pop。取消与对象管理没有额外的“动画对象数组”每个BoardView只有一个QTimer父对象即BoardView没有为 16 个方块各建QPropertyAnimation因此不存在动画对象泄漏动画进行中Controller 的on_move直接返回撤销/重做入口也检查is_animating()这不是“中途取消”而是“拒绝新请求”关闭窗口时BoardView随父窗口销毁定时器作为子对象一并回收正常结束路径由_finish_animation主动stop()若主线程卡顿导致丢 tickt的累加会落后于墙钟时间动画表现为变慢而不是跳帧——这也是 3.2 节不把 16ms 当作实测帧率的原因。4. Controller输入节流与收尾动画期间应禁止玩家继续操作否则动画尚未结束就再次移动会导致状态错乱。因此on_move中设有两道检查defon_move(self,direction):ifself.model.game_overorself.board_view.is_animating():return# 动画进行中忽略输入ifnotself.model.apply_move(direction):return# 非法移动撞墙不产生动画self._render_score(self.score_label,分数,self.model.score)self._render_score(self.best_label,最高分,self.model.best_score)self._sync_history_buttons()# 历史已变化立即更新按钮外观无动画耦合self.board_view.animate_move(self.model.grid,self.model.last_transitions,self.model.last_spawn)撤销和重做按钮不会因动画开始而被禁用其启用状态仅跟随历史栈_sync_history_buttons()即setEnabled(model.can_undo())/setEnabled(model.can_redo())。动画期间的误操作由上述入口检查拦截因此既保护了历史状态又不会在连续快速滑动时出现按钮反复变化的闪烁。动画结束用信号通知 Controller再统一刷新按钮、弹胜负框self.board_view.animationFinished.connect(self._on_animation_finished)def_on_animation_finished(self):self.refresh()ifself.model.game_over:self.show_game_over()elifself.model.wonandnotself.win_announced:self.win_announcedTrueself._celebrate()# 首次达成 2048金色庆祝粒子self.show_game_won()粒子反馈Particle/ParticlePreset/ParticleEngine 窗口级ParticleOverlay覆盖层鼠标穿透。合并落定时在目标格按“接触区 / 方块内部 / 四周边缘”三区域随机取样释放碎片悬停启动页按钮时持续发射首次达成 2048 时触发一次金色庆祝。它只做视觉反馈与棋盘动画和游戏规则互不影响。合并落定瞬间的粒子扩散撤销和重做同样带动画。关键在于让 Model 在压栈时同时保存这一步的过渡信息撤回时反向回放、重做时正向回放# Model一个回合压栈时连过渡一起存self.undo_stack.append({snapshot:snapshot,transitions:transitions,spawn:spawn})# Model撤回时把该步过渡原样返回给 Controller重做同理defundo(self):ifnotself.undo_stack:returnNoneentryself.undo_stack.pop()self.redo_stack.append({snapshot:self._snapshot(),transitions:entry[transitions],spawn:entry[spawn]})self._restore(entry[snapshot])self.save_game_state();self.update_index()return{kind:undo,transitions:entry[transitions],spawn:entry[spawn]}# Controller撤回用 animate_undo反向滑行重做用 animate_move正向滑行defon_undo(self):ifself.board_view.is_animating():returninfoself.model.undo()ifnotinfo:returnself.board_view.animate_undo(self.model.grid,info[transitions],info[spawn])View 侧仅将插值方向反转animate_move从src滑到dstanimate_undo从dst滑回src同时让新生方块由 1 缩到 0 消失ifself.anim[mode]reverse:xtx(sx-tx)*prog# 从落点滑回起点yty(sy-ty)*progelse:xsx(tx-sx)*prog ysy(ty-sy)*prog5. 性能要点背景层缓存静态背景只在构造时绘制一次每帧只做一次drawPixmap的位图拷贝省去 16 个圆角矩形 抗锯齿的重复开销。只重绘棋盘控件动画只触发BoardView.update()分数卡与按钮不参与重绘窗口也没有整窗重排。这里的粒度是“整个棋盘控件”不是“逐瓦片脏矩形”。按目标 tick 推进QTimer间隔 16ms名义 62.5Hz进度用t interval累加动画时长仅 0.12s / 0.14s对操作手感影响很小。该写法在丢 tick 时会产生时间漂移且当前没有实测帧率数据。两块合并时一同滑动落定后一次给出结果避免中间态闪烁。沿用第 3 篇的高 DPI 适配AA_EnableHighDpiScaling/AA_UseHighDpiPixmaps与配色常量两版使用同一套颜色与尺寸逐像素一致性未做自动比对。更激进的做法是只重绘脏矩形移动方块覆盖到的区域或使用QGraphicsView/ Qt Quick 交由 GPU 处理。当前实现并未做逐瓦片脏矩形裁剪4×4 仅有 16 格整块棋盘全量重绘已足够因此这里优先保证简单可读。6. 运行与操作pipinstallPyQt5 python-mv4_pyqt_2048_animated.v4_pyqt_2048_animated方向键 /WASD移动方块带平滑动画CtrlZ/CtrlY撤销和重做带反向 / 正向滑行动画底部按钮撤销、重做、新游戏启动页继续上一局 / 开始新游戏Tab切换焦点回车/空格触发。动画版启动页继续上一局 / 开始新游戏动画版复用第 3 篇的GameCore但使用自己的v4_pyqt_2048_animated/game_history/存档目录不与第 3 篇的对局混在一起。7. 小结这一篇没有改动游戏规则只实现了可观测的流畅度父类不改子类补充过渡信息GameCore原样复用AnimatedGameCore覆盖_slide/add_new_tile只记录“每个方块的起点与终点”。MVC 分层使“更换渲染方式”的成本极低。分层渲染静态背景层缓存成QPixmap动画层用QTimer逐帧插值滑行 / 合并回弹 / 新生放大衔接连贯撤回 / 重做对同一套过渡做“反向 / 正向回放”同样平滑。性能取舍背景烘焙 整块棋盘重绘 16ms 目标 tick。在 4×4 的规模下以最简单的方式获得足够流畅的效果实际帧率未测量丢 tick 时存在时间漂移。这套“静态背景 动画层 整块棋盘重绘”适合元素少、需要在既定节奏下精确控制绘制内容的场景也能迁移到类似的小型网格应用元素成百上千或需要复杂层级与命中测试时应该交给QGraphicsView / QGraphicsScene。上一篇第 3 篇 · 使用 PyQt5 实现 2048 桌面版游戏MVC 架构与撤销和重做下一篇第 5 篇上· 使用 PyQt5 实现 2048 算法求解版五类搜索算法内核
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑