《Calculator: The Game》遊戲解決方案
本文將介紹手機遊戲《Calculator: The Game》的完整解決方案。
開源解決方案程式碼地址為:https://github.com/FunkyKoki/Calculator_Game
《Calculator: The Game》這款遊戲是Simple Machine開發的一款益智類解謎遊戲,在一局遊戲中,你務必在有限步驟[MOVES]內,使用該局提供的“操作”——如加減乘除、翻轉、映象等,將原始數字轉化為目標數字[GOAL]。
遊戲總關卡數[LEVEL]為200,目前我已全部通關。另外,遊戲內的植入廣告頗多,我的建議是 關閉該遊戲的聯網許可權 ,包括無線區域網與蜂窩網路,之後就可以盡享遊戲樂趣了。
如需瞭解遊戲的詳細介紹,請移步:
- 【Android】https://play.google.com/store/apps/details?id=com.sm.calculateme&hl=en_US
- 【iOS】https://itunes.apple.com/us/app/calculator-the-game/id1243055750
遊戲介面如下圖所示(整體看來還是比較呆萌的),類似於計算器LCD顯示屏上顯示的數字即當前計算得到的數字(在遊戲開始時即為初始化的數字),MOVES顯示可用的步驟數,GOAL顯示目標數字,CLR用於清空之前的所有操作(即還原該局遊戲),‘+4’、‘*4’、‘/4’就是我們在這一局遊戲中可用的“操作”了,HINTS和設定就不用我再多說了:
在進行這款遊戲的破解工作前,首先要保證自己對整個遊戲有一個基本瞭解才可以,而我在試玩前十幾關時注意到了一個重要現象,這對之後破解這款遊戲起到了非常關鍵的作用,即: 一局遊戲中,介面上給出的“操作”都是需要用到的操作,不存在冗餘無關項 。我將這一點稱為 1號公理 ,僅這一點就大大降低了程式設計破解的難度。
另外值得注意的一點是: 步驟數永遠大於等於運算元(這由1號公理可以顯然推出),且在步驟數大於運算元的情況下,最多僅大出5 。我將這一點稱為 2號公理 。我們定義一局遊戲給出的步驟數為MOVES,給出的"操作"的數量為OPS,則OPS<=MOVES<=OPS+5。
根據以上兩個公理,我們就有了暴力破解的“理論指導”,說白了,我們將 有理有據地窮舉 所有可行的操作序列,並依序 測試操作序列 的正確與否。
第一個問題,如何列舉所有可行的操作序列?
這個問題往簡單了說,就是一個 排列問題 ,即將可用的操作以不同的排列順序全部列舉出來。但是,它又不僅僅是一個排列問題。需要注意到,步驟數MOVES與運算元OPS之間有兩種關係,一種是 MOVES==OPS ,另一種是在 滿足2號定理的前提下,MOVES!=OPS ,這就將排列問題劃分為了兩種情況。
1. MOVES==OPS
如果步驟數等於運算元,則問題變得很簡單。根據1號公理,我們知道所有的操作都必然被使用,因此為了窮舉所有可行的操作序列,我們所要解決的就是一個很簡單的 全排列問題 。
設步驟數MOVES=4,OPS分別為A、B、C、D,此時MOVES==OPS,問題轉化為全排列問題。我們使用下面的程式碼來解決這個全排列問題:
1 # 遞迴全排列 2 def Permutation(l, beg, endl, result): 3if beg == endl - 1: 4res = l[:] 5result.append(res) 6return 7for i in range(beg, endl): 8if l[i] in l[beg:i]: 9continue 10l[i], l[beg] = l[beg], l[i] 11Permutation(l, beg+1, endl, result) 12l[beg], l[i] = l[i], l[beg]
這個全排列的Python程式碼主要借鑑自https://blog.csdn.net/zhoufen12345/article/details/53560099,不過我做了一些改變,原始碼主要用於列印所有可能的排列,但這不是我的目的,我的主要目的在於得到所有的排列,而不是顯式地打印出來,也就是說,要將這些排列結果儲存下來。這裡需要注意,第4、5行不可簡寫為 result.append(l) ,這是 由於在遞迴過程中, l 位於堆疊之中,不能直接拿來使用,因此需要使用一箇中間變數進行過渡 。
如此,我們就可以獲得全部可用排列,再送至後面的檢驗部門測試哪個排列結果是可用的。
2. MOVES!=OPS
在這種情況下,表明有至少一個“操作”被重複使用了,這可如何解決是好呢?
我的解決方案是,基於每一個操作都必然被使用的前提,對於後(MOVES-OPS)步,我們使用for迴圈 強行將每一種可能重複使用的操作的情況新增進一個待排列的序列中,再對這個增加了重複操作的序列進行全排列 。
舉個例子,如MOVES=4,OPS為A、B、C,無論如何必然會使用ABC這三個操作,接著對這個序列新增所有可能的重複操作,則得到ABCA、ABCB、ABCC這三個序列,最後,我們將這三個序列分別進行全排列並將結果新增至result中。
說白了,想盡辦法窮舉所有可能的排列情況。
這裡我的程式碼是這樣寫的,還值得優化,但是太懶了,所以就來了個“硬編碼”:
1 if moves > ops_num: # moves為步驟數,ops_num為運算元 2if moves - ops_num == 1: 3for i in range(ops_num): 4ops = copy.copy(init_ops) 5ops.append(init_ops[i]) 6Permutation(ops, beg, endl, all_ops) 7if moves - ops_num == 2: 8for i in range(ops_num): 9for j in range(ops_num): 10ops = copy.copy(init_ops) 11ops.append(init_ops[i]) 12ops.append(init_ops[j]) 13Permutation(ops, beg, endl, all_ops) 14if moves - ops_num == 3: 15for i in range(ops_num): 16for j in range(ops_num): 17for k in range(ops_num): 18ops = copy.copy(init_ops) 19ops.append(init_ops[i]) 20ops.append(init_ops[j]) 21ops.append(init_ops[k]) 22Permutation(ops, beg, endl, all_ops) 23if moves - ops_num == 4: 24for i in range(ops_num): 25for j in range(ops_num): 26for k in range(ops_num): 27for q in range(ops_num): 28ops = copy.copy(init_ops) 29ops.append(init_ops[i]) 30ops.append(init_ops[j]) 31ops.append(init_ops[k]) 32ops.append(init_ops[q]) 33Permutation(ops, beg, endl, all_ops) 34if moves - ops_num == 5: 35for i in range(ops_num): 36for j in range(ops_num): 37for k in range(ops_num): 38for q in range(ops_num): 39for p in range(ops_num): 40ops = copy.copy(init_ops) 41ops.append(init_ops[i]) 42ops.append(init_ops[j]) 43ops.append(init_ops[k]) 44ops.append(init_ops[q]) 45ops.append(init_ops[p]) 46Permutation(ops, beg, endl, all_ops) 47if moves - ops_num == 6: 48for i in range(ops_num): 49for j in range(ops_num): 50for k in range(ops_num): 51for q in range(ops_num): 52for p in range(ops_num): 53for w in range(ops_num): 54ops = copy.copy(init_ops) 55ops.append(init_ops[i]) 56ops.append(init_ops[j]) 57ops.append(init_ops[k]) 58ops.append(init_ops[q]) 59ops.append(init_ops[p]) 60ops.append(init_ops[w]) 61Permutation(ops, beg, endl, all_ops) 62 elif moves == ops_num: 63Permutation(init_ops, beg, endl, all_ops)
以上的程式碼只列舉到了 moves - ops_num == 6 的情況,這是基於2號公理寫的。2號公理指出,步驟數如果大於運算元,最多隻大5,為了防止萬一,我增加了上限,擴充套件成了6。
通過以上操作,我們可以說 基本上 完成了可用操作序列的完全列舉。為什麼說是基本上呢?因為在遊戲後期,大概是LEVEL為155處,遊戲增加了一個有趣的操作,稱為“Store”,它將迫使我們繼續深層次地列舉所有可用操作序列。
第二個問題,如何測試所有操作序列?
看起來比較簡單,不過是依序測試每個操作罷了,那麼我們就先來看看整個遊戲有哪些操作吧。
- 末尾插入數字(如當前數字為1234,插入7,則數字變為12347),插入數字的操作方塊為淺紫色背景
- 數字代換C23=>14(如當前數字為1234,將23代換為14,則數字變為1144)
- 刪除末尾數字<<(如當前數字為1234,刪除一個數字,則數字變為123)
- 翻轉數字Reverse(如當前數字為1234,翻轉一個數字,則數字變為4321)
- Shift<<(如當前數字為1234,向左Shift一個數字,則數字變為2341)
- Shift>>(如當前數字為1234,向右Shift一個數字,則數字變為4123)
- 立方x 3 (如當前數字為4,立方一個數字,則數字變為64)
- SUM(如當前數字為1234,SUM一個數字即求各位數字之和,則數字變為10)
- Mirror(如當前數字為123,Mirror一個數字即映象拼接一個數字,則數字變為123321)
- +/-(如當前數字為1234,經此操作,則數字變為-1234)
- Inv10(如當前數字為1250,Inv10一個數字,則數字變為9850)
- [+]x(將當前所有可用的操作的數值全部加x,如當前所有可用操作為/2、+3、插入2,經過[+]1操作,則變為/3、+4、插入3)
- [-]x,同上,只是變為減x
- Store,長按該鍵儲存當前計算得到的數值,在這之後其功能變為插入鍵的功能,即在末尾插入其儲存的數字,注意, 長按儲存這一步驟並不消耗步驟數
- 標記求和,這個操作是每進行一個操作後,若滿足條件自動完成的,具體來說將在下面詳細介紹
這裡有些操作比較簡單,我主要講一下[+]x、[-]x、Store以及標記求和這幾個操作的處理細節,而前兩個又可以歸為一類。
在詳細介紹這幾個操作的實現之前,我覺得還有幾點需要特意提一下,也是整個程式設計過程中的細節:
- 所有的操作都是整數操作,因此切忌在整個計算過程中誤使數字變為浮點數;
- 一些操作在對正負數進行處理時需要注意細節,如左右Shift的時候,需要將數字和符號先分開(當然,這是我的實現方式,歡迎指出其他方法);
- python有深淺拷貝的區別,需要特別注意。
1. [+]x
細細分析該操作,我將其定義為“攝動”操作符,即該操作影響了其他操作的運算元,為此,我引入了攝動因子 influence ,每當進行該操作時,即改變了攝動因子,而攝動因子又全面影響了其他所有攝動操作符將會影響到的操作。
程式碼如下:
1 influence = 0 2 for j in all_ops[i]: 3if j[0] == '[': 4if j[1] == '+': 5influence += int(j[3:]) 6elif j[1] == '-': 7influence -= int(j[3:]) 8elif j[0] == 'I': 9if calc_num >= 0: 10if int(j[1:]) + influence == 0: 11calc_num = calc_num * 10 12else: 13calc_num = calc_num * pow(10, int(math.log10(int(j[1:]) + influence)) + 1) + int(j[1:])+influence 14else: 15if int(j[1:]) == 0: 16calc_num = calc_num * 10 17else: 18calc_num = calc_num * pow(10, int(math.log10(int(j[1:]) + influence)) + 1) - int(j[1:])-influence 19elif j[0] == '+': 20calc_num += (int(j[1:]) + influence) 21elif j[0] == '-': 22calc_num -= (int(j[1:]) + influence) 23elif j[0] == '*': 24calc_num *= (int(j[1:]) + influence) 25elif j[0] == '/': 26if math.modf(calc_num/(int(j[1:])+influence))[0] != 0: 27break 28calc_num = calc_num // (int(j[1:])+influence)
此處我僅列舉了攝動操作會影響到的操作,如插入、加減乘除,事實上,正如之前介紹的,Store本身也是一個插入操作,只是這裡還沒有詳細介紹,因此暫時不提。
2. Store
Store運算子在這之前稍微介紹了一下,這個操作符最騷的一點是,長按能夠儲存當前計算得到的數字,而長按本身並不消耗步驟數,這一點大大提升瞭解謎的難度,在儲存了數字後,短按該鍵將執行類似於插入數字的操作,短按插入數字時消耗步驟數。
在程式設計時需要解決的難題也就是這個長按與短按的問題,短按可以看作普通按鍵,長按就有些不可理喻了,不過經過一陣摸索,我也得到了一些經驗,即 長按儲存數字的操作,在一局遊戲中至多使用兩次 。
如此一來,我們就又有了窮舉所有排列可能性的理論依據。
不外乎兩種情況, 插入一個長按Store操作 和 插入兩個長按Store操作 ,且值得注意的是, 長按Store操作在整個操作序列末尾是沒有意義的 ,因此經過一通分析後,事情瞬間easy。
另外還有一點很關鍵,要能夠分析到,根據1號公理,所有按鍵均需要使用到,因此在所有含有Store操作的遊戲對局中,為了確保Store操作被使用,其必然經過了至少1次長按Store操作。於是,以下程式碼就誕生了:
1 store_index = [] 2 for i in range(len(all_ops)): 3for it in all_ops[i]: 4if it == 'Store': 5store_index.append(i) 6break 7 for i in range(len(store_index)):# 插入一個Store_S 8temp = copy.deepcopy(all_ops[store_index[i]]) 9for index in range(len(temp)): 10temp_a = copy.deepcopy(temp) 11temp_a.insert(index, 'Store_S') 12all_ops.insert(len(all_ops), temp_a) 13 14 for i in range(len(store_index)):# 插入兩個Store_S 15temp = copy.deepcopy(all_ops[store_index[i]]) 16for index in range(len(temp)-1): 17temp_a = copy.deepcopy(temp) 18temp_a.insert(index, 'Store_S') 19for ttt in range(3-index): 20temp_b = copy.deepcopy(temp_a) 21temp_b.insert(index + ttt + 2, 'Store_S') 22all_ops.insert(len(all_ops), temp_b) 23 24 for i in range(len(store_index)): 25all_ops.pop(0)
這段程式碼的作用就是在全排列得到所有操作後,分別插入1個Store_S(即長按Store操作)與2個Store_S得到可用的全部操作序列,由於必然需要使用Store_S,因此,result佇列最前面不含有Store_S的操作序列均可刪去。
最後,在進行某一操作序列的測試時,我們定義變數 Store_num 用以儲存Store_S操作儲存的數字,並得到以下操作程式碼:
1elif j == 'Store_S': 2Store_num = calc_num 3elif j == 'Store': 4if calc_num >= 0: 5if Store_num + influence == 0: 6calc_num = calc_num * 10 7else: 8calc_num = calc_num * pow(10, int(math.log10(Store_num + influence)) + 1) + Store_num+influence 9else: 10if int(j[1:]) == 0: 11calc_num = calc_num * 10 12else: 13calc_num = calc_num * pow(10, int(math.log10(Store_num + influence)) + 1) - Store_num-influence
如此一來,我們就完成了這個騷氣的Store操作,效果槓槓的。
3. Patrol
這個Patrol操作,我將其中文名稱為標記求和,大致長成下面這個樣子:
所謂的標記求和,就是指對兩個小三角所指的那兩位數字進行求和,低於最左邊三角所在位數的其餘位數保持不變,高於最左邊三角所在位數的數字自動向右移動一位。語言解釋起來沒什麼感覺,我們來看一個實際的例子:
假設當前數字為123,設小三角所指位數從最右向最左由1依次增大,假設左三角指向第4位,右三角指向第1位,則在插入數字1後,數字變為1231,此時,兩個三角所指的位數都有了數字,因此求和第1位和第4位的數字,得2,其餘數字位數不變,最後轉化為232。
再假設當前數字為1234,左三角指向第5位,右三角指向第1位,插入數字56,數字變成123456,第5位數字為2,第1位數字為6,求和得到8,由於1所在的位數為第6位,高於左三角所在位數,因此數字變為13458,此時第1和第5位仍有數字,所以繼續自動進行標記求和操作,最終變為3459。
根據以上陳述,我們可以在經過一個操作序列的其中一個操作後,對得到的數字進行自動處理:
1 if patrol_flag == 1: 2ss = calc_num//abs(calc_num) 3calc_num = list(str(abs(calc_num))) 4while len(calc_num) >= patrol_l: 5patrol_sum = (int(calc_num[len(calc_num)-patrol_l])+int(calc_num[len(calc_num)-patrol_r]))*pow(10, patrol_r-1) 6patrol_else_sum = 0 7for zzz in range(len(calc_num)): 8if zzz != patrol_l-1 and zzz != patrol_r-1 and zzz < patrol_l: 9patrol_else_sum += int(calc_num[len(calc_num) - zzz - 1]) * pow(10, zzz) 10if zzz >= patrol_l: 11patrol_else_sum += int(calc_num[len(calc_num) - zzz - 1]) * pow(10, zzz - 1) 12calc_num = patrol_sum + patrol_else_sum 13calc_num = list(str(calc_num)) 14calc_num = ss * int(''.join(calc_num))
這裡我們使用的是while,為的就是保證數字送進去之後會一直自動變換,直至標記的位數不再同時含有數字。
總結
本來以為寫個幾行程式碼就能通關的,沒想到這個遊戲一直給我驚喜,各式各類的操作符花樣迭出,因此最終完成的這版程式碼也是相當粗糙,目前還有以下幾點值得優化:
- 對MOVES!=OPS的情況下,獲取所有可能的操作序列的程式碼仍待優化,目前的程式碼太暴力了(不過很懶,可能就不改了hhh)
- 不同操作符對應的操作程式碼可以進行包裝,形成函式,整個程式碼看起來應該會更清爽
不得不說,Python是一個相當妙的語言,它的程式設計邏輯與C還是有很大不同的,在寫程式碼的過程中,才發現我對Python掌握得並不是很好,有很多語言特性還沒有把握到,這也正表明了不斷用類似的小專案去磨練自己的必要性。
我的程式碼也沒怎麼加註釋,那這篇博文就當是註釋啦hh