让屏幕动起来:OLED动画效果,丝滑光标、帧动画和一只贪吃蛇

前面的菜单已经能用了:光标上下移动,确认进页面,长按返回。但它是跳着走的——按键一下,方块从一行瞬移到下一行。

下面做两件事,都是“让屏幕动起来”:

  • 操作时的动画:光标不是跳过去,而是滑过去;页面切换不是硬切,而是带一点过渡;
  • 播放内容:整块点阵帧按固定节拍换掉,就是一帧一帧的动画;配上游戏逻辑,就是一台能玩贪吃蛇的小机器。

这两件事共用同一套工具,而且这套工具我们已经有了。那块 OLED 的驱动里,除了 OLED_ShowString()、OLED_ShowNum() 这些,还备着几样动画专用的:

函数 它做什么
OLED_UpdateArea(X, Y, Width, Height) 只把一小块显存推上屏幕
OLED_ClearArea(X, Y, Width, Height) 只清掉一小块
OLED_ReverseArea(X, Y, Width, Height) 把一小块反相(黑白互换)
OLED_ShowImage(X, Y, Width, Height, 数组) 把一整块点阵贴进显存
OLED_DrawPoint / DrawLine / DrawRectangle / DrawCircle 画点、线、矩形、圆

动画能不能跑起来,全看第一行那个函数。

先把刷新这件事算清楚

那块屏是 128×64。驱动内部有一块同样大小的显存,1 位对应 1 个像素:

128 列 × 64 行 = 8192 个像素 = 1024 字节

OLED_Update() 把这一整块 1024 字节推上屏幕。前面算过 I2C 的时序,这里直接代进来:每个字节在线上要 9 位的时间(8 位数据加 1 位应答),所以

400kHz 硬件 I2C:1024 × 9 ÷ 400000 ≈ 23.0 ms   → 理论上限约 43 帧/秒
150kHz 软件 I2C:1024 × 9 ÷ 150000 ≈ 61.4 ms   → 理论上限约 16 帧/秒

这两个数字是天花板,实际还要再低一些:中间有起始、停止、页命令,还有函数本身的开销。所以,整屏刷新的动画做不了很快:软件 I2C 那一档连 16fps 都保不住,画面上就是一顿一顿。

出路不是换更快的屏,而是别刷整屏。OLED_UpdateArea() 只推一小块,数字立刻变了:

整屏:1024 字节        → 23.0 ms
一块 8×8 的格子:8 字节 → 0.18 ms

同样的总线,差 128 倍。动画的第一原则:只重画变的那一块。

一、丝滑光标

位置是连续的,不是格子

菜单里的光标之所以会“跳”,是因为它的位置就是一个行号:0、1、2。想让它滑过去,位置就得是一个像素坐标,而目标是另一个坐标——两者分开存放:

static int16_t cursor_y = 10;           /* 光标现在在哪(像素) */
static int16_t cursor_target = 10;      /* 光标想去哪(像素) */

void OLED_AnimMoveTo(int16_t y)
{
    cursor_target = y;                  /* 按键只改目标 */
}

按键这一下只改目标,剩下的事交给动画节拍——这正是前面那套非阻塞形状:记起点、保持状态、短节拍反复来、到点动一小步。

一个现成的做法:反相加二分逼近

这套动画有个名字:二分法逼近反相动画(原作者见文末的《代码来源与版权》)。它的两个要点都很省事:

  • 用反相显示光标:不去重画文字、也不去填一个方块,只把光标那一块整体反相(OLED_ReverseArea())——黑白一换,位置一目了然;
  • 用二分法逼近:每动一次,就往目标方向走过“剩余距离的一半”。差距大时一步跨得远,差距小时一步挪一点,看起来先快后慢,最后自然停在目标上。
