简介
学而思小喵机是前一段时间在老王家电子进行购买的,据说是因为有人生病了。然后做出来的这一款产品用来筹集医药费。原本大概是20元左右后来是涨价了十元,算上运费大概35元左右。看起来也不是很贵于是我就下单了。纯属是支持一下。小喵机的核心是 ESP32-WROVER-B + ST7735 160×128 TFT,自带 6 个按键和蜂鸣器。

小猫机本体
这个小猫机已经在网上被玩出花了。B站上有数不清的视频分享小猫机的DIY。在下一个篇文章中我将会介绍如何刷入Retro-go固件来使其支持玩FC游戏(已经测试过,完美运行。感谢各位大佬的开源)。板子本来是跑 MicroPython 的,但说实话 Python 在这种小屏幕上刷帧太痛苦了,于是直接用 ESP-IDF 写了个 2048,整个游戏就一个 C 文件,编译完 Flash 占用极小,帧率丝滑。也算是为社区增加一个新的玩法。
整体架构
整个项目就一个源文件 main/game2048.c,模块划分全靠注释分区。现在程序的基本的功能是,通过上下左右键控制方块格子的移动。使用A和B键来控制重新开始和游戏回退。这样的话如果自己陷入死胡同的话直接回退。数据是在NVS里的,所以就算断电了也不会丢失。主函数包括以下主要函数。
LCD 驱动 SPI 总线初始化、ST7735 配置 lcd_init(), lcd_present()
图形绘制 矩形填充、5×7 点阵文本渲染 rect(), text_draw(), text_width()
游戏渲染 棋盘绘制、方块颜色、动画帧 render(), tile_color()
游戏逻辑 滑动合并、新方块、胜负判断 move_board(), add_tile(), can_move()
存档系统 NVS 读写、历史快照栈 save_game(), undo()
蜂鸣器 LEDC PWM 驱动无源蜂鸣器 buzzer_init(), beep()
按键 GPIO 配置、软件消抖 keys_init(), 主循环扫描
这里原本我程序写的时候是打算用LVGL写的,但是这个芯片我之前没有使用过也不确定是否具备PSRAM(今天写帖子的时候查完手册,其实发现是支持PSRAM的)。还有一个主要的原因没有使用PSRAM就是因为其屏幕的数据缓冲区只占用
160×128 × 2 字节(RGB565)= 40 KB
用 heap_caps_malloc(MALLOC_CAP_DMA) 分配在 DMA 可访问内存里。每次渲染直接往这个数组里写像素,写完一次性 SPI 推屏。这样的话游戏的帧率也会更高。DMA的使用也不会占用主CPU的时间。
/**
* @brief 在帧缓冲区中绘制一个填充矩形
*
* @param x 矩形左上角 X 坐标
* @param y 矩形左上角 Y 坐标
* @param w 矩形宽度
* @param h 矩形高度
* @param c 填充颜色(RGB565 格式)
*
* 自动裁剪超出屏幕范围的部分。注意:帧缓冲区以大端序存储,
* 因此写入时需要交换字节顺序 (c << 8 | c >> 8)。
*/
static void rect(int x, int y, int w, int h, uint16_t c)
{
/* 左边界裁剪 */
if (x < 0)
{
w += x;
x = 0;
}
/* 上边界裁剪 */
if (y < 0)
{
h += y;
y = 0;
}
/* 右边界裁剪 */
if (x + w > LCD_W)
w = LCD_W - x;
/* 下边界裁剪 */
if (y + h > LCD_H)
h = LCD_H - y;
/* 逐像素填充,交换字节序以适配 SPI 大端传输 */
for (int j = 0; j < h; j++)
for (int i = 0; i < w; i++)
frame[(y + j) * LCD_W + x + i] = (c << 8) | (c >> 8);
}这里有个坑:SPI 传输是 MSB first,所以 RGB565 的字节序要交换。写入帧缓冲区时必须 (c << 8) | (c >> 8),不然颜色全错。这个问题我调了半小时最后在AI的辅助下才知道次序没有交换正确。原本使用的是LSB。
对于屏幕上方的文本显示则是使用的点阵字体。只覆盖 ASCII 32-90(空格到大写 Z),对 2048 来说够用了——只需要数字和 SCORE、BEST、GAME OVER 这几个词。SCORE 用来记录当前的分数,BEST用来记录历史的分数,和游戏结束(游戏就算结束了也可以通过回退键回退到游戏没有死亡之前,妈妈再也不用担心我拿不到2048分了)。
/**
* @brief 在帧缓冲区中绘制文本字符串
*
* @param x 起始 X 坐标
* @param y 起始 Y 坐标
* @param s 要绘制的字符串(仅支持 ASCII 32..90)
* @param c 文字颜色(RGB565 格式)
* @param scale 缩放倍数(1 = 原始 5×7,2 = 10×14,以此类推)
*/
static void text_draw(int x, int y, const char *s, uint16_t c, int scale)
{
uint16_t sw = (c << 8) | (c >> 8); /* 预计算字节交换后的颜色值 */
while (*s)
{
unsigned ch = (unsigned char)*s++;
/* 查找字符点阵数据,超出范围的显示为空白 */
const uint8_t *g = (ch >= 32 && ch <= 90) ? font[ch - 32] : font[0];
/* 逐列逐行扫描点阵,缩放绘制每个像素 */
for (int col = 0; col < 5; col++)
for (int row = 0; row < 7; row++)
if (g[col] & (1 << row)) /* 该位为 1 表示需要绘制 */
for (int yy = 0; yy < scale; yy++)
for (int xx = 0; xx < scale; xx++)
{
int px = x + col * scale + xx, py = y + row * scale + yy;
if (px >= 0 && px < LCD_W && py >= 0 && py < LCD_H)
frame[py * LCD_W + px] = sw;
}
x += 6 * scale; /* 字符宽度 5 像素 + 1 像素间距,乘以缩放倍数 */
}
}游戏的主要逻辑处理
2048 的核心算法是滑动合并,核心就是:不管往哪个方向移动,都先把每一行或者列当成一维数组,统一按照向左的规则处理。
左:从左到右取,处理完从左到右放回
右:从右到左取,处理完从右到左放回
上:从上到下取,处理完从上到下放回
下:从下到上取,处理完从下到上放回
所以不用写 4 套移动代码,只需要通过 dir 改变取数据和写回数据的顺序。关键代码就这几行
int q = (dir == 1 || dir == 3) ? 3 - k : k; int idx = (dir < 2) ? line * 4 + q : q * 4 + line;
合并时相邻相同方块翻倍,跳过被合并的那个(k++),同时用 anim_merge_mask 位掩码记录哪些位置发生了合并——后面动画要用。说实话一开始没打算做动画,但静态刷新太生硬了。后来就让AI帮忙加了个轻量级动画系统,只有 6 帧,每帧 16ms,总共不到 100ms,不影响操作手感(这里就必须吐槽下,WIOterminal, 在Arduino的环境下做的2048游戏机按下按键之后要大概0.5S才能反应过来)
/* 新方块弹出动画:先放大再回弹到正常大小 */
if (phase && cell == anim_new_cell)
{
static const int8_t pop[7] = {0, 10, 8, 5, 2, -1, 0};
inset = pop[phase]; /* 正值=缩小(内缩),负值=放大(外扩) */
}
/* 合并方块脉冲动画:轻微放大再缩小 */
else if (phase && (anim_merge_mask & (1U << cell)))
{
static const int8_t pulse[7] = {0, 3, 1, -2, -3, -1, 0};
inset = pulse[phase];
}这个实现挺巧的:没有真的去缩放像素,而是通过调整矩形的 inset 来实现。inset 变大,方块就变小;变成负数,方块反而会变大,看起来就像在做缩放动画。另外,新生成的方块在最开始的两帧不会显示数字,这样看起来就像方块突然“出现”一样,动画会更自然。
游戏历史轨迹和存储
游戏的存储是保存的NVS中的。最高可以保存64步,并且断电不丢失。当游戏启动的时候会首先从定义保存在NVS的结构体中来读取到游戏的数据从而完成游戏内容的恢复。
typedef struct
{
uint32_t magic, best; /* magic: 存档校验值; best: 历史最高分 */
uint8_t history_count, won, game_over, reserved;
/* history_count: 历史记录数量; won: 是否达到2048;
game_over: 游戏是否结束; reserved: 保留字段 */
snapshot_t current; /* 当前游戏状态 */
snapshot_t history[HISTORY_MAX]; /* 撤销历史栈 */
} save_data_t;到这里其实游戏的核心逻辑都已经写完了。但是在实际游戏的过程中发现少了那么一点东西。于是便又去查了一下开源出来的引脚图。然后使用ESP-IDF内的LEDC 的PWM功能来驱动了蜂鸣器。蜂鸣器定义信息如下所示

效果演示
附件
总结
整个项目从零到能玩大概花了一天,最耗时的不是游戏逻辑(游戏逻辑可以从网上找到后然后告诉AI来让Ai工具辅助生成)。稍微麻烦的一点是 LCD 驱动和渲染那块——字节序、偏移、旋转镜像这些得对着实际屏幕一点点调。小猫机的可玩性非常高。非常适做一些游戏机一类的内容。
我要赚赏金
