「口袋 AI 鱼缸」搬上行空板 K10:国产 ESP32-S3 上跑 1400 万参数的离线虚拟宠物
一个开源项目迁移实录 · 第二版
从 Waveshare AMOLED 开发板搬到 DFRobot 行空板 K10,保留原项目的「AI 大脑」,重做屏幕、按键与音效;并把上线后一整天的真机调试过程——按键丢帧、反色 LCD、扫掠手势几何、繁殖死结、里程碑清单页——完整记录下来。所有结论都有串口日志和目视结果佐证,不是纸上推演。

一、项目起源:什么是 pocket-tank
pocket-tank 是 2026 年一个很出圈的开源嵌入式 AI 项目(作者 mediacutlet,MIT 协议,纯 C 实现)。它的 idea 很迷人:在一块几美元的 ESP32-S3 上,用一个小语言模型当鱼缸里每条鱼的「大脑」,全程不联网。
原项目的核心数据:
| 指标 | 数值 |
|---|---|
| 学生模型 | 14.3M 参数,dim 384,8 层,8 头 |
| 教师模型 | gemma4:26b(约 260 亿参数,知识蒸馏而来) |
| 量化后体积 | fp32 57 MB → 4-bit 7.56 MB(mmap 直读 flash) |
| 词表 | 54 个 token 的闭式 schema |
| 训练样本 | 51,613 条教师标注的「鱼处境」 |
| 真实板决策耗时 | 约 3.7 s / 决策,12 tok/s |
| 渲染帧率 | 25–30 fps(在另一个核上并行) |
| 学生与教师目标一致率 | 72%(教师自洽 82%) |
原作者的中配介绍视频建议先看一遍再读下文:【中配】自制口袋AI鱼缸:ESP32芯片运行14M参数模型,无需联网的虚拟宠物。
它到底怎么「活」的? 项目把代码严格切成三层,都在 common/ 里,PC 模拟器和 ESP32 固件编译同一份源码:
- 反射层(
tank.c):物理、转向、群游、喂食、水草藻类、触摸手势。每帧都跑,永不阻塞模型。 - LLM 顾问层(
llm/):llama2.c 风格的 4-bit 推理引擎 + 词级分词器 + 共享状态编码器,跑在 ESP32-S3 的第二个核上,用 SIMD 点积、量化激活放内部 SRAM,权重 mmap 在 flash 从不拷贝。 - 养成层(
progression.c):生长、新鱼到来、性格漂移、里程碑、存档(断电不丢)。
每条鱼有 5 个驱动(饥饿、能量、压力、好奇、无聊)、3 个性格(胆大、社交、懒惰)、对你的信任分和生命阶段。鱼会每隔几秒把自己的处境写成一行字交给模型,模型补全一个目标(seek_food / follow_friend / inspect_reef / visit_bubbles / explore / rest / dart_play),反射层再把目标变成游动。模型不做兜底启发式——它拿不定主意时,鱼会 visibly 犹豫,而不是被脚本强行接管。这正是它「像活物」的精髓。
演示视频
二、为什么迁移到行空板 K10
原项目官方只支持 Waveshare 家的四块 AMOLED 板(1.8 寸竖屏、1.75 寸圆屏吊坠、2.06 寸手表、2.16 寸方屏),国内买起来贵且慢。我们想验证一件事:这套优雅的三层架构,能不能无痛搬到一个结构完全不同、但同样基于 ESP32-S3 的国产板上?
选 DFRobot 行空板 K10(UNIHIKER-K10) 的理由很直接:
- 同款主控:ESP32-S3-WROOM-1 N16R8——16 MB Flash + 8 MB Octal PSRAM,和原项目要求完全吻合,模型分区和推理内存预算不用改。
- 自带屏幕:2.8 寸 ILI9341 SPI LCD(320×240),不用另购。
- 板载音频:NS4168 I2S 功放 + 喇叭,直接出声,不用像原项目那样折腾 ES8311 编解码器。
- 扩展 IO:TCA9555 / XL9535(I2C GPIO 扩展)挂着 A/B 键、背光、用户 LED,还能从板边引出 GPIO 做自定义按键。
- 便宜好买:DFRobot 渠道现货,对学生党和创客更友好。
硬件清单:
| 物料 | 数量 | 说明 |
|---|---|---|
| 行空板 K10 | 1 | 主控 + 屏 + 喇叭全集成 |
| LED 灯的数字按钮 | 2(可选) | 接 P0 / P1 ;不接也能玩,A/B 板载键已够用 |
| USB Type-C 数据线 | 1 | 供电 + 烧录 + 串口(USB Serial/JTAG) |
| PC(Windows) | 1 | ESP-IDF v5.4.4,装在 D 盘 |
代价也很明确:原项目是 AMOLED + 电容触摸 + 竖屏,K10 是 LCD + 无触摸 + 横屏,音频芯片、按键布局全不一样,而且这块 LCD 初始化里还带了一条 0x21(Display Inversion ON)。这些正是本文要解决的问题,第五章里有两个坑直接来自这条反色命令。