void OLED_AnimUpdate(void)
{
    int16_t diff = (int16_t)(cursor_target - cursor_y);
    int16_t old_y = cursor_y;

    if (diff == 0)
    {
        return;                                     /* 已经到位,什么都不做 */
    }

    if ((diff <= 1) && (diff >= -1))
    {
        cursor_y = cursor_target;                   /* 只差一个像素:判定到达 */
    }
    else
    {
        cursor_y = (int16_t)(cursor_y + (diff / 2));/* 二分:走剩余距离的一半 */
    }

    OLED_ReverseArea(0, old_y, 128, 9);             /* 先把旧位置还原 */
    OLED_UpdateArea(0, old_y, 128, 9);

    OLED_ReverseArea(0, cursor_y, 128, 9);          /* 再反相新位置 */
    OLED_UpdateArea(0, cursor_y, 128, 9);           /* 刷新由这个模块自己负责 */
}

三句话说明这段为什么这么写:

“差一个像素就判定到达”不能省。 二分法在整数除法下只会越来越近、却永远到不了:差距是 1 时,1 / 2 等于 0,光标就停在离目标一个像素的地方不动了。看起来像卡住,其实是永远到不了——这一类 bug 在动画里最常见,凡是“按比例靠近”的写法,都要配一条“足够近就直接等于目标”的收尾。

旧位置要先还原。 反相是“再反相一次就恢复”。所以每一步是:旧位置反相一次(还原成原样)、新位置反相一次(变成光标)。漏掉第一步,屏幕上会留下一串反相痕迹,像拖影。

刷新和反相都在这个函数里。 所以外面不要再对目标位置做一次反相——那会把它反相两次、变回原样;也不需要再调 OLED_Update(),因为这里已经用 OLED_UpdateArea() 把变动的那两条带推出去了,比整屏快得多。

什么时候动一步,由调用者决定

这个函数只做“逼近一步”,不调用它就一步都不动。于是同一段动画可以有两种用法:

  • 挂在节拍上:主循环里每 10ms 调一次,光标就连续滑过去;
  • 挂在按键事件上:每按一次键调一次,就成了“按一下动一格”的步进动画,不按的时候屏幕完全不刷新。

下面两段里,tick_ms 是 1ms 节拍累加出来的毫秒计数,event 是取出来的按键事件(event.id 是哪一个键,event.type 是单击、长按还是别的)——两样东西前面都已经有了。

/* 用法一:连续滑动(10ms 一拍) */
static uint32_t anim_last_ms = 0;

if ((uint32_t)(tick_ms - anim_last_ms) >= 10U)
{
    anim_last_ms = tick_ms;
    OLED_AnimUpdate();
}
/* 用法二:按一下动一步 */
if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK))
{
    OLED_AnimMoveTo((int16_t)(cursor_y + 10));
    OLED_AnimUpdate();
}

动画的推进权交给谁,效果就不同:交给节拍是“滑”,交给按键是“跳一步”。这两种都不需要 Delay,也不占着主循环。

其他过渡效果

反相这一个动作就能变出好几种味道:

  • 闪烁提示:把选中的那一块反相、再反相回来,用在“确认”“警告”上比单纯闪一下更清楚;
  • 逐行展开:新页面不是一次画完,而是每一拍多画一行,用 OLED_UpdateArea(0, 0, 128, 8 * 行数) 一次次推,十几行分四五拍就有“展开”的感觉。

OLED 是单色的,做不了真正的淡入淡出;上面这些都是用“反相”和“局部刷新”两个动作凑出来的。

二、把帧数据播出来

一帧点阵长什么样

OLED_ShowImage(X, Y, Width, Height, 数组) 贴的是一块按页存放的点阵。看它的实现,取字节的方式是 Image[页 * Width + 列],也就是说:

  • 数据是一列一列摆的,每个字节管这一列的 8 个像素;
  • 8 行算一页,页与页之间按顺序接下去;
  • 一帧 128×64 的图,就是 8 页 × 128 列 = 1024 字节,和上面算的显存大小一模一样(这当然不是巧合,它就是显存的副本)。

这个布局不用背:导出的时候选对取模方式,出来的就是它。宽度、高度在调用时告诉函数,剩下的事它自己处理——包括坐标越界时的裁剪。

