From 84c1f9d5591a96f0cc27472f672477f261fcd0e1 Mon Sep 17 00:00:00 2001 From: haotool Date: Mon, 27 Jul 2026 22:21:58 +0800 Subject: [PATCH 01/10] =?UTF-8?q?chore(starpuff):=20W2=20=E5=88=97?= =?UTF-8?q?=E8=BB=8A=E9=96=8B=E5=B7=A5=E6=B4=BB=E6=80=A7=E9=8C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 星海終局篇 W2:L22 Tariffang 與 L24 Maridella 魔王實作開工 - 基準 origin/main 8da984833(v0.25.3) 測試:無(空 commit 活性錨) Co-authored-by: Cursor From a48f55052577d8c45499f120d82fcbfe36cbc1f6 Mon Sep 17 00:00:00 2001 From: haotool Date: Mon, 27 Jul 2026 23:01:15 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(starpuff):=20=E6=96=B0=E5=A2=9E=20Ta?= =?UTF-8?q?riffang=20=E8=88=87=20Maridella=20=E9=AD=94=E7=8E=8B=20FSM=20?= =?UTF-8?q?=E7=B4=94=E9=82=8F=E8=BC=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BossKind 擴充 tariffang/maridella 兩品種(§122 星海終局篇 W2) - tariffangFsm:三段(貨物稽查/加收費用/全面封關)+P2 被動加收 tryTax 節流 - maridellaFsm:三段(潮線改道/海嘯階梯/深海月蝕)+潮線交替出向+雷化斷召 - 兩王沿 moveTable 加權選招(rng 注入可重放、連續同招上限 2、條件距離帶) - telegraph 全數 ≥600ms;EX 差分走 EX_MODS 共用倍率+質性差分 測試:vitest tariffangFsm/maridellaFsm 32 案全綠(工作進行中提交,全套閘門於收尾前執行) Co-authored-by: Cursor --- apps/starpuff/src/game/core/types.ts | 10 +- .../src/game/logic/maridellaFsm.test.ts | 218 +++++++++++++ apps/starpuff/src/game/logic/maridellaFsm.ts | 284 +++++++++++++++++ .../src/game/logic/tariffangFsm.test.ts | 216 +++++++++++++ apps/starpuff/src/game/logic/tariffangFsm.ts | 291 ++++++++++++++++++ 5 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 apps/starpuff/src/game/logic/maridellaFsm.test.ts create mode 100644 apps/starpuff/src/game/logic/maridellaFsm.ts create mode 100644 apps/starpuff/src/game/logic/tariffangFsm.test.ts create mode 100644 apps/starpuff/src/game/logic/tariffangFsm.ts diff --git a/apps/starpuff/src/game/core/types.ts b/apps/starpuff/src/game/core/types.ts index 206e5a7bd..ab5226a8b 100644 --- a/apps/starpuff/src/game/core/types.ts +++ b/apps/starpuff/src/game/core/types.ts @@ -61,7 +61,15 @@ export type LevelId = // v8 雙魔王(§54):關卡資料以 BossKind 指定魔王品種,null 為走動關。 // v10(§68):新增分裂型稜晶雙子 prismix。v11(§74):場控型 Syrona。 // v12(§82):場控收束型最終魔王 Voidra。 -export type BossKind = 'jellord' | 'noctra' | 'prismix' | 'syrona' | 'voidra'; +// §122 星海終局篇 W2:關稅巨獸 Tariffang(L22)與潮汐女王 Maridella(L24)。 +export type BossKind = + | 'jellord' + | 'noctra' + | 'prismix' + | 'syrona' + | 'voidra' + | 'tariffang' + | 'maridella'; // v9 星化三形態(§57):雷化/風化/殼化;規格表由 logic/transform.ts 持有。 // §119 星海終局篇:新增焰化/潮化/稜化/引力化(引入關見 FORM_INTRO_LEVEL)。 diff --git a/apps/starpuff/src/game/logic/maridellaFsm.test.ts b/apps/starpuff/src/game/logic/maridellaFsm.test.ts new file mode 100644 index 000000000..209aed6b2 --- /dev/null +++ b/apps/starpuff/src/game/logic/maridellaFsm.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; +import { PLAYER } from '../core/config'; +import { EX_MODS } from './bossFsm'; +import { AUDIT_THRESHOLDS, sequenceEntropyBits } from './difficulty'; +import { createSeededRng } from './moveTable'; +import { + EX_MARIDELLA, + MARIDELLA, + createMaridellaFsm, + maridellaMoveTable, + type MaridellaCommand, +} from './maridellaFsm'; + +// 潮汐女王 Maridella FSM(GAME_DESIGN §122):懸浮場控三階段——P1 潮線改道、 +// P2 海嘯階梯(缺口牆)、P3 深海月蝕(三水球依序環爆);hit window = idle 僵直。 + +const driveTo = ( + fsm: ReturnType, + kind: MaridellaCommand['kind'], +): MaridellaCommand | null => { + for (let i = 0; i < 5000; i += 1) { + const command = fsm.tick(50); + if (command?.kind === kind) return command; + } + return null; +}; + +const collectAttacks = (fsm: ReturnType, count: number) => { + const commands: MaridellaCommand[] = []; + for (let i = 0; i < 8000 && commands.length < count; i += 1) { + const command = fsm.tick(50); + if (command && command.kind !== 'idle') commands.push(command); + } + return commands; +}; + +describe('Maridella 三階段加權表(§122)', () => { + it('HP 116 接續終局章階梯(>112);三階段招池對表', () => { + const fsm = createMaridellaFsm(); + expect(fsm.maxHp).toBe(116); + expect(MARIDELLA.maxHp).toBeGreaterThan(112); + expect(maridellaMoveTable('p1').map((m) => m.action)).toEqual(['current', 'droplet']); + expect(maridellaMoveTable('p2').map((m) => m.action)).toEqual([ + 'wave', + 'droplet', + 'summon', + 'current', + ]); + expect(maridellaMoveTable('p3').map((m) => m.action)).toEqual(['moonorb', 'wave', 'current']); + }); + + it('水滴限遠距帶(§111.1 條件欄:貼身拋物不可讀);近距帶只出潮線', () => { + const droplet = maridellaMoveTable('p1').find((m) => m.action === 'droplet'); + expect(droplet?.condition).toEqual({ band: 'far' }); + const fsm = createMaridellaFsm({ rng: createSeededRng(1) }); + fsm.setTargetDistance(120); + const kinds = collectAttacks(fsm, 8).map((c) => c.kind); + expect(kinds).not.toContain('droplet'); + const farFsm = createMaridellaFsm({ rng: createSeededRng(1) }); + const farKinds = collectAttacks(farFsm, 12).map((c) => c.kind); + expect(farKinds).toContain('droplet'); + }); + + it('潮線改道交替出向(可讀性):連續兩次 current 方向必翻轉', () => { + const fsm = createMaridellaFsm({ rng: createSeededRng(2) }); + const dirs: number[] = []; + for (let i = 0; i < 8000 && dirs.length < 4; i += 1) { + const command = fsm.tick(50); + if (command?.kind === 'current') dirs.push(command.dir); + } + expect(dirs.length).toBe(4); + for (let i = 1; i < dirs.length; i += 1) { + expect(dirs[i]).toBe((dirs[i - 1] ?? 1) * -1); + } + }); + + it('傷害驅動 P1→P2(≤66%)與 P2→P3(≤33%);phase 事件依序帶出', () => { + const fsm = createMaridellaFsm(); + let events = fsm.takeDamage(40); + expect(fsm.phase).toBe('p2'); + expect(events.some((e) => e.kind === 'phase' && e.phase === 'p2')).toBe(true); + events = fsm.takeDamage(39); + expect(fsm.phase).toBe('p3'); + expect(events.some((e) => e.kind === 'phase' && e.phase === 'p3')).toBe(true); + }); + + it('P2 海嘯牆:起浪側與缺口位置同 seed 可重放;召喚帶上限 2', () => { + const run = (seed: number) => { + const fsm = createMaridellaFsm({ rng: createSeededRng(seed) }); + fsm.takeDamage(40); + const waves: { fromLeft: boolean; gapLow: boolean }[] = []; + for (let i = 0; i < 8000 && waves.length < 5; i += 1) { + const command = fsm.tick(50); + if (command?.kind === 'wave') { + waves.push({ fromLeft: command.fromLeft, gapLow: command.gapLow }); + } + } + return waves; + }; + expect(run(7)).toEqual(run(7)); + const fsm = createMaridellaFsm({ rng: createSeededRng(3) }); + fsm.takeDamage(40); + const summon = driveTo(fsm, 'summon'); + expect(summon).not.toBeNull(); + if (summon?.kind === 'summon') expect(summon.cap).toBe(MARIDELLA.summonCap); + }); + + it('P3 月蝕:水球 ×3 依 count 帶出;環爆彈數 SSOT 有界', () => { + const fsm = createMaridellaFsm({ rng: createSeededRng(4) }); + fsm.takeDamage(79); + expect(fsm.phase).toBe('p3'); + const moonorb = driveTo(fsm, 'moonorb'); + expect(moonorb).not.toBeNull(); + if (moonorb?.kind === 'moonorb') expect(moonorb.count).toBe(MARIDELLA.moonorbCount); + expect(MARIDELLA.moonorbRingShots).toBeLessThanOrEqual(10); + }); + + it('擊破:hp 歸零鎖存 defeated,之後 tick 無指令且再受擊無事件(冪等)', () => { + const fsm = createMaridellaFsm(); + const events = fsm.takeDamage(116); + expect(events.some((e) => e.kind === 'defeated')).toBe(true); + expect(fsm.defeated).toBe(true); + expect(fsm.tick(5000)).toBeNull(); + expect(fsm.takeDamage(10)).toEqual([]); + }); + + it('供彈保證律(§26):每損 10 HP 掉補給小怪事件', () => { + const fsm = createMaridellaFsm(); + const events = fsm.takeDamage(25); + expect(events.filter((e) => e.kind === 'minionDrop').length).toBe(2); + }); + + it('狂暴節奏:P2/P3 speedFactor ×1.15;僵直窗固定不縮短', () => { + const fsm = createMaridellaFsm(); + expect(fsm.speedFactor).toBe(1); + fsm.takeDamage(40); + expect(fsm.speedFactor).toBeCloseTo(MARIDELLA.enrageSpeedMultiplier, 5); + let elapsed = 0; + let firstCommandAt = -1; + while (elapsed < 3000 && firstCommandAt < 0) { + const command = fsm.tick(50); + elapsed += 50; + if (command) firstCommandAt = elapsed; + } + expect(firstCommandAt).toBeGreaterThanOrEqual(MARIDELLA.idleMs.p2); + expect(firstCommandAt).toBeLessThanOrEqual(MARIDELLA.idleMs.p2 + 100); + }); + + it('雷化斷召(§58 慣例):召喚態可中斷回僵直,非召喚態不成立', () => { + const fsm = createMaridellaFsm({ rng: createSeededRng(5) }); + expect(fsm.interruptSummon()).toBe(false); + fsm.takeDamage(40); + const summon = driveTo(fsm, 'summon'); + expect(summon).not.toBeNull(); + expect(fsm.state).toBe('summon'); + expect(fsm.interruptSummon()).toBe(true); + expect(fsm.state).toBe('idle'); + }); +}); + +describe('加權選招治理(§111.1 去背板)', () => { + it('同 seed 可完整重放;連續同招上限 2', () => { + const run = (seed: number): string[] => { + const fsm = createMaridellaFsm({ rng: createSeededRng(seed) }); + return collectAttacks(fsm, 16).map((c) => c.kind); + }; + expect(run(9)).toEqual(run(9)); + const kinds = run(6); + for (let i = 2; i < kinds.length; i += 1) { + expect(new Set(kinds.slice(i - 2, i + 1)).size).toBeGreaterThan(1); + } + }); + + it('招式序列條件熵 ≥ 門檻(#813;AUDIT_THRESHOLDS.moveEntropyMinBits 口徑)', () => { + const fsm = createMaridellaFsm({ rng: createSeededRng(13) }); + const kinds = collectAttacks(fsm, 60).map((c) => c.kind); + expect(kinds.length).toBeGreaterThanOrEqual(40); + expect(sequenceEntropyBits(kinds)).toBeGreaterThanOrEqual(AUDIT_THRESHOLDS.moveEntropyMinBits); + }); +}); + +describe('可讀性與 anti-softlock 紅線(§122)', () => { + it('全招式 telegraph ≥600ms(可讀性紅線)', () => { + for (const ms of [ + MARIDELLA.currentTelegraphMs, + MARIDELLA.dropletTelegraphMs, + MARIDELLA.waveTelegraphMs, + MARIDELLA.moonorbTelegraphMs, + ]) { + expect(ms).toBeGreaterThanOrEqual(600); + } + }); + + it('潮線推移恆低於玩家全速(交叉不變式 16:不與速度控制器對抗)', () => { + expect(MARIDELLA.currentPushPxPerSec).toBeLessThan(PLAYER.moveSpeed); + expect(MARIDELLA.currentHoldMs).toBeLessThanOrEqual(5000); + }); + + it('海嘯牆缺口恆容玩家本體(48px+裕度);EX 缺口不縮(只縮體不縮窗同源)', () => { + expect(MARIDELLA.waveGapPx).toBeGreaterThanOrEqual(48 + 40); + // EX 差分表無缺口欄位=缺口恆定(質性差分只加球數/召喚/水滴)。 + expect(Object.keys(EX_MARIDELLA)).toEqual(['summonCap', 'moonorbCount', 'dropletCount']); + }); + + it('EX 差分:HP ×1.5(174)、水球 ×4、召喚上限 3、水滴 ×5', () => { + const fsm = createMaridellaFsm({ ex: true, rng: createSeededRng(8) }); + expect(fsm.maxHp).toBe(Math.round(MARIDELLA.maxHp * EX_MODS.hpMul)); + fsm.takeDamage(118); + expect(fsm.phase).toBe('p3'); + const moonorb = driveTo(fsm, 'moonorb'); + if (moonorb?.kind === 'moonorb') expect(moonorb.count).toBe(EX_MARIDELLA.moonorbCount); + const summonFsm = createMaridellaFsm({ ex: true, rng: createSeededRng(3) }); + summonFsm.takeDamage(60); + expect(summonFsm.phase).toBe('p2'); + const summon = driveTo(summonFsm, 'summon'); + if (summon?.kind === 'summon') expect(summon.cap).toBe(EX_MARIDELLA.summonCap); + }); +}); diff --git a/apps/starpuff/src/game/logic/maridellaFsm.ts b/apps/starpuff/src/game/logic/maridellaFsm.ts new file mode 100644 index 000000000..23c2a62ec --- /dev/null +++ b/apps/starpuff/src/game/logic/maridellaFsm.ts @@ -0,0 +1,284 @@ +import type { BossPhase } from '../core/types'; +import { EX_MODS } from './bossFsm'; +import { distanceBandOf, pickMove, type WeightedMove } from './moveTable'; + +// 潮汐女王 Maridella FSM 純邏輯(GAME_DESIGN §122,不 import phaser),vitest 對象。 +// 懸浮場控三段(潮灣主題):P1 潮線改道(地面水流交替推移+水滴彈幕)→ +// P2 海嘯階梯(波浪牆留高低缺口+潮湧召喚)→ P3 深海月蝕(暗場+三水球依序環爆)。 +// phase truth 全數收斂於此,禁止散落 scene(沿 syronaFsm 慣例)。 + +export const MARIDELLA = { + // 魔王 HP 階梯(終局章遞增):Tariffang 112 → Maridella 116。 + maxHp: 116, + bodyDamage: 1, + // 階段轉換閾值:P2 ≤66%、P3 ≤33%。 + p2HpRatio: 0.66, + p3HpRatio: 0.33, + enrageSpeedMultiplier: 1.15, + // 補給節奏(§26 飢荒保證律):每損 10 HP 掉補給小怪。 + minionSpawnHpStep: 10, + // P2 潮湧召喚 foamy 上限(場上同時存活數,超額由呈現層夾限); + // 泡泡上浮兼具渡牆輔助——召喚同時是威脅與資源(孢子味=潮化供給)。 + summonCap: 2, + // 招式時長(僵直窗獨立為 idleMs=固定輸出窗)。 + currentDurationMs: 4200, + dropletDurationMs: 1600, + waveDurationMs: 2400, + summonDurationMs: 900, + moonorbDurationMs: 3600, + // p4 為 EX 專屬型態鍵位(#814 基建):Maridella 無 P4,執行期不可達、沿 p3 值。 + idleMs: { p1: 2200, p2: 1900, p3: 1900, p4: 1900 }, + // telegraph 時長(呈現層讀取;固定不隨狂暴縮放,≥600ms 可讀性紅線)。 + currentTelegraphMs: 900, + dropletTelegraphMs: 650, + waveTelegraphMs: 900, + moonorbTelegraphMs: 600, + // 潮線改道:水流推移強度恆低於玩家全速(220,交叉不變式 16);潮化免推(§119 優勢)。 + currentPushPxPerSec: 90, + currentHoldMs: 4000, + // 水滴彈幕:P1 ×3、P2 起 ×4。 + dropletCountP1: 3, + dropletCountP2: 4, + // 海嘯階梯:波浪牆缺口高度(低缺口貼地可走、高缺口跳+拍翅可穿)—— + // 缺口 ≥100px 恆容玩家 48px 本體(anti-softlock:每道牆必留缺口)。 + waveGapPx: 102, + // 深海月蝕:水球數與每球爆裂放射彈數。 + moonorbCount: 3, + moonorbRingShots: 8, +} as const; + +// Maridella EX 專屬差分(§58 EX 慣例:HP/節奏走 EX_MODS 共用倍率): +// 質性差分=水球 3→4+召喚上限 3+水滴 ×5;缺口高度不縮(只縮體不縮窗紅線同源)。 +export const EX_MARIDELLA = { + summonCap: 3, + moonorbCount: 4, + dropletCount: 5, +} as const; + +export type MaridellaAction = 'idle' | 'current' | 'droplet' | 'wave' | 'summon' | 'moonorb'; + +// tick 輸出給呈現層的指令:telegraph 與演出由 systems/maridella.ts 承擔。 +// current.dir 為水流方向(交替出向,箭頭提前提示);wave.gapLow 為缺口位置 +//(低=貼地走廊、高=跳躍帶,rng 抽選同 seed 可重放)。 +export type MaridellaCommand = + | { kind: 'idle' } + | { kind: 'current'; dir: 1 | -1; holdMs: number } + | { kind: 'droplet'; count: number } + | { kind: 'wave'; fromLeft: boolean; gapLow: boolean } + | { kind: 'summon'; cap: number } + | { kind: 'moonorb'; count: number }; + +export type MaridellaHitEvent = + | { kind: 'damaged'; hp: number } + | { kind: 'phase'; phase: BossPhase } + | { kind: 'minionDrop' } + | { kind: 'defeated' }; + +const SPEED_FACTORS: Record = { + p1: 1, + p2: MARIDELLA.enrageSpeedMultiplier, + p3: MARIDELLA.enrageSpeedMultiplier, + p4: MARIDELLA.enrageSpeedMultiplier, +}; + +// 加權選招表(§111.1 moveTable SSOT):P1 潮線/水滴;P2 海嘯牆+召喚; +// P3 月蝕環爆為主軸(水滴限遠距帶——貼身拋物不可讀,沿 Syrona lob 理據)。 +export function maridellaMoveTable(phase: BossPhase): readonly WeightedMove[] { + switch (phase) { + case 'p1': + return [ + { action: 'current', weight: 3 }, + { action: 'droplet', weight: 3, condition: { band: 'far' } }, + ]; + case 'p2': + return [ + { action: 'wave', weight: 3 }, + { action: 'droplet', weight: 2, condition: { band: 'far' } }, + { action: 'summon', weight: 2 }, + { action: 'current', weight: 2 }, + ]; + // p4 執行期不可達(無 EX P4 型態):沿 p3 招池補鍵位。 + case 'p3': + case 'p4': + return [ + { action: 'moonorb', weight: 3 }, + { action: 'wave', weight: 3 }, + { action: 'current', weight: 2 }, + ]; + default: { + const unhandled: never = phase; + throw new Error(`未知階段:${String(unhandled)}`); + } + } +} + +export interface MaridellaFsm { + readonly hp: number; + readonly maxHp: number; + readonly phase: BossPhase; + readonly state: MaridellaAction; + readonly speedFactor: number; + readonly defeated: boolean; + tick(deltaMs: number): MaridellaCommand | null; + takeDamage(amount: number): MaridellaHitEvent[]; + // 雷化鏈電中斷召喚(§58 慣例):僅召喚態可中斷,成功回 true。 + interruptSummon(): boolean; + // 距離帶餵送(§111.1 條件欄):呈現層逐幀回報與玩家距離;未餵送視為 far。 + setTargetDistance(distancePx: number | null): void; +} + +export interface MaridellaFsmOptions { + ex?: boolean; + // 亂數注入(缺口抽選與加權選招共用;同 seed 可重放;缺省 Math.random)。 + rng?: () => number; +} + +export function createMaridellaFsm(options: MaridellaFsmOptions = {}): MaridellaFsm { + const ex = options.ex === true; + const rng = options.rng ?? Math.random; + const maxHp = Math.round(MARIDELLA.maxHp * (ex ? EX_MODS.hpMul : 1)); + const summonCap = ex ? EX_MARIDELLA.summonCap : MARIDELLA.summonCap; + const dropletCount = (phase: BossPhase): number => { + if (ex) return EX_MARIDELLA.dropletCount; + return phase === 'p1' ? MARIDELLA.dropletCountP1 : MARIDELLA.dropletCountP2; + }; + + let hp = maxHp; + let phase: BossPhase = 'p1'; + let state: MaridellaAction = 'idle'; + let recentAttacks: MaridellaAction[] = []; + let distancePx: number | null = null; + let timerMs: number = MARIDELLA.idleMs.p1; + let damageSinceDrop = 0; + // 潮線交替出向(可讀性):每次改道翻轉方向,箭頭 telegraph 先行。 + let currentDir: 1 | -1 = 1; + let defeated = false; + + const speedFactor = (): number => SPEED_FACTORS[phase] * (ex ? EX_MODS.speedMul : 1); + + const durationMs = (action: MaridellaAction): number => { + switch (action) { + // 僵直窗=固定輸出窗:不隨狂暴縮短(§74 hit window 慣例)。 + case 'idle': + return MARIDELLA.idleMs[phase]; + case 'current': + return MARIDELLA.currentDurationMs / speedFactor(); + case 'droplet': + return MARIDELLA.dropletDurationMs / speedFactor(); + case 'wave': + return MARIDELLA.waveDurationMs / speedFactor(); + case 'summon': + return MARIDELLA.summonDurationMs / speedFactor(); + case 'moonorb': + return MARIDELLA.moonorbDurationMs / speedFactor(); + default: { + const unhandled: never = action; + throw new Error(`未知招式:${String(unhandled)}`); + } + } + }; + + const commandOf = (action: MaridellaAction): MaridellaCommand => { + switch (action) { + case 'idle': + return { kind: 'idle' }; + case 'current': { + currentDir = currentDir === 1 ? -1 : 1; + return { kind: 'current', dir: currentDir, holdMs: MARIDELLA.currentHoldMs }; + } + case 'droplet': + return { kind: 'droplet', count: dropletCount(phase) }; + case 'wave': + // 起浪側與缺口位置由 rng 抽選(同 seed 可重放);缺口恆存(anti-softlock)。 + return { kind: 'wave', fromLeft: rng() < 0.5, gapLow: rng() < 0.5 }; + case 'summon': + return { kind: 'summon', cap: summonCap }; + case 'moonorb': + return { + kind: 'moonorb', + count: ex ? EX_MARIDELLA.moonorbCount : MARIDELLA.moonorbCount, + }; + default: { + const unhandled: never = action; + throw new Error(`未知招式:${String(unhandled)}`); + } + } + }; + + const enterPhase = (next: BossPhase, events: MaridellaHitEvent[]): void => { + phase = next; + state = 'idle'; + recentAttacks = []; + timerMs = durationMs('idle'); + events.push({ kind: 'phase', phase: next }); + }; + + return { + get hp() { + return hp; + }, + get maxHp() { + return maxHp; + }, + get phase() { + return phase; + }, + get state() { + return state; + }, + get speedFactor() { + return speedFactor(); + }, + get defeated() { + return defeated; + }, + tick(deltaMs: number): MaridellaCommand | null { + if (defeated) return null; + timerMs -= deltaMs; + if (timerMs > 0) return null; + if (state === 'idle') { + state = pickMove( + maridellaMoveTable(phase), + { hpRatio: hp / maxHp, distanceBand: distanceBandOf(distancePx) }, + recentAttacks, + rng, + ); + recentAttacks = [...recentAttacks.slice(-1), state]; + } else { + state = 'idle'; + } + // 保留溢出時間,維持節奏不漂移(同 bossFsm)。 + timerMs += durationMs(state); + return commandOf(state); + }, + takeDamage(amount: number): MaridellaHitEvent[] { + if (defeated || amount <= 0) return []; + const events: MaridellaHitEvent[] = []; + hp = Math.max(0, hp - amount); + events.push({ kind: 'damaged', hp }); + if (hp <= 0) { + defeated = true; + events.push({ kind: 'defeated' }); + return events; + } + damageSinceDrop += amount; + while (damageSinceDrop >= MARIDELLA.minionSpawnHpStep) { + damageSinceDrop -= MARIDELLA.minionSpawnHpStep; + events.push({ kind: 'minionDrop' }); + } + // 階段轉換:跨多閾值單次受擊依序帶出 phase 事件(p1→p2→p3 不跳段)。 + if (phase === 'p1' && hp <= maxHp * MARIDELLA.p2HpRatio) enterPhase('p2', events); + if (phase === 'p2' && hp <= maxHp * MARIDELLA.p3HpRatio) enterPhase('p3', events); + return events; + }, + interruptSummon(): boolean { + if (defeated || state !== 'summon') return false; + state = 'idle'; + timerMs = durationMs('idle'); + return true; + }, + setTargetDistance(next: number | null): void { + distancePx = next; + }, + }; +} diff --git a/apps/starpuff/src/game/logic/tariffangFsm.test.ts b/apps/starpuff/src/game/logic/tariffangFsm.test.ts new file mode 100644 index 000000000..3cb3ee034 --- /dev/null +++ b/apps/starpuff/src/game/logic/tariffangFsm.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { EX_MODS } from './bossFsm'; +import { AUDIT_THRESHOLDS, sequenceEntropyBits } from './difficulty'; +import { createSeededRng } from './moveTable'; +import { + EX_TARIFFANG, + TARIFFANG, + createTariffangFsm, + tariffangMoveTable, + type TariffangCommand, +} from './tariffangFsm'; + +// 關稅巨獸 Tariffang FSM(GAME_DESIGN §122):星港海關三階段——P1 貨物稽查、 +// P2 加收費用(命中即生追蹤稅票)、P3 全面封關(閘門+衝撞);hit window = idle 僵直。 + +const driveTo = ( + fsm: ReturnType, + kind: TariffangCommand['kind'], +): TariffangCommand | null => { + for (let i = 0; i < 5000; i += 1) { + const command = fsm.tick(50); + if (command?.kind === kind) return command; + } + return null; +}; + +const collectAttacks = (fsm: ReturnType, count: number) => { + const commands: TariffangCommand[] = []; + for (let i = 0; i < 8000 && commands.length < count; i += 1) { + const command = fsm.tick(50); + if (command && command.kind !== 'idle') commands.push(command); + } + return commands; +}; + +describe('Tariffang 三階段加權表(§122)', () => { + it('HP 112 接續終局章階梯(>110);三階段招池對表', () => { + const fsm = createTariffangFsm(); + expect(fsm.maxHp).toBe(112); + expect(TARIFFANG.maxHp).toBeGreaterThan(110); + expect(tariffangMoveTable('p1').map((m) => m.action)).toEqual(['cargo', 'stamp']); + expect(tariffangMoveTable('p2').map((m) => m.action)).toEqual(['cargo', 'stamp', 'levy']); + expect(tariffangMoveTable('p3').map((m) => m.action)).toEqual(['gate', 'ram', 'levy']); + }); + + it('衝撞限遠距帶(§111.1 條件欄:貼身衝撞不可讀);近距帶不出 ram', () => { + const ram = tariffangMoveTable('p3').find((m) => m.action === 'ram'); + expect(ram?.condition).toEqual({ band: 'far' }); + const fsm = createTariffangFsm({ rng: createSeededRng(1) }); + fsm.takeDamage(76); + expect(fsm.phase).toBe('p3'); + fsm.setTargetDistance(120); + const kinds = collectAttacks(fsm, 10).map((c) => c.kind); + expect(kinds).not.toContain('ram'); + // 未餵送距離視為 far:ram 可入池。 + const farFsm = createTariffangFsm({ rng: createSeededRng(1) }); + farFsm.takeDamage(76); + const farKinds = collectAttacks(farFsm, 14).map((c) => c.kind); + expect(farKinds).toContain('ram'); + }); + + it('P1 貨櫃單側 ×1、P2 起 ×2;一般模式恆單側(bothSides false 可背板)', () => { + const fsm = createTariffangFsm({ rng: createSeededRng(2) }); + const p1Cargo = driveTo(fsm, 'cargo'); + expect(p1Cargo?.kind).toBe('cargo'); + if (p1Cargo?.kind === 'cargo') { + expect(p1Cargo.count).toBe(TARIFFANG.cargoCountP1); + expect(p1Cargo.bothSides).toBe(false); + } + fsm.takeDamage(39); + expect(fsm.phase).toBe('p2'); + const p2Cargo = driveTo(fsm, 'cargo'); + if (p2Cargo?.kind === 'cargo') expect(p2Cargo.count).toBe(TARIFFANG.cargoCountP2); + }); + + it('傷害驅動 P1→P2(≤66%)與 P2→P3(≤33%);phase 事件依序帶出', () => { + const fsm = createTariffangFsm(); + let events = fsm.takeDamage(39); + expect(fsm.phase).toBe('p2'); + expect(events.some((e) => e.kind === 'phase' && e.phase === 'p2')).toBe(true); + events = fsm.takeDamage(37); + expect(fsm.phase).toBe('p3'); + expect(events.some((e) => e.kind === 'phase' && e.phase === 'p3')).toBe(true); + }); + + it('P3 封關指令帶駐留時長;查帳指令帶稅票數', () => { + const fsm = createTariffangFsm({ rng: createSeededRng(3) }); + fsm.takeDamage(76); + const gate = driveTo(fsm, 'gate'); + expect(gate).not.toBeNull(); + if (gate?.kind === 'gate') expect(gate.holdMs).toBe(TARIFFANG.gateHoldMs); + const levy = driveTo(fsm, 'levy'); + expect(levy).not.toBeNull(); + if (levy?.kind === 'levy') expect(levy.count).toBe(TARIFFANG.levyTicketCount); + }); + + it('擊破:hp 歸零鎖存 defeated,之後 tick 無指令且再受擊無事件(冪等)', () => { + const fsm = createTariffangFsm(); + const events = fsm.takeDamage(112); + expect(events.some((e) => e.kind === 'defeated')).toBe(true); + expect(fsm.defeated).toBe(true); + expect(fsm.tick(5000)).toBeNull(); + expect(fsm.takeDamage(10)).toEqual([]); + }); + + it('供彈保證律(§26):每損 10 HP 掉補給小怪事件', () => { + const fsm = createTariffangFsm(); + const events = fsm.takeDamage(25); + expect(events.filter((e) => e.kind === 'minionDrop').length).toBe(2); + }); + + it('狂暴節奏:P2/P3 speedFactor ×1.15;僵直窗固定不縮短', () => { + const fsm = createTariffangFsm(); + expect(fsm.speedFactor).toBe(1); + fsm.takeDamage(39); + expect(fsm.speedFactor).toBeCloseTo(TARIFFANG.enrageSpeedMultiplier, 5); + // 進 P2 後首個指令出現時間=idle 實長;狂暴若誤縮會落在 ≈1565ms。 + let elapsed = 0; + let firstCommandAt = -1; + while (elapsed < 3000 && firstCommandAt < 0) { + const command = fsm.tick(50); + elapsed += 50; + if (command) firstCommandAt = elapsed; + } + expect(firstCommandAt).toBeGreaterThanOrEqual(TARIFFANG.idleMs.p2); + expect(firstCommandAt).toBeLessThanOrEqual(TARIFFANG.idleMs.p2 + 100); + }); +}); + +describe('P2 被動加收(§122 主題機制:命中即生追蹤稅票)', () => { + it('P1/P3 不加收;P2 期加收且冷卻節流', () => { + const fsm = createTariffangFsm(); + expect(fsm.tryTax()).toBe(false); + fsm.takeDamage(39); + expect(fsm.phase).toBe('p2'); + expect(fsm.tryTax()).toBe(true); + // 冷卻內連續命中不重複加收(連射不刷屏)。 + expect(fsm.tryTax()).toBe(false); + fsm.tick(TARIFFANG.reactiveTicketCooldownMs + 50); + expect(fsm.tryTax()).toBe(true); + // P3 起被動加收停用(封關段主軸換閘門/衝撞)。 + fsm.takeDamage(37); + expect(fsm.phase).toBe('p3'); + fsm.tick(TARIFFANG.reactiveTicketCooldownMs + 50); + expect(fsm.tryTax()).toBe(false); + }); + + it('稅票 anti-softlock 紅線:壽命有界、場上上限有界、EX 上限 +2', () => { + expect(TARIFFANG.ticketLifeMs).toBeLessThanOrEqual(8000); + expect(TARIFFANG.ticketCap).toBeLessThanOrEqual(6); + expect(EX_TARIFFANG.ticketCap).toBe(TARIFFANG.ticketCap + 2); + const fsm = createTariffangFsm({ ex: true }); + expect(fsm.ticketCap).toBe(EX_TARIFFANG.ticketCap); + }); +}); + +describe('頭頂短暈(§58 hit window)', () => { + it('待機/出招期可暈(回待機停拍);衝撞中不可暈', () => { + const fsm = createTariffangFsm({ rng: createSeededRng(4) }); + expect(fsm.stun(TARIFFANG.slamStunMs)).toBe(true); + expect(fsm.state).toBe('idle'); + // 驅動至 ram 態:暈眩應被拒絕(高速位移期強行停拍會凍在半途)。 + fsm.takeDamage(76); + const ram = driveTo(fsm, 'ram'); + expect(ram).not.toBeNull(); + expect(fsm.state).toBe('ram'); + expect(fsm.stun(TARIFFANG.slamStunMs)).toBe(false); + }); +}); + +describe('加權選招治理(§111.1 去背板)', () => { + it('同 seed 可完整重放;連續同招上限 2', () => { + const run = (seed: number): string[] => { + const fsm = createTariffangFsm({ rng: createSeededRng(seed) }); + return collectAttacks(fsm, 16).map((c) => c.kind); + }; + expect(run(9)).toEqual(run(9)); + const kinds = run(6); + for (let i = 2; i < kinds.length; i += 1) { + expect(new Set(kinds.slice(i - 2, i + 1)).size).toBeGreaterThan(1); + } + }); + + it('招式序列條件熵 ≥ 門檻(#813;AUDIT_THRESHOLDS.moveEntropyMinBits 口徑)', () => { + const fsm = createTariffangFsm({ rng: createSeededRng(13) }); + const kinds = collectAttacks(fsm, 60).map((c) => c.kind); + expect(kinds.length).toBeGreaterThanOrEqual(40); + expect(sequenceEntropyBits(kinds)).toBeGreaterThanOrEqual(AUDIT_THRESHOLDS.moveEntropyMinBits); + }); +}); + +describe('可讀性與 EX 差分紅線', () => { + it('全招式 telegraph ≥600ms(可讀性紅線)', () => { + for (const ms of [ + TARIFFANG.cargoTelegraphMs, + TARIFFANG.stampTelegraphMs, + TARIFFANG.levyTelegraphMs, + TARIFFANG.gateTelegraphMs, + TARIFFANG.ramTelegraphMs, + ]) { + expect(ms).toBeGreaterThanOrEqual(600); + } + }); + + it('EX 差分:HP ×1.5(168)、貨櫃雙向同時、telegraph 不縮', () => { + const fsm = createTariffangFsm({ ex: true, rng: createSeededRng(8) }); + expect(fsm.maxHp).toBe(Math.round(TARIFFANG.maxHp * EX_MODS.hpMul)); + const cargo = driveTo(fsm, 'cargo'); + expect(cargo?.kind).toBe('cargo'); + if (cargo?.kind === 'cargo') expect(cargo.bothSides).toBe(true); + }); + + it('封關 anti-softlock:閘門駐留有界(≤2.5s,中央走廊恆開由呈現層幾何保證)', () => { + expect(TARIFFANG.gateHoldMs).toBeLessThanOrEqual(2500); + }); +}); diff --git a/apps/starpuff/src/game/logic/tariffangFsm.ts b/apps/starpuff/src/game/logic/tariffangFsm.ts new file mode 100644 index 000000000..20c8d0450 --- /dev/null +++ b/apps/starpuff/src/game/logic/tariffangFsm.ts @@ -0,0 +1,291 @@ +import type { BossPhase } from '../core/types'; +import { EX_MODS } from './bossFsm'; +import { distanceBandOf, pickMove, type WeightedMove } from './moveTable'; + +// 關稅巨獸 Tariffang FSM 純邏輯(GAME_DESIGN §122,不 import phaser),vitest 對象。 +// 地面關卡官三段(星港海關主題):P1 貨物稽查(貨櫃滑入+關稅槌)→ P2 加收費用 +//(命中魔王即生追蹤稅票;吸入清票/焰化燒票)→ P3 全面封關(雙側閘門+高速衝撞)。 +// phase truth 全數收斂於此,禁止散落 scene(沿 syronaFsm 慣例)。 + +export const TARIFFANG = { + // 魔王 HP 階梯(終局章接續 §82 峰值 110 遞增):Voidra 110 → Tariffang 112。 + maxHp: 112, + bodyDamage: 1, + // 階段轉換閾值:P2 ≤66%、P3 ≤33%。 + p2HpRatio: 0.66, + p3HpRatio: 0.33, + enrageSpeedMultiplier: 1.15, + // 補給節奏(§26 飢荒保證律):每損 10 HP 掉補給小怪。 + minionSpawnHpStep: 10, + // 招式時長(僵直窗獨立為 idleMs=固定輸出窗)。 + cargoDurationMs: 2600, + stampDurationMs: 1600, + levyDurationMs: 1400, + gateDurationMs: 3400, + ramDurationMs: 2000, + // p4 為 EX 專屬型態鍵位(#814 基建):Tariffang 無 P4,執行期不可達、沿 p3 值。 + idleMs: { p1: 2200, p2: 1800, p3: 1800, p4: 1800 }, + // telegraph 時長(呈現層讀取;固定不隨狂暴縮放,≥600ms 可讀性紅線)。 + cargoTelegraphMs: 800, + stampTelegraphMs: 700, + levyTelegraphMs: 600, + gateTelegraphMs: 900, + ramTelegraphMs: 700, + // 貨櫃稽查:P1 單側 ×1、P2 起 ×2(左右錯拍)。 + cargoCountP1: 1, + cargoCountP2: 2, + // 關稅槌落點數。 + stampCount: 3, + // 稅票:主動查帳 ×3;場上稅票上限(含 P2 被動加收);壽命逾時必回收(§56)。 + levyTicketCount: 3, + ticketCap: 4, + ticketLifeMs: 6000, + // P2 被動加收(本王主題):星彈命中即生成追蹤稅票,節流防連射刷屏。 + reactiveTicketCooldownMs: 900, + // 閘門封關:降下後駐留時長;中央走廊恆開(anti-softlock)。 + gateHoldMs: 1800, + // 頭頂 hit window(§58 慣例):下砸命中頭頂觸發短暈(攻擊循環停拍)。 + slamStunMs: 800, +} as const; + +// Tariffang EX 專屬差分(§58 EX 慣例:HP/節奏走 EX_MODS 共用倍率): +// 質性差分=貨櫃雙向同時滑入(一般恆單側錯拍可背板)+稅票上限 +2。 +export const EX_TARIFFANG = { + cargoBothSides: true, + ticketCap: 6, +} as const; + +export type TariffangAction = 'idle' | 'cargo' | 'stamp' | 'levy' | 'gate' | 'ram'; + +// tick 輸出給呈現層的指令:telegraph 與演出由 systems/tariffang.ts 承擔。 +export type TariffangCommand = + | { kind: 'idle' } + | { kind: 'cargo'; count: number; bothSides: boolean } + | { kind: 'stamp'; count: number } + | { kind: 'levy'; count: number } + | { kind: 'gate'; holdMs: number } + | { kind: 'ram' }; + +export type TariffangHitEvent = + | { kind: 'damaged'; hp: number } + | { kind: 'phase'; phase: BossPhase } + | { kind: 'minionDrop' } + | { kind: 'defeated' }; + +const SPEED_FACTORS: Record = { + p1: 1, + p2: TARIFFANG.enrageSpeedMultiplier, + p3: TARIFFANG.enrageSpeedMultiplier, + p4: TARIFFANG.enrageSpeedMultiplier, +}; + +// 加權選招表(§111.1 moveTable SSOT):P1 稽查/關稅槌;P2 加入主動查帳; +// P3 封關+衝撞(衝撞限遠距帶——貼身衝撞不可讀,沿 Jellord dash 遠距帶理據)。 +export function tariffangMoveTable(phase: BossPhase): readonly WeightedMove[] { + switch (phase) { + case 'p1': + return [ + { action: 'cargo', weight: 3 }, + { action: 'stamp', weight: 3 }, + ]; + case 'p2': + return [ + { action: 'cargo', weight: 3 }, + { action: 'stamp', weight: 2 }, + { action: 'levy', weight: 3 }, + ]; + // p4 執行期不可達(無 EX P4 型態):沿 p3 招池補鍵位。 + case 'p3': + case 'p4': + return [ + { action: 'gate', weight: 3 }, + { action: 'ram', weight: 3, condition: { band: 'far' } }, + { action: 'levy', weight: 2 }, + ]; + default: { + const unhandled: never = phase; + throw new Error(`未知階段:${String(unhandled)}`); + } + } +} + +export interface TariffangFsm { + readonly hp: number; + readonly maxHp: number; + readonly phase: BossPhase; + readonly state: TariffangAction; + readonly speedFactor: number; + readonly defeated: boolean; + readonly ticketCap: number; + tick(deltaMs: number): TariffangCommand | null; + takeDamage(amount: number): TariffangHitEvent[]; + // P2 被動加收(主題機制):星彈命中時呼叫——P2 期且冷卻期滿回 true(該生一張稅票)。 + // 場上稅票上限由呈現層以 ticketCap 夾限(存活數為呈現層事實)。 + tryTax(): boolean; + // 頭頂命中短暈(§58):回待機並停拍 durationMs,期滿接續攻擊循環;衝撞中不可暈。 + stun(durationMs: number): boolean; + // 距離帶餵送(§111.1 條件欄):呈現層逐幀回報與玩家距離;未餵送視為 far。 + setTargetDistance(distancePx: number | null): void; +} + +export interface TariffangFsmOptions { + ex?: boolean; + // 亂數注入(加權選招;同 seed 可重放;缺省 Math.random)。 + rng?: () => number; +} + +export function createTariffangFsm(options: TariffangFsmOptions = {}): TariffangFsm { + const ex = options.ex === true; + const rng = options.rng ?? Math.random; + const maxHp = Math.round(TARIFFANG.maxHp * (ex ? EX_MODS.hpMul : 1)); + const ticketCap = ex ? EX_TARIFFANG.ticketCap : TARIFFANG.ticketCap; + + let hp = maxHp; + let phase: BossPhase = 'p1'; + let state: TariffangAction = 'idle'; + let recentAttacks: TariffangAction[] = []; + let distancePx: number | null = null; + let timerMs: number = TARIFFANG.idleMs.p1; + let damageSinceDrop = 0; + // 被動加收冷卻(FSM 內部時鐘):tick 累加、命中時查詢。 + let clockMs = 0; + let lastTaxAtMs = Number.NEGATIVE_INFINITY; + let defeated = false; + + const speedFactor = (): number => SPEED_FACTORS[phase] * (ex ? EX_MODS.speedMul : 1); + + const durationMs = (action: TariffangAction): number => { + switch (action) { + // 僵直窗=固定輸出窗:不隨狂暴縮短(§74 hit window 慣例)。 + case 'idle': + return TARIFFANG.idleMs[phase]; + case 'cargo': + return TARIFFANG.cargoDurationMs / speedFactor(); + case 'stamp': + return TARIFFANG.stampDurationMs / speedFactor(); + case 'levy': + return TARIFFANG.levyDurationMs / speedFactor(); + case 'gate': + return TARIFFANG.gateDurationMs / speedFactor(); + case 'ram': + return TARIFFANG.ramDurationMs / speedFactor(); + default: { + const unhandled: never = action; + throw new Error(`未知招式:${String(unhandled)}`); + } + } + }; + + const commandOf = (action: TariffangAction): TariffangCommand => { + switch (action) { + case 'idle': + return { kind: 'idle' }; + case 'cargo': + return { + kind: 'cargo', + count: phase === 'p1' ? TARIFFANG.cargoCountP1 : TARIFFANG.cargoCountP2, + bothSides: ex && EX_TARIFFANG.cargoBothSides, + }; + case 'stamp': + return { kind: 'stamp', count: TARIFFANG.stampCount }; + case 'levy': + return { kind: 'levy', count: TARIFFANG.levyTicketCount }; + case 'gate': + return { kind: 'gate', holdMs: TARIFFANG.gateHoldMs }; + case 'ram': + return { kind: 'ram' }; + default: { + const unhandled: never = action; + throw new Error(`未知招式:${String(unhandled)}`); + } + } + }; + + const enterPhase = (next: BossPhase, events: TariffangHitEvent[]): void => { + phase = next; + state = 'idle'; + recentAttacks = []; + timerMs = durationMs('idle'); + events.push({ kind: 'phase', phase: next }); + }; + + return { + get hp() { + return hp; + }, + get maxHp() { + return maxHp; + }, + get phase() { + return phase; + }, + get state() { + return state; + }, + get speedFactor() { + return speedFactor(); + }, + get defeated() { + return defeated; + }, + get ticketCap() { + return ticketCap; + }, + tick(deltaMs: number): TariffangCommand | null { + if (defeated) return null; + clockMs += deltaMs; + timerMs -= deltaMs; + if (timerMs > 0) return null; + if (state === 'idle') { + state = pickMove( + tariffangMoveTable(phase), + { hpRatio: hp / maxHp, distanceBand: distanceBandOf(distancePx) }, + recentAttacks, + rng, + ); + recentAttacks = [...recentAttacks.slice(-1), state]; + } else { + state = 'idle'; + } + // 保留溢出時間,維持節奏不漂移(同 bossFsm)。 + timerMs += durationMs(state); + return commandOf(state); + }, + takeDamage(amount: number): TariffangHitEvent[] { + if (defeated || amount <= 0) return []; + const events: TariffangHitEvent[] = []; + hp = Math.max(0, hp - amount); + events.push({ kind: 'damaged', hp }); + if (hp <= 0) { + defeated = true; + events.push({ kind: 'defeated' }); + return events; + } + damageSinceDrop += amount; + while (damageSinceDrop >= TARIFFANG.minionSpawnHpStep) { + damageSinceDrop -= TARIFFANG.minionSpawnHpStep; + events.push({ kind: 'minionDrop' }); + } + // 階段轉換:跨多閾值單次受擊依序帶出 phase 事件(p1→p2→p3 不跳段)。 + if (phase === 'p1' && hp <= maxHp * TARIFFANG.p2HpRatio) enterPhase('p2', events); + if (phase === 'p2' && hp <= maxHp * TARIFFANG.p3HpRatio) enterPhase('p3', events); + return events; + }, + tryTax(): boolean { + if (defeated || phase !== 'p2') return false; + if (clockMs - lastTaxAtMs < TARIFFANG.reactiveTicketCooldownMs) return false; + lastTaxAtMs = clockMs; + return true; + }, + stun(durationMs: number): boolean { + // 衝撞中不可暈(高速位移期強行停拍會凍在半途);其餘態回待機停拍。 + if (defeated || state === 'ram') return false; + state = 'idle'; + timerMs = durationMs; + return true; + }, + setTargetDistance(next: number | null): void { + distancePx = next; + }, + }; +} From 21e9e345bcebe52d830566cabb3806ad4496d1f2 Mon Sep 17 00:00:00 2001 From: haotool Date: Mon, 27 Jul 2026 23:24:40 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(starpuff):=20=E6=98=9F=E6=B5=B7?= =?UTF-8?q?=E7=B5=82=E5=B1=80=E7=AF=87=E9=9B=99=E9=AD=94=E7=8E=8B=E9=97=9C?= =?UTF-8?q?=E5=85=A5=E7=B7=A8=EF=BC=88=E9=97=9C=E7=A8=85=E8=A6=81=E5=A1=9E?= =?UTF-8?q?=E8=88=87=E6=BD=AE=E6=B1=90=E7=8E=8B=E5=BA=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 呈現層 systems/tariffang(貨櫃/關稅槌/稅票/閘門/衝撞)與 systems/maridella - (潮線/水滴/海嘯牆/召喚/月蝕環爆);位移一律 approachPoint 逼近禁座標直寫 - 稅票掛 inhalable 走吸入回收管線、焰化 burn 星彈燒票;潮化免水流推移 - levels.ts 補 L22/L24(前室體系+觸發密度契約:焰化/潮化供給 ≥0.5 權重) - difficulty 稽核事實表擴 7 王(L22 總分 9.3、L24 9.4 接續 L20 遞增) - 資產接關:雙王立繪與星港/潮灣道具條自 lazy 分檔搬回主 manifest - 圖鑑/成就/解鎖鏈/SEO 文案(七區廿四關)同步;W1 跳號舊檔相容測試 測試:pnpm --filter @app/starpuff test 1142 案全綠(基準 1106)、typecheck 通過 Co-authored-by: Cursor --- apps/starpuff/app.config.mjs | 2 +- apps/starpuff/public/llms.txt | 10 +- apps/starpuff/public/sitemap.xml | 2 +- apps/starpuff/src/game/core/assetPlan.ts | 7 +- apps/starpuff/src/game/core/assets.ts | 62 ++ apps/starpuff/src/game/core/assetsV21Part1.ts | 76 +-- apps/starpuff/src/game/core/codex.test.ts | 4 +- apps/starpuff/src/game/core/codex.ts | 17 +- .../starpuff/src/game/core/gridLayout.test.ts | 4 +- apps/starpuff/src/game/core/save.test.ts | 28 +- .../src/game/logic/achievements.test.ts | 12 +- apps/starpuff/src/game/logic/achievements.ts | 12 + apps/starpuff/src/game/logic/difficulty.ts | 54 +- apps/starpuff/src/game/logic/eggs.test.ts | 4 + apps/starpuff/src/game/logic/levels.test.ts | 82 ++- apps/starpuff/src/game/logic/levels.ts | 79 +++ .../src/game/systems/bossFactory.test.ts | 40 +- apps/starpuff/src/game/systems/bossFactory.ts | 29 + apps/starpuff/src/game/systems/maridella.ts | 592 ++++++++++++++++ apps/starpuff/src/game/systems/tariffang.ts | 631 ++++++++++++++++++ apps/starpuff/src/seo/seo-metadata.test.ts | 18 +- apps/starpuff/src/seo/seo-metadata.ts | 13 +- 22 files changed, 1650 insertions(+), 128 deletions(-) create mode 100644 apps/starpuff/src/game/systems/maridella.ts create mode 100644 apps/starpuff/src/game/systems/tariffang.ts diff --git a/apps/starpuff/app.config.mjs b/apps/starpuff/app.config.mjs index 8c26fdbb8..d36e1972a 100644 --- a/apps/starpuff/app.config.mjs +++ b/apps/starpuff/app.config.mjs @@ -29,7 +29,7 @@ export const SITE_CONFIG = { shortName: 'StarPuff', title: '星噗噗 StarPuff|免費橫向捲軸動作網頁遊戲・免下載離線可玩', description: - '星噗噗 StarPuff 是免費網頁遊戲:橫向捲軸動作玩法,操控 Q 彈果凍球吸入果凍怪、化為九系星彈與星化變身,闖越果凍星球五大區域二十道關卡、挑戰五大魔王與 EX 變體。免下載、免註冊、無廣告,支援 PWA 安裝與離線遊玩,手機、平板與電腦開啟瀏覽器即可暢玩。', + '星噗噗 StarPuff 是免費網頁遊戲:橫向捲軸動作玩法,操控 Q 彈果凍球吸入果凍怪、化為九系星彈與星化變身,闖越果凍星球七大區域二十四道關卡、挑戰七大魔王與 EX 變體。免下載、免註冊、無廣告,支援 PWA 安裝與離線遊玩,手機、平板與電腦開啟瀏覽器即可暢玩。', }; export const APP_CONFIG = { diff --git a/apps/starpuff/public/llms.txt b/apps/starpuff/public/llms.txt index 52322aeee..60ce3fa03 100644 --- a/apps/starpuff/public/llms.txt +++ b/apps/starpuff/public/llms.txt @@ -1,6 +1,6 @@ # 星噗噗(StarPuff) -> 星噗噗是一款免費、無廣告的繁體中文原創橫向捲軸動作網頁遊戲(PWA):操控 Q 彈果凍球「噗噗」吸入果凍怪、化為九系星彈,闖越果凍星球五大區域、二十道關卡,挑戰五大魔王與 EX 變體。免下載、免安裝、免註冊,手機、平板、電腦開瀏覽器即玩;安裝到主畫面後離線也能完整遊玩。由 haotool.org 出品。 +> 星噗噗是一款免費、無廣告的繁體中文原創橫向捲軸動作網頁遊戲(PWA):操控 Q 彈果凍球「噗噗」吸入果凍怪、化為九系星彈,闖越果凍星球七大區域、二十四道關卡,挑戰七大魔王與 EX 變體。免下載、免安裝、免註冊,手機、平板、電腦開瀏覽器即玩;安裝到主畫面後離線也能完整遊玩。由 haotool.org 出品。 ## 遊戲資訊 @@ -15,13 +15,15 @@ - 出品:HaoTool 好工具(https://haotool.org/) - 原創 IP:主角與怪物皆為原創設計(果凍星球世界觀) -## 世界與關卡(五區二十關) +## 世界與關卡(七區二十四關) - 果凍平原(L1-L4):新手區,魔王「果凍王 Jellord」——果凍雨與地面震波的巨大果凍君主 - 天風峽域(L5-L7):上升氣流與峽谷跳台,魔王「暗月蝠王 Noctra」——俯衝突襲與星屑彈幕的夜空蝠王 - 幻晶深域(L8-L12):星門折躍與洞窟探索,魔王「稜晶雙子 Prismix」——雙血條同場作戰的稜晶孿生體 - 焙糖火山(L13-L16):糖漿潮汐與熱泉噴口,魔王「熔糖窯后 Syrona」——噴泉洗牌與窯風三連的場控女王 -- 星核聖域(L17-L20):低重力與流星雨終章,魔王「蝕星魔核 Voidra」——生存段與星核共鳴的最終魔王 +- 星核聖域(L17-L20):低重力與流星雨主線終章,魔王「蝕星魔核 Voidra」——生存段與星核共鳴的最終魔王 +- 星海港域(L21-L22):星海終局篇首章——貨櫃推進與掃描光,魔王「關稅巨獸 Tariffang」——貨櫃稽查與追蹤稅票的星港關稅官 +- 冰晶潮域(L23-L24):水流浮力與冰面潮汐,魔王「潮汐女王 Maridella」——潮線改道與深海月蝕的潮灣女王 - 魔王擊破後解鎖 EX 挑戰變體,關卡可無限重玩 ## 玩法 @@ -39,7 +41,7 @@ - 需要下載或註冊嗎?不需要,開啟網址即玩;也可選擇安裝成 PWA。 - 沒有網路能玩嗎?可以,安裝到主畫面後離線可玩全部內容。 - 支援哪些裝置?iPhone、Android 手機、平板與桌面瀏覽器(Chrome、Safari、Edge、Firefox)。 -- 遊戲多長?共 20 關,單關 2-4 分鐘,含五場魔王戰與 EX 變體挑戰。 +- 遊戲多長?共 24 關,單關 2-4 分鐘,含七場魔王戰與 EX 變體挑戰。 ## 頁面 diff --git a/apps/starpuff/public/sitemap.xml b/apps/starpuff/public/sitemap.xml index 260491478..ab7d6366d 100644 --- a/apps/starpuff/public/sitemap.xml +++ b/apps/starpuff/public/sitemap.xml @@ -3,7 +3,7 @@ xmlns:xhtml="http://www.w3.org/1999/xhtml"> https://app.haotool.org/starpuff/ - 2026-07-19T00:00:00Z + 2026-07-27T00:00:00Z diff --git a/apps/starpuff/src/game/core/assetPlan.ts b/apps/starpuff/src/game/core/assetPlan.ts index 9ba231c3e..f75b91e14 100644 --- a/apps/starpuff/src/game/core/assetPlan.ts +++ b/apps/starpuff/src/game/core/assetPlan.ts @@ -63,13 +63,16 @@ export const ENEMY_TEXTURE_KEYS: Record = { // assetPlan.test 的 PENDING 三條守門);未來新佔位鍵入列須顯式過審。 export const PENDING_TEXTURE_KEYS: readonly string[] = []; -// 魔王品種 → 立繪鍵:jellord 含暴走幀(logic/bossFsm 轉段切換)。 +// 魔王品種 → 立繪鍵:jellord 含暴走幀(logic/bossFsm 轉段切換); +// §122 W2 兩王含 enraged 幀(P2 換裝,#857 素材)。 export const BOSS_TEXTURE_KEYS: Record = { jellord: ['boss-idle', 'boss-enraged'], noctra: ['boss-noctra'], prismix: ['boss-prismix'], syrona: ['boss-syrona'], voidra: ['boss-voidra'], + tariffang: ['boss-tariffang', 'boss-tariffang-enraged'], + maridella: ['boss-maridella', 'boss-maridella-enraged'], }; // 魔王召喚品種(systems/bossFactory.ts 分派):不在 enemyMix 內仍會登場,必須併入關卡計畫。 @@ -79,6 +82,8 @@ export const BOSS_SUMMON_KINDS: Record = { prismix: ['mirri'], syrona: ['bubbla'], voidra: [], + tariffang: [], + maridella: ['foamy'], }; // 潮汐關生成替換(logic/tide.tideFilterKind:磁極怪浸水改果凍)與滿潮救援紮根品種 diff --git a/apps/starpuff/src/game/core/assets.ts b/apps/starpuff/src/game/core/assets.ts index 8fef34c56..bc458d294 100644 --- a/apps/starpuff/src/game/core/assets.ts +++ b/apps/starpuff/src/game/core/assets.ts @@ -396,6 +396,68 @@ export const ASSETS: AssetEntry[] = [ url: new URL('../../assets/sprites/minion-tideray.webp', import.meta.url).href, phase: 'level', }, + // §122 W2 接關:Tariffang/Maridella 雙王立繪(含 enraged 幀)與星港/潮灣道具條 + // 自 assetsV21Part1 逐筆搬回(#857 接關契約)。 + { + key: 'boss-tariffang', + url: new URL('../../assets/sprites/boss-tariffang.webp', import.meta.url).href, + phase: 'boss', + }, + { + key: 'boss-tariffang-enraged', + url: new URL('../../assets/sprites/boss-tariffang-enraged.webp', import.meta.url).href, + phase: 'boss', + }, + { + key: 'boss-maridella', + url: new URL('../../assets/sprites/boss-maridella.webp', import.meta.url).href, + phase: 'boss', + }, + { + key: 'boss-maridella-enraged', + url: new URL('../../assets/sprites/boss-maridella-enraged.webp', import.meta.url).href, + phase: 'boss', + }, + { + key: 'prop-starport-1', + url: new URL('../../assets/sprites/prop-starport-1.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-starport-2', + url: new URL('../../assets/sprites/prop-starport-2.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-starport-3', + url: new URL('../../assets/sprites/prop-starport-3.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-starport-4', + url: new URL('../../assets/sprites/prop-starport-4.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-tidebay-1', + url: new URL('../../assets/sprites/prop-tidebay-1.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-tidebay-2', + url: new URL('../../assets/sprites/prop-tidebay-2.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-tidebay-3', + url: new URL('../../assets/sprites/prop-tidebay-3.webp', import.meta.url).href, + phase: 'level', + }, + { + key: 'prop-tidebay-4', + url: new URL('../../assets/sprites/prop-tidebay-4.webp', import.meta.url).href, + phase: 'level', + }, ]; // v21-v30 未接關素材(442 條)獨立存放於 assetsV21Part1/2/3,刻意不併入本陣列: diff --git a/apps/starpuff/src/game/core/assetsV21Part1.ts b/apps/starpuff/src/game/core/assetsV21Part1.ts index 49a90dd2c..c4eb9312a 100644 --- a/apps/starpuff/src/game/core/assetsV21Part1.ts +++ b/apps/starpuff/src/game/core/assetsV21Part1.ts @@ -32,80 +32,8 @@ export const ASSETS_V21_PART1: AssetEntry[] = [ url: new URL('../../assets/sprites/fx-market-down-arrow.webp', import.meta.url).href, phase: 'lazy', // 待接關改回 'boss'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) }, - // v21-v30 B02 星港/潮灣場景(載入時機建議:L21/L23 關卡載入,暫沿 Boot 預載)。 - { - key: 'prop-starport-1', - url: new URL('../../assets/sprites/prop-starport-1.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-starport-2', - url: new URL('../../assets/sprites/prop-starport-2.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-starport-3', - url: new URL('../../assets/sprites/prop-starport-3.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-starport-4', - url: new URL('../../assets/sprites/prop-starport-4.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-tidebay-1', - url: new URL('../../assets/sprites/prop-tidebay-1.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-tidebay-2', - url: new URL('../../assets/sprites/prop-tidebay-2.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-tidebay-3', - url: new URL('../../assets/sprites/prop-tidebay-3.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'prop-tidebay-4', - url: new URL('../../assets/sprites/prop-tidebay-4.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'level'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - // v21-v30 B02 雙王(載入時機建議:L22/L24 魔王關載入,暫沿 Boot 預載)。 - { - key: 'boss-tariffang', - url: new URL('../../assets/sprites/boss-tariffang.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'boss'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'boss-tariffang-enraged', - url: new URL('../../assets/sprites/boss-tariffang-enraged.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'boss'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'boss-maridella', - url: new URL('../../assets/sprites/boss-maridella.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'boss'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, - { - key: 'boss-maridella-enraged', - url: new URL('../../assets/sprites/boss-maridella-enraged.webp', import.meta.url).href, - - phase: 'lazy', // 待接關改回 'boss'(W2/W3 接入 LevelSpec/TRANSFORM_FORMS 時必改) - }, + // v21-v30 B02 星港/潮灣場景與雙王:已於 §122 W2 接關認領,條目搬回主 manifest + //(assets.ts)並標正確 phase——本檔僅保留未認領批次。 // v21-v30 B02 六新小怪(載入時機建議:L21/L23 關卡載入,暫沿 Boot 預載)。 // v21-v30 B02 潮化基底與焰化/潮化變身五幀(載入時機建議:形態解鎖前延遲載入,暫沿 Boot 預載)。 { diff --git a/apps/starpuff/src/game/core/codex.test.ts b/apps/starpuff/src/game/core/codex.test.ts index dbcc6e927..8b4fb8e06 100644 --- a/apps/starpuff/src/game/core/codex.test.ts +++ b/apps/starpuff/src/game/core/codex.test.ts @@ -9,7 +9,7 @@ import { } from './codex'; describe('CODEX_MONSTERS', () => { - it('收錄全部二十四種小怪與五魔王(§120 星海終局篇六新怪入鑑)', () => { + it('收錄全部二十四種小怪與七魔王(§120 六新怪+§122 雙新王入鑑)', () => { expect(CODEX_MONSTERS.map((m) => m.kind)).toEqual([ 'jelly', 'floaty', @@ -40,6 +40,8 @@ describe('CODEX_MONSTERS', () => { 'prismix', 'syrona', 'voidra', + 'tariffang', + 'maridella', ]); }); diff --git a/apps/starpuff/src/game/core/codex.ts b/apps/starpuff/src/game/core/codex.ts index 3a8ecd78d..c766024d3 100644 --- a/apps/starpuff/src/game/core/codex.ts +++ b/apps/starpuff/src/game/core/codex.ts @@ -18,7 +18,7 @@ export const CODEX_TAB_GRIDS = { export const MONSTER_PAGE_SIZE = 12; export interface CodexMonster { - kind: EnemyKind | 'boss' | 'noctra' | 'prismix' | 'syrona' | 'voidra'; + kind: EnemyKind | 'boss' | 'noctra' | 'prismix' | 'syrona' | 'voidra' | 'tariffang' | 'maridella'; textureKey: string; nameZh: string; behavior: string; @@ -237,6 +237,21 @@ export const CODEX_MONSTERS: readonly CodexMonster[] = [ behavior: '牽引、轟炸生存段、低重力終局最終魔王', inhalable: false, }, + // §122 星海終局篇 W2:兩新魔王徽記入鑑。 + { + kind: 'tariffang', + textureKey: 'boss-tariffang', + nameZh: '關稅巨獸', + behavior: '貨櫃稽查、追蹤稅票、封關衝撞星港魔王', + inhalable: false, + }, + { + kind: 'maridella', + textureKey: 'boss-maridella', + nameZh: '潮汐女王', + behavior: '潮線改道、海嘯階梯、深海月蝕潮灣魔王', + inhalable: false, + }, ] as const; export interface CodexSkill { diff --git a/apps/starpuff/src/game/core/gridLayout.test.ts b/apps/starpuff/src/game/core/gridLayout.test.ts index c6dac4a07..d5313e3e4 100644 --- a/apps/starpuff/src/game/core/gridLayout.test.ts +++ b/apps/starpuff/src/game/core/gridLayout.test.ts @@ -40,9 +40,9 @@ describe('圖鑑分頁縱向守門(§96 P1-01:任何分頁內容不得超出 } }); - it('成就分頁:當前 21 條維持 6 欄 4 列、成長至 30 條仍不溢出', () => { + it('成就分頁:當前 25 條收斂 7 欄 4 列(§122 雙新王 +4)、成長至 30 條仍不溢出', () => { const grid = fitBoundedGrid(ACHIEVEMENTS.length, CODEX_TAB_GRIDS.achievements); - expect(grid).toEqual({ cols: 6, rows: 4 }); + expect(grid).toEqual({ cols: 7, rows: 4 }); expect(gridBottom(grid, CODEX_TAB_GRIDS.achievements)).toBeLessThanOrEqual(470); const future = fitBoundedGrid(30, CODEX_TAB_GRIDS.achievements); expect(gridBottom(future, CODEX_TAB_GRIDS.achievements)).toBeLessThanOrEqual(470); diff --git a/apps/starpuff/src/game/core/save.test.ts b/apps/starpuff/src/game/core/save.test.ts index 9f07d8cdd..461acfd2e 100644 --- a/apps/starpuff/src/game/core/save.test.ts +++ b/apps/starpuff/src/game/core/save.test.ts @@ -189,24 +189,44 @@ describe('解鎖規則與節點狀態(§39)', () => { save = recordLevelClear(save, 19, 1000); expect(currentChallenge(save)).toBe(20); save = recordLevelClear(save, 20, 1000); - // §121 星海終局篇(W1 過渡跳號):L20 之後接續 L21 → L23,全通關為 null。 + // §121/§122 星海終局篇:L20 之後接續 L21 → L22 → L23 → L24,全通關為 null。 expect(currentChallenge(save)).toBe(21); save = recordLevelClear(save, 21, 1000); + expect(currentChallenge(save)).toBe(22); + save = recordLevelClear(save, 22, 1000); expect(currentChallenge(save)).toBe(23); save = recordLevelClear(save, 23, 1000); + expect(currentChallenge(save)).toBe(24); + save = recordLevelClear(save, 24, 1000); expect(currentChallenge(save)).toBeNull(); }); - it('§121 解鎖鏈跳號銜接:L21 需 L20 通關、L23 需 L21 通關(不看不存在的 L22)', () => { + it('§121/§122 解鎖鏈在編序銜接:L22 需 L21 通關、L23 需 L22 通關(W2 補號自動收斂)', () => { let save = createDefaultSave(); for (let id = 1; id <= 19; id += 1) save = recordLevelClear(save, id as LevelId, 1000); expect(isLevelUnlocked(save, 21)).toBe(false); save = recordLevelClear(save, 20, 1000); expect(isLevelUnlocked(save, 21)).toBe(true); - expect(isLevelUnlocked(save, 23)).toBe(false); + expect(isLevelUnlocked(save, 22)).toBe(false); save = recordLevelClear(save, 21, 1000); + expect(isLevelUnlocked(save, 22)).toBe(true); + // W1 舊檔跳號通關(L21→L23 已通)情境:補號後 L23 解鎖依在編前一關 L22。 + expect(isLevelUnlocked(save, 23)).toBe(false); + save = recordLevelClear(save, 22, 1000); expect(isLevelUnlocked(save, 23)).toBe(true); - expect(isLevelUnlocked(save, 22)).toBe(false); + save = recordLevelClear(save, 23, 1000); + expect(isLevelUnlocked(save, 24)).toBe(true); + }); + + it('§122 W1 舊檔相容:跳號通關(L21/L23 已通、無 L22)載入後 L22 為當前挑戰、L23 保持 cleared', () => { + let save = createDefaultSave(); + for (let id = 1; id <= 21; id += 1) save = recordLevelClear(save, id as LevelId, 1000); + save = recordLevelClear(save, 23, 1000); + // 補號後:L22 開放(前一關 L21 已通)、L23 已通不倒退、L24 隨 L23 已通直接開放。 + expect(nodeStatus(save, 22)).toBe('open'); + expect(nodeStatus(save, 23)).toBe('cleared'); + expect(nodeStatus(save, 24)).toBe('open'); + expect(currentChallenge(save)).toBe(22); }); it('v11 存檔相容(§76):v10 存檔(1-12 通關)載入後 L13 開放、L14 鎖定', () => { diff --git a/apps/starpuff/src/game/logic/achievements.test.ts b/apps/starpuff/src/game/logic/achievements.test.ts index eda30cac8..da4553274 100644 --- a/apps/starpuff/src/game/logic/achievements.test.ts +++ b/apps/starpuff/src/game/logic/achievements.test.ts @@ -16,9 +16,9 @@ import { } from './achievements'; import { BOSS_LEVEL_IDS, LEVELS } from './levels'; -// §121 星海終局篇:在編關卡含過渡跳號 21/23(all-clear 由 LEVELS 派生)。 +// §121/§122 星海終局篇:在編關卡 1-24(all-clear 由 LEVELS 派生)。 const ALL_IDS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, ] as const; function saveWithClears(ids: readonly number[], timeMs = 45000): SaveData { @@ -36,8 +36,8 @@ function saveWithAllEggs(): SaveData { } describe('成就資料表(§94 SSOT 不變式)', () => { - it('id 唯一、數量 21、名稱描述非空', () => { - expect(ACHIEVEMENTS).toHaveLength(21); + it('id 唯一、數量 25(§122 雙新王首勝+EX 各 +2)、名稱描述非空', () => { + expect(ACHIEVEMENTS).toHaveLength(25); expect(new Set(ACHIEVEMENTS.map((a) => a.id)).size).toBe(ACHIEVEMENTS.length); for (const spec of ACHIEVEMENTS) { expect(spec.nameZh.length).toBeGreaterThan(0); @@ -184,7 +184,7 @@ describe('awardAchievements(補發與去重)', () => { expect(newly).not.toContain('first-clear'); }); - it('歷史滿進度存檔一次補發全部 21 條(舊玩家補發語意)', () => { + it('歷史滿進度存檔一次補發全部 25 條(舊玩家補發語意)', () => { const save = saveWithAllEggs(); for (const id of BOSS_LEVEL_IDS) recordExClear(save, id); // 速通門檻:45000 ≤ 60000 → 兩條速通同時成立。 @@ -276,7 +276,7 @@ describe('v9–v14 舊存檔載入補發', () => { expect(newly).not.toContain('ex-conquest'); }); - it('v13/v14 世代(全 20+五王 EX+全彩蛋)一次補發全部 21 條', () => { + it('滿進度在編存檔(全關+七王 EX+全彩蛋)一次補發全部 25 條', () => { const save = parseSave( eraSave( LEVELS.map((level) => ({ diff --git a/apps/starpuff/src/game/logic/achievements.ts b/apps/starpuff/src/game/logic/achievements.ts index aa82fcdb0..3a6ae21b4 100644 --- a/apps/starpuff/src/game/logic/achievements.ts +++ b/apps/starpuff/src/game/logic/achievements.ts @@ -51,6 +51,18 @@ const BOSS_TITLES: Record { 'eat-sequence', 'survive-collect', 'reach-x', + 'crown-early-hit', 'stand-count', + 'crown-early-hit', ]); expect(LEVELS.map((l) => l.easterEggs[0]?.reward)).toEqual([ 'hp-up', @@ -60,6 +62,8 @@ describe('LEVELS easterEggs 資料(§24)', () => { 'gold-star', 'full-magazine', 'hp-up', + 'heal', + 'full-magazine', 'full-magazine', ]); }); diff --git a/apps/starpuff/src/game/logic/levels.test.ts b/apps/starpuff/src/game/logic/levels.test.ts index e377a18c2..32a406f05 100644 --- a/apps/starpuff/src/game/logic/levels.test.ts +++ b/apps/starpuff/src/game/logic/levels.test.ts @@ -29,42 +29,42 @@ import { WARP } from './warp'; import { BRICK_SIZE, maxDecorInWindow } from './stageModel'; describe('LEVELS 資料(GAME_DESIGN §15/§50/§60/§66/§67/§68/§72/§84)', () => { - it('在編關卡依序為 1-20+§121 星海終局篇 21/23(W1 過渡跳號)且參數對表', () => { + it('在編關卡依序為 1-20+§121/§122 星海終局篇 21-24 且參數對表', () => { expect(LEVELS.map((l) => l.id)).toEqual([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, ]); expect(LEVELS.map((l) => l.worldWidth)).toEqual([ 2700, 3100, 3500, 854, 3300, 3600, 854, 3400, 3700, 3400, 3700, 854, 3300, 3600, 3800, 854, - 3400, 3700, 4000, 854, 3800, 3900, + 3400, 3700, 4000, 854, 3800, 854, 3900, 854, ]); expect(LEVELS.map((l) => l.killQuota)).toEqual([ - 6, 9, 10, 0, 10, 12, 0, 11, 12, 12, 13, 0, 11, 12, 14, 0, 11, 13, 15, 0, 14, 14, + 6, 9, 10, 0, 10, 12, 0, 11, 12, 12, 13, 0, 11, 12, 14, 0, 11, 13, 15, 0, 14, 0, 14, 0, ]); expect(LEVELS.map((l) => l.spawnIntervalMs)).toEqual([ 2600, 1800, 1300, 3500, 1500, 1200, 4500, 1400, 1150, 1150, 1100, 3000, 1400, 1250, 1100, - 3000, 1350, 1200, 1000, 2800, 900, 850, + 3000, 1350, 1200, 1000, 2800, 900, 3000, 850, 2900, ]); expect(LEVELS.map((l) => l.maxOnScreen)).toEqual([ - 3, 4, 5, 2, 5, 5, 1, 5, 5, 5, 5, 2, 5, 5, 5, 2, 5, 5, 6, 2, 6, 6, + 3, 4, 5, 2, 5, 5, 1, 5, 5, 5, 5, 2, 5, 5, 5, 2, 5, 5, 6, 2, 6, 2, 6, 2, ]); expect(LEVELS.map((l) => l.safeZoneTailPx)).toEqual([ 480, 480, 480, 0, 480, 480, 0, 480, 480, 480, 480, 0, 480, 480, 480, 0, 480, 480, 480, 0, 480, - 480, + 0, 480, 0, ]); }); - it('星核制霸判定(§86):BOSS_LEVEL_IDS 由 LEVELS 派生;5 王 exCleared 全 true 才成立', () => { - expect(BOSS_LEVEL_IDS).toEqual([4, 7, 12, 16, 20]); + it('星核制霸判定(§86):BOSS_LEVEL_IDS 由 LEVELS 派生;7 王 exCleared 全 true 才成立', () => { + expect(BOSS_LEVEL_IDS).toEqual([4, 7, 12, 16, 20, 22, 24]); let save = createDefaultSave(); expect(exConquestDone(save)).toBe(false); - for (const id of [4, 7, 12, 16] as const) save = recordExClear(save, id); - // 差一王不成立。 + for (const id of [4, 7, 12, 16, 20, 22] as const) save = recordExClear(save, id); + // 差一王不成立(§122 起制霸語意隨 LEVELS 派生自然擴為七王)。 expect(exConquestDone(save)).toBe(false); - save = recordExClear(save, 20); + save = recordExClear(save, 24); expect(exConquestDone(save)).toBe(true); }); - it('五魔王品種標記(§54/§68/§74/§82):L4/L7/L12/L16/L20;教學與提示對表', () => { + it('七魔王品種標記(§54/§68/§74/§82/§122):L4/L7/L12/L16/L20/L22/L24;教學與提示對表', () => { expect(LEVELS.map((l) => l.boss)).toEqual([ null, null, @@ -87,7 +87,9 @@ describe('LEVELS 資料(GAME_DESIGN §15/§50/§60/§66/§67/§68/§72/§84) null, 'voidra', null, + 'tariffang', null, + 'maridella', ]); expect(LEVELS.map((l) => l.tutorial)).toEqual([ true, @@ -131,6 +133,40 @@ describe('LEVELS 資料(GAME_DESIGN §15/§50/§60/§66/§67/§68/§72/§84) } }); + it('§122 魔王關體系:L22/L24 前室與增益對表、補生供給滿足變身觸發密度契約', () => { + const l22 = getLevel(22); + expect(l22.anteroomPx).toBe(400); + expect(l22.anteroomBuffs).toEqual(['shield', 'power']); + expect(l22.arenaBuff).toBe('swift'); + expect(l22.platforms).toEqual([]); + expect(l22.elements).toEqual([]); + expect(l22.elites).toEqual([]); + expect(l22.bossApplies).toEqual(['ember-form', 'transform']); + // 觸發密度(§119 使用者硬需求):焰化供給(重鑽味)為補生主軸(權重 ≥0.5), + // 常態補給下 ≤30s 可湊齊同系 ×3——燒稅票優勢情境的星味保證。 + const emberShare = l22.enemyMix + .filter((entry) => inhaleFlavor(entry.kind) === 'drilly') + .reduce((sum, entry) => sum + entry.weight, 0); + expect(emberShare).toBeGreaterThanOrEqual(0.5); + // 補生全可吸(§26 飢荒保證律供給面)。 + for (const entry of l22.enemyMix) expect(canInhale(entry.kind)).toBe(true); + + const l24 = getLevel(24); + expect(l24.anteroomPx).toBe(400); + expect(l24.anteroomBuffs).toEqual(['shield', 'swift']); + expect(l24.arenaBuff).toBe('power'); + expect(l24.platforms).toEqual([]); + expect(l24.elements).toEqual([]); + expect(l24.elites).toEqual([]); + expect(l24.bossApplies).toEqual(['tide-form', 'transform']); + // 潮化供給(孢子味)為補生主軸(權重 ≥0.5)+P2 潮湧召喚 foamy 加持。 + const tideShare = l24.enemyMix + .filter((entry) => inhaleFlavor(entry.kind) === 'spora') + .reduce((sum, entry) => sum + entry.weight, 0); + expect(tideShare).toBeGreaterThanOrEqual(0.5); + for (const entry of l24.enemyMix) expect(canInhale(entry.kind)).toBe(true); + }); + it('低重力係數(§81/§10.2-6):僅 L17/L18/L19 配置且值域 [0.5, 1.0]', () => { for (const level of LEVELS) { if (level.id === 17 || level.id === 18) { @@ -470,8 +506,10 @@ describe('LEVELS 資料(GAME_DESIGN §15/§50/§60/§66/§67/§68/§72/§84) } }); - it('getLevel 未知 id 擲錯(W1 過渡期 L22 尚未入編)', () => { - expect(() => getLevel(22)).toThrow(); + it('getLevel:L22/L24 已入編(§122 W2)、未知 id 擲錯', () => { + expect(getLevel(22).boss).toBe('tariffang'); + expect(getLevel(24).boss).toBe('maridella'); + expect(() => getLevel(25)).toThrow(); expect(() => getLevel(99 as never)).toThrow(); }); @@ -658,9 +696,10 @@ describe('LEVELS 資料(GAME_DESIGN §15/§50/§60/§66/§67/§68/§72/§84) 'bg-meteorfield': '(arena|throne)', 'bg-starcourt': '(arena|throne)', 'bg-voidcore': 'throne', - // §121 星海終局篇:星港混排 throne/arena(港區機械)、潮灣沿用 arena(冰晶)。 - 'bg-starport': '(arena|throne)', - 'bg-tidebay': 'arena', + // §121 星海終局篇:星港混排 throne/arena(港區機械)、潮灣沿用 arena(冰晶); + // §122 魔王關改用專屬 prop-starport/prop-tidebay 道具條(#857 素材)。 + 'bg-starport': '(arena|throne|starport)', + 'bg-tidebay': '(arena|tidebay)', }; for (const level of LEVELS) { const theme = decorTheme[level.bgKey] ?? level.bgKey.replace('bg-', ''); @@ -682,12 +721,13 @@ describe('LEVELS 資料(GAME_DESIGN §15/§50/§60/§66/§67/§68/§72/§84) } }); - it('nextLevelId 依 LEVELS 在編序推進(含 §121 過渡跳號 21→23),末關回 null', () => { + it('nextLevelId 依 LEVELS 在編序推進(§122 W2 補號 21→22→23→24),末關回 null', () => { for (let i = 0; i + 1 < LEVELS.length; i += 1) { expect(nextLevelId(LEVELS[i]?.id as never)).toBe(LEVELS[i + 1]?.id); } - expect(nextLevelId(21)).toBe(23); - expect(nextLevelId(23)).toBeNull(); + expect(nextLevelId(21)).toBe(22); + expect(nextLevelId(22)).toBe(23); + expect(nextLevelId(24)).toBeNull(); }); // §119 觸發密度契約:形態引入關的主形態星味須有 ≥2 種供給怪,且形態練習區 diff --git a/apps/starpuff/src/game/logic/levels.ts b/apps/starpuff/src/game/logic/levels.ts index dc7c068b2..acfa7f48d 100644 --- a/apps/starpuff/src/game/logic/levels.ts +++ b/apps/starpuff/src/game/logic/levels.ts @@ -1521,6 +1521,45 @@ export const LEVELS: readonly LevelSpec[] = [ { kind: 'cargo', x: 1720 }, ], }, + // §122 星海終局篇(六區星海港域)——L22 關稅要塞:關稅巨獸 Tariffang—— + // 貨物稽查(貨櫃地形改寫)× 加收費用(命中即生追蹤稅票)× 全面封關(閘門+衝撞); + // 魔王關體系沿用(§69 前室/增益/彩蛋)。 + { + id: 22, + nameZh: '關稅要塞', + bgKey: 'bg-starport', + worldWidth: 854, + killQuota: 0, + spawnIntervalMs: 3000, + maxOnScreen: 2, + safeZoneTailPx: 0, + // 補生全可吸(§26)+觸發密度契約(§119):cargo 0.5(重鑽味=焰化供給主軸), + // 常態補給下 ≤30s 可湊齊焰化 ×3——燒稅票優勢情境的星味供給保證。 + enemyMix: [ + { kind: 'cargo', weight: 0.5 }, + { kind: 'jelly', weight: 0.3 }, + { kind: 'floaty', weight: 0.2 }, + ], + platforms: [], + elements: [], + decor: [ + { key: 'prop-starport-1', x: 110 }, + { key: 'prop-starport-2', x: 320 }, + { key: 'prop-starport-3', x: 540 }, + { key: 'prop-starport-4', x: 750 }, + ], + // §24 彩蛋二十二:巨獸可擊打後 5 秒內首次命中(與 L4/L7 同型)。 + easterEggs: [{ trigger: 'crown-early-hit', reward: 'heal', windowMs: 5000 }], + elites: [], + boss: 'tariffang', + tutorial: false, + // §110 魔王驗收:焰化燒稅票(優勢情境)+變身。 + bossApplies: ['ember-form', 'transform'], + // 魔王關體系(§69 沿用):前室 400px+護盾/星力二選一;P2 高風險位刷疾風靴。 + anteroomPx: 400, + anteroomBuffs: ['shield', 'power'], + arenaBuff: 'swift', + }, // §121 星海終局篇(七區冰晶潮域)——L23 冰晶潮灣:水流浮力(週期浮力柱)× // 冰面滑行(frosty)×潮汐走動關;潮化取得——孢子味三新怪供給(foamy/frosty/manta), // 中段形態練習區保證 3 隻孢子菇(定點紮根留駐)。 @@ -1622,6 +1661,46 @@ export const LEVELS: readonly LevelSpec[] = [ { kind: 'foamy', x: 1760 }, ], }, + // §122 星海終局篇(七區冰晶潮域)——L24 潮汐王庭:潮汐女王 Maridella—— + // 潮線改道(水流推移+箭頭提示)× 海嘯階梯(缺口牆)× 深海月蝕(三水球環爆); + // 魔王關體系沿用(§69 前室/增益/彩蛋)。 + { + id: 24, + nameZh: '潮汐王庭', + bgKey: 'bg-tidebay', + worldWidth: 854, + killQuota: 0, + spawnIntervalMs: 2900, + maxOnScreen: 2, + safeZoneTailPx: 0, + // 補生全可吸(§26)+觸發密度契約(§119):潮系雙供給 0.55(foamy/frosty= + // 孢子味=潮化)+P2 潮湧召喚 foamy 加持——穩控優勢情境的星味供給保證。 + enemyMix: [ + { kind: 'foamy', weight: 0.3 }, + { kind: 'frosty', weight: 0.25 }, + { kind: 'jelly', weight: 0.25 }, + { kind: 'floaty', weight: 0.2 }, + ], + platforms: [], + elements: [], + decor: [ + { key: 'prop-tidebay-1', x: 110 }, + { key: 'prop-tidebay-2', x: 320 }, + { key: 'prop-tidebay-3', x: 540 }, + { key: 'prop-tidebay-4', x: 750 }, + ], + // §24 彩蛋二十四:女王可擊打後 5 秒內首次命中(與 L4/L7 同型)。 + easterEggs: [{ trigger: 'crown-early-hit', reward: 'full-magazine', windowMs: 5000 }], + elites: [], + boss: 'maridella', + tutorial: false, + // §110 魔王驗收:潮化穩控(優勢情境)+變身。 + bossApplies: ['tide-form', 'transform'], + // 魔王關體系(§69 沿用):前室 400px+護盾/疾風二選一;P2 高風險位刷星力果。 + anteroomPx: 400, + anteroomBuffs: ['shield', 'swift'], + arenaBuff: 'power', + }, ]; export function getLevel(id: LevelId): LevelSpec { diff --git a/apps/starpuff/src/game/systems/bossFactory.test.ts b/apps/starpuff/src/game/systems/bossFactory.test.ts index 0c9ec52fa..a6cbc23b0 100644 --- a/apps/starpuff/src/game/systems/bossFactory.test.ts +++ b/apps/starpuff/src/game/systems/bossFactory.test.ts @@ -2,16 +2,20 @@ 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 { MARIDELLA } from '../logic/maridellaFsm'; import { NOCTRA } from '../logic/noctraFsm'; import { PRISMIX } from '../logic/prismixFsm'; import { SYRONA } from '../logic/syronaFsm'; +import { TARIFFANG } from '../logic/tariffangFsm'; import { VOIDRA } from '../logic/voidraFsm'; import type { LevelSpec } from '../logic/levels'; import { createBossKit, type BossFactoryHooks } from './bossFactory'; import { createBoss } from './boss'; +import { createMaridella } from './maridella'; import { createNoctra } from './noctra'; import { createPrismix } from './prismix'; import { createSyrona } from './syrona'; +import { createTariffang } from './tariffang'; import { createVoidra } from './voidra'; import { spawnHealPickup } from './pickups'; import type { EnemySystem } from './enemies'; @@ -29,6 +33,8 @@ 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('./tariffang', () => ({ createTariffang: vi.fn(() => ({ id: 'tariffang-handle' })) })); +vi.mock('./maridella', () => ({ createMaridella: vi.fn(() => ({ id: 'maridella-handle' })) })); vi.mock('./pickups', () => ({ spawnHealPickup: vi.fn() })); vi.mock('../audio/sfx', () => ({ playSfx: vi.fn(), stopSfx: vi.fn() })); @@ -135,12 +141,14 @@ describe('createBossKit 品種分派表(§54 唯一分派點)', () => { expect(vi.mocked(createBoss)).toHaveBeenCalledTimes(1); }); - it('noctra/prismix/syrona/voidra 各取對應 handle 與體傷常數', () => { + it('noctra/prismix/syrona/voidra/tariffang/maridella 各取對應 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 }, + { boss: 'tariffang', factory: createTariffang, damage: TARIFFANG.bodyDamage }, + { boss: 'maridella', factory: createMaridella, damage: MARIDELLA.bodyDamage }, ] as const; for (const spec of cases) { const enemies = makeEnemies(); @@ -325,4 +333,34 @@ describe('品種回呼接線(彩蛋餵送與環境管線委派)', () => { expect(args?.[1]).toBe(854 * 0.3); expect(args?.[4]).toMatchObject({ driftToY: GROUND_TOP - 22 }); }); + + it('tariffang playerStars:燒稅票 overlap 的星彈群由 player 單點供給(§122)', () => { + const enemies = makeEnemies(); + const stars = { id: 'stars-group' }; + const { hooks } = makeHooks(enemies, { + player: () => ({ getStars: () => stars }) as unknown as PlayerHandle, + }); + createBossKit(makeScene().scene, makeLevel({ boss: 'tariffang' }), GROUND_TOP, hooks); + const tariffangHooks = vi.mocked(createTariffang).mock.calls[0]?.[1] as { + playerStars(): unknown; + }; + expect(tariffangHooks.playerStars()).toBe(stars); + }); + + it('maridella summonFoamy 走召喚夾限管線;playerForm 取 player 形態真值(§122)', () => { + const enemies = makeEnemies(); + enemies.setAlive([{ kind: 'foamy' }]); + const { hooks } = makeHooks(enemies, { + player: () => ({ getTransformState: () => ({ form: 'tide' }) }) as unknown as PlayerHandle, + }); + createBossKit(makeScene().scene, makeLevel({ boss: 'maridella' }), GROUND_TOP, hooks); + const maridellaHooks = vi.mocked(createMaridella).mock.calls[0]?.[1] as { + summonFoamy(cap: number): void; + playerForm(): string | null; + }; + maridellaHooks.summonFoamy(2); + // 場上已 1 隻 foamy → 補 1 隻至 cap 2。 + expect(enemies.spawned).toEqual([{ kind: 'foamy', x: 48, y: 330 }]); + expect(maridellaHooks.playerForm()).toBe('tide'); + }); }); diff --git a/apps/starpuff/src/game/systems/bossFactory.ts b/apps/starpuff/src/game/systems/bossFactory.ts index c619abc6c..59dd48e69 100644 --- a/apps/starpuff/src/game/systems/bossFactory.ts +++ b/apps/starpuff/src/game/systems/bossFactory.ts @@ -6,6 +6,8 @@ import { NOCTRA } from '../logic/noctraFsm'; import { PRISMIX } from '../logic/prismixFsm'; import { SYRONA } from '../logic/syronaFsm'; import { VOIDRA } from '../logic/voidraFsm'; +import { TARIFFANG } from '../logic/tariffangFsm'; +import { MARIDELLA } from '../logic/maridellaFsm'; import type { EggEvent } from '../logic/eggs'; import type { LevelSpec } from '../logic/levels'; import { MERCY_HEAL } from '../logic/mercyHeal'; @@ -15,6 +17,8 @@ import { createNoctra } from './noctra'; import { createPrismix } from './prismix'; import { createSyrona } from './syrona'; import { createVoidra } from './voidra'; +import { createTariffang } from './tariffang'; +import { createMaridella } from './maridella'; import type { EnemySystem } from './enemies'; import type { FxSystem } from './fx'; import type { MeteorSystem } from './meteor'; @@ -217,6 +221,31 @@ export function createBossKit( ), bodyDamage: VOIDRA.bodyDamage, }; + case 'tariffang': + return { + handle: createTariffang( + scene, + { + // 焰化燒稅票(§119 burn 命中端):星彈群由 player 單點供給。 + playerStars: () => hooks.player().getStars(), + }, + { ex: hooks.exMode, arenaLeft: () => hooks.arenaLeft() }, + ), + bodyDamage: TARIFFANG.bodyDamage, + }; + case 'maridella': + return { + handle: createMaridella( + scene, + { + summonFoamy: (cap) => summonMinion('foamy', cap), + // 潮化穩控(§119):水流推移對潮化免效;形態真值由 player 單點供給。 + playerForm: () => hooks.player().getTransformState().form, + }, + { ex: hooks.exMode, arenaLeft: () => hooks.arenaLeft() }, + ), + bodyDamage: MARIDELLA.bodyDamage, + }; default: { const unhandled: never = kind; throw new Error(`未知魔王品種:${String(unhandled)}`); diff --git a/apps/starpuff/src/game/systems/maridella.ts b/apps/starpuff/src/game/systems/maridella.ts new file mode 100644 index 000000000..cc01aee9d --- /dev/null +++ b/apps/starpuff/src/game/systems/maridella.ts @@ -0,0 +1,592 @@ +import Phaser from 'phaser'; +import { acquirePooled } from '../core/poolFlags'; +import { VIEW } from '../core/config'; +import { GameEvents, emitGameEvent } from '../core/events'; +import type { TransformForm } from '../core/types'; +import { approachPoint } from '../logic/noctraFlight'; +import { MARIDELLA, createMaridellaFsm, type MaridellaCommand } from '../logic/maridellaFsm'; +import { playSfx } from '../audio/sfx'; +import type { BossDamageSource, BossHandle } from './boss'; +import { FX_TEXTURES, ensureFxTextures, spawnTelegraph } from './fx'; +import { getVisualScale } from './visualScale'; + +// 潮汐女王 Maridella 呈現層(GAME_DESIGN §122):與 boss/noctra/prismix/syrona/voidra +// 共用 BossHandle。懸浮場控型:女王優雅懸浮於中高帶(approachPoint 逼近錨點, +// §64 慣例禁座標直寫),威脅來自潮線改道(水流推移)、海嘯階梯(缺口牆)與 +// 深海月蝕(三水球依序環爆);phase truth 由 logic/maridellaFsm.ts 持有。 +// arena 幾何全數依動態視寬比例佈建(§28 禁硬編 854)。 + +const GROUND_TOP = VIEW.height - 80; +const BODY_W = 160; +const BODY_H = 140; +// 懸浮帶:單跳星彈可打帶(§63 慣例,體底 320 落於可及幾何);玩家亦可自底下走過。 +const HOVER_Y = 250; +const APPROACH_SPEED = 300; +const SWAY_FREQ = 0.0006; +const SWAY_AMP_RATIO = 0.14; +const BOB_FREQ = 0.0021; +const BOB_AMP_PX = 10; +const AQUA_TINT = 0x6ac8e8; +const DEEP_TINT = 0x3a78b0; +// 潮線改道:地面帶判定高(腳踏地面帶才受推移);潮化免推(§119 穩控優勢)。 +const CURRENT_BAND_Y = GROUND_TOP - 60; +// 水滴彈幕拋物初速。 +const DROPLET_SPEED_X = 140; +const DROPLET_SPEED_Y = -320; +// 海嘯階梯:牆體橫移速度;缺口幾何由 gapLow 決定(低缺口貼地走廊、高缺口跳躍帶)。 +const WAVE_SPEED = 240; +const WAVE_TOP_Y = 90; +const WAVE_LOW_GAP_TOP = 298; +const WAVE_HIGH_GAP_TOP = 208; +// 深海月蝕:水球軌道半徑與環爆彈速;暗場覆蓋維持 telegraph 可讀(認知紅線)。 +const ORB_RADIUS_PX = 90; +const ORB_SIZE = 30; +const ORB_DETONATE_STEP_MS = 900; +const RING_SPEED = 175; +const ECLIPSE_OVERLAY_ALPHA = 0.32; + +// 入場運鏡(§17 慣例):黑幕淡入 → 推近潮心 → 女王自水面升起 → 咆哮 → 復位開戰。 +const INTRO_FADE_MS = 280; +const INTRO_PUSH_MS = 1200; +const INTRO_ZOOM = 1.42; +const INTRO_ROAR_MS = 820; +const INTRO_RESET_MS = 550; +const INTRO_FADE_RGB = [14, 26, 40] as const; + +// 佔位材質:正式 sprite 缺件時以潮水藍圓體烘焙保底(同 boss.ts 慣例)。 +function ensureTextures(scene: Phaser.Scene): void { + const bake = (key: string, color: number, w: number, h: number): void => { + if (scene.textures.exists(key)) return; + const g = scene.make.graphics({ x: 0, y: 0 }, false); + g.fillStyle(color, 1); + g.fillEllipse(w / 2, h / 2, w, h); + g.generateTexture(key, w, h); + g.destroy(); + }; + bake('boss-maridella', AQUA_TINT, BODY_W, BODY_H); + bake('boss-maridella-enraged', DEEP_TINT, BODY_W, BODY_H); + if (!scene.textures.exists('maridella-drop')) { + const g = scene.make.graphics({ x: 0, y: 0 }, false); + g.fillStyle(0x9ad8f0, 1); + g.fillCircle(8, 8, 8); + g.generateTexture('maridella-drop', 16, 16); + g.destroy(); + } + if (!scene.textures.exists('maridella-arrow')) { + const g = scene.make.graphics({ x: 0, y: 0 }, false); + g.fillStyle(0xbfe8f8, 1); + g.fillTriangle(0, 0, 0, 18, 22, 9); + g.generateTexture('maridella-arrow', 22, 18); + g.destroy(); + } +} + +export interface MaridellaHooks { + // 潮湧召喚 foamy(§122 P2):GameScene 依場上現量夾限至 cap,走正式 spawn 管線; + // 泡泡上浮兼具渡牆輔助=威脅同時是資源(孢子味=潮化供給)。 + summonFoamy(cap: number): void; + // 潮化穩控(§119 形態優勢):潮化期間水流推移不作用;形態真值由 player 單點供給。 + playerForm(): TransformForm | null; +} + +export interface MaridellaOptions { + ex?: boolean; + // 前室魔王關(§69):arena 左緣由 GameScene 注入(前室寬),佈局禁硬編視寬。 + arenaLeft(): number; +} + +export function createMaridella( + scene: Phaser.Scene, + hooks: MaridellaHooks, + options: MaridellaOptions, +): BossHandle { + ensureTextures(scene); + ensureFxTextures(scene); + + const ex = options.ex === true; + const fsm = createMaridellaFsm({ ex }); + const minionHandlers: (() => void)[] = []; + const timers: Phaser.Time.TimerEvent[] = []; + let active = false; + let dying = false; + let target: { x: number; y: number } | null = null; + let elapsedMs = 0; + // 潮線改道狀態:方向與迄點;telegraph 期不推移(箭頭先行)。 + let currentDir: 1 | -1 = 1; + let currentUntilMs = 0; + // 月蝕水球(視覺體,爆裂時轉環形彈)。 + const orbs = new Set(); + + const viewW = () => scene.scale.width; + const arenaLeft = () => options.arenaLeft(); + const arenaX = (ratio: number) => arenaLeft() + viewW() * ratio; + const arenaCx = () => arenaLeft() + viewW() / 2; + + const body = scene.physics.add.sprite(arenaCx(), -BODY_H, 'boss-maridella'); + body.setDisplaySize(BODY_W, BODY_H); + // 物理/視覺縮放解耦(§77 根治):脈動/死亡收縮走 fx 代理,物理箱恆為基準。 + const vscale = getVisualScale(scene); + vscale.register(body); + const fxScale = vscale.fx(body); + const physBody = body.body as Phaser.Physics.Arcade.Body; + physBody.setAllowGravity(false); + physBody.setImmovable(true); + physBody.setSize(body.width * 0.85, body.height * 0.85); + + const projectiles = scene.physics.add.group({ maxSize: 26, allowGravity: false }); + const shockwaves = scene.physics.add.group({ maxSize: 10, allowGravity: false }); + + // 潮流地面帶視覺(恆駐低透明,推移期加亮)+月蝕暗場覆蓋(P3,置於角色層之下 + // 保 telegraph 與本體可讀——暗場是氛圍不是資訊遮蔽)。 + const currentBand = scene.add + .rectangle(arenaCx(), GROUND_TOP - 18, viewW(), 36, AQUA_TINT, 0.06) + .setDepth(-3); + const eclipseOverlay = scene.add + .rectangle(arenaCx(), VIEW.height / 2, viewW() + 80, VIEW.height, 0x0a1430, 0) + .setDepth(-2); + const moon = scene.add.circle(arenaX(0.82), 120, 40, 0xd8ecff, 0).setDepth(-2); + + const delay = (ms: number, fn: () => void) => { + timers.push(scene.time.delayedCall(ms, fn)); + }; + + const flashWhite = () => { + body.setTint(0xffffff).setTintMode(Phaser.TintModes.FILL); + delay(90, () => { + body.setTintMode(Phaser.TintModes.MULTIPLY); + body.clearTint(); + }); + }; + + const spawnShot = ( + x: number, + y: number, + gravity: boolean, + ): Phaser.Physics.Arcade.Sprite | null => { + const shot = acquirePooled(projectiles, x, y, 'maridella-drop'); + if (!shot) return null; + shot.enableBody(true, x, y, true, true); + shot.setTexture('maridella-drop'); + shot.setDisplaySize(16, 16); + shot.setTint(0x9ad8f0); + (shot.body as Phaser.Physics.Arcade.Body).setAllowGravity(gravity); + return shot; + }; + + // 潮線改道(P1+):方向箭頭提前流動 telegraph → 地面帶水流推移玩家(潮化免推)。 + const doCurrent = (dir: 1 | -1, holdMs: number) => { + currentDir = dir; + playSfx('inhale', 0.4); + // 方向箭頭 ×4 沿地面帶流動(telegraph 期即預告方向)。 + for (let i = 0; i < 4; i += 1) { + const startRatio = dir === 1 ? 0.1 + i * 0.2 : 0.9 - i * 0.2; + const arrow = scene.add + .image(arenaX(startRatio), GROUND_TOP - 24, 'maridella-arrow') + .setAlpha(0) + .setFlipX(dir === -1) + .setDepth(-1); + scene.tweens.add({ + targets: arrow, + alpha: { from: 0.9, to: 0 }, + x: arrow.x + dir * 90, + duration: MARIDELLA.currentTelegraphMs + holdMs * 0.4, + ease: 'Sine.easeOut', + onComplete: () => arrow.destroy(), + }); + } + delay(MARIDELLA.currentTelegraphMs, () => { + if (dying) return; + currentUntilMs = elapsedMs + holdMs; + currentBand.setFillStyle(AQUA_TINT, 0.18); + playSfx('pop', 0.6); + }); + }; + + // 水滴彈幕(P1+):舉臂 telegraph → 拋物 ×count 朝玩家側散射(沿 Syrona lob 慣例)。 + const doDroplet = (count: number) => { + flashWhite(); + scene.tweens.add({ targets: body, angle: -6, duration: 160, yoyo: true }); + delay(MARIDELLA.dropletTelegraphMs, () => { + if (dying) return; + playSfx('pop'); + const dir = (target?.x ?? arenaCx()) < body.x ? -1 : 1; + for (let i = 0; i < count; i += 1) { + const shot = spawnShot(body.x + dir * 36, body.y - 20, true); + (shot?.body as Phaser.Physics.Arcade.Body | undefined)?.setVelocity( + dir * (DROPLET_SPEED_X + i * 55), + DROPLET_SPEED_Y - i * 25, + ); + } + }); + }; + + // 海嘯階梯(P2+):起浪側 telegraph → 留缺口的波浪牆橫越全場——低缺口貼地可走、 + // 高缺口跳+拍翅可穿(缺口 ≥102px 恆容本體,anti-softlock 由 FSM 測試守門)。 + const doWave = (fromLeft: boolean, gapLow: boolean) => { + const startX = fromLeft ? arenaLeft() + 24 : arenaLeft() + viewW() - 24; + const dir = fromLeft ? 1 : -1; + spawnTelegraph(scene, startX, GROUND_TOP - 60, MARIDELLA.waveTelegraphMs); + delay(MARIDELLA.waveTelegraphMs, () => { + if (dying) return; + playSfx('boss-slam', 0.7); + // 牆體切兩段留缺口:gapLow=上段(貼地走廊);gapHigh=上下兩段(跳躍帶)。 + const segments: { top: number; bottom: number }[] = gapLow + ? [{ top: WAVE_TOP_Y, bottom: WAVE_LOW_GAP_TOP }] + : [ + { top: WAVE_TOP_Y, bottom: WAVE_HIGH_GAP_TOP }, + { top: WAVE_HIGH_GAP_TOP + MARIDELLA.waveGapPx, bottom: GROUND_TOP }, + ]; + for (const segment of segments) { + const height = segment.bottom - segment.top; + const wall = acquirePooled(shockwaves, startX, segment.top + height / 2, '__WHITE'); + if (!wall) continue; + wall.enableBody(true, startX, segment.top + height / 2, true, true); + wall.setDisplaySize(44, height).setTint(DEEP_TINT).setAlpha(0.9); + (wall.body as Phaser.Physics.Arcade.Body).setVelocityX(dir * WAVE_SPEED); + // 壽命 = 橫越全場時間+緩衝(逾時必回收 §56)。 + delay((viewW() / WAVE_SPEED) * 1000 + 500, () => wall.disableBody(true, true)); + } + }); + }; + + // 潮湧召喚(P2):吟唱抖動後由 GameScene 走正式 spawn 管線(雷化鏈電可斷召 §58)。 + const doSummon = (cap: number) => { + scene.tweens.add({ targets: body, angle: 5, duration: 60, yoyo: true, repeat: 5 }); + playSfx('boss-roar', 0.6); + delay(MARIDELLA.summonDurationMs * 0.6, () => { + if (!dying) hooks.summonFoamy(cap); + }); + }; + + // 深海月蝕(P3):水球依序繞行 → 每球爆裂前閃爍 telegraph → 環形放射彈。 + const doMoonorb = (count: number) => { + playSfx('zap', 0.6); + for (let i = 0; i < count; i += 1) { + const orb = scene.add + .image(body.x, body.y, FX_TEXTURES.dot) + .setDisplaySize(ORB_SIZE, ORB_SIZE) + .setTint(0x9ad8f0) + .setAlpha(0.95) + .setDepth(3); + orb.setData('orbIndex', i); + orb.setData('orbCount', count); + orb.setData('orbBornMs', elapsedMs); + orbs.add(orb); + const detonateAt = (i + 1) * ORB_DETONATE_STEP_MS; + // 爆裂前閃爍 telegraph(≥600ms 可讀性紅線)。 + delay(detonateAt - MARIDELLA.moonorbTelegraphMs, () => { + if (dying || !orb.active) return; + scene.tweens.add({ + targets: orb, + alpha: { from: 0.95, to: 0.3 }, + duration: 120, + yoyo: true, + repeat: Math.floor(MARIDELLA.moonorbTelegraphMs / 240), + }); + }); + delay(detonateAt, () => { + if (dying || !orb.active) return; + const { x, y } = orb; + orbs.delete(orb); + orb.destroy(); + playSfx('boss-slam', 0.8); + // 環形衝擊波:全向放射彈 ×moonorbRingShots(無重力等速)。 + for (let shotIndex = 0; shotIndex < MARIDELLA.moonorbRingShots; shotIndex += 1) { + const angle = (Math.PI * 2 * shotIndex) / MARIDELLA.moonorbRingShots; + const shot = spawnShot(x, y, false); + (shot?.body as Phaser.Physics.Arcade.Body | undefined)?.setVelocity( + Math.cos(angle) * RING_SPEED, + Math.sin(angle) * RING_SPEED, + ); + } + }); + } + }; + + const runCommand = (command: MaridellaCommand) => { + switch (command.kind) { + case 'idle': + return; + case 'current': + doCurrent(command.dir, command.holdMs); + return; + case 'droplet': + doDroplet(command.count); + return; + case 'wave': + doWave(command.fromLeft, command.gapLow); + return; + case 'summon': + doSummon(command.cap); + return; + case 'moonorb': + doMoonorb(command.count); + return; + default: { + const unhandled: never = command; + throw new Error(`未知指令:${String(unhandled)}`); + } + } + }; + + const killProjectile = (obj: Phaser.GameObjects.GameObject) => { + (obj as Phaser.Physics.Arcade.Sprite).disableBody(true, true); + }; + + const clearOrbs = () => { + for (const orb of orbs) orb.destroy(); + orbs.clear(); + }; + + // 死亡冪等(§74 慣例):defeated 由 FSM 單向鎖存,本序列僅執行一次。 + const dieSequence = () => { + dying = true; + active = false; + scene.tweens.killTweensOf(body); + vscale.killFxTweens(body); + projectiles.getMatching('active', true).forEach(killProjectile); + shockwaves.getMatching('active', true).forEach(killProjectile); + clearOrbs(); + currentBand.setFillStyle(AQUA_TINT, 0.06); + // 月蝕暗場隨擊破散去(星光回歸語彙)。 + scene.tweens.add({ targets: [eclipseOverlay, moon], fillAlpha: 0, alpha: 0, duration: 600 }); + emitGameEvent(scene.events, GameEvents.BOSS_DEFEATED, { x: body.x, y: body.y }); + delay(600, () => { + scene.tweens.add({ targets: fxScale, sx: 0, sy: 0, duration: 420, ease: 'Back.easeIn' }); + scene.tweens.add({ targets: body, alpha: 0, duration: 420, ease: 'Back.easeIn' }); + }); + }; + + const applyDamageInternal = (amount: number, source?: BossDamageSource) => { + if (!active) return; + // 雷化斷召(§58 慣例):鏈電命中吟唱中的女王立即中斷召喚。 + if (source === 'volt' && fsm.interruptSummon()) { + playSfx('break'); + scene.tweens.add({ targets: body, angle: 4, duration: 45, yoyo: true, repeat: 3 }); + } + for (const event of fsm.takeDamage(amount)) { + switch (event.kind) { + case 'damaged': + flashWhite(); + scene.tweens.add({ targets: body, angle: 3, duration: 45, yoyo: true, repeat: 2 }); + emitGameEvent(scene.events, GameEvents.BOSS_DAMAGED, { + hp: event.hp, + maxHp: fsm.maxHp, + damage: amount, + }); + break; + case 'phase': + emitGameEvent(scene.events, GameEvents.BOSS_PHASE, { phase: event.phase }); + if (event.phase === 'p2') { + // 狂暴換裝(§17 慣例):P2 起換 enraged 立繪(顯示尺寸重錨+vscale 基準回寫)。 + body.setTexture('boss-maridella-enraged').setDisplaySize(BODY_W, BODY_H); + vscale.rebase(body); + playSfx('boss-roar', 1.1); + scene.cameras.main.flash(280, 150, 210, 255); + } else if (event.phase === 'p3') { + // 深海月蝕入場:背景轉暗+水月升起(氛圍暗場,telegraph 亮度不受影響)。 + playSfx('boss-roar', 1.3); + scene.cameras.main.flash(320, 90, 140, 220); + scene.cameras.main.shake(200, 0.007); + scene.tweens.add({ + targets: eclipseOverlay, + fillAlpha: ECLIPSE_OVERLAY_ALPHA, + duration: 900, + }); + moon.setAlpha(0); + scene.tweens.add({ targets: moon, alpha: 0.85, y: 96, duration: 1200 }); + } + break; + case 'minionDrop': + minionHandlers.forEach((handler) => handler()); + break; + case 'defeated': + dieSequence(); + break; + default: { + const unhandled: never = event; + throw new Error(`未知事件:${String(unhandled)}`); + } + } + } + }; + + // 懸浮錨點(§64 慣例):相位導出目標、速度上限逼近——接管交還必為連續飛行。 + const anchorPoint = () => ({ + x: arenaCx() + Math.sin(elapsedMs * SWAY_FREQ) * viewW() * SWAY_AMP_RATIO, + y: HOVER_Y + Math.sin(elapsedMs * BOB_FREQ) * BOB_AMP_PX, + }); + + // 入場:潮汐女王自潮面升起(與滑入/降臨型區隔——「湧升」語彙)。 + const introRise = () => { + body.setPosition(arenaCx(), GROUND_TOP + BODY_H); + scene.tweens.add({ + targets: body, + y: HOVER_Y, + duration: 1000, + ease: 'Sine.easeOut', + onComplete: () => { + playSfx('boss-slam'); + scene.cameras.main.shake(120, 0.006); + introRoar(); + }, + }); + }; + + const introRoar = () => { + emitGameEvent(scene.events, GameEvents.BOSS_SPAWNED, { maxHp: fsm.maxHp }); + playSfx('boss-roar'); + scene.tweens.add({ + targets: fxScale, + sx: 1.12, + sy: 1.1, + duration: 170, + yoyo: true, + repeat: 1, + ease: 'Sine.easeInOut', + }); + delay(INTRO_ROAR_MS, introReset); + }; + + const introReset = () => { + const cam = scene.cameras.main; + cam.pan(arenaCx(), VIEW.height / 2, INTRO_RESET_MS, 'Sine.easeInOut'); + cam.zoomTo(1, INTRO_RESET_MS, 'Sine.easeInOut'); + delay(INTRO_RESET_MS, () => { + active = true; + // EX 入場變色(§58 慣例):緋紅呼吸循環作為變體識別基調。 + if (ex) { + scene.tweens.addCounter({ + from: 0, + to: 1, + duration: 900, + yoyo: true, + repeat: -1, + onUpdate: (tween) => { + if (dying) return; + const v = tween.getValue() ?? 0; + const mix = (a: number, b: number) => Math.round(a + (b - a) * v); + body.setTint((mix(255, 216) << 16) | (mix(255, 75) << 8) | mix(255, 106)); + }, + }); + } + }); + }; + + return { + spawn() { + const cam = scene.cameras.main; + const [red, green, blue] = INTRO_FADE_RGB; + const focusX = arenaCx(); + const focusY = VIEW.height / INTRO_ZOOM / 2; + cam.fadeOut(INTRO_FADE_MS, red, green, blue); + cam.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => { + cam.pan(focusX, focusY, INTRO_PUSH_MS, 'Sine.easeInOut'); + cam.zoomTo(INTRO_ZOOM, INTRO_PUSH_MS, 'Sine.easeInOut'); + cam.fadeIn(INTRO_FADE_MS + 140, red, green, blue); + delay(INTRO_PUSH_MS * 0.5, introRise); + }); + }, + applyDamage(amount: number, source?: BossDamageSource) { + applyDamageInternal(amount, source); + }, + update(deltaMs: number) { + if (!active || dying) return; + elapsedMs += deltaMs; + // 距離帶餵送(§111.1 條件欄)。 + fsm.setTargetDistance( + target ? Phaser.Math.Distance.Between(body.x, body.y, target.x, target.y) : null, + ); + const command = fsm.tick(deltaMs); + if (command) runCommand(command); + // 懸浮駕駛:一律 approachPoint 逼近錨點(§64 慣例禁座標直寫)。 + const next = approachPoint( + { x: body.x, y: body.y }, + anchorPoint(), + APPROACH_SPEED * fsm.speedFactor, + deltaMs, + ); + body.setPosition(next.x, next.y); + body.setFlipX((target?.x ?? body.x) < body.x); + body.setRotation(fsm.state === 'idle' ? 0.04 : 0); + // 潮線推移結算:地面帶內水平 positional drift(恆低於玩家全速,交叉不變式 16); + // 潮化免推(§119 穩控)——零傷推移,非傷害管線。 + if (target && elapsedMs < currentUntilMs) { + if (target.y > CURRENT_BAND_Y && hooks.playerForm() !== 'tide') { + target.x += currentDir * MARIDELLA.currentPushPxPerSec * (deltaMs / 1000); + } + } else if (currentUntilMs > 0 && elapsedMs >= currentUntilMs) { + currentUntilMs = 0; + currentBand.setFillStyle(AQUA_TINT, 0.06); + } + // 月蝕水球繞行(視覺體,錨定本體軌道)。 + for (const orb of orbs) { + const index = (orb.getData('orbIndex') as number) ?? 0; + const count = (orb.getData('orbCount') as number) ?? 1; + const born = (orb.getData('orbBornMs') as number) ?? elapsedMs; + const angle = ((Math.PI * 2) / count) * index + (elapsedMs - born) * 0.0024; + orb.setPosition( + body.x + Math.cos(angle) * ORB_RADIUS_PX, + body.y + Math.sin(angle) * ORB_RADIUS_PX, + ); + } + // 出界/落地回收(anti-softlock §56 投射物壽命慣例)。 + projectiles.getMatching('active', true).forEach((obj) => { + const shot = obj as Phaser.Physics.Arcade.Sprite; + if ( + shot.y > GROUND_TOP + 20 || + shot.y < -40 || + shot.x < arenaLeft() - 60 || + shot.x > arenaLeft() + viewW() + 60 + ) { + killProjectile(shot); + } + }); + // 海嘯牆出界回收。 + shockwaves.getMatching('active', true).forEach((obj) => { + const wave = obj as Phaser.Physics.Arcade.Sprite; + if (wave.x < arenaLeft() - 120 || wave.x > arenaLeft() + viewW() + 120) { + killProjectile(wave); + } + }); + }, + destroy() { + timers.forEach((timer) => timer.remove(false)); + scene.tweens.killTweensOf(body); + vscale.unregister(body); + body.destroy(); + projectiles.destroy(true); + shockwaves.destroy(true); + clearOrbs(); + currentBand.destroy(); + eclipseOverlay.destroy(); + moon.destroy(); + }, + isActive() { + return active; + }, + // 懸浮場控型免暈(§74 慣例):輸出窗來自 idle 僵直,下砸命中僅回彈免體傷。 + trySlamStun() { + return false; + }, + getBody() { + return body; + }, + getProjectiles() { + return projectiles; + }, + getShockwaves() { + return shockwaves; + }, + setTarget(next: { x: number; y: number } | null) { + target = next; + }, + onMinionDrop(handler: () => void) { + minionHandlers.push(handler); + }, + // e2e 觀測(§83 慣例):FSM 階段/招式即時值。 + getDebugState() { + return { phase: fsm.phase, state: fsm.state }; + }, + }; +} diff --git a/apps/starpuff/src/game/systems/tariffang.ts b/apps/starpuff/src/game/systems/tariffang.ts new file mode 100644 index 000000000..2518d7e02 --- /dev/null +++ b/apps/starpuff/src/game/systems/tariffang.ts @@ -0,0 +1,631 @@ +import Phaser from 'phaser'; +import { acquirePooled } from '../core/poolFlags'; +import { VIEW } from '../core/config'; +import { GameEvents, emitGameEvent } from '../core/events'; +import { approachPoint } from '../logic/noctraFlight'; +import { TARIFFANG, createTariffangFsm, type TariffangCommand } from '../logic/tariffangFsm'; +import { playSfx } from '../audio/sfx'; +import type { BossDamageSource, BossHandle } from './boss'; +import { FX_TEXTURES, ensureFxTextures, spawnTelegraph } from './fx'; +import { getVisualScale } from './visualScale'; + +// 關稅巨獸 Tariffang 呈現層(GAME_DESIGN §122):與 boss/noctra/prismix/syrona/voidra +// 共用 BossHandle。地面關卡官型:本體駐守海關崗哨(衝撞後換側),威脅來自貨櫃地形 +// 改寫、追蹤稅票與封關閘門;phase truth 由 logic/tariffangFsm.ts 持有,本模組僅結算 +// 演出/幾何/物理。arena 幾何全數依動態視寬比例佈建(§28 禁硬編 854)。 + +const GROUND_TOP = VIEW.height - 80; +const BODY_W = 170; +const BODY_H = 150; +const STAND_Y = GROUND_TOP - BODY_H / 2; +// 崗哨雙側位(衝撞後換側駐守);歸位沿 approachPoint 逼近(§64 慣例禁座標直寫)。 +const STATION_RATIOS = [0.78, 0.22] as const; +const APPROACH_SPEED = 300; +const RAM_SPEED = 560; +const BRASS_TINT = 0xc8a04a; +const CRATE_TINT = 0xb07840; +const GATE_TINT = 0x8a6a3a; +const TICKET_TINT = 0xffd966; +// 貨櫃:地面滑入的移動地形危害;高度低於平台頂(328)保平台避讓路線。 +const CRATE_W = 90; +const CRATE_H = 52; +const CRATE_SPEED = 200; +const CRATE_STAGGER_MS = 700; +// 關稅槌:高空落點打擊(直墜)。 +const STAMP_FALL_SPEED = 420; +const STAMP_SPREAD_PX = 130; +// 稅票:緩速追蹤後直線(沿 §30 jellord 追蹤彈 homingMs 慣例)。 +const TICKET_TRACK_SPEED = 110; +const TICKET_STRAIGHT_SPEED = 260; +const TICKET_TRACK_MS = 2200; +// 封關閘門:雙側寬區由上而下關閉,中央走廊(30%–70%)恆開(anti-softlock)。 +const GATE_ZONE_RATIOS = [0.15, 0.85] as const; +const GATE_W_RATIO = 0.3; +const GATE_H = 340; +const GATE_DESCEND_MS = 600; +const GATE_ASCEND_MS = 400; +// arena 浮台 ×3(§74 慣例,getPlatforms 接 GameScene collider):低台 ×2 貨櫃避讓、 +// 高台 ×1(208)滿拍翅可達的衝撞避難位;全數落於封關走廊內。 +const PLATFORM_SPECS = [ + { ratio: 0.34, y: 336 }, + { ratio: 0.5, y: 208 }, + { ratio: 0.66, y: 336 }, +] as const; +const PLATFORM_W = 140; + +// 入場運鏡(§17 慣例):黑幕淡入 → 推近崗哨 → 巨獸滑入蓋章 → 咆哮 → 復位開戰。 +const INTRO_FADE_MS = 280; +const INTRO_PUSH_MS = 1200; +const INTRO_ZOOM = 1.42; +const INTRO_ROAR_MS = 820; +const INTRO_RESET_MS = 550; +const INTRO_FADE_RGB = [36, 28, 16] as const; + +// 佔位材質:正式 sprite 缺件時以海關銅金圓體烘焙保底(同 boss.ts 慣例)。 +function ensureTextures(scene: Phaser.Scene): void { + const bake = (key: string, color: number, w: number, h: number): void => { + if (scene.textures.exists(key)) return; + const g = scene.make.graphics({ x: 0, y: 0 }, false); + g.fillStyle(color, 1); + g.fillEllipse(w / 2, h / 2, w, h); + g.generateTexture(key, w, h); + g.destroy(); + }; + bake('boss-tariffang', BRASS_TINT, BODY_W, BODY_H); + bake('boss-tariffang-enraged', 0xd8784a, BODY_W, BODY_H); + if (!scene.textures.exists('tariffang-ticket')) { + const g = scene.make.graphics({ x: 0, y: 0 }, false); + g.fillStyle(TICKET_TINT, 1); + g.fillRoundedRect(0, 0, 22, 15, 4); + g.generateTexture('tariffang-ticket', 22, 15); + g.destroy(); + } +} + +export interface TariffangHooks { + // 焰化燒稅票(§119 burn 命中端結算):星彈群由 GameScene 的 player 供給, + // burn 星彈命中稅票即燒毀(穿透續飛);一般星彈不與稅票互動(吸入才是清票解)。 + playerStars(): Phaser.Physics.Arcade.Group; +} + +export interface TariffangOptions { + ex?: boolean; + // 前室魔王關(§69):arena 左緣由 GameScene 注入(前室寬),佈局禁硬編視寬。 + arenaLeft(): number; +} + +export function createTariffang( + scene: Phaser.Scene, + hooks: TariffangHooks, + options: TariffangOptions, +): BossHandle { + ensureTextures(scene); + ensureFxTextures(scene); + + const ex = options.ex === true; + const fsm = createTariffangFsm({ ex }); + const minionHandlers: (() => void)[] = []; + const timers: Phaser.Time.TimerEvent[] = []; + let active = false; + let dying = false; + let target: { x: number; y: number } | null = null; + let elapsedMs = 0; + // 崗哨側位索引(衝撞抵達後翻轉)與衝撞目標(null=駐守歸位)。 + let stationIndex = 0; + let ramTargetX: number | null = null; + let starsOverlapWired = false; + + const viewW = () => scene.scale.width; + const arenaLeft = () => options.arenaLeft(); + const arenaX = (ratio: number) => arenaLeft() + viewW() * ratio; + const arenaCx = () => arenaLeft() + viewW() / 2; + const stationX = () => arenaX(STATION_RATIOS[stationIndex] ?? 0.78); + + const body = scene.physics.add.sprite(arenaX(0.78), -BODY_H, 'boss-tariffang'); + body.setDisplaySize(BODY_W, BODY_H); + // 物理/視覺縮放解耦(§77 根治):脈動/死亡收縮走 fx 代理,物理箱恆為基準。 + const vscale = getVisualScale(scene); + vscale.register(body); + const fxScale = vscale.fx(body); + const physBody = body.body as Phaser.Physics.Arcade.Body; + physBody.setAllowGravity(false); + physBody.setImmovable(true); + physBody.setSize(body.width * 0.85, body.height * 0.85); + + const projectiles = scene.physics.add.group({ maxSize: 24, allowGravity: false }); + const shockwaves = scene.physics.add.group({ maxSize: 12, allowGravity: false }); + + // arena 浮台(§74 慣例):單向靜態平台;collider 由 GameScene 接線(getPlatforms)。 + const platforms = PLATFORM_SPECS.map((spec) => { + const platform = scene.add.rectangle( + arenaX(spec.ratio), + spec.y, + PLATFORM_W, + 16, + 0xe8c890, + 0.95, + ); + scene.physics.add.existing(platform, true); + const platformBody = platform.body as Phaser.Physics.Arcade.StaticBody; + platformBody.checkCollision.down = false; + platformBody.checkCollision.left = false; + platformBody.checkCollision.right = false; + return platform; + }); + + const delay = (ms: number, fn: () => void) => { + timers.push(scene.time.delayedCall(ms, fn)); + }; + + const flashWhite = () => { + body.setTint(0xffffff).setTintMode(Phaser.TintModes.FILL); + delay(90, () => { + body.setTintMode(Phaser.TintModes.MULTIPLY); + body.clearTint(); + }); + }; + + // 場上存活稅票數(被動加收夾限用;呈現層事實)。 + const aliveTickets = (): number => + projectiles.getMatching('active', true).filter((obj) => { + return (obj as Phaser.Physics.Arcade.Sprite).getData('ticket') === true; + }).length; + + // 追蹤稅票(§122 主題):吸入回收為彈藥(inhalable 旗標走 overlaps 既有管線)、 + // 焰化 burn 星彈可燒毀;壽命有界逾時必回收(§56)。 + const spawnTicket = (x: number, y: number) => { + const ticket = acquirePooled(projectiles, x, y, 'tariffang-ticket'); + if (!ticket) return; + ticket.enableBody(true, x, y, true, true); + ticket.setTexture('tariffang-ticket'); + ticket.setDisplaySize(22, 15); + ticket.setTint(TICKET_TINT); + ticket.setData('ticket', true); + ticket.setData('inhalable', true); + ticket.setData('homingMs', TICKET_TRACK_MS); + ticket.setData('ticketUntil', elapsedMs + TARIFFANG.ticketLifeMs); + const ticketBody = ticket.body as Phaser.Physics.Arcade.Body; + ticketBody.setAllowGravity(false); + ticketBody.setVelocity(Phaser.Math.Between(-90, 90), Phaser.Math.Between(-200, -120)); + playSfx('pop', 0.9); + }; + + // 貨物稽查(P1/P2):「檢查中」邊緣警示 → 貨櫃沿地面滑入橫越全場; + // 一般單側錯拍(可背板)、EX 雙向同時(質性差分)。 + const doCargo = (count: number, bothSides: boolean) => { + for (let wave = 0; wave < count; wave += 1) { + const fromLeft = bothSides ? true : (target?.x ?? arenaCx()) > arenaCx() ? false : true; + const sides = bothSides ? [true, false] : [wave % 2 === 0 ? fromLeft : !fromLeft]; + delay(wave * CRATE_STAGGER_MS, () => { + if (dying) return; + for (const left of sides) { + const startX = left ? arenaLeft() + 30 : arenaLeft() + viewW() - 30; + spawnTelegraph(scene, startX, GROUND_TOP - CRATE_H / 2, TARIFFANG.cargoTelegraphMs); + delay(TARIFFANG.cargoTelegraphMs, () => { + if (dying) return; + const crate = acquirePooled(shockwaves, startX, GROUND_TOP - CRATE_H / 2, '__WHITE'); + if (!crate) return; + crate.enableBody(true, startX, GROUND_TOP - CRATE_H / 2, true, true); + crate.setDisplaySize(CRATE_W, CRATE_H).setTint(CRATE_TINT).setAlpha(0.95); + (crate.body as Phaser.Physics.Arcade.Body).setVelocityX( + left ? CRATE_SPEED : -CRATE_SPEED, + ); + playSfx('metal', 0.7); + // 壽命 = 橫越全場時間+緩衝(逾時必回收 §56)。 + delay((viewW() / CRATE_SPEED) * 1000 + 600, () => crate.disableBody(true, true)); + }); + } + }); + } + }; + + // 關稅槌(P1/P2):玩家位置夾擊三落點 telegraph → 槌印直墜。 + const doStamp = (count: number) => { + flashWhite(); + const centerX = target?.x ?? arenaCx(); + for (let i = 0; i < count; i += 1) { + const x = Phaser.Math.Clamp( + centerX + (i - (count - 1) / 2) * STAMP_SPREAD_PX, + arenaLeft() + 40, + arenaLeft() + viewW() - 40, + ); + spawnTelegraph(scene, x, GROUND_TOP - 8, TARIFFANG.stampTelegraphMs); + delay(TARIFFANG.stampTelegraphMs, () => { + if (dying) return; + const stamp = acquirePooled(projectiles, x, 30, '__WHITE'); + if (!stamp) return; + stamp.enableBody(true, x, 30, true, true); + stamp.setTexture('__WHITE'); + stamp.setDisplaySize(34, 30).setTint(BRASS_TINT).setAlpha(0.95); + stamp.setData('homingMs', 0); + const stampBody = stamp.body as Phaser.Physics.Arcade.Body; + stampBody.setAllowGravity(false); + stampBody.setVelocity(0, STAMP_FALL_SPEED); + playSfx('pop', 0.7); + }); + } + }; + + // 主動查帳(P2/P3):舉章 telegraph 後撒出追蹤稅票(夾限場上上限)。 + const doLevy = (count: number) => { + scene.tweens.add({ targets: body, angle: -8, duration: 150, yoyo: true, repeat: 1 }); + playSfx('boss-roar', 0.5); + delay(TARIFFANG.levyTelegraphMs, () => { + if (dying) return; + const room = Math.max(0, fsm.ticketCap - aliveTickets()); + for (let i = 0; i < Math.min(count, room); i += 1) { + delay(i * 160, () => { + if (!dying) spawnTicket(body.x, body.y - BODY_H * 0.4); + }); + } + }); + }; + + // 全面封關(P3):雙側閘門由上而下關閉(中央走廊恆開);駐留後升起。 + const doGate = (holdMs: number) => { + playSfx('boss-slam', 0.6); + for (const ratio of GATE_ZONE_RATIOS) { + const x = arenaX(ratio); + const gateW = viewW() * GATE_W_RATIO; + spawnTelegraph(scene, x, 60, TARIFFANG.gateTelegraphMs); + delay(TARIFFANG.gateTelegraphMs, () => { + if (dying) return; + const gate = acquirePooled(shockwaves, x, -GATE_H / 2, '__WHITE'); + if (!gate) return; + gate.enableBody(true, x, -GATE_H / 2, true, true); + gate.setDisplaySize(gateW, GATE_H).setTint(GATE_TINT).setAlpha(0.88); + (gate.body as Phaser.Physics.Arcade.Body).setVelocity(0, 0); + playSfx('metal', 0.9); + scene.tweens.add({ + targets: gate, + y: GROUND_TOP - GATE_H / 2 + 10, + duration: GATE_DESCEND_MS, + ease: 'Sine.easeIn', + onComplete: () => { + scene.cameras.main.shake(90, 0.004); + delay(holdMs, () => { + if (!gate.active) return; + scene.tweens.add({ + targets: gate, + y: -GATE_H / 2, + duration: GATE_ASCEND_MS, + ease: 'Sine.easeIn', + onComplete: () => gate.disableBody(true, true), + }); + }); + }, + }); + }); + } + }; + + // 高速衝撞(P3):三拍閃白前搖 → 直線衝向對側崗哨(跳+拍翅可越,§58 慣例); + // 抵達即換側駐守,位移全程走 approachPoint 速度上限(禁瞬移)。 + const doRam = () => { + for (let i = 0; i < 3; i += 1) { + delay((TARIFFANG.ramTelegraphMs / 3) * i, () => { + if (!dying) flashWhite(); + }); + } + scene.tweens.add({ targets: body, angle: 6, duration: 80, yoyo: true, repeat: 4 }); + delay(TARIFFANG.ramTelegraphMs, () => { + if (dying) return; + stationIndex = stationIndex === 0 ? 1 : 0; + ramTargetX = stationX(); + playSfx('boss-slam'); + }); + }; + + const runCommand = (command: TariffangCommand) => { + switch (command.kind) { + case 'idle': + return; + case 'cargo': + doCargo(command.count, command.bothSides); + return; + case 'stamp': + doStamp(command.count); + return; + case 'levy': + doLevy(command.count); + return; + case 'gate': + doGate(command.holdMs); + return; + case 'ram': + doRam(); + return; + default: { + const unhandled: never = command; + throw new Error(`未知指令:${String(unhandled)}`); + } + } + }; + + const killProjectile = (obj: Phaser.GameObjects.GameObject) => { + (obj as Phaser.Physics.Arcade.Sprite).disableBody(true, true); + }; + + // 焰化燒稅票(§119 形態優勢):burn 星彈命中稅票即燒毀且穿透續飛; + // 一般星彈不與稅票互動(吸入清票/走位等票期滿為保底解)。 + const wireStarsOverlap = () => { + if (starsOverlapWired) return; + starsOverlapWired = true; + scene.physics.add.overlap(hooks.playerStars(), projectiles, (a, b) => { + const aIsProjectile = (projectiles.getChildren() as unknown[]).includes(a); + const ticket = (aIsProjectile ? a : b) as Phaser.Physics.Arcade.Sprite; + const star = (aIsProjectile ? b : a) as Phaser.Physics.Arcade.Sprite; + if (!star.active || !ticket.active) return; + if (ticket.getData('ticket') !== true) return; + if (star.getData('burn') !== true) return; + ticket.disableBody(true, true); + // 燒毀演出:單次爆散後即銷毀發射器(不入長駐粒子預算)。 + const burnFx = scene.add + .particles(ticket.x, ticket.y, FX_TEXTURES.dot, { + speed: { min: 40, max: 110 }, + scale: { start: 0.5, end: 0 }, + alpha: { start: 0.9, end: 0 }, + lifespan: 320, + tint: [0xff8a50, 0xffd966], + emitting: false, + }) + .setDepth(4); + burnFx.explode(6); + delay(420, () => burnFx.destroy()); + playSfx('break', 0.8); + }); + }; + + // 死亡冪等(§74 慣例):defeated 由 FSM 單向鎖存,本序列僅執行一次。 + const dieSequence = () => { + dying = true; + active = false; + ramTargetX = null; + scene.tweens.killTweensOf(body); + vscale.killFxTweens(body); + projectiles.getMatching('active', true).forEach(killProjectile); + shockwaves.getMatching('active', true).forEach(killProjectile); + emitGameEvent(scene.events, GameEvents.BOSS_DEFEATED, { x: body.x, y: body.y }); + delay(600, () => { + scene.tweens.add({ targets: fxScale, sx: 0, sy: 0, duration: 420, ease: 'Back.easeIn' }); + scene.tweens.add({ targets: body, alpha: 0, duration: 420, ease: 'Back.easeIn' }); + }); + }; + + const applyDamageInternal = (amount: number, source?: BossDamageSource) => { + if (!active) return; + // 加收費用(§122 P2 主題):星彈命中即生追蹤稅票(FSM 節流+場上上限夾限)。 + if (source === 'star' && fsm.tryTax() && aliveTickets() < fsm.ticketCap) { + spawnTicket(body.x, body.y - BODY_H * 0.4); + } + for (const event of fsm.takeDamage(amount)) { + switch (event.kind) { + case 'damaged': + flashWhite(); + scene.tweens.add({ targets: body, angle: 3, duration: 45, yoyo: true, repeat: 2 }); + emitGameEvent(scene.events, GameEvents.BOSS_DAMAGED, { + hp: event.hp, + maxHp: fsm.maxHp, + damage: amount, + }); + break; + case 'phase': + emitGameEvent(scene.events, GameEvents.BOSS_PHASE, { phase: event.phase }); + // 狂暴換裝(§17 慣例):P2 起換 enraged 立繪(顯示尺寸重錨+vscale 基準回寫)。 + if (event.phase === 'p2') { + body.setTexture('boss-tariffang-enraged').setDisplaySize(BODY_W, BODY_H); + vscale.rebase(body); + playSfx('boss-roar', 1.1); + scene.cameras.main.flash(280, 255, 190, 120); + } else if (event.phase === 'p3') { + playSfx('boss-roar', 1.3); + scene.cameras.main.flash(320, 255, 150, 90); + scene.cameras.main.shake(200, 0.007); + } + break; + case 'minionDrop': + minionHandlers.forEach((handler) => handler()); + break; + case 'defeated': + dieSequence(); + break; + default: { + const unhandled: never = event; + throw new Error(`未知事件:${String(unhandled)}`); + } + } + } + }; + + // 入場:關稅巨獸自右側滑入崗哨(與升起/降臨型區隔——「進站」語彙)。 + const introSlide = () => { + body.setPosition(arenaLeft() + viewW() + BODY_W, STAND_Y); + scene.tweens.add({ + targets: body, + x: arenaX(0.78), + duration: 900, + ease: 'Sine.easeOut', + onComplete: () => { + playSfx('boss-slam'); + scene.cameras.main.shake(120, 0.006); + introRoar(); + }, + }); + }; + + const introRoar = () => { + emitGameEvent(scene.events, GameEvents.BOSS_SPAWNED, { maxHp: fsm.maxHp }); + playSfx('boss-roar'); + scene.tweens.add({ + targets: fxScale, + sx: 1.12, + sy: 1.1, + duration: 170, + yoyo: true, + repeat: 1, + ease: 'Sine.easeInOut', + }); + delay(INTRO_ROAR_MS, introReset); + }; + + const introReset = () => { + const cam = scene.cameras.main; + cam.pan(arenaCx(), VIEW.height / 2, INTRO_RESET_MS, 'Sine.easeInOut'); + cam.zoomTo(1, INTRO_RESET_MS, 'Sine.easeInOut'); + delay(INTRO_RESET_MS, () => { + active = true; + // EX 入場變色(§58 慣例):緋紅呼吸循環作為變體識別基調。 + if (ex) { + scene.tweens.addCounter({ + from: 0, + to: 1, + duration: 900, + yoyo: true, + repeat: -1, + onUpdate: (tween) => { + if (dying) return; + const v = tween.getValue() ?? 0; + const mix = (a: number, b: number) => Math.round(a + (b - a) * v); + body.setTint((mix(255, 216) << 16) | (mix(255, 75) << 8) | mix(255, 106)); + }, + }); + } + }); + }; + + return { + spawn() { + wireStarsOverlap(); + const cam = scene.cameras.main; + const [red, green, blue] = INTRO_FADE_RGB; + const focusX = arenaX(0.78); + const focusY = VIEW.height / INTRO_ZOOM / 2; + cam.fadeOut(INTRO_FADE_MS, red, green, blue); + cam.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => { + cam.pan(focusX, focusY, INTRO_PUSH_MS, 'Sine.easeInOut'); + cam.zoomTo(INTRO_ZOOM, INTRO_PUSH_MS, 'Sine.easeInOut'); + cam.fadeIn(INTRO_FADE_MS + 140, red, green, blue); + delay(INTRO_PUSH_MS * 0.5, introSlide); + }); + }, + applyDamage(amount: number, source?: BossDamageSource) { + applyDamageInternal(amount, source); + }, + update(deltaMs: number) { + if (!active || dying) return; + elapsedMs += deltaMs; + // 距離帶餵送(§111.1 條件欄)。 + fsm.setTargetDistance( + target ? Phaser.Math.Distance.Between(body.x, body.y, target.x, target.y) : null, + ); + const command = fsm.tick(deltaMs); + if (command) runCommand(command); + // 駐守/衝撞一律 approachPoint 速度上限逼近(§64 慣例禁座標直寫): + // 衝撞期以 RAM_SPEED 直奔對側崗哨,抵達即回駐守節奏。 + const bob = Math.sin(elapsedMs * 0.0022) * 4; + const ramming = ramTargetX !== null; + const goal = ramming + ? { x: ramTargetX ?? stationX(), y: STAND_Y } + : { x: stationX(), y: STAND_Y + bob }; + const next = approachPoint( + { x: body.x, y: body.y }, + goal, + (ramming ? RAM_SPEED : APPROACH_SPEED) * fsm.speedFactor, + deltaMs, + ); + body.setPosition(next.x, next.y); + if (ramming && Math.abs(body.x - (ramTargetX ?? 0)) < 2) { + ramTargetX = null; + scene.cameras.main.shake(110, 0.005); + playSfx('metal', 0.8); + } + body.setFlipX((target?.x ?? body.x) < body.x); + body.setRotation(fsm.state === 'idle' ? 0.05 : 0); + // 稅票追蹤(沿 §30 homingMs 慣例)+壽命逾時回收(§56);出界回收。 + projectiles.getMatching('active', true).forEach((obj) => { + const shot = obj as Phaser.Physics.Arcade.Sprite; + const shotBody = shot.body as Phaser.Physics.Arcade.Body; + if (shot.getData('ticket') === true) { + if (elapsedMs >= ((shot.getData('ticketUntil') as number) ?? 0)) { + killProjectile(shot); + return; + } + const homingMs = (shot.getData('homingMs') as number | undefined) ?? 0; + if (homingMs > 0) { + const remaining = homingMs - deltaMs; + shot.setData('homingMs', Math.max(0, remaining)); + if (remaining > 0 && target) { + scene.physics.moveTo(shot, target.x, target.y, TICKET_TRACK_SPEED); + } else { + const heading = Math.atan2(shotBody.velocity.y, shotBody.velocity.x); + shotBody.setVelocity( + Math.cos(heading) * TICKET_STRAIGHT_SPEED, + Math.sin(heading) * TICKET_STRAIGHT_SPEED, + ); + } + } + shot.setRotation(shot.rotation + deltaMs * 0.006); + } + if ( + shot.y > GROUND_TOP + 20 || + shot.y < -60 || + shot.x < arenaLeft() - 60 || + shot.x > arenaLeft() + viewW() + 60 + ) { + killProjectile(shot); + } + }); + // 貨櫃出界回收(閘門由 tween 生命週期自管)。 + shockwaves.getMatching('active', true).forEach((obj) => { + const wave = obj as Phaser.Physics.Arcade.Sprite; + if (wave.x < arenaLeft() - 120 || wave.x > arenaLeft() + viewW() + 120) { + killProjectile(wave); + } + }); + }, + destroy() { + timers.forEach((timer) => timer.remove(false)); + scene.tweens.killTweensOf(body); + vscale.unregister(body); + body.destroy(); + projectiles.destroy(true); + shockwaves.destroy(true); + platforms.forEach((platform) => platform.destroy()); + }, + isActive() { + return active; + }, + // 頭頂 hit window(§58):下砸命中頭頂觸發短暈(衝撞中免疫,FSM 裁決)。 + trySlamStun() { + if (!active || dying) return false; + const stunned = fsm.stun(TARIFFANG.slamStunMs); + if (stunned) { + scene.tweens.add({ targets: body, angle: -10, duration: 120, yoyo: true, repeat: 2 }); + } + return stunned; + }, + getBody() { + return body; + }, + getProjectiles() { + return projectiles; + }, + getShockwaves() { + return shockwaves; + }, + setTarget(next: { x: number; y: number } | null) { + target = next; + }, + onMinionDrop(handler: () => void) { + minionHandlers.push(handler); + }, + // arena 浮台(§74 慣例):GameScene 接玩家 collider。 + getPlatforms() { + return platforms; + }, + // e2e 觀測(§83 慣例):FSM 階段/招式即時值。 + getDebugState() { + return { phase: fsm.phase, state: fsm.state }; + }, + }; +} diff --git a/apps/starpuff/src/seo/seo-metadata.test.ts b/apps/starpuff/src/seo/seo-metadata.test.ts index c3a955a36..f33262f96 100644 --- a/apps/starpuff/src/seo/seo-metadata.test.ts +++ b/apps/starpuff/src/seo/seo-metadata.test.ts @@ -47,13 +47,21 @@ describe('seo-metadata SSOT', () => { expect(emojiPattern.test(buildSeoBody())).toBe(false); }); - it('五區二十關與五魔王對照完整', () => { - expect(WORLD_ZONES).toHaveLength(5); + it('七區與七魔王對照完整(§122 星海終局篇六/七區入編)', () => { + expect(WORLD_ZONES).toHaveLength(7); const bosses = WORLD_ZONES.map((z) => z.boss).join(''); - for (const name of ['果凍王', '暗月蝠王', '稜晶雙子', '熔糖窯后', '蝕星魔核']) { + for (const name of [ + '果凍王', + '暗月蝠王', + '稜晶雙子', + '熔糖窯后', + '蝕星魔核', + '關稅巨獸', + '潮汐女王', + ]) { expect(bosses).toContain(name); } - expect(WORLD_ZONES[WORLD_ZONES.length - 1]?.levels).toBe('L17-L20'); + expect(WORLD_ZONES[WORLD_ZONES.length - 1]?.levels).toBe('L23-L24'); }); }); @@ -124,7 +132,7 @@ describe('buildSeoHead / buildSeoBody', () => { expect(sectionIndex).toBeGreaterThanOrEqual(0); expect(sectionIndex).toBeLessThan(noscriptIndex); expect(body).toContain('

