完整記錄如何分析 HTML5 遊戲結構、找到關鍵變數、並透過修改 JavaScript 原始碼實現無限 Swap 的完整歷程
Tower Swap 是一款結合 Match-3 配對與 Tower Defense 的益智遊戲,玩家每回合有限制的 Swap 次數來排列資源與防禦塔。這份筆記記錄了如何在不影響其他玩家的前提下,修改本機的遊戲執行環境,讓 Swap 次數不再減少,以便純粹享受規劃佈局的樂趣。
在動手之前,需要先了解遊戲是用什麼方式跑起來的。打開 Firefox DevTools 後,發現這個遊戲有三層 iframe 巢狀結構,這是後來許多方法失效的根本原因。
localStorage,所有遊戲狀態(swap 數、血量、棋盤布局)都以 tb_0_m_* 為前綴存放在瀏覽器本地。但遊戲執行時使用的是記憶體內的 closure 變數,不是每次都從 localStorage 讀取。
透過 Console 觀察 gNativeSend() 的輸出,可以看到所有遊戲狀態:
gNativeSend() savetext:=tb_0_m_swaps:=4 gNativeSend() savetext:=tb_0_m_castlehealth:=5 gNativeSend() savetext:=tb_0_m_day:=1 gNativeSend() savetext:=tb_0_m_board:=e0e0e0e0e0e0g0s0i0...
這個過程經歷了多個方向的嘗試,每次失敗都帶來新的資訊,逐步縮小問題範圍。
tb_0_m_swaps = 99,得到 ReferenceError。window 下所有物件,尋找含有 swaps 屬性的物件。googletag、google 等),確認遊戲變數完全被 IIFE 封鎖。
tower-swap.game-files.crazygames.com,但 gNativeSend、tb_0_m_swaps 仍是 undefined,因為這些都在 game.js 的 IIFE closure 內。
Object.defineProperty(window, 'gNativeSend', { set: fn }) 在變數被賦值時攔截。typeof gNativeSend 回傳 null,表示遊戲初始化時設為 null,之後在 closure 內部替換,我們的 setter 攔截不到。
fetch、XMLHttpRequest、WebSocket,尋找遊戲儲存 swap 的網路通訊。localStorage,找到 57 筆資料,全部以 tb_0_m_ 為前綴,包括 tb_0_m_swaps: 8。r6 裡,不再每次從 localStorage 讀,所以修改 localStorage 無法即時影響遊戲。
Storage.prototype.setItem,在寫入時把低 swap 值改高。r6 值,不是 localStorage,所以畫面上看不到任何變化。
J.loopstart(updateFn, drawFn) 是遊戲主迴圈入口,嘗試替換 J._updatefunc。typeof J 回傳 undefined,確認 J 物件也在 IIFE 內部,無法從外部存取。
unsafeWindow.fetch 攔截 game.js 的下載請求,在傳回前修改原始碼。<script src> 標籤載入,不是用 fetch(),根本不會觸發我們的攔截器。
document.createElement,當建立 script 標籤時攔截 src 屬性設定。<script src="game.js?113"> 是直接寫在 HTML 原始碼裡的靜態標籤,不是 JavaScript 動態建立的,所以 document-start 時這個標籤早就存在了。
fetch 下載 game.js 並存成檔案,上傳分析。找到了所有關鍵變數和函數。下載 game.js 後(約 410KB 的壓縮單行檔),用 grep 找到以下關鍵結構:
| 內部變數 | localStorage 鍵名 | 意義 |
|---|---|---|
H |
— | 就是 localStorage 本身(H = localStorage) |
V |
— | 前綴字串 "tb_0_m_" |
r6 |
tb_0_m_swaps |
當前 Swap 次數(執行時在記憶體,存檔寫 localStorage) |
r4 |
— | Swap UI 動畫計數器(r4 = 3 * r6) |
tn |
tb_0_m_castlehealth |
城堡血量 |
儲存與讀取函數:
// 寫入函數:同時寫 localStorage 和通知平台 function _C(value, key) { H[V + key] = value; // 等同 localStorage["tb_0_m_" + key] = value if ("web" != gPlatform) mM("savetext:=" + key + ":=" + value); } // 讀取函數:從 localStorage 讀 function _b(key) { return 1 * _k(key) || 0; } function _k(key) { return H[V + key] || ""; } // 遊戲初始化時讀取 swap(之後存在 r6 記憶體變數,不再重讀) r4 = 3 * (r6 = _b("swaps"));
_C() 函數每次 swap 後都會把 r6 寫入 localStorage,但遊戲畫面顯示和邏輯判斷用的是記憶體裡的 r6。所以單純修改 localStorage 無法影響遊戲,必須修改 r6 本身——而唯一的方法就是修改 game.js 原始碼。
找到 Swap 被扣除的兩個地方:
// 【修改一】dE() — 每次 swap 動作觸發,扣除 1 點 function dE() { nW=nH, nM=nV, $w() || sj==sW || sj==sU || sj==sM || ( r6-=1, ← 改成 r6-=0 rw=r6, r4=3*r6 ), dN(), it=0, ... } // 【修改二】Boss 關卡計時器 — 時間到自動扣 swap (r6>0 && (nA+=1), nA>=60 && ( nA=0, (r6-=1)<0 && (r6=0), ← 改成 (r6-=0)<0&&(r6=0) r4=3*r6, (rw-=1)<0 && (rw=0) ← 改成 (rw-=0)<0&&(rw=0) )) // 【修改三】遊戲啟動讀取初始 swap 值 r4=3*(r6=_b("swaps")) ← 改成 r4=3*(r6=Math.max(_b("swaps"),99))
from flask import Flask, Response import requests app = Flask(__name__) GAME_JS_URL = "https://tower-swap.game-files.crazygames.com/tower-swap/96/game.js?113" @app.route("/game.js") def patched_game_js(): r = requests.get(GAME_JS_URL) code = r.text # 修改一:dE() 扣除 code = code.replace("r6-=1,rw=r6,r4=3*r6", "r6-=0,rw=r6,r4=3*r6") # 修改二:Boss 計時器扣除 code = code.replace( "(r6-=1)<0&&(r6=0),r4=3*r6,(rw-=1)<0&&(rw=0)", "(r6-=0)<0&&(r6=0),r4=3*r6,(rw-=0)<0&&(rw=0)" ) # 修改三:初始 swap 最少 99 code = code.replace( 'r4=3*(r6=_b("swaps"))', 'r4=3*(r6=Math.max(_b("swaps"),99))' ) return Response(code, content_type="application/javascript", headers={"Access-Control-Allow-Origin": "*"} ) if __name__ == "__main__": app.run(host="0.0.0.0", port=18080)
在瀏覽器開啟 http://伺服器IP:18080/game.js,用 Ctrl+F 搜尋 r6-=0,確認修改已套用。
http://伺服器IP:18080/game.js)覆蓋該檔案fetch('game.js?113').then(r=>r.text()).then(code=>{ console.log('r6-=0 存在:', code.includes('r6-=0,rw=r6')); console.log('r6-=1 存在:', code.includes('r6-=1,rw=r6')); });
| 嘗試方法 | 失敗原因 | 結果 |
|---|---|---|
| 直接修改全域變數 | 所有遊戲變數在 IIFE closure 內 | 失敗 |
| 攔截 gNativeSend | 變數為 null,closure 內部替換 | 失敗 |
| 攔截網路請求 | 遊戲用 localStorage,無網路儲存 | 失敗 |
| 攔截 localStorage.setItem | 遊戲 UI 讀記憶體變數,不讀 localStorage | 部分 |
| 攔截 J._updatefunc | J 物件也在 IIFE 內,外部無法存取 | 失敗 |
| fetch 攔截 game.js | game.js 用靜態 <script src> 載入,非 fetch | 失敗 |
| createElement 攔截 | script 標籤是靜態 HTML,不是動態建立 | 失敗 |
| Python Proxy + Firefox 網路覆蓋 | — | ✅ 成功 |