从一张图、一段视频到帧数据

屏幕自己不会播视频,它只会被一张张点阵图轮着贴。所谓“在屏幕上放动画、放 GIF”,真正的工作全在电脑上做完,单片机只负责按节拍把现成的点阵贴出去。这条流水线一共五步:

  1. 拆帧:把 GIF 或视频拆成一帧帧图片;
  2. 缩放:把每一帧缩放并裁剪到屏幕大小(128×64),保持宽高比,别把人拉扁;
  3. 单色化:屏幕只有“亮”和“不亮”两种状态,灰度图必须先变成黑白两色;
  4. 取模:把每一帧转成一串点阵字节;
  5. 拼帧表:把各帧的字节排成一张表,就是下面播放器要的 frames[]。

第三步最容易出事。直接定一个阈值(比如亮度过半算亮)最简单,但暗部会糊成一整块、亮部会糊成一片,五官全没了。常用的是抖动:把每个像素的灰度误差分给右边和下面的像素,用点的疏密来“假装”灰度。同一张 128×64 的图,阈值法出来是一团糊,抖动之后能看出层次——单色屏上让画面“像”,靠的就是这一步。

第四步的取模,是把图像变成字节的那一下。这类工具有好几个(常见的一个叫 PCtoLCD2002),选项名字都差不多,照着下面这套对就行。要选的一共四组:

选项 可选值 该选哪个
取模方式 逐行 / 逐列 / 行列 / 列行 列行
阴码阳码 阴码(亮点为 1)/ 阳码(亮点为 0) 阴码
取模走向 纵向(第一位为低位)/ 倒向(第一位为高位) 纵向
输出格式 C 语言数组 / 汇编 / 自定义 C 语言数组

“列行”这个名字听着绕,说清了很简单:先纵向取第一列的前 8 个点做第一个字节,再横向挪到第二列取前 8 个点做第二个字节,一列一列地走。所以一个字节管一列的 8 个像素,字节的顺序就是列的顺序——这正好是 Image[页 * Width + 列]。

阴码阳码为什么选阴码,看驱动贴图的那一句就明白了:

OLED_DisplayBuf[Page + j][X + i] |= Image[j * Width + i] << Shift;

数据里的 1 被直接“或”进显存(OLED_DisplayBuf 就是驱动里那块 1024 字节的显存),而显存里的 1 就是点亮,所以数据必须亮点为 1,也就是阴码。选成阳码,图是反的:脸是黑的,背景是亮的。

顺手核一下尺寸:一张 15×15 的小图取出来是 30 字节 = 2 页 × 15 列。15 行跨了两页(8 行一页),第二页其实只用到 7 行,可它每一列仍然占一个整字节——“页”是不可分割的单位,这一点在算存储时别忘。128×64 的整屏图就是 8 页 × 128 列 = 1024 字节,和前面算的显存大小一样。

帧率不需要跟着原视频。 原视频 30fps,屏幕这边 8~15fps 就已经在动了;超出去的部分要么丢帧,要么降分辨率。所以顺序是:先按带宽估一个能接受的帧率,再回过头去抽帧。

上面这些设置里,唯一不能记错的就是一句话:导出必须是“列行、阴码、低位在前”。 这一条对了,用哪个工具、甚至自己写脚本批量转,都没问题;这一条错了,屏幕上的图会花、会镜像、会上下颠倒——而问题不在单片机的代码里。

播放器:一张帧表加一个节拍

帧就是这样一堆常量数组,把它们收进一张表:

(frame_0 到 frame_3 就是取模软件导出的四个 1024 字节常量数组,格式就是“一帧点阵长什么样”那一节说的“页优先、页内按列”。)

#define FRAME_COUNT  4U
#define FRAME_MS     100U

static const uint8_t *const frames[FRAME_COUNT] =
{
    frame_0, frame_1, frame_2, frame_3
};

static uint8_t  play_index = 0U;
static uint32_t play_last_ms = 0U;

