【深圳 IO 攻略】第 10 关:真人 CS

本文首发于 B 站《深圳 IO》文集(https://www.bilibili.com/read/readlist/rl569860)。原创不易,转载请注明出处。
关卡展示

从这关开始,难度就变大了。这一关是一个真人 CS 模拟器,你需要根据实时的【击中】、【复活】输入信号生成出实时的【活着】信号,再根据【活着】、【添弹】、【扣扳机】这三路输入信号生成出实时的【射击】输出信号。信号生成规则如下:
初始状态下,【活着】信号处于关闭状态;
任何时候,当【复活】信号出现时,开启【活着】信号;
任何时候,当【击中】信号出现时,关闭【活着】信号;
任何时候,当【装弹】信号出现时,将弹药数补充至【填弹】常数芯片所指示的最大值(死了也可以装弹,很奇怪)。该操作不需要表现在时序图中。
【活着】时出现【扣扳机】信号时,若仍有弹药剩余,则消耗一枚弹药,发送时长 1 秒钟的【射击】脉冲信号。
这道题其实分为两部分,我们先把左半部分的【活着】信号给生成出来。这个信号相对还是容易生成的,我们只需要将实时的【复活】和【击中】信号做差值运算,差值为 100 时说明出现了【复活】信号,【活着】信号强制置为 100;差值为 -100 时说明出现了【击中】信号,【活着】信号强制置为 0。同样地,三个 p 口,其中必然有一个需要借助 DX-300 转换。相信看到了这里的你,很容易写出下面这样的代码:

我们把左半部分写完后,点击左下角的【模拟】按钮,可以看到【活着】信号已经正确了。
由于右侧的【射击】信号需要以左侧的【活着】信号为基准,所以左边这块 MC4000 生成的【活着】信号要复制一份给右边的芯片。右边的芯片需要连接 3 个 p 口输入和 1 个 p 口输出,所以必然有两个 p 口要使用 DX-300 转换成 x 口信号。这里我们选择把【扣扳机】和【添弹】两个输入信号接到 DX-300 上。电路图变成了下面的样子:

这里我们用到了一个新的元件:【桥接器】。它仅仅是帮助你布置交叉的导线,不需要花费额外的金钱。它的作用是:当导线不得不交叉放置时,把它放置在交叉的位置,让纵向导线从横向导线的上方跨过去,以避免交叉。在这里,位于下方的 x1 要接的是位于上方的 DX-300,而位于上方的 p1 要接的是位于下方的【射击】端口,交叉不可避免,只能使用桥接器来避开交叉。
现在我们开始在右边的 MC4000 中写代码。核心逻辑只有两条:
当【装弹】信号出现时,将 acc 的值重置为 x0 的值;
当【活着】和【扣扳机】信号同时出现,且 acc 的值大于 0 时,令 acc -1,同时向 p1 口发出长度为 1 秒的脉冲信号。
因此,我们很容易在右边的芯片里写出这样的代码:

首先我们观察一下 DX-300。【扣扳机】是接在 p1 上的,影响十位;【添弹】是接在 p0 上的,影响个位。那么我们从右侧芯片的 x1 口中可以读出以下三种可能的值:
0:无事发生;
1:出现了【添弹】信号;
10:出现了【扣扳机】信号。
于是,我们首先判断 x1 口是否为 1(teq x1 1)。如果是 1,那么立刻将弹药数重置到最大数量(+ mov x0 acc)。然后是一连串的用 + 号连接起来的测试指令。我在第 5 关的谜题里提到过:用连续 + 号串联起来的测试指令构成了【与】关系,必须要这些测试条件全部满足才能最终保持执行 + 号里的指令。任意一条测试指令不满足条件,都会立刻跳到 - 处执行。
因此,只有出现了【扣扳机】信号时(teq x1 10)有弹药剩余(+ tgt acc 0),同时还保持活着的状态时(+ teq p0 100),才能发出射击脉冲(+ gen p1 1 1)并扣减一枚弹药(+ sub 1)。以上任何一个条件不满足,那都是休眠一秒保持待命(- slp 1)。
点击左下角的【模拟】,稍等片刻,便会弹出结算界面:

优化成本
如果我们将左侧的【击中】和【复活】两个简单输入用一个 DX-300 合并的话,实际上我们可以只用一块 MC6000 完成任务。电路图和代码如下:


这一块芯片,代码写满了 14 行,acc 和 dat 两个寄存器都用到了,接口也是用了五个,几乎是充分利用了有效资源。这段代码很好理解,几乎就是将上一个版本里分散在两块芯片里的代码合并到了一块芯片里而已。注意这个设计方案里,左右两侧的 DX-300 接线位置有变化,相应的判断也变成了检查两个 DX-300 的值是否为 10 或 100。
这里我只着重提一下为什么设置【活着】信号的时候要把值复制一份给 dat,而不是直接从 p0 口读。因为 p0 口所连着的【活着】端口是一个【只写】端口,只能往该端口写数据,而当你尝试从该端口读数据时,非但什么数据都读不到(会返回恒 0),而且芯片的 p0 口的读写模式也会从原先的写模式转换成读模式,原先写的数据也会被擦除(变成 0)。不过这个小技巧可以用来同时清除多组数据,例如上方的第 2~3 行代码
可以化简成一条指令:
当你读只写的 p0 时,会读到 0,同时之前写入 p0 口的数据也会被清除。与此同时,你将读到的 0 赋给 acc,这样就成功用一条指令将两处存储置零了(节省了电量,甚至还可能因此省下关键的一行代码,正好够放在一块 4000 或 6000 里)。
扯远了,回到正题。因为读只写口非但读不到数据,而且还会把原先写入的数据擦除,所以我们必须使用其他的方式来记录这个口的实时状态。这里我们将 0/100 的值复制一份到既可读又可写的 dat 中,后续代码判断 dat 的值才是正解。

省下了一块钱,电量也由 362 降低到了 339。
优化电量和代码行数:巧用非门
我们不难发现,左侧的【击中】和【复活】信号构成了一个三态:常规战斗状态、击中状态和复活状态。其中处于常规战斗状态时,我们不需要更新【活着】的值,只有在处于另外两种状态时需要更新。如果我们将【击中】和 DX-300 的 p1 口相连,【复活】和 DX-300 的 p2 口相连,那么我们就可以得到如下的三态:
常规战斗状态:000
击中状态:010
复活状态:100
对于三态判断,我们很容易会想到 tcp 指令。但是 tcp 指令一般是和一个中间值做比较,然后当处于两端的状态时,再执行特定代码。可是,用 DX-300 处理后,我们发现当读到最左端的 0 值时不需要做额外处理,反而是读到中间的 10 和右端的 100 时才需要更新【活着】的状态。这样子的话,即使用 tcp 指令也显得很不方便。那么我们能不能想办法让常规状态展现出中间值,而让特殊状态展现出两端的值呢?
答案是可以的,只要你使用非门。我们将【击中】信号加上非门处理一下,平常是 100,击中时瞬间变成 0。【复活】信号维持原样输出。然后我们惊奇地发现,三态信号变成了下面的样子:
常规战斗状态:010
击中状态:000
复活状态:110
常规战斗状态变成了中间值,与此同时,处于两端状态时,我们甚至可以不用做额外处理,直接将对应的状态值发给 p0(超过上限时自动取 100),这时候甚至不用 tcp 三态了,直接 teq 双态就能搞定!

前三行代码就这么搞定了。然后我们看右边,也是个三态:待命状态、添弹状态、扣扳机状态。将【扣扳机】和【添弹】分别和 DX-300 的 p2、p1 口连接,可以得到如下的三态:
待命状态 000
添弹状态 010
扣扳机状态 100
如果我们将【添弹】端口用非门处理一下,三态就变成了如下的样子:
待命状态 010
添弹状态 000
扣扳机状态 110
这样就完全可以用 tcp 指令让 DX-300 的值和 10 比大小,然后完成三态判断了。

此时要注意以下两点:
判断是否活着要判定 dat 是否与 110 相等,而不是 100。
最后的 gen 和 slp 指令中,gen 指令要改成 gen p1 1 0,slp 指令要去掉前方的 - 号,因为当 x3 的值正好等于 10 时(tcp x3 10 同时关闭 + - 指令时)也需要正常休眠一秒。

如此,加了 2 块钱的非门元件,换来了电量从 339 减少到 267,代码行数从 14 行减少到 10 行的成果。