【TIS-100 攻略】TIS-NET 第 12 关:测试图 3

本文首发于 B 站《TIS-100》文集(https://www.bilibili.com/read/readlist/rl626023)。原创不易,转载请注明出处。
TIS-NET 第 12 关《测试图 3》(Image Pattern 3)关卡展示

回字有四种写法,你知道吗?本关要画的回字就是其中一种写法哦~
开个玩笑。本关要画的回字其实是由 10 条水平线和 8 条垂直线构成的。我们分两步来完成,第一步把所有的水平线画出来,这些水平线依次为:
以 (0, 0) 起始,长度为 30 的水平线;
以 (2, 2) 起始,长度为 26 的水平线;
以 (4, 4) 起始,长度为 22 的水平线;
以 (6, 6) 起始,长度为 18 的水平线;
以 (8, 8) 起始,长度为 14 的水平线;
以 (8, 9) 起始,长度为 14 的水平线;
以 (6, 11) 起始,长度为 18 的水平线;
以 (4, 13) 起始,长度为 22 的水平线;
以 (2, 15) 起始,长度为 26 的水平线;
以 (0, 17) 起始,长度为 30 的水平线。
水平线的长度 w 和起始点的 x 坐标呈现这样一种对应关系:

因此我们将一个节点配置为提供起始 (x, y) 坐标的无限流,画图节点向 image 模块传出起始 (x, y) 后,根据 x 的值计算出 w 的值,并发送 w 次 3(白色)后,发送一次 -1,即可完成一条水平线的绘制。画水平线的代码如下:

中央节点提供的是水平线起始坐标 (x, y) 的无限流,前 10 行很好理解,依次提供了 (0, 17)、(2, 15)、(4, 13)、(6, 11)、(8, 9) 这几个起始点(mov 0 down, mov 17 down, mov 2 down, mov 15 down, mov 4 down, mov 13 down, mov 6 down, mov 11 down, mov 8 down, mov 9 down)。第 11~15 行则提供的是 (8, 8)、(6, 6)、(4, 4)、(2, 2)、(0, 0) 这几个起始点:首先将 acc 置为初始值 8(mov 8 acc),然后每向下发送两次 acc(mov acc down, mov acc down),就将 acc 减去 2 后(sub 2)跳回到第 12 行继续发(jmp c)。当 acc 减到 -2 后,下方的画图节点会发现自己收到了一个负数,就会停止画水平线。然后看下方的画图节点:
下方的画图节点首先从中央节点接收一个起始 x 坐标(mov up acc),
如果收到的是负数,说明水平线画完了,跳到第 13 行(jlz d)。
收到的不是负数时,将起始 x 坐标(mov acc down)
和接下来收到的起始 y 坐标一并发给下方的 image 输出(mov up down)。
接下来我们根据 x 来计算 w:因为 w = 30 - 2x,所以我们首先将 x 乘以 -2(add acc)
(neg)
然后再加上 30(add 30),便得到了 w 的值。
第 8~10 行的循环是为了给下方发送 w 次 3(白色)的(mov 3 down)
(sub 1)
(jnz 8)
发完 w 次 3 后,发送一个 -1 停止绘制本条水平线(mov -1 down),
然后跳回第一行(jmp 1),接收下一组起始 (x, y)。
直到收到的起始 x 变为负数后,跳到第 13 行,准备执行后续的画垂直线的任务。
点击左下角的【RUN】,检查阶段性成果:

很好,所有水平线都画出来了。接下来我们进入第二步:画垂直线。这些垂直线依次为:
以 (0, 1) 起始,长度为 16 的垂直线;
以 (2, 3) 起始,长度为 12 的垂直线;
以 (4, 5) 起始,长度为 8 的垂直线;
以 (6, 7) 起始,长度为 4 的垂直线;
以 (23, 7) 起始,长度为 4 的垂直线;
以 (25, 5) 起始,长度为 8 的垂直线;
以 (27, 3) 起始,长度为 12 的垂直线;
以 (29, 1) 起始,长度为 16 的垂直线。
垂直线的高度 h 和起始点的 y 坐标呈现这样一种对应关系:

和画水平线不同,垂直线不能像水平线一样靠连续提供颜色值的方法来画,只能一个像素一个像素画。因此每画一点都要确定实时的 (x, y) 坐标,不能像画水平线那样,只要知道起始的 (x, y) 坐标就 OK 了。
绘制垂直线时,需要两个节点配合完成:A 节点用于将起始 (x, y) 坐标发给 B 节点,然后将高度值 h 存到自己的 acc 里。B 节点收到起始 (x, y) 坐标后,先依次向 image 发送 x, y, 3, -1 这四个值后,再将 y 加上 1,并听从 A 节点的指令。由于刚才已经画了一个点,还需要继续画 h-1 个点才能将垂直线的所有 h 个点画完,因此 A 节点给 B 节点发送 h-1 次【继续】命令,最后发送 1 次【终止】命令,我们就成功画出了一条垂直线。
原理就是这么简单,具体到代码上,则如下:

首先我们看 image 上方的节点,第 12 行和第 13 行的代码变成了 mov left down, jmp d。由于这个节点已经用掉了大量的代码空间来画水平线,因此垂直线已经无法再继续亲自画,只能委托左边节点来画。接下来,它将左下角节点发来的所有数字都无脑传到 image 中(mov left down, jmp d),也就是说,接下来左下角的节点变成了事实上的绘图节点。
左上角的节点被配置成了提供垂直线的起始 (x, y) 坐标的无限流。只不过这个流做了微调,先提供的是 y 坐标,后提供的是 x 坐标。至于为什么要这样提供,我们后面会说到。
它的前 8 行代码很好理解,依次提供了 (29, 1)、(27, 3)、(25, 5)、(23, 7) 这几个起始点(mov 1 down, mov 29 down, mov 3 down, mov 27 down, mov 5 down, mov 25 down, mov 7 down, mov 23 down)。
第 9~12 行代码提供的则是 (6, 7)、(4, 5)、(2, 3)、(0, 1) 这几个起始点。我们发现,如果这个节点还按常规方法先提供 x,后提供 y 的话,那么提供的数字依次是 6、7、4、5、2、3、0、1,增量按照 1、-3、1、-3 的方式循环,代码如下:
而如果先提供 y,后提供 x 的话,提供的数字就变成了 7、6、5、4、3、2、1、0,增量恒定为 -1,代码如下:
使用后者的提供方式,可以省去 2 行代码行数。这就是为什么这个节点我们先提供 y,再提供 x 的原因。左上角节点的 9~12 行代码,和以上列出来的第二个代码块完全一致。
中间靠左的节点(A 节点)和左下角节点(B 节点)配合画垂直线。
A 节点首先从上方收取起始 y 坐标,先放到 acc 里暂存,用于将来计算 h(mov up acc),
再复制一份发给 B 节点(mov acc down)。
上方提供的 x 坐标由于自己用不着,所以直接传给 B 节点(mov up down)。
因为 h = 18 - 2y,所以我们首先将 y 乘以 -2(add acc)
(neg)
然后再加上 18,便可得到 h 的值。但是注意到,这里我们的代码是 add 17。我前不久提了这样一句话:由于刚才已经画了一个点,还需要继续画 h-1 个点才能将垂直线的所有 h 个点画完,因此 A 节点给 B 节点发送 h-1 次【继续】命令,最后发送 1 次【终止】命令,我们就成功画出了一条垂直线。因此我们的 acc 需要的是 h-1 的值,而不是 h 的值。因为 h-1 = 18 - 2y - 1 = 17 - 2y,所以我们这里加上的值是 17。
接下来的第 7~10 行代码用于给 B 节点发送 h-1 次【继续】(mov -7 down)
(sub 1)
(jnz 7)
和 1 次【终止】(mov -10 down)。
现在来看 B 节点。B 节点已经是事实上的绘图节点,它右侧的节点因为会将所有信号原封不动地传给 image,所以干脆直接将右方视为 image。
它会首先从 A 节点处收到一条垂直线的起始 (x, y) 坐标,我们将先发来的 y 坐标放入 bak(mov up acc)
(swp)
将后发来的 x 坐标放入 acc(mov up acc),接下来就开始绘制垂直线。
我们依次给 image 发送 x(mov acc right)
y(swp)
(mov acc right)
3(mov 3 right)
-1(mov -1 right)这四个值,
再将 y 加上 1(add 1)
(swp)
然后听从 A 节点的指令(jro up)。A 节点发送表示【继续】的 -7 时,我们向上跳 7 行,跳回到第 4 行继续发送新的 x, y, 3, -1;A 节点发送表示【终止】的 -10 时,我们向上跳 10 行,跳回到第 1 行,重新从 A 节点接收下一条垂直线的起始 (x, y) 坐标。
如此,垂直线便也绘制完成了。点击左下角的【RUN】,观察输出结果:

非常完美!以上是只差最后一个点的样子。因为游戏机制的原因,和答案完全对上后,程序就会自动跳到下一个样例,所以和答案一字不差的图是截不出来的。继续运行程序,直到弹出结算界面:

优化运行速度
我们将代码进行微调,即可【加量不加价】,在不增加代码行数的前提下提高运行速度。注意这个公式:

它也可以写成

画水平线时,我们可以将循环次数改为 15-x,每次循环改为发送两个 3(白色)。这样做有两个好处:
①x 只需要变为 -x,不需要变为 -2x,可以省去一次乘以 2 的运算;
②循环次数减少了一半,可以节省一半的判断次数。

以上选中的部分即为本次改动的部分。另外左下角的第 7~10 行代码可以适当调整顺序:

将 add 1 和 swp 这两条自己做事的指令分别插到了 mov 3 right 和 mov -1 right 这两条传话指令的前面。因为右下角的节点将发来的值传给 image 后(mov left down),需要耗费一个周期跳转回第 13 行(jmp d),而这一个周期里,我们的话是传不过去的。所以我们不妨把这个时间空当利用起来,在这一个周期里做一件自己的事,便可大幅提高运行效率。

节点数和代码行数和上一版方案一致,依然是 5 个节点和 62 行代码。但运行效率由 2156 周期提升到了 1768 周期,本方案加量不加价,完爆了上一版方案。