廢棄城市 FPS 逆向與外掛 - 筆記歸檔

Preface

「廢棄城市(Abandoned City FPS)」是 DUNGEON REBORN 同一位作者的新作,用 Godot 4.7.2 做的殭屍射擊遊戲,有 10 個關卡的殭屍模式和 12 人的單機大逃殺。原始碼公開在 GitHub。一開始網頁版放在作者的 GitHub Pages;2026-10-17 作者推出 v2.0 後拿掉了網頁版(8eeb89c),現在只在 itch.io 和 GitHub Releases 發行 Windows exe 與 Android APK。

結論先寫在前面:

  • 不管是當時的網頁版 index.pck,還是現在 Windows exe 內嵌的 pck,反編譯回來都跟 GitHub 原始碼一致。沒有加密、沒有混淆、沒有反作弊,也不連網驗證。
  • 存檔是純文字的 Godot ConfigFile,沒有簽章。排行榜是遊戲自己用固定名單加亂數產生的。
  • 外掛不改作者的任何檔案。Godot 啟動時會讀一份 override.cfg 覆寫專案設定,外掛就在這裡把自己登記成 autoload。網頁版時期由 Tampermonkey 在引擎啟動前把它塞進虛擬檔案系統;v2.0 起改成直接寫在 exe 旁邊。
  • 作者每次發行新版,siao-server 會自動下載 exe、反編譯,跑 86 項 Godot 無頭測試,再照玩家的步驟把外掛裝到作者的 exe 上實測一次。對不上就把錯誤交給 Claude 修,修好才推出新版。

外掛 repo:siao/abandoned-city-fps-mod。安裝步驟:

  1. 從 itch.io 或 GitHub Releases 下載遊戲 AbandonedCityFPS-windows.exe。
  2. 到外掛 repo 右上角登入(accounts.siao.ai 免費註冊),按「程式碼」→「下載 ZIP」。要用 git clone 的話,同一個選單有 clone 網址和存取權杖。
  3. 解壓縮後雙擊 windows\install.bat。整個資料夾放在遊戲 exe 旁邊會自動找到遊戲,找不到時會跳出視窗讓你選。移除就雙擊 windows\uninstall.bat。

