在上一篇博文《方格中的混沌与秩序》中,我们构建了一个功能完备的 2048 逻辑内核。如果你运行过那个代码,你会发现当我们按下方向键时,方块会瞬间出现在新位置。
从计算机科学的角度看,这是完全正确的:状态 $S_t$ 在一个时间步长内变成了 $S_{t+1}$。但在人类的视觉感知中,物体从 A 点到 B 点必须经过一条连续的路径。
为了弥补这一感知裂痕,我们需要在离散的逻辑帧之间,插入连续的渲染帧。这就是动画 的本质。
第一部分:数学基础——线性插值 (Lerp) 要让一个方块在时间 $T$ 内从位置 $P_{start}$ 平滑移动到 $P_{end}$,我们需要知道在任意时间点 $t$ ($0 \le t \le T$),方块应该在哪里。
为了简化计算,我们将时间归一化为 $[0, 1]$ 的区间。$t=0$ 代表动画开始,$t=1$ 代表动画结束。
在二维平面上,任意时刻的位置 $P(t)$ 可以通过**线性插值(Linear Interpolation,简称 Lerp)**公式计算得出:
$$ P(t) = P_{start} + t \cdot (P_{end} - P_{start}) $$
或者写作更直观的加权形式:
$$ P(t) = (1 - t) \cdot P_{start} + t \cdot P_{end} $$
当 $t=0$ 时,$P(0) = P_{start}$
当 $t=1$ 时,$P(1) = P_{end}$
当 $t=0.5$ 时,$P(0.5)$ 恰好位于两者中点。
这个简单的公式是我们实现所有平滑移动的基础。
第二部分:架构挑战与重构 现在我们面临一个严峻的工程挑战。
在上一版的代码中,我们的棋盘 self.grid 只是一个简单的二维整数数组 [[0, 2, 0, 0], ...] 和。当执行一次左移操作后,数字 2 从位置 (0, 1) 变成了位置 (0, 0)。
问题在于: 新的网格只告诉了我们“现在这里有个 2”,它丢失了“这个 2 是从哪里来的”这一关键信息。没有起点,我们就无法使用 Lerp 公式。
为了实现动画,我们需要知道每个方块的前世今生 。
2.1 引入 Tile 对象模型 我们必须放弃简单的整数网格,转而使用对象。每个方块不再是一个冷冰冰的数字,而是一个拥有状态的 Tile 对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Tile : def __init__ (self, value, row, col ): self .value = value self .row = row self .col = col self .old_row = row self .old_col = col def move_to (self, new_row, new_col ): """更新逻辑位置前,先记录旧位置""" self .old_row = self .row self .old_col = self .col self .row = new_row self .col = new_col def reset_position (self ): """动画结束后,起点与终点重合""" self .old_row = self .row self .old_col = self .col
现在,我们的棋盘将存储 Tile 对象的引用,空白处为 None。
2.2 重构逻辑引擎 (Logic Engine) 这是一个巨大的破坏性重构。之前的矩阵变换方法(转置、翻转)虽然优雅,但在处理对象引用和追踪位置时会变得异常复杂。为了追踪每个 Tile 的移动,回归到传统的基于行列遍历的方法反而更加清晰和易于管理。
这是一种工程上的权衡(Trade-off):为了获得更好的交互体验,我们牺牲了一部分代码的数学简洁性。
(篇幅有限,以下仅展示核心的左移逻辑重构,其他方向逻辑类似)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import randomclass LogicEngineAdvanced : def __init__ (self ): self .grid = [[None ] * 4 for _ in range (4 )] self .score = 0 self .add_new_tile() self .add_new_tile() self .moved_tiles = [] def add_new_tile (self ): empty_cells = [(r, c) for r in range (4 ) for c in range (4 ) if self .grid[r][c] is None ] if not empty_cells: return r, c = random.choice(empty_cells) val = 2 if random.random() < 0.9 else 4 self .grid[r][c] = Tile(val, r, c) def reset_tile_positions (self ): """每轮动画开始前,同步所有 Tile 的起点""" self .moved_tiles.clear() for r in range (4 ): for c in range (4 ): if self .grid[r][c]: self .grid[r][c].reset_position() def move_left (self ): self .reset_tile_positions() moved = False for r in range (4 ): tiles = [self .grid[r][c] for c in range (4 ) if self .grid[r][c] is not None ] new_row = [] skip = False for i in range (len (tiles)): if skip: skip = False continue curr_tile = tiles[i] if i + 1 < len (tiles) and curr_tile.value == tiles[i + 1 ].value: next_tile = tiles[i + 1 ] merged_value = curr_tile.value * 2 self .score += merged_value new_tile = Tile(merged_value, r, len (new_row)) new_tile.merged_from = (curr_tile, next_tile) curr_tile.move_to(r, len (new_row)) next_tile.move_to(r, len (new_row)) new_row.append(new_tile) self .moved_tiles.extend([curr_tile, next_tile]) skip = True moved = True else : curr_tile.move_to(r, len (new_row)) new_row.append(curr_tile) if curr_tile.old_col != curr_tile.col: self .moved_tiles.append(curr_tile) moved = True for c in range (4 ): self .grid[r][c] = new_row[c] if c < len (new_row) else None return moved
第三部分:实现动画渲染循环 逻辑引擎现在准备好了数据:每个 Tile 都知道自己上一帧在哪 (old_row, old_col),以及现在应该在哪 (row, col)。
我们需要修改 UI 类,引入一个“动画状态”。
3.1 线性插值辅助函数 1 2 3 def lerp (start, end, t ): """线性插值计算""" return start + t * (end - start)
3.2 重构 GameUI 我们需要定义动画的持续时间,并在渲染循环中计算当前的进度 $t$。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 import pygameimport sysimport time ANIMATION_DURATION = 0.15 class GameUIAdvanced : def __init__ (self ): self .engine = LogicEngineAdvanced() self .is_animating = False self .anim_start_time = 0 def trigger_move (self, direction ): """触发移动并开始动画""" if self .is_animating: return moved = False if direction == 'Left' : moved = self .engine.move_left() if moved: self .engine.add_new_tile() self .is_animating = True self .anim_start_time = time.time() def draw_tile (self, tile, r, c, cell_size, padding, start_y ): """辅助函数:在指定行列绘制一个 Tile""" rect_x = padding + c * (cell_size + padding) rect_y = start_y + r * (cell_size + padding) color = COLORS.get(tile.value, (60 , 58 , 50 )) pygame.draw.rect(self .screen, color, (rect_x, rect_y, cell_size, cell_size), border_radius=5 ) def draw (self ): self .screen.fill(BG_COLOR) current_time = time.time() t = 0 if self .is_animating: t = (current_time - self .anim_start_time) / ANIMATION_DURATION if t >= 1.0 : t = 1.0 self .is_animating = False cell_size = 80 padding = 10 start_y = 100 for r in range (4 ): for c in range (4 ): tile = self .engine.grid[r][c] if tile is None : continue if self .is_animating: render_r = lerp(tile.old_row, tile.row, t) render_c = lerp(tile.old_col, tile.col, t) else : render_r, render_c = tile.row, tile.col self .draw_tile(tile, render_r, render_c, cell_size, padding, start_y) def run (self ): while True : self .draw() pygame.display.update() self .clock.tick(60 )
3.3 关键点解析 在 draw 方法中,我们不再直接使用 tile.row 和 tile.col 进行绘制。而是检查当前是否处于动画状态。如果是,我们利用 lerp 函数,根据当前时间进度 $t$,计算出 Tile 在起点 (old_row, old_col) 和终点 (row, col) 之间的中间位置。
当 $t$ 从 0 增加到 1 时,render_r 和 render_c 就会平滑地从起点过渡到终点,从而在屏幕上呈现出滑动的效果。
总结 为了实现平滑动画,我们付出了不小的代价:我们将优雅简洁的矩阵操作代码,重构成了相对复杂的对象状态管理代码。
这在软件工程中是非常典型的体现:需求的变化往往导致架构的变迁 。纯粹的数据变换(上一篇)和富交互的视觉呈现(这一篇)对数据结构的要求是截然不同的。
通过引入 Tile 对象模型和线性插值算法,我们成功连接了离散的逻辑世界和连续的视觉世界,让 2048 的体验上了一个新的台阶。
进阶思考: 当前的实现中,合并的方块是瞬间变化的。如何利用 new_tile.merged_from 属性,实现两个旧方块移动到一起,然后新方块“弹出来”(Scale Animation)的效果?这需要更复杂的动画状态管理。