ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

第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(子类)只额外记录每块的"起点 → 终点"与新生方块位置
ViewBoardView(QWidget+QPainter自绘)从 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: [(源下标, 目标下标, 该块当前显示的数值, 是否合并)] """size=len(line)items=[(v,p)forp,vinenumerate(line)ifv!=0]# (数值, 源下标)result=[]# (最终值, [源下标...], 是否合并, 滑动时显示的数值)i=0whilei<len(items):ifi+1<len(items)anditems[i][0]==items[i+1][0]:v=items[i][0]self.score+=v*2result.append((v*2,[items[i][1],items[i+1][1]],True,v))i+=2else:result.append((items[i][0],[items[i][1]],False,items[i][0]))i+=1new_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):size=self.grid_size self.last_transitions=[]moved=Falseifdirectionin('up','down'):self.grid=[list(row)forrowinzip(*self.grid)]foriinrange(size):original=self.grid[i]row=original[::-1]ifdirectionin('right','down')elseoriginal new_row,ops=self._merge_line_tracked(row)ifdirectionin('right','down'):new_row=new_row[::-1]ifnew_row!=original:# 与原行(而非翻转后的行)比较moved=Trueself.grid[i]=new_rowforsrc_p,dst_p,value,mergedinops:# 行内下标 -> 棋盘坐标(注意翻转与转置)ifdirectionin('right','down'):src_p,dst_p=size-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_spawn=NonereturnFalsei,j=random.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 个空格)恒定不变,无需每帧重绘并重复圆角抗锯齿计算。构造时"烘焙"为一张QPixmap:

def_build_background(self):pm=QPixmap(BOARD_SIDE,BOARD_SIDE)pm.fill(Qt.transparent)p=QPainter(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()returnpm

paintEvent中一句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":# 回退只有“滑回”一个阶段(见 §4)self._finish_animation()returnself.anim["phase"]="pop"self.anim["t"]=0.0self._emit_merge_fragments()# 合并落定:与弹性动画同步释放碎片else:ifself.anim["t"]>=POP_DURATION:self._finish_animation()returnself.update()def_finish_animation(self):self.grid=self.anim["target"]self.anim=Noneself.timer.stop()self.update()self.animationFinished.emit()

时间口径的限制:_tick用t += timer.interval() / 1000.0累加,而不是读取perf_counter()的墙钟时间。它能保证在定时器按时触发时动画时长稳定,但主线程一旦被阻塞,定时器会丢 tick,累加值会比真实时间慢,表现为动画“变慢”或跳帧。16ms 间隔只是目标调度周期,没有对实际帧率做测量;若需要可证明的稳定帧率,应记录每帧墙钟时间并统计 p50/p95,而不是由 interval 推导。

动画落定后的游戏页:棋盘、分数与按钮恢复稳定布局

滑行阶段按进度在起点/终点之间插值,缓动用ease_out_cubic(起步快、收尾稳):

prog=ease_out_cubic(min(1.0,self.anim["t"]/MOVE_DURATION))forvalue,sx,sy,tx,tyinself.anim["moving"]:x=sx+(tx-sx)*prog y=sy+(ty-sy)*prog self._draw_tile(p,value,x,y,1.0)

pop 阶段只对两类格子做缩放:合并处做一次回弹,新生格从 0 放大:

if(r,c)inpop_cells:scale=1.0+0.16*math.sin(math.pi*prog)# 合并回弹elifspawnisnotNoneand(r,c)==spawn:scale=ease_out_back(prog)# 新生放大出现

_draw_tile以格子中心为锚点缩放,字号也随scale一起放大,因此方块呈现"从中心弹出"的效果:

def_draw_tile(self,p,value,x,y,scale):size=TILE_SIZE*scale cx,cy=x+TILE_SIZE/2.0,y+TILE_SIZE/2.0rect=QRectF(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动画结束后要落定的网格

生命周期只有一条主线:

  1. animate_move/animate_undo填充self.anim,启动唯一的QTimer;
  2. _tick累加t,move阶段到达MOVE_DURATION=0.12s后切到pop,pop到达POP_DURATION=0.14s后结束;
  3. _finish_animation把grid落定为target、清空anim、停止定时器、update()并发出animationFinished;
  4. reverse模式只有move阶段:滑回起点后直接结束,不进入pop。

取消与对象管理没有额外的“动画对象数组”:

  • 每个BoardView只有一个QTimer(父对象即BoardView),没有为 16 个方块各建QPropertyAnimation,因此不存在动画对象泄漏;
  • 动画进行中,Controller 的on_move直接返回,撤销/重做入口也检查is_animating();这不是“中途取消”,而是“拒绝新请求”;
  • 关闭窗口时BoardView随父窗口销毁,定时器作为子对象一并回收;正常结束路径由_finish_animation主动stop();
  • 若主线程卡顿导致丢 tick,t的累加会落后于墙钟时间,动画表现为变慢而不是跳帧——这也是 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_announced=Trueself._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:returnNoneentry=self.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():returninfo=self.model.undo()ifnotinfo:returnself.board_view.animate_undo(self.model.grid,info["transitions"],info["spawn"])

View 侧仅将插值方向反转:animate_move从src滑到dst,animate_undo从dst滑回src,同时让新生方块由 1 缩到 0 消失:

ifself.anim["mode"]=="reverse":x=tx+(sx-tx)*prog# 从落点滑回起点y=ty+(sy-ty)*progelse:x=sx+(tx-sx)*prog y=sy+(ty-sy)*prog

5. 性能要点

  1. 背景层缓存:静态背景只在构造时绘制一次,每帧只做一次drawPixmap的位图拷贝,省去 16 个圆角矩形 + 抗锯齿的重复开销。
  2. 只重绘棋盘控件:动画只触发BoardView.update(),分数卡与按钮不参与重绘,窗口也没有整窗重排。这里的粒度是“整个棋盘控件”,不是“逐瓦片脏矩形”。
  3. 按目标 tick 推进:QTimer间隔 16ms(名义 62.5Hz),进度用t += interval累加;动画时长仅 0.12s / 0.14s,对操作手感影响很小。该写法在丢 tick 时会产生时间漂移,且当前没有实测帧率数据。
  4. 两块合并时一同滑动,落定后一次给出结果,避免中间态闪烁。
  5. 沿用第 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:移动方块(带平滑动画);
  • Ctrl+Z/Ctrl+Y:撤销和重做(带反向 / 正向滑行动画);
  • 底部按钮:撤销、重做、新游戏;
  • 启动页:继续上一局 / 开始新游戏(Tab切换焦点,回车/空格触发)。

动画版启动页:继续上一局 / 开始新游戏

动画版复用第 3 篇的GameCore,但使用自己的v4_pyqt_2048_animated/game_history/存档目录,不与第 3 篇的对局混在一起。


7. 小结

这一篇没有改动游戏规则,只实现了"可观测的流畅度":

  1. 父类不改,子类补充过渡信息:GameCore原样复用,AnimatedGameCore覆盖_slide/add_new_tile,只记录“每个方块的起点与终点”。MVC 分层使“更换渲染方式”的成本极低。
  2. 分层渲染:静态背景层缓存成QPixmap,动画层用QTimer逐帧插值,滑行 / 合并回弹 / 新生放大衔接连贯;撤回 / 重做对同一套过渡做“反向 / 正向回放”,同样平滑。
  3. 性能取舍:背景烘焙 + 整块棋盘重绘 + 16ms 目标 tick。在 4×4 的规模下,以最简单的方式获得足够流畅的效果;实际帧率未测量,丢 tick 时存在时间漂移。

这套“静态背景 + 动画层 + 整块棋盘重绘”适合元素少、需要在既定节奏下精确控制绘制内容的场景,也能迁移到类似的小型网格应用;元素成百上千或需要复杂层级与命中测试时,应该交给QGraphicsView / QGraphicsScene。


上一篇:第 3 篇 · 使用 PyQt5 实现 2048 桌面版游戏:MVC 架构与撤销和重做

下一篇:第 5 篇(上)· 使用 PyQt5 实现 2048 算法求解版:五类搜索算法内核

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进