void Anim_Service(uint32_t now)
{
    if ((uint32_t)(now - play_last_ms) < FRAME_MS)
    {
        return;                             /* 还没到换帧的时间 */
    }

    play_last_ms = now;

    OLED_ShowImage(0, 0, 128U, 64U, frames[play_index]);
    OLED_Update();

    play_index++;
    if (play_index >= FRAME_COUNT)
    {
        play_index = 0U;
    }
}

const 这两个字很重要:帧数据是只读的,const 会让它待在 Flash 里,不占 RAM。我把这段代码连四帧数据编译后,看编译器给的体积:

Code      476 字节      ← 播放器加上它那点调度逻辑
RO Data  4144 字节      ← 四帧点阵(4 × 1024 = 4096)在这里
RW Data    12 字节
ZI Data  1120 字节      ← 驱动那块 1024 字节的显存也算在 ZI 里

数据是大头,代码几乎可以忽略。

能播多长、能多快

两件事,决定你能播什么。

第一件是总线带宽(前面算过):整屏 1024 字节在 400kHz 下要 23ms,所以全屏换帧的天花板约 43fps;软件 I2C 那一档约 16fps。想让画面更流畅,只有两条路——换更快的链路(硬件 I2C),或者只换动了的那一块。

第二件是存储:一帧 1KB,一秒 10 帧的动画,5 秒就是 50 帧、50KB。STM32F103C8T6 只有 64KB Flash、20KB RAM。所以:

  • 短动画放片内 Flash 就够了(几十帧、几秒);
  • 长一点的要么降帧率、降分辨率,要么压缩,要么外挂一块 SPI Flash——那是 8MB 级别的存储,够放几千帧。

两件事一起看,结论很实际:“能不能播”由带宽和存储共同决定,不由愿望决定。

帧数据不够用时的压缩:最小 RLE

单色图像有个便宜可占:大片区域是同一种颜色。游程编码(RLE)就是把这个事实记下来——“接下来 200 个像素都是亮的”,用两个字节表示。

这里的数据格式很简单:每两个字节一组,第一字节是重复次数,第二字节是像素值;次数为 0 表示结束。

/* 把一段 RLE 数据按行、从 (x, y) 开始铺开 */
static void RLE_Draw(int16_t x, int16_t y, const uint8_t *data)
{
    uint16_t index = 0U;
    uint16_t count;
    uint8_t  value;
    int16_t  px = x;
    int16_t  py = y;

    while (1)
    {
        count = data[index];
        value = data[index + 1U];
        index += 2U;

        if (count == 0U)
        {
            return;                     /* 次数 0:这一帧画完了 */
        }

        while (count > 0U)
        {
            if (value != 0U)
            {
                OLED_DrawPoint(px, py);         /* 亮的像素:点亮 */
            }
            else
            {
                OLED_ClearArea(px, py, 1U, 1U); /* 暗的像素:擦掉 */
            }

            px++;
            if (px >= 128)
            {
                px = 0;
                py++;
            }

            count--;
        }
    }
}

这里有一个细节要留意:“点亮”和“擦掉”是两个不同的函数。 OLED_DrawPoint() 只会把像素置 1,OLED_DrawRectangle(..., 1U) 也一样——拿它们去画“灭”的像素,结果是把它点亮。想擦掉就得用 OLED_ClearArea()。

上面的写法两种情况都照顾到了。如果确定整帧是从“空白”开始画的(前面先清过那片区域),那么 value == 0 的分支可以直接跳过——省下的调用次数等于暗像素的个数,对稀疏图形很大。

拿刚才那四帧里的一帧实测,压缩效果是这样:

原始一帧:1024 字节
点亮的像素:384 个(共 8192 个)
RLE 编码后:116 字节
省下:88.7%

省下的是 Flash,花掉的是 CPU。 解压要一个像素一个像素地写显存,8192 次函数调用不是白来的。所以这是一次交换:存不下的时候用它换空间,机器跑得动的时候才用它。 图形越稀疏(线条、图标、文字),压缩越划算;满屏噪点几乎压不动。