'); - for (const heading of ['遊戲特色', '五大區域與魔王', '操作方式', '安裝與離線遊玩']) { + for (const heading of ['遊戲特色', '世界區域與魔王', '操作方式', '安裝與離線遊玩']) { expect(body).toContain(heading); } for (const zone of WORLD_ZONES) { diff --git a/apps/starpuff/src/seo/seo-metadata.ts b/apps/starpuff/src/seo/seo-metadata.ts index 6ece6eeb4..3484b32e1 100644 --- a/apps/starpuff/src/seo/seo-metadata.ts +++ b/apps/starpuff/src/seo/seo-metadata.ts @@ -30,7 +30,7 @@ export const SCREENSHOT_URL = `${SITE_URL}icons/screenshot-gameplay.jpg`; export const DATE_PUBLISHED = '2026-07-14'; // sitemap lastmod 政策(Google 官方):反映內容的重大更新日,禁止隨每次 build 自動跳動。 // SEO 內容(文案/結構化資料/llms)有實質變更時,隨該 PR 手動更新此常數。 -export const SEO_CONTENT_LASTMOD = '2026-07-19'; +export const SEO_CONTENT_LASTMOD = '2026-07-27'; // 各區魔王行銷文案(區名與關卡區間由 zones.ts 推導,僅魔王描述在此維護)。 const ZONE_BOSSES: Record> = { @@ -39,6 +39,9 @@ const ZONE_BOSSES: Record> 3: { boss: '稜晶雙子 Prismix', bossEn: 'Prismix', trait: '雙血條同場作戰的稜晶孿生體' }, 4: { boss: '熔糖窯后 Syrona', bossEn: 'Syrona', trait: '噴泉洗牌與窯風三連的場控女王' }, 5: { boss: '蝕星魔核 Voidra', bossEn: 'Voidra', trait: '生存段與星核共鳴的最終魔王' }, + // §122 星海終局篇 W2:六/七區魔王隨 L22/L24 入編補文案。 + 6: { boss: '關稅巨獸 Tariffang', bossEn: 'Tariffang', trait: '貨櫃稽查與追蹤稅票的星港關稅官' }, + 7: { boss: '潮汐女王 Maridella', bossEn: 'Maridella', trait: '潮線改道與深海月蝕的潮灣女王' }, }; // 分區與魔王對照:分區資料直接取自遊戲內 ZONES SSOT;§121 星海終局篇新區的魔王 @@ -55,7 +58,7 @@ export const WORLD_ZONES: BossEntry[] = ZONES.filter((zone) => ZONE_BOSSES[zone. export const GAME_FEATURES = [ '完全免費、無廣告、免註冊,開啟瀏覽器即玩', - '五大區域二十道關卡,五大魔王與 EX 挑戰變體', + '七大區域二十四道關卡,七大魔王與 EX 挑戰變體', '吸入果凍怪化為九系星彈:穿透、爆裂、雷鏈、緩速、迴旋與雙味混合配方', '星化變身爆發輸出,搭配漂浮、下砸等動作技巧', 'PWA 可安裝到主畫面,離線也能完整遊玩', @@ -172,15 +175,15 @@ export function buildSeoBody(): string {