三、架构:如何复用原项目 90% 的代码
pocket-tank 最漂亮的设计是平台无关核心(common/)和硬件端口层分离。迁移几乎不用碰 common/,只要给 K10 写一套硬件适配层。
pocket-tank-k10/
├── CMakeLists.txt # 顶层:EXTRA_COMPONENT_DIRS 指向 tankcore
├── sdkconfig.defaults # K10 板级配置(esp32s3 / 16MB flash / Octal PSRAM)
├── k10env.ps1 # D 盘 ESP-IDF 环境一键初始化
├── key_monitor.py # 串口长监听 + .events 侧车日志(调试主力)
└── main/
├── app_main.c # 主循环:tank_tick → progression_tick → render → 送屏
├── k10_hw.c/.h # 板级事实:LCD 引脚、I2C、TCA9555、按钮、I2S
├── k10_lcd.c # ILI9341 SPI 驱动 + 448×368→320×240 缩放送屏
├── k10_input.c/.h # 100 Hz 独立扫描任务 + 事件队列 + 手势判定
├── audio_port_k10.c # NS4168 I2S 音频端口(新增)
└── Kconfig.projbuild # K10 专属编译开关
关键点:通过 EXTRA_COMPONENT_DIRS 把原项目的 pocket-tank/firmware/components/tankcore 直接挂进来——tank.c、render.c、llm/ 推理引擎、audio.c 混音器全部原样编译进 K10 工程。整个迁移过程中,对 common/ 的改动只有三处用 #ifndef 包起来的条件编译(禁用双击切灯、禁用三连击惊鱼),其余全部集中在 K10 端口层。这就是好架构的红利。
四、迁移中的关键改造
4.1 屏幕适配:逻辑分辨率不动,物理屏缩放
最容易踩的坑是「为了适配屏分辨率去改鱼缸世界」。不能改——模型的训练世界几何(气泡柱、礁石、分区位置)硬编码在 tank.h 里,渲染分辨率一变,模型看到的「世界」就和训练时不一样,鱼的行为会失真。
所以保持原项目的 448×368 landscape 逻辑分辨率(恰好和原 1.8 寸 AMOLED 一致),在 k10_lcd.c 里等比缩放到 320×240 物理屏再走 SPI 刷出去:
/* k10_lcd.c:ILI9341 走 SPI3 @40MHz,MADCTL=0x28(横屏、RGB 顺序) */
spi_bus_config_t bus = {
.mosi_io_num = 21, .sclk_io_num = 12, .cs_io_num = 14,
.dc_io_num = 13, .rst_io_num = -1, // RST 由扩展 IO 管理
.miso_io_num = -1, // LCD 只读不写,MISO 释放给按钮
};
注:原 K10 把 LCD 的 MISO 接在 GPIO2 上,但 LCD 根本不读 MISO。为了把 GPIO2 腾出来做按钮,这里显式改成
-1。
4.2 释放 GPIO:关掉电阻触摸,腾出 P0/P1
板边引出的 P0=GPIO1、P1=GPIO2 是两块完全空闲的全功能 GPIO,但原工程默认开启了 4 线电阻触摸(XPT2046),正好占用 GPIO1(CS)/GPIO2(MISO),而 K10 的触摸屏物理上没接线。于是:
CONFIG_K10_RTOUCH=n,k10_rtouch.c自动退化成空实现;- 如上把 LCD 的 MISO 改成
-1,彻底释放 GPIO2; k10_hw.c把 GPIO1/GPIO2 配成内部上拉输入(低有效),新增k10_hw_p0p1_read();k10_input.c用 P0/P1 替换掉原来的 BOOT 键。
板边接两个LED 灯的数字按钮,按下即触发;不接也完全不影响 A/B 键的所有玩法。
4.3 板载喇叭音效:NS4168 I2S
原项目音频端口是 ES8311(要走 I2C 配置编解码器),K10 直接用板载 NS4168 I2S 功放,更简单——无编解码器、无 I2C 配置,只要推 I2S 数据。引脚经 DFRobot 官方网络收音机示例、K10 原理图、CircuitPython K10 移植三方资料交叉确认:
I2S: BCLK = GPIO0 WS/LRCLK = GPIO38 DOUT = GPIO45 (NS4168 无 MCLK 脚,不接)
这里有个 K10 特有的坑:原理图上 NS4168 的 CTRL 直接接 3V3(1.5V ~ VDD 对应「右声道」),功放硬件常开、固定右声道,没有软件使能脚。所以播放任务把混音器 16 kHz 单声道复制成 L/R 立体声再写 I2S,保证右槽一定有数据:
for (int i = 0; i < BLOCK; i++) {
s_stereo[2 * i] = s_mono[i];
s_stereo[2 * i + 1] = s_mono[i];
}
i2s_channel_write(s_tx, s_stereo, sizeof s_stereo, &w, 100);
勘误:我最早按网上的引脚表把 TCA9555 的 P17(0x80) 当成功放使能位去拉高,实际原理图第 3 页里 P17 是 UserLed,拉高它只是点亮用户 LED,和喇叭毫无关系。这处无效写入后来已删除。如果你也照着旧资料接过,别在这根脚上浪费时间。
样本库 sounds.bin(566 KB,含音效 cue)通过 EMBED_FILES 直接嵌进镜像,复用原 audio.c 混音器调度。实测 bank 里有些 cue 是空的(例如 tap),所以端口层加了回退表:tap → wheel_tick、wipe → snip,保证每个交互都有声音——这一条在后面的调试里帮了大忙。
4.4 视觉重做:淡蓝鱼缸 + 高对比鱼色
想要「淡蓝背景,鱼的颜色和背景区分开」。但这里有个陷阱:原 ROSTER 六条鱼的颜色是为 AMOLED 黑底专门挑的亮色,只把水换成淡蓝,鱼会彻底糊掉。我们写了 palette_check.py 按 WCAG 相对亮度逐段验算,旧色板最差对比度只有 1.02:1(几乎看不见)。
所以水和鱼必须一起改,且用编译开关隔离,不影响原项目:
- 新增
CONFIG_K10_LIGHT_BLUE_WATER(默认 y)→ tankcore 定义PT_LIGHT_BLUE_WATER; render.c的water_rgb()/floor_rgb()改成淡蓝渐变(水面#eef8ff→ 中段#d2ecfb→ 近底#b8def4)+ 浅沙底,porthole 四角暗角从「近全黑」降为约 25% 柔暗;tank.c的ROSTER改成深饱和配色(深青 / 朱红 / 深绿 / 深紫 / 深琥珀 / 玫红);- 新增
tank_reset_looks():存档存的是颜色值而非色板编号,开机刷一次,旧存档才会换装。
验算结果:新色板全水深最差 3.18:1(合格)。原项目 sim / AMOLED 固件不定义该宏,外观原样不变。
4.5 交互重映射
我们把 B键 改绑原项目 tank_touch_drag() 的 WIPE / SNIP 手势——这个手势在 K10 代码里早有 K10_EV_DRAG 枚举和调用分支,却从未被任何按键触发过;音效库里也躺着现成的 SND_WIPE / SND_SNIP。
按下 B 会展开成一次 16 帧的横向扫掠,每帧一个 DRAG 事件(tank.c 要求连续调用才能累积成一次 stroke,所以短按也会完整扫完,不用一直按着),扫完补 2 帧「抬手」让 stroke 正常收尾。后续又按玩法需要把两种工具拆开(见 5.8),最终分工如下:
| 按键 | 手势 | 功能 | 对应原项目交互 |
|---|---|---|---|
| A(板载) | 短按 | 轻点玻璃(左 / 中 / 右三点循环) | tank_touch_tap |
| 长按 > 600 ms | 手指贴玻璃,高信任鱼游来(涨信任) | tank_touch_hold |
|
| 500 ms 内三连击 | 催生:立刻来一尾小鱼 | progression_force_arrival(原为惊鱼) |
|
| B(板载) | 短按 | 只擦玻璃:白色刮水痕 + 整列清藻 | tank_touch_drag 的 WIPE |
| 长按 > 600 ms / 双击 | 只剪草:把草冠剃短 | tank_touch_drag 的 SNIP |
|
| A + B | 同按 0.8 s | 开关里程碑清单页;页内 A 逐项看门槛说明 | render_milestones 整页 |
| P0(外接) | 短按 | 投食 | tank_feed |
| P1(外接) | 短按 | 切换日夜灯光 | tank_toggle_light |
组合键成立时,输入层会把本次 A/B 的单键手势全部标记为「已消费」,看清单时不会顺手把产房草床铲掉。
五、真机调试实录:八轮迭代里的九个问题
第一版固件编译、烧录、串口都漂亮,AI 模型也正常出词,但一上手按键,问题才真正开始。下面按真实时间顺序复盘,每个问题都遵循「现象 → 定位 → 修法 → 验证」四步。
5.0 先说调试方法:别用肉眼猜
整套调试靠三件小工具,成本几乎为零,但省了大半天:
- 长时串口监听 + 侧车日志:
key_monitor.py 3600 <日志路径>后台跑一小时,同时自动写<日志>.events,只保留按键、事件、音效、异常四类行。用户不用守在电脑前,按完回一句话,随时读.events出结论。 - 每个事件都打日志:
KEY A 短按 -> TAP x=112、EVENT DRAG(B)/剪草、B 剪草结束: 16 帧, 藻 X% -> Y%, 草尖 y A -> B, 剪掉草长 N px。给不可见的量找到可观测量,这是后面能纯靠日志定位草没剪到的前提。 - 用 ELF SHA256 认固件:启动日志里的
Compile time会因为esp_app_desc.c.obj没重编而停留在几天前,不能用来判断板子上跑的是不是刚烧的版本。只认ELF file SHA256与本地build/*.elf的哈希前缀一致。
另一个实操细节:监听脚本会占住 COM3,重新烧录前必须先按命令行精确停掉那一个 python 进程,否则
Could not open COM3。
5.1 「按 A、B 键无任何反应」
现象:黑块能看到、声音能听到,但更多时候短按完全没反应;偶尔长按才触发一次。
定位:主循环只有约 10 fps(渲染 60 ms + 送屏 40 ms),每 100 ms 才轮询一次按键,而人手一次短按常常只有 40~80 ms,整个「按下 → 松开」落在两次轮询之间被完整漏掉。另外还有三处叠加问题:notice_dismiss() 吞掉了开场的 TAP、空 cue 导致静音、B 的 stroke 没有抬手帧所以第二次扫掠接不上。
修法:把 A/B(TCA9555 bit-bang I2C)和 P0/P1(GPIO 直读)放进独立 FreeRTOS 任务,100 Hz 采样 + 15 ms 去抖,边沿判定全在任务里完成,结果压进 32 槽环形队列;主循环只管取事件,帧率再低也不丢按键。扫掠固定 16 帧 + 2 帧 NONE 抬手,连按排队上限 2。运行期只有这个任务碰 TCA9555,避免 bit-bang I2C 并发。
验证:600 s 日志里 10 次 A 短按 1:1 产生 KEY A 短按 -> TAP + EVENT TAP(A);B 连按两下出现「先咔咔两声、再咔咔两声」,日志证实是两组完整 16 帧扫掠,中间有抬手帧。
5.2 「A 快按两下,屏幕发白」与「按键后有黑块」
发白:common/tank.c 的 touch_tick() 里,双击会翻转 light_manual_off(原项目的双击切日夜),白天场景整体变亮;而 P1 走 tank_toggle_light() 也在管灯光,两处控制同一件事。修法是新增 K10 编译开关 PT_K10_NO_DBL_TAP_LIGHT,把双击翻灯那一块包进 #ifndef,灯光统一交给 P1;同时在开机时清掉旧存档残留的 light_manual_off,避免老存档让板子永远停在夜晚。sim / AMOLED 不定义该宏,行为原样。
黑块:早期排查按键无反应时加的四角 56×56 反馈块写的是 0xFFFF。但 ILI9341 初始化含 0x21 Display Inversion ON,真机上 0xFFFF 是黑、0x0000 才是白——探针本身变成了黑块。按键链路闭环后直接删掉,即时反馈改由音效、鱼缸自身反应、B 键白色刮水痕和串口日志承担。
这条反色规律后面还救了一次场(见 5.9 的里程碑页)。在 K10 上画任何覆盖层之前,先想清楚「我要的白色其实是
0x0000」。
验证:日志里两次 A 短按间隔约 160 ms(双击窗口内),全程无 EVENT LIGHT,用户目视确认屏幕不发白、黑块消失。
5.3 「B 连按两下只有咔咔两声,没有擦玻璃 / 剪草效果」
定位:输入层其实是对的,问题在效果层,三条根因叠加:
- 扫掠线 y 取「全局最高草尖 + 8」,但起点 x=396 属于更矮的草床,
veg_near_frond()判不到,剪草从不触发; WIPE_RADIUS=20,只擦 stroke 路径附近的一条水平窄带,而藻是从边角成斑块长的,恰好不落在这条带上;- 完全没有任何手势动画,用户看不出「刚才发生了什么」。
修法(全部在 K10 端口层,common/ 一行不动):
- 新增
wipe_trail_mark()/draw_wipe_trail():在帧缓冲上叠贯穿缸高的白色刮水片(竖直亮线 + 身后渐稀水痕 + 泡沫),扫完淡出 14 帧;只用0x0000,不受字节交换影响,反色屏上正确显示为白; - DRAG 分支按起点 x 找所在的那张草床,y 取该床
top_y + 2; - 结束日志打印藻覆盖率、草尖 y、修剪计数,让效果可被客观核对。
改完这一版再实测,立刻暴露出下一层问题:约 15 次扫掠,每次都是 16 帧, 藻 28.4% -> 28.4% (净擦格 +0), 修剪 +1。剪草计数通了,擦藻一格未动。日志里另加的「起手带内原有藻 5 格」直接坐实:tank.c 的 wipe_algae() 只擦 stroke 路径附近 ±20 px 的水平窄带,而藻几乎不在那两行。于是补了 wipe_column_clean(x):按 ALGAE_CELL / COLS / ROWS 把刮水片扫过的整列全高藻格清零,与「贯穿缸高的白色刮水片」视觉一致。
验证:藻 28.4% -> 4.3% (刮片清藻 +155 格),用户目视「白色刮水痕能看到,刮到的地方已没有藻」。
5.4 「两侧未刮的地方还有藻」
定位:wipe_column_clean() 按 x ± 20px 映射列,扫掠 x 从 396 到 56 只覆盖 cx=2..26,28 列里留下 cx=0,1,27 三列。
修法:WIPE_X1 从 56 改成 8,刮水片推到左边缘;再在首帧额外清一次 TANK_W-2 补右边缘。合计覆盖全宽。
验证:藻 16.9% -> 0.0% (刮片清藻 +126 格),第二次按 B 保持 0.0%。用户一句「干净了」,WIPE 链路闭环。
5.5 「但没有剪水草」——有计数、没变化
定位:这是最有意思的一个。日志里 修剪 +1 说明 veg_cut() 确实被调用了,但肉眼完全看不出草变矮。翻 common/tank.c 的判定:
float hf = (TANK_BOT - 16 - cy) / ((VEG_SEGS_FULL - 1) * VEG_SEG_PX);
if (hf >= t->veg_h[b][i]) continue; /* the stroke passed above the tip */
K10 默认 TANK_BOT=368、VEG_SEG_PX=3.2、VEG_SEGS_FULL=107。实测草尖 btop≈76(veg_h≈0.79),扫掠 y=78 → hf≈0.808 >= 0.79,整刀被判成「从草尖上方划过」直接跳过。y=81 时只有个别略高的草被削掉几个像素,所以计数 +1 但看不出来。
顺带更正一个常见误读:veg_near_frond() 判断的是 y >= tip - up(y 更大、更深同样满足),不是「必须贴着草尖」。所以完全可以把刀口往下压而不丢 arm。
修法:新增 WIPE_CUT_DEPTH 70.0f,起手算出 btop+2 后再 y += 70,并 clamp 到 TANK_BOT-24。y=148 时 hf≈0.60 < 0.79,一次扫掠就把经过的草剪到该高度,连按逐层剃短。结束日志新增 草尖 y X -> Y 和 剪掉草长 N px(trims 每 stroke 最多 +1,看不出剪了多少根,这个量才能反映覆盖面)。
5.6 「草冠左边剪到底了,最右边两根没动过」
定位:纯几何问题,靠代码推导 + 用户目视对齐。K10 默认 TANK_W=448、TANK_FX1=448,veg_bed_base() 里 bed1 的 bx0 = 448 × 0.84 = 376.32,veg_pitch=12,被右边界裁成 5 根 frond:
376.32 / 388.32 / 400.32 / 412.32 / 424.32
veg_cut() 的 [lo,hi] 取 stroke 相邻两帧的 x 范围,左行时 lo -= 8,只有首次落刀才 hi += 8。原起点 WIPE_X0=396 → 最大只到 404,所以 412 和 424 这两根从未被任何线段扫过——和用户看到的「最右边两根没动」完全吻合。
修法:WIPE_X0 改成 432(= 最右 frond 424 + 8)。|432 − 424| = 8 ≤ SLASH_START_SIDE_PX(16) 仍能 arm,落刀 hi=440 覆盖全部 5 根;WIPE_X1=8 不变,列清藻与刮水痕同步右移,全宽覆盖不受影响。
验证:一小时的监听里 6 次 B 扫掠,全缸最高草尖 y 逐次
66 → 127 → 182 → 242 → 306 → 332 → 332
剪掉草长 622 / 449 / 695 / 627 / 230 / 0 px(累计 2623 px)
关键在于 highest_veg_top() 取的是全缸最高的一根 frond:只要 bed1 的 412/424 保持原高,这个值就会恒为 66 不动。它降到了 332(只剩 5 段),证明最右两根确实被剪到。全程 10.7–12.1 fps,无 error / panic。用户确认「没有问题」。
5.7 「这两只鱼,什么时候生小鱼?」——一个被交互设计卡死的机制
定位:翻 common/progression.c 才发现,繁殖根本不是计时器,而是 5 项照料门槛 + 产房。两条鱼时要求:TRUST ≥ 6.0、MEALS ≥ 12、HOLD ≥ 1 次、GLASS 藻覆盖 ≤ 15%,外加 GRASS 某张草床 veg_growth ≥ 12%(产房)。全部满足后才进入 8~20 s 等待 → 10 s 产卵舞 → do_arrival() 在草床里生小鱼。
K10 上暴露出三个移植层问题:
- 进度不可见:原项目靠触摸 UI 的里程碑页展示这 5 项,K10 没有触摸,
progression_next_fry()从未被调用,屏幕和串口都看不到「还差什么」; - 开机预置薄藻把门槛堵死:旧逻辑只要藻覆盖 < 8% 就补 140 步,实测补到 17~20%,直接超过
ALGAE_DIRTY=15%;且每次重启都会把玩家擦干净的玻璃重新弄脏,草长回产房要 3.8 h,期间藻还会自然涨约 9%,GLASS 门槛永远不可能满足; - 交互死结:繁殖要「玻璃干净」+「草够高」,但当时 B 键一次按下同时清藻和剪草——擦玻璃必然把产房剪秃,想生小鱼得 4 h 不按 B,而玻璃长了藻又得按 B。
修法(分两步):
- 新增只读
fry_progress_log():开机打一次、每 60 s 状态变化才打、投食 / 轻点后节流刷新,把 5 项清单和产房草床预计恢复小时数打到串口,不改世界状态;预置薄藻改成只在全新缸(cells_cleaned == 0 && trims == 0,均为存档字段)补 40 步(约 5~6%,低于门槛),老缸一律不补。 - 按玩法需求拆开 B:短按只擦玻璃(整列清藻 + 白色刮水痕,不调
tank_touch_drag());长按 / 双击才剪草(只调tank_touch_drag(),不清藻)。输入层用状态机判手势:按下沿判双击(350 ms 窗口)、按住 ≥ 600 ms 判长按、释放后等窗口无第二击才判单击,用out.cut区分两种 DRAG。同时把 A 三连击从「惊鱼」改成「催生」(progression_force_arrival()+ 清 K10 不存在的欢迎页残留),common/tank.c里原三连击tank_startle()用#ifndef PT_K10_TRIPLE_TAP_ARRIVAL包住。
开机实测清单(真实存档):
繁殖: 下一尾小鱼 2/5 项达标 | 排队=0 求偶=0 产卵 0.0/10s | 3 条鱼 | 最高草床 3.5% / 需 12%
TRUST 1.7/6.0 (未达成) MEALS DONE HOLD NOT YET GLASS OK (0%) GRASS 30% THERE
产房草床约还需 3.8 小时开机时间
验证:日志里 B 短按 擦玻璃 x=432 y=184 藻=0.9%、B 长按 剪草 x=432 y=344 草床=1 藻=0.0%,两者互不干扰;A 三连击后清单从 2 条鱼 变成 3 条鱼。死结解开。
5.8 「如何实现显示里程碑清单页」

思路:不重画 UI,直接复用 common/render.c 已有的 render_milestones() 整页。关键前提是尺寸巧合——render.h 的 PAGE_W=448 / PAGE_H=368 与 K10 的帧缓冲完全相同,K10 又没开 TANK_ROUND,所以 PAGE_X=PAGE_Y=0,页面坐标就是帧缓冲坐标,一行调用就能画出鱼卡、成长条、徽章和 NEW FRY 那行 5 个门槛:
render_milestones(&s_tank, s_fb, TANK_W);
#if K10_PAGE_INVERT
/* ILI9341 带 0x21 反色:页面原稿是深底浅字,送屏前整帧取反抵消 */
for (size_t i = 0; i < (size_t)TANK_W * TANK_H; i++) s_fb[i] = ~s_fb[i];
#endif
k10_lcd_flush(s_fb);
三个细节:
- 不用
0x20INVOFF:那会连鱼缸一起翻、切页闪烁;软件整帧取反只动页面帧,鱼缸帧一行不改。取反与PT_FB_SWAP的字节交换可交换,顺序无关。若真机上深浅颠倒,把K10_PAGE_INVERT改成 0 重编即可。 - 无触摸屏怎么点:
render_milestones_tap(t, x, y)要精确坐标,于是把「页内 A 单击」映射成顺序点开第 k 个门槛徽章,x = PAGE_X + MSP_BADGE_X0 + k*MSP_BADGE_DX + 8、y = PAGE_Y + MSP_ROW_Y0 + n_fish*MSP_ROW_H + 10;每次先render_milestones_leave()关掉上一个 modal。A+B 0.8 s 开关页面,组合成立时抑制两键单键手势。 - 世界照常推进:页内置
ui_cover=true并吞掉 TAP/HOLD/FEED/DRAG/ARRIVAL,但tank_tick()继续跑;5 分钟无操作自动退回鱼缸。
验证:编译后镜像 0x11eaf0 = 1,174,256 B(页面图标 / 鱼预览首次链接,比上版大约 71 KB,分区仍空 72%);ELF SHA fc3f5ca9e… 与本地一致,帧率 11.8 fps。串口侧车日志里两次 KEY A+B 组合键 -> PAGE / EVENT PAGE(A+B)(一次开、一次关),中间 20 多次 EVENT TAP(A) 逐项翻门槛说明,无 error / panic。
5.9 九个问题一张表
| # | 现象 | 根因 | 修法要点 |
|---|---|---|---|
| 1 | A/B 按了没反应 | 10 fps 主循环漏 40~80 ms 短按 | 100 Hz 独立扫描 + 事件队列 + 抬手帧 |
| 2 | A 双击屏幕发白 | 双击切灯与 P1 功能重叠 | 编译开关禁用,灯光归 P1 |
| 3 | 四角黑块 | 0x21 反色下 0xFFFF 显黑 |
删除探针,改用音效 / 刮水痕反馈 |
| 4 | B 只有咔咔声 | 刀口没对准草床 + 藻只擦窄带 + 无动画 | 白色刮水痕 + 按床对齐 + 整列清藻 |
| 5 | 两侧残留藻 | 扫掠 x 只覆盖 28 列中的 25 列 | WIPE_X1=8 + 首帧右边缘补清 |
| 6 | 有修剪计数但草没矮 | veg_cut() 判定「从草尖上方划过」 |
WIPE_CUT_DEPTH=70 把刀口压进草冠 |
| 7 | 最右两根草不动 | 起点 396 的落刀上限只有 404 | WIPE_X0=432,覆盖 412 / 424 两根 |
| 8 | 鱼迟迟不生小鱼 | 预置藻堵死门槛 + B 同时擦藻剪草 | 只全新缸补藻、进度日志、拆 B、三连击催生 |
| 9 | 看不到里程碑清单 | K10 无触摸 UI | 复用整页 + 整帧反色补偿 + 坐标模拟点击 |
回头看,九个问题里只有一个(第 8 个的繁殖机制)属于「逻辑设计」,其余全是平台差异:帧率、颜色极性、手势几何、无触摸屏。这也是移植类项目最真实的工作量分布——架构迁移往往只占两成,剩下八成是把「在原板上理所当然的事」重新验证一遍。
六、效果与验收

全链路自动化跑通:编译 → 烧录(COM3)→ 串口验收,串口日志零 error、零 panic。最终版关键日志节选:
k10_hw: bit-bang I2C SDA=... SCL=..., TCA9555 @0x20
k10_hw: 按键脚规范化: CFG0 ... -> ... (P00 背光输出、P02/P14 按键输入)
k10_lcd: ILI9341 ready: SPI3 @40 MHz ... MADCTL=0x28 ← 屏正常,GPIO2 已不是 MISO
audio-k10: NS4168 CTRL 硬件接 3V3 常开(右声道),无需软件使能
audio-k10: 19/26 cues, bank 566 KB 已映射, 音量 2 (BCLK=0 WS=38 DOUT=45)
pt-k10: 繁殖: 下一尾小鱼 2/5 项达标 | 3 条鱼 | 最高草床 3.5% / 需 12%
advisor: model dim 384 ... → seek_food urgency 6 p=0.91 [4347 ms, 10.1 tok/s] ← AI 在跑
30 帧: 渲染 43.7 ms, 送屏 38.6 ms, 实测 11.8 fps, PSRAM 余 4987 KB
几条最有说服力的实测证据:
KEY B 短按 -> 请求擦玻璃扫掠 EVENT DRAG(B)/擦玻璃 藻 16.9% -> 0.0% (刮片清藻 +126 格)
KEY B 长按 -> 请求剪草扫掠 EVENT DRAG(B)/剪草 草尖 y 66 -> 332,剪掉草长累计 2623 px
KEY A 三连击 -> 催生 EVENT ARRIVAL(A) 2 条鱼 -> 3 条鱼
KEY A+B 组合键 -> PAGE EVENT PAGE(A+B) 里程碑清单页开 / 关
K10 实测约 11–12 fps(原项目 25–30 fps),差距来自 ILI9341 是 SPI 接口(40 MHz 刷整屏比 AMOLED 并口慢)且多了一次缩放;但 core0 渲染与 core1 跑 LLM 错峰,AI 决策 10.1 tok/s、PSRAM 余近 5 MB,体验完全可用。
七、如何复现
环境准备(Windows,IDF 装在 D 盘):
# k10env.ps1 内容核心
$env:IDF_TOOLS_PATH = "D:\Espressif"
$env:IDF_PATH = "D:\Espressif\frameworks\esp-idf-v5.4.4"
$env:IDF_PYTHON_ENV_PATH = "D:\Espressif\python_env\idf5.4_py3.11_env"
$env:PATH = "$env:IDF_PYTHON_ENV_PATH\Scripts;" + $env:PATH
. "D:\Espressif\frameworks\esp-idf-v5.4.4\export.ps1"
我中间还重装了系统,重装后的环境:C 盘那份 ESP-IDF 随系统没了,但 D 盘的
Espressif(框架 v5.4.4 + tools + python_env)完好保留,只需重新初始化环境变量即可,省下数 GB 下载。idf.py --version出ESP-IDF v5.4.4即成功。
构建与烧录:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
. "E:\xiangmu\pocket-tank-k10\k10env.ps1"
python "$env:IDF_PATH\tools\idf.py" build
python "$env:IDF_PATH\tools\idf.py" -p COM3 -b 460800 flash
python "$env:IDF_PATH\tools\idf.py" -p COM3 monitor
调试监听(可选,但强烈建议):
python key_monitor.py 3600 key_log.txt # 监听 1 小时,同时生成 key_log.txt.events
硬件接线:板边 P0(GPIO1) / P1(GPIO2) 各接一个轻触开关到 GND(内部已上拉)。两个外接键都是可选的,本文所有 A/B 玩法不依赖它们;喇叭板载,无需接线。
改动文件清单:
- 复用 / 几乎未改:
common/tank.c、common/render.c、common/progression.c、common/llm/*、common/audio.c、sounds.bin - 条件编译(K10 专属宏):
tank.c的双击切灯、三连击惊鱼;render.c的淡蓝水与鱼色板 - 板级:
main/k10_hw.c/.h、main/k10_lcd.c、main/k10_input.c/.h、main/app_main.c、main/audio_port_k10.c - 配置:
sdkconfig.defaults、main/Kconfig.projbuild、tankcoreCMakeLists.txt(注入PT_LIGHT_BLUE_WATER、PT_K10_NO_DBL_TAP_LIGHT、PT_K10_TRIPLE_TAP_ARRIVAL) - 验证脚本:
palette_check.py(对比度)、k10env.ps1、key_monitor.py/cap_log.py(串口)、capture_cam.py(屏幕拍照)
八、踩坑速查
- 重装系统后 IDF 去哪了:优先去 D 盘 / 非系统盘找
Espressif,别急着重装。 - COM3 设备错误 31「设备未就绪」:不是端口被占,而是 USB Serial/JTAG 进入 wedged 状态。用 PowerShell 的
Disable-PnpDevice/Enable-PnpDevice对 COM3 做一次禁用→启用循环即恢复;若端口被监听脚本占着,按命令行精确结束那一个 python 进程。 - BOOT 键让位给 I2S 时钟:GPIO0 运行期是 I2S BCLK,别在运行期按它(烧录用的是复位后 ROM 采样 GPIO0,照常能烧)。
- Compile time 不可信:认
ELF file SHA256,别认编译时间戳。 - K10 的颜色极性:
0xFFFF→ 黑,0x0000→ 白。画覆盖层前先确认。 - 功放没有软件使能脚:P17 是 UserLed,别再拉它。
九、总结与展望
这次迁移证明了两件事。
第一,pocket-tank 精良的三层架构确实有「可移植性」这个隐藏属性:把 common/ 当黑盒,只重写薄薄一层硬件端口(屏幕、按键、音频),就能把一个为 Waveshare AMOLED 设计的项目搬到结构完全相反的国产 LCD 板上,AI 大脑一行不改照常思考。
第二,「编译通过 + 串口无报错」离「好玩」还很远。九个真机问题里,没有一个是编译器能告诉你的:短按被帧率吃掉、白色其实是黑色、刀口从草尖上方划过、最右两根草的 x 坐标差了 28 px、玩家的一次擦玻璃把繁殖条件永久剪秃。能把它们一个个揪出来,靠的不是更聪明的猜测,而是把每个不可见的量都变成串口里可核对的数字(藻格数、草尖 y、剪掉草长、鱼的数量、ELF SHA),再加上用户一句句朴素的目视反馈:「干净了」「但没有剪水草」「最右边两根就没剪动过」。
如果你也在玩行空板 K10 或想给口袋鱼缸换个「家」,欢迎按上面的步骤复现,有问题随时交流。
参考资料
- 原项目仓库:https://github.com/mediacutlet/pocket-tank
- 中配介绍视频(B站):https://b23.tv/YUYyyEM
- Circuit Digest 报道:https://circuitdigest.com/news/open-source-pocket-tank-runs-a-14-3m-parameter-ai-model-on-an-esp32-s3-to-give-virtual-fish-their-own-brains
- DFRobot 行空板 K10 文档与 K10-IDF-Examples
- 本文涉及的串口日志:
key_log_*.txt/key_log_*.txt.events(按轮次命名:ondemand/wipe/col/fullwidth/cut/rightfrond/fry/split/page)