想把这份开销也降下来,办法是让解压直接写显存(驱动里那块显存数组)而不是逐点调绘制函数,或者按“列”来压缩——单色屏的列天然就是 8 个像素一个字节,正好和显存对齐。先明白原理,再按需优化。

三、贪吃蛇:两个按键刚好够

前面两节把工具备齐了,现在拿一个游戏把它们全用上。

规则:蛇在 16×8 的格子里走,每格 8 像素;吃到食物变长;撞墙或者撞到自己就结束。两个按键就是两个转向:

  • A 单击:顺时针转 90°;
  • B 单击:逆时针转 90°。

为什么两个键就够?因为只能转 90°,永远不会掉头;方向在“右、下、左、上”四个值里循环,加一就是顺时针,加三就是逆时针:

static uint8_t snake_dir = 0U;          /* 0 右、1 下、2 左、3 上 */

void Snake_Turn(uint8_t clockwise)
{
    if (clockwise != 0U)
    {
        snake_dir = (uint8_t)((snake_dir + 1U) & 0x03U);
    }
    else
    {
        snake_dir = (uint8_t)((snake_dir + 3U) & 0x03U);
    }
}

蛇身用环形缓冲

蛇身要记住一长串格子坐标,而且头一直在加、尾一直在减。这不就是前面讲过的队列吗——一块固定长度的数组,加一个头、一个尾,写到末尾就绕回开头:

#define SNAKE_MAX_LEN   64U
#define CELL            8U

typedef struct
{
    uint8_t x;
    uint8_t y;
} Point;

static Point   snake_body[SNAKE_MAX_LEN];
static uint8_t snake_head = 0U;         /* 头在哪一格 */
static uint8_t snake_tail = 0U;         /* 尾在哪一格 */
static uint8_t snake_length = 1U;
static Point   snake_food;              /* 食物在哪一格 */
static uint8_t snake_over = 0U;         /* 1 表示这一局结束 */
static uint32_t snake_last_ms = 0U;     /* 上一次走动的时刻 */

走一步就是“在头部加一格、在尾部减一格”,两个下标各自绕圈:

snake_head = (uint8_t)((snake_head + 1U) % SNAKE_MAX_LEN);
snake_body[snake_head] = next;

/* 没吃到食物时,尾巴也往前挪一格 */
snake_tail = (uint8_t)((snake_tail + 1U) % SNAKE_MAX_LEN);

这就是“以空间换时间”用在游戏上的样子:不开动态内存、不搬数组,64 格的固定缓冲就够玩一局。吃到食物时不减尾巴,蛇自然变长一格。

每一步只画两个格子

蛇走一格,屏幕上真正变的只有两个位置:新头和旧尾。

OLED_DrawRectangle(next.x * CELL, next.y * CELL, CELL, CELL, 1U);       /* 画新头 */
OLED_UpdateArea(next.x * CELL, next.y * CELL, CELL, CELL);

OLED_ClearArea(old_tail.x * CELL, old_tail.y * CELL, CELL, CELL);       /* 抹旧尾 */
OLED_UpdateArea(old_tail.x * CELL, old_tail.y * CELL, CELL, CELL);

一个 8×8 的格子占 8 列、1 页,就是 8 个字节;两个格子 16 字节。整屏是 1024 字节——同样的总线,少推 64 倍。 所以这条蛇可以走得又快又稳,而整屏刷新的写法在软件 I2C 上根本跑不动。

食物:拿时间当随机数

食物得放在一个空格子上。棋盘 16×8 共 128 格,直接从“当前时刻”里取一个数,再往后一格格找,找到第一个不压蛇身的位置就停下——不需要 rand(),也不需要 stdlib:

static void Snake_DrawFood(void)
{
    /* 画成一个 4×4 的小方块,和 8×8 的蛇身区分开 */
    OLED_DrawRectangle((int16_t)(snake_food.x * CELL + 2),
                       (int16_t)(snake_food.y * CELL + 2), 4U, 4U, 1U);
    OLED_UpdateArea((int16_t)(snake_food.x * CELL),
                    (int16_t)(snake_food.y * CELL), CELL, CELL);
}

