在上一篇博文《方格中的混沌与秩序》中,我们构建了一个功能完备的 2048 逻辑内核。如果你运行过那个代码,你会发现当我们按下方向键时,方块会瞬间出现在新位置。
从计算机科学的角度看,这是完全正确的:状态 StS_tSt 在一个时间步长内变成了 St+1S_{t+1}St+1。但在人类的视觉感知中,物体从 A 点到 B 点必须经过一条连续的路径。
为了弥补这一感知裂痕,我们需要在离散的逻辑帧之间,插入连续的渲染帧。这就是动画 的本质。

第一部分:数学基础------线性插值 (Lerp)
要让一个方块在时间 TTT 内从位置 PstartP_{start}Pstart 平滑移动到 PendP_{end}Pend,我们需要知道在任意时间点 ttt (0≤t≤T0 \le t \le T0≤t≤T),方块应该在哪里。
为了简化计算,我们将时间归一化为 [0,1][0, 1][0,1] 的区间。t=0t=0t=0 代表动画开始,t=1t=1t=1 代表动画结束。
在二维平面上,任意时刻的位置 P(t)P(t)P(t) 可以通过**线性插值(Linear Interpolation,简称 Lerp)**公式计算得出:
P(t)=Pstart+t⋅(Pend−Pstart) P(t) = P_{start} + t \cdot (P_{end} - P_{start}) P(t)=Pstart+t⋅(Pend−Pstart)
或者写作更直观的加权形式:
P(t)=(1−t)⋅Pstart+t⋅Pend P(t) = (1 - t) \cdot P_{start} + t \cdot P_{end} P(t)=(1−t)⋅Pstart+t⋅Pend
- 当 t=0t=0t=0 时,P(0)=PstartP(0) = P_{start}P(0)=Pstart
- 当 t=1t=1t=1 时,P(1)=PendP(1) = P_{end}P(1)=Pend
- 当 t=0.5t=0.5t=0.5 时,P(0.5)P(0.5)P(0.5) 恰好位于两者中点。
这个简单的公式是我们实现所有平滑移动的基础。
第二部分:架构挑战与重构
现在我们面临一个严峻的工程挑战。
在上一版的代码中,我们的棋盘 self.grid 只是一个简单的二维整数数组 [[0, 2, 0, 0], ...] 和。当执行一次左移操作后,数字 2 从位置 (0, 1) 变成了位置 (0, 0)。
问题在于: 新的网格只告诉了我们"现在这里有个 2",它丢失了"这个 2 是从哪里来的"这一关键信息。没有起点,我们就无法使用 Lerp 公式。
为了实现动画,我们需要知道每个方块的前世今生。
2.1 引入 Tile 对象模型
我们必须放弃简单的整数网格,转而使用对象。每个方块不再是一个冷冰冰的数字,而是一个拥有状态的 Tile 对象。
python
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):为了获得更好的交互体验,我们牺牲了一部分代码的数学简洁性。
(篇幅有限,以下仅展示核心的左移逻辑重构,其他方向逻辑类似)
python
import random
# ... (引入上面的 Tile 类定义) ...
class LogicEngineAdvanced:
def __init__(self):
# grid 现在存储 Tile 对象或 None
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
# 创建新 Tile 对象
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):
# 1. 提取本行非空 Tile
tiles = [self.grid[r][c] for c in range(4) if self.grid[r][c] is not None]
new_row = []
skip = False
# 2. 执行合并逻辑
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
# 创建合并后的新 Tile
new_tile = Tile(merged_value, r, len(new_row))
# 关键:记录是由哪两个旧 Tile 合并而来,用于后续(可能的)合并动画
new_tile.merged_from = (curr_tile, next_tile)
# 更新旧 Tile 的目标位置,以便它们滑向合并点
curr_tile.move_to(r, len(new_row))
next_tile.move_to(r, len(new_row))
new_row.append(new_tile)
# 将这两个移动过的旧 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
# 3. 填充 None 并更新网格
for c in range(4):
self.grid[r][c] = new_row[c] if c < len(new_row) else None
return moved
# ... (省略 move_right, move_up, move_down 的类似实现) ...
# 这里的实现需要分别编写四个方向的逻辑,虽然繁琐,但能精确控制每个对象的移动
第三部分:实现动画渲染循环
逻辑引擎现在准备好了数据:每个 Tile 都知道自己上一帧在哪 (old_row, old_col),以及现在应该在哪 (row, col)。
我们需要修改 UI 类,引入一个"动画状态"。
3.1 线性插值辅助函数
python
def lerp(start, end, t):
"""线性插值计算"""
return start + t * (end - start)
3.2 重构 GameUI
我们需要定义动画的持续时间,并在渲染循环中计算当前的进度 ttt。
python
import pygame
import sys
import time
# ... (保留之前的颜色常量定义) ...
# 动画配置
ANIMATION_DURATION = 0.15 # 动画持续 150ms
class GameUIAdvanced:
def __init__(self):
# ... (初始化 Pygame, 字体等,同上一篇) ...
# 使用新的逻辑引擎
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()
# elif ... (其他方向)
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
# 计算动画进度 t [0.0, 1.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
# 绘制所有 Tile
for r in range(4):
for c in range(4):
tile = self.engine.grid[r][c]
if tile is None: continue
# 核心逻辑:根据进度 t 计算当前的渲染位置
# 如果在动画中,使用插值位置;否则使用目标位置
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.trigger_move) ...
self.draw()
pygame.display.update()
# 提高帧率以获得更平滑的动画
self.clock.tick(60)
3.3 关键点解析
在 draw 方法中,我们不再直接使用 tile.row 和 tile.col 进行绘制。而是检查当前是否处于动画状态。如果是,我们利用 lerp 函数,根据当前时间进度 ttt,计算出 Tile 在起点 (old_row, old_col) 和终点 (row, col) 之间的中间位置。
当 ttt 从 0 增加到 1 时,render_r 和 render_c 就会平滑地从起点过渡到终点,从而在屏幕上呈现出滑动的效果。
总结
为了实现平滑动画,我们付出了不小的代价:我们将优雅简洁的矩阵操作代码,重构成了相对复杂的对象状态管理代码。
这在软件工程中是非常典型的体现:需求的变化往往导致架构的变迁。纯粹的数据变换(上一篇)和富交互的视觉呈现(这一篇)对数据结构的要求是截然不同的。
通过引入 Tile 对象模型和线性插值算法,我们成功连接了离散的逻辑世界和连续的视觉世界,让 2048 的体验上了一个新的台阶。
进阶思考:
当前的实现中,合并的方块是瞬间变化的。如何利用 new_tile.merged_from 属性,实现两个旧方块移动到一起,然后新方块"弹出来"(Scale Animation)的效果?这需要更复杂的动画状态管理。