外掛面板固定在遊戲畫面右上角,點標題列(或按 `、F2)收合、展開,各區塊也能個別收合,狀態會記住。遊戲進行中按 ` 放出滑鼠來點面板,要回到遊戲就按 Tab 或點畫面空白處,滑鼠會鎖回去,面板保持原樣。

分類 功能
生存 無敵(含大逃殺安全區毒圈)、隱身(殭屍不追不打、AI 看不到也打不到)、生命上限倍率、回滿血
武器 無限彈藥免換彈、無後座力無擴散、一擊必殺、傷害倍率、射速倍率、每發彈丸數倍率、射程倍率、直接拿任一把槍、加任意數量備用子彈
移動與速度 移動速度倍率、跳躍倍率、飛行(空白鍵上升、Shift 下降)、遊戲速度
刷怪與補給 在身邊刷普通/跑者/坦克/吐酸殭屍/膨脹者/Boss/隨機(可指定數量與強度波次)、自動刷怪(間隔與場上上限可調)、新生成殭屍的血量與速度倍率、丟彈藥或醫療補給
殭屍模式 凍結所有殭屍、技能無冷卻、技能自動全滿並突破、清除場上殭屍、跳到下一波、直接過關
大逃殺 跳傘立即著陸、暫停縮圈、凍結所有 AI、生成更多 AI 對手、傳送到安全區中心、淘汰所有對手(含還在跳傘的,算你的擊殺)、立即打開空投
存檔 擊殺喪屍幣倍率、喪屍幣加任意數量或直接設定、解鎖全部關卡、解鎖全部槍枝皮膚、解鎖並穿上全部裝備(含只有空投才拿得到的傳說級)

所有數值欄位都只有下限、沒有上限,可以直接輸入任意大的數字。遊戲中的熱鍵:U 無敵、I 回滿血、K 清怪/淘汰、N 下一波、J 刷怪、O 遊戲加速、Y 飛行、L 跳傘立即著陸。在數值欄打字時熱鍵不會觸發。v1.x 用的 G、H、T 在 v2.0 的大逃殺變成手榴彈、醫療包、護甲板,所以外掛改用 U、I、O。

(1)反編譯:pck 就是原始碼

網頁版的檔案只有幾個:index.html、index.js(Godot 的載入器)、index.wasm(引擎,39 MB)和 index.pck(遊戲本體,約 1.7 MB)。遊戲邏輯全部在 pck 裡。

pck 標頭寫著格式版本和引擎版本 4.7.2。匯出設定 script_export_mode=2,代表 GDScript 是以二進位 token 形式存放,不是純文字。這不是加密,GDRE Tools 可以還原:

gdre_tools --headless --recover=index.pck --output=out
Decompiled scripts:  24
Scripts not decompiled:  0

24 支腳本全部還原。把還原結果跟 GitHub 上的原始碼逐檔比對(去掉註解和空白),24 支裡 22 支完全相同,剩下兩支的差異只是格式:

  • 反編譯把 0.40 寫成 0.4;
  • 多行字串改用三個引號。

再拿 server 上的 Godot 4.7.2 編輯器,從 GitHub 原始碼直接匯出 pck,大小是 1709660 bytes,跟作者網頁版的 index.pck 一模一樣。所以這款遊戲目前不需要逆向:GitHub 上的原始碼就是線上跑的版本。

監看程式還是會每次反編譯、比對,因為 DUNGEON REBORN 就是在某一版之後才突然加上混淆。比對結果一旦不一致,就代表上線版本跟 GitHub 不同,這時反編譯結果才是唯一可信的來源。

v2.0 拿掉網頁版之後,比對對象換成 Windows exe。Godot 匯出的 exe 是「引擎 + 內嵌 pck」,GDRE Tools 可以直接吃 exe:

gdre_tools --headless --recover=AbandonedCityFPS-windows.exe --output=out
Decompiled scripts:  44
Scripts not decompiled:  0

v2.0 的腳本從 24 支增加到 44 支,同樣跟 GitHub 原始碼一致。

(2)遊戲結構與數值

三個 autoload 管全域狀態:Game(模式、關卡、分數、勝負)、Save(存檔、喪屍幣、裝備、抽獎)、Sfx(程式合成的音效)。主場景 main.tscn 底下有 Player、EnemySpawner、Skills、BR(大逃殺管理)和 UI。

武器 傷害 射擊間隔 彈匣/備彈 彈丸
AR-47 rifle 25 0.11 30/120 1
M9 pistol 38 0.2 12/96 1
SG-12 shotgun 15 0.85 6/42 8
MP-7 smg 16 0.065 40/200 1
AWM sniper 160 1.1 5/35 1
M60 lmg 28 0.09 100/200 1
0200400227AR-47190M9141SG-12246MP-7145AWM311M60
理論每秒傷害(傷害 × 彈丸 ÷ 射擊間隔,不計換彈與距離衰減)

傷害公式是 damage * damage_mult * equip_dmg * falloff。超過射程一半後,falloff 從 1.0 線性降到 0.35。命中判定在客戶端用 intersect_ray,沒有爆頭判定。

殭屍有三種:NORMAL(血量 60+15w)、RUNNER(35+8w,第 2 波起出現)、TANK(250+40w,第 3 波起出現),w 是波次減 1。每一關再乘上關卡的血量倍率:

血量倍率 ×10波數0102091010.5111212.513.51517205566778891012345678910
10 個關卡的殭屍血量倍率與波數

大逃殺是單機的,11 個對手都是 AI。安全區分 5 個階段縮小,圈外每秒扣血:

縮圈後半徑圈外每秒傷害 ×10010020070452612415254070110第 1 階段第 2 階段第 3 階段第 4 階段第 5 階段
大逃殺安全區:半徑(公尺)與圈外每秒傷害

剩自己一人時會降下空投,打開後從還沒擁有的稀有以上裝備抽一件,3.2 秒後結算勝利。

(3)存檔與防護

存檔在 user://profile.cfg,網頁版對應到瀏覽器的 IndexedDB。格式是 Godot 的 ConfigFile,就是 INI 風格的純文字,沒有雜湊或簽章:

  • [profile]:coins、owned_skins、owned_equip、equipped、cleared_level、level_best、大逃殺紀錄等;
  • [settings]:音量、靈敏度、FOV、觸控模式。

讀檔時會檢查裝備、皮膚是否存在且已擁有,但不檢查 coins 和 cleared_level 的範圍。排行榜 Leaderboard.entries() 用固定的 NAMES 和分數表加上每天的亂數抖動合成,不連任何伺服器。整個遊戲找不到 HTTPRequest 或 WebSocket。

所以這款遊戲沒有需要繞過的防線。外掛要解決的只有一件事:怎麼讓自己的程式碼跑進遊戲裡。

(4)外掛怎麼進到遊戲

v1.x 走網頁版,v2.0 起走 Windows exe,兩條路最後都落在同一個 Godot 機制:override.cfg。下面先講網頁版時期的做法,再講網頁版下架之後怎麼改。

1. 網頁版時期:瀏覽器的 JS 碰不到遊戲

DUNGEON REBORN 是純 JavaScript,外掛可以直接改遊戲物件。這款不行:遊戲邏輯是 GDScript,跑在 wasm 裡的 Godot 引擎中,player.health 這種變數只存在引擎的記憶體裡,頁面的 JS 看不到。

外掛只能用 GDScript 寫,再想辦法讓引擎載入它。Godot 的做法是 autoload:在專案設定的 [autoload] 區段登記一支腳本,引擎啟動時會把它建成全域節點,跟 Game、Save 一樣。外掛本體 mod/cheat.gd 就是這樣一個節點。

第一版:重建整個 pck

最直接的做法是把 cheat.gd 加進遊戲專案、在 project.godot 登記 autoload,用 Godot 無頭匯出新的 pck,使用者腳本再把遊戲要下載的 index.pck 換掉。

這條路走得通。匯出 pck 不需要 1.2 GB 的匯出模板,只要編輯器本身。使用者腳本先下載原版、算 SHA-256,跟 manifest 比對一致才換上外掛版,作者更新而外掛版還沒重建時就用原版啟動。Chromium 實測也通過了。

問題出在發布:使用者的瀏覽器要能下載外掛版 pck 和 manifest。git.siao.ai 對公網只放行逐一指定的路徑,每多開一個路徑都是一次公網曝光。改成下面的做法以後,只需要公開 .user.js 一個檔案。

2. 網頁版時期:在引擎啟動前放進檔案

Godot 網頁版的啟動流程在 index.js 裡。頁面執行 engine.startGame(),它做兩件事:載入 wasm,以及用 fetch 下載 index.pck 放進預載清單。兩邊都好了,引擎才把預載清單裡的檔案寫進虛擬檔案系統,然後執行 main。

engine.preloadFile(buffer, path) 是公開的 API,可以把任何資料排進同一份預載清單。Godot 啟動時,讀完主 pck 裡的 project.binary 之後,還會讀主 pck 同目錄的 override.cfg,用它覆寫專案設定。這是 Godot 給玩家改設定用的機制,[autoload] 也能寫在裡面。

所以外掛只要在預載清單裡多放兩個檔案:

檔案 內容
acmod/cheat.gd 外掛本體的 GDScript 原始碼
override.cfg [autoload] 底下一行 Cheat="*/acmod/cheat.gd"

匯出的遊戲照樣內建 GDScript 編譯器,純文字的 .gd 會在啟動時編譯。cheat.gd 裡用 preload("res://scripts/data/weapon_db.gd") 讀遊戲自己的資料表,也照常能用。

Tampermonkey(document-start) 換掉 window.fetch index.html:const engine = new Engine(...) engine.startGame() 載入器 fetch("index.pck") 外掛的 fetch 攔到 index.pck engine.preloadFile(cheat.gd) engine.preloadFile(override.cfg) 照常下載作者原版 index.pck 引擎把預載檔案寫進虛擬檔案系統 index.pck、acmod/cheat.gd、override.cfg Godot main:讀 project.binary 再讀 override.cfg autoload:Game、Save、Sfx、Cheat
外掛的載入順序

攔截點選在 index.pck 的 fetch,是因為只有這個時間點同時滿足兩個條件:engine 已經建好,而預載清單還沒寫進檔案系統。

    window.fetch = function (input, init) {
      const url = typeof input === 'string' ? input : (input && input.url) || '';
      if (/(^|\/)index\.pck(\?|#|$)/.test(url)) {
        try {
          const engine = (0, eval)('engine');   // index.html 裡的 const engine = new Engine(GODOT_CONFIG)
          const enc = new TextEncoder();
          engine.preloadFile(enc.encode(CHEAT).buffer, 'acmod/cheat.gd');
          engine.preloadFile(enc.encode('[autoload]\n\nCheat="*/acmod/cheat.gd"\n').buffer, 'override.cfg');
          console.log('[廢棄城市外掛] 已排入外掛檔案 v' + VERSION);
        } catch (e) {
          console.warn('[廢棄城市外掛] 注入失敗', e);
          notice('作者改了網頁載入方式,外掛這次沒有載入 (' + e.message + ')。server 會自動檢查修正。');
        }
      }
      return realFetch(input, init);

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/userscript.template.js L39-53

全域的 Engine 拿不到 prototype

一開始想直接呼叫 Engine.prototype.preloadFile,結果是 undefined。index.js 裡的全域 Engine 是一層包裝(SafeEngine),每次 new 都在裡面建一個真正的 Engine 實例再回傳。prototype 是建實例時才設定在內部那個建構式上的,外面拿不到。

改成拿頁面上的實例。index.html 寫的是 const engine = new Engine(GODOT_CONFIG),頂層 const 不會掛在 window 上,但同一頁的其他腳本可以用全域間接 eval 拿到:(0, eval)('engine')。攔到 index.pck 的時候 startGame() 已經在跑,engine 一定存在。

整個外掛最後是一個約 17 KB 的 .user.js,cheat.gd 以 JSON 字串內嵌在裡面,由 tools/make-userjs.py 從樣板產生。作者更新遊戲時不需要重建任何東西。只要遊戲的變數名稱沒變,同一份外掛照樣能用。這套使用者腳本留在外掛 repo 的 tag web-v1.3.0。

3. 網頁版下架之後:exe 旁邊的 override.cfg

作者在 8eeb89c 把 gh-pages 整個刪掉,GitHub Pages 變成 404,使用者腳本沒有頁面可以注入。改發行的 Windows exe 是 Godot 匯出的單一執行檔,pck 內嵌在 exe 尾端。

網頁版用到的 override.cfg 在桌面版一樣有效,而且不用搶時機。Godot 4.7.2 的 core/config/project_settings.cpp 在載入內嵌 pck 之後是這樣寫的:

Error err = _load_settings_text_or_binary("res://project.godot", "res://project.binary");
#ifdef OVERRIDE_ENABLED
	if (err == OK && !p_ignore_override) {
		// Load overrides from the PCK and the executable location.
		// Optional, we don't mind if either fails.
		bool disable_override = GLOBAL_GET("application/config/disable_project_settings_override");
		if (!disable_override) {
			_load_settings_text("res://override.cfg");
			_load_settings_text(exec_path.get_base_dir().path_join("override.cfg"));

OVERRIDE_ENABLED 在 SConstruct 裡預設開啟(disable_overrides=False),官方匯出範本都有;作者的 project.godot 也沒有設 disable_project_settings_override。所以只要在 exe 同一個資料夾放一份 override.cfg:

[autoload]
 
Cheat="*C:/Games/AbandonedCity/acmod/cheat.gd"

遊戲啟動時就會把外掛當成第一層 autoload 載入。autoload 的路徑可以是檔案系統的絕對路徑,不一定要在 res:// 底下。exe 一個位元組都不用改,作者更新時玩家直接換 exe,外掛照樣生效。

安裝交給 windows/install.ps1(install.bat 只是讓人可以雙擊的入口)。它把 cheat.gd 複製到遊戲資料夾的 acmod\,再把 Cheat= 那一行插進 override.cfg 的 [autoload] 區段。原本就有的其他設定會保留,重複安裝只會有一行,移除時也只刪這一行:

    # Godot 的路徑用正斜線
    $entry = 'Cheat="*' + ($dst -replace '\\', '/') + '"'
    # 已經有 [autoload] 區段就接在它下面,沒有就加在檔尾
    $out = New-Object System.Collections.Generic.List[string]
    $added = $false
    foreach ($l in $lines) {
        $out.Add($l)
        if (-not $added -and $l.Trim() -eq "[autoload]") {
            $out.Add($entry)
            $added = $true
        }
    }
    if (-not $added) {
        if ($out.Count -gt 0) { $out.Add("") }
        $out.Add("[autoload]")
        $out.Add("")
        $out.Add($entry)
    }
    Write-Override $cfg $out.ToArray()

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/windows/install.ps1 L86-104

Windows 內建的 PowerShell 5.1 與中文路徑

玩家多半用 Windows 內建的 PowerShell 5.1,它讀沒有 BOM 的 .ps1 時會用系統字碼頁解讀,中文字串會變成亂碼。所以 install.ps1 存成 UTF-8 BOM,語法也只用 5.1 有的功能(沒有三元運算子、沒有 ??)。

反過來,寫給 Godot 的 override.cfg 不能有 BOM,路徑也要換成正斜線。腳本用 [IO.File]::WriteAllText(..., New-Object Text.UTF8Encoding $false) 寫檔,不用 Out-File。

路徑含中文和空白(例如 下載\遊戲 資料夾)是測試的固定條件。在 server 上用免安裝版 Wine 執行作者真正的 Windows exe,override.cfg 指向這樣的路徑,exe 照樣印出「外掛 v2.0.0 已載入」。

本節來源:README.md

(5)外掛本體

cheat.gd 每一幀從遊戲的資料表重新計算數值,不保存「改之前的值」。關掉某個功能,就直接寫回資料表裡的原版數值,不會有還原失敗的狀態。

1. 無敵用護甲,不改 take_damage

玩家受傷的公式是 damage * (1.0 - armor),armor 由裝備決定,上限 60%。外掛把 armor 設成 1.0,傷害就是 0。安全區毒圈也走同一個 take_damage,所以一起擋掉。

GDScript 函式沒辦法從外面換掉,只能改它讀的變數。armor 只在玩家 _ready() 時讀一次裝備,之後不會被遊戲改寫,適合拿來用。每一局開始時,外掛記下這個玩家節點的原始 armor、移動速度和跳躍力,關掉無敵就寫回去。

func apply_player(p: CharacterBody3D) -> void:
	if S["god"]:
		p.armor = 1.0
		if not p.is_dead and p.health < p.max_health:
			p.health = p.max_health
			p.update_health_ui()
	else:
		p.armor = _base["armor"]
	p.move_speed = _base["speed"] * S["speed"]
	p.jump_velocity = _base["jump"] * S["jump"]

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/mod/cheat.gd L93-102

2. 武器數值每幀重寫

切槍時,遊戲的 _apply_slot() 會從 WeaponDB.WEAPONS 把傷害、射速、後座力全部重新載入,所以外掛不能只改一次,每一幀都要按倍率重算。

狂暴技能和無限彈藥共用同一個旗標

狂暴技能突破後會把 weapon.infinite_ammo 設成 true,結束時設回 false。外掛如果每幀都把它寫成 false,會把技能效果蓋掉。

所以外掛只在「無限彈藥」開著時寫 true,關掉的那一幀寫一次 false,其餘時間不碰,讓技能自己管。

3. 大逃殺淘汰要算玩家擊殺

AI 死亡時,die() 把 last_attacker 交給 bot_died() 判斷是誰的擊殺。沒有攻擊者會被當成「被安全區淘汰」,玩家拿不到喪屍幣。v1.x 是呼叫 take_damage(damage, 玩家) 讓遊戲自己記下攻擊者,淘汰全部對手後剩自己一人,遊戲會自己降空投、結算勝利。

v2.0 開局改成從 240 公尺高空跳傘,AI 也是空降。take_damage() 開頭多了一行:AI 還在 dropping 就直接 return。開局按淘汰,所有 AI 都還在空中,一個都打不死,自動測試就在這裡失敗(淘汰數 13,存活還是 12)。

v2.0.0 改成直接設定 last_attacker 再呼叫 die(),也順便跳過 AI 新增的護甲和護盾。另一個問題是空投:大逃殺管理器在玩家著陸之前,_process() 會提早 return,空投永遠不會開。所以淘汰之後如果玩家還在跳傘,外掛會讓玩家立即著陸。

func kill_all() -> int:
	var n := 0
	var p := player()
	for e in get_tree().get_nodes_in_group("enemies"):
		if not e.is_dead:
			e.take_damage(1000000000.0)
			n += 1
	# 大逃殺 AI 直接 die():跳傘中的 AI 不吃傷害 (take_damage 遇到 dropping 直接 return),
	# 護甲、護盾也會吸收。last_attacker 設成玩家才會算你的擊殺、拿喪屍幣。
	for b in get_tree().get_nodes_in_group("br_bots"):
		if not b.is_dead:
			b.last_attacker = p
			b.die()
			n += 1
	if n > 0 and Game.is_br():
		land_now()      # 跳傘中奪冠:遊戲要等玩家著陸才處理空投
	return n
 
 
# 大逃殺開局跳傘:直接落到正下方地面。玩家物理下一幀碰到地面就會照遊戲流程著陸。
func land_now() -> bool:
	var p := player()
	var br := scene_node("BR")
	if p == null or br == null or not bool(p.get("skydiving")):
		return false
	var g: Vector3 = br.ground_point(Vector3(p.global_position.x, 0.0, p.global_position.z), 0.0, 3.0)
	p.global_position = Vector3(g.x, g.y + 0.3, g.z)
	p.velocity = Vector3.ZERO
	p.dive_time = maxf(p.dive_time, 1.0)
	return true

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/mod/cheat.gd L259-288

「立即著陸」不直接發 landed 訊號,而是把玩家移到正下方的地面、清掉速度。下一格玩家的 _skydive_physics() 偵測到 is_on_floor(),就照遊戲自己的流程收傘、還原鏡頭和霧、發出 landed,大逃殺開始計時。

4. 鎖回滑鼠要在按鍵當下(網頁版)

這一節只適用於網頁版;Windows 版沒有瀏覽器的限制,Input.mouse_mode 設下去就鎖住了。

遊戲本身只在切換狀態時鎖滑鼠,Game.set_state(PLAYING) 才設 Input.mouse_mode = MOUSE_MODE_CAPTURED。在瀏覽器裡按 Esc,瀏覽器會先放開滑鼠,遊戲卻還停在 PLAYING,沒有路可以鎖回去。

Godot 網頁版鎖滑鼠最後呼叫的是 canvas.requestPointerLock()。瀏覽器只准在按鍵、點擊事件的當下呼叫它,Esc 本身也不算數。Godot 的輸入要排到下一格才處理,這時呼叫有時會被擋掉。

所以鎖定分兩邊做。cheat.gd 用 JavaScriptBridge.eval 把「是否在遊玩中、面板是否展開」寫進 window.__acmodUI。使用者腳本在 keydown 的當下讀這個值,按 Tab(或面板開著時按 ` 收合)就直接 requestPointerLock()。點畫面空白處則交給 _unhandled_input:面板和遊戲按鈕會先吃掉點擊,只有點到空白處才會走到這裡,而且放開時才鎖,這一下不會被當成開槍。

    // 鎖回滑鼠:瀏覽器只准在按鍵、點擊事件的當下鎖滑鼠,Godot 要到下一格才處理輸入,有時會被擋,
    // 所以 Tab (回遊戲) 和面板開著時按 ` / F2 (收合),這裡在按鍵當下直接鎖。遊戲狀態由 cheat.gd 寫進 window.__acmodUI。
    addEventListener('keydown', (e) => {
      const ui = window.__acmodUI;
      if (!ui) return;
      if (e.code === 'Tab') e.preventDefault();   // 不要讓 Tab 把焦點移出遊戲畫面
      if (e.repeat || !ui.playing || document.pointerLockElement) return;
      if (e.code !== 'Tab' && !((e.code === 'Backquote' || e.code === 'F2') && ui.open)) return;
      const canvas = document.getElementById('canvas');
      try {
        const p = canvas && canvas.requestPointerLock && canvas.requestPointerLock();
        if (p && p.catch) p.catch(() => {});
      } catch (_) {}
    }, true);

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/userscript.template.js L39-52

5. 刷怪、隱身與大逃殺加 AI

刷怪沒有呼叫遊戲的 EnemySpawner.spawn_enemy(),因為它的種類由 _pick_type() 依波次抽籤決定。外掛自己 instantiate() 殭屍場景,在 add_child 之前呼叫 setup(type, wave),再乘上關卡的血量和速度倍率。順序不能反過來,因為殭屍的 _ready() 會把 max_health 複製成目前血量。生成點比照遊戲的作法,從上往下打射線,打到屋頂就換一個點,避免殭屍卡在建築裡。

大逃殺模式只停掉 EnemySpawner,殭屍場景本身照樣能用,所以大逃殺也能刷殭屍。刷出來的殭屍會計入殭屍模式的波次:場上殭屍要全部清光,遊戲才會進下一波。

# 刷怪:在玩家周圍 8~16 公尺生成殭屍。type 0 普通、1 跑者、2 坦克、3 隨機。
# 強度用指定波次 (0 = 目前波次) 和關卡倍率計算,大逃殺也能刷。擊殺照常算分數和喪屍幣。
# 殭屍模式要清光場上殭屍 (含刷出來的) 才會進下一波。
func spawn_zombies(n: int, type: int = 3) -> int:
	var p := player()
	var scene := get_tree().current_scene
	if p == null or scene == null or not in_run() or n <= 0:
		return 0
	var wave: int = int(S["spawn_wave"]) if int(S["spawn_wave"]) >= 1 else maxi(Game.wave, 1)
	var cfg: Dictionary = Game.level_cfg
	for i in n:
		var e := ENEMY_SCENE.instantiate()
		e.setup(type if type >= 0 and type <= 2 else randi_range(0, 2), wave)
		e.max_health *= float(cfg.get("hp_mult", 1.0))
		e.move_speed *= float(cfg.get("speed_mult", 1.0))
		e.position = ground_near(p.global_position, 8.0, 16.0)
		scene.add_child(e)
	return n

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/mod/cheat.gd L359-376

隱身分兩邊做。殭屍只看兩個距離:attack_range 內攻擊、detection_range 內追擊,外掛把兩個都設成 0。大逃殺的 AI 用 combatants 群組找目標,子彈也只對這個群組的對象造成傷害,所以外掛把玩家移出這個群組,AI 就看不到也打不到你。

生成 AI 對手時照抄遊戲 _spawn_bots() 的設定,存活人數跟著加。這些 AI 被擊殺時照樣算喪屍幣。「暫停縮圈」利用 autoload 排在主場景前面:每一幀外掛先把 stage_time 加上 delta,接著大逃殺管理器再扣掉同樣的量,計時就停住了。

v2.0 新增了吐酸殭屍 SPITTER、膨脹者 BLOATER 和 Boss,EnemyAI.Type 變成 NORMAL, RUNNER, TANK, BOSS, SPITTER, BLOATER。外掛的刷怪選單原本第 4 項是「隨機」,存在設定檔裡的值是 3,剛好撞上新的 BOSS = 3。所以選單維持「隨機」在第 4 項,新種類接在後面,再用一張表對應到遊戲的編號,舊設定讀進來還是隨機。外掛刷的 Boss 有名字才會顯示 Boss 血條,外掛會幫它取名。

存檔類功能直接呼叫遊戲自己的 Save 方法,再 Save.save() 寫回。外掛自己的設定另外存在 user://acmod.cfg(Windows 是 %APPDATA%\Godot\app_userdata\AbandonedCityFPS\),不碰遊戲存檔。

(6)測試

1. Godot 無頭測試

遊戲自帶一支冒煙測試 tests/smoke.gd,用 godot --headless -s 執行,會自動開局、選技能、殺光殭屍直到勝利。外掛的 tools/test.gd 沿用同樣的開局方式,再逐項驗證外掛:

  • 殭屍模式 41 項:無敵、倍率套用與還原、拿槍、無限彈藥、一擊必殺、存檔解鎖、面板與區塊收合、遊戲中鎖回滑鼠、數值欄無上限、彈丸與射程、生命上限、飛行、遊戲速度、刷怪(含吐酸殭屍、膨脹者、Boss)、隱身、喪屍幣倍率、補給、熱鍵、技能全滿、技能無冷卻、凍結、清怪算擊殺、跳波、直接過關等;
  • 大逃殺 45 項:共用的檢查,加上跳傘立即著陸、生成 AI、暫停縮圈、凍結 AI、淘汰所有對手、擊殺歸屬、空投降落與開啟、奪冠結算。

v2.0 的遊戲改用 Game.gameplay_boot 判斷「這次載入是開始遊戲」,沒設的話關卡不會建、大逃殺也不會開局。測試比照遊戲自己的 tests/smoke.gd,在建主場景之前設成 true。擊殺也會順帶觸發成就獎勵(例如「初次見血」+10 喪屍幣),所以喪屍幣倍率的檢查改成「至少多拿到 3 倍」。

tools/test.sh 把遊戲專案複製一份,在 project.godot 登記 Cheat autoload,跑兩個模式,一輪約 10 秒。

2. 發行版實測

無頭測試用的是 GitHub 原始碼,不會經過玩家的安裝流程。tools/release-test.sh 拿作者發行的 exe,照玩家的步驟再測一次:

  1. 建一個「遊戲 資料夾」(中文加空白),放進作者的 exe,再把外掛 repo 的 mod/、windows/ 放在旁邊,等同玩家下載 ZIP 解壓;
  2. 用 PowerShell 7 執行 windows/install.ps1,確認它自己找到遊戲、寫好 override.cfg;
  3. 用 Linux 版 Godot 4.7.2 以 --main-pack 讀作者 exe 內嵌的 pck。--main-pack 會讀 pck 同資料夾的 override.cfg,走的是同一條載入路徑。殭屍模式、大逃殺各跑一輪 test.gd;
  4. 執行 install.ps1 -Uninstall,確認 override.cfg 和 acmod\ 都清掉。

兩個模式在作者的 v2.0 exe 上都是 41 項、45 項全部通過。另外用 Wine 直接執行作者的 Windows exe,確認 Windows 版引擎也會讀 exe 旁的 override.cfg、從中文路徑載入外掛。完整的 test.gd 在 Wine 裡沒有跑完:外掛載入後,測試卡在開局前,CPU 使用率為 0,看起來是遊戲的分幀載入在 Wine 無頭模式下不會往下走。所以 Windows 上的實際操作,目前只驗到「外掛有載入」這一步。

3. 網頁版時期的 Chromium 實測(已停用)

無頭測試驗的是邏輯,不會經過網頁載入。tools/browser-test.mjs 用 Playwright 開 headless Chromium(SwiftShader 提供 WebGL 2),載入作者的線上頁面,模擬 Tampermonkey 執行使用者腳本。它檢查四件事:外掛檔案有排進預載清單、Godot 印出外掛已載入、cheat.gd 有把遊戲狀態寫進 window.__acmodUI、console 沒有任何 GDScript 錯誤。

v1.2.0 另外手動跑過一次完整流程:開局選技能後,模擬 Esc 放開滑鼠,再分別按 Tab、點畫面空白處、按 ` 收合,三種方式都能鎖回去;點面板上的選項則維持放開。這段流程要點作者的選單座標,作者改版面就會失準,所以沒放進自動測試。

故意在 cheat.gd 放一個語法錯誤再跑一次,Godot 會印出 Parse Error 和「Failed to instantiate an autoload」,兩項檢查都失敗。

Playwright 的 init script 比 <html> 還早

第一次實測,攔截完全沒有觸發。原因是 Playwright 的 addInitScript 跑得比 <html> 元素建立還早,document.documentElement 是 null,使用者腳本要插入的 <script> 沒有地方放。

Tampermonkey 的 document-start 時 <html> 通常已經存在,所以這是測試環境的差異。測試裡的 GM_addElement 替身改成直接在頁面環境執行腳本內容。使用者腳本本身也補了退路:沒有 <html> 時改用不帶父節點的 GM_addElement('script', …),由 Tampermonkey 自己決定放哪裡。

4. 假通過

自動修正流程上線前,先模擬了一次「作者把 infinite_ammo 改名成 unlimited_ammo」。流程把錯誤交給 Claude,Claude 正確修好了 cheat.gd,測試也印出「全部通過」,但項數只有 16 和 15。

原因是 test.gd 自己也讀了 w.infinite_ammo。在新版遊戲上,這一行會發生執行錯誤,GDScript 直接跳過那個函式剩下的檢查。沒跑到的檢查不會記成失敗,所以「沒有 FAIL」不代表全部通過。

現在有兩道檢查:test.gd 結束時核對每個模式應有的項數,test.sh 看到 log 裡有 SCRIPT ERROR 就判定失敗。

var passed := 0
# 每個模式應該跑到的檢查數。檢查中途出現執行錯誤時,GDScript 會跳過該函式剩下的檢查,
# 只看「沒有 FAIL」會變成假通過,所以結束時要對項數。新增或刪除檢查時要一起改。
const EXPECTED := {"zombie": 23, "br": 22}

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/test.gd L13-16

同時讓 Claude 在遊戲成員只是改名時,也可以同步更新 test.gd。重跑模擬後,Claude 一次改好 cheat.gd 和 test.gd 兩處,測試是完整的 23 項和 22 項通過。

(7)作者更新時

siao-server 上的 systemd timer 每 15 分鐘執行一次 watch.sh。沒有更新時只做一次 git ls-remote 和一次 GitHub releases API。

網頁版時期追的是 gh-pages 的 commit。作者刪掉 gh-pages 之後,監看改追最新 release 裡 Windows exe 的 sha256(GitHub API 直接給)。v2.0 的 release 在 07:50 UTC 建立,exe 到 08:49 才上傳,作者可能在同一個 tag 底下換檔,只看 tag 會漏掉。

git ls-remote + releases/latest: master、tag、exe 的 sha256 有變? 下載 exe、核對 sha256 GDRE Tools 反編譯內嵌的 pck exe 變了 跟 GitHub 原始碼、上一版各比一次 claude -p 寫中文分析 tools/test.sh(Godot 無頭 86 項) tools/release-test.sh(作者的 exe 安裝實測) claude -p 修 cheat.gd 只給 Read、Edit、Glob、Grep 最多 2 輪 失敗 CHANGELOG.md、部落格版本紀錄 推 SiaoHub、推部落格 通過 升小版號 修好且重測通過 維持舊版,紀錄等待人工處理 2 輪都沒通過
作者發行新版後的流程

給 Claude 的資料包含測試輸出、反編譯分析、新舊發行版的 diff 和新版遊戲專案(唯讀副本)。它不能執行指令,修完由流程重跑全部測試。不准它刪功能來讓測試通過,也不准它改版本號或安裝腳本。

這個工作階段不能執行指令,測試會在你結束後自動重跑。
不要刪掉功能來讓測試通過;某個功能在新版確實做不到時,保留其他功能並在 cheat.gd 註解說明。
不要改 windows/ 的安裝腳本,也不要改版本號。

在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/update.sh L51-53

v2.0 那次,網頁版下架前還有兩個版本經過自動修正。第一次(6cb5e9d)Claude 沒找出原因,測試卡在開局;第二次(5549172)它找出 gameplay_boot,殭屍模式通過了,但沒發現跳傘中的 AI 不吃傷害。兩次都沒過,流程照設計停下來等人工處理。後面的跳傘修正、熱鍵衝突和改成 Windows 版,都是人工做的。改版後的新流程第一次自己跑,就判定外掛 v2.0.0 跟作者的 exe 直接相容。

推送用一把專用的 deploy key,只能寫入外掛 repo。部落格用的是 DUNGEON REBORN 那套既有的 deploy key,兩個監看程式共用一把鎖,不會同時推。

版本紀錄

2026-10-18 06:33 UTC · 發行版 v2.1.2 392eacee9db6

v2.1.2 修正殭屍關卡尾聲找不到敵人的問題,新增敵人保底傳送與最近敵人方位提示。

外掛 v2.0.0 直接相容,Godot 無頭測試與發行版安裝實測全部通過(3e3f639)。

2026-10-18 05:33 UTC · 發行版 v2.1.1 631667ea2037

v2.1.1 只調整設定選單介面排版,玩法、數值與防護皆無變動,發行版與原始碼一致。

外掛 v2.0.0 直接相容,Godot 無頭測試與發行版安裝實測全部通過(661a264)。

2026-10-17 19:04 UTC · 發行版 v2.1 5e88a82f1c02

v2.1 只修大逃殺 AI 卡關與全螢幕版面,加入決賽圈超時保底,無新增防護。

外掛 v2.0.0 直接相容,Godot 無頭測試與發行版安裝實測全部通過(d86f286)。

2026-10-17 14:34 UTC · 發行版 v2.0 b84196f25794

監看改追 Windows 發行版後的第一次檢查。exe 內嵌的 pck 反編譯出 44 支腳本,跟 GitHub 原始碼一致。

外掛 v2.0.0 直接相容,Godot 無頭測試與發行版安裝實測全部通過(b2f8797)。

2026-10-17 · 外掛 v2.0.0(人工)

作者拿掉網頁版,只發行 Windows exe 與 Android APK。外掛改成 Windows 安裝包,用 exe 旁的 override.cfg 載入;修正跳傘中淘汰不了 AI,熱鍵避開大逃殺的 G/H/T,刷怪加入吐酸殭屍、膨脹者和 Boss,新增跳傘立即著陸(85ea7c4、f582bd7)。Android 版要重新打包 APK、換簽章,這次沒有做。

2026-10-17 08:39 UTC · 網頁版 5549172

新增預烘焙音檔與分幀載入畫面,修正網頁凍結與無聲;數值與防護未變,網頁版與原始碼一致。

外掛 v1.3.0 對這一版測試未通過,自動修正 2 輪也沒成功,等待人工處理(59705ae)。

2026-10-17 08:08 UTC · 網頁版 6cb5e9d

v2.0 加入成長系統、槍枝配件、大逃殺跳傘與道具,資料中未見新增防護。

外掛 v1.3.0 對這一版測試未通過,自動修正 2 輪也沒成功,等待人工處理(09dfa2d)。

2026-10-16 03:03 UTC · 網頁版 ef149b1

這次更新只做效能優化(特效物件池、距離 LOD、網頁低規格模式),沒改玩法數值,也沒新增防護。

外掛 v1.0.0 直接相容,Godot 無頭測試與 Chromium 實測全部通過(eb804e5)。

2026-10-16 · 網頁版 4bb6478

作者加入 Noto Sans TC 中文字型子集,設為介面預設字型,讓中文在網頁版能正常顯示。玩法和數值沒有變。

外掛 v1.0.0 首版(cccc960)。

本節來源:README.md