static void Snake_PlaceFood(void)
{
    uint8_t tries;

    for (tries = 0U; tries < 128U; tries++)
    {
        uint8_t cell = (uint8_t)((snake_last_ms / 7U + tries) % 128U);
        uint8_t i;

        snake_food.x = (uint8_t)(cell % 16U);
        snake_food.y = (uint8_t)(cell / 16U);

        for (i = 0U; i < snake_length; i++)
        {
            uint8_t index = (uint8_t)((snake_tail + i) % SNAKE_MAX_LEN);

            if ((snake_body[index].x == snake_food.x) &&
                (snake_body[index].y == snake_food.y))
            {
                break;              /* 压在身上,换一格再试 */
            }
        }

        if (i >= snake_length)
        {
            Snake_DrawFood();       /* 找到空位,画出来 */
            return;
        }
    }
}

snake_last_ms 每一拍都在变,拿它当种子就够了;tries 保证最坏情况下也不会原地打转——蛇最长 64 格,128 次尝试一定找得到空位。开局时先 Snake_PlaceFood() 一次,之后每次吃到再调一次。

逻辑节拍和按键分开

游戏里有两件事,节奏完全不同:

  • 蛇什么时候往前走:固定节拍,比如每 200ms 一格;
  • 玩家什么时候转向:随时,由按键事件决定。

它们各走各的:按键只改方向,走不走由节拍决定。

#define SNAKE_STEP_MS   200U

void Snake_Service(uint32_t now)
{
    if (snake_over != 0U)
    {
        return;
    }

    if ((uint32_t)(now - snake_last_ms) < SNAKE_STEP_MS)
    {
        return;                         /* 还没到下一步的时间 */
    }

    snake_last_ms = now;
    Snake_Step();
}

按键那一侧的接线很薄:

KeyEvent event;

while (Key_GetEvent(&event) != 0U)
{
    if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK))
    {
        Snake_Turn(1U);                 /* 顺时针 */
    }
    else if ((event.id == KEY_B) && (event.type == KEY_EVENT_CLICK))
    {
        Snake_Turn(0U);                 /* 逆时针 */
    }
}

把“什么时候发生”和“发生什么”分开,是这类程序能稳住的关键。 如果按键直接去移动蛇,按住键就会让蛇狂奔,而且每多按一次就多跑一步——玩家按得越急,蛇越不听话。

走一格的全部逻辑

最后一块是碰撞和吃到食物:

static const int8_t step_x[4] = { 1, 0, -1, 0 };
static const int8_t step_y[4] = { 0, 1, 0, -1 };

static uint8_t Snake_HitSelf(const Point *next)
{
    uint8_t i;

    for (i = 0U; i < snake_length; i++)
    {
        uint8_t index = (uint8_t)((snake_tail + i) % SNAKE_MAX_LEN);

        if ((snake_body[index].x == next->x) &&
            (snake_body[index].y == next->y))
        {
            return 1U;
        }
    }

    return 0U;
}

void Snake_Step(void)
{
    Point next;
    Point old_tail;

    next.x = (uint8_t)(snake_body[snake_head].x + step_x[snake_dir]);
    next.y = (uint8_t)(snake_body[snake_head].y + step_y[snake_dir]);

    if ((next.x >= 16U) || (next.y >= 8U) || (Snake_HitSelf(&next) != 0U))
    {
        snake_over = 1U;                /* 撞墙或者撞到自己:结束 */
        return;
    }

    snake_head = (uint8_t)((snake_head + 1U) % SNAKE_MAX_LEN);
    snake_body[snake_head] = next;

    if ((next.x == snake_food.x) && (next.y == snake_food.y))
    {
        if (snake_length < SNAKE_MAX_LEN)
        {
            snake_length++;             /* 吃到:加长,上限就是缓冲长度 */
        }

        Snake_PlaceFood();              /* 被吃掉的食物换个位置 */
    }
    else
    {
        old_tail = snake_body[snake_tail];

        OLED_ClearArea((int16_t)(old_tail.x * CELL), (int16_t)(old_tail.y * CELL),
                       CELL, CELL);
        OLED_UpdateArea((int16_t)(old_tail.x * CELL), (int16_t)(old_tail.y * CELL),
                        CELL, CELL);

        snake_tail = (uint8_t)((snake_tail + 1U) % SNAKE_MAX_LEN);
    }

    OLED_DrawRectangle((int16_t)(next.x * CELL), (int16_t)(next.y * CELL),
                       CELL, CELL, 1U);
    OLED_UpdateArea((int16_t)(next.x * CELL), (int16_t)(next.y * CELL),
                    CELL, CELL);
}

