一块 2.4 英寸的 ILI9341 屏幕,一个由四个轻触按键组成的十字键,再加两个 A、B 按键:这就是一台掌上游戏机,而一块 ESP32 足以驱动它。我们为这种布局做了五款游戏,然后又为带模拟摇杆的更大掌机做了第六款 Skythread,并把六款全部放进了 Velxio 的示例库。每一款都是一个 Arduino 草图,每一款都能在你的浏览器里运行:按下 Run 就能玩,不需要开发板、不需要屏幕、不需要焊接。
| 游戏 | 开发板 | 游戏类型 | Velxio 中的帧率 |
|---|---|---|---|
| ESP32 Doom | ESP32 DevKit | 第一人称射击 | 约 18 FPS |
| Sky Duel | Seeed XIAO ESP32-S3 | 侧视空战 | 约 29 FPS |
| XIAO Quest | Seeed XIAO ESP32-S3 | 俯视角 RPG | 约 32 FPS |
| Gear Hop | Seeed XIAO ESP32-S3 | 横版卷轴平台跳跃 | 约 27 FPS |
| Circuit Defense | Seeed XIAO ESP32-S3 | 塔防 | 约 31 FPS |
| Skythread | Seeed XIAO ESP32-S3 Sense | 在生成的地形上空进行 3D 飞行 | 约 20 FPS |
第一款 Doom 是最早做的,运行在经典 ESP32 上。接下来的四款运行在 Seeed Studio XIAO ESP32-S3 上,这是我们合作伙伴 Seeed 推出的拇指大小的开发板,它们共用一个小型引擎。最后一款 Skythread 运行在 XIAO ESP32-S3 Sense 上,同样的芯片但带有 8 MB PSRAM,它需要这些内存来把整片地形保存在内存中。本文介绍每款游戏的玩法及其内部原理。
如何在 Velxio 中游玩
- 从上面的表格中打开任意一款游戏。草图、库和电路会一起加载。
- 按下 Run。Velxio 会编译草图并启动它。草图的首次编译最多需要一分钟;之后就会从构建缓存中加载。
- 每款游戏都以自动演示的吸引模式开始。按任意键或按钮即可接管。
- 用键盘游玩:方向键是十字键,Z 是 A,X 是 B。画布上的每个按钮都会在一个小键帽中显示对应的按键。你也可以用鼠标点击按钮,按住不放即可保持按下状态。Skythread 有模拟摇杆和更多按钮;其操作方式见下方专门章节。
- 打开 Serial 监视器查看游戏日志:一行
STAT显示帧率,每发生一次击杀、升级、拾取和死亡都会有一行EVT。Skythread 每两秒打印自己的一行,包含帧率、每帧绘制和发送所花的时间以及分数。
Velxio 中的任何轻触按键都可以用键盘驱动,不仅限于这些。右键点击一个按钮并使用 Keyboard control 卡片:点击它,按下一个键,此后在仿真运行时按住该键就会按下按钮。
模拟器运行的是为真实芯片编译的固件本身:Velxio 在你的浏览器中逐条指令地模拟 ESP32 和 ESP32-S3,并绘制草图发送到 ILI9341 的任何内容。
掌机

