Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/starpuff-v17-debt-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/starpuff': patch
---

內部維護性重構:GameScene 拆解為六個獨立系統模組並補齊事件契約測試與檔案行數守門,玩法、數值、視覺與存檔行為完全不變。
44 changes: 40 additions & 4 deletions apps/starpuff/docs/GAME_DESIGN.md
Original file line number Diff line number Diff line change
@@ -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. 產品定位

Expand Down Expand Up @@ -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 精英房)
Expand All @@ -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)保證無害
且恆有利玩家,屬既有設計定案,不加鎖存。
2 changes: 1 addition & 1 deletion apps/starpuff/e2e/v6.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function tapFire(page: Page): Promise<void> {
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());
Expand Down
237 changes: 237 additions & 0 deletions apps/starpuff/src/game/core/events.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ((...args: unknown[]) => 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<typeof createBossFsm>,
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();
});
});
Loading
Loading