Snake_HitSelf() 里有一个容易写错的地方:要检查的是“新头这一格是不是已经在蛇身上”,所以遍历要从尾巴开始数 snake_length 格——不能把整个数组从头扫一遍,数组里还留着上一次用过的旧坐标。

这一段连同上两节的代码(播放器、丝滑光标、RLE、这条蛇),我用这套工程的编译器真编了一遍:零错误;把四帧点阵一起算上,代码总共 848 字节,而四帧数据一项就是 4096 字节——数据比代码大好几倍,前面那个结论在这里又对上了。

四、三个常被问到的

“帧率到底由什么决定?” 由“每帧要推多少字节”和“总线的速度”两件事决定,不由循环写得多紧凑决定。整屏 1024 字节在 400kHz 下就是 23ms,这一条改不了;能改的是推多少——把整屏换成局部,帧率自然上去。

“为什么我的动画一顿一顿的?” 先看是不是每帧都在刷整屏,再看节拍是不是被别的事情挡住:如果刷新挤在主循环里,而主循环里又有一段要跑几十毫秒的活,动画就会跟着卡。动画节拍应该是独立的定时任务,只负责“该动了吗、动一小步”。

“游戏为什么按了没反应?” 多半是按键和移动绑在了一起——按键只改方向、移动交给节拍,中间隔着一层状态,两者就不会互相拖累。另一类原因是事件被吞了:如果某处还在用“直接读电平”的写法,按住键会连续触发,松开反而没有事件。

代码来源与版权

用到的 OLED 驱动与图形接口(OLED_Init()、OLED_UpdateArea()、OLED_ClearArea()、OLED_ReverseArea()、OLED_ShowImage() 以及各绘制函数)来自江协科技公开发布的 OLED 驱动程序,基础部分的版权归原作者,程序文件里保留了原版权声明;对外接口没有改动,只做拆解和实验。

“二分法逼近反相动画”这一套做法的原作者是梦想做游戏的电工薯。反相显示光标、每次调用只逼近剩余距离的一半、由 OLED_AnimUpdate() 每次调用推进一步,以及“调用者不要再对目标位置反相、也不必再调用 OLED_Update()”这条注意事项,都出自这段函数的说明;这里的实现按同样的思路写,并补上了“差一个像素就判定到达”和“旧位置先还原”两句,好让它在整数运算下真的能停下来。

取模方式(列行、阴码、低位在前)的依据来自取模软件自己的说明;播放器、RLE、帧表数据和贪吃蛇的代码是为讲解写的。

最后橘猫说

动画的天花板是总线:1024 字节推一次要 23ms,所以第一原则永远是只重画变的那一块。丝滑光标靠反相加二分,再补两条收尾——差一个像素就判定到达,旧位置先还原。帧数据全在电脑上生成:拆帧、缩放、抖动单色化、取模,三项顺序错一个图就花;存不下就上 RLE,拿 CPU 换 Flash。贪吃蛇把这一切串起来:两个键只管转向,走不走交给 200ms 拍子,蛇身走环形缓冲,每一步只画新头和旧尾。

最后蜘蛛说

我知道你在想什么……

posted @ 2026-11-13 19:41  Zw-awa  阅读(6)  评论(0)    收藏  举报