XIAO 只有 11 个 GPIO 引脚,而一块屏幕加六个按钮需要十二个。诀窍在于屏幕不需要它通常的两条线:从来不需要从它读回任何数据,所以 MISO 保持不连接,其复位引脚接到 3.3 V,随开发板一起复位。这样引脚数量就刚好够用。
| XIAO 引脚 | 连接到 | XIAO 引脚 | 连接到 | |
|---|---|---|---|---|
| D8 | 屏幕 SCK | D0 | 十字键 上 | |
| D10 | 屏幕 MOSI | D1 | 十字键 下 | |
| D7 | 屏幕 CS | D2 | 十字键 左 | |
| D9 | 屏幕 D/C | D3 | 十字键 右 | |
| 3V3 | 屏幕 VCC、LED 和 RST | D4 | A | |
| GND | 屏幕 GND、所有按钮 | D5 | B |
每个按钮将其引脚连接到地。草图通过 pinMode(pin, INPUT_PULLUP) 打开芯片的内部上拉电阻,因此按钮松开时引脚读为 HIGH,按下时读为 LOW。无论是在画布上还是在真实的面包板上,都不需要电阻。Doom 在更大的 ESP32 DevKit 上使用同样的思路,但引脚不同(列在其下方章节中)。
为什么选择 XIAO ESP32-S3
四款 XIAO 游戏先把整幅画面绘制到内存中,然后一次性发送到屏幕。一幅 320 x 240 的画面,每像素两字节,就是 153,600 字节,而且必须放在一块连续的内存中。
这正是两款芯片的差异所在。我们在每块芯片上运行草图并打印最大的空闲块:经典 ESP32 上为 110,592 字节,ESP32-S3 上为 278,528 字节。S3 可以容纳整帧还有余量;经典 ESP32 则不行,这就是为什么 Doom 改为逐列绘制画面(详见下文)。
在模拟器中 S3 也是两者中更快的。在两块开发板上运行相同的帧缓冲基准测试,Velxio 的浏览器内引擎运行 ESP32 达到每秒 8100 万到 9000 万条指令,ESP32-S3 达到每秒 9600 万到 1.06 亿条指令,并且 S3 每秒推送 33 个完整帧,而 ESP32 为 31 个。
四款游戏共用一个引擎
四个 XIAO 草图都以相同的 200 行代码开头:引擎。它负责帧缓冲、按钮、若干绘图函数和帧循环。

绘制到 RAM 意味着顺序无关紧要。游戏可以先画天空,再画山丘,再画飞机,最后在上面画爆炸,而屏幕只会看到完成的画面,因此不会闪烁。发送它只需调用 Adafruit ILI9341 库三次:
static void present() {
tft.startWrite();
tft.setAddrWindow(0, 0, SCR_W, SCR_H); // the whole 320 x 240 screen
tft.writePixels(fb, SCR_W * SCR_H); // 153,600 bytes in one burst
tft.endWrite();
}
在 40 MHz 下,这 153,600 字节在 SPI 线上大约需要 31 ms,因此仅传输本身就设定了接近每秒 30 帧的上限。帧的其余部分,即游戏逻辑和绘制,必须挤进剩余的时间里,这就是为什么绘图函数都很简单:填充矩形、线条、圆、使用 Adafruit GFX 自带的 5 x 7 字体的文本,以及精灵。
精灵以文本形式编写,每个字符代表一个像素,. 表示透明像素,其余使用 128 色调色板。这是 Gear Hop 中的机器人:
"........a.......",
"....mmmmmmmm....",
"...mMMMMMMMMm...",
"...mMllMMllMm...", // l = the eyes, light cyan
"...mMllMMllMm...",
"...mMMMMMMMMm...",
"....mmmrrmmm....", // r = the red mouth
这些游戏中的任何内容都不来自图像文件。地图瓦片、纹理和特效都在开发板启动时由代码绘制到 RAM 中,因此每个草图都是纯文本,你可以在编辑器中阅读和修改。
Sky Duel:带飞行模型的空中缠斗
你驾驶一架双翼飞机,飞越一个四屏宽、可环绕的世界:从右边缘飞出,就会从左边缘回来。一支红色中队在追猎你,高射炮和卡车在地面上等待,每一波都会多一架敌机。
怎么玩。 用方向键指向你想飞的方向,包括斜向;飞机会朝那个方向转向。按住 A 发射机枪,轻按 B 投下炸弹(每波六枚)。炸弹是摧毁地面目标的唯一方式。顶部的雷达显示整个世界,因此你能找到屏幕外的飞机。
怎么运作。 飞机并非以固定速度移动。每一帧草图都会运行一个小型飞行模型:

速度会逐渐趋向巡航值,但爬升会损失速度,俯冲会获得速度,且与角度成正比。长时间垂直拉升,飞机就会降到失速速度以下:机头会自行朝地面下坠,你必须俯冲才能重新飞起来。敌方飞行员使用相同的模型、相同的失速,这正是它成为空中缠斗而非打靶游戏的原因。
飞机是一个朝右的 16 x 16 精灵图。启动时,草图将它旋转成 32 个朝向并全部存储,朝左的那些通过镜像生成,这样飞机就不会倒着飞。绘制一架飞机就变成一次查表,在屏幕上放大两倍。敌人会朝你前方的一点转向,当你位于其炮口前方时开火,并在近距离交错后脱离,以免撞上你。
XIAO Quest:一款小型 RPG
一位英雄、一个村庄、一片森林和一个洞穴。喷泉旁的长老告诉你,暗影之王把村庄的水晶带进了古老的洞穴,并给了你打开挡路大门的钥匙。在森林和洞穴中,怪物会袭击你,你与它们进行回合制战斗。
怎么玩。 用方向键行走。A 与人交谈、阅读告示、打开宝箱并确认;B 显示你的属性和物品。喷泉会治疗你,商人以 10 金币出售药水,三个宝箱里装着两瓶药水、一把铁剑和一枚银戒指。在战斗中,用方向键选择并用 A 确认:攻击、施放火焰(3 MP,双倍伤害,无视防御)、喝药水或逃跑。
怎么运作。 游戏是一个小型状态机。每个状态都有自己的输入处理和绘制逻辑,以下是推动它从一个状态转到另一个状态的事件:

三张地图都是 20 x 14 个 16 像素的图块,在草图中以字符串形式书写,每个字符代表一个图块:# 是树,~ 是水,= 是小路,K 是上锁的大门。英雄一次移动一个图块,并在 0.16 秒内于图块之间滑动,因此移动感觉平滑,但碰撞保持简单:一步要么允许,要么不允许。
战斗使用小型整数公式。一次攻击造成你的攻击力加上 0 到 3 的随机值,再减去敌人的防御。经验带来等级,每升一级都会增加生命、魔法、攻击和防御。洞穴是黑暗的:绘制地图后,草图将英雄周围 50 到 70 像素之间的每个像素调暗到一半亮度,将更远处的所有像素调暗到四分之一,因此你随身带着一盏灯笼。逐像素计算距离使帧率从约 27 降到 16。改为每行计算一次,即一个亮区、一个暗区和其余黑暗区,帧率又回到约 22。
Gear Hop:一款平台跳跃游戏
一个小机器人穿过两个关卡收集齿轮。甲虫在地面上行走,无人机在空中漂浮,有些地板布满尖刺,最宽的缺口只能通过移动平台跨越。到达每个关卡尽头的旗帜。
怎么玩。 用 LEFT 和 RIGHT 行走,按住 B 奔跑,按 A 跳跃。按住 A 更久可以跳得更高。落在敌人身上可以踩踏它,从下方跳入 ? 方块可以敲出一个齿轮。你有三颗心;尖刺和敌人各扣一颗,掉进坑里也一样。
怎么运作。 关卡是 128 x 14 个图块,由一个小脚本生成并作为字符串粘贴到草图中。机器人是一个 10 x 14 像素的盒子,每一帧它分两步移动:先横向移动,若最终陷入任何墙内则被推回;然后纵向移动,落在地面、单向木板或移动平台上,或者头部撞到方块。将两个轴分开处理,正是防止机器人粘在墙上或滑过角落的原因。
三条小规则让跳跃手感恰到好处。在落地前最多 0.12 秒内按下的跳跃,会在机器人着地时仍然生效(跳跃缓冲)。在走出平台边缘后最多 0.08 秒内按下的跳跃仍然算数(土狼时间)。提前松开 A 会削减上升速度,这就是按住按钮能跳得更高的原因。两层山丘分别以摄像机速度的五分之一和不到一半滚动,为场景带来纵深感。
Circuit Defense:电路板上的塔防
虫子爬过电路板,朝芯片而去。你在板上搭建电子元件来阻止它们:LED 向一只虫子发射快速激光,电容 向周围所有虫子放电,线圈 则让虫子减速。每只到达芯片的虫子都会让芯片损失一点生命值,而一波波攻势会越来越强。
怎么玩。 用方向键移动光标。B 选择元件,A 建造它,对已建造的元件按 A 可将其升级,最高 3 级。消灭虫子会获得金币,每清完一波还有额外奖励。光标周围的圆圈显示你即将建造的元件的射程。
怎么运作。 虫子并不沿固定路线行进。它们在开阔的板面上行走,而你可以用建造的东西引导它们:板上的铜走线始终是它们会走的路线。其背后是广度优先搜索,每次建造后都会重新运行:

