審查日期: 2026年08月20日
審查者: 戰鬥陀螺每日 Code Review(Claude Opus 5)
審查範圍: 215479a2..36dbea63(88 顆 non-merge commit、166 個檔案,其中程式碼 76 個)
驗證狀態: npx tsc --noEmit 綠(exit 0)/npx vitest run 紅(22 檔 77 顆)/npx vite build 成功
本次審查涵蓋陀螺遊戲本體 76 個程式碼檔,主要涉及開發面板改成 runtime 判斷、廚師怪(ChampionChef)與死靈法師新怪、限時刀刃 buff 規則移除、票券數字改走 font atlas、以及物件池與 dev server 中介層的調整。
共 17 條:🟥 Critical 4 條/🟧 Warning 8 條/🟩 Suggestion 5 條。
四條 Critical 中,三條是「main 現在就是紅的」——本批把測試從 21 檔紅推到 22 檔紅,新增了 3 個紅檔共 12 顆失敗。另一條是正式站會掛上開發面板、它的 Numpad 監聽和 P4 的方向鍵撞在同四顆鍵上。
⚠️ 關於紅色基線:基準 215479a2 本身就有 21 檔/75 顆紅。我沒有逐一判定那 75 顆是真紅還是環境造成,所以本報告只主張基準與 HEAD 的差集。但這件事本身值得提:紅色基線會把新的紅蓋掉——底下那三個新紅檔混在 21 個舊紅裡,不做基準對照根本看不出來。
檔案: web/Game/Script/Stage/Fight/FightUi/Components/Ui/ToonPanel.tsx [83ba432d] [ACD_RD1謝岱錡]
相關: web/Game/Script/Stage/Fight/FightUi/DevToolsFlag.ts [673e0163] [daone_barry](把開發工具改成 opt-out 的那顆)
問題: 673e0163 把開發工具改成 opt-out(dev !== '0'),所以沒帶任何參數的正式站也會掛上 DevOverlays,連帶掛上 ToonPanel。ToonPanel 有兩個 keydown 監聽,Comma/Period 那個有 if (!visible) return 保護,handleNumpad 那個沒有。它吃的四顆鍵正好是 P4 的方向鍵(TopConfig.ts:149 → up:'Numpad5', down:'Numpad8', left:'Numpad6', right:'Numpad4'):
| 鍵 | ToonPanel 做的事 | 同一顆鍵在玩法層 |
|---|---|---|
Numpad5 | 燈光 target → (0,0,0) | P4 上 |
Numpad8 | 燈光 target → (0,0,1000) | P4 下 |
Numpad6 | 燈光 target → (1000,0,0) | P4 左 |
Numpad4 | 燈光 target → (-1000,0,0) | P4 右 |
而且它同時 lights.directional.position.set(0, 500, 0)。__webglLights 在 RenderPipeline.ts 是無條件掛上 window 的(不是 DEV 專屬),所以那句 if (!lights) return 早退擋不住。
這正是這份 code 自己的註解在警告的形狀——DevOverlays.tsx 檔頭就寫著「ToonPanel 的按鍵監聽就是這個形狀造成的:面板收合(看不見)卻照樣攔截玩家按鍵」。同檔的 Comma/Period effect 修好了,handleNumpad 漏了。
驗證狀態: 🔴 這條是讀碼推論,我沒有實機按過。 要坐實只需要:開沒帶參數的站、P4 按上下左右,看場景陰影方向會不會跳。
建議修正:
// Before: (ToonPanel.tsx Line ~270–293)
// 九宮格數字鍵快速設定光源 target 位置(測試用)
useEffect(() => {
const handleNumpad = (e: KeyboardEvent) => {
const lights = (window as any).__webglLights;
if (!lights) return;
// ...
};
window.addEventListener('keydown', handleNumpad);
return () => window.removeEventListener('keydown', handleNumpad);
}, [rerender]);
// After: 跟同檔 Line ~398 的 Comma/Period effect 一致,面板收合就不註冊
useEffect(() => {
if (!visible) return;
const handleNumpad = (e: KeyboardEvent) => {
const lights = (window as any).__webglLights;
if (!lights) return;
// ...
};
window.addEventListener('keydown', handleNumpad);
return () => window.removeEventListener('keydown', handleNumpad);
}, [visible, rerender]);
⚠️ 需要裁示的是另一半:「dev tools 預設開啟」是 barry 在 commit message 裡明講的決定,我不推翻。但同一個 DevOverlays 還會在正式站啟動 ToonPanel 每 200ms 的 setInterval(checkBg, 200)、PrefabViewer 與 WebGL2DTestPanel 各自的 requestAnimationFrame 迴圈。正式站要不要保留這批面板與它們的迴圈,需要決定。
檔案: web/Game/Script/System/SpinningTopSystem/Systems/TimedBladeBuff.test.ts [e6e5a546] [daone_barry]
問題: e6e5a546(08-19 14:48)加了這支測試,守「火焰/刀刃期間不累積碰撞計量、也不自動觸發」。e8ff8a63(08-19 17:15)把那條規則整個從 ChargeAimSystem.ts 與 GameLoop/Tops.ts 拿掉,測試沒跟著改。現在 tryAutoTriggerChargeDash() 回 true,測試斷言 false。
⇒ 這是「規則被裁掉、守衛還在」,不是測試寫錯。 修法不是改數字讓它變綠。
證據(可重跑): cd web && npx vitest run Game/Script/System/SpinningTopSystem/Systems/TimedBladeBuff.test.ts
→ ✗ 火焰生效時不累積… / ✗ 刀刃生效時不累積… / ✗ 集滿飛行途中才取得buff…
建議: 規則已經被裁掉,這三顆應該刪掉,或改成反向斷言(明寫「現在就是會累積」)。同時處理下面 🟧 的 blocksCollisionChargeMeter 孤兒函式——那是同一件事的另一半。
檔案: web/Game/Script/System/SpinningTopSystem/Systems/SpawnDirector.test.ts [ade23013] [daone_barry]
問題: 死靈法師接進 SpawnDirector.spawnMechanic()(if (type === 'zombie_necromancer'))之後,測試檔的 vi.mock('./SpawnSystem') 工廠沒有補上 spawnNecromancerZombie,vitest 直接丟:
Error: [vitest] No "spawnNecromancerZombie" export is defined on the "./SpawnSystem" mock
🔴 連帶後果:這支測試整個跑不起來 ⇒ SpawnDirector 的 boss 輪替新邏輯目前有 0 測試保護。
建議修正:
// Before: (SpawnDirector.test.ts Line ~45–49)
vi.mock('./SpawnSystem', () => ({
spawnBombZombie, spawnGoldenZombie, spawnMechaKongBoss, spawnChest,
trySpawnBladeCartonBoy, spawnUnderbossZombie,
spawnWaveWalkingZombie, spawnCarZombie,
}));
// After:
vi.mock('./SpawnSystem', () => ({
spawnBombZombie, spawnGoldenZombie, spawnMechaKongBoss, spawnChest,
trySpawnBladeCartonBoy, spawnUnderbossZombie,
spawnWaveWalkingZombie, spawnCarZombie,
spawnNecromancerZombie, // ← 補這個(上面同時要宣告對應的 vi.fn())
}));
檔案: web/Game/Script/System/SpinningTopSystem/Quest/QuestDisabledRow.test.ts [ca170059] [daone_barry](測試檔本身本批未改)
觸發來源: web/Game/DynamicResource/Data/Quest/QuestData.json [df8989ea] [daone_barry](加了廚師任務列 12/13/14)
問題: 這支測試守的意圖是「含炸彈怪(3)或盜墓者(5)的任務列必須停用」,但它是把結果釘死在一個 ID 清單上:
const enabledIds = rows.filter(r => r.enabled !== false).map(r => r.ID);
expect(enabledIds).toEqual([10]);
⇒ df8989ea 一加任務列就紅。這種寫法每加一列資料就要改一次測試,而且改的人分不出「這次紅是真的違規,還是又多了一列」。
建議修正:
// Before:
const enabledIds = rows.filter(r => r.enabled !== false).map(r => r.ID);
expect(enabledIds).toEqual([10]);
// After: 直接斷言那條規則本身,不釘死清單
const enabled = rows.filter(r => r.enabled !== false);
for (const row of enabled) {
expect([row.pos0_enemyType, row.pos1_enemyType, row.pos2_enemyType])
.not.toContain(3);
expect([row.pos0_enemyType, row.pos1_enemyType, row.pos2_enemyType])
.not.toContain(5);
}
檔案: web/Game/Script/System/SpinningTopSystem/Zombie/ChefZombie.ts [5587f9c4] [GIPONHSU]
相關: 同目錄 BigZombie.ts / BouncingZombie.ts / CarZombie.ts / BombZombie.ts [d36043fa] [ACD_RD1 黃信霖]
問題: 「全場同時最多一隻怪在攻擊」這個互斥是五個檔案各抄一份同樣的 filter。新加的 ChefZombie.ts 那份有 isChefAttacking,另外四份沒有。
⇒ 廚師會等別人打完;大怪/木乃伊/車怪/炸彈怪不會等廚師。 廚師轉圈攻擊時它們照樣開攻,互斥實際上是破的。
建議修正:
// Before: (BigZombie.ts Line ~186–192,另外三支同款)
const attackingCount = engine.zombies.filter(other => {
if (other.id === big.id) return false;
const isBigAttacking = (other.type === 'zombie_big' || other.type === 'zombie_bomb') && ...;
const isBouncingAttacking = other.type === 'zombie_bouncing' && ...;
const isCarAttacking = (other.type as string) === 'zombie_car' && ...;
return isBigAttacking || isBouncingAttacking || isCarAttacking; // ← 少了廚師
}).length;
// After: 抽成 Systems/ 底下一支共用函式,五個 caller 都改呼叫
// Systems/ZombieAttackMutex.ts
export function isAnyZombieAttacking(engine: GameEngine, selfId: string): boolean { ... }
// 各 Zombie 檔
if (!isAnyZombieAttacking(engine, big.id) && !globalCooldownActive) { ... }
⚠️ 重點不只是補一項:現在這個形狀,每加一種會攻擊的怪就要記得改另外 N 個檔案,漏了不會有任何訊號。
檔案: web/Game/Script/System/SpinningTopSystem/Systems/TimedBuffSystem.ts [e6e5a546] [daone_barry]
問題: e8ff8a63 把三個呼叫端全部移除(ChargeAimSystem.registerChargeHit / tryAutoTriggerChargeDash / completeChargeCollectFlight,以及 GameLoop/Tops.ts 那段)。這支函式現在只剩測試在呼叫,但 docstring 仍寫著「這兩種限時攻擊能力生效時,碰撞計量條暫停且不得自動觸發」——那句話在現行程式裡已經不成立。
同時,實際的「屬性道具互斥」判斷改到了 CollisionSystem.ts:2160 附近,用的是另一組謂詞(hasActiveAttributeEquipment + hasEquippedBladeParts,看 rapidAxisTimer 與 guard_top),跟 blocksCollisionChargeMeter(看 comboBladeTimer 與 bladeBuffTimer)不是同一套條件。兩份定義並存、其中一份沒人用——正是 wiki/conventions/duplicated-contract-drift.md 講的形狀。
證據(可重跑):
cd web && grep -rn "blocksCollisionChargeMeter" --include='*.ts' . | grep -v node_modules
# 只命中 Systems/TimedBuffSystem.ts(定義)與 Systems/TimedBladeBuff.test.ts(測試)
陽性對照(確認查法有效):同一支 grep 對 getBladeAttackMultiplier 會命中 Systems/GameUtils.ts:71 的生產呼叫端 ⇒「找不到 = 真的沒有」,不是我的查法壞掉。
建議: 連同上面 TimedBladeBuff.test.ts 那三顆一起處理——規則被裁掉就把 helper 刪掉。留著一支沒人呼叫、docstring 還在描述舊規則的函式,下一個人讀它會以為那條規則還在。
檔案: web/Game/DynamicResource/Data/Animation/ChampionChef.anim.json [05af0157] [ACD_RD1謝岱錡]
相關: web/Game/Script/System/StageSystem/Bridge/AnimJsonRegistry.ts [7eadf65d] [ACD_RD1謝岱錡]
問題: ChampionChef.anim.json 的 behavior 宣告了四個參數,AnimJsonRegistry 也會把它們解析出來(還給了預設值),但全 repo 沒有任何玩法程式讀它們。實際生效的是寫死的值,而且四個都對不上:
| 欄位 | JSON 寫的 | 實際生效 | 生效位置 |
|---|---|---|---|
killProbability | 0.5 | 1/10 | GameUtils.applyDamageToZombie(廚師被併進 zombie_bouncing 分支) |
killHardCap | 4 | 20 | 同上(currentHits >= 20) |
warningDuration | 1.5 | 1.0 | ChefZombie.ts 的 chefAttackTimer = 1.0 |
attackRange | 120 | 250(警示圈) | Mappers/Effects.ts 的 chef_attack_warn 與 ChefZombie.ts |
⇒ 美術/企劃在編輯器改廚師這四個數字,遊戲裡不會有任何反應,也不會有任何訊息。 AGENTS.md 的「控制項沒有作用仍是 bug」正好講這個。
證據(可重跑):
cd web && grep -rn "killProbability\|killHardCap\|warningDuration\|attackRange" \
--include='*.ts' Game/Script/ | grep -v '\.test\.'
# 只有 AnimJsonRegistry.ts / StageConfig.ts 的型別與預設值,沒有玩法端 reader
陽性對照:同一支 grep 對 radius/collisionOffsetX 會命中 SpawnSystem.resolveZombieRadius/resolveZombieCollisionOffset 兩個真的 reader ⇒ 這支 grep 抓得到「有 consumer」的情況。
建議: 二選一,不要維持現狀——(a) 把四個欄位接上(applyDamageToZombie 與 ChefZombie 改讀 registry);(b) 這一批不接就從 ChampionChef.anim.json 拿掉這四個欄位,讓編輯器不要顯示不會生效的控制項。⚠️ 走哪一條需要裁示。
檔案: web/Game/Script/System/SpinningTopSystem/GameLoop/PhysicsCollisions.ts [5587f9c4] [GIPONHSU]
問題: isFixedWaitingTop 的條件是 isZeroHpWaitingTop 的真子集(多一個 isDeadState === true),而 top 的 hp 全 repo 都用 Math.max(0, ...) 夾住、不會是負值(CollisionSystem.ts:1055/1863、GameLoop/Tops.ts:872/2256)⇒ hp <= 0 ⟺ hp === 0 ⇒ 第二個分支永遠進不去。
結果是「待機陀螺仍可被撞到」這個行為被整個關掉了,但那行註解還留在原地說它還在。
建議修正:
// Before: (PhysicsCollisions.ts Line ~57–63)
// 0 能量待機陀螺完全不進碰撞網格(幽靈狀態,可穿透)
if (isZeroHpWaitingTop(top)) continue;
// 待機中的陀螺仍進碰撞網格——讓 active top 能撞到它 ← 這行註解描述的行為已經進不去了
if (isFixedWaitingTop(top)) {
addToGrid(engine.tops[i]);
continue;
}
// After(若「0 能量陀螺完全穿透」就是要的行為):
// 0 能量待機陀螺完全不進碰撞網格(幽靈狀態,可穿透)
if (isZeroHpWaitingTop(top)) continue;
// isFixedWaitingTop 與它的分支一併刪除(條件是上一行的真子集,永遠不會成立)
⚠️ 需要裁示:如果只想讓「還沒回到起點的 0 能量陀螺」穿透、被撞的行為要保留,那要改的是 isZeroHpWaitingTop 的條件,不是刪 isFixedWaitingTop。這是玩法差異,我不自行決定。
檔案: web/Game/Script/Tools/IGSWebEngine/Painters/ImageCache.ts [b931278d] [daone_barry]
相關: web/Game/Script/System/UIRenderCore/fontAtlasCanvas.ts [b931278d] [daone_barry]
問題: 改版前 drawFlyingTicketNumber 是 ctx.fillText,只要函式被呼叫就一定畫得出數字。改版後走 font atlas:圖是 new Image() 懶載入的,drawFontAtlasText / paintSprite 都在圖沒 complete 時靜默 return——沒有 onerror、沒有 console.warn、沒有退回 fillText。
⇒ 圖路徑打錯或 404 時,票券數字完全不出現,而且沒有任何線索。 這個專案 8/19 才因為圖片路徑搬家吃過一次「本機正常、線上整片破圖」(c3abdedb),是同一種失敗形狀。
建議修正:
// Before: (ImageCache.ts)
export function getPainterImage(src: string): HTMLImageElement | null {
if (!src || typeof Image === 'undefined') return null;
let image = imageCache.get(src);
if (!image) {
image = new Image();
image.src = src;
imageCache.set(src, image);
}
return image;
}
// After: 載入失敗時出一次聲(帶 src),不要讓它靜默消失
export function getPainterImage(src: string): HTMLImageElement | null {
if (!src || typeof Image === 'undefined') return null;
let image = imageCache.get(src);
if (!image) {
image = new Image();
image.onerror = () => console.warn(`[ImageCache] 載入失敗:${src}`);
image.src = src;
imageCache.set(src, image);
}
return image;
}
要更保險就在 drawFontAtlasText 的早退處加一個 ctx.fillText fallback——票券數字是玩家看得懂輸贏的東西,寧可字型醜也不要不見。
檔案: web/Game/Script/System/SpinningTopSystem/GameEngine.ts [cc93597d] [daone_barry]
問題: handle 沒存、destroy() 也沒有 clearTimeout(整個 GameEngine.ts 一次 clearTimeout 都沒有,grep -c → 0)。⇒ 開局後 1 秒內離開(或連續重開),destroy() 的 SoundSystem.stopAll() 跑完之後,這個 timer 才醒過來又播一次 Countdown;快速重開會疊。
建議修正:
// Before: (GameEngine.ts Line ~1177–1182)
if (!this.introActive) {
SoundSystem.play('SpinningTop_Voice_Ready');
setTimeout(() => {
SoundSystem.play('SpinningTop_Voice_Countdown');
}, 1000); // 延遲 1 秒播放 Countdown
}
// After:
private countdownVoiceTimer: ReturnType<typeof setTimeout> | null = null;
// ...
if (!this.introActive) {
SoundSystem.play('SpinningTop_Voice_Ready');
this.countdownVoiceTimer = setTimeout(() => {
this.countdownVoiceTimer = null;
SoundSystem.play('SpinningTop_Voice_Countdown');
}, 1000);
}
// destroy() 內:
if (this.countdownVoiceTimer !== null) {
clearTimeout(this.countdownVoiceTimer);
this.countdownVoiceTimer = null;
}
檔案: tools/prefab-editor/vite.config.ts [cc93597d] [daone_barry]
問題: relPath 直接來自請求路徑、沒有正規化,也沒有檢查結果是否仍在 gameAssetsRoot 底下。GET /Game/../../../<任意檔>(不經瀏覽器正規化的 client,例如 curl --path-as-is)就能讀到 repo 外的檔案。
⚠️ 這是編輯器的 dev server、只在本機起,不是線上風險——但這台機器上有 key.txt 這種東西。
建議修正:
// Before: (tools/prefab-editor/vite.config.ts Line ~20–22)
const relPath = url.slice('/Game/'.length).split('?')[0];
const filePath = path.join(gameAssetsRoot, relPath);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
// After:
const relPath = url.slice('/Game/'.length).split('?')[0];
const filePath = path.resolve(gameAssetsRoot, '.' + path.posix.normalize('/' + relPath));
if (!filePath.startsWith(gameAssetsRoot + path.sep)) return next();
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
檔案: web/Game/Script/System/SpinningTopSystem/Systems/GameUtils.ts [b931278d] [daone_barry]
問題: 本批在 Item 上加了三個欄位(Types.ts 的 sourceZombieType / mechanicIconSrc / visualOnly),getItem() 的回收清除只加了 sourceZombieType。
目前還打不到:唯一的 item_ticket 產生點 GameEngine.spawnTicket() 每次都會明確寫這三個欄位。但 visualOnly 控制的是 GameLoop/Items.ts 的 if (match && !item.visualOnly)——那是加票的那一行。只要哪天多一條不設 visualOnly 的票券產生路徑,回收到的舊值就會讓那張票靜靜地不加分。
同一份清單也漏了 initialDist(Items.ts:97)——那個已經是活的舊問題(150a7e87,2026-07-15,不是本批引入):它只在 undefined 時寫入、從不清除,所以回收的 item 會沿用上一次飛行的距離,把「固定 0.5 秒飛行」算成錯的速度。
建議修正:
// Before: (GameUtils.ts getItem)
// clear properties
p.bounceCount = undefined;
p.followTargetId = undefined;
p.markForDeletion = undefined;
p.glowTimer = undefined;
p.specialType = undefined;
p.dropDelay = undefined;
p.sourceZombieType = undefined;
// After(最小修法):
p.sourceZombieType = undefined;
p.mechanicIconSrc = undefined;
p.visualOnly = undefined;
p.initialDist = undefined; // ← 舊問題,順手一起
⚠️ 更耐用的做法是改成一份 ITEM_RESET_FIELDS 常數(或回收時直接重建物件)——現在這個清單是「手工維護、漏了不會有訊號」的形狀。
檔案: web/Game/Script/Stage/Fight/FightUi/App.tsx [83e0c2e3] [daone_barry]
相關: web/Game/Script/Stage/Fight/FightUi/Components/Ui/DevOverlays.tsx [83e0c2e3] [daone_barry]
問題: App.tsx 寫「開發工具只能由明確的 URL 參數開啟」「必須由 ?dev=1 明確開啟」,DevOverlays.tsx 檔頭寫「RELEASE 版不存在」「Rollup 連同這個模組整批 tree-shake 掉」。改成 runtime 判斷之後,這三句全部不成立——面板一定會進產物。讀的人會被誤導。
建議: 三句註解一起改成現況(opt-out:?dev=0 才關)。
檔案: web/Game/Script/System/SpinningTopSystem/Systems/SpawnSystem.ts [3d929f5e] [GIPONHSU]
問題: Line ~1210 的 let px 在整支函式內從未重新賦值(只被 nx / walkTargetX / jumpTargetX 讀),而緊接著下一行的 py 已經是 const ⇒ 同一組座標兩種寫法。
建議修正:
// Before: (SpawnSystem.ts Line ~1210)
let px = Math.max(leftCenterX, Math.min(sx, rightCenterX));
const py = centerY;
// After:
const px = Math.max(leftCenterX, Math.min(sx, rightCenterX));
const py = centerY;
檔案: web/Game/Script/System/SpinningTopSystem/Systems/SpawnSystem.ts [3d929f5e] [GIPONHSU]
問題: Line ~415 寫 if (spawnType === 'zombie_chef') r = 101; // ChampionChef.anim.json behavior.radius,但那份 JSON 的 behavior.radius 是 89,不是 101。
(實際生效的是 resolveZombieRadius() 讀到的 89,101 只是 fallback,所以不影響畫面——但註解指的來源是錯的,照它去對數字的人會被誤導。)
建議: 把註解改成「fallback 值;實際以 resolveZombieRadius() 讀到的 behavior.radius 為準」,或直接把 fallback 對齊 89。
檔案: web/vite.config.ts [132cd5b5] [ACD_RD1謝岱錡]
問題: 這次改成先讀舊檔再合併(方向對),但三個縮放欄位寫的是 payload.topScale ?? 1.0——少了中間那層 ?? existing.topScale。目前唯一的 caller(ObjectPanel.saveScale)四個欄位都會送,所以還沒事;將來只要有人送部分 payload,縮放就會被默默重設成 1.0——正是這顆 commit 要修的那種 bug 的鏡像。
建議修正:
// Before: (web/vite.config.ts Line ~171–177)
const data: Record<string, unknown> = {
...existing,
...payload,
topScale: (payload.topScale as number) ?? 1.0,
zombieScale: (payload.zombieScale as number) ?? 1.0,
propScale: (payload.propScale as number) ?? 1.0,
};
// After:
const data: Record<string, unknown> = {
...existing,
...payload,
topScale: (payload.topScale as number) ?? (existing.topScale as number) ?? 1.0,
zombieScale: (payload.zombieScale as number) ?? (existing.zombieScale as number) ?? 1.0,
propScale: (payload.propScale as number) ?? (existing.propScale as number) ?? 1.0,
};
檔案: web/Game/Script/Stage/Fight/FightUi/Components/Screens/HudAssetGlobCoverage.guard.test.ts [c3abdedb] [ACD_RD1蔡昆竹]
相關: web/Game/Script/Stage/Fight/FightUi/Components/Screens/HudJSONOverlay.tsx [ade23013] [daone_barry]
問題: HudJSONOverlay.tsx 的註解寫「那一格改由 HudAssetGlobCoverage.guard.test.ts 守:**它比對所有 *.ui.json 的 src 前綴」,但那支測試只 import hudRaw from '.../Hud.ui.json'——GameOver.ui.json / Loading.ui.json / ModeSelect.ui.json 三份都沒被檢查。**
⇒ 守衛宣稱的範圍大於實際範圍,讀註解的人會以為那三份也被守著。
建議: 測試改成用 import.meta.glob('../**/*.ui.json') 掃全部,或把註解改成「只守 Hud.ui.json」。
main 現在是紅的(22 檔/77 顆),本批新增 3 檔/12 顆。 上面四條 Critical 有三條是這件事——先讓 main 綠回來,其他都在其次。TimedBladeBuff.test.ts + blocksCollisionChargeMeter)。移除玩法規則時,同批要一起處理它的測試與 helper,否則下一個人讀 docstring 會以為規則還在。let px/const py 不一致)。抄的當下沒事,加第六個的時候漏掉不會有任何訊號。getItem() 的清除欄位、vi.mock 的 export 清單、HudAssetGlobCoverage 的檢查對象)。這類清單漏了都是靜默失敗,建議改成由型別或 glob 推導。getPainterImage / drawFontAtlasText / paintSprite 三處在資源沒載到時直接 return,沒有任何線索——8/19 的線上破圖就是這個形狀。behavior 欄位現在是「改了不會有反應也不會有訊息」,AGENTS.md 明訂那算 bug。🔴 這一節不是客套,它是這份報告可信度的來源——沒有邊界聲明的報告,讀的人無法判斷「沒提到」是「沒問題」還是「沒看」。
tools/animation-editor/src/(main.ts +277 行、paramEditor.ts、recordSwitch.ts)與 tools/prefab-editor/src/(RenderEngine.ts +154、PreviewCanvas.tsx、ConfigSchema.ts、types.ts)。依角色設定排除編輯器程式,只看了兩支 vite.config.ts(其中一支有 finding)。⇒ 編輯器端的邏輯這一批沒有人審過。web/Game/Media/ 與 web/Game/Stage/ 的 55 個素材/.ui.json 版面改動(朱冠蓉那批 HUD 素材)。只查了「路徑解得到」這一層,沒有看版面對不對、也沒有看圖對不對。wiki/** 的 12 份文件變更(蔡自己寫的規範)——不在程式碼審查範圍。AudioMixer.json、Gorilla.anim.json、Mummy.anim.json 的內容差異。ZombieModelLoader.applyToonAndFlashPatch 的 GLSL 與 ZombieAdapter 的 vColor g 通道打包我讀過,customProgramCacheKey 也有跟著換(v6 → v9-dissolve-flashfix),但 visual-verify 的真 GPU 逐幀我一次都沒跑。畫面對不對我不知道。FontAtlasRenderer / WebGL2DContext.drawFontAtlasGlyph 的 GPU 批次:確認了 dispose() 有配對、_batches 數量被 atlas 圖張數綁住(不會無限長),但沒有量過 draw call 或 texture upload。ZombieAdapter._blendStates 的逐隻過渡狀態:清除路徑存在,但我沒能證實「死亡中(alive === false)但還在畫的怪,每幀被清掉會不會讓死亡過渡失效」——這要跑起來看,我沒跑。SpawnDirector 的 boss 輪替新邏輯(蔡自己那兩顆):逐條讀過 selectNextMechanic 的四個分支,沒有找到「bossDue 時 boss 被漏掉又不被排除」的路徑,但那支測試檔本身是紅的 ⇒ 現有測試對這段新邏輯目前提供 0 保護。Audio is not defined、happy-dom 的 act(...) not configured、GameRenderer 的 fetch)。本報告只主張差集。?2d=1。依 2026-07-31 裁示,legacy Canvas 2D 不維護,2D 缺什麼都不算 bug。/mnt/f/ACD_RD1_Project/SpinningTop-review-daily-codereview,跟主 repo 工作目錄分開,全程沒有動主 repo。web/node_modules 是用 npm ci 在這個 worktree 獨立裝的(342 MB,沒跟其他 worktree 共用 .vite cache)。留著,下次審查可直接跑測試。npx vite build 產生的 dist/(377 MB)已刪除。git checkout 215479a2 跑的(package.json / package-lock.json 在這個範圍內沒變動,兩次跑的是同一份依賴),跑完已 checkout 回 review/daily-codereview。215479a2..36dbea6336dbea63git fetch origin main 失敗(這個 worktree 沒有 igsgithub 憑證),用的是別的行程在 11:37:10 抓下來的 origin/main。11:37 之後推的 commit 本報告沒有看到——當天實際 HEAD 已到 adafb55a。fetch 的正確做法記在同目錄 README.md。