跨越离散时间:在 2048 游戏中实现基于线性插值(Lerp)的平滑动画

在上一篇博文《方格中的混沌与秩序》中,我们构建了一个功能完备的 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 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 线性插值辅助函数

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 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.rowtile.col 进行绘制。而是检查当前是否处于动画状态。如果是,我们利用 lerp 函数,根据当前时间进度 $t$,计算出 Tile 在起点 (old_row, old_col) 和终点 (row, col) 之间的中间位置。

当 $t$ 从 0 增加到 1 时,render_rrender_c 就会平滑地从起点过渡到终点,从而在屏幕上呈现出滑动的效果。

总结

为了实现平滑动画,我们付出了不小的代价:我们将优雅简洁的矩阵操作代码,重构成了相对复杂的对象状态管理代码。

这在软件工程中是非常典型的体现:需求的变化往往导致架构的变迁。纯粹的数据变换(上一篇)和富交互的视觉呈现(这一篇)对数据结构的要求是截然不同的。

通过引入 Tile 对象模型和线性插值算法,我们成功连接了离散的逻辑世界和连续的视觉世界,让 2048 的体验上了一个新的台阶。

进阶思考:
当前的实现中,合并的方块是瞬间变化的。如何利用 new_tile.merged_from 属性,实现两个旧方块移动到一起,然后新方块“弹出来”(Scale Animation)的效果?这需要更复杂的动画状态管理。


跨越离散时间:在 2048 游戏中实现基于线性插值(Lerp)的平滑动画
https://sunfove.xyz/2026/01/19/2026-01-19-smooth-animation-in-2048/
Author
Sunfove
Posted on
January 19, 2026
Licensed under