本文將介紹手機游戲《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): 3 if beg == endl - 1: 4 res = l[:] 5 result.append(res) 6 return 7 for i in range(beg, endl): 8 if l[i] in l[beg:i]: 9 continue 10 l[i], l[beg] = l[beg], l[i] 11 Permutation(l, beg+1, endl, result) 12 l[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為操作數 2 if moves - ops_num == 1: 3 for i in range(ops_num): 4 ops = copy.copy(init_ops) 5 ops.append(init_ops[i]) 6 Permutation(ops, beg, endl, all_ops) 7 if moves - ops_num == 2: 8 for i in range(ops_num): 9 for j in range(ops_num): 10 ops = copy.copy(init_ops) 11 ops.append(init_ops[i]) 12 ops.append(init_ops[j]) 13 Permutation(ops, beg, endl, all_ops) 14 if moves - ops_num == 3: 15 for i in range(ops_num): 16 for j in range(ops_num): 17 for k in range(ops_num): 18 ops = copy.copy(init_ops) 19 ops.append(init_ops[i]) 20 ops.append(init_ops[j]) 21 ops.append(init_ops[k]) 22 Permutation(ops, beg, endl, all_ops) 23 if moves - ops_num == 4: 24 for i in range(ops_num): 25 for j in range(ops_num): 26 for k in range(ops_num): 27 for q in range(ops_num): 28 ops = copy.copy(init_ops) 29 ops.append(init_ops[i]) 30 ops.append(init_ops[j]) 31 ops.append(init_ops[k]) 32 ops.append(init_ops[q]) 33 Permutation(ops, beg, endl, all_ops) 34 if moves - ops_num == 5: 35 for i in range(ops_num): 36 for j in range(ops_num): 37 for k in range(ops_num): 38 for q in range(ops_num): 39 for p in range(ops_num): 40 ops = copy.copy(init_ops) 41 ops.append(init_ops[i]) 42 ops.append(init_ops[j]) 43 ops.append(init_ops[k]) 44 ops.append(init_ops[q]) 45 ops.append(init_ops[p]) 46 Permutation(ops, beg, endl, all_ops) 47 if moves - ops_num == 6: 48 for i in range(ops_num): 49 for j in range(ops_num): 50 for k in range(ops_num): 51 for q in range(ops_num): 52 for p in range(ops_num): 53 for w in range(ops_num): 54 ops = copy.copy(init_ops) 55 ops.append(init_ops[i]) 56 ops.append(init_ops[j]) 57 ops.append(init_ops[k]) 58 ops.append(init_ops[q]) 59 ops.append(init_ops[p]) 60 ops.append(init_ops[w]) 61 Permutation(ops, beg, endl, all_ops) 62 elif moves == ops_num: 63 Permutation(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)
- 立方x3(如當前數字為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]: 3 if j[0] == '[': 4 if j[1] == '+': 5 influence += int(j[3:]) 6 elif j[1] == '-': 7 influence -= int(j[3:]) 8 elif j[0] == 'I': 9 if calc_num >= 0: 10 if int(j[1:]) + influence == 0: 11 calc_num = calc_num * 10 12 else: 13 calc_num = calc_num * pow(10, int(math.log10(int(j[1:]) + influence)) + 1) + int(j[1:])+influence 14 else: 15 if int(j[1:]) == 0: 16 calc_num = calc_num * 10 17 else: 18 calc_num = calc_num * pow(10, int(math.log10(int(j[1:]) + influence)) + 1) - int(j[1:])-influence 19 elif j[0] == '+': 20 calc_num += (int(j[1:]) + influence) 21 elif j[0] == '-': 22 calc_num -= (int(j[1:]) + influence) 23 elif j[0] == '*': 24 calc_num *= (int(j[1:]) + influence) 25 elif j[0] == '/': 26 if math.modf(calc_num/(int(j[1:])+influence))[0] != 0: 27 break 28 calc_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)): 3 for it in all_ops[i]: 4 if it == 'Store': 5 store_index.append(i) 6 break 7 for i in range(len(store_index)): # 插入一個Store_S 8 temp = copy.deepcopy(all_ops[store_index[i]]) 9 for index in range(len(temp)): 10 temp_a = copy.deepcopy(temp) 11 temp_a.insert(index, 'Store_S') 12 all_ops.insert(len(all_ops), temp_a) 13 14 for i in range(len(store_index)): # 插入兩個Store_S 15 temp = copy.deepcopy(all_ops[store_index[i]]) 16 for index in range(len(temp)-1): 17 temp_a = copy.deepcopy(temp) 18 temp_a.insert(index, 'Store_S') 19 for ttt in range(3-index): 20 temp_b = copy.deepcopy(temp_a) 21 temp_b.insert(index + ttt + 2, 'Store_S') 22 all_ops.insert(len(all_ops), temp_b) 23 24 for i in range(len(store_index)): 25 all_ops.pop(0)
這段代碼的作用就是在全排列得到所有操作后,分別插入1個Store_S(即長按Store操作)與2個Store_S得到可用的全部操作序列,由於必然需要使用Store_S,因此,result隊列最前面不含有Store_S的操作序列均可刪去。
最后,在進行某一操作序列的測試時,我們定義變量 Store_num 用以保存Store_S操作保存的數字,並得到以下操作代碼:
1 elif j == 'Store_S': 2 Store_num = calc_num 3 elif j == 'Store': 4 if calc_num >= 0: 5 if Store_num + influence == 0: 6 calc_num = calc_num * 10 7 else: 8 calc_num = calc_num * pow(10, int(math.log10(Store_num + influence)) + 1) + Store_num+influence 9 else: 10 if int(j[1:]) == 0: 11 calc_num = calc_num * 10 12 else: 13 calc_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: 2 ss = calc_num//abs(calc_num) 3 calc_num = list(str(abs(calc_num))) 4 while len(calc_num) >= patrol_l: 5 patrol_sum = (int(calc_num[len(calc_num)-patrol_l])+int(calc_num[len(calc_num)-patrol_r]))*pow(10, patrol_r-1) 6 patrol_else_sum = 0 7 for zzz in range(len(calc_num)): 8 if zzz != patrol_l-1 and zzz != patrol_r-1 and zzz < patrol_l: 9 patrol_else_sum += int(calc_num[len(calc_num) - zzz - 1]) * pow(10, zzz) 10 if zzz >= patrol_l: 11 patrol_else_sum += int(calc_num[len(calc_num) - zzz - 1]) * pow(10, zzz - 1) 12 calc_num = patrol_sum + patrol_else_sum 13 calc_num = list(str(calc_num)) 14 calc_num = ss * int(''.join(calc_num))
這里我們使用的是while,為的就是保證數字送進去之后會一直自動變換,直至標記的位數不再同時含有數字。
總結
本來以為寫個幾行代碼就能通關的,沒想到這個游戲一直給我驚喜,各式各類的操作符花樣迭出,因此最終完成的這版代碼也是相當粗糙,目前還有以下幾點值得優化:
- 對MOVES!=OPS的情況下,獲取所有可能的操作序列的代碼仍待優化,目前的代碼太暴力了(不過很懶,可能就不改了hhh)
- 不同操作符對應的操作代碼可以進行包裝,形成函數,整個代碼看起來應該會更清爽
不得不說,Python是一個相當妙的語言,它的編程邏輯與C還是有很大不同的,在寫代碼的過程中,才發現我對Python掌握得並不是很好,有很多語言特性還沒有把握到,這也正表明了不斷用類似的小項目去磨練自己的必要性。
我的代碼也沒怎么加注釋,那這篇博文就當是注釋啦hh