搜索从芯片旁边的格子开始,将它们编号为 0,然后向外扩散:每个已编号格子的空闲邻居都会得到该编号加一。完成后,每个格子都保存了它到芯片的距离,包括塔墙。站在某个格子上的虫子只需走到编号更小的邻居即可。无需为每只虫子单独规划路线:一次搜索,260 个格子,服务所有虫子。
同样的数字让游戏的唯一规则易于执行:你可以延长路径,但绝不能封死它。当你建造时,草图会放下元件,重新运行搜索并查看入口格子。如果它不再有编号,虫子就无法通过:元件会被移除,游戏显示 THAT BLOCKS THE PATH。
ESP32 Doom:第一人称射击游戏
五款游戏中最古老的一款运行在经典的 ESP32 DevKit 上。你走过贴图墙壁的迷宫,射击追逐你的机器人和吐酸液的史莱姆,打开门,拾取医疗包和弹药,并清完一波又一波。
怎么玩。 方向键行走和转向,A 开火(按住可自动开火),B 打开你面前的门,按住 B 同时按左或右可横向移动。引脚与 XIAO 游戏不同:上在 GPIO 32,下 33,左 25,右 15,A 在 22,B 在 21,屏幕接在 ESP32 的 VSPI 总线上(SCK 18、MOSI 23、MISO 19、CS 5、D/C 2、RST 4)。
怎么运作。 Doom 是一个光线投射器,即 Wolfenstein 3D(1992)的技术:对屏幕的每一列,它从玩家向 20 x 20 的网格地图发射一条射线,并在第一堵墙处停止。距离决定墙切片绘制的高度。由于经典 ESP32 没有空间容纳完整帧,草图一次构建一列,放在 200 像素的缓冲区中,地板、天花板、墙壁、敌人、武器和效果都已合成好,然后一次性发送:
机器人径直走向你,每秒用爪击造成 5 点生命值伤害;史莱姆保持距离,吐出造成 7 点伤害的酸液。只要没有更近的墙,射击就会命中准星下最近的敌人。每清完一批敌人,下一波就会多出两只,速度稍快、更强壮一些。
Skythread:在生成的世界中 3D 飞行
Doom 是室内的 3D。Skythread 是室外的 3D:你驾驶一架三角翼滑翔机在开阔乡野上空低飞,沿着河谷、掠过群山、飞越湖泊,地平线处有雾气,天空从白天变为黄金时刻再变为夜晚。圆环悬挂在你前方的空中,而时间在流逝。你一开始有 45 秒,每穿过一个圆环就增加几秒。连续穿过圆环会提升连击,最高可达五倍分数,从圆环正中间穿过可得 PERFECT。错过一个圆环连击就会重置;碰到地面会被弹开,速度变慢并损失三秒。和其他游戏一样,它以一个自动飞行的演示开场。
掌机。 Skythread 是为运行我们的 Metal Gear Solid 和 Castlevania 移植版的掌机编写的,而不是为其他四款游戏所用的方向键布局:一台 Seeed Studio XIAO ESP32-S3 Sense、同样的 ILI9341 屏幕、一个双轴模拟摇杆、两个接在各自引脚上的按键,以及另外六个接在同一个引脚上的按键。在 Velxio 中,示例打开时的布局就像那台掌机:摇杆在左侧,屏幕在中间,按键在右侧和屏幕下方。
| XIAO 引脚 | 连接到 | XIAO 引脚 | 连接到 | |
|---|---|---|---|---|
| D8 | 屏幕 SCK | D0 | 摇杆 X(左右) | |
| D10 | 屏幕 MOSI | D1 | 摇杆 Y(上下) | |
| D9 | 屏幕 MISO | D2 | 六个按键,通过电阻梯形网络 | |
| D3 | 屏幕 CS | D5 | FIRE | |
| D6 | 屏幕 D/C | D7 | BRAKE | |
| D4 | 屏幕 RST | 3V3、GND | 为屏幕、摇杆和梯形网络供电 |
玩法。 用摇杆操控:在画布上点击它并使用方向键,或者用鼠标按住它的某个箭头。向后拉(下)爬升,向前推俯冲,向左或向右倾斜转弯。FIRE(Z)是加速,会消耗蓝色能量条,也可用于开始游戏。BRAKE(X)让你减速以便急转弯。START(Enter)开始和暂停,CROSS(C)也可开始游戏,SELECT(Q)返回演示,CIRCLE(T)切换一天中的时段,TRIANGLE(W)生成新世界,L1(F)显示帧率。
一个引脚上的六个按键。 XIAO 有 11 个引脚,而屏幕、摇杆和八个按键需要 16 个。其中六个按键的诀窍是电阻梯形网络。一个 10 kΩ 电阻将 D2 上拉到 3.3 V,每个按键通过不同的电阻将 D2 接地,因此每个按键形成不同的分压。引脚读数为 3.3 V × R / (R + 10 kΩ):
| 按键 | 通过以下电阻接地 | 预期值 | 在 Velxio 中测得 |
|---|---|---|---|
| CIRCLE | 0 Ω | 0 V | 0 mV |
| CROSS | 2.2 kΩ | 0.595 V | 612 mV |
| TRIANGLE | 4.7 kΩ | 1.055 V | 1065 mV |
| L1 | 10 kΩ | 1.650 V | 1651 mV |
| START | 22 kΩ | 2.269 V | 2258 mV |
| SELECT | 47 kΩ | 2.721 V | 2665 mV |
草图用 analogReadMilliVolts() 读取 D2,并选择电压最接近的按键,阈值取相邻值的中点(300、824、1350、1960、2496 和 2960 mV)。Velxio 将梯形网络作为真实电路求解:最后一列是我们在画布上按下每个按键时草图在串口监视器上打印的值。梯形网络一次只能读取一个按键,因为同时按下两个按键看起来就像一个电阻更小的按键。这就是为什么游戏将飞行时需要按住的 FIRE 和 BRAKE 放在各自的引脚上。它还会在见过一次释放之前忽略梯形网络,因此未连接的 D2 永远不会触发幽灵按键。
工作原理。 世界在开发板启动时生成:一张 512 x 512 的地形图,用菱形方形噪声生成,每个单元格占两个字节,一个表示高度,一个表示调色板中的颜色。再加上一份用于远距离的半尺寸副本和两个帧缓冲区,总共约 940 KB,这就是 Skythread 需要 Sense 及其 PSRAM 的原因。在 Velxio 中,构建世界需要 3 秒的模拟时间,第一帧在上电后 3.4 秒到达屏幕。
绘制使用 1990 年代初体素飞行游戏的列光线投射。对于屏幕的每一列,草图从摄像机出发,在图上由近及远地走一条射线,最多 160 步、340 个单元格远;超过 110 个单元格后改用半尺寸地图。每一步它将该处的地形高度转换为屏幕上的行。如果该行高于该列中已绘制的任何内容,就用该单元格的颜色填充间隙并记住新的顶部:

