前面的菜单已经能用了:光标上下移动,确认进页面,长按返回。但它是跳着走的------按键一下,方块从一行瞬移到下一行。
下面做两件事,都是"让屏幕动起来":
- 操作时的动画:光标不是跳过去,而是滑过去;页面切换不是硬切,而是带一点过渡;
- 播放内容:整块点阵帧按固定节拍换掉,就是一帧一帧的动画;配上游戏逻辑,就是一台能玩贪吃蛇的小机器。
这两件事共用同一套工具,而且这套工具我们已经有了。那块 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 个像素:
text
128 列 × 64 行 = 8192 个像素 = 1024 字节
OLED_Update() 把这一整块 1024 字节推上屏幕。前面算过 I2C 的时序,这里直接代进来:每个字节在线上要 9 位的时间(8 位数据加 1 位应答),所以
text
400kHz 硬件 I2C:1024 × 9 ÷ 400000 ≈ 23.0 ms → 理论上限约 43 帧/秒
150kHz 软件 I2C:1024 × 9 ÷ 150000 ≈ 61.4 ms → 理论上限约 16 帧/秒
这两个数字是天花板 ,实际还要再低一些:中间有起始、停止、页命令,还有函数本身的开销。所以,整屏刷新的动画做不了很快:软件 I2C 那一档连 16fps 都保不住,画面上就是一顿一顿。
出路不是换更快的屏,而是别刷整屏 。OLED_UpdateArea() 只推一小块,数字立刻变了:
text
整屏:1024 字节 → 23.0 ms
一块 8×8 的格子:8 字节 → 0.18 ms
同样的总线,差 128 倍。动画的第一原则:只重画变的那一块。
一、丝滑光标
位置是连续的,不是格子
菜单里的光标之所以会"跳",是因为它的位置就是一个行号:0、1、2。想让它滑过去,位置就得是一个像素坐标,而目标是另一个坐标------两者分开存放:
c
static int16_t cursor_y = 10; /* 光标现在在哪(像素) */
static int16_t cursor_target = 10; /* 光标想去哪(像素) */
void OLED_AnimMoveTo(int16_t y)
{
cursor_target = y; /* 按键只改目标 */
}
按键这一下只改目标,剩下的事交给动画节拍------这正是前面那套非阻塞形状:记起点、保持状态、短节拍反复来、到点动一小步。
一个现成的做法:反相加二分逼近
这套动画有个名字:二分法逼近反相动画(原作者见文末的《代码来源与版权》)。它的两个要点都很省事:
- 用反相显示光标 :不去重画文字、也不去填一个方块,只把光标那一块整体反相 (
OLED_ReverseArea())------黑白一换,位置一目了然; - 用二分法逼近:每动一次,就往目标方向走过"剩余距离的一半"。差距大时一步跨得远,差距小时一步挪一点,看起来先快后慢,最后自然停在目标上。
c
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 是单击、长按还是别的)------两样东西前面都已经有了。
c
/* 用法一:连续滑动(10ms 一拍) */
static uint32_t anim_last_ms = 0;
if ((uint32_t)(tick_ms - anim_last_ms) >= 10U)
{
anim_last_ms = tick_ms;
OLED_AnimUpdate();
}
c
/* 用法二:按一下动一步 */
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",真正的工作全在电脑上做完,单片机只负责按节拍把现成的点阵贴出去。这条流水线一共五步:
- 拆帧:把 GIF 或视频拆成一帧帧图片;
- 缩放:把每一帧缩放并裁剪到屏幕大小(128×64),保持宽高比,别把人拉扁;
- 单色化:屏幕只有"亮"和"不亮"两种状态,灰度图必须先变成黑白两色;
- 取模:把每一帧转成一串点阵字节;
- 拼帧表 :把各帧的字节排成一张表,就是下面播放器要的
frames[]。
第三步最容易出事。直接定一个阈值 (比如亮度过半算亮)最简单,但暗部会糊成一整块、亮部会糊成一片,五官全没了。常用的是抖动:把每个像素的灰度误差分给右边和下面的像素,用点的疏密来"假装"灰度。同一张 128×64 的图,阈值法出来是一团糊,抖动之后能看出层次------单色屏上让画面"像",靠的就是这一步。
第四步的取模,是把图像变成字节的那一下。这类工具有好几个(常见的一个叫 PCtoLCD2002),选项名字都差不多,照着下面这套对就行。要选的一共四组:
| 选项 | 可选值 | 该选哪个 |
|---|---|---|
| 取模方式 | 逐行 / 逐列 / 行列 / 列行 | 列行 |
| 阴码阳码 | 阴码(亮点为 1)/ 阳码(亮点为 0) | 阴码 |
| 取模走向 | 纵向(第一位为低位)/ 倒向(第一位为高位) | 纵向 |
| 输出格式 | C 语言数组 / 汇编 / 自定义 | C 语言数组 |
"列行"这个名字听着绕,说清了很简单:先纵向取第一列的前 8 个点做第一个字节,再横向挪到第二列取前 8 个点做第二个字节 ,一列一列地走。所以一个字节管一列的 8 个像素,字节的顺序就是列的顺序------这正好是 Image[页 * Width + 列]。
阴码阳码为什么选阴码,看驱动贴图的那一句就明白了:
c
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 字节常量数组,格式就是"一帧点阵长什么样"那一节说的"页优先、页内按列"。)
c
#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。我把这段代码连四帧数据编译后,看编译器给的体积:
text
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 表示结束。
c
/* 把一段 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 的分支可以直接跳过------省下的调用次数等于暗像素的个数,对稀疏图形很大。
拿刚才那四帧里的一帧实测,压缩效果是这样:
text
原始一帧:1024 字节
点亮的像素:384 个(共 8192 个)
RLE 编码后:116 字节
省下:88.7%
省下的是 Flash,花掉的是 CPU。 解压要一个像素一个像素地写显存,8192 次函数调用不是白来的。所以这是一次交换:存不下的时候用它换空间,机器跑得动的时候才用它。 图形越稀疏(线条、图标、文字),压缩越划算;满屏噪点几乎压不动。
想把这份开销也降下来,办法是让解压直接写显存(驱动里那块显存数组)而不是逐点调绘制函数,或者按"列"来压缩------单色屏的列天然就是 8 个像素一个字节,正好和显存对齐。先明白原理,再按需优化。
三、贪吃蛇:两个按键刚好够
前面两节把工具备齐了,现在拿一个游戏把它们全用上。
规则:蛇在 16×8 的格子里走,每格 8 像素;吃到食物变长;撞墙或者撞到自己就结束。两个按键就是两个转向:
- A 单击:顺时针转 90°;
- B 单击:逆时针转 90°。
为什么两个键就够?因为只能转 90°,永远不会掉头;方向在"右、下、左、上"四个值里循环,加一就是顺时针,加三就是逆时针:
c
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);
}
}
蛇身用环形缓冲
蛇身要记住一长串格子坐标,而且头一直在加、尾一直在减。这不就是前面讲过的队列吗------一块固定长度的数组,加一个头、一个尾,写到末尾就绕回开头:
c
#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; /* 上一次走动的时刻 */
走一步就是"在头部加一格、在尾部减一格",两个下标各自绕圈:
c
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 格的固定缓冲就够玩一局。吃到食物时不减尾巴,蛇自然变长一格。
每一步只画两个格子
蛇走一格,屏幕上真正变的只有两个位置:新头 和旧尾。
c
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:
c
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 一格;
- 玩家什么时候转向:随时,由按键事件决定。
它们各走各的:按键只改方向,走不走由节拍决定。
c
#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();
}
按键那一侧的接线很薄:
c
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); /* 逆时针 */
}
}
把"什么时候发生"和"发生什么"分开,是这类程序能稳住的关键。 如果按键直接去移动蛇,按住键就会让蛇狂奔,而且每多按一次就多跑一步------玩家按得越急,蛇越不听话。
走一格的全部逻辑
最后一块是碰撞和吃到食物:
c
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 拍子,蛇身走环形缓冲,每一步只画新头和旧尾。
最后蜘蛛说
我知道你在想什么......