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

本文首發(fā)于 B 站《深圳 IO》文集(https://www.bilibili.com/read/readlist/rl569860)。原創(chuàng)不易,轉載請注明出處。
關卡展示

從這關開始,難度就變大了。這一關是一個真人 CS 模擬器,你需要根據實時的【擊中】、【復活】輸入信號生成出實時的【活著】信號,再根據【活著】、【添彈】、【扣扳機】這三路輸入信號生成出實時的【射擊】輸出信號。信號生成規(guī)則如下:
初始狀態(tài)下,【活著】信號處于關閉狀態(tài);
任何時候,當【復活】信號出現(xiàn)時,開啟【活著】信號;
任何時候,當【擊中】信號出現(xiàn)時,關閉【活著】信號;
任何時候,當【裝彈】信號出現(xiàn)時,將彈藥數(shù)補充至【填彈】常數(shù)芯片所指示的最大值(死了也可以裝彈,很奇怪)。該操作不需要表現(xiàn)在時序圖中。
【活著】時出現(xiàn)【扣扳機】信號時,若仍有彈藥剩余,則消耗一枚彈藥,發(fā)送時長?1 秒鐘的【射擊】脈沖信號。
這道題其實分為兩部分,我們先把左半部分的【活著】信號給生成出來。這個信號相對還是容易生成的,我們只需要將實時的【復活】和【擊中】信號做差值運算,差值為 100 時說明出現(xiàn)了【復活】信號,【活著】信號強制置為 100;差值為 -100 時說明出現(xiàn)了【擊中】信號,【活著】信號強制置為 0。同樣地,三個 p 口,其中必然有一個需要借助 DX-300 轉換。相信看到了這里的你,很容易寫出下面這樣的代碼:

我們把左半部分寫完后,點擊左下角的【模擬】按鈕,可以看到【活著】信號已經正確了。
由于右側的【射擊】信號需要以左側的【活著】信號為基準,所以左邊這塊 MC4000 生成的【活著】信號要復制一份給右邊的芯片。右邊的芯片需要連接?3 個 p 口輸入和 1 個 p 口輸出,所以必然有兩個 p 口要使用?DX-300 轉換成 x 口信號。這里我們選擇把【扣扳機】和【添彈】兩個輸入信號接到 DX-300 上。電路圖變成了下面的樣子:

這里我們用到了一個新的元件:【橋接器】。它僅僅是幫助你布置交叉的導線,不需要花費額外的金錢。它的作用是:當導線不得不交叉放置時,把它放置在交叉的位置,讓縱向導線從橫向導線的上方跨過去,以避免交叉。在這里,位于下方的 x1 要接的是位于上方的 DX-300,而位于上方的 p1 要接的是位于下方的【射擊】端口,交叉不可避免,只能使用橋接器來避開交叉。
現(xiàn)在我們開始在右邊的 MC4000 中寫代碼。核心邏輯只有兩條:
當【裝彈】信號出現(xiàn)時,將 acc 的值重置為 x0 的值;
當【活著】和【扣扳機】信號同時出現(xiàn),且 acc 的值大于 0 時,令 acc -1,同時向 p1 口發(fā)出長度為 1 秒的脈沖信號。
因此,我們很容易在右邊的芯片里寫出這樣的代碼:

首先我們觀察一下 DX-300。【扣扳機】是接在 p1 上的,影響十位;【添彈】是接在 p0 上的,影響個位。那么我們從右側芯片的 x1 口中可以讀出以下三種可能的值:
0:無事發(fā)生;
1:出現(xiàn)了【添彈】信號;
10:出現(xiàn)了【扣扳機】信號。
于是,我們首先判斷 x1 口是否為 1(teq x1 1)。如果是 1,那么立刻將彈藥數(shù)重置到最大數(shù)量(+ mov x0 acc)。然后是一連串的用 + 號連接起來的測試指令。我在第 5 關的謎題里提到過:用連續(xù) + 號串聯(lián)起來的測試指令構成了【與】關系,必須要這些測試條件全部滿足才能最終保持執(zhí)行 + 號里的指令。任意一條測試指令不滿足條件,都會立刻跳到 - 處執(zhí)行。
因此,只有出現(xiàn)了【扣扳機】信號時(teq x1 10)有彈藥剩余(+ tgt acc 0),同時還保持活著的狀態(tài)時(+ teq p0 100),才能發(fā)出射擊脈沖(+ gen p1 1 1)并扣減一枚彈藥(+ sub 1)。以上任何一個條件不滿足,那都是休眠一秒保持待命(- slp 1)。
點擊左下角的【模擬】,稍等片刻,便會彈出結算界面:

優(yōu)化成本
如果我們將左側的【擊中】和【復活】兩個簡單輸入用一個 DX-300 合并的話,實際上我們可以只用一塊 MC6000 完成任務。電路圖和代碼如下:


這一塊芯片,代碼寫滿了 14 行,acc 和 dat 兩個寄存器都用到了,接口也是用了五個,幾乎是充分利用了有效資源。這段代碼很好理解,幾乎就是將上一個版本里分散在兩塊芯片里的代碼合并到了一塊芯片里而已。注意這個設計方案里,左右兩側的 DX-300 接線位置有變化,相應的判斷也變成了檢查兩個 DX-300 的值是否為 10 或 100。
這里我只著重提一下為什么設置【活著】信號的時候要把值復制一份給 dat,而不是直接從 p0 口讀。因為 p0 口所連著的【活著】端口是一個【只寫】端口,只能往該端口寫數(shù)據,而當你嘗試從該端口讀數(shù)據時,非但什么數(shù)據都讀不到(會返回恒 0),而且芯片的 p0 口的讀寫模式也會從原先的寫模式轉換成讀模式,原先寫的數(shù)據也會被擦除(變成 0)。不過這個小技巧可以用來同時清除多組數(shù)據,例如上方的第 2~3 行代碼
可以化簡成一條指令:
當你讀只寫的 p0 時,會讀到 0,同時之前寫入 p0 口的數(shù)據也會被清除。與此同時,你將讀到的 0 賦給 acc,這樣就成功用一條指令將兩處存儲置零了(節(jié)省了電量,甚至還可能因此省下關鍵的一行代碼,正好夠放在一塊 4000 或 6000 里)。
扯遠了,回到正題。因為讀只寫口非但讀不到數(shù)據,而且還會把原先寫入的數(shù)據擦除,所以我們必須使用其他的方式來記錄這個口的實時狀態(tài)。這里我們將 0/100 的值復制一份到既可讀又可寫的 dat 中,后續(xù)代碼判斷 dat 的值才是正解。

省下了一塊錢,電量也由 362?降低到了 339。
優(yōu)化電量和代碼行數(shù):巧用非門
我們不難發(fā)現(xiàn),左側的【擊中】和【復活】信號構成了一個三態(tài):常規(guī)戰(zhàn)斗狀態(tài)、擊中狀態(tài)和復活狀態(tài)。其中處于常規(guī)戰(zhàn)斗狀態(tài)時,我們不需要更新【活著】的值,只有在處于另外兩種狀態(tài)時需要更新。如果我們將【擊中】和 DX-300 的 p1 口相連,【復活】和 DX-300 的 p2 口相連,那么我們就可以得到如下的三態(tài):
常規(guī)戰(zhàn)斗狀態(tài):000
擊中狀態(tài):010
復活狀態(tài):100
對于三態(tài)判斷,我們很容易會想到 tcp 指令。但是 tcp 指令一般是和一個中間值做比較,然后當處于兩端的狀態(tài)時,再執(zhí)行特定代碼。可是,用 DX-300 處理后,我們發(fā)現(xiàn)當讀到最左端的 0 值時不需要做額外處理,反而是讀到中間的 10 和右端的 100 時才需要更新【活著】的狀態(tài)。這樣子的話,即使用 tcp 指令也顯得很不方便。那么我們能不能想辦法讓常規(guī)狀態(tài)展現(xiàn)出中間值,而讓特殊狀態(tài)展現(xiàn)出兩端的值呢?
答案是可以的,只要你使用非門。我們將【擊中】信號加上非門處理一下,平常是 100,擊中時瞬間變成 0?!緩突睢啃盘柧S持原樣輸出。然后我們驚奇地發(fā)現(xiàn),三態(tài)信號變成了下面的樣子:
常規(guī)戰(zhàn)斗狀態(tài):010
擊中狀態(tài):000
復活狀態(tài):110
常規(guī)戰(zhàn)斗狀態(tài)變成了中間值,與此同時,處于兩端狀態(tài)時,我們甚至可以不用做額外處理,直接將對應的狀態(tài)值發(fā)給 p0(超過上限時自動取 100),這時候甚至不用 tcp 三態(tài)了,直接 teq 雙態(tài)就能搞定!

前三行代碼就這么搞定了。然后我們看右邊,也是個三態(tài):待命狀態(tài)、添彈狀態(tài)、扣扳機狀態(tài)。將【扣扳機】和【添彈】分別和 DX-300 的 p2、p1 口連接,可以得到如下的三態(tài):
待命狀態(tài) 000
添彈狀態(tài) 010
扣扳機狀態(tài) 100
如果我們將【添彈】端口用非門處理一下,三態(tài)就變成了如下的樣子:
待命狀態(tài) 010
添彈狀態(tài) 000
扣扳機狀態(tài) 110
這樣就完全可以用 tcp 指令讓 DX-300 的值和 10 比大小,然后完成三態(tài)判斷了。

此時要注意以下兩點:
判斷是否活著要判定 dat 是否與 110 相等,而不是 100。
最后的 gen 和 slp 指令中,gen 指令要改成 gen p1 1 0,slp 指令要去掉前方的 - 號,因為當 x3 的值正好等于 10 時(tcp x3 10 同時關閉 + - 指令時)也需要正常休眠一秒。

如此,加了?2 塊錢的非門元件,換來了電量從 339?減少到?267,代碼行數(shù)從 14?行減少到?10 行的成果。