第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"。
  • 正确做法是在合并发生的那一刻记录过渡信息。

于是分层这样安排:

层 类 变化
Model GameCore(第 3 篇,一行不改 ) + AnimatedGameCore(子类) 只额外记录每块的"起点 → 终点"与新生方块位置
View BoardView(QWidget + QPainter 自绘) 从 16 个 QLabel 换成"静态背景层(缓存)+ 动画层(逐帧插值)"
Controller MainWindow 动画进行中忽略输入;动画结束后再做胜负提示

Model 直接复用第 3 篇:from v3_pyqt_2048_with_history import GameCore。也就是说,这一篇没有改动任何一条合并规则、存档或撤销重做的逻辑,再次印证了 MVC 分层的价值。


2. Model:只补"过渡信息"

AnimatedGameCore 继承 GameCore,把两件事记下来:last_transitions(每块的去向)和 last_spawn(新方块坐标)。

2.1 逐行合并时输出"去向"

python 复制代码
def _merge_line_tracked(self, line):
    """
    返回 (新行, ops)。
    ops: [(源下标, 目标下标, 该块当前显示的数值, 是否合并)]
    """
    size = len(line)
    items = [(v, p) for p, v in enumerate(line) if v != 0]   # (数值, 源下标)

    result = []          # (最终值, [源下标...], 是否合并, 滑动时显示的数值)
    i = 0
    while i < len(items):
        if i + 1 < len(items) and items[i][0] == items[i + 1][0]:
            v = items[i][0]
            self.score += v * 2
            result.append((v * 2, [items[i][1], items[i + 1][1]], True, v))
            i += 2
        else:
            result.append((items[i][0], [items[i][1]], False, items[i][0]))
            i += 1

    new_line = [r[0] for r in result] + [0] * (size - len(result))
    ops = []
    for dst_p, (_final, srcs, merged, tile_v) in enumerate(result):
        for sp in srcs:
            ops.append((sp, dst_p, tile_v, merged))
    return new_line, ops

关键细节:合并时两块携带各自的原值(例如两个 2)一起滑向同一格,落定后才显示合并结果 4。若把两块都当作 4 来绘制,滑行过程中就会出现"凭空多出一个 4"的错觉。

2.2 覆盖 _slide:行内下标 → 棋盘坐标

_slide 仍然沿用第 3 篇那套"上下先转置、左右按需翻转"的方向抽象,只是额外记录了过渡信息:

python 复制代码
def _slide(self, direction):
    size = self.grid_size
    self.last_transitions = []
    moved = False

    if direction in ('up', 'down'):
        self.grid = [list(row) for row in zip(*self.grid)]

    for i in range(size):
        original = self.grid[i]
        row = original[::-1] if direction in ('right', 'down') else original
        new_row, ops = self._merge_line_tracked(row)
        if direction in ('right', 'down'):
            new_row = new_row[::-1]
        if new_row != original:          # 与原行(而非翻转后的行)比较
            moved = True
        self.grid[i] = new_row

        for src_p, dst_p, value, merged in ops:
            # 行内下标 -> 棋盘坐标(注意翻转与转置)
            if direction in ('right', 'down'):
                src_p, dst_p = size - 1 - src_p, size - 1 - dst_p
            if direction in ('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))

    if direction in ('up', 'down'):
        self.grid = [list(row) for row in zip(*self.grid)]
    return moved

再记录新生方块的位置,动画即可确定"哪个格子需要放大出现":

python 复制代码
def add_new_tile(self):
    empty = [(i, j) for i in range(self.grid_size)
             for j in range(self.grid_size) if self.grid[i][j] == 0]
    if not empty:
        self.last_spawn = None
        return False
    i, j = random.choice(empty)
    self.grid[i][j] = 2 if random.random() < 0.9 else 4
    self.last_spawn = (i, j)
    return True

已遇到的问题 :moved 必须与翻转前的原行比较。早期版本与翻转后的 row 比较,导致"向右/向下"方向在实际上未移动时也返回 True,按下一次方向键就会多出一个新方块。后来用随机棋盘将 AnimatedGameCore._slide 与第 3 篇的 GameCore._slide 逐一对比,才定位到该偏差。


3. View:分层渲染 + 逐帧插值

BoardView 不再使用 QLabel,而是继承 QWidget 自行使用 QPainter 绘制。帧结构很简单:先绘制背景,再绘制方块。

3.1 静态背景层:只画一次,缓存成 pixmap

背景(棋盘底板 + 16 个空格)恒定不变,无需每帧重绘并重复圆角抗锯齿计算。构造时"烘焙"为一张 QPixmap:

python 复制代码
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))
    for i in range(self.grid_size):
        for j in range(self.grid_size):
            p.drawRoundedRect(self._cell_rect(i, j), TILE_RADIUS, TILE_RADIUS)
    p.end()
    return pm

paintEvent 中一句 p.drawPixmap(0, 0, self._background) 即可绘制整块底板,其余按过渡信息绘制全部方块(含原地不动的方块)。

动画渲染分层

text 复制代码
静态背景 QPixmap:棋盘底板 + 16 个空格
           ↓ 每次 paintEvent 复制背景
动态方块 QPainter:普通方块 + 正在插值的方块
           ↓ 合并落定后触发
粒子覆盖层 ParticleOverlay:合并碎片 / 交互反馈

缓存背景减少了重复绘制底板的成本,但 BoardView.update() 仍会请求重绘整个棋盘控件,不能将其称为"逐瓦片脏矩形优化"。粒子反馈是另一层视觉效果,不能与搜索计算耗时混作同一种性能指标。

3.2 动画层:滑行 → 合并/新生

