摘要 我在 ESP-Mosaico 的像素鱼塘里实现了八条锦鲤的巡游、休息和投食响应。行为层每 50 毫秒推进一次,绘制层在相邻状态之间插值。两条鱼在同一步内碰到同一粒鱼食时,固件用步首和步末的鱼嘴距离估算进圈时间。下面以 Pixel Koi 1.3.0 的原生固件为例,拆解这三部分怎样接起来。

一、先确定每一步由谁更新

实机投食演示里,点一下水面,指尖附近会出现几粒鱼食。原来各游各的鱼开始转向,有的先绕一段弧,有的能直接游到鱼食旁边。我要处理的是一组同时发生的动作:触摸改变鱼食状态,鱼根据当前朝向选择怎么游,最后还要决定鱼食归谁。

场景里的逻辑坐标是 384×384,屏幕是 480×480。鱼的位置用整数表示,256 个定点单位对应一个逻辑像素,这样慢速移动时也可以积累不足一像素的位移。触摸点先从屏幕坐标换回逻辑坐标,再根据画面方向转换,最后交给投食函数。下面涉及的鱼嘴距离和鱼食半径,都按逻辑像素来理解。

轻点屏幕 → 换算逻辑坐标 → school_feed() 添加鱼食
                                  ↓
                   school_step() 按 50 ms 推进行为
                                  ↓
                   school_interpolate() 生成绘制状态
                                  ↓
                      绘制鱼、水面和鱼食

pond_scene.c 管触摸、时间和绘制流程,pond_school.h 管鱼群、路线、鱼食和到达判定。行为层持有上一状态 previous 和当前状态 pond,绘制读插值后的 view。投食和归属更新直接发生在行为状态上。

二、巡游用路点,转向和速度按步变化

1. 路点确定方向,速度决定这一小步游多远

八条鱼各有一组六个循环路点,也各有自己的巡游速度。鱼游进当前路点附近,就开始朝下一个点转。这样即使鱼偏离了连接两个路点的直线,也可以继续前进。部分路线安排了屏幕外的点,画面看不到鱼时,行为层仍然会更新它的位置。撒食后,这些鱼也能改变游向,重新游入画面。

每一步先求当前朝向和目标朝向的角度差,再限制转向量,并把弯曲量逐步调整到需要的值。目标几乎在正后方时,代码还会尽量维持已经开始的转向方向,避免前后两步分别选择从不同方向掉头。鱼随后沿更新后的朝向前进,所以连续几次位置更新自然连成一条弧线。

巡游目标速度里叠加了一个缓慢变化的正弦项,不同鱼用的相位也不同。转弯时,school_swim() 还会根据原朝向与目标朝向的误差下调目标速度,再限制本步加减速的幅度。水面上于是同时有慢游、加速和转弯减速,八条鱼也不会在同一时刻齐刷刷地变速。

2. 休息从滑行减速开始

每条鱼有自己的等待计数。计数到头时,鱼要游到离池边足够远的位置才开始休息。此时它沿原方向滑行,弯曲量逐步收回,速度逐步降低。速度降到阈值以下才归零,并开始扣减停留时间。代码给停稳阶段分配 22~47 步,按 50 毫秒步长换算是 1.10~2.35 秒,滑行减速的时间另算。

尾巴由 tail_phase 推进,速度为零时仍保留基础相位增量。因此鱼身停在原地,尾巴仍在摆。找到可追的鱼食后,代码会清掉当前的休息计数,并延后下一次休息,让这条鱼可以接着转向、加速。

三、用插值填满两次行为更新之间的空档

以 50 毫秒推进模拟,相当于每秒安排 20 个行为步。屏幕绘制时可以取两步之间的状态,因此一段移动可以显示出几个中间姿态。下面是 school_interpolate() 中用于鱼身位置和朝向的实际代码摘录。

sample.x = first->x + (last->x - first->x) * fraction / FISH_FRAME_MS;
sample.y = first->y + (last->y - first->y) * fraction / FISH_FRAME_MS;
sample.heading = fish_interpolate_angle(first->heading, last->heading, fraction);
sample.tail_phase = fish_interpolate_angle(first->tail_phase, last->tail_phase, fraction);

fraction 来自 elapsed % FISH_FRAME_MS,单位是毫秒。位置和弯曲量按比例取中间值。朝向和尾摆相位用 0~255 表示一圈,插值函数会先求绕一圈意义下的带符号角差。例如朝向从 252 变成 4 时,会沿经过 0 的短方向插值。直接对 252 和 4 做普通整数插值,中途会得到约 128,画出来就是鱼朝反方向甩了一下。

