「口袋 AI 鱼缸」搬上行空板 K10:国产 ESP32-S3 上跑 1400 万参数的离线虚拟宠物

2026-11-066

一个开源项目迁移实录 · 第二版

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

「口袋 AI 鱼缸」搬上行空板 K10:国产 ESP32-S3 上跑 1400 万参数的离线虚拟宠物_image_3.webp

一、项目起源:什么是 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 固件编译同一份源码:

  1. 反射层(tank.c):物理、转向、群游、喂食、水草藻类、触摸手势。每帧都跑,永不阻塞模型。
  2. LLM 顾问层(llm/):llama2.c 风格的 4-bit 推理引擎 + 词级分词器 + 共享状态编码器,跑在 ESP32-S3 的第二个核上,用 SIMD 点积、量化激活放内部 SRAM,权重 mmap 在 flash 从不拷贝。
  3. 养成层(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)。这些正是本文要解决的问题,第五章里有两个坑直接来自这条反色命令。
「口袋 AI 鱼缸」搬上行空板 K10:国产 ESP32-S3 上跑 1400 万参数的离线虚拟宠物_image_4.webp

三、架构:如何复用原项目 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 的触摸屏物理上没接线。于是:

  1. CONFIG_K10_RTOUCH=n,k10_rtouch.c 自动退化成空实现;
  2. 如上把 LCD 的 MISO 改成 -1,彻底释放 GPIO2;
  3. k10_hw.c 把 GPIO1/GPIO2 配成内部上拉输入(低有效),新增 k10_hw_p0p1_read();
  4. 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 先说调试方法:别用肉眼猜

整套调试靠三件小工具,成本几乎为零,但省了大半天:

  1. 长时串口监听 + 侧车日志:key_monitor.py 3600 <日志路径> 后台跑一小时,同时自动写 <日志>.events,只保留按键、事件、音效、异常四类行。用户不用守在电脑前,按完回一句话,随时读 .events 出结论。
  2. 每个事件都打日志:KEY A 短按 -> TAP x=112、EVENT DRAG(B)/剪草、B 剪草结束: 16 帧, 藻 X% -> Y%, 草尖 y A -> B, 剪掉草长 N px。给不可见的量找到可观测量,这是后面能纯靠日志定位草没剪到的前提。
  3. 用 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 连按两下只有咔咔两声,没有擦玻璃 / 剪草效果」

定位:输入层其实是对的,问题在效果层,三条根因叠加:

  1. 扫掠线 y 取「全局最高草尖 + 8」,但起点 x=396 属于更矮的草床,veg_near_frond() 判不到,剪草从不触发;
  2. WIPE_RADIUS=20,只擦 stroke 路径附近的一条水平窄带,而藻是从边角成斑块长的,恰好不落在这条带上;
  3. 完全没有任何手势动画,用户看不出「刚才发生了什么」。

修法(全部在 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 上暴露出三个移植层问题:

  1. 进度不可见:原项目靠触摸 UI 的里程碑页展示这 5 项,K10 没有触摸,progression_next_fry() 从未被调用,屏幕和串口都看不到「还差什么」;
  2. 开机预置薄藻把门槛堵死:旧逻辑只要藻覆盖 < 8% 就补 140 步,实测补到 17~20%,直接超过 ALGAE_DIRTY=15%;且每次重启都会把玩家擦干净的玻璃重新弄脏,草长回产房要 3.8 h,期间藻还会自然涨约 9%,GLASS 门槛永远不可能满足;
  3. 交互死结:繁殖要「玻璃干净」+「草够高」,但当时 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 「如何实现显示里程碑清单页」

「口袋 AI 鱼缸」搬上行空板 K10:国产 ESP32-S3 上跑 1400 万参数的离线虚拟宠物_image_5.webp

思路:不重画 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);

三个细节:

  • 不用 0x20 INVOFF:那会连鱼缸一起翻、切页闪烁;软件整帧取反只动页面帧,鱼缸帧一行不改。取反与 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 个的繁殖机制)属于「逻辑设计」,其余全是平台差异:帧率、颜色极性、手势几何、无触摸屏。这也是移植类项目最真实的工作量分布——架构迁移往往只占两成,剩下八成是把「在原板上理所当然的事」重新验证一遍。

六、效果与验收

「口袋 AI 鱼缸」搬上行空板 K10:国产 ESP32-S3 上跑 1400 万参数的离线虚拟宠物_image_6.webp

全链路自动化跑通:编译 → 烧录(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、tankcore CMakeLists.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 或想给口袋鱼缸换个「家」,欢迎按上面的步骤复现,有问题随时交流。


参考资料

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -