Python小游戏:在 2048 游戏中实现基于线性插值(Lerp)的平滑动画

在上一篇博文《方格中的混沌与秩序》中,我们构建了一个功能完备的 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.rowtile.col 进行绘制。而是检查当前是否处于动画状态。如果是,我们利用 lerp 函数,根据当前时间进度 ttt,计算出 Tile 在起点 (old_row, old_col) 和终点 (row, col) 之间的中间位置。

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

总结

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

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

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

进阶思考:

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

相关推荐
2501_944526422 小时前
Flutter for OpenHarmony 万能游戏库App实战 - 抽牌游戏实现
android·开发语言·python·flutter·游戏
a程序小傲2 小时前
听说前端又死了?
开发语言·前端·mysql·算法·postgresql·深度优先
副露のmagic2 小时前
python基础复健
python·算法
学Linux的语莫2 小时前
python项目打包为镜像
java·python·spring
夜雨声烦丿2 小时前
Flutter 框架跨平台鸿蒙开发 - 游戏存档管理器应用开发教程
flutter·游戏·华为·harmonyos
Ashley_Amanda2 小时前
Python 进阶:从熟练到精通的核心技能体系
开发语言·python
你怎么知道我是队长2 小时前
C语言---命令行参数
c语言·开发语言
秋刀鱼程序编程2 小时前
Java编程基础入门(四)---选择循环语句
java·开发语言·算法
不会飞的鲨鱼2 小时前
腾讯语音识别 一句话识别python接口
人工智能·python·语音识别