${escapeHtml(SEO_TITLE)}

星噗噗(StarPuff)是一款繁體中文原創 IP 的免費橫向捲軸動作網頁遊戲:操控 Q - 彈果凍球「噗噗」張大嘴吸入果凍怪、把牠們化為星彈反擊,一路闖越果凍星球的五大區域、二十道關卡, - 挑戰五位風格迥異的魔王。免下載、免安裝、免註冊,手機、平板與電腦開啟瀏覽器即可遊玩; + 彈果凍球「噗噗」張大嘴吸入果凍怪、把牠們化為星彈反擊,一路闖越果凍星球的七大區域、二十四道關卡, + 挑戰七位風格迥異的魔王。免下載、免安裝、免註冊,手機、平板與電腦開啟瀏覽器即可遊玩; 支援 PWA 安裝到主畫面,離線也能完整進行遊戲。

遊戲特色

    ${featureItems}
-

五大區域與魔王

+

世界區域與魔王

    ${zoneItems}
From 489e0ab891a8419f08f3fe428ee5ac1272871c99 Mon Sep 17 00:00:00 2001 From: haotool Date: Mon, 27 Jul 2026 23:39:55 +0800 Subject: [PATCH 04/10] =?UTF-8?q?docs(starpuff):=20GAME=5FDESIGN=20?= =?UTF-8?q?=E8=A3=9C=20=C2=A7122=20=E9=9B=99=E9=AD=94=E7=8E=8B=E8=A6=8F?= =?UTF-8?q?=E6=A0=BC=E4=B8=A6=E4=BF=AE=E6=AD=A3=20v15=20=E8=A3=9C=E7=99=BC?= =?UTF-8?q?=E6=96=B7=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design/03-enemies-bosses.md 新增 §122:兩王三階段招池、反制線、EX 差分與紅線 - GAME_DESIGN 索引補 §122 列、魔王數更新為七座(verify-design-docs 通過) - v15.spec 開機補發斷言對齊派生語意:20 關/5 EX 世代存檔不再含 - all-clear/ex-conquest(W1 起即過時的斷言一併修正,e2e 不在 CI 屬手動债) 測試:node scripts/verify-design-docs.mjs 通過;vitest 全套 1142 案已於前一提交驗證 Co-authored-by: Cursor --- apps/starpuff/docs/GAME_DESIGN.md | 3 +- .../starpuff/docs/design/03-enemies-bosses.md | 55 ++++++++++++++++++- apps/starpuff/e2e/v15.spec.ts | 11 ++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/apps/starpuff/docs/GAME_DESIGN.md b/apps/starpuff/docs/GAME_DESIGN.md index 7867b9ff7..8d9c83b4f 100644 --- a/apps/starpuff/docs/GAME_DESIGN.md +++ b/apps/starpuff/docs/GAME_DESIGN.md @@ -34,7 +34,7 @@ | [`00-foundations.md`](design/00-foundations.md) | 產品定位、場景流程、技術架構與事件契約、品質門檻、版本號、跨系統架構級契約 | | [`01-controls-input.md`](design/01-controls-input.md) | 搖桿與 A/B/SP 鍵語意、輸入矩陣、觸控判定、移動手感、旋轉殼、虛擬鍵布局與配置頁 | | [`02-combat-star.md`](design/02-combat-star.md) | 戰鬥數值、九系星味與混合星、殼盾與雷鏈、星化三形態與教學矩陣 | -| [`03-enemies-bosses.md`](design/03-enemies-bosses.md) | 主角與 18 隻小怪 FSM、精英系統、五座魔王階段與招式、EX 變體與 P4 第二血條 | +| [`03-enemies-bosses.md`](design/03-enemies-bosses.md) | 主角與小怪 FSM、精英系統、七座魔王階段與招式、EX 變體與 P4 第二血條 | | [`04-levels-world.md`](design/04-levels-world.md) | 20 關資料與節奏、五區分頁世界地圖、彩蛋、平台與環境機制、魔王關前室與增益 | | [`05-save-settings.md`](design/05-save-settings.md) | 存檔 schema 與備援、重置語意、成就系統、使用者偏好 SSOT 與設定頁、PWA 更新閘 | | [`06-ui-shell-pwa.md`](design/06-ui-shell-pwa.md) | 主選單與圖鑑分頁、暫停系統、HUD 命中層、結算動線、殼層卡片、PWA 安裝指引 | @@ -175,3 +175,4 @@ | §119 | 星海終局篇四新變身(W1;#886) | [`02-combat-star.md`](design/02-combat-star.md) | | §120 | 星海終局篇六新小怪(W1;#886) | [`03-enemies-bosses.md`](design/03-enemies-bosses.md) | | §121 | 星海終局篇 L21/L23 走動關與進程(W1;#886) | [`04-levels-world.md`](design/04-levels-world.md) | +| §122 | 星海終局篇雙魔王:Tariffang 關稅巨獸(L22)與 Maridella 潮汐女王(L24)(W2;#886) | [`03-enemies-bosses.md`](design/03-enemies-bosses.md) | diff --git a/apps/starpuff/docs/design/03-enemies-bosses.md b/apps/starpuff/docs/design/03-enemies-bosses.md index 33c306eb9..b5c7bb271 100644 --- a/apps/starpuff/docs/design/03-enemies-bosses.md +++ b/apps/starpuff/docs/design/03-enemies-bosses.md @@ -1,6 +1,6 @@ # 星噗噗 — 角色、怪物與魔王 -> **職責範圍**:場上會動的東西。主角與 18 隻小怪的設定與 FSM、精英變體系統、五座魔王(Jellord/Noctra/Prismix/Syrona/Voidra)的階段結構、加權選招與主題招式、EX 變體與 P4 第二血條,以及魔王演出與難度根修紀錄。 +> **職責範圍**:場上會動的東西。主角與小怪的設定與 FSM、精英變體系統、七座魔王(Jellord/Noctra/Prismix/Syrona/Voidra+星海終局篇 Tariffang/Maridella)的階段結構、加權選招與主題招式、EX 變體與 P4 第二血條,以及魔王演出與難度根修紀錄。 > > **不在本檔**:怪物出現在哪一關與出現比例(見 `04-levels-world.md`);魔王關前室與增益體系(見 `04-levels-world.md` §69);素材規格(見 `07-art-audio-fx.md`)。 > @@ -801,3 +801,56 @@ telegraph ≥600ms 測試守門;FSM 進 enemyFsm 表、逐幀 AI 沿 `finaleEn - 立繪佔位:`PENDING_TEXTURE_KEYS` 凍結 texture key 待素材車 B02;CodexScene 回退 fx-star 剪影、enemies 回退同色圓角色塊。 + +## 122. 星海終局篇雙魔王:Tariffang 關稅巨獸(L22)與 Maridella 潮汐女王(L24)(W2;#886) + +終局章前二王(PRD `.claude/prds/starpuff-v21-v30-design.md` §5);FSM 表驅動沿 +§111.1 moveTable SSOT(rng 注入可重放、連續同招上限 2、條件距離帶),phase truth +分別收斂 `logic/tariffangFsm.ts`/`logic/maridellaFsm.ts`,呈現層 +`systems/tariffang.ts`/`systems/maridella.ts` 接 BossHandle(GameScene 零分支)。 +telegraph 全招式 ≥600ms;魔王關體系沿 §69(前室 400px+二選一台座+arena 增益); +每損 10 HP 掉補給(§26)。難度軸接續 L20 遞增(靜態三軸 L20 9.2 → L22 9.3 → +L24 9.4;PRD §2「L21 起接續 L20、終點 L30 ≤10」)。 + +### 122.1 Tariffang 關稅巨獸(L22 關稅要塞,地面關卡官型) + +- 數值:HP **112**、體傷 1、P2 ≤66%、P3 ≤33%、狂暴 ×1.15;駐守崗哨雙側位 + (衝撞後換側),駐守/衝撞位移一律 `approachPoint` 速度上限逼近(§64 慣例)。 +- 招池(P1 `cargo`/`stamp`;P2 加 `levy`;P3 `gate`/`ram`/`levy`,ram 限遠距帶): + - **貨物稽查 cargo**:「檢查中」邊緣警示 0.8s → 貨櫃沿地面滑入橫越全場 + (高 52 低於平台頂——平台避讓與跳越雙解);P1 ×1、P2 ×2 錯拍。 + - **關稅槌 stamp**:玩家位置夾擊三落點預警 0.7s → 槌印直墜。 + - **加收費用(主題)**:P2 期星彈命中即生成**追蹤稅票**(0.9s 節流+場上上限 4); + `levy` 主動查帳再撒 ×3。稅票緩速跟蹤 2.2s 後直線、壽命 6s 必回收(§56)。 + - **全面封關 gate**:雙側閘門預警 0.9s 後由上而下關閉(各覆蓋 30% 寬), + 中央走廊恆開(anti-softlock);駐留 1.8s 升起。 + - **高速衝撞 ram**:三拍閃白 0.7s → 直線衝向對側崗哨(跳+拍翅可越,§58 慣例)。 +- 反制三線+:① 吸入清票(稅票掛 `inhalable`,吸入回收為彈藥——稅金退還); + ② 焰化燒票(burn 星彈命中稅票即燒毀且穿透續飛,§119 優勢情境——L22 補生 + cargo 0.5 權重保證重鑽味供給);③ 閘門走廊+跳越衝撞;④ 下砸頭頂短暈 0.8s + (`stun`,衝撞中免疫);⑤ 平台避讓貨櫃。 +- EX 差分(EX_MODS ×1.5/×1.15 共用 → HP 168):質性差分=**貨櫃雙向同時滑入** + (一般恆單側錯拍可背板)+稅票上限 +2。 + +### 122.2 Maridella 潮汐女王(L24 潮汐王庭,懸浮場控型) + +- 數值:HP **116**、體傷 1、P2 ≤66%、P3 ≤33%、狂暴 ×1.15;懸浮帶 y≈250 + (單跳星彈可打帶+可自底下走過),錨點正弦漂移+`approachPoint` 逼近。 +- 招池(P1 `current`/`droplet`;P2 加 `wave`/`summon`;P3 `moonorb`/`wave`/`current`, + droplet 限遠距帶): + - **潮線改道 current(主題)**:方向箭頭沿地面流動預告 0.9s → 地面帶水流 + 推移玩家 4s(90px/s 恆低於玩家全速,交叉不變式 16);交替出向可讀。 + **潮化免推(§119 穩控優勢)**——L24 補生 foamy/frosty 0.55 權重保證孢子味供給。 + - **水滴彈幕 droplet**:舉臂 0.65s → 拋物 ×3(P2 起 ×4)朝玩家側散射。 + - **海嘯階梯 wave**:起浪側預警 0.9s → 留缺口的波浪牆橫越全場(缺口 102px + 恆容本體)——低缺口貼地走廊、高缺口跳+拍翅穿越;缺口位置 rng 抽選同 seed 可重放。 + - **潮湧召喚 summon**:吟唱後召 foamy(上限 2;雷化鏈電可斷召 §58 慣例)—— + 泡泡上浮兼具渡牆輔助,威脅同時是資源(孢子味=潮化供給)。 + - **深海月蝕 moonorb(P3)**:入 P3 背景轉暗+水月升起(暗場為氛圍層、 + telegraph 亮度不受影響——認知紅線);三水球繞行本體、每球爆裂前閃爍 0.6s + 後依序爆成環形放射彈 ×8。 +- 反制:① 讀水流箭頭逆走/跳離地面帶;② 潮化穩控(免推)+潮環撥開月蝕彈幕 + (§119 既有白名單管線);③ 讀缺口過牆/乘 foamy 泡泡浮渡;④ 雷化斷召; + ⑤ 懸浮場控免暈(下砸僅回彈,輸出窗=idle 僵直 ≥1.9s)。 +- EX 差分(EX_MODS 共用 → HP 174):質性差分=水球 3→4+召喚上限 3+水滴 ×5; + 缺口高度不縮(只縮體不縮窗紅線同源)。 diff --git a/apps/starpuff/e2e/v15.spec.ts b/apps/starpuff/e2e/v15.spec.ts index 5e6be936f..5ce7944c9 100644 --- a/apps/starpuff/e2e/v15.spec.ts +++ b/apps/starpuff/e2e/v15.spec.ts @@ -79,8 +79,9 @@ async function gotoTitle(page: Page): Promise { test('舊存檔開機補發(§94):v13 世代 v1 存檔載入即補發歷史成就並升 v2', async ({ page }) => { const errors = collectErrors(page); - // 全 20 通關+五王 EX+三隱藏彩蛋(彩蛋僅 3 顆):開機補發精確 19 條—— - // egg-10/egg-all 未達門檻必須不補發(無多發)。 + // 全 20 通關+五王 EX+三隱藏彩蛋(彩蛋僅 3 顆):開機補發精確 17 條—— + // egg-10/egg-all 未達門檻必須不補發(無多發);§121/§122 新關與雙新王入編後, + // all-clear/ex-conquest 隨 LEVELS 派生語意不再由 20 關/5 EX 成立(通關新章後補發)。 await seedV1Save(page, ALL_TWENTY, { exIds: BOSS_IDS, eggs: { 12: ['twin-finish'], 16: ['vent-hit-count'], 20: ['survive-collect'] }, @@ -96,11 +97,9 @@ test('舊存檔開機補發(§94):v13 世代 v1 存檔載入即補發歷 ); expect(persisted.schemaVersion).toBe(2); const awarded = persisted.achievements ?? []; - expect(awarded).toHaveLength(19); + expect(awarded).toHaveLength(17); for (const id of [ 'first-clear', - 'all-clear', - 'ex-conquest', 'egg-first', 'egg-twin', 'egg-vent', @@ -110,6 +109,8 @@ test('舊存檔開機補發(§94):v13 世代 v1 存檔載入即補發歷 ]) { expect(awarded).toContain(id); } + expect(awarded).not.toContain('all-clear'); + expect(awarded).not.toContain('ex-conquest'); expect(awarded).not.toContain('egg-10'); expect(awarded).not.toContain('egg-all'); await page.waitForTimeout(400); From e84106a7152555da0a9d8de6d815f395ff4dbc65 Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 28 Jul 2026 00:38:50 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(starpuff):=20=E8=B2=A8=E6=AB=83?= =?UTF-8?q?=E9=81=A0=E5=81=B4=E6=BB=91=E5=85=A5=E8=88=87=E9=97=9C=E7=A8=85?= =?UTF-8?q?=E6=A7=8C=E6=B1=A0=E5=BE=A9=E7=94=A8=E6=97=97=E6=A8=99=E5=BE=A9?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 貨櫃改自玩家遠側入場(逼近可讀性:橫越全場朝玩家推進,非同側即出) - 關稅槌復用稅票池物件時顯式清除 ticket/ticketUntil 自訂旗標—— - poolFlags 僅復位標準五旗標,殘留壽命戳記會使槌被追蹤迴圈當過期稅票即時回收 - 附 changeset(minor:新關卡新魔王為使用者直接可感知內容) 測試:vitest tariffangFsm 16 案綠、typecheck 通過;全套閘門隨 pre-push 執行 Co-authored-by: Cursor --- .changeset/starpuff-w2-finale-bosses.md | 5 +++++ apps/starpuff/src/game/systems/tariffang.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/starpuff-w2-finale-bosses.md diff --git a/.changeset/starpuff-w2-finale-bosses.md b/.changeset/starpuff-w2-finale-bosses.md new file mode 100644 index 000000000..cbc59de50 --- /dev/null +++ b/.changeset/starpuff-w2-finale-bosses.md @@ -0,0 +1,5 @@ +--- +'@app/starpuff': minor +--- + +星海終局篇 W2:新增兩座魔王關卡——L22 關稅要塞(關稅巨獸 Tariffang:貨櫃稽查、追蹤稅票、全面封關三階段)與 L24 潮汐王庭(潮汐女王 Maridella:潮線改道、海嘯階梯、深海月蝕三階段)。焰化星彈可燒毀稅票、吸入可把稅票回收成彈藥;潮化可在水流中穩定行走。兩王擊破後各自開放 EX 變體挑戰,世界地圖六、七區補齊成對章節,圖鑑與成就同步收錄兩位新魔王。 diff --git a/apps/starpuff/src/game/systems/tariffang.ts b/apps/starpuff/src/game/systems/tariffang.ts index 2518d7e02..8edcac9a6 100644 --- a/apps/starpuff/src/game/systems/tariffang.ts +++ b/apps/starpuff/src/game/systems/tariffang.ts @@ -194,7 +194,8 @@ export function createTariffang( // 一般單側錯拍(可背板)、EX 雙向同時(質性差分)。 const doCargo = (count: number, bothSides: boolean) => { for (let wave = 0; wave < count; wave += 1) { - const fromLeft = bothSides ? true : (target?.x ?? arenaCx()) > arenaCx() ? false : true; + // 遠側滑入(逼近可讀):玩家在右半場則自左緣入場,橫越全場朝玩家推進。 + const fromLeft = bothSides ? true : (target?.x ?? arenaCx()) > arenaCx(); const sides = bothSides ? [true, false] : [wave % 2 === 0 ? fromLeft : !fromLeft]; delay(wave * CRATE_STAGGER_MS, () => { if (dying) return; @@ -237,6 +238,10 @@ export function createTariffang( stamp.enableBody(true, x, 30, true, true); stamp.setTexture('__WHITE'); stamp.setDisplaySize(34, 30).setTint(BRASS_TINT).setAlpha(0.95); + // 池復用殘留復位(poolFlags 僅復位標準旗標):稅票身分屬本系統自訂資料, + // 復用為關稅槌時必須顯式清除,防被追蹤/壽命迴圈誤收。 + stamp.setData('ticket', false); + stamp.setData('ticketUntil', 0); stamp.setData('homingMs', 0); const stampBody = stamp.body as Phaser.Physics.Arcade.Body; stampBody.setAllowGravity(false); From d8381127dc588a1b1498965e34d6fa93a1c6448c Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 28 Jul 2026 01:08:33 +0800 Subject: [PATCH 06/10] =?UTF-8?q?test(starpuff):=20poolFlags=20=E7=B9=9E?= =?UTF-8?q?=E9=81=8E=E9=A9=97=E8=AD=89=E6=A1=88=E8=A3=9C=2060s=20=E9=80=BE?= =?UTF-8?q?=E6=99=82=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 虛擬 program 編譯在全 workspace 平行測試負載下實測 12s+,預設 5s 間歇誤紅 - 對齊同檔全量掃描案既有 60s 上限(單點調整,不動全域 testTimeout) 測試:pnpm test 全 workspace 通過(pre-push 取證三連 flake 根因即此案) Co-authored-by: Cursor --- apps/starpuff/src/game/core/poolFlags.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/starpuff/src/game/core/poolFlags.test.ts b/apps/starpuff/src/game/core/poolFlags.test.ts index 138a0b30e..e3756d72d 100644 --- a/apps/starpuff/src/game/core/poolFlags.test.ts +++ b/apps/starpuff/src/game/core/poolFlags.test.ts @@ -184,5 +184,7 @@ export { asserted, bag, viaBag, viaWrapper }; expect(hows).toContain('destructured get'); expect(hows).toContain("element access ['get']"); expect(hows).toContain('Reflect.get'); - }); + // 逾時上限對齊同檔全量掃描案(60s):虛擬 program 編譯在全 workspace 平行 + // 測試負載下實測 12s+,預設 5s 會間歇性誤紅(pre-push 全量閘取證)。 + }, 60_000); }); From 6b6aaa4a25486bf7106fd4dbc4be95f6472ec5ad Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 28 Jul 2026 04:13:01 +0800 Subject: [PATCH 07/10] =?UTF-8?q?docs(starpuff):=20=E6=94=BB=E7=95=A5?= =?UTF-8?q?=E6=89=8B=E5=86=8A=E8=A3=9C=20L21-L24=20=E7=AB=A0=E7=AF=80?= =?UTF-8?q?=E4=B8=A6=E4=BF=AE=E6=B0=B4=E6=9C=88=E9=A1=AF=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WALKTHROUGH 六/七區入冊:兩走動關速記+兩魔王三段結構表、保底線與星化優勢解 - 實測依分級 bot 序列量測(L22/L24 high×7 皆 86%、low×7 皆 0%;走動關 100%) - 附錄二補記 L21-L24 量測口徑;EX 節與星核制霸語意隨七王更新 - 修 Maridella 水月 fillAlpha/alpha 相乘不可見(月蝕暗場真瀏覽器截圖驗證) 測試:真瀏覽器 P1-P3 截圖驗證(console error 0);vitest/typecheck 隨 pre-push 執行 Co-authored-by: Cursor --- apps/starpuff/docs/WALKTHROUGH.md | 100 ++++++++++++++++++-- apps/starpuff/src/game/systems/maridella.ts | 4 +- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/apps/starpuff/docs/WALKTHROUGH.md b/apps/starpuff/docs/WALKTHROUGH.md index 1b816dace..d98605c23 100644 --- a/apps/starpuff/docs/WALKTHROUGH.md +++ b/apps/starpuff/docs/WALKTHROUGH.md @@ -1,4 +1,4 @@ -# 星噗噗 StarPuff 全關卡攻略(L1–L20) +# 星噗噗 StarPuff 全關卡攻略(L1–L24) > 對應遊戲版本:`@app/starpuff` 0.15.3(v18 修復列車;實測基準 0.13.0——其後 > v15/v16 未變更關卡資料與魔王 FSM(僅 L1 教學關死亡重試改保留一半配額), @@ -448,8 +448,9 @@ P3 讀金紋打裂核窗,補給循環+段起點重試保證磨死。 - **星化優勢解(非必需)**:P2 風化機動全收星屑;P3 雷化裂核大窗爆發。 - **彩蛋**:`survive-collect`——「星核共鳴」:P2 生存段 5 枚星屑全收,瞬間滿彈匣+星光謝幕演出。 -- **通關後**:星光復甦謝幕(製作名單)→ 結算。全 20 關通關、五魔王擊破後, - 地圖魔王節點開放 EX 挑戰(HP ×1.5、節奏 ×1.15、專屬新招)——全五王 EX 制霸另有星核紀念章。 +- **通關後**:星光復甦謝幕(製作名單)→ 結算。魔王關通關後該節點開放 EX 挑戰 + (HP ×1.5、節奏 ×1.15、專屬新招)——**全在編魔王** EX 制霸另有星核紀念章 + (§122 起共七王:主線五王+星海終局篇 Tariffang/Maridella)。 - **實測**:114.7 秒擊破、死亡 1(P2 段起點重試驗證成功)——P1 約 7 秒、P2 生存段表定 40 秒(40 秒播完時間驅動入 P3;本次含一次段重試共經歷約 90 秒、過熱窗三輪輸出)、 P3 低重力下約 11 秒融毀(截圖 `l20-start.png`、`l20-arena.png`、 @@ -457,11 +458,95 @@ --- +## 第六區:星海港域(L21–L22) + +> 星海終局篇首章(§121/§122)。本區起為主線全破後的終局內容,難度接續 L20 續升。 +> 本區兩關的實測數據來自分級 bot 序列量測(`scripts/level-audit.mjs`,真實瀏覽器 +> 遊玩),與前二十關的人工實測方法互補;bot 水準與口徑見附錄二補記。 + +### L21 星港集散地(走動關・焰化取得) + +- **主題**:貨櫃推進(cargo 緩推折返)× 掃描光(scanna 鎖定直線);十一種混編、 + 六同屏高壓;蒸氣閥週期氣壓柱 ×2、卡點中點重生(checkpoint 1850)。 +- **焰化教學**:中段練習區保證 3 隻貨櫃丁——連吞 3 隻(重鑽味 ×3)站地面按 SP + 即焰化(焰彈小爆+焰衝刺+熔岩爆;burn 可燒冰史萊姆與 L22 稅票)。 +- **要點**:貨櫃丁恆可吸是彈藥主軸;scanna 鎖定後不修正——鎖定閃爍時橫移一步即脫離; + 雙精英(巨貨櫃長/緋紅掃描長)全在練習區之後,60 秒逾時保險照舊。 +- **實測(mid bot ×3)**:通關 3/3(100%)、通關中位 80.1 秒、3.33 死/次、 + console error 0——與 W1 出貨量測(100%、84.4 秒)口徑一致。 + +### L22 關稅要塞(魔王:關稅巨獸 Tariffang) + +- **主題**:星港海關三段——地面關卡官型。HP 112(接續 Voidra 110 續升)、體傷 1; + 駐守崗哨(衝撞後換側),P2 起狂暴 ×1.15+換 enraged 立繪。 +- **前室**:慈悲愛心+補給 3 隻+二選一:**護盾泡**/**星力果**;P2 高風險位刷疾風靴。 + 建議:星力果——本王輸出窗規律(idle 僵直 1.8–2.2 秒),吃傷害加成回報高。 +- **三段結構與反制**: + +| 段 | 門檻 | 內容 | 反制 | +| ----------- | ----- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| P1 貨物稽查 | > 66% | 貨櫃「檢查中」邊緣警示 0.8 秒 → 沿地面滑入橫越全場(高 52px);關稅槌三落點預警 0.7 秒直墜 | 貨櫃跳越(單跳 98px 綽綽有餘)或站浮台讓過;關稅槌看落點橫移;僵直窗全力輸出 | +| P2 加收費用 | ≤ 66% | **星彈命中即生追蹤稅票**(0.9 秒節流、場上上限 4);`levy` 查帳再撒 ×3;貨櫃 ×2 錯拍 | **吸入稅票=回收成彈藥**(稅金退還);焰化焰彈直接燒票且穿透續飛;打帶跑——連射會被票海反噬 | +| P3 全面封關 | ≤ 33% | 雙側閘門預警 0.9 秒由上而下關閉(各覆蓋 30% 寬、駐留 1.8 秒),中央走廊恆開;高速衝撞(三拍閃白 0.7 秒) | 聽到封關警示就進中央走廊;衝撞原地跳+拍翅 2 次即可越過(本體高 150 < 淨高 211);閘門升起再輸出 | + +- **保底打法(純標準星)**:吸補給點射 → P2 改「射一輪、吸一輪」節奏把稅票當免費彈藥 → + P3 守走廊跳衝撞,僵直窗磨死。閘門僅駐留 1.8 秒、被關在外側也只吃 1 傷(i-frame 足夠穿過)。 +- **星化優勢解(非必需)**:焰化燒票線——補給 cargo 權重 0.5,常態補給 ≤30 秒湊齊 + 重鑽味 ×3;焰化期間稅票威脅歸零、焰彈照常打王。 +- **下砸線**:下砸命中頭頂觸發 0.8 秒短暈(衝撞中免疫)——貼身高風險高回報。 +- **彩蛋**:`crown-early-hit`——可擊打後 5 秒內首次命中,回復 +2 HP。 +- **實測(×7 序列)**:high bot 通關 6/7(86%)、TTK 中位 30.3 秒(包絡 17.7–30.9)、 + 含重試通關中位 220.7 秒、5.0 死/次(三輪零死——乾淨線存在,死亡集中貼身站樁); + low bot 0/7(0%)——「中高手可過、新手過不了」的終局定位成立;console error 0。 + +--- + +## 第七區:冰晶潮域(L23–L24) + +### L23 冰晶潮灣(走動關・潮化取得) + +- **主題**:糖漿潮汐管線的冰潮換裝(週期漲落、dry-window 55%)× 週期浮力柱 ×2; + 潮系四供給 56%(frosty/foamy/manta/spora)、九種混編六同屏;checkpoint 1900。 +- **潮化教學**:中段練習區保證 3 隻泡泡機——連吞 3 隻(孢子味 ×3)按 SP 潮化 + (水引拉近+泡泡盾+霜滑翔+潮環撥開投射物)。 +- **要點**:漲潮期站平台層等退潮(等窗推進是官方合法保底);manta 水刃走 hazards—— + 潮化潮環可直接撥開;frosty 分裂體用焰化燒或逐隻吸。 +- **實測(mid bot ×3)**:通關 3/3(100%)、通關中位 85.2 秒、4.33 死/次、 + console error 0——與 W1 出貨量測(100%、85.1 秒)口徑一致。 + +### L24 潮汐王庭(魔王:潮汐女王 Maridella) + +- **主題**:潮灣終章三段——懸浮場控型。HP 116(全魔王新峰值)、體傷 1; + 女王懸浮於中高帶(y≈250,可自她底下走過),P2 起狂暴 ×1.15+換 enraged 立繪。 +- **前室**:慈悲愛心+補給 3 隻+二選一:**護盾泡**/**疾風靴**;P2 高風險位刷星力果。 + 建議:疾風靴——本王的威脅全是「位置壓力」(水流/牆/環爆),移速直接換生存。 +- **三段結構與反制**: + +| 段 | 門檻 | 內容 | 反制 | +| ----------- | ----- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| P1 潮線改道 | > 66% | 方向箭頭沿地面流動預告 0.9 秒 → 地面帶水流推移 4 秒(90px/s、交替出向);水滴彈幕拋物 ×3(舉臂 0.65 秒) | 水流推速低於你的移速——逆走可頂住,跳離地面帶直接免疫;**潮化完全免推**;水滴看拋物線落點讓開 | +| P2 海嘯階梯 | ≤ 66% | 波浪牆起浪預警 0.9 秒 → 留缺口橫越全場(240px/s):低缺口=貼地走廊、高缺口=跳躍帶(102px 恆容本體);潮湧召喚 foamy ×2 | 看牆型選解:低缺口站著不動就過、高缺口單跳入縫(拍翅修正);foamy 泡泡踩上浮=免費渡牆電梯;雷化鏈電可打斷召喚吟唱 | +| P3 深海月蝕 | ≤ 33% | 背景轉暗、水月升起(氛圍暗場,預警亮度不變);三水球繞行女王、每球爆裂前閃爍 0.6 秒 → 依序爆成環形放射彈 ×8 | 讀閃爍順序、貼著「已爆完的球位」走位;環形彈間有固定縫隙,站遠縫隙更寬;潮化潮環可直接撥開放射彈 | + +- **保底打法(純標準星)**:她懸浮在單跳星彈可打帶——地面平射微導向自然上彎命中; + idle 僵直(1.9–2.2 秒)是固定輸出窗;水流期跳打、牆來讀缺口、月蝕期先躲完三爆再輸出。 +- **星化優勢解(非必需)**:潮化穩控線——補生孢子味權重 0.55+P2 召喚 foamy 再補, + 潮化期間水流失效、潮環撥開水滴與環爆彈;「潮汐女王的主場規則對你無效」。 +- **注意**:懸浮場控免暈——下砸只回彈不觸發暈眩;輸出窗來自僵直不來自打斷。 +- **彩蛋**:`crown-early-hit`——可擊打後 5 秒內首次命中,瞬間滿彈匣。 +- **實測(×7 序列)**:high bot 通關 6/7(86%)、TTK 中位 29.5 秒(包絡 26.2–36.9)、 + 含重試通關中位 155.3 秒、4.86 死/次;low bot 0/7(0%);console error 0。 + 全魔王實測難度峰值(對照 L20 高階 100%、2.17 死/次),符合終局章收章定位。 + +--- + ## EX 挑戰(#814 全面重設計,T6) -全 20 關通關後地圖魔王節點開放。共同差分:HP ×1.5、節奏 ×1.15(面板僅此, -§8.1 紅線)+**每王專屬新型態(phase+1)**;進場清除蓄能星(EX 純度); -補給保證律照常(基礎星彈恆可通關)。 +魔王關通關後該節點開放 EX 入口。共同差分:HP ×1.5、節奏 ×1.15(面板僅此, +§8.1 紅線)+**主線五王專屬新型態(phase+1)**;進場清除蓄能星(EX 純度); +補給保證律照常(基礎星彈恆可通關)。星海終局篇雙王(L22/L24)的 EX 為 +HP/節奏共同差分+質性差分(貨櫃雙向同時/水球 ×4 與召喚 ×3,見 §122), +暫無專屬 P4 型態。 | 王 | EX 專屬型態 | 血條結構 | 反制要點 | | ----------- | ---------------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -517,6 +602,9 @@ ②L14/L15/L19 於 4 分鐘真實戰鬥後以官方 e2e 慣例之配額輔助收尾(該三關 bot 死亡率高, 路線與機制描述均來自輔助前的真實遊玩段)。 - 人類玩家可完整走本文所有路線;上述輔助僅為自動化實測的工程收尾手段。 +- **L21–L24 補記(§121/§122 出貨車)**:星海終局篇四關的實測改由 `scripts/level-audit.mjs` + 分級 bot 序列量測(walk=mid 350ms/boss=high 250ms;魔王關供彈沿 v13 bot 慣例受控賦星)—— + 每份數據標注 bot 水準與 runs;勝率依 AGENTS 口徑為觀測值,裁決以靜態三軸+TTK 包絡為準。 ## 附錄三:實測發現問題(回報開發;v18 修復列車已全數收斂) diff --git a/apps/starpuff/src/game/systems/maridella.ts b/apps/starpuff/src/game/systems/maridella.ts index cc01aee9d..2ee66d258 100644 --- a/apps/starpuff/src/game/systems/maridella.ts +++ b/apps/starpuff/src/game/systems/maridella.ts @@ -144,7 +144,9 @@ export function createMaridella( const eclipseOverlay = scene.add .rectangle(arenaCx(), VIEW.height / 2, viewW() + 80, VIEW.height, 0x0a1430, 0) .setDepth(-2); - const moon = scene.add.circle(arenaX(0.82), 120, 40, 0xd8ecff, 0).setDepth(-2); + // 水月以物件 alpha 控顯隱(fill 恆 1):fillAlpha 與 alpha 相乘合成, + // 兩者混用會使 tween alpha 永遠乘 0 不可見。 + const moon = scene.add.circle(arenaX(0.82), 120, 40, 0xd8ecff, 1).setAlpha(0).setDepth(-2); const delay = (ms: number, fn: () => void) => { timers.push(scene.time.delayedCall(ms, fn)); From 7c464e08eba3f30fd06d6ffa558326323d542a6c Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 28 Jul 2026 04:52:19 +0800 Subject: [PATCH 08/10] =?UTF-8?q?docs(starpuff):=20=E5=AF=A9=E6=9F=A5?= =?UTF-8?q?=E5=9B=9E=E9=A5=8B=E6=8E=AA=E8=BE=AD=E4=BF=AE=E6=AD=A3=EF=BC=88?= =?UTF-8?q?=E4=B8=83=E7=8E=8B=E8=A8=BB=E8=A7=A3=E8=88=87=20v15=20=E6=AD=B8?= =?UTF-8?q?=E5=9B=A0=E5=88=86=E5=AF=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - difficulty 魔王軸註解 5 王改 7 王(BOSS_LEVEL_IDS 已隨 §122 擴編) - v15.spec 補發排除成因分寫:all-clear 為 W1 既有債、ex-conquest 為本車七王擴編直接結果 測試:純註解變更零行為;全套閘門隨 pre-push 執行 Co-authored-by: Cursor --- apps/starpuff/e2e/v15.spec.ts | 5 +++-- apps/starpuff/src/game/logic/difficulty.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/starpuff/e2e/v15.spec.ts b/apps/starpuff/e2e/v15.spec.ts index 5ce7944c9..c7f6017e3 100644 --- a/apps/starpuff/e2e/v15.spec.ts +++ b/apps/starpuff/e2e/v15.spec.ts @@ -80,8 +80,9 @@ async function gotoTitle(page: Page): Promise { test('舊存檔開機補發(§94):v13 世代 v1 存檔載入即補發歷史成就並升 v2', async ({ page }) => { const errors = collectErrors(page); // 全 20 通關+五王 EX+三隱藏彩蛋(彩蛋僅 3 顆):開機補發精確 17 條—— - // egg-10/egg-all 未達門檻必須不補發(無多發);§121/§122 新關與雙新王入編後, - // all-clear/ex-conquest 隨 LEVELS 派生語意不再由 20 關/5 EX 成立(通關新章後補發)。 + // egg-10/egg-all 未達門檻必須不補發(無多發)。兩項排除的成因不同: + // all-clear 自 §121(W1 新關入編)起即不由 20 關成立(本斷言當時未同步=既有債); + // ex-conquest 則是 §122(本車 BOSS_LEVEL_IDS 擴為七王)的直接結果(5 EX 不再滿編)。 await seedV1Save(page, ALL_TWENTY, { exIds: BOSS_IDS, eggs: { 12: ['twin-finish'], 16: ['vent-hit-count'], 20: ['survive-collect'] }, diff --git a/apps/starpuff/src/game/logic/difficulty.ts b/apps/starpuff/src/game/logic/difficulty.ts index 8f81905bd..15fac4c5f 100644 --- a/apps/starpuff/src/game/logic/difficulty.ts +++ b/apps/starpuff/src/game/logic/difficulty.ts @@ -476,7 +476,7 @@ export function walkAxisRaw(level: LevelSpec, firstSeen: Set): WalkAx // ===== 魔王關靜態三軸 raw(彈幕壓力/階段機制數/迴避精度)===== // 權重取向:D 以血池(TTK 壓力)為主、招式面為輔;M 計階段結構與場控疊加; -// P 以最短 telegraph 反應窗為主。錨定校準(L4/L20)吸收單位,5 王總分需嚴格遞增。 +// P 以最短 telegraph 反應窗為主。錨定校準(L4/L20)吸收單位,7 王總分需嚴格遞增。 export function bossAxisRaw(facts: BossAuditFacts): WalkAxisRaw { const dRaw = facts.maxHp * 0.06 + facts.attackKinds * 0.5; const mRaw = 3 + facts.attackKinds * 0.4 + facts.arenaMechanics + (facts.multiBody ? 0.5 : 0); From a22837a8b566186747333bfed20dbe1ba7d29928 Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 28 Jul 2026 05:45:12 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(starpuff):=20=E5=AF=A9=E6=9F=A5?= =?UTF-8?q?=E5=B0=BE=E4=BF=AE=E2=80=94=E2=80=94=E5=88=B6=E9=9C=B8=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=B4=BE=E7=94=9F=E3=80=81=E7=A8=85=E7=A5=A8=E6=97=97?= =?UTF-8?q?=E6=A8=99=E5=85=A5=E6=B1=A0=20SSOT=E3=80=81=E5=8F=AC=E5=96=9A?= =?UTF-8?q?=E5=89=8D=E6=90=96=E9=81=94=E7=B4=85=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ex-conquest 文案王數改由 BOSS_LEVEL_IDS 派生(擴王自動跟進)+單測釘模板與計數 - ticket/ticketUntil 納入 POOL_TRANSIENT_FLAGS(取出即復位取代 stamp 手動清鍵) - +全清單釘住測試(自清單移除即紅) - Maridella 召喚前搖 540ms→650ms 固定常數(≥600 紅線;telegraph 單測補 summon 錨定 - 與斷召窗恆先於生成的狂暴下界斷言) - difficulty.test 標題跟上七王遞增(L20 < L22 < L24) 測試:pnpm --filter @app/starpuff test 1169 案全綠、typecheck 通過 Co-authored-by: Cursor --- apps/starpuff/src/game/core/poolFlags.test.ts | 12 ++++++++++++ apps/starpuff/src/game/core/poolFlags.ts | 5 +++++ apps/starpuff/src/game/logic/achievements.test.ts | 8 +++++++- apps/starpuff/src/game/logic/achievements.ts | 3 ++- apps/starpuff/src/game/logic/difficulty.test.ts | 2 +- apps/starpuff/src/game/logic/maridellaFsm.test.ts | 7 ++++++- apps/starpuff/src/game/logic/maridellaFsm.ts | 3 +++ apps/starpuff/src/game/systems/maridella.ts | 3 ++- apps/starpuff/src/game/systems/tariffang.ts | 6 ++---- 9 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/starpuff/src/game/core/poolFlags.test.ts b/apps/starpuff/src/game/core/poolFlags.test.ts index e3756d72d..4ee8d63ef 100644 --- a/apps/starpuff/src/game/core/poolFlags.test.ts +++ b/apps/starpuff/src/game/core/poolFlags.test.ts @@ -10,6 +10,18 @@ import { POOL_TRANSIENT_FLAGS, acquirePooled, resetTransientFlags } from './pool // 參數/Reflect.get)都會被抓;動態字串組鍵等蓄意繞過不在守門範圍(誠實邊界)。 describe('poolFlags(池瞬時旗標 SSOT)', () => { + it('在冊旗標全清單釘住(§122 稅票身分/壽命戳記入冊;自清單移除復位即紅)', () => { + expect([...POOL_TRANSIENT_FLAGS]).toEqual([ + 'tideDeflected', + 'reflected', + 'burn', + 'inhalable', + 'caramel', + 'ticket', + 'ticketUntil', + ]); + }); + it('resetTransientFlags 將在冊旗標全部歸位 false', () => { const data = new Map(POOL_TRANSIENT_FLAGS.map((flag) => [flag, true])); resetTransientFlags({ setData: (key, value) => data.set(key, value) }); diff --git a/apps/starpuff/src/game/core/poolFlags.ts b/apps/starpuff/src/game/core/poolFlags.ts index b30128b30..cd3ade895 100644 --- a/apps/starpuff/src/game/core/poolFlags.ts +++ b/apps/starpuff/src/game/core/poolFlags.ts @@ -24,12 +24,17 @@ import type Phaser from 'phaser'; // 每發必寫——新增取出分支時人工複查。 // - fxTrail【慣例·無機械鎖】:附著物件需 destroy 而非布林覆寫,生命週期由 // recycleStar/starSteering 自管——新增星彈回收路徑時人工複查。 +// ticket/ticketUntil(§122 Tariffang 稅票身分與壽命戳記):稅票追蹤/壽命/吸入 +// 迴圈全數以 ticket === true 為閘——取出即歸 false 使殘留戳記不可達, +// 取代 stamp 路徑的手動逐鍵復位(審查回饋:手動清鍵是殘留旗標事故的候選缺口)。 export const POOL_TRANSIENT_FLAGS = [ 'tideDeflected', 'reflected', 'burn', 'inhalable', 'caramel', + 'ticket', + 'ticketUntil', ] as const; interface DataHolder { diff --git a/apps/starpuff/src/game/logic/achievements.test.ts b/apps/starpuff/src/game/logic/achievements.test.ts index da4553274..e71db9a13 100644 --- a/apps/starpuff/src/game/logic/achievements.test.ts +++ b/apps/starpuff/src/game/logic/achievements.test.ts @@ -91,7 +91,7 @@ describe('魔王類判定', () => { expect(unlockedAchievements(exOnly).has('ex-noctra')).toBe(true); }); - it('ex-conquest:4/5 不成立、5/5 成立(§86 同源判定)', () => { + it('ex-conquest:6/7 不成立、7/7 成立(§86 同源判定)', () => { const partial = createDefaultSave(); for (const id of BOSS_LEVEL_IDS.slice(0, -1)) recordExClear(partial, id); expect(unlockedAchievements(partial).has('ex-conquest')).toBe(false); @@ -99,6 +99,12 @@ describe('魔王類判定', () => { for (const id of BOSS_LEVEL_IDS) recordExClear(full, id); expect(unlockedAchievements(full).has('ex-conquest')).toBe(true); }); + + it('ex-conquest 文案王數由 BOSS_LEVEL_IDS 派生(§122 審查回饋:擴王不得漂移)', () => { + const conquest = ACHIEVEMENTS.find((a) => a.id === 'ex-conquest'); + expect(conquest?.descZh).toBe(`${BOSS_LEVEL_IDS.length} 王 EX 變體全數制霸`); + expect(BOSS_LEVEL_IDS.length).toBe(7); + }); }); describe('收集類判定', () => { diff --git a/apps/starpuff/src/game/logic/achievements.ts b/apps/starpuff/src/game/logic/achievements.ts index 3a6ae21b4..3dcf63d79 100644 --- a/apps/starpuff/src/game/logic/achievements.ts +++ b/apps/starpuff/src/game/logic/achievements.ts @@ -141,7 +141,8 @@ export const ACHIEVEMENTS: readonly AchievementSpec[] = [ { id: 'ex-conquest', nameZh: '星核制霸', - descZh: '五王 EX 變體全數制霸', + // 王數由 BOSS_LEVEL_IDS 派生(§122 審查回饋):擴王時文案自動跟進,禁硬編數字。 + descZh: `${BOSS_LEVEL_IDS.length} 王 EX 變體全數制霸`, category: 'boss', hidden: false, unlocked: (save) => exConquestDone(save), diff --git a/apps/starpuff/src/game/logic/difficulty.test.ts b/apps/starpuff/src/game/logic/difficulty.test.ts index a1436cb38..53ab1becb 100644 --- a/apps/starpuff/src/game/logic/difficulty.test.ts +++ b/apps/starpuff/src/game/logic/difficulty.test.ts @@ -133,7 +133,7 @@ describe('三軸自動計算(主計畫 §3.1 錨定行重現)', () => { expect(voidra.total).toBe(9.2); }); - it('非錨定魔王插值落於錨帶內且總分遞增(L4 < L7 < L12 < L16 < L20)', () => { + it('非錨定魔王插值落於錨帶內且七王總分遞增(L4 < L7 < L12 < L16 < L20 < L22 < L24)', () => { const totals = BOSS_LEVEL_IDS.map((id) => computeLevelAxes(levelOf(id), LEVELS).total); for (let i = 1; i < totals.length; i += 1) { expect(totals[i]).toBeGreaterThan(totals[i - 1] ?? 0); diff --git a/apps/starpuff/src/game/logic/maridellaFsm.test.ts b/apps/starpuff/src/game/logic/maridellaFsm.test.ts index 209aed6b2..d2895b720 100644 --- a/apps/starpuff/src/game/logic/maridellaFsm.test.ts +++ b/apps/starpuff/src/game/logic/maridellaFsm.test.ts @@ -180,15 +180,20 @@ describe('加權選招治理(§111.1 去背板)', () => { }); describe('可讀性與 anti-softlock 紅線(§122)', () => { - it('全招式 telegraph ≥600ms(可讀性紅線)', () => { + it('全招式 telegraph ≥600ms(可讀性紅線;含召喚吟唱前搖)', () => { for (const ms of [ MARIDELLA.currentTelegraphMs, MARIDELLA.dropletTelegraphMs, MARIDELLA.waveTelegraphMs, + MARIDELLA.summonTelegraphMs, MARIDELLA.moonorbTelegraphMs, ]) { expect(ms).toBeGreaterThanOrEqual(600); } + // 前搖須落於召喚態時長內(狂暴 ×1.15 下仍成立):斷召反應窗恆先於實際生成。 + expect(MARIDELLA.summonTelegraphMs).toBeLessThan( + MARIDELLA.summonDurationMs / MARIDELLA.enrageSpeedMultiplier, + ); }); it('潮線推移恆低於玩家全速(交叉不變式 16:不與速度控制器對抗)', () => { diff --git a/apps/starpuff/src/game/logic/maridellaFsm.ts b/apps/starpuff/src/game/logic/maridellaFsm.ts index 23c2a62ec..abc655364 100644 --- a/apps/starpuff/src/game/logic/maridellaFsm.ts +++ b/apps/starpuff/src/game/logic/maridellaFsm.ts @@ -32,6 +32,9 @@ export const MARIDELLA = { currentTelegraphMs: 900, dropletTelegraphMs: 650, waveTelegraphMs: 900, + // 召喚吟唱前搖(審查回饋:原沿 duration×0.6=540ms 低於紅線)——固定常數化, + // 兼作雷化斷召的可讀反應窗。 + summonTelegraphMs: 650, moonorbTelegraphMs: 600, // 潮線改道:水流推移強度恆低於玩家全速(220,交叉不變式 16);潮化免推(§119 優勢)。 currentPushPxPerSec: 90, diff --git a/apps/starpuff/src/game/systems/maridella.ts b/apps/starpuff/src/game/systems/maridella.ts index 2ee66d258..1ef04265a 100644 --- a/apps/starpuff/src/game/systems/maridella.ts +++ b/apps/starpuff/src/game/systems/maridella.ts @@ -252,10 +252,11 @@ export function createMaridella( }; // 潮湧召喚(P2):吟唱抖動後由 GameScene 走正式 spawn 管線(雷化鏈電可斷召 §58)。 + // 前搖走 summonTelegraphMs 固定常數(≥600ms 紅線;審查回饋修正 540ms 漏網)。 const doSummon = (cap: number) => { scene.tweens.add({ targets: body, angle: 5, duration: 60, yoyo: true, repeat: 5 }); playSfx('boss-roar', 0.6); - delay(MARIDELLA.summonDurationMs * 0.6, () => { + delay(MARIDELLA.summonTelegraphMs, () => { if (!dying) hooks.summonFoamy(cap); }); }; diff --git a/apps/starpuff/src/game/systems/tariffang.ts b/apps/starpuff/src/game/systems/tariffang.ts index 8edcac9a6..02d99cf31 100644 --- a/apps/starpuff/src/game/systems/tariffang.ts +++ b/apps/starpuff/src/game/systems/tariffang.ts @@ -238,10 +238,8 @@ export function createTariffang( stamp.enableBody(true, x, 30, true, true); stamp.setTexture('__WHITE'); stamp.setDisplaySize(34, 30).setTint(BRASS_TINT).setAlpha(0.95); - // 池復用殘留復位(poolFlags 僅復位標準旗標):稅票身分屬本系統自訂資料, - // 復用為關稅槌時必須顯式清除,防被追蹤/壽命迴圈誤收。 - stamp.setData('ticket', false); - stamp.setData('ticketUntil', 0); + // 稅票身分/壽命戳記由 poolFlags SSOT 取出即復位(§122 審查收斂); + // homingMs 沿 §30 jellord 慣例每發必寫。 stamp.setData('homingMs', 0); const stampBody = stamp.body as Phaser.Physics.Arcade.Body; stampBody.setAllowGravity(false); From 89d5863ae0fd287881cfea8e11cbcd6951d07b79 Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 28 Jul 2026 05:46:42 +0800 Subject: [PATCH 10/10] =?UTF-8?q?docs(dev):=20002=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E8=A8=98=E9=8C=84=20W2=20=E9=9B=99=E9=AD=94=E7=8E=8B=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E8=88=87=E5=85=A9=E7=AD=86=E5=AF=A9=E6=9F=A5=E6=95=99?= =?UTF-8?q?=E8=A8=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 聚合本車 3 筆:reward W2 雙魔王交付+penalty 量測 HMR 汙染+penalty 召喚前搖漏網 - 檔頭終值:本次分數變化 -1(reward 1、penalty 2、neutral 0)|累計總分 +329 測試:node scripts/verify-002-log.mjs --base-ref origin/main 通過 Co-authored-by: Cursor --- docs/dev/002_development_reward_penalty_log.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index e4134b6af..3a5ebe388 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 2、penalty 1、neutral 0)|累計總分:+330 +> 本次分數變化:-1(reward 1、penalty 2、neutral 0)|累計總分:+329 ## 新增模板(4 行) @@ -13,6 +13,21 @@ ## 條目(新→舊) +- 日期:2026-07-28 +- ID:reward-starpuff-w2-tariffang-maridella-ship +- 原因:星海終局篇 W2 需交付 L22/L24 雙魔王關(#886),關卡/魔王/圖鑑/成就/SEO 多面接線且難度須接續 L20 遞增不倒掛 +- 解法:tariffangFsm/maridellaFsm 表驅動三段+各自 systems 呈現層(GameScene 零分支),靜態軸 9.2→9.3→9.4、high bot ×7 均 86%(≥60%)、low ×7 均 0%(<20%),1142 案測試全綠 + +- 日期:2026-07-28 +- ID:penalty-starpuff-w2-summon-telegraph-gap +- 原因:Maridella 召喚前搖沿 duration×0.6 慣例得 540ms,低於「全招 ≥600ms」自報紅線且 telegraph 單測未覆蓋 summon 而漏網,經玩法席審查抓出 +- 解法:前搖常數化 summonTelegraphMs 650ms 並把 summon 納入 telegraph 單測錨定(含斷召窗恆先於生成的狂暴下界斷言),L24 audit 抽跑確認通關率不受影響 + +- 日期:2026-07-28 +- ID:penalty-starpuff-w2-hmr-measurement-pollution +- 原因:level-audit 序列量測進行中就地修改 systems 程式碼,Vite HMR 重載汙染量測批次且貨櫃/關稅槌修正使前批 L22 數據失效 +- 解法:終止批次並確立「量測期間程式碼凍結」紀律,修正後以負載閘門腳本重跑全套 ×7 序列量測取代失效數據 + - 日期:2026-07-28 - ID:penalty-starpuff-facing-pr914-review-gaps - 原因:PR #914 首輪交付遭兩席異家族審查 REQUEST CHANGES——盤點宣稱 26 種實為 24(漏 chompy/twinkla)、註解自稱 facing SSOT 但實為 6 處散落呼叫點無共用 helper、回歸測試為硬編個案而非表驅動守門(新增品種漏設面向不會轉紅)、前搖與多狀態面向未同步且無速度死區