diff --git a/.changeset/starpuff-v17-debt-refactor.md b/.changeset/starpuff-v17-debt-refactor.md new file mode 100644 index 000000000..0cb9089c4 --- /dev/null +++ b/.changeset/starpuff-v17-debt-refactor.md @@ -0,0 +1,5 @@ +--- +'@app/starpuff': patch +--- + +內部維護性重構:GameScene 拆解為六個獨立系統模組並補齊事件契約測試與檔案行數守門,玩法、數值、視覺與存檔行為完全不變。 diff --git a/apps/starpuff/docs/GAME_DESIGN.md b/apps/starpuff/docs/GAME_DESIGN.md index 8dcd1c383..b642de572 100644 --- a/apps/starpuff/docs/GAME_DESIGN.md +++ b/apps/starpuff/docs/GAME_DESIGN.md @@ -1,7 +1,7 @@ # 星噗噗 StarPuff — 遊戲設計 SPEC(SSOT) > 手機優先 PWA 動作小遊戲。穿越層層果凍關卡、吸入果凍怪、化為星彈、擊敗果凍魔王。 -> 版本:v16(PM 派工;v16 產品缺陷修復列車 §95-§105——三席批評收斂:直持預設鍵位重錨右下拇指帶(cw/ccw 雙向+預設態不落盤儲存語意)/圖鑑分頁有界網格(y≤470 守門)/區關命名層級標示/選單 DOM 鈕命中短邊 48 保底/版本頁腳 SHA 來源修正/勝利結算「下一關」主 CTA/HUD 暫停靜音 DOM 化+aria/配置遮罩加深/標題字形品牌化/怪物圖鑑分頁化(每頁 12 格)/教學關死亡配額結轉與精英門明示提示、v15 成就系統列車 §94——21 條成就 SSOT 純呈現層聚合(進度/魔王/EX/收集/速通/隱藏)/save schema v2 versioned migration 與舊玩家開機補發/圖鑑成就分頁與解鎖 toast 佇列/Result 解鎖名單、v14 UX/PWA 列車 §87-§93——直持預設方向翻轉 ccw(含回訪告知與偏好切換)/按鈕配置直欄化與標籤單行/虛擬鍵縮放(sp-key-layout v2)/PWA 安裝偵測與分平台指引/觸覺回饋與螢幕常亮/殼層卡片基建/殼局部 safe-area 量測、v13 EX 全魔王與星核制霸 §86——五王 EX 徽鈕開放收尾/EX 慈悲上限/EX 再戰保留/L4·L7 前室 retrofit/星核制霸全制霸獎勵、v12.1 P0 熱修 §85——真實觸控下滑判定重修(扇區+幅度+drop-intent 緩衝窗+蹲下鉗水平)、v12 五區終章 §78-§84——分區分頁世界地圖/流星雨/Twinkla·Cometa/低重力/L17-L20 二十關完結/最終魔王蝕星魔核 Voidra(生存段·星核共鳴·段起點重試)/星光復甦謝幕與圖鑑補完、v11.1 P0 熱修 §77——站台下跳穿落根修/蹲姿與跳鍵下跳指示/吸入接觸豁免/大嘴吸入影格、v11 四區完結 §71-§76——糖漿潮汐/熱泉噴口/Bubbla·Splatta/L13-L16 十六關/第四魔王 Syrona 場控型(噴泉洗牌·皇冠弱點·窯風三連)、v10 三區完結 §65-§70——星門折躍/L10-L12 十二關/卡點 checkpoint/第三魔王稜晶雙子 Prismix/魔王關特殊體系(前室·增益·專屬彩蛋)、v9.1 P0 熱修 §64——Noctra 返空連續飛行/星暴 5s 無敵窗、v9 星化與挑戰 §57-§63、v8 世界擴張 §50-§56、v7 手感與深度 §44-§49、v6 存檔/世界地圖/新技能/手感 §38-§43、v5 控制自訂/暫停/圖鑑/開場 §33-§37、v4 免轉向與元素包 §28-§32、v3 橫式轉向 §21-§27)|路由:`https://app.haotool.org/starpuff/`|24h 衝刺交付 +> 版本:v17(PM 派工;v17 債務列車 §106——行為零改變維護性收斂:GameScene strangler 拆解(2121→1196 行,bossFactory/starCombat/starSteering/overlaps/eggTracker/toasts 六模組)/GameEvents 契約測試/ESLint max-lines 守門、v16 產品缺陷修復列車 §95-§105——三席批評收斂:直持預設鍵位重錨右下拇指帶(cw/ccw 雙向+預設態不落盤儲存語意)/圖鑑分頁有界網格(y≤470 守門)/區關命名層級標示/選單 DOM 鈕命中短邊 48 保底/版本頁腳 SHA 來源修正/勝利結算「下一關」主 CTA/HUD 暫停靜音 DOM 化+aria/配置遮罩加深/標題字形品牌化/怪物圖鑑分頁化(每頁 12 格)/教學關死亡配額結轉與精英門明示提示、v15 成就系統列車 §94——21 條成就 SSOT 純呈現層聚合(進度/魔王/EX/收集/速通/隱藏)/save schema v2 versioned migration 與舊玩家開機補發/圖鑑成就分頁與解鎖 toast 佇列/Result 解鎖名單、v14 UX/PWA 列車 §87-§93——直持預設方向翻轉 ccw(含回訪告知與偏好切換)/按鈕配置直欄化與標籤單行/虛擬鍵縮放(sp-key-layout v2)/PWA 安裝偵測與分平台指引/觸覺回饋與螢幕常亮/殼層卡片基建/殼局部 safe-area 量測、v13 EX 全魔王與星核制霸 §86——五王 EX 徽鈕開放收尾/EX 慈悲上限/EX 再戰保留/L4·L7 前室 retrofit/星核制霸全制霸獎勵、v12.1 P0 熱修 §85——真實觸控下滑判定重修(扇區+幅度+drop-intent 緩衝窗+蹲下鉗水平)、v12 五區終章 §78-§84——分區分頁世界地圖/流星雨/Twinkla·Cometa/低重力/L17-L20 二十關完結/最終魔王蝕星魔核 Voidra(生存段·星核共鳴·段起點重試)/星光復甦謝幕與圖鑑補完、v11.1 P0 熱修 §77——站台下跳穿落根修/蹲姿與跳鍵下跳指示/吸入接觸豁免/大嘴吸入影格、v11 四區完結 §71-§76——糖漿潮汐/熱泉噴口/Bubbla·Splatta/L13-L16 十六關/第四魔王 Syrona 場控型(噴泉洗牌·皇冠弱點·窯風三連)、v10 三區完結 §65-§70——星門折躍/L10-L12 十二關/卡點 checkpoint/第三魔王稜晶雙子 Prismix/魔王關特殊體系(前室·增益·專屬彩蛋)、v9.1 P0 熱修 §64——Noctra 返空連續飛行/星暴 5s 無敵窗、v9 星化與挑戰 §57-§63、v8 世界擴張 §50-§56、v7 手感與深度 §44-§49、v6 存檔/世界地圖/新技能/手感 §38-§43、v5 控制自訂/暫停/圖鑑/開場 §33-§37、v4 免轉向與元素包 §28-§32、v3 橫式轉向 §21-§27)|路由:`https://app.haotool.org/starpuff/`|24h 衝刺交付 ## 1. 產品定位 @@ -1707,13 +1707,14 @@ epic 資料夾(art-v8-ticket.md / run-art-v8.sh)。 ## 104. v16 怪物圖鑑分頁(F-03,取代 §76 單頁定案) -- 根因(Composer 席):24 隻 × 12 欄 2 列——854 最窄殼 cellW 67px、行為敘述 +- 根因(Composer 席):23 隻 × 12 欄 2 列——854 最窄殼 cellW 67px、行為敘述 4-5 字/行,直持過密難讀(§76 當年以 18 格評估的 cellW≈89 已被 v12 圖鑑 - 補完擠破)。 + 補完擠破)。(v17 訂正:CODEX_MONSTERS 實為 23 隻(12+11),原文 24 為 + 敘述失準;ceil 分頁斷言不受影響。) - 修法:接 §96 有界網格——每頁 12 格 6×2(MONSTER_PAGE_SIZE,cellW 回到 134+),底緣「上一頁/第 n/N 頁/下一頁」翻頁列(restart 帶 monsterPage, 沿分頁切換慣例;單頁不顯示);頁序沿 CODEX_MONSTERS,未來新增怪自動增頁。 -- 驗證:gridLayout 守門(每頁 6×2 ≤470、24 隻恰兩頁);v16 e2e 翻頁雙向; +- 驗證:gridLayout 守門(每頁 6×2 ≤470、23 隻恰兩頁);v16 e2e 翻頁雙向; 854/1200/直持截圖對照。 ## 105. v16 前期節奏保守調參(D5+F-05,補充 §15 教學關與 §48 精英房) @@ -1732,3 +1733,38 @@ epic 資料夾(art-v8-ticket.md / run-art-v8.sh)。 曲線風險大於收益);L2 精英數值調整(60s 保險+提示已消解卡住感)。 - 驗證:levels 單測(結轉夾限/非教學歸零/種子推進);v16 e2e 真實戰鬥管線 擊殺 4 隻→死亡→重生 2/6;v7 精英房回歸全綠。 + +# v17 債務列車:行為零改變維護性收斂(PM 派工) + +## 106. GameScene strangler 拆解與守門(三席維護性 5/7/8 收斂) + +### 106.1 拆解記錄(行為零改變) + +- 性質:純維護性重構——玩法、數值、視覺、事件順序、存檔語意一律不動; + events 契約凍結(§11),本車為其補契約測試而非修改。 +- GameScene 2121 → 1196 行,職責收斂為一句話:orchestration+scene lifecycle + (create 接線、update 逐幀委派、事件路由、勝敗轉場、e2e 鉤子面)。 +- 六個抽取單元(每步先補 characterization test 鎖行為,全測綠才 commit, + 獨立可 revert): + - `systems/bossFactory`:魔王品種分派(never 守衛)、補給小怪節奏、召喚夾限。 + - `systems/starCombat`:星彈規格解析單一出口+技能世界結算(星暴/下衝擊/ + 殼盾反擊/鏈電束/風化落地/雷鏈/凍結場/爆裂波及)。 + - `systems/starSteering`:準星輔助/追電導引/磁場吸偏/拖尾附掛(導向順序凍結)。 + - `systems/overlaps`:戰鬥碰撞接線(接線順序凍結;damagePlayer/damageBossAt + 單一入口不變)。 + - `systems/eggTracker`:彩蛋進度鎖存/逐幀餵送/獎勵落地/crown 時間窗。 + - `systems/toasts`:成就 toast 佇列/星味首遇提示/彩蛋慶祝演出。 +- 新增測試:GameEvents 契約測試(事件名唯一、emit/on/off 對偶、FSM phase + 恰一次、waves 序列)+六模組 characterization——vitest 580 → 650。 +- ESLint 守門:apps/starpuff 範圍 `max-lines` error 1200(levels.ts 資料表 + 豁免),防 GameScene 再膨脹。 + +### 106.2 觀察項設計理由註記(不動行為) + +- 直持 joy-zone 幾何包住 A/B(Grok 席 v16 觀察項):維持 §95 設計定案—— + keys-layer 為後繪兄弟層天然承接命中,誤觸空白處僅零位移搖桿錨定(無害)。 + 若未來要消「右拇指邊緣偶發搖桿錨定」感,方向為 joy-zone 右緣排除鍵命中區; + 本車行為零改變原則下不動。 +- 教學關死亡結轉讀值時窗(§105 D5):retryLevel 於 350ms 延遲回呼讀取配額, + 理論上飛行中星彈可多算 1 殺——carryKillsOnDeath 夾限(≤配額-1)保證無害 + 且恆有利玩家,屬既有設計定案,不加鎖存。 diff --git a/apps/starpuff/e2e/v6.spec.ts b/apps/starpuff/e2e/v6.spec.ts index 3041a0c85..8114eeb43 100644 --- a/apps/starpuff/e2e/v6.spec.ts +++ b/apps/starpuff/e2e/v6.spec.ts @@ -81,7 +81,7 @@ async function tapFire(page: Page): Promise { await page.keyboard.up('X'); } -test('版本號(§42):__sp.version 回報 semver+SHA(無 git 環境為 nogit)格式', async ({ page }) => { +test('版本號(§42/§99):__sp.version 回報 semver(短 SHA 後綴可省略)格式', async ({ page }) => { const errors = collectErrors(page); await gotoTitle(page); const version = await page.evaluate(() => window.__sp.version()); diff --git a/apps/starpuff/src/game/core/events.test.ts b/apps/starpuff/src/game/core/events.test.ts new file mode 100644 index 000000000..437b62faf --- /dev/null +++ b/apps/starpuff/src/game/core/events.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest'; +import type Phaser from 'phaser'; +import { GameEvents, emitGameEvent, offGameEvent, onGameEvent } from './events'; +import { BOSS, createBossFsm } from '../logic/bossFsm'; +import { getLevel } from '../logic/levels'; +import { createWaveRunner } from '../systems/waves'; +import type { EnemySystem } from '../systems/enemies'; + +// 事件契約測試(v17 債務列車):GameEvents 為跨系統唯一溝通管道(GAME_DESIGN §11,凍結)。 +// 本檔僅鎖住既有行為契約,不改 events.ts 本體;GameScene 端的勝敗轉場順序 +// (GAME_WON/GAME_LOST → Result/Credits)需完整 Phaser Scene,由 e2e smoke 覆蓋。 + +// 最小事件匯流排:與 Phaser EventEmitter 同語意(同步派發、依註冊序呼叫)。 +interface TestBus { + emit(event: string, ...args: unknown[]): unknown; + on(event: string, fn: (...args: never[]) => void): unknown; + off(event: string, fn: (...args: never[]) => void): unknown; + listenerCount(event: string): number; +} + +const bus = (): TestBus => { + const listeners = new Map void)[]>(); + return { + emit(event, ...args) { + for (const fn of [...(listeners.get(event) ?? [])]) fn(...args); + }, + on(event, fn) { + listeners.set(event, [...(listeners.get(event) ?? []), fn as (...args: unknown[]) => void]); + }, + off(event, fn) { + listeners.set( + event, + (listeners.get(event) ?? []).filter((candidate) => candidate !== fn), + ); + }, + listenerCount(event) { + return (listeners.get(event) ?? []).length; + }, + }; +}; + +describe('GameEvents 事件名契約', () => { + it('事件名全檔唯一(匯流排以字串路由,撞名即契約破壞)', () => { + const names = Object.values(GameEvents); + expect(new Set(names).size).toBe(names.length); + }); + + it('事件名維持 domain:action 命名(凍結格式)', () => { + for (const name of Object.values(GameEvents)) { + expect(name).toMatch(/^[a-z]+:[a-z-]+$/); + } + }); +}); + +describe('emitGameEvent / onGameEvent / offGameEvent 對偶契約', () => { + it('emit 後 handler 收到完整 payload(PLAYER_DAMAGED)', () => { + const emitter = bus(); + const received: { hp: number; maxHp: number; damage: number }[] = []; + onGameEvent(emitter, GameEvents.PLAYER_DAMAGED, (payload) => received.push(payload)); + emitGameEvent(emitter, GameEvents.PLAYER_DAMAGED, { hp: 3, maxHp: 5, damage: 2 }); + expect(received).toEqual([{ hp: 3, maxHp: 5, damage: 2 }]); + }); + + it('off 解除後不再收到事件', () => { + const emitter = bus(); + let count = 0; + const handler = (): void => { + count += 1; + }; + onGameEvent(emitter, GameEvents.GAME_WON, handler); + emitGameEvent(emitter, GameEvents.GAME_WON, { timeMs: 1000 }); + offGameEvent(emitter, GameEvents.GAME_WON, handler); + emitGameEvent(emitter, GameEvents.GAME_WON, { timeMs: 2000 }); + expect(count).toBe(1); + }); + + it('事件名互不串線(emit BOSS_PHASE 不觸發 BOSS_DAMAGED handler)', () => { + const emitter = bus(); + let damaged = 0; + let phased = 0; + onGameEvent(emitter, GameEvents.BOSS_DAMAGED, () => { + damaged += 1; + }); + onGameEvent(emitter, GameEvents.BOSS_PHASE, () => { + phased += 1; + }); + emitGameEvent(emitter, GameEvents.BOSS_PHASE, { phase: 'p2' }); + expect(damaged).toBe(0); + expect(phased).toBe(1); + }); + + it('同事件多監聽者皆收到(HUD 與 SFX 同源訂閱模式)', () => { + const emitter = bus(); + const hits: string[] = []; + onGameEvent(emitter, GameEvents.GAME_LOST, () => hits.push('a')); + onGameEvent(emitter, GameEvents.GAME_LOST, () => hits.push('b')); + emitGameEvent(emitter, GameEvents.GAME_LOST, { timeMs: 500 }); + expect(hits).toEqual(['a', 'b']); + }); +}); + +describe('魔王階段事件契約(FSM 事件源 → BOSS_PHASE)', () => { + // boss 呈現層(systems/boss.ts applyDamage)將 FSM phase 事件一對一轉發為 + // BOSS_PHASE;此處以同構轉發迴圈鎖住「跨 p3 門檻必發恰一次 p3」的完整鏈。 + const forwardDamage = ( + emitter: TestBus, + fsm: ReturnType, + amount: number, + ): void => { + for (const event of fsm.takeDamage(amount)) { + if (event.kind === 'phase') { + emitGameEvent(emitter, GameEvents.BOSS_PHASE, { phase: event.phase }); + } + } + }; + + it('血量依序跌破 50%/25% 門檻時各發 BOSS_PHASE 恰一次', () => { + const emitter = bus(); + const fsm = createBossFsm(); + const phases: string[] = []; + onGameEvent(emitter, GameEvents.BOSS_PHASE, ({ phase }) => phases.push(phase)); + forwardDamage(emitter, fsm, Math.ceil(BOSS.maxHp * 0.5)); + expect(phases).toEqual(['p2']); + forwardDamage(emitter, fsm, Math.ceil(BOSS.maxHp * 0.25)); + expect(phases).toEqual(['p2', 'p3']); + // p3 內續傷不再重發 phase 事件。 + forwardDamage(emitter, fsm, 1); + expect(phases).toEqual(['p2', 'p3']); + }); + + it('單擊跨雙門檻只發最終 phase 恰一次(現行為鎖定:不逐檻補發)', () => { + const emitter = bus(); + const fsm = createBossFsm(); + const phases: string[] = []; + onGameEvent(emitter, GameEvents.BOSS_PHASE, ({ phase }) => phases.push(phase)); + forwardDamage(emitter, fsm, BOSS.maxHp - 1); + expect(phases).toEqual(['p3']); + }); + + it('takeDamage 進 p3 後 FSM phase 唯讀狀態一致', () => { + const fsm = createBossFsm(); + fsm.takeDamage(BOSS.maxHp - 1); + expect(fsm.phase).toBe('p3'); + expect(fsm.defeated).toBe(false); + }); +}); + +describe('waves 關卡事件序列契約(最小 scene harness)', () => { + // L2(非教學、無 hint、非 boss)之 start/forceQuota/destroy 僅觸 scene.events, + // 其餘 Phaser 面不進入;enemies 於此路徑零呼叫。 + const LEVEL_ID = 2; + const harness = (): { scene: Phaser.Scene; emitter: TestBus } => { + const emitter = bus(); + return { scene: { events: emitter } as unknown as Phaser.Scene, emitter }; + }; + const enemiesStub = {} as EnemySystem; + + it('start() 發 LEVEL_CHANGED 恰一次且 payload 對齊關卡資料', () => { + const { scene, emitter } = harness(); + const level = getLevel(LEVEL_ID); + const changed: { levelId: number; nameZh: string; killQuota: number }[] = []; + onGameEvent(emitter, GameEvents.LEVEL_CHANGED, (payload) => changed.push(payload)); + const runner = createWaveRunner(scene, enemiesStub, LEVEL_ID); + runner.start(); + expect(changed).toEqual([ + { levelId: level.id, nameZh: level.nameZh, killQuota: level.killQuota }, + ]); + runner.destroy(); + }); + + it('配額補滿:LEVEL_QUOTA 逐殺遞增、LEVEL_GATE_OPENED 於達標瞬間恰發一次', () => { + const { scene, emitter } = harness(); + const level = getLevel(LEVEL_ID); + const quotas: number[] = []; + const gates: number[] = []; + onGameEvent(emitter, GameEvents.LEVEL_QUOTA, ({ killCount }) => quotas.push(killCount)); + onGameEvent(emitter, GameEvents.LEVEL_GATE_OPENED, ({ levelId }) => gates.push(levelId)); + const runner = createWaveRunner(scene, enemiesStub, LEVEL_ID); + runner.start(); + runner.forceQuota(); + expect(quotas).toEqual(Array.from({ length: level.killQuota }, (_, i) => i + 1)); + expect(gates).toEqual([level.id]); + expect(runner.isGateOpen()).toBe(true); + // 開門後再殺(ENEMY_KILLED)不重發 GATE_OPENED、不再推進配額。 + emitGameEvent(emitter, GameEvents.ENEMY_KILLED, { kind: 'jelly', x: 0, y: 0 }); + expect(gates).toEqual([level.id]); + expect(quotas.length).toBe(level.killQuota); + runner.destroy(); + }); + + it('擊殺與吞下皆計入配額(ENEMY_KILLED 與 ENEMY_INHALED 同權)', () => { + const { scene, emitter } = harness(); + const quotas: number[] = []; + onGameEvent(emitter, GameEvents.LEVEL_QUOTA, ({ killCount }) => quotas.push(killCount)); + const runner = createWaveRunner(scene, enemiesStub, LEVEL_ID); + runner.start(); + emitGameEvent(emitter, GameEvents.ENEMY_KILLED, { kind: 'jelly', x: 0, y: 0 }); + emitGameEvent(emitter, GameEvents.ENEMY_INHALED, { kind: 'floaty' }); + expect(quotas).toEqual([1, 2]); + runner.destroy(); + }); + + it('BOSS_DEFEATED 後停止推進(勝利演出期不再計配額)', () => { + const { scene, emitter } = harness(); + const quotas: number[] = []; + onGameEvent(emitter, GameEvents.LEVEL_QUOTA, ({ killCount }) => quotas.push(killCount)); + const runner = createWaveRunner(scene, enemiesStub, LEVEL_ID); + runner.start(); + emitGameEvent(emitter, GameEvents.BOSS_DEFEATED, { x: 0, y: 0 }); + emitGameEvent(emitter, GameEvents.ENEMY_KILLED, { kind: 'jelly', x: 0, y: 0 }); + expect(quotas).toEqual([]); + runner.destroy(); + }); + + it('destroy() 解除所有訂閱(跨局監聽不累積)', () => { + const { scene, emitter } = harness(); + const runner = createWaveRunner(scene, enemiesStub, LEVEL_ID); + runner.start(); + const before = emitter.listenerCount(GameEvents.ENEMY_KILLED); + runner.destroy(); + expect(before).toBe(1); + expect(emitter.listenerCount(GameEvents.ENEMY_KILLED)).toBe(0); + expect(emitter.listenerCount(GameEvents.BOSS_DEFEATED)).toBe(0); + expect(emitter.listenerCount(GameEvents.ENEMY_INHALED)).toBe(0); + expect(emitter.listenerCount(GameEvents.AMMO_CHANGED)).toBe(0); + }); + + it('教學關配額結轉(initialKills)於 start 即補發現值 LEVEL_QUOTA', () => { + const { scene, emitter } = harness(); + const quotas: number[] = []; + onGameEvent(emitter, GameEvents.LEVEL_QUOTA, ({ killCount }) => quotas.push(killCount)); + const runner = createWaveRunner(scene, enemiesStub, LEVEL_ID, {}, 3); + runner.start(); + expect(quotas).toEqual([3]); + runner.destroy(); + }); +}); diff --git a/apps/starpuff/src/game/core/gridLayout.test.ts b/apps/starpuff/src/game/core/gridLayout.test.ts index fa4e10b2b..5a32adc9e 100644 --- a/apps/starpuff/src/game/core/gridLayout.test.ts +++ b/apps/starpuff/src/game/core/gridLayout.test.ts @@ -48,7 +48,7 @@ describe('圖鑑分頁縱向守門(§96 P1-01:任何分頁內容不得超出 expect(gridBottom(future, CODEX_TAB_GRIDS.achievements)).toBeLessThanOrEqual(470); }); - it('怪物分頁(§104 F-03):每頁 12 格 6×2 不溢出,24 隻恰為兩頁', () => { + it('怪物分頁(§104 F-03):每頁 12 格 6×2 不溢出,23 隻恰為兩頁', () => { const grid = fitBoundedGrid(MONSTER_PAGE_SIZE, CODEX_TAB_GRIDS.monsters); expect(grid).toEqual({ cols: 6, rows: 2 }); expect(gridBottom(grid, CODEX_TAB_GRIDS.monsters)).toBeLessThanOrEqual(470); diff --git a/apps/starpuff/src/game/core/layout.ts b/apps/starpuff/src/game/core/layout.ts index 158e4e059..7c61d5e51 100644 --- a/apps/starpuff/src/game/core/layout.ts +++ b/apps/starpuff/src/game/core/layout.ts @@ -178,6 +178,8 @@ export function getDefaultLayout(): ControlLayout { return structuredClone(DEFAULT_LAYOUT); } +// 回傳值為橫持基準預設;直持配置頁實際走 defaultLayoutFor(rotation) 旋轉分流, +// 此處僅負責清除自訂落盤(§95)。 export function resetLayout(): ControlLayout { try { localStorage.removeItem(LAYOUT_STORAGE_KEY); diff --git a/apps/starpuff/src/game/scenes/GameScene.ts b/apps/starpuff/src/game/scenes/GameScene.ts index 3b114b3fa..1d3832d8a 100644 --- a/apps/starpuff/src/game/scenes/GameScene.ts +++ b/apps/starpuff/src/game/scenes/GameScene.ts @@ -1,20 +1,5 @@ import Phaser from 'phaser'; -import { - EGG_HP_CAP, - ENEMY, - GRAVITY_Y, - INHALE, - PLAYER, - SLAM, - STARSTORM, - STAR_FLAVORS, - VIEW, - getMix, - type MixId, - type StarFlavor, - type StarFlavorSpec, - type StarMixSpec, -} from '../core/config'; +import { GRAVITY_Y, INHALE, PLAYER, VIEW } from '../core/config'; import { GameEvents, emitGameEvent, @@ -31,47 +16,12 @@ import { recordLevelClear, type SaveData, } from '../core/save'; -import { - SceneKeys, - type BossKind, - type EnemyKind, - type GameResultData, - type LevelId, -} from '../core/types'; +import { SceneKeys, type GameResultData, type LevelId } from '../core/types'; import { awardAchievements, getAchievement } from '../logic/achievements'; import { BOSS } from '../logic/bossFsm'; -import { NOCTRA } from '../logic/noctraFsm'; -import { PRISMIX } from '../logic/prismixFsm'; -import { SYRONA } from '../logic/syronaFsm'; -import { VOIDRA } from '../logic/voidraFsm'; -import { - inhaleFlavor, - inhaleGraceUntil, - isContactHarmless, - isInInhaleRange, - knockbackVelocity, - pickInRadius, -} from '../logic/combat'; -import { magnetPull } from '../logic/enemyFsm'; -import { - BOSS_AIM_ASSIST, - HOMING_RANGE_PX, - HOMING_TURN_RAD_PER_MS, - nearestInRange, - steerTowardTarget, -} from '../logic/homing'; -import { SHELL_SHIELD, pickChainTargets } from '../logic/skills'; -import { GALE_FLIGHT, SHELL_REFLECT, TRANSFORM_FORMS, VOLT_BEAM } from '../logic/transform'; -import { - advanceEgg, - createEggProgress, - type EasterEggSpec, - type EggEvent, - type EggProgress, -} from '../logic/eggs'; +import { inhaleFlavor, inhaleGraceUntil, isInInhaleRange } from '../logic/combat'; import { buffAccelMul, - buffDamageMul, buffSpeedMul, consumeShieldBlock, createBuffState, @@ -96,22 +46,24 @@ import { } from '../logic/mercyHeal'; import { crossedGate, type BoundsRect } from '../logic/stageModel'; import { createParallaxBackground, type BackgroundHandle } from '../systems/background'; -import { createBoss, type BossDamageSource, type BossHandle } from '../systems/boss'; +import type { BossDamageSource, BossHandle } from '../systems/boss'; +import { createBossKit } from '../systems/bossFactory'; import { createBossRoom, type BossRoomHandle } from '../systems/bossRoom'; -import { createNoctra } from '../systems/noctra'; -import { createPrismix } from '../systems/prismix'; -import { createSyrona } from '../systems/syrona'; -import { createVoidra } from '../systems/voidra'; import { createControls, type ControlsSystem } from '../systems/controls'; +import { createEggTracker, type EggTracker } from '../systems/eggTracker'; import { createEliteRoom, type EliteRoomHandle } from '../systems/eliteRoom'; import { createEnemySystem, type EnemySystem } from '../systems/enemies'; -import { createFx, type FxSystem, type TrailHandle } from '../systems/fx'; +import { createFx, type FxSystem } from '../systems/fx'; import { createHud } from '../systems/hud'; import { openPauseMenu } from '../systems/pause'; import { spawnHealPickup } from '../systems/pickups'; import { createPlayer, type PlayerHandle } from '../systems/player'; import { createMeteorSystem, type MeteorSystem } from '../systems/meteor'; +import { wireCombatOverlaps } from '../systems/overlaps'; import { createStage, type StageHandle } from '../systems/stage'; +import { createStarCombat, type StarCombat } from '../systems/starCombat'; +import { createStarSteering, type StarSteering } from '../systems/starSteering'; +import { createToasts, type ToastSystem } from '../systems/toasts'; import { createTide, type TideHandle } from '../systems/tide'; import { TIDE, tideSoakVelocity } from '../logic/tide'; import { createWaveRunner, type WaveRunner } from '../systems/waves'; @@ -119,10 +71,6 @@ import { bindSfxToEvents, playSfx, stopSfx } from '../audio/sfx'; const GROUND_HEIGHT = 80; const GROUND_TOP = VIEW.height - GROUND_HEIGHT; -const SPAWN_EDGE_X = 48; -// 與 waves.ts 生成高度一致:飄飄鳥須在跳躍+拍翅可達高度(橫式地面頂 y=400)。 -const SPAWN_AIR_Y = 240; -const SPAWN_DROP_Y = 330; const MOUTH_OFFSET_X = 26; const SWALLOW_RANGE_PX = 46; const PULL_BASE_SPEED = 160; @@ -144,8 +92,6 @@ const GATE_ZONE_H = 150; const GATE_ABSORB_MS = 700; // 過關星爆停留短拍後進世界地圖(§39:通關後自動進入)。 const MAP_ENTER_DELAY_MS = 500; -// 雷鏈跳電演出(§40):折線閃電段淡出時長。 -const BOLT_FADE_MS = 200; interface GameSceneData { levelId?: LevelId; @@ -190,23 +136,15 @@ export class GameScene extends Phaser.Scene { private bossDown = false; private prevVy = 0; private wasInhaling = false; - private minionDropCount = 0; private mouth = { x: 0, y: 0 }; private gate: Phaser.GameObjects.Container | null = null; private gateRect: BoundsRect | null = null; private prevPlayerX = 0; // 卡點關中點重生(§67):本命最遠推進 x——越過 checkpoint 後死亡自 checkpoint 重生。 private farthestX = 0; - // 彩蛋(§24):每關進度鎖存;bossActiveAt 供 crown-early-hit 時間窗。 - private eggProgress: EggProgress[] = []; - private bossActiveAt = -1; - // 成就(§94):toast 待播佇列與播放中旗標(同批合併單張、跨批序列);pendingUnlocked - // 為本局勝利瞬間新頒發清單,經 GameResultData 帶入結算頁防演出期漏看。 - private achievementToasts: string[] = []; - private achievementToastActive = false; + // 成就(§94):pendingUnlocked 為本局勝利瞬間新頒發清單,經 GameResultData + // 帶入結算頁防演出期漏看;toast 佇列委派 systems/toasts。 private pendingUnlocked: string[] = []; - // e2e 觀測點(§94):最近一張成就 toast 文案(canvas 文字無法由 DOM 斷言)。 - lastAchievementToast = ''; // 中魔王精英房(§48/§52):全流程委派 systems/eliteRoom.ts;v8 起一關可多房(L6 雙精英)。 private eliteRooms: EliteRoomHandle[] = []; @@ -232,6 +170,16 @@ export class GameScene extends Phaser.Scene { private bossTouchDamage: number = BOSS.bodyDamage; private fx!: FxSystem; private stage!: StageHandle; + private starCombat!: StarCombat; + private starSteering!: StarSteering; + private toasts!: ToastSystem; + // 彩蛋(§24):每關進度與 crown-early-hit 時間窗委派 systems/eggTracker。 + private eggTracker!: EggTracker; + + // e2e 觀測點(§94):最近一張成就 toast 文案(canvas 文字無法由 DOM 斷言)。 + get lastAchievementToast(): string { + return this.toasts ? this.toasts.lastAchievementToast() : ''; + } constructor() { super(SceneKeys.Game); @@ -253,17 +201,11 @@ export class GameScene extends Phaser.Scene { this.transitioning = false; this.bossDown = false; this.wasInhaling = false; - this.minionDropCount = 0; this.playerHp = PLAYER.maxHp; this.bossHp = -1; this.gate = null; this.gateRect = null; - this.eggProgress = this.level.easterEggs.map(() => createEggProgress()); - this.bossActiveAt = -1; - this.achievementToasts = []; - this.achievementToastActive = false; this.pendingUnlocked = []; - this.lastAchievementToast = ''; this.farthestX = 0; this.mercy = createMercyState(); this.mercyRng = Math.random; @@ -322,12 +264,66 @@ export class GameScene extends Phaser.Scene { }, this.carryKills, ); - // 雙魔王(§54):品種唯一分派點 buildBoss;非 boss 關建 jellord 待命殼(永不 spawn)。 - const bossKit = this.buildBoss(this.level.boss ?? 'jellord'); + // 雙魔王(§54):品種唯一分派點 systems/bossFactory;非 boss 關建 jellord 待命殼 + //(永不 spawn)。hooks 閉包延遲解析(fx 於後續建立,回呼觸發時已就緒)。 + const bossKit = createBossKit(this, this.level, GROUND_TOP, { + exMode: this.exMode, + arenaLeft: () => this.arenaLeft(), + worldWidth: () => this.worldWidth(), + enemies: () => this.enemies, + fx: () => this.fx, + player: () => this.player, + playerHp: () => this.playerHp, + tide: () => this.tide, + replaceTide: (spec) => { + this.tide?.destroy(); + this.tide = createTide(this, spec, this.worldWidth()); + }, + meteor: () => this.meteor, + feedEggs: (event) => this.eggTracker.feed(event), + }); this.boss = bossKit.handle; this.bossTouchDamage = bossKit.bodyDamage; this.fx = createFx(this); createHud(this); + // 場內浮字與慶祝演出(§24/§46/§94):委派 systems/toasts(fx 閉包延遲解析)。 + this.toasts = createToasts(this, { + fx: () => this.fx, + playerPos: () => ({ x: this.player.sprite.x, y: this.player.sprite.y }), + }); + // 彩蛋進度追蹤(§24):每關重建;存檔寫入與成就佇列經 persistAndAward 回流; + // bossKit 的 feedEggs 回呼僅於魔王事件觸發(此時 tracker 已就緒)。 + this.eggTracker = createEggTracker(this.level, { + player: () => this.player, + playerHp: () => this.playerHp, + bossActive: () => this.boss.isActive(), + now: () => this.time.now, + recordEggAndAward: (trigger) => + this.persistAndAward(recordEgg(this.save, this.currentLevelId, trigger)), + celebrate: (message) => this.toasts.celebrate(message), + }); + // 星彈規格與技能世界結算(§23/§46/§57):委派 systems/starCombat; + // GameScene 只留事件路由與 overlap 接線。 + this.starCombat = createStarCombat(this, { + enemies: () => this.enemies, + fx: () => this.fx, + boss: () => this.boss, + player: () => this.player, + buff: () => this.buff, + bossBodies: () => this.bossBodies(), + damageBossAt: (amount, x, y, source) => this.damageBossAt(amount, x, y, source), + }); + // 星彈飛行導向與拖尾(§46/§54/§59):候選過濾與 velocity 套用委派 starSteering。 + this.starSteering = createStarSteering({ + player: () => this.player, + enemies: () => this.enemies, + boss: () => this.boss, + combat: () => this.starCombat, + fx: () => this.fx, + isBossLevel: () => this.level.boss !== null, + isBossDown: () => this.bossDown, + nearestBossBody: (x, y) => this.nearestBossBody(x, y), + }); // 精英房(§48/§52):boss 關無精英;一關可多房,hooks 閉包延遲解析既有系統。 const eliteHooks = { player: () => this.player, @@ -399,10 +395,27 @@ export class GameScene extends Phaser.Scene { }); } this.physics.add.collider(this.enemies.getGroup(), ground); - this.addOverlaps(); + // 戰鬥碰撞接線(§29/§58/§68/§79):委派 systems/overlaps,接線順序凍結。 + wireCombatOverlaps(this, { + player: () => this.player, + enemies: () => this.enemies, + boss: () => this.boss, + fx: () => this.fx, + meteor: () => this.meteor, + stage: () => this.stage, + combat: () => this.starCombat, + bossBodies: () => this.bossBodies(), + nearestBossBody: (x, y) => this.nearestBossBody(x, y), + bossTouchDamage: () => this.bossTouchDamage, + damagePlayer: (damage, sourceX) => this.damagePlayer(damage, sourceX), + damageBossAt: (amount, x, y, source) => this.damageBossAt(amount, x, y, source), + isSettled: () => this.finished || this.transitioning, + isBossDown: () => this.bossDown, + now: () => this.time.now, + }); this.bindEvents(); - this.boss.onMinionDrop(() => this.spawnBossMinion()); + this.boss.onMinionDrop(() => bossKit.spawnBossMinion()); // shutdown 清理 Phaser 不接管的資源:scene.events 監聽(restart 不重建 emitter, // 未解除即跨局累積)、DOM 監聽、音訊迴圈。player 的 PRE/POST_UPDATE bob 掛鉤必須 @@ -445,14 +458,12 @@ export class GameScene extends Phaser.Scene { this.farthestX = Math.max(this.farthestX, this.player.sprite.x); this.syncJumpSfx(); this.syncInhale(); - this.syncEggs(); + this.eggTracker.sync(); this.syncGateSweep(); for (const room of this.eliteRooms) room.update(); this.bossRoom?.update(); this.advanceBuff(deltaMs); - this.steerHomingStars(deltaMs); - this.steerBossAimAssist(deltaMs); - this.steerMagnetizedStars(deltaMs); + this.starSteering.update(deltaMs); this.advanceMercy(deltaMs); // 側風推移(§52):委派 enemies 系統結算;迴旋星驅動已內建於 player.update。 this.enemies.applyEnvironmentalForces(this.player.sprite, deltaMs); @@ -466,7 +477,7 @@ export class GameScene extends Phaser.Scene { // 魔王關補生等入場運鏡完成(boss active)才推進,避免入場中生怪干擾玩家(review #698)。 if (!this.level.boss || this.boss.isActive()) this.waves.update(deltaMs); this.boss.update(deltaMs); - this.syncStarTrails(); + this.starSteering.syncTrails(); } forceWin(): void { @@ -646,7 +657,7 @@ export class GameScene extends Phaser.Scene { this.buff = pickupBuff(this.buff, id); this.buffPickups += 1; this.player.setBuffMoveMods(buffSpeedMul(this.buff), buffAccelMul(this.buff)); - this.flavorToast(`${BUFF_SPECS[id].nameZh}!短暫強化`); + this.toasts.flavor(`${BUFF_SPECS[id].nameZh}!短暫強化`); } private advanceBuff(deltaMs: number): void { @@ -743,231 +754,6 @@ export class GameScene extends Phaser.Scene { else this.boss.applyDamage(amount, source); } - private addOverlaps(): void { - const stars = this.player.getStars(); - - this.physics.add.overlap(stars, this.enemies.getGroup(), (star, enemy) => { - const target = enemy as Phaser.GameObjects.GameObject; - const s = asSprite(star); - if (!s.active || !this.enemies.kindOf(target)) return; - // 磁場星彈免傷(§59 magno field):星彈吸附於磁殼失效,近戰/下砸路徑不受影響。 - if (this.enemies.isStarImmune(target)) { - this.fx.burstSmall(s.x, s.y, 0x8ab0e8); - playSfx('metal', 0.7); - this.player.onStarHit(s, 'absorb'); - return; - } - // 鏡面反射(§59 mirri mirror):星彈改生成朝玩家的反射彈(反射彈有傷害)。 - if (this.enemies.isReflective(target)) { - const mirri = asSprite(enemy); - this.enemies.reflectStar(mirri.x, mirri.y, this.player.sprite.x, this.player.sprite.y); - this.fx.burstSmall(s.x, s.y, 0xf0f4ff); - this.player.onStarHit(s, 'absorb'); - return; - } - const spec = this.starSpecOf(s); - const outcome = this.enemies.damage(target, this.starDamageOf(s)); - if (outcome === 'ignored') return; - if (spec.aoeRadiusPx > 0) this.explodeStar(s.x, s.y, spec, target); - // 雷鏈星(§40):命中後跳電至半徑內最近敵,主目標排除。 - if (spec.chainCount > 0) this.chainLightning(s.x, s.y, spec, target); - // 孢子星(§53):命中未死目標套緩速+輕持續傷。 - if (spec.slowMs > 0 && outcome === 'hurt') { - this.enemies.applySlow(target, spec.slowMs, spec.dotDamage); - } - // 凝光星(§46):命中處生成凍結光域。 - const mix = this.starMixOf(s); - if (mix && mix.freezeRadiusPx > 0) this.freezeField(s.x, s.y, mix); - // 未死目標(chompy 扣血)吃掉星彈;擊殺則依穿透續飛。 - // 迴旋星(§53):命中不吸收——依穿透結算,保留回程判定。 - this.player.onStarHit(s, outcome === 'killed' || spec.boomerang ? 'pierce' : 'absorb'); - }); - - // group vs sprite 的回調參數順序不保證,取非魔王側為星彈。 - // 入場動畫與死亡演出期間(非 active)星彈直接穿過,不消耗。 - // 多本體(§68):逐本體接線,受擊側由 damageBossAt 依命中位置歸屬。 - for (const hitBody of this.bossBodies()) { - this.physics.add.overlap(stars, hitBody, (a, b) => { - const star = asSprite((a as unknown) === (hitBody as unknown) ? b : a); - if (!star.active || !this.boss.isActive()) return; - if (!(hitBody.body as Phaser.Physics.Arcade.Body).enable) return; - const spec = this.starSpecOf(star); - if (spec.aoeRadiusPx > 0) this.explodeStar(star.x, star.y, spec, null); - // 雷鏈星命中魔王同樣跳電波及補給小怪(§40 群戰原型)。 - if (spec.chainCount > 0) this.chainLightning(star.x, star.y, spec, null); - const mix = this.starMixOf(star); - if (mix && mix.freezeRadiusPx > 0) this.freezeField(star.x, star.y, mix); - this.player.onStarHit(star, 'absorb'); - this.damageBossAt(this.starDamageOf(star), star.x, star.y); - }); - } - - // 碎晶盾(§68 P3):可擊破的星彈屏障——星彈被吸收、盾即碎裂。 - const shields = this.boss.getShields?.(); - if (shields) { - this.physics.add.overlap(stars, shields, (a, b) => { - const isShield = (shields.getChildren() as unknown[]).includes(a); - const shard = asSprite(isShield ? a : b); - const star = asSprite(isShield ? b : a); - if (!star.active || !shard.active) return; - shard.disableBody(true, true); - this.fx.burstSmall(shard.x, shard.y, 0xe8dcff); - playSfx('break', 0.7); - this.player.onStarHit(star, 'absorb'); - }); - } - - // 新怪危險物:puffy 爆刺彈與 chompy 咬合 hitbox(傷害 1,命中即失效)。 - // zappy 放電環(§30)同走此 hazards 管線結算,不另設 overlap。 - this.physics.add.overlap(this.player.sprite, this.enemies.getHazards(), (_p, hz) => { - const hazard = asSprite(hz); - if (!hazard.active || this.finished || this.transitioning) return; - hazard.disableBody(true, true); - this.damagePlayer(ENEMY.touchDamage, hazard.x); - }); - - this.physics.add.overlap(this.player.sprite, this.enemies.getGroup(), (_p, enemy) => { - const kind = this.enemies.kindOf(enemy as Phaser.GameObjects.GameObject); - if (!kind || this.finished || this.transitioning) return; - // 半入地無害態(§47 drilly 潛地/前搖):不結算觸碰傷害。 - if (this.enemies.isPhasedOut(enemy as Phaser.GameObjects.GameObject)) return; - const target = asSprite(enemy); - // 雷化帶電體(§57):接觸對小怪放電(damage CD 節流),玩家觸碰傷害照常結算。 - const contactDamage = this.playerFormSpec()?.contactDamage ?? 0; - if (contactDamage > 0) { - const zapped = this.enemies.damage(enemy as Phaser.GameObjects.GameObject, contactDamage); - if (zapped !== 'ignored') this.fx.burstSmall(target.x, target.y, TRANSFORM_FORMS.volt.tint); - if (zapped === 'killed') return; - } - // 吸入錐形內的可吸怪(§30:shelly 僅暈眩窗)交由吞下流程,不結算觸碰傷害。 - const { x, y } = this.player.sprite; - if ( - this.player.isInhaling() && - this.enemies.isInhalable(enemy as Phaser.GameObjects.GameObject) && - isInInhaleRange(x, y, this.player.getFacing(), target.x, target.y, INHALE.rangePx) - ) { - return; - } - // 吸入接觸豁免(§77):被吸入中(拉力豁免窗內)的怪貼身不傷——涵蓋轉向/ - // 鬆開瞬間與出錐殘餘飛行;窗過期即恢復傷害性,未被吸的其他怪照常結算。 - if (isContactHarmless(this.time.now, (target.getData('inhaleGraceUntil') as number) ?? 0)) { - return; - } - this.damagePlayer(ENEMY.touchDamage, target.x); - }); - - this.physics.add.overlap(this.player.getInhaleZone(), this.enemies.getGroup(), (_z, enemy) => { - asSprite(enemy).setData('inhalePull', true); - }); - - // 觸碰與頭頂 hit window(§58/§68):多本體逐一接線,停用本體不結算; - // 入場運鏡期(非 active)傷害雙向靜默——降落中的魔王不得對走位玩家先手。 - for (const touchBody of this.bossBodies()) { - this.physics.add.overlap(this.player.sprite, touchBody, () => { - if (this.finished || this.transitioning || this.bossDown) return; - if (!this.boss.isActive()) return; - if (!(touchBody.body as Phaser.Physics.Arcade.Body).enable) return; - const playerBody = this.player.sprite.body as Phaser.Physics.Arcade.Body; - // 魔王頭頂 hit window(§58):下砸命中上半身=頭頂——嘗試觸發暈眩並回彈,免體傷。 - if (this.player.isSlamming() && playerBody.bottom <= touchBody.y) { - const stunned = this.boss.trySlamStun(); - this.player.onSlamBounce(); - this.fx.burstSmall(this.player.sprite.x, playerBody.bottom, 0xffd966); - if (stunned) this.fx.shake(8); - return; - } - this.damagePlayer(this.bossTouchDamage, touchBody.x); - }); - } - - this.physics.add.overlap(this.player.sprite, this.boss.getProjectiles(), (_p, ball) => { - const projectile = asSprite(ball); - if (!projectile.active || this.finished || this.transitioning) return; - if (projectile.getData('reflected') === true) return; - // 殼化反彈(§57/§58):彈幕不傷身,反向射回最近存活本體(§68 多本體)。 - if (this.playerFormSpec()?.reflectProjectiles) { - projectile.setData('reflected', true); - projectile.setTint(TRANSFORM_FORMS.shell.tint); - (projectile.body as Phaser.Physics.Arcade.Body).setAllowGravity(false); - const boss = this.nearestBossBody(projectile.x, projectile.y); - this.physics.moveTo(projectile, boss.x, boss.y, SHELL_REFLECT.speed); - playSfx('metal'); - return; - } - projectile.disableBody(true, true); - this.damagePlayer(this.bossTouchDamage, projectile.x); - }); - - // 反彈彈幕回傷(§57/§58):僅帶反彈標記的彈體對魔王結算(§68 多本體逐一接線)。 - for (const reflectBody of this.bossBodies()) { - this.physics.add.overlap(reflectBody, this.boss.getProjectiles(), (a, b) => { - const projectile = asSprite((a as unknown) === (reflectBody as unknown) ? b : a); - if (!projectile.active || projectile.getData('reflected') !== true) return; - if (!this.boss.isActive() || !(reflectBody.body as Phaser.Physics.Arcade.Body).enable) { - return; - } - projectile.disableBody(true, true); - this.fx.burstSmall(projectile.x, projectile.y, TRANSFORM_FORMS.shell.tint); - this.damageBossAt(SHELL_REFLECT.damage, projectile.x, projectile.y, 'reflect'); - }); - } - - this.physics.add.overlap(this.player.sprite, this.boss.getShockwaves(), (_p, wave) => { - const shockwave = asSprite(wave); - if (!shockwave.active || this.finished || this.transitioning) return; - this.damagePlayer(this.bossTouchDamage, shockwave.x); - }); - - // 流星雨(§79):隕星可被星彈擊碎(碎裂演出、星彈吸收);隕星與餘燼命中玩家 - // 走 damagePlayer 單一入口(i-frame/護盾泡自然生效),隕星命中即碎。 - if (this.meteor) { - const meteorGroup = this.meteor.getMeteors(); - this.physics.add.overlap(stars, meteorGroup, (a, b) => { - const isRock = (meteorGroup.getChildren() as unknown[]).includes(a); - const rock = asSprite(isRock ? a : b); - const star = asSprite(isRock ? b : a); - if (!star.active || !rock.active) return; - this.meteor?.shatter(rock); - this.player.onStarHit(star, 'absorb'); - }); - this.physics.add.overlap(this.player.sprite, meteorGroup, (_p, rock) => { - const sprite = asSprite(rock); - if (!sprite.active || this.finished || this.transitioning) return; - this.meteor?.shatter(sprite); - this.damagePlayer(ENEMY.touchDamage, sprite.x); - }); - this.physics.add.overlap(this.player.sprite, this.meteor.getEmbers(), (_p, hz) => { - const ember = asSprite(hz); - if (!ember.active || this.finished || this.transitioning) return; - ember.disableBody(true, true); - this.damagePlayer(ENEMY.touchDamage, ember.x); - }); - } - - // v4 平台元素(§29):單向自上著地、移動平台載運、磚體實心;彈簧與破磚交由 stage 結算。 - this.physics.add.collider( - this.player.sprite, - this.stage.getOneWay(), - undefined, - this.stage.canLandOneWay, - ); - this.physics.add.collider(this.player.sprite, this.stage.getMoving()); - this.physics.add.collider(this.player.sprite, this.stage.getBreakables()); - this.physics.add.overlap( - this.player.sprite, - this.stage.getSprings(), - this.stage.onSpringOverlap, - ); - this.physics.add.overlap(stars, this.stage.getBreakables(), (a, b) => { - const brick = (this.stage.getBreakables() as unknown[]).includes(a) ? a : b; - const star = asSprite(brick === a ? b : a); - if (!star.active) return; - if (this.stage.breakBrick(brick as Phaser.GameObjects.GameObject)) { - this.player.onStarHit(star, 'absorb'); - } - }); - } - private bindEvents(): void { const bind = ( event: K, @@ -990,32 +776,30 @@ export class GameScene extends Phaser.Scene { const key = top.mix ?? top.flavor; if (seenFlavorHints.has(key)) return; seenFlavorHints.add(key); - this.flavorToast(top.mix !== undefined ? MIX_HINTS[top.mix] : FLAVOR_HINTS[top.flavor]); + this.toasts.flavor(top.mix !== undefined ? MIX_HINTS[top.mix] : FLAVOR_HINTS[top.flavor]); }); - // 技能世界結算(§23):player 只發事件,場上效果集中於 GameScene。 - bind(GameEvents.SKILL_STARSTORM, () => this.resolveStarstorm()); + // 技能世界結算(§23):player 只發事件,場上效果委派 starCombat。 + bind(GameEvents.SKILL_STARSTORM, () => this.starCombat.resolveStarstorm()); // 下衝擊落點同步破磚(§29):磚的 damage 接口由 stage 提供,沿用既有 SKILL 事件契約。 bind(GameEvents.SKILL_SLAM_LANDED, ({ x, y }) => { - this.resolveSlamImpact(x, y); - this.stage.damageBricksInRadius(x, y, this.slamRadiusPx()); + this.starCombat.resolveSlamImpact(x, y); + this.stage.damageBricksInRadius(x, y, this.starCombat.slamRadiusPx()); }); // 殼盾格擋成功(§40):正面反擊星爆,波及面前小怪。 bind(GameEvents.SKILL_SHIELD_BLOCK, ({ x, y, facing }) => - this.resolveShieldCounter(x, y, facing), + this.starCombat.resolveShieldCounter(x, y, facing), ); - // 星化形態技(§57):雷化鏈電束/風化落地衝擊由 player 發事件、此處結算世界效果。 + // 星化形態技(§57):雷化鏈電束/風化落地衝擊由 player 發事件、starCombat 結算。 bind(GameEvents.SKILL_TRANSFORM_STRIKE, ({ kind, x, y, facing }) => { - if (kind === 'volt-beam') this.resolveVoltBeam(x, y, facing); - else this.resolveGaleLanding(x, y); + if (kind === 'volt-beam') this.starCombat.resolveVoltBeam(x, y, facing); + else this.starCombat.resolveGaleLanding(x, y); }); bind(GameEvents.BOSS_SPAWNED, ({ maxHp }) => { this.bossHp = maxHp; }); bind(GameEvents.BOSS_DAMAGED, ({ hp }) => { this.bossHp = hp; - if (this.bossActiveAt >= 0) { - this.feedEggs({ kind: 'boss-hit', sinceActiveMs: this.time.now - this.bossActiveAt }); - } + this.eggTracker.noteBossHit(); }); // P3 進場演出(§30):星環衝擊波由 boss 系統呈現,時停以既有 fx API 組合。 // 高風險位增益投放(§69/§82):arena 中央高位刷 1 顆;EX 刷新減半=不投放; @@ -1035,7 +819,7 @@ export class GameScene extends Phaser.Scene { // 彩蛋事件餵送(§24):吞噬歷史與魔王首擊時間窗。 bind(GameEvents.ENEMY_INHALED, ({ kind }) => { const flavor = inhaleFlavor(kind); - if (flavor) this.feedEggs({ kind: 'swallow', flavor }); + if (flavor) this.eggTracker.feed({ kind: 'swallow', flavor }); }); // 敗北語意:走動關死亡重試當前關(卡點關越過中點改自 checkpoint 重生,§67); // 魔王戰死亡進敗北結算(再玩一次直接重試魔王關)。 @@ -1326,156 +1110,6 @@ export class GameScene extends Phaser.Scene { ); } - // 魔王每損 10 HP 補生可吸小怪(供彈藥),arena 左右邊緣交替入場;品種輪替讀關卡 enemyMix。 - // 場上上限(§82 審查根修):爆發傷害連觸多次掉落時夾限累積(waves 上限 +2 供給裕度), - // 防補給怪堆積形成接觸傷害牆;彈藥保證由飢荒立即補生承擔(§26)。 - private spawnBossMinion(): void { - if (this.enemies.aliveCount() >= this.level.maxOnScreen + 2) return; - const kinds = this.level.enemyMix.map((entry) => entry.kind); - const kind = kinds[this.minionDropCount % kinds.length] ?? 'jelly'; - const x = - this.minionDropCount % 2 === 0 - ? this.arenaLeft() + SPAWN_EDGE_X - : this.worldWidth() - SPAWN_EDGE_X; - this.minionDropCount += 1; - this.enemies.spawn(kind, x, kind === 'floaty' || kind === 'gusty' ? SPAWN_AIR_Y : SPAWN_DROP_Y); - } - - // 魔王召喚小怪(§54 P2 floaty/§68 P2 mirri/§74 P2 bubbla):依場上現量夾限至 cap, - // 走正式 spawn 管線;召喚路徑同套潮汐生成調整(交叉不變式 13/17,審查修復)。 - private summonMinion(kind: EnemyKind, cap: number): void { - let alive = 0; - for (const child of this.enemies.getGroup().getChildren()) { - if (child.active && this.enemies.kindOf(child) === kind) alive += 1; - } - for (let i = 0; i < cap - alive; i += 1) { - const x = i % 2 === 0 ? this.arenaLeft() + SPAWN_EDGE_X : this.worldWidth() - SPAWN_EDGE_X; - const defaultY = kind === 'floaty' ? SPAWN_AIR_Y : SPAWN_DROP_Y; - const adjusted = this.tide - ? { kind: this.tide.filterSpawnKind(kind), y: this.tide.adjustSpawnY(defaultY) } - : { kind, y: defaultY }; - this.enemies.spawn(adjusted.kind, x, adjusted.y); - } - } - - // 魔王品種工廠(§54):BossKind 唯一分派點(呈現層 handle+體傷常數一次取齊); - // 新增品種未接線將於編譯期 never 守衛失敗。 - private buildBoss(kind: BossKind): { handle: BossHandle; bodyDamage: number } { - switch (kind) { - case 'jellord': - return { - handle: createBoss(this, { - ex: this.exMode, - arenaLeft: () => this.arenaLeft(), - // EX 擊破分裂(§58):於魔王位置生成小果凍,走正式 spawn 管線。 - onSplit: (x, y, count) => { - for (let i = 0; i < count; i += 1) { - this.enemies.spawn('jelly', x + (i - (count - 1) / 2) * 46, y - 20); - } - }, - }), - bodyDamage: BOSS.bodyDamage, - }; - case 'noctra': - return { - handle: createNoctra( - this, - { summonFloaty: (cap) => this.summonMinion('floaty', cap) }, - { ex: this.exMode, arenaLeft: () => this.arenaLeft() }, - ), - bodyDamage: NOCTRA.bodyDamage, - }; - case 'prismix': - return { - handle: createPrismix( - this, - { - summonMirri: (cap) => this.summonMinion('mirri', cap), - // 雙子連破(§68/§70):FSM 單一真值,GameScene 餵彩蛋+全屏稜光演出。 - onTwinFinish: () => { - this.feedEggs({ kind: 'twin-finish' }); - this.cameras.main.flash(360, 220, 200, 255); - this.fx.starBurst(this.arenaLeft() + this.scale.width / 2, VIEW.height / 2 - 40); - }, - }, - { ex: this.exMode, arenaLeft: () => this.arenaLeft() }, - ), - bodyDamage: PRISMIX.bodyDamage, - }; - case 'syrona': - return { - handle: createSyrona( - this, - { - summonBubbla: (cap) => this.summonMinion('bubbla', cap), - // 窯風三連(§75):呈現層單一真值,GameScene 餵彩蛋+窯火謝幕演出。 - onVentEgg: () => { - this.feedEggs({ kind: 'vent-hit-count' }); - this.cameras.main.flash(320, 255, 214, 140); - this.fx.starBurst(this.arenaLeft() + this.scale.width / 2, VIEW.height / 2 - 40); - }, - // 場控潮汐(§74):沿 GameScene 單一潮汐管線(浸水/生成過濾自然生效)。 - startTide: (spec) => { - this.tide?.destroy(); - this.tide = createTide(this, spec, this.worldWidth()); - }, - boilTide: (spec) => this.tide?.setSpec(spec), - }, - { ex: this.exMode, arenaLeft: () => this.arenaLeft() }, - ), - bodyDamage: SYRONA.bodyDamage, - }; - case 'voidra': - return { - handle: createVoidra( - this, - { - // 星核共鳴(§83):FSM 單一真值,GameScene 餵彩蛋+星雨謝幕演出。 - onShardEgg: () => { - this.feedEggs({ kind: 'survive-collect' }); - this.cameras.main.flash(360, 255, 230, 160); - this.fx.starBurst(this.arenaLeft() + this.scale.width / 2, VIEW.height / 2 - 40); - }, - // P2 定點轟炸(§82):沿 GameScene 單一 meteor 管線開關與調參; - // 收轟炸(轉段/擊破/段重試)連飛行中隕星一併回收(審查收斂)。 - setBombardment: (spec) => { - if (spec) { - this.meteor?.setSpec(spec); - this.meteor?.setActive(true); - } else { - this.meteor?.setActive(false); - this.meteor?.clearAirborne(); - } - }, - // P3 低重力(§81):全域重力直寫;null 回關卡預設(create 亦會重設)。 - setGravityScale: (scale) => { - this.physics.world.gravity.y = GRAVITY_Y * (scale ?? this.level.gravityScale ?? 1); - }, - // P2 段內固定愛心(§6.3 保底):走既有 heal pickup 管線、緩降至地面帶。 - dropSurvivalHeart: () => { - const x = this.arenaLeft() + this.scale.width * 0.3; - playSfx('reveal'); - this.fx.burstSmall(x, 150, 0xff9ec4); - spawnHealPickup( - this, - x, - 150, - { player: () => this.player, playerHp: () => this.playerHp }, - { healHp: MERCY_HEAL.healHp, driftToY: GROUND_TOP - 22 }, - ); - }, - }, - { ex: this.exMode, arenaLeft: () => this.arenaLeft() }, - ), - bodyDamage: VOIDRA.bodyDamage, - }; - default: { - const unhandled: never = kind; - throw new Error(`未知魔王品種:${String(unhandled)}`); - } - } - } - // 跳躍/拍翅無契約事件,以速度轉變判定配音(buffer 觸發的跳躍無當幀按壓)。 private syncJumpSfx(): void { const vy = (this.player.sprite.body as Phaser.Physics.Arcade.Body).velocity.y; @@ -1536,75 +1170,6 @@ export class GameScene extends Phaser.Scene { } } - // 彩蛋逐幀事件(§24):世界座標與平台站立;魔王可擊打起點供時間窗計算。 - private syncEggs(): void { - if (this.level.boss && this.bossActiveAt < 0 && this.boss.isActive()) { - this.bossActiveAt = this.time.now; - } - if (this.eggProgress.every((progress) => progress.done)) return; - this.feedEggs({ kind: 'position', x: this.player.sprite.x }); - this.feedEggs({ kind: 'stand', platformY: this.standingPlatformY() }); - } - - // 站立平台判定:腳底貼齊平台頂(rect 高 16 → 頂 = y - 8)且 x 落於平台範圍。 - private standingPlatformY(): number | null { - const body = this.player.sprite.body as Phaser.Physics.Arcade.Body; - if (!body.blocked.down && !body.touching.down) return null; - for (const spec of this.level.platforms) { - if ( - Math.abs(body.bottom - (spec.y - 8)) <= 4 && - Math.abs(this.player.sprite.x - spec.x) <= spec.w / 2 + 12 - ) { - return spec.y; - } - } - return null; - } - - private feedEggs(event: EggEvent): void { - this.level.easterEggs.forEach((spec, i) => { - const progress = this.eggProgress[i]; - if (!progress || progress.done) return; - const result = advanceEgg(spec, progress, event); - this.eggProgress[i] = result.progress; - if (result.triggered) this.grantEggReward(spec); - }); - } - - // 獎勵落地(§24):+1 HP(上限 6)/滿彈匣/金星彈/+1 HP。 - private grantEggReward(spec: EasterEggSpec): void { - // 存檔寫入時機(§38):彩蛋觸發即記錄(trigger 型別為關內唯一 id)。 - this.persistAndAward(recordEgg(this.save, this.currentLevelId, spec.trigger)); - switch (spec.reward) { - case 'hp-up': - this.player.heal(1, EGG_HP_CAP); - this.eggCelebration('彩虹果凍 +1 HP'); - break; - case 'full-magazine': - this.player.grantFullMagazine(); - this.eggCelebration('星星雨!彈匣全滿'); - break; - case 'gold-star': - this.player.grantGoldStar(); - this.eggCelebration('金星彈入匣!'); - break; - case 'heal': - // 滿血時 heal 無感(player.heal 靜默略過):fallback 改給滿彈匣,獎勵必有回饋。 - if (this.playerHp >= PLAYER.maxHp) { - this.player.grantFullMagazine(); - this.eggCelebration('皇冠火花!彈匣全滿'); - } else { - this.player.heal(1, PLAYER.maxHp); - this.eggCelebration('皇冠火花 +1 HP'); - } - break; - default: { - const exhaustive: never = spec.reward; - void exhaustive; - } - } - } - // 存檔寫入單點(§94):寫入後評估成就增量——頒發、單次持久化、排入 toast 佇列。 // 成就判定恆由 save 資料派生(awardAchievements 內部 diff),此處不做侵入式鉤子。 // 同批多解鎖合併為單張橫幅(審查 U1):勝利轉場 2.8s 窗口內必可播完整批。 @@ -1613,161 +1178,7 @@ export class GameScene extends Phaser.Scene { persistSave(save); if (newly.length === 0) return; this.pendingUnlocked.push(...newly); - this.achievementToasts.push(newly.map((id) => getAchievement(id)?.nameZh ?? id).join('、')); - this.drainAchievementToasts(); - } - - // 成就 toast 佇列(§94):一次一張序列播放(跨批不重疊);轉場即隨場景銷毀, - // 漏播由 Result 名單與圖鑑成就頁兜底。金色橫幅帶深色底襯(勝利白閃下仍可讀), - // 禁全屏遮罩。 - private drainAchievementToasts(): void { - if (this.achievementToastActive) return; - const names = this.achievementToasts.shift(); - if (names === undefined) return; - this.achievementToastActive = true; - this.lastAchievementToast = names; - playSfx('pop'); - const toast = this.add - .text(this.scale.width / 2, this.scale.height * 0.3, `成就解鎖:${names}`, { - fontFamily: 'system-ui, sans-serif', - fontSize: '19px', - fontStyle: 'bold', - color: '#ffe9a8', - stroke: '#8a6a1f', - strokeThickness: 4, - backgroundColor: 'rgba(58, 42, 20, 0.82)', - padding: { x: 14, y: 7 }, - align: 'center', - // CJK 逐字換行:多重解鎖合併名單在 854 寬不溢出。 - wordWrap: { width: Math.min(this.scale.width - 160, 700), useAdvancedWrap: true }, - }) - .setOrigin(0.5) - .setDepth(110) - .setScrollFactor(0) - .setAlpha(0); - this.tweens.chain({ - targets: toast, - tweens: [ - { alpha: 1, duration: 220, ease: 'Quad.easeOut' }, - { alpha: 0, y: '-=16', duration: 360, delay: 1500, ease: 'Quad.easeIn' }, - ], - onComplete: () => { - toast.destroy(); - this.achievementToastActive = false; - this.drainAchievementToasts(); - }, - }); - } - - // 星味首遇 toast(§46/§47):頂部橫幅下方一行小字,淡入停留後上飄淡出。 - private flavorToast(message: string): void { - const toast = this.add - .text(this.scale.width / 2, this.scale.height * 0.22, message, { - fontFamily: 'system-ui, sans-serif', - fontSize: '19px', - fontStyle: 'bold', - color: '#ffffff', - stroke: '#7a5fb8', - strokeThickness: 4, - }) - .setOrigin(0.5) - .setDepth(110) - .setScrollFactor(0) - .setAlpha(0); - this.tweens.chain({ - targets: toast, - tweens: [ - { alpha: 1, duration: 220, ease: 'Quad.easeOut' }, - { alpha: 0, y: '-=16', duration: 360, delay: 1600, ease: 'Quad.easeIn' }, - ], - onComplete: () => toast.destroy(), - }); - } - - // 彩蛋演出(§24):金光 popIn + 專屬 jingle + 浮字(既有 fx 組合)。 - private eggCelebration(message: string): void { - playSfx('jingle'); - const { y } = this.player.sprite; - // 浮字夾限於鏡頭視野內,避免世界邊緣觸發時被裁切。 - const view = this.cameras.main.worldView; - const x = Phaser.Math.Clamp(this.player.sprite.x, view.x + 110, view.right - 110); - const glow = this.add - .image(x, y, 'fx-star') - .setDisplaySize(130, 130) - .setTint(0xffc93c) - .setAlpha(0) - .setDepth(94); - this.tweens.add({ - targets: glow, - alpha: { from: 0.9, to: 0 }, - scale: { from: glow.scale * 0.3, to: glow.scale * 1.7 }, - duration: 750, - ease: 'Quad.easeOut', - onComplete: () => glow.destroy(), - }); - this.fx.starBurst(x, y - 20); - const label = this.add - .text(x, y - 64, message, { - fontFamily: 'system-ui, sans-serif', - fontSize: '24px', - fontStyle: 'bold', - color: '#ffc93c', - stroke: '#3a3a4a', - strokeThickness: 5, - }) - .setOrigin(0.5) - .setDepth(96); - this.tweens.add({ - targets: label, - y: y - 118, - alpha: { from: 1, to: 0 }, - duration: 1200, - ease: 'Cubic.easeOut', - onComplete: () => label.destroy(), - }); - } - - private starFlavorOf(star: Phaser.Physics.Arcade.Sprite): StarFlavor { - return (star.getData('flavor') as StarFlavor | undefined) ?? 'jelly'; - } - - // 混合星規格(§46):發射端寫入 mix id;非混合彈回 null。 - private starMixOf(star: Phaser.Physics.Arcade.Sprite): StarMixSpec | null { - const id = star.getData('mix') as MixId | null | undefined; - return id ? getMix(id) : null; - } - - // 彈道有效規格單一出口(§46):混合彈讀配方表,其餘讀屬性表。 - private starSpecOf(star: Phaser.Physics.Arcade.Sprite): StarFlavorSpec { - return this.starMixOf(star) ?? STAR_FLAVORS[this.starFlavorOf(star)]; - } - - // 星彈傷害(§23/§69):發射端已依槽位(強化 ×1.6 / 金星 20)寫入;星力果倍率結算端疊乘。 - private starDamageOf(star: Phaser.Physics.Arcade.Sprite): number { - const base = (star.getData('damage') as number | undefined) ?? this.starSpecOf(star).damage; - return base * buffDamageMul(this.buff); - } - - // 星暴(§23):白閃 + 震屏 + 視野內星雨連爆;清場全小怪、魔王固定 12 傷。 - private resolveStarstorm(): void { - this.cameras.main.flash(280, 255, 255, 255); - this.fx.shake(12); - const view = this.cameras.main.worldView; - for (let i = 0; i < 6; i += 1) { - this.time.delayedCall(i * 90, () => - this.fx.starBurst( - view.x + Math.random() * view.width, - view.y + Math.random() * view.height * 0.6, - ), - ); - } - for (const child of this.enemies.getGroup().getChildren()) { - if (child.active) this.enemies.kill(child); - } - if (this.boss.isActive()) { - // 多本體(§68):星暴固定傷結算至玩家最近的存活本體。 - this.damageBossAt(STARSTORM.bossDamage, this.player.sprite.x, this.player.sprite.y); - } + this.toasts.queueAchievements(newly.map((id) => getAchievement(id)?.nameZh ?? id).join('、')); } // P3 全場震落(§30):slam 附加全場訊號,站立玩家強制彈起。 @@ -1778,341 +1189,6 @@ export class GameScene extends Phaser.Scene { if (body.blocked.down || body.touching.down) this.player.sprite.setVelocityY(QUAKE_BOUNCE_VY); } - // 玩家當前形態規格(§57):未變身為 null。 - private playerFormSpec(): (typeof TRANSFORM_FORMS)['volt'] | null { - const form = this.player.getTransformState().form; - return form ? TRANSFORM_FORMS[form] : null; - } - - // 下衝擊有效半徑(§57):殼化下砸範圍加倍。 - private slamRadiusPx(): number { - return SLAM.radiusPx * (this.playerFormSpec()?.slamRadiusMul ?? 1); - } - - // 下衝擊落地(§23):60px 圓域傷害 2 + 擊退;未死者被震開。 - private resolveSlamImpact(x: number, y: number): void { - this.fx.shake(6); - const radius = this.slamRadiusPx(); - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active) continue; - const enemy = asSprite(child); - if (Phaser.Math.Distance.Between(x, y, enemy.x, enemy.y) > radius) continue; - const outcome = this.enemies.damage(child, SLAM.damage); - if (outcome === 'hurt') { - const kb = knockbackVelocity(enemy.x, x, SLAM.knockbackSpeed, SLAM.knockbackLift); - enemy.setVelocity(kb.x, kb.y); - } - } - } - - // 殼盾反擊星爆(§40):盾面前定點星爆,波及面前 90px 小怪。 - private resolveShieldCounter(x: number, y: number, facing: 1 | -1): void { - const originX = x + facing * 30; - this.fx.starBurst(originX, y); - this.fx.shake(5); - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active) continue; - const enemy = asSprite(child); - if ( - Phaser.Math.Distance.Between(originX, y, enemy.x, enemy.y) <= SHELL_SHIELD.counterRadiusPx - ) { - this.enemies.damage(child, SHELL_SHIELD.counterDamage); - } - } - } - - // 雷化鏈電束(§57/§68):短程面向側取最近目標(小怪或魔王本體)主傷,再沿雷鏈跳電 - // 波及——跳電候選含小怪、其餘存活魔王本體與碎晶盾(分裂型天然剋制,審查修復)。 - private resolveVoltBeam(x: number, y: number, facing: 1 | -1): void { - interface BeamTarget { - x: number; - y: number; - ref: Phaser.GameObjects.GameObject | null; - shield?: Phaser.Physics.Arcade.Sprite; - } - const candidates: BeamTarget[] = []; - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active) continue; - const enemy = asSprite(child); - candidates.push({ x: enemy.x, y: enemy.y, ref: child }); - } - if (this.boss.isActive()) { - // 多本體(§68):全部存活本體入候選,鏈電束就近取目標。 - for (const body of this.bossBodies()) { - if (!(body.body as Phaser.Physics.Arcade.Body).enable) continue; - candidates.push({ x: body.x, y: body.y, ref: null }); - } - for (const obj of this.boss.getShields?.()?.getMatching('active', true) ?? []) { - const shard = asSprite(obj); - candidates.push({ x: shard.x, y: shard.y, ref: null, shield: shard }); - } - } - const inFront = candidates.filter((c) => Math.sign(c.x - x) === facing || c.x === x); - const target = pickChainTargets(x, y, inFront, 1, VOLT_BEAM.rangePx)[0]; - if (!target) return; - // 跳電單一結算出口:主傷與波及共用(盾碎裂/小怪/魔王本體三路)。 - const strike = (hit: BeamTarget, damage: number): void => { - this.fx.burstSmall(hit.x, hit.y, TRANSFORM_FORMS.volt.tint); - if (hit.shield) { - hit.shield.disableBody(true, true); - playSfx('break', 0.7); - return; - } - if (hit.ref) { - this.enemies.damage(hit.ref, damage); - return; - } - this.damageBossAt(damage, hit.x, hit.y, 'volt'); - }; - playSfx('zap'); - this.drawBolt(x, y, target.x, target.y); - // 星力果(§69):變身射擊同享傷害倍率。 - strike(target, VOLT_BEAM.damage * buffDamageMul(this.buff)); - // 跳電波及:以主目標為原點取最近候選(排除主目標自身)。 - const rest = candidates.filter((c) => c !== target); - let fromX = target.x; - let fromY = target.y; - for (const hop of pickChainTargets( - target.x, - target.y, - rest, - VOLT_BEAM.chainCount, - VOLT_BEAM.rangePx, - )) { - this.drawBolt(fromX, fromY, hop.x, hop.y); - strike(hop, VOLT_BEAM.chainDamage); - fromX = hop.x; - fromY = hop.y; - } - } - - // 風化落地衝擊(§57):小範圍傷害+輕擊退(半徑/傷害低於下衝擊,零彈藥消耗)。 - private resolveGaleLanding(x: number, y: number): void { - this.fx.shake(3); - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active) continue; - const enemy = asSprite(child); - if (Phaser.Math.Distance.Between(x, y, enemy.x, enemy.y) > GALE_FLIGHT.landingRadiusPx) { - continue; - } - const outcome = this.enemies.damage(child, GALE_FLIGHT.landingDamage); - if (outcome === 'hurt') { - const kb = knockbackVelocity(enemy.x, x, SLAM.knockbackSpeed * 0.7, SLAM.knockbackLift); - enemy.setVelocity(kb.x, kb.y); - } - } - } - - // 雷鏈跳電(§40):命中點半徑內取最近 chainCount 隻依序跳電,折線閃電演出。 - private chainLightning( - x: number, - y: number, - spec: StarFlavorSpec, - exclude: Phaser.GameObjects.GameObject | null, - ): void { - const candidates: { x: number; y: number; ref: Phaser.GameObjects.GameObject }[] = []; - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active || child === exclude) continue; - const enemy = asSprite(child); - candidates.push({ x: enemy.x, y: enemy.y, ref: child }); - } - const targets = pickChainTargets(x, y, candidates, spec.chainCount, spec.chainRadiusPx); - if (targets.length === 0) return; - playSfx('zap'); - let fromX = x; - let fromY = y; - for (const target of targets) { - this.drawBolt(fromX, fromY, target.x, target.y); - this.fx.burstSmall(target.x, target.y, spec.tint); - this.enemies.damage(target.ref, spec.chainDamage); - fromX = target.x; - fromY = target.y; - } - } - - // 折線閃電:兩點間三段隨機垂直抖動折線,快速淡出自毀。 - private drawBolt(x1: number, y1: number, x2: number, y2: number): void { - const bolt = this.add.graphics().setDepth(93); - bolt.lineStyle(3, 0xffe28a, 1); - bolt.beginPath(); - bolt.moveTo(x1, y1); - const nx = -(y2 - y1); - const ny = x2 - x1; - const len = Math.hypot(nx, ny) || 1; - for (const t of [0.25, 0.5, 0.75]) { - const jitter = (Math.random() - 0.5) * 22; - bolt.lineTo( - x1 + (x2 - x1) * t + (nx / len) * jitter, - y1 + (y2 - y1) * t + (ny / len) * jitter, - ); - } - bolt.lineTo(x2, y2); - bolt.strokePath(); - this.tweens.add({ - targets: bolt, - alpha: 0, - duration: BOLT_FADE_MS, - ease: 'Quad.easeIn', - onComplete: () => bolt.destroy(), - }); - } - - // 凝光星凍結場(§46):命中處光域擴散,域內小怪凍結停擺(主目標一併凍結); - // 選敵下沉 combat.pickInRadius,Scene 只管視覺 tween 與凍結套用。 - private freezeField(x: number, y: number, mix: StarMixSpec): void { - const field = this.add - .circle(x, y, mix.freezeRadiusPx, 0xdff2ff, 0.22) - .setStrokeStyle(3, 0xbfe8ff, 0.9) - .setDepth(59) - .setScale(0.3); - this.tweens.add({ - targets: field, - scale: 1, - duration: 220, - ease: 'Quad.easeOut', - }); - this.tweens.add({ - targets: field, - alpha: 0, - delay: mix.freezeMs - 300, - duration: 300, - onComplete: () => field.destroy(), - }); - const candidates: { x: number; y: number; ref: Phaser.GameObjects.GameObject }[] = []; - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active) continue; - const enemy = asSprite(child); - candidates.push({ x: enemy.x, y: enemy.y, ref: child }); - } - for (const target of pickInRadius(x, y, candidates, mix.freezeRadiusPx + 20)) { - this.enemies.freeze(target.ref, mix.freezeMs); - } - } - - // 魔王房準星輔助(§54 難度根修):一般星彈對活動中魔王微幅導向——地面水平彈 - // 自然上彎入盤旋帶,保底線不依賴拍翅精度;轉率遠低於追電星、大致對向才生效。 - // 多本體(§68):導向星彈最近的存活本體。 - private steerBossAimAssist(deltaMs: number): void { - if (!this.level.boss || !this.boss.isActive() || this.bossDown) return; - for (const child of this.player.getStars().getChildren()) { - const star = asSprite(child); - if (!star.active) continue; - // 追電星有自己的導向;迴旋星彈道由回程驅動,皆不疊加輔助。 - if (this.starMixOf(star)?.homing || this.starSpecOf(star).boomerang) continue; - const body = this.nearestBossBody(star.x, star.y); - const starBody = star.body as Phaser.Physics.Arcade.Body; - const towardBoss = Math.sign(body.x - star.x); - if (towardBoss !== 0 && Math.sign(starBody.velocity.x) !== towardBoss) continue; - if (Phaser.Math.Distance.Between(star.x, star.y, body.x, body.y) > BOSS_AIM_ASSIST.rangePx) { - continue; - } - const steered = steerTowardTarget( - starBody.velocity.x, - starBody.velocity.y, - star.x, - star.y, - body.x, - body.y, - this.starSpecOf(star).speed, - BOSS_AIM_ASSIST.turnRadPerMs * deltaMs, - ); - starBody.setVelocity(steered.vx, steered.vy); - } - } - - // 追電星導引(§46):飛行中朝最近小怪限速轉向;鎖敵與轉向數學下沉 logic/homing.ts, - // Scene 只管 body velocity 套用。 - private steerHomingStars(deltaMs: number): void { - for (const child of this.player.getStars().getChildren()) { - const star = asSprite(child); - if (!star.active) continue; - const mix = this.starMixOf(star); - if (!mix?.homing) continue; - const candidates: { x: number; y: number }[] = []; - for (const candidate of this.enemies.getGroup().getChildren()) { - if (!candidate.active) continue; - const enemy = asSprite(candidate); - candidates.push({ x: enemy.x, y: enemy.y }); - } - const nearest = nearestInRange(star.x, star.y, candidates, HOMING_RANGE_PX); - if (!nearest) continue; - const body = star.body as Phaser.Physics.Arcade.Body; - const steered = steerTowardTarget( - body.velocity.x, - body.velocity.y, - star.x, - star.y, - nearest.x, - nearest.y, - mix.speed, - HOMING_TURN_RAD_PER_MS * deltaMs, - ); - body.setVelocity(steered.vx, steered.vy); - } - } - - // 磁場吸偏(§59 magno field):域內玩家星彈逐幀被拉向磁極獸;數學下沉 logic/enemyFsm。 - private steerMagnetizedStars(deltaMs: number): void { - const magnos: { x: number; y: number }[] = []; - for (const child of this.enemies.getGroup().getChildren()) { - if (!child.active || this.enemies.kindOf(child) !== 'magno') continue; - if (child.getData('magnoPhase') !== 'field') continue; - const magno = asSprite(child); - magnos.push({ x: magno.x, y: magno.y }); - } - if (magnos.length === 0) return; - for (const child of this.player.getStars().getChildren()) { - const star = asSprite(child); - if (!star.active) continue; - const body = star.body as Phaser.Physics.Arcade.Body; - for (const magno of magnos) { - const pulled = magnetPull( - star.x, - star.y, - body.velocity.x, - body.velocity.y, - magno.x, - magno.y, - deltaMs, - ); - body.setVelocity(pulled.vx, pulled.vy); - } - } - } - - // 爆裂星 AoE(§20/§53):命中處圓形距離判定波及其他小怪,主目標排除避免重複結算; - // 毒爆雲(slowMs > 0)對波及未死者加套緩速持續傷。 - private explodeStar( - x: number, - y: number, - spec: StarFlavorSpec, - exclude: Phaser.GameObjects.GameObject | null, - ): void { - this.fx.burstSmall(x, y, spec.tint); - for (const child of this.enemies.getGroup().getChildren()) { - if (child === exclude || !child.active) continue; - const enemy = asSprite(child); - if (Phaser.Math.Distance.Between(x, y, enemy.x, enemy.y) <= spec.aoeRadiusPx) { - const outcome = this.enemies.damage(child, spec.aoeDamage); - if (spec.slowMs > 0 && outcome === 'hurt') { - this.enemies.applySlow(child, spec.slowMs, spec.dotDamage); - } - } - } - } - - private syncStarTrails(): void { - for (const child of this.player.getStars().getChildren()) { - const star = asSprite(child); - const trail = star.getData('fxTrail') as TrailHandle | undefined; - if (star.active && !trail) star.setData('fxTrail', this.fx.attachTrail(star)); - else if (!star.active && trail) { - trail.stop(); - star.setData('fxTrail', undefined); - } - } - } - // 教學浮字:偵測首次任一操作輸入,交由 waves 排程淡出。 private syncTutorialInput(): void { const { left, right, jumpHeld, actionHeld } = this.controls.state; diff --git a/apps/starpuff/src/game/scenes/TitleScene.ts b/apps/starpuff/src/game/scenes/TitleScene.ts index b7a25333b..99057d62e 100644 --- a/apps/starpuff/src/game/scenes/TitleScene.ts +++ b/apps/starpuff/src/game/scenes/TitleScene.ts @@ -127,7 +127,7 @@ export class TitleScene extends Phaser.Scene { }); } - // logo 動畫入場(§36/§103 D6 品牌化):雙層字形——底層深紫厚描邊下沉 3px 造 + // logo 動畫入場(§36/§103 D6 品牌化):雙層字形——底層深紫厚描邊下沉 5px 造 // 糖果立體感,頂層白字帶垂直粉漸層 tint+柔影;零新字型檔,僅字重/層次/陰影。 const logoY = height * 0.15; const logoDepth = this.add diff --git a/apps/starpuff/src/game/systems/bossFactory.test.ts b/apps/starpuff/src/game/systems/bossFactory.test.ts new file mode 100644 index 000000000..0c9ec52fa --- /dev/null +++ b/apps/starpuff/src/game/systems/bossFactory.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { GRAVITY_Y } from '../core/config'; +import { BOSS } from '../logic/bossFsm'; +import { NOCTRA } from '../logic/noctraFsm'; +import { PRISMIX } from '../logic/prismixFsm'; +import { SYRONA } from '../logic/syronaFsm'; +import { VOIDRA } from '../logic/voidraFsm'; +import type { LevelSpec } from '../logic/levels'; +import { createBossKit, type BossFactoryHooks } from './bossFactory'; +import { createBoss } from './boss'; +import { createNoctra } from './noctra'; +import { createPrismix } from './prismix'; +import { createSyrona } from './syrona'; +import { createVoidra } from './voidra'; +import { spawnHealPickup } from './pickups'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { MeteorSystem } from './meteor'; +import type { PlayerHandle } from './player'; +import type { TideHandle } from './tide'; + +// characterization(v17 債務列車):鎖住自 GameScene 抽出的魔王工廠現行為—— +// 品種分派表、體傷常數對映、補給小怪節奏與品種回呼接線;呈現層本體由各自 +// systems 模組與 e2e 覆蓋。 + +vi.mock('./boss', () => ({ createBoss: vi.fn(() => ({ id: 'jellord-handle' })) })); +vi.mock('./noctra', () => ({ createNoctra: vi.fn(() => ({ id: 'noctra-handle' })) })); +vi.mock('./prismix', () => ({ createPrismix: vi.fn(() => ({ id: 'prismix-handle' })) })); +vi.mock('./syrona', () => ({ createSyrona: vi.fn(() => ({ id: 'syrona-handle' })) })); +vi.mock('./voidra', () => ({ createVoidra: vi.fn(() => ({ id: 'voidra-handle' })) })); +vi.mock('./pickups', () => ({ spawnHealPickup: vi.fn() })); +vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); + +const GROUND_TOP = 400; + +function makeScene(): { scene: Phaser.Scene; flash: ReturnType } { + const flash = vi.fn(); + return { + flash, + scene: { + cameras: { main: { flash } }, + scale: { width: 854 }, + physics: { world: { gravity: { y: GRAVITY_Y } } }, + } as unknown as Phaser.Scene, + }; +} + +function makeLevel(overrides: Partial): LevelSpec { + return { + boss: null, + maxOnScreen: 5, + enemyMix: [{ kind: 'jelly', weight: 1 }], + gravityScale: undefined, + ...overrides, + } as unknown as LevelSpec; +} + +interface EnemiesStub { + system: EnemySystem; + spawned: { kind: string; x: number; y: number }[]; + setAlive(entries: { kind: string; active?: boolean }[]): void; + aliveTotal: number; +} + +function makeEnemies(): EnemiesStub { + const spawned: { kind: string; x: number; y: number }[] = []; + let children: { kind: string; active: boolean }[] = []; + const stub: EnemiesStub = { + spawned, + aliveTotal: 0, + setAlive(entries) { + children = entries.map((entry) => ({ kind: entry.kind, active: entry.active ?? true })); + }, + system: { + spawn: (kind: string, x: number, y: number) => { + spawned.push({ kind, x, y }); + }, + aliveCount: () => stub.aliveTotal, + getGroup: () => ({ getChildren: () => children }), + kindOf: (child: unknown) => (child as { kind: string }).kind, + } as unknown as EnemySystem, + }; + return stub; +} + +function makeHooks( + enemies: EnemiesStub, + overrides: Partial = {}, +): { + hooks: BossFactoryHooks; + feedEggs: ReturnType; + fxStarBurst: ReturnType; + replaceTide: ReturnType; +} { + const feedEggs = vi.fn(); + const fxStarBurst = vi.fn(); + const replaceTide = vi.fn(); + const hooks: BossFactoryHooks = { + exMode: false, + arenaLeft: () => 0, + worldWidth: () => 854, + enemies: () => enemies.system, + fx: () => ({ starBurst: fxStarBurst, burstSmall: vi.fn() }) as unknown as FxSystem, + player: () => ({}) as unknown as PlayerHandle, + playerHp: () => 5, + tide: () => null, + replaceTide, + meteor: () => null, + feedEggs, + ...overrides, + }; + return { hooks, feedEggs, fxStarBurst, replaceTide }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createBossKit 品種分派表(§54 唯一分派點)', () => { + it('jellord:createBoss 接 ex 旗標、bodyDamage 取 BOSS 常數', () => { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies, { exMode: true }); + const kit = createBossKit(makeScene().scene, makeLevel({ boss: 'jellord' }), GROUND_TOP, hooks); + expect(kit.handle).toEqual({ id: 'jellord-handle' }); + expect(kit.bodyDamage).toBe(BOSS.bodyDamage); + expect(vi.mocked(createBoss).mock.calls[0]?.[1]).toMatchObject({ ex: true }); + }); + + it('非 boss 關建 jellord 待命殼(永不 spawn 的預設分支)', () => { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies); + const kit = createBossKit(makeScene().scene, makeLevel({ boss: null }), GROUND_TOP, hooks); + expect(kit.handle).toEqual({ id: 'jellord-handle' }); + expect(vi.mocked(createBoss)).toHaveBeenCalledTimes(1); + }); + + it('noctra/prismix/syrona/voidra 各取對應 handle 與體傷常數', () => { + const cases = [ + { boss: 'noctra', factory: createNoctra, damage: NOCTRA.bodyDamage }, + { boss: 'prismix', factory: createPrismix, damage: PRISMIX.bodyDamage }, + { boss: 'syrona', factory: createSyrona, damage: SYRONA.bodyDamage }, + { boss: 'voidra', factory: createVoidra, damage: VOIDRA.bodyDamage }, + ] as const; + for (const spec of cases) { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies); + const kit = createBossKit( + makeScene().scene, + makeLevel({ boss: spec.boss }), + GROUND_TOP, + hooks, + ); + expect(kit.handle).toEqual({ id: `${spec.boss}-handle` }); + expect(kit.bodyDamage).toBe(spec.damage); + expect(vi.mocked(spec.factory)).toHaveBeenCalledTimes(1); + } + }); + + it('jellord EX 擊破分裂:onSplit 於魔王位置置中生成 count 隻小果凍', () => { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies, { exMode: true }); + createBossKit(makeScene().scene, makeLevel({ boss: 'jellord' }), GROUND_TOP, hooks); + const options = vi.mocked(createBoss).mock.calls[0]?.[1] as { + onSplit?: (x: number, y: number, count: number) => void; + }; + options.onSplit?.(400, 300, 3); + expect(enemies.spawned).toEqual([ + { kind: 'jelly', x: 400 - 46, y: 280 }, + { kind: 'jelly', x: 400, y: 280 }, + { kind: 'jelly', x: 400 + 46, y: 280 }, + ]); + }); +}); + +describe('spawnBossMinion 補給節奏(每損 10 HP 掉補給)', () => { + it('左右邊緣交替入場、品種依 enemyMix 輪替、floaty 走空中高度', () => { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies); + const level = makeLevel({ + boss: 'jellord', + enemyMix: [ + { kind: 'jelly', weight: 1 }, + { kind: 'floaty', weight: 1 }, + ] as LevelSpec['enemyMix'], + }); + const kit = createBossKit(makeScene().scene, level, GROUND_TOP, hooks); + kit.spawnBossMinion(); + kit.spawnBossMinion(); + kit.spawnBossMinion(); + expect(enemies.spawned).toEqual([ + { kind: 'jelly', x: 48, y: 330 }, + { kind: 'floaty', x: 854 - 48, y: 240 }, + { kind: 'jelly', x: 48, y: 330 }, + ]); + }); + + it('場上達 maxOnScreen+2 時不再補生(防接觸傷害牆)', () => { + const enemies = makeEnemies(); + enemies.aliveTotal = 7; + const { hooks } = makeHooks(enemies); + const kit = createBossKit( + makeScene().scene, + makeLevel({ boss: 'jellord', maxOnScreen: 5 }), + GROUND_TOP, + hooks, + ); + kit.spawnBossMinion(); + expect(enemies.spawned).toEqual([]); + }); +}); + +describe('summonMinion 召喚夾限(P2 帶品種 cap)', () => { + it('noctra summonFloaty:依場上現量補至 cap、左右交替入場', () => { + const enemies = makeEnemies(); + enemies.setAlive([{ kind: 'floaty' }, { kind: 'jelly' }]); + const { hooks } = makeHooks(enemies); + createBossKit(makeScene().scene, makeLevel({ boss: 'noctra' }), GROUND_TOP, hooks); + const summonHooks = vi.mocked(createNoctra).mock.calls[0]?.[1] as { + summonFloaty(cap: number): void; + }; + summonMinionViaCap(summonHooks, 3); + // 場上已 1 隻 floaty → 補 2 隻至 cap 3;jelly 不計入同種現量。 + expect(enemies.spawned).toEqual([ + { kind: 'floaty', x: 48, y: 240 }, + { kind: 'floaty', x: 854 - 48, y: 240 }, + ]); + }); + + it('召喚路徑同套潮汐生成調整(交叉不變式 13/17)', () => { + const enemies = makeEnemies(); + const tide = { + filterSpawnKind: vi.fn(() => 'jelly'), + adjustSpawnY: vi.fn(() => 300), + } as unknown as TideHandle; + const { hooks } = makeHooks(enemies, { tide: () => tide }); + createBossKit(makeScene().scene, makeLevel({ boss: 'noctra' }), GROUND_TOP, hooks); + const summonHooks = vi.mocked(createNoctra).mock.calls[0]?.[1] as { + summonFloaty(cap: number): void; + }; + summonMinionViaCap(summonHooks, 1); + expect(enemies.spawned).toEqual([{ kind: 'jelly', x: 48, y: 300 }]); + }); +}); + +function summonMinionViaCap(hooks: { summonFloaty(cap: number): void }, cap: number): void { + hooks.summonFloaty(cap); +} + +describe('品種回呼接線(彩蛋餵送與環境管線委派)', () => { + it('prismix onTwinFinish:餵 twin-finish 彩蛋+全屏稜光演出', () => { + const enemies = makeEnemies(); + const { hooks, feedEggs, fxStarBurst } = makeHooks(enemies); + const { scene, flash } = makeScene(); + createBossKit(scene, makeLevel({ boss: 'prismix' }), GROUND_TOP, hooks); + const prismixHooks = vi.mocked(createPrismix).mock.calls[0]?.[1] as { + onTwinFinish(): void; + }; + prismixHooks.onTwinFinish(); + expect(feedEggs).toHaveBeenCalledWith({ kind: 'twin-finish' }); + expect(flash).toHaveBeenCalled(); + expect(fxStarBurst).toHaveBeenCalledWith(854 / 2, expect.any(Number)); + }); + + it('syrona startTide/boilTide:換裝走 replaceTide、沸騰走現役 setSpec', () => { + const enemies = makeEnemies(); + const setSpec = vi.fn(); + const tide = { setSpec } as unknown as TideHandle; + const { hooks, replaceTide } = makeHooks(enemies, { tide: () => tide }); + createBossKit(makeScene().scene, makeLevel({ boss: 'syrona' }), GROUND_TOP, hooks); + const syronaHooks = vi.mocked(createSyrona).mock.calls[0]?.[1] as { + startTide(spec: unknown): void; + boilTide(spec: unknown): void; + }; + const spec = { periodMs: 1000 }; + syronaHooks.startTide(spec); + expect(replaceTide).toHaveBeenCalledWith(spec); + syronaHooks.boilTide(spec); + expect(setSpec).toHaveBeenCalledWith(spec); + }); + + it('voidra setBombardment:開=setSpec+setActive(true)、關=setActive(false)+清空中隕星', () => { + const enemies = makeEnemies(); + const setSpec = vi.fn(); + const setActive = vi.fn(); + const clearAirborne = vi.fn(); + const meteor = { setSpec, setActive, clearAirborne } as unknown as MeteorSystem; + const { hooks } = makeHooks(enemies, { meteor: () => meteor }); + createBossKit(makeScene().scene, makeLevel({ boss: 'voidra' }), GROUND_TOP, hooks); + const voidraHooks = vi.mocked(createVoidra).mock.calls[0]?.[1] as { + setBombardment(spec: unknown): void; + }; + voidraHooks.setBombardment({ intervalMs: 3400, waveSize: 2 }); + expect(setSpec).toHaveBeenCalledWith({ intervalMs: 3400, waveSize: 2 }); + expect(setActive).toHaveBeenCalledWith(true); + voidraHooks.setBombardment(null); + expect(setActive).toHaveBeenCalledWith(false); + expect(clearAirborne).toHaveBeenCalledTimes(1); + }); + + it('voidra setGravityScale:直寫全域重力、null 回關卡預設', () => { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies); + const { scene } = makeScene(); + createBossKit(scene, makeLevel({ boss: 'voidra', gravityScale: 0.8 }), GROUND_TOP, hooks); + const voidraHooks = vi.mocked(createVoidra).mock.calls[0]?.[1] as { + setGravityScale(scale: number | null): void; + }; + voidraHooks.setGravityScale(0.5); + expect(scene.physics.world.gravity.y).toBe(GRAVITY_Y * 0.5); + voidraHooks.setGravityScale(null); + expect(scene.physics.world.gravity.y).toBe(GRAVITY_Y * 0.8); + }); + + it('voidra dropSurvivalHeart:走 heal pickup 管線、緩降至地面帶', () => { + const enemies = makeEnemies(); + const { hooks } = makeHooks(enemies); + createBossKit(makeScene().scene, makeLevel({ boss: 'voidra' }), GROUND_TOP, hooks); + const voidraHooks = vi.mocked(createVoidra).mock.calls[0]?.[1] as { + dropSurvivalHeart(): void; + }; + voidraHooks.dropSurvivalHeart(); + expect(vi.mocked(spawnHealPickup)).toHaveBeenCalledTimes(1); + const args = vi.mocked(spawnHealPickup).mock.calls[0]; + expect(args?.[1]).toBe(854 * 0.3); + expect(args?.[4]).toMatchObject({ driftToY: GROUND_TOP - 22 }); + }); +}); diff --git a/apps/starpuff/src/game/systems/bossFactory.ts b/apps/starpuff/src/game/systems/bossFactory.ts new file mode 100644 index 000000000..a98bc0124 --- /dev/null +++ b/apps/starpuff/src/game/systems/bossFactory.ts @@ -0,0 +1,223 @@ +import type Phaser from 'phaser'; +import { GRAVITY_Y, VIEW } from '../core/config'; +import type { BossKind, EnemyKind } from '../core/types'; +import { BOSS } from '../logic/bossFsm'; +import { NOCTRA } from '../logic/noctraFsm'; +import { PRISMIX } from '../logic/prismixFsm'; +import { SYRONA } from '../logic/syronaFsm'; +import { VOIDRA } from '../logic/voidraFsm'; +import type { EggEvent } from '../logic/eggs'; +import type { LevelSpec } from '../logic/levels'; +import { MERCY_HEAL } from '../logic/mercyHeal'; +import type { TideSpec } from '../logic/tide'; +import { createBoss, type BossHandle } from './boss'; +import { createNoctra } from './noctra'; +import { createPrismix } from './prismix'; +import { createSyrona } from './syrona'; +import { createVoidra } from './voidra'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { MeteorSystem } from './meteor'; +import { spawnHealPickup } from './pickups'; +import type { PlayerHandle } from './player'; +import type { TideHandle } from './tide'; +import { playSfx } from '../audio/sfx'; + +// 魔王品種工廠與補給小怪(GAME_DESIGN §54):BossKind 唯一分派點(呈現層 handle+ +// 體傷常數一次取齊);GameScene 只留建構委派與 onMinionDrop 接線。 + +const SPAWN_EDGE_X = 48; +// 與 waves.ts 生成高度一致:飄飄鳥須在跳躍+拍翅可達高度(橫式地面頂 y=400)。 +const SPAWN_AIR_Y = 240; +const SPAWN_DROP_Y = 330; + +// hooks 以閉包延遲解析:player/enemies/fx/tide/meteor 於 GameScene create 後續建立。 +export interface BossFactoryHooks { + exMode: boolean; + arenaLeft(): number; + worldWidth(): number; + enemies(): EnemySystem; + fx(): FxSystem; + player(): PlayerHandle; + playerHp(): number; + tide(): TideHandle | null; + // 場控潮汐(§74 Syrona):換裝與沸騰沿 GameScene 單一潮汐管線。 + replaceTide(spec: TideSpec): void; + meteor(): MeteorSystem | null; + feedEggs(event: EggEvent): void; +} + +export interface BossKit { + handle: BossHandle; + bodyDamage: number; + // 魔王每損 10 HP 補生可吸小怪(onMinionDrop 接線)。 + spawnBossMinion(): void; +} + +export function createBossKit( + scene: Phaser.Scene, + level: LevelSpec, + groundTop: number, + hooks: BossFactoryHooks, +): BossKit { + let minionDropCount = 0; + + // 魔王每損 10 HP 補生可吸小怪(供彈藥),arena 左右邊緣交替入場;品種輪替讀關卡 enemyMix。 + // 場上上限(§82 審查根修):爆發傷害連觸多次掉落時夾限累積(waves 上限 +2 供給裕度), + // 防補給怪堆積形成接觸傷害牆;彈藥保證由飢荒立即補生承擔(§26)。 + function spawnBossMinion(): void { + if (hooks.enemies().aliveCount() >= level.maxOnScreen + 2) return; + const kinds = level.enemyMix.map((entry) => entry.kind); + const kind = kinds[minionDropCount % kinds.length] ?? 'jelly'; + const x = + minionDropCount % 2 === 0 + ? hooks.arenaLeft() + SPAWN_EDGE_X + : hooks.worldWidth() - SPAWN_EDGE_X; + minionDropCount += 1; + hooks + .enemies() + .spawn(kind, x, kind === 'floaty' || kind === 'gusty' ? SPAWN_AIR_Y : SPAWN_DROP_Y); + } + + // 魔王召喚小怪(§54 P2 floaty/§68 P2 mirri/§74 P2 bubbla):依場上現量夾限至 cap, + // 走正式 spawn 管線;召喚路徑同套潮汐生成調整(交叉不變式 13/17,審查修復)。 + function summonMinion(kind: EnemyKind, cap: number): void { + let alive = 0; + for (const child of hooks.enemies().getGroup().getChildren()) { + if (child.active && hooks.enemies().kindOf(child) === kind) alive += 1; + } + for (let i = 0; i < cap - alive; i += 1) { + const x = i % 2 === 0 ? hooks.arenaLeft() + SPAWN_EDGE_X : hooks.worldWidth() - SPAWN_EDGE_X; + const defaultY = kind === 'floaty' ? SPAWN_AIR_Y : SPAWN_DROP_Y; + const tide = hooks.tide(); + const adjusted = tide + ? { kind: tide.filterSpawnKind(kind), y: tide.adjustSpawnY(defaultY) } + : { kind, y: defaultY }; + hooks.enemies().spawn(adjusted.kind, x, adjusted.y); + } + } + + // 魔王品種工廠(§54):新增品種未接線將於編譯期 never 守衛失敗。 + function buildBoss(kind: BossKind): { handle: BossHandle; bodyDamage: number } { + switch (kind) { + case 'jellord': + return { + handle: createBoss(scene, { + ex: hooks.exMode, + arenaLeft: () => hooks.arenaLeft(), + // EX 擊破分裂(§58):於魔王位置生成小果凍,走正式 spawn 管線。 + onSplit: (x, y, count) => { + for (let i = 0; i < count; i += 1) { + hooks.enemies().spawn('jelly', x + (i - (count - 1) / 2) * 46, y - 20); + } + }, + }), + bodyDamage: BOSS.bodyDamage, + }; + case 'noctra': + return { + handle: createNoctra( + scene, + { summonFloaty: (cap) => summonMinion('floaty', cap) }, + { ex: hooks.exMode, arenaLeft: () => hooks.arenaLeft() }, + ), + bodyDamage: NOCTRA.bodyDamage, + }; + case 'prismix': + return { + handle: createPrismix( + scene, + { + summonMirri: (cap) => summonMinion('mirri', cap), + // 雙子連破(§68/§70):FSM 單一真值,此處餵彩蛋+全屏稜光演出。 + onTwinFinish: () => { + hooks.feedEggs({ kind: 'twin-finish' }); + scene.cameras.main.flash(360, 220, 200, 255); + hooks + .fx() + .starBurst(hooks.arenaLeft() + scene.scale.width / 2, VIEW.height / 2 - 40); + }, + }, + { ex: hooks.exMode, arenaLeft: () => hooks.arenaLeft() }, + ), + bodyDamage: PRISMIX.bodyDamage, + }; + case 'syrona': + return { + handle: createSyrona( + scene, + { + summonBubbla: (cap) => summonMinion('bubbla', cap), + // 窯風三連(§75):呈現層單一真值,此處餵彩蛋+窯火謝幕演出。 + onVentEgg: () => { + hooks.feedEggs({ kind: 'vent-hit-count' }); + scene.cameras.main.flash(320, 255, 214, 140); + hooks + .fx() + .starBurst(hooks.arenaLeft() + scene.scale.width / 2, VIEW.height / 2 - 40); + }, + // 場控潮汐(§74):沿 GameScene 單一潮汐管線(浸水/生成過濾自然生效)。 + startTide: (spec) => hooks.replaceTide(spec), + boilTide: (spec) => hooks.tide()?.setSpec(spec), + }, + { ex: hooks.exMode, arenaLeft: () => hooks.arenaLeft() }, + ), + bodyDamage: SYRONA.bodyDamage, + }; + case 'voidra': + return { + handle: createVoidra( + scene, + { + // 星核共鳴(§83):FSM 單一真值,此處餵彩蛋+星雨謝幕演出。 + onShardEgg: () => { + hooks.feedEggs({ kind: 'survive-collect' }); + scene.cameras.main.flash(360, 255, 230, 160); + hooks + .fx() + .starBurst(hooks.arenaLeft() + scene.scale.width / 2, VIEW.height / 2 - 40); + }, + // P2 定點轟炸(§82):沿 GameScene 單一 meteor 管線開關與調參; + // 收轟炸(轉段/擊破/段重試)連飛行中隕星一併回收(審查收斂)。 + setBombardment: (spec) => { + const meteor = hooks.meteor(); + if (spec) { + meteor?.setSpec(spec); + meteor?.setActive(true); + } else { + meteor?.setActive(false); + meteor?.clearAirborne(); + } + }, + // P3 低重力(§81):全域重力直寫;null 回關卡預設(create 亦會重設)。 + setGravityScale: (scale) => { + scene.physics.world.gravity.y = GRAVITY_Y * (scale ?? level.gravityScale ?? 1); + }, + // P2 段內固定愛心(§6.3 保底):走既有 heal pickup 管線、緩降至地面帶。 + dropSurvivalHeart: () => { + const x = hooks.arenaLeft() + scene.scale.width * 0.3; + playSfx('reveal'); + hooks.fx().burstSmall(x, 150, 0xff9ec4); + spawnHealPickup( + scene, + x, + 150, + { player: () => hooks.player(), playerHp: () => hooks.playerHp() }, + { healHp: MERCY_HEAL.healHp, driftToY: groundTop - 22 }, + ); + }, + }, + { ex: hooks.exMode, arenaLeft: () => hooks.arenaLeft() }, + ), + bodyDamage: VOIDRA.bodyDamage, + }; + default: { + const unhandled: never = kind; + throw new Error(`未知魔王品種:${String(unhandled)}`); + } + } + } + + const built = buildBoss(level.boss ?? 'jellord'); + return { handle: built.handle, bodyDamage: built.bodyDamage, spawnBossMinion }; +} diff --git a/apps/starpuff/src/game/systems/eggTracker.test.ts b/apps/starpuff/src/game/systems/eggTracker.test.ts new file mode 100644 index 000000000..ce46100b8 --- /dev/null +++ b/apps/starpuff/src/game/systems/eggTracker.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EGG_HP_CAP, PLAYER } from '../core/config'; +import type { LevelSpec } from '../logic/levels'; +import { createEggTracker, type EggTrackerHooks } from './eggTracker'; +import type { PlayerHandle } from './player'; + +// characterization(v17 債務列車):鎖住自 GameScene 抽出的彩蛋追蹤與獎勵 +// 落地現行為;advanceEgg 推進數學由 logic/eggs 單測覆蓋。 + +vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); + +function makeLevel(overrides: Partial): LevelSpec { + return { + boss: null, + platforms: [], + easterEggs: [], + ...overrides, + } as unknown as LevelSpec; +} + +function makeHarness( + level: LevelSpec, + config: { hp?: number; bossActive?: boolean } = {}, +): { + tracker: ReturnType; + heal: ReturnType; + grantFullMagazine: ReturnType; + grantGoldStar: ReturnType; + recordEggAndAward: ReturnType; + celebrate: ReturnType; + playerBody: { blocked: { down: boolean }; touching: { down: boolean }; bottom: number }; + playerSprite: { x: number; y: number }; + setNow: (value: number) => void; +} { + const heal = vi.fn(); + const grantFullMagazine = vi.fn(); + const grantGoldStar = vi.fn(); + const recordEggAndAward = vi.fn(); + const celebrate = vi.fn(); + const playerBody = { blocked: { down: true }, touching: { down: false }, bottom: 292 }; + const playerSprite = { x: 100, y: 280, body: playerBody }; + let now = 1000; + const hooks: EggTrackerHooks = { + player: () => + ({ + sprite: playerSprite, + heal, + grantFullMagazine, + grantGoldStar, + }) as unknown as PlayerHandle, + playerHp: () => config.hp ?? 3, + bossActive: () => config.bossActive ?? false, + now: () => now, + recordEggAndAward, + celebrate, + }; + return { + tracker: createEggTracker(level, hooks), + heal, + grantFullMagazine, + grantGoldStar, + recordEggAndAward, + celebrate, + playerBody, + playerSprite, + setNow: (value) => { + now = value; + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('reach-x 彩蛋(§24 回頭走)', () => { + const level = makeLevel({ + easterEggs: [{ trigger: 'reach-x', reward: 'hp-up', maxX: 150 }] as LevelSpec['easterEggs'], + }); + + it('位置事件退至 x ≤ maxX 觸發一次:記錄存檔+回血+慶祝,之後不重複', () => { + const harness = makeHarness(level); + harness.playerSprite.x = 400; + harness.tracker.sync(); + expect(harness.recordEggAndAward).not.toHaveBeenCalled(); + harness.playerSprite.x = 120; + harness.tracker.sync(); + expect(harness.recordEggAndAward).toHaveBeenCalledWith('reach-x'); + expect(harness.heal).toHaveBeenCalledWith(1, EGG_HP_CAP); + expect(harness.celebrate).toHaveBeenCalledWith('彩虹果凍 +1 HP'); + harness.tracker.sync(); + expect(harness.recordEggAndAward).toHaveBeenCalledTimes(1); + }); +}); + +describe('stand-count 彩蛋與站立平台判定', () => { + const level = makeLevel({ + platforms: [{ x: 100, y: 300, w: 120 }] as LevelSpec['platforms'], + easterEggs: [ + { trigger: 'stand-count', reward: 'full-magazine', platformY: 300, count: 2 }, + ] as LevelSpec['easterEggs'], + }); + + it('上升緣計數:站上→離開→再站上達次數觸發滿彈匣(連續站立不重計)', () => { + const harness = makeHarness(level); + harness.playerBody.bottom = 292; + harness.playerSprite.x = 100; + harness.tracker.sync(); + // 連續站立同平台不重計。 + harness.tracker.sync(); + expect(harness.grantFullMagazine).not.toHaveBeenCalled(); + // 離開平台(離地)再站回:第二次上升緣觸發。 + harness.playerBody.blocked.down = false; + harness.tracker.sync(); + harness.playerBody.blocked.down = true; + harness.tracker.sync(); + expect(harness.grantFullMagazine).toHaveBeenCalledTimes(1); + expect(harness.celebrate).toHaveBeenCalledWith('星星雨!彈匣全滿'); + }); + + it('離地(非 blocked/touching down)回報 null 不推進', () => { + const harness = makeHarness(level); + harness.playerBody.blocked.down = false; + harness.playerBody.touching.down = false; + harness.tracker.sync(); + harness.tracker.sync(); + expect(harness.grantFullMagazine).not.toHaveBeenCalled(); + }); +}); + +describe('crown-early-hit 魔王受擊時間窗', () => { + const level = makeLevel({ + boss: 'jellord', + easterEggs: [ + { trigger: 'crown-early-hit', reward: 'heal', windowMs: 5000 }, + ] as LevelSpec['easterEggs'], + }); + + it('魔王 active 起算;窗內受擊觸發、記錄起點前受擊不觸發', () => { + const harness = makeHarness(level, { bossActive: true, hp: 3 }); + // active 前 noteBossHit 零效果(bossActiveAt 未鎖存)。 + harness.tracker.noteBossHit(); + expect(harness.recordEggAndAward).not.toHaveBeenCalled(); + harness.setNow(2000); + harness.tracker.sync(); + harness.setNow(4000); + harness.tracker.noteBossHit(); + expect(harness.recordEggAndAward).toHaveBeenCalledWith('crown-early-hit'); + // 非滿血走 +1 HP 分支(PLAYER.maxHp 上限)。 + expect(harness.heal).toHaveBeenCalledWith(1, PLAYER.maxHp); + expect(harness.celebrate).toHaveBeenCalledWith('皇冠火花 +1 HP'); + }); + + it('滿血時 heal fallback 改給滿彈匣(獎勵必有回饋)', () => { + const harness = makeHarness(level, { bossActive: true, hp: PLAYER.maxHp }); + harness.setNow(2000); + harness.tracker.sync(); + harness.setNow(3000); + harness.tracker.noteBossHit(); + expect(harness.grantFullMagazine).toHaveBeenCalledTimes(1); + expect(harness.celebrate).toHaveBeenCalledWith('皇冠火花!彈匣全滿'); + }); +}); + +describe('gold-star 與外部事件餵送', () => { + it('twin-finish 事件經 feed 觸發金星彈獎勵', () => { + const level = makeLevel({ + easterEggs: [{ trigger: 'twin-finish', reward: 'gold-star' }] as LevelSpec['easterEggs'], + }); + const harness = makeHarness(level); + harness.tracker.feed({ kind: 'twin-finish' }); + expect(harness.grantGoldStar).toHaveBeenCalledTimes(1); + expect(harness.celebrate).toHaveBeenCalledWith('金星彈入匣!'); + }); +}); diff --git a/apps/starpuff/src/game/systems/eggTracker.ts b/apps/starpuff/src/game/systems/eggTracker.ts new file mode 100644 index 000000000..d134cefed --- /dev/null +++ b/apps/starpuff/src/game/systems/eggTracker.ts @@ -0,0 +1,113 @@ +import type Phaser from 'phaser'; +import { EGG_HP_CAP, PLAYER } from '../core/config'; +import { + advanceEgg, + createEggProgress, + type EasterEggSpec, + type EggEvent, + type EggProgress, +} from '../logic/eggs'; +import type { LevelSpec } from '../logic/levels'; +import type { PlayerHandle } from './player'; + +// 彩蛋進度追蹤與獎勵落地(GAME_DESIGN §24):每關進度鎖存、逐幀事件餵送與 +// 觸發獎勵集中於此;存檔寫入與成就佇列留 GameScene(recordEggAndAward 委派)。 + +export interface EggTrackerHooks { + player(): PlayerHandle; + playerHp(): number; + bossActive(): boolean; + now(): number; + // 存檔寫入時機(§38):彩蛋觸發即記錄(trigger 型別為關內唯一 id)。 + recordEggAndAward(trigger: EasterEggSpec['trigger']): void; + celebrate(message: string): void; +} + +export interface EggTracker { + // 逐幀事件(§24):世界座標與平台站立;魔王可擊打起點供時間窗計算。 + sync(): void; + feed(event: EggEvent): void; + // 魔王受擊時間窗(crown-early-hit):入場運鏡完成後起算。 + noteBossHit(): void; +} + +export function createEggTracker(level: LevelSpec, hooks: EggTrackerHooks): EggTracker { + const progress: EggProgress[] = level.easterEggs.map(() => createEggProgress()); + let bossActiveAt = -1; + + function feed(event: EggEvent): void { + level.easterEggs.forEach((spec, i) => { + const entry = progress[i]; + if (!entry || entry.done) return; + const result = advanceEgg(spec, entry, event); + progress[i] = result.progress; + if (result.triggered) grantReward(spec); + }); + } + + // 站立平台判定:腳底貼齊平台頂(rect 高 16 → 頂 = y - 8)且 x 落於平台範圍。 + function standingPlatformY(): number | null { + const sprite = hooks.player().sprite; + const body = sprite.body as Phaser.Physics.Arcade.Body; + if (!body.blocked.down && !body.touching.down) return null; + for (const spec of level.platforms) { + if ( + Math.abs(body.bottom - (spec.y - 8)) <= 4 && + Math.abs(sprite.x - spec.x) <= spec.w / 2 + 12 + ) { + return spec.y; + } + } + return null; + } + + // 獎勵落地(§24):+1 HP(上限 6)/滿彈匣/金星彈/+1 HP。 + function grantReward(spec: EasterEggSpec): void { + hooks.recordEggAndAward(spec.trigger); + switch (spec.reward) { + case 'hp-up': + hooks.player().heal(1, EGG_HP_CAP); + hooks.celebrate('彩虹果凍 +1 HP'); + break; + case 'full-magazine': + hooks.player().grantFullMagazine(); + hooks.celebrate('星星雨!彈匣全滿'); + break; + case 'gold-star': + hooks.player().grantGoldStar(); + hooks.celebrate('金星彈入匣!'); + break; + case 'heal': + // 滿血時 heal 無感(player.heal 靜默略過):fallback 改給滿彈匣,獎勵必有回饋。 + if (hooks.playerHp() >= PLAYER.maxHp) { + hooks.player().grantFullMagazine(); + hooks.celebrate('皇冠火花!彈匣全滿'); + } else { + hooks.player().heal(1, PLAYER.maxHp); + hooks.celebrate('皇冠火花 +1 HP'); + } + break; + default: { + const exhaustive: never = spec.reward; + void exhaustive; + } + } + } + + return { + sync() { + if (level.boss && bossActiveAt < 0 && hooks.bossActive()) { + bossActiveAt = hooks.now(); + } + if (progress.every((entry) => entry.done)) return; + feed({ kind: 'position', x: hooks.player().sprite.x }); + feed({ kind: 'stand', platformY: standingPlatformY() }); + }, + feed, + noteBossHit() { + if (bossActiveAt >= 0) { + feed({ kind: 'boss-hit', sinceActiveMs: hooks.now() - bossActiveAt }); + } + }, + }; +} diff --git a/apps/starpuff/src/game/systems/overlaps.test.ts b/apps/starpuff/src/game/systems/overlaps.test.ts new file mode 100644 index 000000000..dc8679f50 --- /dev/null +++ b/apps/starpuff/src/game/systems/overlaps.test.ts @@ -0,0 +1,349 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { ENEMY, STAR_FLAVORS } from '../core/config'; +import { SHELL_REFLECT } from '../logic/transform'; +import { wireCombatOverlaps, type CombatOverlapHooks } from './overlaps'; +import type { BossHandle } from './boss'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { MeteorSystem } from './meteor'; +import type { PlayerHandle } from './player'; +import type { StageHandle } from './stage'; +import type { StarCombat } from './starCombat'; + +// characterization(v17 債務列車):鎖住自 GameScene 抽出的 overlap 接線現行為—— +// 接線數量/順序與關鍵回調結算路徑;真實碰撞偵測由 Phaser 引擎與 e2e 覆蓋。 + +vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); + +type OverlapCallback = (a: unknown, b: unknown) => void; + +interface Wiring { + kind: 'overlap' | 'collider'; + a: unknown; + b: unknown; + callback: OverlapCallback | undefined; +} + +function makeScene(): { scene: Phaser.Scene; wirings: Wiring[]; moveTo: ReturnType } { + const wirings: Wiring[] = []; + const moveTo = vi.fn(); + const scene = { + physics: { + add: { + overlap: (a: unknown, b: unknown, callback?: OverlapCallback) => { + wirings.push({ kind: 'overlap', a, b, callback }); + }, + collider: ( + a: unknown, + b: unknown, + callback?: OverlapCallback, + process?: OverlapCallback, + ) => { + wirings.push({ kind: 'collider', a, b, callback: callback ?? process }); + }, + }, + moveTo, + }, + } as unknown as Phaser.Scene; + return { scene, wirings, moveTo }; +} + +interface HarnessConfig { + bossBodies?: unknown[]; + shields?: unknown[] | null; + meteor?: boolean; + bossActive?: boolean; + settled?: boolean; + reflectForm?: boolean; +} + +function makeHarness(config: HarnessConfig = {}): { + scene: Phaser.Scene; + wirings: Wiring[]; + hooks: CombatOverlapHooks; + spies: { + damagePlayer: ReturnType; + damageBossAt: ReturnType; + onStarHit: ReturnType; + enemyDamage: ReturnType; + shatter: ReturnType; + moveTo: ReturnType; + trySlamStun: ReturnType; + onSlamBounce: ReturnType; + }; + groups: { + stars: object; + enemyGroup: object; + hazards: object; + inhaleZone: object; + playerSprite: { x: number; y: number; body: object }; + projectiles: object; + shockwaves: object; + }; +} { + const { scene, wirings, moveTo } = makeScene(); + const stars = { name: 'stars' }; + const enemyGroup = { name: 'enemies', getChildren: () => [] as unknown[] }; + const hazards = { name: 'hazards' }; + const inhaleZone = { name: 'inhale-zone' }; + const playerSprite = { x: 100, y: 300, body: { bottom: 300 } }; + const projectiles = { name: 'projectiles' }; + const shockwaves = { name: 'shockwaves' }; + const shields = config.shields ? { name: 'shields', getChildren: () => config.shields! } : null; + const damagePlayer = vi.fn(); + const damageBossAt = vi.fn(); + const onStarHit = vi.fn(); + const enemyDamage = vi.fn(() => 'hurt'); + const shatter = vi.fn(); + const trySlamStun = vi.fn(() => false); + const onSlamBounce = vi.fn(); + const player = { + sprite: playerSprite, + getStars: () => stars, + getInhaleZone: () => inhaleZone, + onStarHit, + isInhaling: () => false, + getFacing: () => 1, + isSlamming: () => false, + onSlamBounce, + getTransformState: () => ({ form: config.reflectForm ? 'shell' : null, remainingMs: 0 }), + } as unknown as PlayerHandle; + const enemies = { + getGroup: () => enemyGroup, + getHazards: () => hazards, + kindOf: (child: unknown) => (child as { kind?: string }).kind ?? null, + isStarImmune: () => false, + isReflective: () => false, + isPhasedOut: () => false, + isInhalable: () => false, + damage: enemyDamage, + applySlow: vi.fn(), + reflectStar: vi.fn(), + } as unknown as EnemySystem; + const boss = { + isActive: () => config.bossActive ?? true, + getShields: shields ? () => shields : undefined, + getProjectiles: () => projectiles, + getShockwaves: () => shockwaves, + trySlamStun, + } as unknown as BossHandle; + const meteor = config.meteor + ? ({ + getMeteors: () => ({ name: 'meteors', getChildren: () => [] as unknown[] }), + getEmbers: () => ({ name: 'embers' }), + shatter, + } as unknown as MeteorSystem) + : null; + const stage = { + getOneWay: () => ({ name: 'oneway' }), + getMoving: () => ({ name: 'moving' }), + getBreakables: () => ({ name: 'breakables' }), + getSprings: () => ({ name: 'springs' }), + canLandOneWay: vi.fn(), + onSpringOverlap: vi.fn(), + breakBrick: vi.fn(() => true), + } as unknown as StageHandle; + const combat = { + specOf: (star: unknown) => + (star as { spec?: typeof STAR_FLAVORS.jelly }).spec ?? STAR_FLAVORS.jelly, + mixOf: () => null, + damageOf: () => 5, + playerFormSpec: () => + config.reflectForm ? { reflectProjectiles: true, contactDamage: 0 } : null, + explodeStar: vi.fn(), + chainLightning: vi.fn(), + freezeField: vi.fn(), + } as unknown as StarCombat; + const hooks: CombatOverlapHooks = { + player: () => player, + enemies: () => enemies, + boss: () => boss, + fx: () => ({ burstSmall: vi.fn(), shake: vi.fn() }) as unknown as FxSystem, + meteor: () => meteor, + stage: () => stage, + combat: () => combat, + bossBodies: () => (config.bossBodies ?? []) as Phaser.Physics.Arcade.Sprite[], + nearestBossBody: () => ({ x: 400, y: 200 }) as unknown as Phaser.Physics.Arcade.Sprite, + bossTouchDamage: () => 2, + damagePlayer, + damageBossAt, + isSettled: () => config.settled ?? false, + isBossDown: () => false, + now: () => 10_000, + }; + return { + scene, + wirings, + hooks, + spies: { + damagePlayer, + damageBossAt, + onStarHit, + enemyDamage, + shatter, + moveTo, + trySlamStun, + onSlamBounce, + }, + groups: { stars, enemyGroup, hazards, inhaleZone, playerSprite, projectiles, shockwaves }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('接線數量與順序(凍結契約)', () => { + it('單本體魔王+無盾+無流星雨:13 條接線且順序固定', () => { + const body = { name: 'boss-body', body: { enable: true } }; + const { scene, wirings, hooks, groups } = makeHarness({ bossBodies: [body] }); + wireCombatOverlaps(scene, hooks); + expect(wirings.map((w) => w.kind)).toEqual([ + 'overlap', // 星彈 vs 小怪 + 'overlap', // 星彈 vs 魔王本體 + 'overlap', // 玩家 vs 危險物 + 'overlap', // 玩家 vs 小怪 + 'overlap', // 吸入區 vs 小怪 + 'overlap', // 玩家 vs 魔王本體 + 'overlap', // 玩家 vs 彈幕 + 'overlap', // 反彈彈幕 vs 魔王本體 + 'overlap', // 玩家 vs 衝擊波 + 'collider', // 玩家 vs 單向平台 + 'collider', // 玩家 vs 移動平台 + 'collider', // 玩家 vs 磚 + 'overlap', // 玩家 vs 彈簧 + 'overlap', // 星彈 vs 磚 + ]); + expect(wirings[0]?.a).toBe(groups.stars); + expect(wirings[0]?.b).toBe(groups.enemyGroup); + expect(wirings[1]?.b).toBe(body); + }); + + it('雙本體魔王:星彈/觸碰/反彈各逐本體接線(+3);流星雨關再 +3', () => { + const bodies = [ + { name: 'a', body: { enable: true } }, + { name: 'b', body: { enable: true } }, + ]; + const twin = makeHarness({ bossBodies: bodies }); + wireCombatOverlaps(twin.scene, twin.hooks); + const twinCount = twin.wirings.length; + const single = makeHarness({ bossBodies: [bodies[0]] }); + wireCombatOverlaps(single.scene, single.hooks); + expect(twinCount).toBe(single.wirings.length + 3); + + const meteor = makeHarness({ bossBodies: [bodies[0]], meteor: true }); + wireCombatOverlaps(meteor.scene, meteor.hooks); + expect(meteor.wirings.length).toBe(single.wirings.length + 3); + }); +}); + +describe('關鍵回調結算路徑', () => { + it('危險物命中:hazard 失效+damagePlayer 單一入口;轉場窗靜默', () => { + const { scene, wirings, hooks, spies, groups } = makeHarness({}); + wireCombatOverlaps(scene, hooks); + const hazardWiring = wirings.find((w) => w.b === groups.hazards); + const hazard = { active: true, x: 240, disableBody: vi.fn() }; + hazardWiring?.callback?.({}, hazard); + expect(hazard.disableBody).toHaveBeenCalledWith(true, true); + expect(spies.damagePlayer).toHaveBeenCalledWith(ENEMY.touchDamage, 240); + + const settled = makeHarness({ settled: true }); + wireCombatOverlaps(settled.scene, settled.hooks); + const settledWiring = settled.wirings.find((w) => w.b === settled.groups.hazards); + const hazard2 = { active: true, x: 240, disableBody: vi.fn() }; + settledWiring?.callback?.({}, hazard2); + expect(settled.spies.damagePlayer).not.toHaveBeenCalled(); + }); + + it('星彈命中魔王本體:吸收+damageBossAt 帶命中座標;非 active 穿過', () => { + const body = { name: 'boss-body', body: { enable: true } }; + const { scene, wirings, hooks, spies } = makeHarness({ bossBodies: [body] }); + wireCombatOverlaps(scene, hooks); + const wiring = wirings[1]; + const star = { active: true, x: 300, y: 200, getData: () => undefined }; + wiring?.callback?.(star, body); + expect(spies.onStarHit).toHaveBeenCalledWith(star, 'absorb'); + expect(spies.damageBossAt).toHaveBeenCalledWith(5, 300, 200); + + spies.damageBossAt.mockClear(); + const spent = { active: false, x: 300, y: 200, getData: () => undefined }; + wiring?.callback?.(spent, body); + expect(spies.damageBossAt).not.toHaveBeenCalled(); + }); + + it('殼化反彈:彈幕標記 reflected+朝最近本體 moveTo,不傷玩家', () => { + const body = { name: 'boss-body', body: { enable: true } }; + const { scene, wirings, hooks, spies, groups } = makeHarness({ + bossBodies: [body], + reflectForm: true, + }); + wireCombatOverlaps(scene, hooks); + const wiring = wirings.find((w) => w.a === groups.playerSprite && w.b === groups.projectiles); + const data: Record = {}; + const projectile = { + active: true, + x: 150, + y: 250, + getData: (key: string) => data[key], + setData: (key: string, value: unknown) => { + data[key] = value; + }, + setTint: vi.fn(), + disableBody: vi.fn(), + body: { setAllowGravity: vi.fn() }, + }; + wiring?.callback?.({}, projectile); + expect(data['reflected']).toBe(true); + expect(spies.moveTo).toHaveBeenCalledWith(projectile, 400, 200, SHELL_REFLECT.speed); + expect(spies.damagePlayer).not.toHaveBeenCalled(); + expect(projectile.disableBody).not.toHaveBeenCalled(); + }); + + it('反彈彈幕回傷:僅帶標記彈體對魔王結算 reflect 歸因', () => { + const body = { name: 'boss-body', body: { enable: true } }; + const { scene, wirings, hooks, spies } = makeHarness({ bossBodies: [body] }); + wireCombatOverlaps(scene, hooks); + const wiring = wirings.find((w) => w.a === body); + const reflected = { + active: true, + x: 350, + y: 180, + getData: (key: string) => (key === 'reflected' ? true : undefined), + disableBody: vi.fn(), + }; + wiring?.callback?.(body, reflected); + expect(spies.damageBossAt).toHaveBeenCalledWith(SHELL_REFLECT.damage, 350, 180, 'reflect'); + + spies.damageBossAt.mockClear(); + const plain = { active: true, x: 350, y: 180, getData: () => undefined, disableBody: vi.fn() }; + wiring?.callback?.(body, plain); + expect(spies.damageBossAt).not.toHaveBeenCalled(); + }); + + it('魔王頭頂 hit window:下砸命中上半身走暈眩回彈免體傷', () => { + const body = { name: 'boss-body', y: 320, x: 400, body: { enable: true } }; + const harness = makeHarness({ bossBodies: [body] }); + const player = harness.hooks.player(); + (player as { isSlamming(): boolean }).isSlamming = () => true; + (player.sprite.body as { bottom: number }).bottom = 300; + wireCombatOverlaps(harness.scene, harness.hooks); + const wiring = harness.wirings[5]; + wiring?.callback?.(harness.groups.playerSprite, body); + expect(harness.spies.trySlamStun).toHaveBeenCalledTimes(1); + expect(harness.spies.onSlamBounce).toHaveBeenCalledTimes(1); + expect(harness.spies.damagePlayer).not.toHaveBeenCalled(); + }); + + it('隕星命中玩家:先碎裂再走 damagePlayer 單一入口', () => { + const harness = makeHarness({ meteor: true }); + wireCombatOverlaps(harness.scene, harness.hooks); + const meteorWiring = harness.wirings.find( + (w) => w.a === harness.groups.playerSprite && (w.b as { name?: string }).name === 'meteors', + ); + const rock = { active: true, x: 500 }; + meteorWiring?.callback?.({}, rock); + expect(harness.spies.shatter).toHaveBeenCalledWith(rock); + expect(harness.spies.damagePlayer).toHaveBeenCalledWith(ENEMY.touchDamage, 500); + }); +}); diff --git a/apps/starpuff/src/game/systems/overlaps.ts b/apps/starpuff/src/game/systems/overlaps.ts new file mode 100644 index 000000000..8810f5911 --- /dev/null +++ b/apps/starpuff/src/game/systems/overlaps.ts @@ -0,0 +1,272 @@ +import type Phaser from 'phaser'; +import { ENEMY, INHALE } from '../core/config'; +import { isContactHarmless, isInInhaleRange } from '../logic/combat'; +import { SHELL_REFLECT, TRANSFORM_FORMS } from '../logic/transform'; +import type { BossDamageSource, BossHandle } from './boss'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { MeteorSystem } from './meteor'; +import type { PlayerHandle } from './player'; +import type { StageHandle } from './stage'; +import type { StarCombat } from './starCombat'; +import { playSfx } from '../audio/sfx'; + +// 戰鬥 overlap/collider 接線(GAME_DESIGN §29/§58/§68/§79):星彈/觸碰/危險物/ +// 彈幕/隕星/平台元素的碰撞結算集中於此;GameScene create 單點委派。 +// 接線順序凍結:同幀多重結算次序與抽取前完全一致,不得重排。 + +const asSprite = (obj: unknown): Phaser.Physics.Arcade.Sprite => + obj as Phaser.Physics.Arcade.Sprite; + +export interface CombatOverlapHooks { + player(): PlayerHandle; + enemies(): EnemySystem; + boss(): BossHandle; + fx(): FxSystem; + meteor(): MeteorSystem | null; + stage(): StageHandle; + combat(): StarCombat; + // 多本體工具(§68)與受擊單一入口由 GameScene 持有。 + bossBodies(): Phaser.Physics.Arcade.Sprite[]; + nearestBossBody(x: number, y: number): Phaser.Physics.Arcade.Sprite; + bossTouchDamage(): number; + damagePlayer(damage: number, sourceX: number): void; + damageBossAt(amount: number, x: number, y: number, source?: BossDamageSource): void; + // 勝敗轉場窗(finished || transitioning):期間傷害結算靜默。 + isSettled(): boolean; + isBossDown(): boolean; + now(): number; +} + +export function wireCombatOverlaps(scene: Phaser.Scene, hooks: CombatOverlapHooks): void { + const stars = hooks.player().getStars(); + + scene.physics.add.overlap(stars, hooks.enemies().getGroup(), (star, enemy) => { + const target = enemy as Phaser.GameObjects.GameObject; + const s = asSprite(star); + if (!s.active || !hooks.enemies().kindOf(target)) return; + // 磁場星彈免傷(§59 magno field):星彈吸附於磁殼失效,近戰/下砸路徑不受影響。 + if (hooks.enemies().isStarImmune(target)) { + hooks.fx().burstSmall(s.x, s.y, 0x8ab0e8); + playSfx('metal', 0.7); + hooks.player().onStarHit(s, 'absorb'); + return; + } + // 鏡面反射(§59 mirri mirror):星彈改生成朝玩家的反射彈(反射彈有傷害)。 + if (hooks.enemies().isReflective(target)) { + const mirri = asSprite(enemy); + const playerSprite = hooks.player().sprite; + hooks.enemies().reflectStar(mirri.x, mirri.y, playerSprite.x, playerSprite.y); + hooks.fx().burstSmall(s.x, s.y, 0xf0f4ff); + hooks.player().onStarHit(s, 'absorb'); + return; + } + const spec = hooks.combat().specOf(s); + const outcome = hooks.enemies().damage(target, hooks.combat().damageOf(s)); + if (outcome === 'ignored') return; + if (spec.aoeRadiusPx > 0) hooks.combat().explodeStar(s.x, s.y, spec, target); + // 雷鏈星(§40):命中後跳電至半徑內最近敵,主目標排除。 + if (spec.chainCount > 0) hooks.combat().chainLightning(s.x, s.y, spec, target); + // 孢子星(§53):命中未死目標套緩速+輕持續傷。 + if (spec.slowMs > 0 && outcome === 'hurt') { + hooks.enemies().applySlow(target, spec.slowMs, spec.dotDamage); + } + // 凝光星(§46):命中處生成凍結光域。 + const mix = hooks.combat().mixOf(s); + if (mix && mix.freezeRadiusPx > 0) hooks.combat().freezeField(s.x, s.y, mix); + // 未死目標(chompy 扣血)吃掉星彈;擊殺則依穿透續飛。 + // 迴旋星(§53):命中不吸收——依穿透結算,保留回程判定。 + hooks.player().onStarHit(s, outcome === 'killed' || spec.boomerang ? 'pierce' : 'absorb'); + }); + + // group vs sprite 的回調參數順序不保證,取非魔王側為星彈。 + // 入場動畫與死亡演出期間(非 active)星彈直接穿過,不消耗。 + // 多本體(§68):逐本體接線,受擊側由 damageBossAt 依命中位置歸屬。 + for (const hitBody of hooks.bossBodies()) { + scene.physics.add.overlap(stars, hitBody, (a, b) => { + const star = asSprite((a as unknown) === (hitBody as unknown) ? b : a); + if (!star.active || !hooks.boss().isActive()) return; + if (!(hitBody.body as Phaser.Physics.Arcade.Body).enable) return; + const spec = hooks.combat().specOf(star); + if (spec.aoeRadiusPx > 0) hooks.combat().explodeStar(star.x, star.y, spec, null); + // 雷鏈星命中魔王同樣跳電波及補給小怪(§40 群戰原型)。 + if (spec.chainCount > 0) hooks.combat().chainLightning(star.x, star.y, spec, null); + const mix = hooks.combat().mixOf(star); + if (mix && mix.freezeRadiusPx > 0) hooks.combat().freezeField(star.x, star.y, mix); + hooks.player().onStarHit(star, 'absorb'); + hooks.damageBossAt(hooks.combat().damageOf(star), star.x, star.y); + }); + } + + // 碎晶盾(§68 P3):可擊破的星彈屏障——星彈被吸收、盾即碎裂。 + const shields = hooks.boss().getShields?.(); + if (shields) { + scene.physics.add.overlap(stars, shields, (a, b) => { + const isShield = (shields.getChildren() as unknown[]).includes(a); + const shard = asSprite(isShield ? a : b); + const star = asSprite(isShield ? b : a); + if (!star.active || !shard.active) return; + shard.disableBody(true, true); + hooks.fx().burstSmall(shard.x, shard.y, 0xe8dcff); + playSfx('break', 0.7); + hooks.player().onStarHit(star, 'absorb'); + }); + } + + // 新怪危險物:puffy 爆刺彈與 chompy 咬合 hitbox(傷害 1,命中即失效)。 + // zappy 放電環(§30)同走此 hazards 管線結算,不另設 overlap。 + scene.physics.add.overlap(hooks.player().sprite, hooks.enemies().getHazards(), (_p, hz) => { + const hazard = asSprite(hz); + if (!hazard.active || hooks.isSettled()) return; + hazard.disableBody(true, true); + hooks.damagePlayer(ENEMY.touchDamage, hazard.x); + }); + + scene.physics.add.overlap(hooks.player().sprite, hooks.enemies().getGroup(), (_p, enemy) => { + const kind = hooks.enemies().kindOf(enemy as Phaser.GameObjects.GameObject); + if (!kind || hooks.isSettled()) return; + // 半入地無害態(§47 drilly 潛地/前搖):不結算觸碰傷害。 + if (hooks.enemies().isPhasedOut(enemy as Phaser.GameObjects.GameObject)) return; + const target = asSprite(enemy); + // 雷化帶電體(§57):接觸對小怪放電(damage CD 節流),玩家觸碰傷害照常結算。 + const contactDamage = hooks.combat().playerFormSpec()?.contactDamage ?? 0; + if (contactDamage > 0) { + const zapped = hooks.enemies().damage(enemy as Phaser.GameObjects.GameObject, contactDamage); + if (zapped !== 'ignored') { + hooks.fx().burstSmall(target.x, target.y, TRANSFORM_FORMS.volt.tint); + } + if (zapped === 'killed') return; + } + // 吸入錐形內的可吸怪(§30:shelly 僅暈眩窗)交由吞下流程,不結算觸碰傷害。 + const { x, y } = hooks.player().sprite; + if ( + hooks.player().isInhaling() && + hooks.enemies().isInhalable(enemy as Phaser.GameObjects.GameObject) && + isInInhaleRange(x, y, hooks.player().getFacing(), target.x, target.y, INHALE.rangePx) + ) { + return; + } + // 吸入接觸豁免(§77):被吸入中(拉力豁免窗內)的怪貼身不傷——涵蓋轉向/ + // 鬆開瞬間與出錐殘餘飛行;窗過期即恢復傷害性,未被吸的其他怪照常結算。 + if (isContactHarmless(hooks.now(), (target.getData('inhaleGraceUntil') as number) ?? 0)) { + return; + } + hooks.damagePlayer(ENEMY.touchDamage, target.x); + }); + + scene.physics.add.overlap( + hooks.player().getInhaleZone(), + hooks.enemies().getGroup(), + (_z, enemy) => { + asSprite(enemy).setData('inhalePull', true); + }, + ); + + // 觸碰與頭頂 hit window(§58/§68):多本體逐一接線,停用本體不結算; + // 入場運鏡期(非 active)傷害雙向靜默——降落中的魔王不得對走位玩家先手。 + for (const touchBody of hooks.bossBodies()) { + scene.physics.add.overlap(hooks.player().sprite, touchBody, () => { + if (hooks.isSettled() || hooks.isBossDown()) return; + if (!hooks.boss().isActive()) return; + if (!(touchBody.body as Phaser.Physics.Arcade.Body).enable) return; + const playerBody = hooks.player().sprite.body as Phaser.Physics.Arcade.Body; + // 魔王頭頂 hit window(§58):下砸命中上半身=頭頂——嘗試觸發暈眩並回彈,免體傷。 + if (hooks.player().isSlamming() && playerBody.bottom <= touchBody.y) { + const stunned = hooks.boss().trySlamStun(); + hooks.player().onSlamBounce(); + hooks.fx().burstSmall(hooks.player().sprite.x, playerBody.bottom, 0xffd966); + if (stunned) hooks.fx().shake(8); + return; + } + hooks.damagePlayer(hooks.bossTouchDamage(), touchBody.x); + }); + } + + scene.physics.add.overlap(hooks.player().sprite, hooks.boss().getProjectiles(), (_p, ball) => { + const projectile = asSprite(ball); + if (!projectile.active || hooks.isSettled()) return; + if (projectile.getData('reflected') === true) return; + // 殼化反彈(§57/§58):彈幕不傷身,反向射回最近存活本體(§68 多本體)。 + if (hooks.combat().playerFormSpec()?.reflectProjectiles) { + projectile.setData('reflected', true); + projectile.setTint(TRANSFORM_FORMS.shell.tint); + (projectile.body as Phaser.Physics.Arcade.Body).setAllowGravity(false); + const boss = hooks.nearestBossBody(projectile.x, projectile.y); + scene.physics.moveTo(projectile, boss.x, boss.y, SHELL_REFLECT.speed); + playSfx('metal'); + return; + } + projectile.disableBody(true, true); + hooks.damagePlayer(hooks.bossTouchDamage(), projectile.x); + }); + + // 反彈彈幕回傷(§57/§58):僅帶反彈標記的彈體對魔王結算(§68 多本體逐一接線)。 + for (const reflectBody of hooks.bossBodies()) { + scene.physics.add.overlap(reflectBody, hooks.boss().getProjectiles(), (a, b) => { + const projectile = asSprite((a as unknown) === (reflectBody as unknown) ? b : a); + if (!projectile.active || projectile.getData('reflected') !== true) return; + if (!hooks.boss().isActive() || !(reflectBody.body as Phaser.Physics.Arcade.Body).enable) { + return; + } + projectile.disableBody(true, true); + hooks.fx().burstSmall(projectile.x, projectile.y, TRANSFORM_FORMS.shell.tint); + hooks.damageBossAt(SHELL_REFLECT.damage, projectile.x, projectile.y, 'reflect'); + }); + } + + scene.physics.add.overlap(hooks.player().sprite, hooks.boss().getShockwaves(), (_p, wave) => { + const shockwave = asSprite(wave); + if (!shockwave.active || hooks.isSettled()) return; + hooks.damagePlayer(hooks.bossTouchDamage(), shockwave.x); + }); + + // 流星雨(§79):隕星可被星彈擊碎(碎裂演出、星彈吸收);隕星與餘燼命中玩家 + // 走 damagePlayer 單一入口(i-frame/護盾泡自然生效),隕星命中即碎。 + const meteor = hooks.meteor(); + if (meteor) { + const meteorGroup = meteor.getMeteors(); + scene.physics.add.overlap(stars, meteorGroup, (a, b) => { + const isRock = (meteorGroup.getChildren() as unknown[]).includes(a); + const rock = asSprite(isRock ? a : b); + const star = asSprite(isRock ? b : a); + if (!star.active || !rock.active) return; + hooks.meteor()?.shatter(rock); + hooks.player().onStarHit(star, 'absorb'); + }); + scene.physics.add.overlap(hooks.player().sprite, meteorGroup, (_p, rock) => { + const sprite = asSprite(rock); + if (!sprite.active || hooks.isSettled()) return; + hooks.meteor()?.shatter(sprite); + hooks.damagePlayer(ENEMY.touchDamage, sprite.x); + }); + scene.physics.add.overlap(hooks.player().sprite, meteor.getEmbers(), (_p, hz) => { + const ember = asSprite(hz); + if (!ember.active || hooks.isSettled()) return; + ember.disableBody(true, true); + hooks.damagePlayer(ENEMY.touchDamage, ember.x); + }); + } + + // v4 平台元素(§29):單向自上著地、移動平台載運、磚體實心;彈簧與破磚交由 stage 結算。 + scene.physics.add.collider( + hooks.player().sprite, + hooks.stage().getOneWay(), + undefined, + hooks.stage().canLandOneWay, + ); + scene.physics.add.collider(hooks.player().sprite, hooks.stage().getMoving()); + scene.physics.add.collider(hooks.player().sprite, hooks.stage().getBreakables()); + scene.physics.add.overlap( + hooks.player().sprite, + hooks.stage().getSprings(), + hooks.stage().onSpringOverlap, + ); + scene.physics.add.overlap(stars, hooks.stage().getBreakables(), (a, b) => { + const brick = (hooks.stage().getBreakables() as unknown[]).includes(a) ? a : b; + const star = asSprite(brick === a ? b : a); + if (!star.active) return; + if (hooks.stage().breakBrick(brick as Phaser.GameObjects.GameObject)) { + hooks.player().onStarHit(star, 'absorb'); + } + }); +} diff --git a/apps/starpuff/src/game/systems/starCombat.test.ts b/apps/starpuff/src/game/systems/starCombat.test.ts new file mode 100644 index 000000000..8eeb87790 --- /dev/null +++ b/apps/starpuff/src/game/systems/starCombat.test.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { SLAM, STARSTORM, STAR_FLAVORS, getMix } from '../core/config'; +import { createBuffState, pickupBuff, BUFF_SPECS } from '../logic/buffs'; +import { SHELL_SHIELD } from '../logic/skills'; +import { GALE_FLIGHT, VOLT_BEAM, TRANSFORM_FORMS } from '../logic/transform'; +import { createStarCombat, type StarCombatHooks } from './starCombat'; +import type { BossHandle } from './boss'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { PlayerHandle } from './player'; +import { playSfx } from '../audio/sfx'; + +// characterization(v17 債務列車):鎖住自 GameScene 抽出的星彈規格解析與技能 +// 世界結算現行為;幾何選敵數學由 combat/skills 單測覆蓋,此處鎖委派與結算路徑。 + +vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); + +interface FakeStarData { + flavor?: string; + mix?: string | null; + damage?: number; +} + +function makeStar(data: FakeStarData): Phaser.Physics.Arcade.Sprite { + return { + getData: (key: string) => (data as Record)[key], + } as unknown as Phaser.Physics.Arcade.Sprite; +} + +interface FakeEnemy { + x: number; + y: number; + active: boolean; + setVelocity: ReturnType; +} + +function makeEnemy(x: number, y: number, active = true): FakeEnemy { + return { x, y, active, setVelocity: vi.fn() }; +} + +function chainable(): Record> { + const target: Record> = {}; + for (const key of [ + 'setDepth', + 'setScale', + 'setStrokeStyle', + 'lineStyle', + 'beginPath', + 'moveTo', + 'lineTo', + 'strokePath', + 'destroy', + ]) { + target[key] = vi.fn(() => target); + } + return target; +} + +function makeHarness(overrides: { + enemies?: FakeEnemy[]; + bossActive?: boolean; + form?: 'volt' | 'shell' | 'gale' | null; + playerAt?: { x: number; y: number }; +}): { + combat: ReturnType; + hooks: StarCombatHooks; + damage: ReturnType; + kill: ReturnType; + applySlow: ReturnType; + freeze: ReturnType; + damageBossAt: ReturnType; + fx: { + burstSmall: ReturnType; + shake: ReturnType; + starBurst: ReturnType; + }; + flash: ReturnType; + delayedCall: ReturnType; + setBuff: (next: ReturnType) => void; +} { + const enemies = overrides.enemies ?? []; + const damage = vi.fn(() => 'hurt'); + const kill = vi.fn(); + const applySlow = vi.fn(); + const freeze = vi.fn(); + const damageBossAt = vi.fn(); + const fx = { burstSmall: vi.fn(), shake: vi.fn(), starBurst: vi.fn() }; + const flash = vi.fn(); + const delayedCall = vi.fn(); + let buff = createBuffState(); + const scene = { + add: { graphics: () => chainable(), circle: () => chainable() }, + tweens: { add: vi.fn() }, + cameras: { main: { flash, worldView: { x: 0, y: 0, width: 854, height: 480 } } }, + time: { delayedCall }, + } as unknown as Phaser.Scene; + const hooks: StarCombatHooks = { + enemies: () => + ({ + getGroup: () => ({ getChildren: () => enemies }), + damage, + applySlow, + kill, + freeze, + }) as unknown as EnemySystem, + fx: () => fx as unknown as FxSystem, + boss: () => + ({ + isActive: () => overrides.bossActive ?? false, + getShields: undefined, + }) as unknown as BossHandle, + player: () => + ({ + sprite: overrides.playerAt ?? { x: 100, y: 300 }, + getTransformState: () => ({ form: overrides.form ?? null, remainingMs: 0 }), + }) as unknown as PlayerHandle, + buff: () => buff, + bossBodies: () => [], + damageBossAt, + }; + return { + combat: createStarCombat(scene, hooks), + hooks, + damage, + kill, + applySlow, + freeze, + damageBossAt, + fx, + flash, + delayedCall, + setBuff: (next) => { + buff = next; + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('星彈規格解析(§46 單一出口)', () => { + it('無 data 回預設 jelly 屬性表', () => { + const { combat } = makeHarness({}); + expect(combat.specOf(makeStar({}))).toBe(STAR_FLAVORS.jelly); + expect(combat.mixOf(makeStar({}))).toBeNull(); + }); + + it('flavor data 讀對應屬性表', () => { + const { combat } = makeHarness({}); + expect(combat.specOf(makeStar({ flavor: 'puffy' }))).toBe(STAR_FLAVORS.puffy); + }); + + it('mix data 優先於 flavor(混合彈讀配方表)', () => { + const { combat } = makeHarness({}); + const star = makeStar({ flavor: 'jelly', mix: 'thunderburst' }); + expect(combat.mixOf(star)).toBe(getMix('thunderburst')); + expect(combat.specOf(star)).toBe(getMix('thunderburst')); + }); + + it('damageOf:data damage 優先、缺省讀 spec,星力果倍率疊乘', () => { + const { combat, setBuff } = makeHarness({}); + expect(combat.damageOf(makeStar({ flavor: 'jelly', damage: 20 }))).toBe(20); + expect(combat.damageOf(makeStar({ flavor: 'jelly' }))).toBe(STAR_FLAVORS.jelly.damage); + setBuff(pickupBuff(createBuffState(), 'power')); + expect(combat.damageOf(makeStar({ flavor: 'jelly', damage: 10 }))).toBe( + 10 * BUFF_SPECS.power.damageMul, + ); + }); + + it('playerFormSpec/slamRadiusPx:未變身走基礎半徑、殼化下砸範圍加倍', () => { + const base = makeHarness({}); + expect(base.combat.playerFormSpec()).toBeNull(); + expect(base.combat.slamRadiusPx()).toBe(SLAM.radiusPx); + const shell = makeHarness({ form: 'shell' }); + expect(shell.combat.playerFormSpec()).toBe(TRANSFORM_FORMS.shell); + expect(shell.combat.slamRadiusPx()).toBe(SLAM.radiusPx * TRANSFORM_FORMS.shell.slamRadiusMul); + }); +}); + +describe('explodeStar 爆裂波及(§20/§53)', () => { + it('半徑內結算 aoeDamage、主目標排除、半徑外不波及', () => { + const inner = makeEnemy(120, 300); + const outer = makeEnemy(500, 300); + const excluded = makeEnemy(100, 300); + const { combat, damage } = makeHarness({ enemies: [inner, outer, excluded] }); + const spec = STAR_FLAVORS.puffy; + combat.explodeStar(100, 300, spec, excluded as unknown as Phaser.GameObjects.GameObject); + expect(damage).toHaveBeenCalledTimes(1); + expect(damage).toHaveBeenCalledWith(inner, spec.aoeDamage); + }); + + it('毒爆雲(slowMs>0)對 hurt 未死者加套緩速持續傷', () => { + const target = makeEnemy(120, 300); + const { combat, applySlow } = makeHarness({ enemies: [target] }); + const spore = getMix('sporeblast'); + combat.explodeStar(100, 300, spore, null); + expect(applySlow).toHaveBeenCalledWith(target, spore.slowMs, spore.dotDamage); + }); +}); + +describe('chainLightning 雷鏈跳電(§40)', () => { + it('半徑內依 chainCount 跳電、主目標排除、無候選零結算', () => { + const a = makeEnemy(140, 300); + const b = makeEnemy(180, 300); + const c = makeEnemy(2000, 300); + const excluded = makeEnemy(100, 300); + const { combat, damage } = makeHarness({ enemies: [a, b, c, excluded] }); + const spec = STAR_FLAVORS.zappy; + combat.chainLightning(100, 300, spec, excluded as unknown as Phaser.GameObjects.GameObject); + expect(damage.mock.calls.map((call) => call[0])).toEqual([a, b]); + expect(damage).toHaveBeenCalledWith(a, spec.chainDamage); + expect(vi.mocked(playSfx)).toHaveBeenCalledWith('zap'); + }); + + it('無候選時不放音效不結算', () => { + const { combat, damage } = makeHarness({ enemies: [] }); + combat.chainLightning(100, 300, STAR_FLAVORS.zappy, null); + expect(damage).not.toHaveBeenCalled(); + expect(vi.mocked(playSfx)).not.toHaveBeenCalled(); + }); +}); + +describe('freezeField 凝光凍結場(§46)', () => { + it('域內(半徑+20 放寬)小怪凍結、域外不受影響', () => { + const mix = getMix('gleamfield'); + const inner = makeEnemy(100 + mix.freezeRadiusPx + 10, 300); + const outer = makeEnemy(100 + mix.freezeRadiusPx + 60, 300); + const { combat, freeze } = makeHarness({ enemies: [inner, outer] }); + combat.freezeField(100, 300, mix); + expect(freeze).toHaveBeenCalledTimes(1); + expect(freeze).toHaveBeenCalledWith(inner, mix.freezeMs); + }); +}); + +describe('resolveStarstorm 星暴(§23)', () => { + it('白閃+震屏+清場全小怪;魔王未活動不結算魔王傷', () => { + const a = makeEnemy(100, 300); + const dead = makeEnemy(200, 300, false); + const { combat, kill, flash, fx, damageBossAt, delayedCall } = makeHarness({ + enemies: [a, dead], + }); + combat.resolveStarstorm(); + expect(flash).toHaveBeenCalled(); + expect(fx.shake).toHaveBeenCalledWith(12); + expect(kill).toHaveBeenCalledTimes(1); + expect(kill).toHaveBeenCalledWith(a); + expect(delayedCall).toHaveBeenCalledTimes(6); + expect(damageBossAt).not.toHaveBeenCalled(); + }); + + it('魔王活動中:固定傷結算至玩家位置歸屬的存活本體', () => { + const { combat, damageBossAt } = makeHarness({ bossActive: true, playerAt: { x: 77, y: 88 } }); + combat.resolveStarstorm(); + expect(damageBossAt).toHaveBeenCalledWith(STARSTORM.bossDamage, 77, 88); + }); +}); + +describe('resolveSlamImpact 下衝擊落地(§23/§57)', () => { + it('半徑內傷害+hurt 擊退;半徑外不結算', () => { + const near = makeEnemy(130, 300); + const far = makeEnemy(400, 300); + const { combat, damage } = makeHarness({ enemies: [near, far] }); + combat.resolveSlamImpact(100, 300); + expect(damage).toHaveBeenCalledTimes(1); + expect(damage).toHaveBeenCalledWith(near, SLAM.damage); + expect(near.setVelocity).toHaveBeenCalledTimes(1); + }); + + it('殼化形態下砸半徑加倍(§57)', () => { + const midRange = makeEnemy(100 + SLAM.radiusPx + 20, 300); + const { combat, damage } = makeHarness({ enemies: [midRange], form: 'shell' }); + combat.resolveSlamImpact(100, 300); + expect(damage).toHaveBeenCalledWith(midRange, SLAM.damage); + }); +}); + +describe('resolveShieldCounter 殼盾反擊(§40)', () => { + it('盾面前 30px 定點星爆、counterRadiusPx 內結算反擊傷', () => { + const near = makeEnemy(130 + 50, 300); + const behind = makeEnemy(-100, 300); + const { combat, damage, fx } = makeHarness({ enemies: [near, behind] }); + combat.resolveShieldCounter(100, 300, 1); + expect(fx.starBurst).toHaveBeenCalledWith(130, 300); + expect(damage).toHaveBeenCalledTimes(1); + expect(damage).toHaveBeenCalledWith(near, SHELL_SHIELD.counterDamage); + }); +}); + +describe('resolveVoltBeam 雷化鏈電束(§57/§68)', () => { + it('面向側最近目標主傷(星力果倍率疊乘)、反向目標不取', () => { + const front = makeEnemy(200, 300); + const back = makeEnemy(50, 300); + const { combat, damage, setBuff } = makeHarness({ enemies: [front, back] }); + setBuff(pickupBuff(createBuffState(), 'power')); + combat.resolveVoltBeam(100, 300, 1); + expect(damage.mock.calls[0]).toEqual([front, VOLT_BEAM.damage * BUFF_SPECS.power.damageMul]); + }); + + it('主目標後沿雷鏈波及最近候選(chainDamage)', () => { + const first = makeEnemy(150, 300); + const second = makeEnemy(190, 300); + const { combat, damage } = makeHarness({ enemies: [first, second] }); + combat.resolveVoltBeam(100, 300, 1); + expect(damage.mock.calls).toEqual([ + [first, VOLT_BEAM.damage], + [second, VOLT_BEAM.chainDamage], + ]); + }); + + it('無面向側候選時零結算', () => { + const back = makeEnemy(50, 300); + const { combat, damage } = makeHarness({ enemies: [back] }); + combat.resolveVoltBeam(100, 300, 1); + expect(damage).not.toHaveBeenCalled(); + }); + + it('魔王活動中本體入候選:無小怪時主傷走 damageBossAt(volt 歸因)', () => { + const { combat, damageBossAt, hooks } = makeHarness({ bossActive: true }); + const body = { + x: 200, + y: 300, + body: { enable: true }, + } as unknown as Phaser.Physics.Arcade.Sprite; + vi.spyOn(hooks, 'bossBodies').mockReturnValue([body]); + combat.resolveVoltBeam(100, 300, 1); + expect(damageBossAt).toHaveBeenCalledWith(VOLT_BEAM.damage, 200, 300, 'volt'); + }); +}); + +describe('resolveGaleLanding 風化落地衝擊(§57)', () => { + it('landingRadiusPx 內傷害+輕擊退(0.7 倍擊退速度)', () => { + const near = makeEnemy(100 + GALE_FLIGHT.landingRadiusPx - 5, 300); + const far = makeEnemy(100 + GALE_FLIGHT.landingRadiusPx + 50, 300); + const { combat, damage } = makeHarness({ enemies: [near, far] }); + combat.resolveGaleLanding(100, 300); + expect(damage).toHaveBeenCalledTimes(1); + expect(damage).toHaveBeenCalledWith(near, GALE_FLIGHT.landingDamage); + expect(near.setVelocity).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/starpuff/src/game/systems/starCombat.ts b/apps/starpuff/src/game/systems/starCombat.ts new file mode 100644 index 000000000..1baad2262 --- /dev/null +++ b/apps/starpuff/src/game/systems/starCombat.ts @@ -0,0 +1,365 @@ +import type Phaser from 'phaser'; +import { + SLAM, + STARSTORM, + STAR_FLAVORS, + getMix, + type MixId, + type StarFlavor, + type StarFlavorSpec, + type StarMixSpec, +} from '../core/config'; +import { knockbackVelocity, pickInRadius } from '../logic/combat'; +import { buffDamageMul, type BuffState } from '../logic/buffs'; +import { SHELL_SHIELD, pickChainTargets } from '../logic/skills'; +import { GALE_FLIGHT, TRANSFORM_FORMS, VOLT_BEAM } from '../logic/transform'; +import type { BossDamageSource, BossHandle } from './boss'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { PlayerHandle } from './player'; +import { playSfx } from '../audio/sfx'; + +// 星彈規格與技能世界結算(GAME_DESIGN §23/§40/§46/§57):player 只發事件, +// 場上效果集中於此;GameScene 只留事件路由與 overlap 接線。 + +// 雷鏈跳電演出(§40):折線閃電段淡出時長。 +const BOLT_FADE_MS = 200; + +// 平面距離(與 Phaser.Math.Distance.Between 同語意):本模組不 value-import phaser, +// 維持 node 環境可單測。 +const distanceBetween = (x1: number, y1: number, x2: number, y2: number): number => + Math.hypot(x2 - x1, y2 - y1); + +export type PlayerFormSpec = (typeof TRANSFORM_FORMS)['volt']; + +// hooks 以閉包延遲解析:player/enemies/fx/boss 於 GameScene create 依序建立。 +export interface StarCombatHooks { + enemies(): EnemySystem; + fx(): FxSystem; + boss(): BossHandle; + player(): PlayerHandle; + buff(): BuffState; + // 多本體工具(§68):存活本體清單與受擊單一出口由 GameScene 持有。 + bossBodies(): Phaser.Physics.Arcade.Sprite[]; + damageBossAt(amount: number, x: number, y: number, source?: BossDamageSource): void; +} + +export interface StarCombat { + // 彈道有效規格單一出口(§46):混合彈讀配方表,其餘讀屬性表。 + mixOf(star: Phaser.Physics.Arcade.Sprite): StarMixSpec | null; + specOf(star: Phaser.Physics.Arcade.Sprite): StarFlavorSpec; + damageOf(star: Phaser.Physics.Arcade.Sprite): number; + // 玩家當前形態規格(§57):未變身為 null。 + playerFormSpec(): PlayerFormSpec | null; + slamRadiusPx(): number; + explodeStar( + x: number, + y: number, + spec: StarFlavorSpec, + exclude: Phaser.GameObjects.GameObject | null, + ): void; + chainLightning( + x: number, + y: number, + spec: StarFlavorSpec, + exclude: Phaser.GameObjects.GameObject | null, + ): void; + freezeField(x: number, y: number, mix: StarMixSpec): void; + resolveStarstorm(): void; + resolveSlamImpact(x: number, y: number): void; + resolveShieldCounter(x: number, y: number, facing: 1 | -1): void; + resolveVoltBeam(x: number, y: number, facing: 1 | -1): void; + resolveGaleLanding(x: number, y: number): void; +} + +export function createStarCombat(scene: Phaser.Scene, hooks: StarCombatHooks): StarCombat { + function flavorOf(star: Phaser.Physics.Arcade.Sprite): StarFlavor { + return (star.getData('flavor') as StarFlavor | undefined) ?? 'jelly'; + } + + // 混合星規格(§46):發射端寫入 mix id;非混合彈回 null。 + function mixOf(star: Phaser.Physics.Arcade.Sprite): StarMixSpec | null { + const id = star.getData('mix') as MixId | null | undefined; + return id ? getMix(id) : null; + } + + function specOf(star: Phaser.Physics.Arcade.Sprite): StarFlavorSpec { + return mixOf(star) ?? STAR_FLAVORS[flavorOf(star)]; + } + + // 星彈傷害(§23/§69):發射端已依槽位(強化 ×1.6 / 金星 20)寫入;星力果倍率結算端疊乘。 + function damageOf(star: Phaser.Physics.Arcade.Sprite): number { + const base = (star.getData('damage') as number | undefined) ?? specOf(star).damage; + return base * buffDamageMul(hooks.buff()); + } + + function playerFormSpec(): PlayerFormSpec | null { + const form = hooks.player().getTransformState().form; + return form ? TRANSFORM_FORMS[form] : null; + } + + // 下衝擊有效半徑(§57):殼化下砸範圍加倍。 + function slamRadiusPx(): number { + return SLAM.radiusPx * (playerFormSpec()?.slamRadiusMul ?? 1); + } + + // 爆裂星 AoE(§20/§53):命中處圓形距離判定波及其他小怪,主目標排除避免重複結算; + // 毒爆雲(slowMs > 0)對波及未死者加套緩速持續傷。 + function explodeStar( + x: number, + y: number, + spec: StarFlavorSpec, + exclude: Phaser.GameObjects.GameObject | null, + ): void { + hooks.fx().burstSmall(x, y, spec.tint); + for (const child of hooks.enemies().getGroup().getChildren()) { + if (child === exclude || !child.active) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + if (distanceBetween(x, y, enemy.x, enemy.y) <= spec.aoeRadiusPx) { + const outcome = hooks.enemies().damage(child, spec.aoeDamage); + if (spec.slowMs > 0 && outcome === 'hurt') { + hooks.enemies().applySlow(child, spec.slowMs, spec.dotDamage); + } + } + } + } + + // 雷鏈跳電(§40):命中點半徑內取最近 chainCount 隻依序跳電,折線閃電演出。 + function chainLightning( + x: number, + y: number, + spec: StarFlavorSpec, + exclude: Phaser.GameObjects.GameObject | null, + ): void { + const candidates: { x: number; y: number; ref: Phaser.GameObjects.GameObject }[] = []; + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active || child === exclude) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + candidates.push({ x: enemy.x, y: enemy.y, ref: child }); + } + const targets = pickChainTargets(x, y, candidates, spec.chainCount, spec.chainRadiusPx); + if (targets.length === 0) return; + playSfx('zap'); + let fromX = x; + let fromY = y; + for (const target of targets) { + drawBolt(fromX, fromY, target.x, target.y); + hooks.fx().burstSmall(target.x, target.y, spec.tint); + hooks.enemies().damage(target.ref, spec.chainDamage); + fromX = target.x; + fromY = target.y; + } + } + + // 折線閃電:兩點間三段隨機垂直抖動折線,快速淡出自毀。 + function drawBolt(x1: number, y1: number, x2: number, y2: number): void { + const bolt = scene.add.graphics().setDepth(93); + bolt.lineStyle(3, 0xffe28a, 1); + bolt.beginPath(); + bolt.moveTo(x1, y1); + const nx = -(y2 - y1); + const ny = x2 - x1; + const len = Math.hypot(nx, ny) || 1; + for (const t of [0.25, 0.5, 0.75]) { + const jitter = (Math.random() - 0.5) * 22; + bolt.lineTo( + x1 + (x2 - x1) * t + (nx / len) * jitter, + y1 + (y2 - y1) * t + (ny / len) * jitter, + ); + } + bolt.lineTo(x2, y2); + bolt.strokePath(); + scene.tweens.add({ + targets: bolt, + alpha: 0, + duration: BOLT_FADE_MS, + ease: 'Quad.easeIn', + onComplete: () => bolt.destroy(), + }); + } + + // 凝光星凍結場(§46):命中處光域擴散,域內小怪凍結停擺(主目標一併凍結); + // 選敵下沉 combat.pickInRadius,此處只管視覺 tween 與凍結套用。 + function freezeField(x: number, y: number, mix: StarMixSpec): void { + const field = scene.add + .circle(x, y, mix.freezeRadiusPx, 0xdff2ff, 0.22) + .setStrokeStyle(3, 0xbfe8ff, 0.9) + .setDepth(59) + .setScale(0.3); + scene.tweens.add({ + targets: field, + scale: 1, + duration: 220, + ease: 'Quad.easeOut', + }); + scene.tweens.add({ + targets: field, + alpha: 0, + delay: mix.freezeMs - 300, + duration: 300, + onComplete: () => field.destroy(), + }); + const candidates: { x: number; y: number; ref: Phaser.GameObjects.GameObject }[] = []; + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + candidates.push({ x: enemy.x, y: enemy.y, ref: child }); + } + for (const target of pickInRadius(x, y, candidates, mix.freezeRadiusPx + 20)) { + hooks.enemies().freeze(target.ref, mix.freezeMs); + } + } + + // 星暴(§23):白閃 + 震屏 + 視野內星雨連爆;清場全小怪、魔王固定 12 傷。 + function resolveStarstorm(): void { + scene.cameras.main.flash(280, 255, 255, 255); + hooks.fx().shake(12); + const view = scene.cameras.main.worldView; + for (let i = 0; i < 6; i += 1) { + scene.time.delayedCall(i * 90, () => + hooks + .fx() + .starBurst( + view.x + Math.random() * view.width, + view.y + Math.random() * view.height * 0.6, + ), + ); + } + for (const child of hooks.enemies().getGroup().getChildren()) { + if (child.active) hooks.enemies().kill(child); + } + if (hooks.boss().isActive()) { + // 多本體(§68):星暴固定傷結算至玩家最近的存活本體。 + const sprite = hooks.player().sprite; + hooks.damageBossAt(STARSTORM.bossDamage, sprite.x, sprite.y); + } + } + + // 下衝擊落地(§23):60px 圓域傷害 2 + 擊退;未死者被震開。 + function resolveSlamImpact(x: number, y: number): void { + hooks.fx().shake(6); + const radius = slamRadiusPx(); + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + if (distanceBetween(x, y, enemy.x, enemy.y) > radius) continue; + const outcome = hooks.enemies().damage(child, SLAM.damage); + if (outcome === 'hurt') { + const kb = knockbackVelocity(enemy.x, x, SLAM.knockbackSpeed, SLAM.knockbackLift); + enemy.setVelocity(kb.x, kb.y); + } + } + } + + // 殼盾反擊星爆(§40):盾面前定點星爆,波及面前 90px 小怪。 + function resolveShieldCounter(x: number, y: number, facing: 1 | -1): void { + const originX = x + facing * 30; + hooks.fx().starBurst(originX, y); + hooks.fx().shake(5); + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + if (distanceBetween(originX, y, enemy.x, enemy.y) <= SHELL_SHIELD.counterRadiusPx) { + hooks.enemies().damage(child, SHELL_SHIELD.counterDamage); + } + } + } + + // 雷化鏈電束(§57/§68):短程面向側取最近目標(小怪或魔王本體)主傷,再沿雷鏈跳電 + // 波及——跳電候選含小怪、其餘存活魔王本體與碎晶盾(分裂型天然剋制,審查修復)。 + function resolveVoltBeam(x: number, y: number, facing: 1 | -1): void { + interface BeamTarget { + x: number; + y: number; + ref: Phaser.GameObjects.GameObject | null; + shield?: Phaser.Physics.Arcade.Sprite; + } + const candidates: BeamTarget[] = []; + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + candidates.push({ x: enemy.x, y: enemy.y, ref: child }); + } + if (hooks.boss().isActive()) { + // 多本體(§68):全部存活本體入候選,鏈電束就近取目標。 + for (const body of hooks.bossBodies()) { + if (!(body.body as Phaser.Physics.Arcade.Body).enable) continue; + candidates.push({ x: body.x, y: body.y, ref: null }); + } + for (const obj of hooks.boss().getShields?.()?.getMatching('active', true) ?? []) { + const shard = obj as Phaser.Physics.Arcade.Sprite; + candidates.push({ x: shard.x, y: shard.y, ref: null, shield: shard }); + } + } + const inFront = candidates.filter((c) => Math.sign(c.x - x) === facing || c.x === x); + const target = pickChainTargets(x, y, inFront, 1, VOLT_BEAM.rangePx)[0]; + if (!target) return; + // 跳電單一結算出口:主傷與波及共用(盾碎裂/小怪/魔王本體三路)。 + const strike = (hit: BeamTarget, damage: number): void => { + hooks.fx().burstSmall(hit.x, hit.y, TRANSFORM_FORMS.volt.tint); + if (hit.shield) { + hit.shield.disableBody(true, true); + playSfx('break', 0.7); + return; + } + if (hit.ref) { + hooks.enemies().damage(hit.ref, damage); + return; + } + hooks.damageBossAt(damage, hit.x, hit.y, 'volt'); + }; + playSfx('zap'); + drawBolt(x, y, target.x, target.y); + // 星力果(§69):變身射擊同享傷害倍率。 + strike(target, VOLT_BEAM.damage * buffDamageMul(hooks.buff())); + // 跳電波及:以主目標為原點取最近候選(排除主目標自身)。 + const rest = candidates.filter((c) => c !== target); + let fromX = target.x; + let fromY = target.y; + for (const hop of pickChainTargets( + target.x, + target.y, + rest, + VOLT_BEAM.chainCount, + VOLT_BEAM.rangePx, + )) { + drawBolt(fromX, fromY, hop.x, hop.y); + strike(hop, VOLT_BEAM.chainDamage); + fromX = hop.x; + fromY = hop.y; + } + } + + // 風化落地衝擊(§57):小範圍傷害+輕擊退(半徑/傷害低於下衝擊,零彈藥消耗)。 + function resolveGaleLanding(x: number, y: number): void { + hooks.fx().shake(3); + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active) continue; + const enemy = child as Phaser.Physics.Arcade.Sprite; + if (distanceBetween(x, y, enemy.x, enemy.y) > GALE_FLIGHT.landingRadiusPx) { + continue; + } + const outcome = hooks.enemies().damage(child, GALE_FLIGHT.landingDamage); + if (outcome === 'hurt') { + const kb = knockbackVelocity(enemy.x, x, SLAM.knockbackSpeed * 0.7, SLAM.knockbackLift); + enemy.setVelocity(kb.x, kb.y); + } + } + } + + return { + mixOf, + specOf, + damageOf, + playerFormSpec, + slamRadiusPx, + explodeStar, + chainLightning, + freezeField, + resolveStarstorm, + resolveSlamImpact, + resolveShieldCounter, + resolveVoltBeam, + resolveGaleLanding, + }; +} diff --git a/apps/starpuff/src/game/systems/starSteering.test.ts b/apps/starpuff/src/game/systems/starSteering.test.ts new file mode 100644 index 000000000..a90916a42 --- /dev/null +++ b/apps/starpuff/src/game/systems/starSteering.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { getMix } from '../core/config'; +import { BOSS_AIM_ASSIST } from '../logic/homing'; +import { createStarSteering, type StarSteeringHooks } from './starSteering'; +import { createStarCombat, type StarCombat } from './starCombat'; +import type { BossHandle } from './boss'; +import type { EnemySystem } from './enemies'; +import type { FxSystem } from './fx'; +import type { PlayerHandle } from './player'; + +// characterization(v17 債務列車):鎖住自 GameScene 抽出的星彈導向候選過濾與 +// 委派現行為;轉向數學由 logic/homing 與 logic/enemyFsm 單測覆蓋。 + +vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); + +interface FakeStar { + active: boolean; + x: number; + y: number; + body: { velocity: { x: number; y: number }; setVelocity: ReturnType }; + data: Record; + getData: (key: string) => unknown; + setData: ReturnType; +} + +function makeStar(x: number, y: number, vx: number, data: Record = {}): FakeStar { + const star: FakeStar = { + active: true, + x, + y, + body: { velocity: { x: vx, y: 0 }, setVelocity: vi.fn() }, + data, + getData: (key) => star.data[key], + setData: vi.fn((key: string, value: unknown) => { + star.data[key] = value; + }), + }; + return star; +} + +interface FakeEnemy { + active: boolean; + x: number; + y: number; + kind: string; + data: Record; + getData: (key: string) => unknown; +} + +function makeEnemy( + x: number, + y: number, + kind = 'jelly', + data: Record = {}, +): FakeEnemy { + return { active: true, x, y, kind, data, getData: (key) => data[key] }; +} + +function makeHarness(config: { + stars?: FakeStar[]; + enemies?: FakeEnemy[]; + bossLevel?: boolean; + bossActive?: boolean; + bossDown?: boolean; + bossBodyAt?: { x: number; y: number }; +}): { steering: ReturnType; attachTrail: ReturnType } { + const stars = config.stars ?? []; + const enemies = config.enemies ?? []; + const attachTrail = vi.fn(() => ({ stop: vi.fn() })); + const player = { + getStars: () => ({ getChildren: () => stars }), + sprite: { x: 0, y: 0 }, + getTransformState: () => ({ form: null, remainingMs: 0 }), + } as unknown as PlayerHandle; + const enemySystem = { + getGroup: () => ({ getChildren: () => enemies }), + kindOf: (child: unknown) => (child as FakeEnemy).kind, + } as unknown as EnemySystem; + const boss = { isActive: () => config.bossActive ?? false } as unknown as BossHandle; + const combatHooks = { + enemies: () => enemySystem, + fx: () => ({}) as unknown as FxSystem, + boss: () => boss, + player: () => player, + buff: () => ({ id: null, remainingMs: 0 }), + bossBodies: () => [], + damageBossAt: vi.fn(), + }; + const combat: StarCombat = createStarCombat({} as unknown as Phaser.Scene, combatHooks as never); + const hooks: StarSteeringHooks = { + player: () => player, + enemies: () => enemySystem, + boss: () => boss, + combat: () => combat, + fx: () => ({ attachTrail }) as unknown as FxSystem, + isBossLevel: () => config.bossLevel ?? false, + isBossDown: () => config.bossDown ?? false, + nearestBossBody: () => + ({ + x: config.bossBodyAt?.x ?? 0, + y: config.bossBodyAt?.y ?? 0, + }) as unknown as Phaser.Physics.Arcade.Sprite, + }; + return { steering: createStarSteering(hooks), attachTrail }; +} + +describe('魔王準星輔助(§54)候選過濾', () => { + it('對向且射程內的一般星彈被限速轉向', () => { + const star = makeStar(100, 300, 520, { flavor: 'jelly' }); + const { steering } = makeHarness({ + stars: [star], + bossLevel: true, + bossActive: true, + bossBodyAt: { x: 100 + BOSS_AIM_ASSIST.rangePx - 10, y: 260 }, + }); + steering.update(16); + expect(star.body.setVelocity).toHaveBeenCalledTimes(1); + }); + + it('非 boss 關/魔王未活動/魔王已倒:不套用輔助', () => { + for (const config of [ + { bossLevel: false, bossActive: true, bossDown: false }, + { bossLevel: true, bossActive: false, bossDown: false }, + { bossLevel: true, bossActive: true, bossDown: true }, + ]) { + const star = makeStar(100, 300, 520, { flavor: 'jelly' }); + const { steering } = makeHarness({ + stars: [star], + ...config, + bossBodyAt: { x: 200, y: 300 }, + }); + steering.update(16); + expect(star.body.setVelocity).not.toHaveBeenCalled(); + } + }); + + it('背向魔王/超出射程的星彈不轉向', () => { + const away = makeStar(100, 300, -520, { flavor: 'jelly' }); + const far = makeStar(100, 300, 520, { flavor: 'jelly' }); + const { steering: s1 } = makeHarness({ + stars: [away], + bossLevel: true, + bossActive: true, + bossBodyAt: { x: 300, y: 300 }, + }); + s1.update(16); + expect(away.body.setVelocity).not.toHaveBeenCalled(); + const { steering: s2 } = makeHarness({ + stars: [far], + bossLevel: true, + bossActive: true, + bossBodyAt: { x: 100 + BOSS_AIM_ASSIST.rangePx + 50, y: 300 }, + }); + s2.update(16); + expect(far.body.setVelocity).not.toHaveBeenCalled(); + }); + + it('追電星與迴旋星不疊加輔助(自有彈道)', () => { + const homing = makeStar(100, 300, 520, { mix: 'voltseeker' }); + const boomerang = makeStar(100, 300, 520, { flavor: 'boomy' }); + const { steering } = makeHarness({ + stars: [homing, boomerang], + bossLevel: true, + bossActive: true, + bossBodyAt: { x: 180, y: 300 }, + }); + steering.update(16); + // 追電星無小怪候選(enemies 空)不轉向;迴旋星被排除——兩者皆零套用。 + expect(homing.body.setVelocity).not.toHaveBeenCalled(); + expect(boomerang.body.setVelocity).not.toHaveBeenCalled(); + }); +}); + +describe('追電星導引(§46)', () => { + it('homing mix 朝最近小怪轉向;非 homing 不動', () => { + expect(getMix('voltseeker').homing).toBe(true); + const homing = makeStar(100, 300, 520, { mix: 'voltseeker' }); + const plain = makeStar(100, 300, 520, { flavor: 'jelly' }); + const { steering } = makeHarness({ + stars: [homing, plain], + enemies: [makeEnemy(200, 300)], + }); + steering.update(16); + expect(homing.body.setVelocity).toHaveBeenCalledTimes(1); + expect(plain.body.setVelocity).not.toHaveBeenCalled(); + }); + + it('無存活候選時不轉向', () => { + const homing = makeStar(100, 300, 520, { mix: 'voltseeker' }); + const dead = makeEnemy(200, 300); + dead.active = false; + const { steering } = makeHarness({ stars: [homing], enemies: [dead] }); + steering.update(16); + expect(homing.body.setVelocity).not.toHaveBeenCalled(); + }); +}); + +describe('磁場吸偏(§59 magno field)', () => { + it('field 相位磁極獸拉偏所有星彈;非 field 相位不拉', () => { + const star = makeStar(100, 300, 520, { flavor: 'jelly' }); + const { steering } = makeHarness({ + stars: [star], + enemies: [makeEnemy(200, 300, 'magno', { magnoPhase: 'field' })], + }); + steering.update(16); + expect(star.body.setVelocity).toHaveBeenCalledTimes(1); + + const calm = makeStar(100, 300, 520, { flavor: 'jelly' }); + const { steering: s2 } = makeHarness({ + stars: [calm], + enemies: [makeEnemy(200, 300, 'magno', { magnoPhase: 'walk' })], + }); + s2.update(16); + expect(calm.body.setVelocity).not.toHaveBeenCalled(); + }); +}); + +describe('拖尾附掛(syncTrails)', () => { + it('active 星彈補掛拖尾、失效星彈停噴並清欄位', () => { + const fresh = makeStar(100, 300, 520); + const stop = vi.fn(); + const spent = makeStar(200, 300, 0, { fxTrail: { stop } }); + spent.active = false; + const { steering, attachTrail } = makeHarness({ stars: [fresh, spent] }); + steering.syncTrails(); + expect(attachTrail).toHaveBeenCalledTimes(1); + expect(fresh.setData).toHaveBeenCalledWith('fxTrail', expect.anything()); + expect(stop).toHaveBeenCalledTimes(1); + expect(spent.setData).toHaveBeenCalledWith('fxTrail', undefined); + }); +}); diff --git a/apps/starpuff/src/game/systems/starSteering.ts b/apps/starpuff/src/game/systems/starSteering.ts new file mode 100644 index 000000000..501548adc --- /dev/null +++ b/apps/starpuff/src/game/systems/starSteering.ts @@ -0,0 +1,154 @@ +import type Phaser from 'phaser'; +import { magnetPull } from '../logic/enemyFsm'; +import { + BOSS_AIM_ASSIST, + HOMING_RANGE_PX, + HOMING_TURN_RAD_PER_MS, + nearestInRange, + steerTowardTarget, +} from '../logic/homing'; +import type { BossHandle } from './boss'; +import type { EnemySystem } from './enemies'; +import type { FxSystem, TrailHandle } from './fx'; +import type { PlayerHandle } from './player'; +import type { StarCombat } from './starCombat'; + +// 星彈飛行導向與拖尾(GAME_DESIGN §46/§54/§59):鎖敵與轉向數學下沉 logic 層, +// 此處只管候選蒐集、過濾與 body velocity 套用;GameScene 只留逐幀委派。 + +const asSprite = (obj: unknown): Phaser.Physics.Arcade.Sprite => + obj as Phaser.Physics.Arcade.Sprite; + +// 平面距離(與 Phaser.Math.Distance.Between 同語意):不 value-import phaser 維持可測。 +const distanceBetween = (x1: number, y1: number, x2: number, y2: number): number => + Math.hypot(x2 - x1, y2 - y1); + +export interface StarSteeringHooks { + player(): PlayerHandle; + enemies(): EnemySystem; + boss(): BossHandle; + combat(): StarCombat; + fx(): FxSystem; + isBossLevel(): boolean; + isBossDown(): boolean; + // 多本體(§68):最近存活本體歸屬由 GameScene 持有。 + nearestBossBody(x: number, y: number): Phaser.Physics.Arcade.Sprite; +} + +export interface StarSteering { + // 逐幀導向(gameplay 塊內):追電星 → 魔王準星輔助 → 磁場吸偏,順序凍結。 + update(deltaMs: number): void; + // 拖尾附掛(每幀尾端無條件):active 星彈補掛、失效星彈停噴。 + syncTrails(): void; +} + +export function createStarSteering(hooks: StarSteeringHooks): StarSteering { + // 魔王房準星輔助(§54 難度根修):一般星彈對活動中魔王微幅導向——地面水平彈 + // 自然上彎入盤旋帶,保底線不依賴拍翅精度;轉率遠低於追電星、大致對向才生效。 + // 多本體(§68):導向星彈最近的存活本體。 + function steerBossAimAssist(deltaMs: number): void { + if (!hooks.isBossLevel() || !hooks.boss().isActive() || hooks.isBossDown()) return; + for (const child of hooks.player().getStars().getChildren()) { + const star = asSprite(child); + if (!star.active) continue; + // 追電星有自己的導向;迴旋星彈道由回程驅動,皆不疊加輔助。 + if (hooks.combat().mixOf(star)?.homing || hooks.combat().specOf(star).boomerang) continue; + const body = hooks.nearestBossBody(star.x, star.y); + const starBody = star.body as Phaser.Physics.Arcade.Body; + const towardBoss = Math.sign(body.x - star.x); + if (towardBoss !== 0 && Math.sign(starBody.velocity.x) !== towardBoss) continue; + if (distanceBetween(star.x, star.y, body.x, body.y) > BOSS_AIM_ASSIST.rangePx) { + continue; + } + const steered = steerTowardTarget( + starBody.velocity.x, + starBody.velocity.y, + star.x, + star.y, + body.x, + body.y, + hooks.combat().specOf(star).speed, + BOSS_AIM_ASSIST.turnRadPerMs * deltaMs, + ); + starBody.setVelocity(steered.vx, steered.vy); + } + } + + // 追電星導引(§46):飛行中朝最近小怪限速轉向。 + function steerHomingStars(deltaMs: number): void { + for (const child of hooks.player().getStars().getChildren()) { + const star = asSprite(child); + if (!star.active) continue; + const mix = hooks.combat().mixOf(star); + if (!mix?.homing) continue; + const candidates: { x: number; y: number }[] = []; + for (const candidate of hooks.enemies().getGroup().getChildren()) { + if (!candidate.active) continue; + const enemy = asSprite(candidate); + candidates.push({ x: enemy.x, y: enemy.y }); + } + const nearest = nearestInRange(star.x, star.y, candidates, HOMING_RANGE_PX); + if (!nearest) continue; + const body = star.body as Phaser.Physics.Arcade.Body; + const steered = steerTowardTarget( + body.velocity.x, + body.velocity.y, + star.x, + star.y, + nearest.x, + nearest.y, + mix.speed, + HOMING_TURN_RAD_PER_MS * deltaMs, + ); + body.setVelocity(steered.vx, steered.vy); + } + } + + // 磁場吸偏(§59 magno field):域內玩家星彈逐幀被拉向磁極獸;數學下沉 logic/enemyFsm。 + function steerMagnetizedStars(deltaMs: number): void { + const magnos: { x: number; y: number }[] = []; + for (const child of hooks.enemies().getGroup().getChildren()) { + if (!child.active || hooks.enemies().kindOf(child) !== 'magno') continue; + if (child.getData('magnoPhase') !== 'field') continue; + const magno = asSprite(child); + magnos.push({ x: magno.x, y: magno.y }); + } + if (magnos.length === 0) return; + for (const child of hooks.player().getStars().getChildren()) { + const star = asSprite(child); + if (!star.active) continue; + const body = star.body as Phaser.Physics.Arcade.Body; + for (const magno of magnos) { + const pulled = magnetPull( + star.x, + star.y, + body.velocity.x, + body.velocity.y, + magno.x, + magno.y, + deltaMs, + ); + body.setVelocity(pulled.vx, pulled.vy); + } + } + } + + return { + update(deltaMs: number) { + steerHomingStars(deltaMs); + steerBossAimAssist(deltaMs); + steerMagnetizedStars(deltaMs); + }, + syncTrails() { + for (const child of hooks.player().getStars().getChildren()) { + const star = asSprite(child); + const trail = star.getData('fxTrail') as TrailHandle | undefined; + if (star.active && !trail) star.setData('fxTrail', hooks.fx().attachTrail(star)); + else if (!star.active && trail) { + trail.stop(); + star.setData('fxTrail', undefined); + } + } + }, + }; +} diff --git a/apps/starpuff/src/game/systems/toasts.test.ts b/apps/starpuff/src/game/systems/toasts.test.ts new file mode 100644 index 000000000..9b442fcb7 --- /dev/null +++ b/apps/starpuff/src/game/systems/toasts.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { createToasts, type ToastHooks } from './toasts'; +import type { FxSystem } from './fx'; + +// characterization(v17 債務列車審查補遺):鎖住自 GameScene 抽出的成就 toast +// 佇列現行為——同批合併單張、跨批序列播放、觀測點文案;tween 呈現由 e2e 覆蓋。 + +vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); + +interface TweenCapture { + onComplete?: () => void; +} + +function makeHarness(): { + toasts: ReturnType; + chains: TweenCapture[]; + texts: { destroy: ReturnType }[]; + starBurst: ReturnType; +} { + const chains: TweenCapture[] = []; + const texts: { destroy: ReturnType }[] = []; + const starBurst = vi.fn(); + const chainable = (): Record => { + const target: Record = { destroy: vi.fn() }; + for (const key of [ + 'setOrigin', + 'setDepth', + 'setScrollFactor', + 'setAlpha', + 'setDisplaySize', + 'setTint', + ]) { + target[key] = vi.fn(() => target); + } + texts.push(target as { destroy: ReturnType }); + return target; + }; + const scene = { + add: { text: chainable, image: chainable }, + scale: { width: 854, height: 480 }, + cameras: { main: { worldView: { x: 0, right: 854 } } }, + tweens: { + chain: vi.fn((config: TweenCapture) => chains.push(config)), + add: vi.fn(), + }, + } as unknown as Phaser.Scene; + const hooks: ToastHooks = { + fx: () => ({ starBurst }) as unknown as FxSystem, + playerPos: () => ({ x: 400, y: 300 }), + }; + return { toasts: createToasts(scene, hooks), chains, texts, starBurst }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('成就 toast 佇列(§94)', () => { + it('單批入列即播、觀測點回報最近文案', () => { + const { toasts, chains } = makeHarness(); + expect(toasts.lastAchievementToast()).toBe(''); + toasts.queueAchievements('初戰告捷'); + expect(chains.length).toBe(1); + expect(toasts.lastAchievementToast()).toBe('初戰告捷'); + }); + + it('播放中再入列不重疊;播畢自動接續下一張(跨批序列)', () => { + const { toasts, chains } = makeHarness(); + toasts.queueAchievements('第一批'); + toasts.queueAchievements('第二批'); + // 第一張播放中:第二張等待、觀測點仍為第一張。 + expect(chains.length).toBe(1); + expect(toasts.lastAchievementToast()).toBe('第一批'); + // 第一張播畢(onComplete):自動 drain 第二張。 + chains[0]?.onComplete?.(); + expect(chains.length).toBe(2); + expect(toasts.lastAchievementToast()).toBe('第二批'); + // 全部播畢不再有殘留播放。 + chains[1]?.onComplete?.(); + expect(chains.length).toBe(2); + }); +}); + +describe('彩蛋慶祝與星味提示(§24/§46)', () => { + it('celebrate:星爆錨定玩家位置上方、浮字與金光經 tween 播放', () => { + const { toasts, starBurst } = makeHarness(); + toasts.celebrate('彩虹果凍 +1 HP'); + expect(starBurst).toHaveBeenCalledWith(400, 280); + }); + + it('celebrate 浮字座標夾限於鏡頭視野內(左緣 +110)', () => { + const starBurst = vi.fn(); + const hooks = { + fx: () => ({ starBurst }) as unknown as FxSystem, + playerPos: () => ({ x: 5, y: 300 }), + }; + const scene = { + add: { + text: () => makeChain(), + image: () => makeChain(), + }, + scale: { width: 854, height: 480 }, + cameras: { main: { worldView: { x: 0, right: 854 } } }, + tweens: { chain: vi.fn(), add: vi.fn() }, + } as unknown as Phaser.Scene; + const clamped = createToasts(scene, hooks); + clamped.celebrate('測試'); + expect(starBurst).toHaveBeenCalledWith(110, 280); + }); + + it('flavor:入場即播、不進成就佇列、不影響觀測點', () => { + const { toasts, chains } = makeHarness(); + toasts.flavor('疾風星!穿透 2 隻'); + expect(chains.length).toBe(1); + expect(toasts.lastAchievementToast()).toBe(''); + }); +}); + +function makeChain(): Record { + const target: Record = { destroy: vi.fn(), scale: 1 }; + for (const key of [ + 'setOrigin', + 'setDepth', + 'setScrollFactor', + 'setAlpha', + 'setDisplaySize', + 'setTint', + ]) { + target[key] = vi.fn(() => target); + } + return target; +} diff --git a/apps/starpuff/src/game/systems/toasts.ts b/apps/starpuff/src/game/systems/toasts.ts new file mode 100644 index 000000000..bd5364c95 --- /dev/null +++ b/apps/starpuff/src/game/systems/toasts.ts @@ -0,0 +1,150 @@ +import type Phaser from 'phaser'; +import type { FxSystem } from './fx'; +import { playSfx } from '../audio/sfx'; + +// 場內浮字與慶祝演出(GAME_DESIGN §24/§46/§94):成就 toast 佇列、星味首遇 +// 提示與彩蛋慶祝集中於此;GameScene 只留佇列委派與 e2e 觀測轉發。 + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +export interface ToastHooks { + fx(): FxSystem; + playerPos(): { x: number; y: number }; +} + +export interface ToastSystem { + // 星味首遇 toast(§46/§47):頂部橫幅下方一行小字,淡入停留後上飄淡出。 + flavor(message: string): void; + // 成就 toast 佇列(§94):同批合併單張、跨批序列播放。 + queueAchievements(names: string): void; + // e2e 觀測點(§94):最近一張成就 toast 文案(canvas 文字無法由 DOM 斷言)。 + lastAchievementToast(): string; + // 彩蛋慶祝(§24):金光 popIn + 專屬 jingle + 浮字(既有 fx 組合)。 + celebrate(message: string): void; +} + +export function createToasts(scene: Phaser.Scene, hooks: ToastHooks): ToastSystem { + const achievementQueue: string[] = []; + let achievementActive = false; + let lastAchievement = ''; + + // 成就 toast 佇列(§94):一次一張序列播放(跨批不重疊);轉場即隨場景銷毀, + // 漏播由 Result 名單與圖鑑成就頁兜底。金色橫幅帶深色底襯(勝利白閃下仍可讀), + // 禁全屏遮罩。 + function drainAchievements(): void { + if (achievementActive) return; + const names = achievementQueue.shift(); + if (names === undefined) return; + achievementActive = true; + lastAchievement = names; + playSfx('pop'); + const toast = scene.add + .text(scene.scale.width / 2, scene.scale.height * 0.3, `成就解鎖:${names}`, { + fontFamily: 'system-ui, sans-serif', + fontSize: '19px', + fontStyle: 'bold', + color: '#ffe9a8', + stroke: '#8a6a1f', + strokeThickness: 4, + backgroundColor: 'rgba(58, 42, 20, 0.82)', + padding: { x: 14, y: 7 }, + align: 'center', + // CJK 逐字換行:多重解鎖合併名單在 854 寬不溢出。 + wordWrap: { width: Math.min(scene.scale.width - 160, 700), useAdvancedWrap: true }, + }) + .setOrigin(0.5) + .setDepth(110) + .setScrollFactor(0) + .setAlpha(0); + scene.tweens.chain({ + targets: toast, + tweens: [ + { alpha: 1, duration: 220, ease: 'Quad.easeOut' }, + { alpha: 0, y: '-=16', duration: 360, delay: 1500, ease: 'Quad.easeIn' }, + ], + onComplete: () => { + toast.destroy(); + achievementActive = false; + drainAchievements(); + }, + }); + } + + return { + flavor(message: string) { + const toast = scene.add + .text(scene.scale.width / 2, scene.scale.height * 0.22, message, { + fontFamily: 'system-ui, sans-serif', + fontSize: '19px', + fontStyle: 'bold', + color: '#ffffff', + stroke: '#7a5fb8', + strokeThickness: 4, + }) + .setOrigin(0.5) + .setDepth(110) + .setScrollFactor(0) + .setAlpha(0); + scene.tweens.chain({ + targets: toast, + tweens: [ + { alpha: 1, duration: 220, ease: 'Quad.easeOut' }, + { alpha: 0, y: '-=16', duration: 360, delay: 1600, ease: 'Quad.easeIn' }, + ], + onComplete: () => toast.destroy(), + }); + }, + + queueAchievements(names: string) { + achievementQueue.push(names); + drainAchievements(); + }, + + lastAchievementToast() { + return lastAchievement; + }, + + celebrate(message: string) { + playSfx('jingle'); + const { x: playerX, y } = hooks.playerPos(); + // 浮字夾限於鏡頭視野內,避免世界邊緣觸發時被裁切。 + const view = scene.cameras.main.worldView; + const x = clamp(playerX, view.x + 110, view.right - 110); + const glow = scene.add + .image(x, y, 'fx-star') + .setDisplaySize(130, 130) + .setTint(0xffc93c) + .setAlpha(0) + .setDepth(94); + scene.tweens.add({ + targets: glow, + alpha: { from: 0.9, to: 0 }, + scale: { from: glow.scale * 0.3, to: glow.scale * 1.7 }, + duration: 750, + ease: 'Quad.easeOut', + onComplete: () => glow.destroy(), + }); + hooks.fx().starBurst(x, y - 20); + const label = scene.add + .text(x, y - 64, message, { + fontFamily: 'system-ui, sans-serif', + fontSize: '24px', + fontStyle: 'bold', + color: '#ffc93c', + stroke: '#3a3a4a', + strokeThickness: 5, + }) + .setOrigin(0.5) + .setDepth(96); + scene.tweens.add({ + targets: label, + y: y - 118, + alpha: { from: 1, to: 0 }, + duration: 1200, + ease: 'Cubic.easeOut', + onComplete: () => label.destroy(), + }); + }, + }; +} diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index aacfd8f96..07081e39d 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+154 +> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+155 ## 新增模板(4 行) @@ -1993,3 +1993,7 @@ - ID:ratewise-lhci-homepage-runtime-refresh-guard - 原因:PR #350 的 Lighthouse CI 在首頁 `/` 掉到 `0.87~0.88`,根因為 LHCI 離線模式下首頁仍執行 runtime 匯率 refresh,失敗後插入 stale warning 與額外背景工作,拉低首屏效能。 - 解法:在 `exchangeRateService` 與 `useExchangeRates` 增加 `VITE_LHCI_OFFLINE` 穩定分支,LHCI 直接使用 build-time 匯率並跳過 runtime refresh / polling,另補 service 與 hook 回歸測試鎖住行為。 +- 日期:2026-07-20 +- ID:reward-starpuff-v17-gamescene-strangler-debt-train +- 原因:GameScene 2121 行單檔承載魔王工廠/星彈結算/碰撞接線/彩蛋/toast 等八類職責,三席維護性評分 5/7/8 觸發 Blocking;events 契約與 waves 序列零測試。 +- 解法:六單元 strangler 拆解(bossFactory/starCombat/starSteering/overlaps/eggTracker/toasts,每步 characterization 先行、獨立 commit 可 revert)收斂至 1196 行,補 GameEvents 契約測試與 apps/starpuff max-lines error 1200 守門,vitest 580→650、Playwright 92 全綠零行為變更。 diff --git a/eslint.config.js b/eslint.config.js index 8689a9e4b..8c54aead5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -209,6 +209,16 @@ export default tseslint.config( }, }, + // starpuff 檔案行數守門(v17 債務列車):GameScene 拆解後鎖 1200 行硬閘防再膨脹。 + // levels.ts 為資料驅動關卡表(純資料隨內容成長),不受行數閘限制。 + { + files: ['apps/starpuff/src/**/*.ts'], + ignores: ['apps/starpuff/src/game/logic/levels.ts'], + rules: { + 'max-lines': ['error', { max: 1200, skipBlankLines: false, skipComments: false }], + }, + }, + // Cloudflare Workers 全域變數(security-headers worker) { files: ['security-headers/src/**/*.js'],