每列记住的顶部称为 y 缓冲区。近处地面总是先绘制,因此远处地面只能显示在其上方,任何像素都不会被绘制两次。雾由 20 个调色板组成,每个都向天空颜色混合得更多一点,每个对应一个距离带。倾斜是一种剪切:每列的地平线根据其距屏幕中心的距离上下移动,看起来就像翻滚。圆环、滑翔机的阴影和太阳都在各自的距离上与同一个 y 缓冲区进行检测,因此圆环前面的山会遮住它。
帧按列存储,已经采用 ILI9341 期望的字节顺序。在面板自身的纵向寻址中,风景画面的一列就是它的一行,因此整个帧可以一次突发发送出去。借助两个帧缓冲区,芯片的两个核心同时工作:核心 0 上的任务以 40 MHz 通过 SPI 将一帧发送到屏幕,同时核心 1 绘制下一帧。
草图还会监视自己的速度。当绘制一帧平均耗时超过 22 ms 时,它投射 160 条射线而不是 320 条,并将每列绘制为两个像素宽,当低于 7 ms 时恢复全宽。超过 45 ms 时,其注释为慢速主机(例如浏览器模拟器)保留的情况,它停止重叠两个核心,并等待每一帧发送完毕后再绘制下一帧,因此模拟面板始终接收完整的帧。在 Velxio 中它全程以该模式运行,160 列,每帧绘制耗时 33 到 50 ms 的模拟时间。
帧率
我们在服务器的无头 Chrome 浏览器中测量了 velxio.dev 上的每款游戏,每款游玩约一分钟。
| 游戏 | 草图报告值(中位数,范围) | 实时帧率 |
|---|---|---|
| ESP32 Doom | 17.6(13 到 24) | 16.1 |
| Sky Duel | 28.9(23 到 38) | 27.2 |
| XIAO Quest | 32.2(21 到 39) | 30.8 |
| Gear Hop | 27.0(21 到 35) | 23.4 |
| Circuit Defense | 31.3(23 到 40) | 29.4 |
| Skythread | 20.4(16 到 23) | 17.7 |
每款游戏有两个数字。第一个是游戏本身在串口监视器上报告的,来自芯片自己的 millis():模拟开发板的帧率。第二个是浏览器每实时秒实际显示的帧数。当运行模拟的计算机无法让模拟芯片保持全速时,两者会不同;此时模拟时间会比墙上的时钟走得稍慢。你自己的数字取决于你的计算机。
四款 2D XIAO 游戏的帧率接近 Doom 的两倍。部分原因是工作量:光线投射器投射 320 条射线并逐像素计算地板和天花板。部分原因是传输:每帧一次突发而不是 320 次小突发,每次都有自己的地址窗口。Skythread 是唯一绘制 3D 风景的游戏,通过投射 160 条射线而不是 320 条来保持约 20 帧每秒,这是它自己的代码在绘制一帧耗时超过 22 ms 时所做的权衡。
把它们改成你自己的
每款游戏都是一个 sketch,代码按块组织:引擎、美术、游戏状态、更新和绘制。有些改动只需一分钟:
- Sky Duel: 修改
fly()中的巡航速度(125)或失速速度(50),感受飞机有何变化。 - XIAO Quest: 地图就是
MAP_V、MAP_F和MAP_C中的字符串;怪物的数值在FOES表中。 - Gear Hop: 跳跃力度是
movePlayer()中的-335,重力是950;试试月球模式。 - Circuit Defense: 在
TW_NAME、TW_COST和updateTowers()中添加第四种部件。 - Doom:
MAP_SRC是 20 个由 20 位数字组成的字符串;每个数字代表一种墙壁纹理,9 代表门。 - Skythread: sketch 顶部的
WORLD_SEED决定你启动时进入的世界,将PITCH_PULL_TO_CLIMB设为 0 后,向前推摇杆即可爬升。
编辑后按 Run,Velxio 会编译修改后的 sketch,因此你可以改一个数字,几秒钟后就能看到结果。当你满意后,同一个 sketch 可以运行在真实的 XIAO ESP32-S3 上,搭配 ILI9341 和按上述方式接线的六个按钮;而 Skythread 则运行在 XIAO ESP32-S3 Sense 上,使用其章节中介绍的摇杆和按钮。