一次移动分两个阶段,用一个 16ms 目标 tick 的 QTimer 推进(名义 62.5Hz;这是调度间隔,不是实测帧率):

  • move:所有方块从起点插值滑到终点;
  • pop:落定后,合并的格子做一次回弹,新生格子放大出现。
python 复制代码
def _tick(self):
    if self.anim is None:
        self.timer.stop()
        return
    self.anim["t"] += self.timer.interval() / 1000.0

    if self.anim["phase"] == "move":
        if self.anim["t"] >= MOVE_DURATION:
            if self.anim["mode"] == "reverse":   # 回退只有"滑回"一个阶段(见 §4)
                self._finish_animation()
                return
            self.anim["phase"] = "pop"
            self.anim["t"] = 0.0
            self._emit_merge_fragments()     # 合并落定:与弹性动画同步释放碎片
    else:
        if self.anim["t"] >= POP_DURATION:
            self._finish_animation()
            return
    self.update()

def _finish_animation(self):
    self.grid = self.anim["target"]
    self.anim = None
    self.timer.stop()
    self.update()
    self.animationFinished.emit()

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

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

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

python 复制代码
prog = ease_out_cubic(min(1.0, self.anim["t"] / MOVE_DURATION))
for value, sx, sy, tx, ty in self.anim["moving"]:
    x = sx + (tx - sx) * prog
    y = sy + (ty - sy) * prog
    self._draw_tile(p, value, x, y, 1.0)

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

python 复制代码
if (r, c) in pop_cells:
    scale = 1.0 + 0.16 * math.sin(math.pi * prog)   # 合并回弹
elif spawn is not None and (r, c) == spawn:
    scale = ease_out_back(prog)                     # 新生放大出现

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

python 复制代码
def _draw_tile(self, p, value, x, y, scale):
    size = TILE_SIZE * scale
    cx, cy = x + TILE_SIZE / 2.0, y + TILE_SIZE / 2.0
    rect = 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 中:

字段 含义
phase move(滑行)或 pop(回弹/新生)
mode forward(正常移动)或 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 中设有两道检查:

python 复制代码
def on_move(self, direction):
    if self.model.game_over or self.board_view.is_animating():
        return                       # 动画进行中忽略输入
    if not self.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,再统一刷新按钮、弹胜负框:

python 复制代码
self.board_view.animationFinished.connect(self._on_animation_finished)

def _on_animation_finished(self):
    self.refresh()
    if self.model.game_over:
        self.show_game_over()
    elif self.model.won and not self.win_announced:
        self.win_announced = True
        self._celebrate()            # 首次达成 2048:金色庆祝粒子
        self.show_game_won()

粒子反馈 :Particle / ParticlePreset / ParticleEngine + 窗口级 ParticleOverlay 覆盖层(鼠标穿透)。合并落定时在目标格按"接触区 / 方块内部 / 四周边缘"三区域随机取样释放碎片;悬停启动页按钮时持续发射;首次达成 2048 时触发一次金色庆祝。它只做视觉反馈,与棋盘动画和游戏规则互不影响。

合并落定瞬间的粒子扩散

撤销和重做同样带动画。关键在于:让 Model 在压栈时同时保存这一步的过渡信息,撤回时反向回放、重做时正向回放:

python 复制代码
# Model:一个回合压栈时,连过渡一起存
self.undo_stack.append({"snapshot": snapshot,
                        "transitions": transitions, "spawn": spawn})

# Model:撤回时把该步过渡原样返回给 Controller(重做同理)
def undo(self):
    if not self.undo_stack:
        return None
    entry = 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"]}
python 复制代码
# Controller:撤回用 animate_undo(反向滑行),重做用 animate_move(正向滑行)
def on_undo(self):
    if self.board_view.is_animating():
        return
    info = self.model.undo()
    if not info:
        return
    self.board_view.animate_undo(self.model.grid, info["transitions"], info["spawn"])

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

python 复制代码
if self.anim["mode"] == "reverse":
    x = tx + (sx - tx) * prog     # 从落点滑回起点
    y = ty + (sy - ty) * prog
else:
    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. 运行与操作

bash 复制代码
pip install PyQt5
python -m v4_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 算法求解版:五类搜索算法内核,换一条线让 2048 会算:可解释的评估函数与启发式 / 贪心模拟 / Expectimax / MCTS / 强化学习五类策略,并用确定性测试证明它们真的对。

相关推荐
海姐软件测试1 小时前
Python 自动化与 AI 结合:基于 Jenkins 的回归验证实践
人工智能·python·自动化
Haoyunyi1 小时前
2026年做期货量化,和三年前已经不是一回事了
大数据·人工智能·python·金融·区块链
余槐i2 小时前
yt-dlp 线程开到 16 反而更慢:1000 个视频的实测与限速原因
性能优化·ffmpeg·自动化·开发工具·yt-dlp
再__努力1点2 小时前
一个开源的AI边缘计算平台:一个项目把算能、瑞芯微、x86的NPU全打通了
人工智能·python·课程设计·媒体·流媒体
小oo呆2 小时前
【学习心得】Python函数参数中的两个特殊分隔符
开发语言·python
SEO_juper2 小时前
AI 爬虫 robots.txt 检查工具:你的站是不是把 AI 挡在门外了
开发语言·人工智能·爬虫·python·外贸独立站
看我debug2 小时前
Android 运行时分析工作台 (Demo)。统一管理 Frida 脚本、自动注入(修改运行时代码)、收集结构化结果、生成 JSON 报告。
android·python·pip
染指11102 小时前
151.Agent-LangGraph-Send发送(Map-Reduce模式)
人工智能·python·langchain·状态模式·agents
西红柿炖牛腩3542 小时前
3D 模型压缩脚本又报错又配环境?换个不用维护的方案
javascript·3d·性能优化