这套取样把绘制姿态落在前后两个已经算好的状态之间,避免外推。鱼食状态则直接取当前步的结果,方便显示刚撒下的食物和已经被吃到的食物。渲染间隔过长时,advance() 单次最多补算 3 步,剩余旧步会跳过。这里的 20 步/秒是行为更新的目标节拍,显示帧率由屏幕完成刷新来衡量。

四、投食归属要看鱼嘴进圈的时刻

1. 追哪一粒和吃到哪一粒是两个判断

有效轻点会在指尖附近尝试撒下随机的 3~6 粒鱼食。鱼刚开始寻食时,按鱼身位置挑一粒最近且仍可吃的鱼食。一旦选中,就继续追这个目标,直到它被吃掉或槽位已换成另一粒。槽位编号和鱼食 ID 一起校验,连续投食时才能区分同一个位置槽中先后出现的两粒食物。

食物在被接触前可以同时作为几条鱼的目标。追食只决定这条鱼往哪儿转、游多快。判定是否吃到时,程序沿鱼身朝向算出鱼嘴位置,再拿本步移动前后的鱼嘴位置逐一检查可吃的鱼食。因此鱼游向一粒食物时,途中也可能先碰到另一粒。

2. 同一步进圈,比较两个时间比例

设步首距离为 d0,步末距离为 d1,接触阈值为 R,在这里是 11 个逻辑像素。两端都在阈值外,这一步跳过。步首已进入阈值,记作本步第 0 时刻。其余情况下,按距离在一步内线性变化估算时间比例:

步内时间比例 = (d0 - R) / (d0 - d1)
估计进圈时刻 = 步内时间比例 × 50 ms

在这里插入图片描述

图中是一个便于手算的例子。甲鱼从 15 像素游到 7 像素,需要走完这一小步的 (15 - 11) / (15 - 7) = 1/2 才进圈,约在第 25 毫秒。乙鱼从 12 像素游到 8 像素,走到 (12 - 11) / (12 - 8) = 1/4 就进圈,约在第 12.5 毫秒。到步末时甲鱼更近,这粒鱼食仍归先接触的乙鱼。

固件把这个比例换成以 256 为尺度的整数。每条鱼先选本步最早接触的一粒,每粒鱼食再从申请的鱼里选估计到达最早的那条。比例相同就比较步末距离,两项都相同则保留遍历时先记录的候选。等鱼都走完一步后,程序统一提交归属,避免先遍历到的鱼提前把鱼食从后续比较里拿走。被吃到的鱼食进入 PELLET_EATING,颜色变暗、尺寸变小,鱼嘴会往鱼食处微调一小段,吃完后继续找食或恢复巡游。

3. 这一步估算有哪些边界

fish_distance() 用较大的坐标差加较小坐标差的 3/8 来估算距离,斜方向上的接触范围和图中的圆会有出入。时间比例也只根据两个端点的距离计算,遇到步内掠过接触范围、两个端点都在外面的轨迹时,会漏掉这次接触。每条鱼本步只提交一个候选,没赢到这粒的鱼会在后续步里重新行动。对于这套鱼塘交互,这些取舍让判定逻辑保持短小,结果也能用前后两帧的位置解释。

五、我用什么验证这些动作

实机投食片段可以看轻点撒食、鱼转身和围拢。仓库里的 preview.ps1 会用同一份场景代码生成离屏预览,verify.mjs 检查连续两次轻点都能增加 3~6 粒鱼食、八条鱼能同时入画、运行期间有鱼吃到鱼食。图中的 25 和 12.5 毫秒来自示例计算。脚本的检查项覆盖了投食链路和最终结果,双鱼的同一步排序可以单独用上面的两组端点距离复算。

如果在本地单独验证归属逻辑,我会把两条鱼和一粒可用鱼食放进最小场景,记录每条鱼本步的两个端点距离、估算时间和最终归属。除了图中的例子,还可以检查几个容易写错的分支。

输入情况按现有实现预期的结果
一条鱼的鱼嘴在步首已满足接触阈值时间比例为 0,无需求差或除法
两条鱼估计进圈时间一样,步末距离不同步末更近的一条获胜
鱼食已在进食中,或原目标槽位换成了新 ID寻食时重新选目标,已有归属不被其他鱼抢走
一条鱼同一步接触两粒,且最早接触的那粒被另一条抢到当前步只对第一候选参与比较,下一步重新判断

这些是从代码推导出的复核用例。把行为测试放在显示驱动之外,可以先确认鱼食归属,再用实机检查靠近、停留、离开这些动作连在一起是否自然。

鱼游动时,绘制层还需要擦掉上一帧里的旧鱼身,并显示当前姿态。下一篇会沿着这个问题拆解局部刷新,讲清楚鱼游过以后屏幕究竟更新了哪一块。

Logo

宁波官方开源宣传和活动阵地,欢迎各位和我们共建开源生态体系!

更多推荐