diablo2-web/tests/client/escape-menu-113c.test.ts

286 lines
11 KiB
TypeScript

import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { CommandMapper } from '../../src/client/input/command-mapper.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import {
ESCAPE_MENU_BANNER_SIZES,
ESCAPE_MENU_PENTSPIN_FRAMES,
ESCAPE_MENU_PENTSPIN_MAX_WIDTH,
WorldPanelsHud,
computeEscapeMenuLayout,
hitTestEscapeMenu,
} from '../../src/client/ui/world-panels.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ClientCommand } from '../../src/netproto/index.ts'
describe('Diablo II v1.13c In-Game Escape Menu (D2Client.dll gmenu.cpp 0x6fb13b00..0x6fb16300)', () => {
it('verifies packed Escape menu PNG assets exist in public/ui/', () => {
const publicUiDir = resolve(process.cwd(), 'public/ui')
expect(existsSync(resolve(publicUiDir, 'esc-options.png'))).toBe(true)
expect(existsSync(resolve(publicUiDir, 'esc-exit.png'))).toBe(true)
expect(existsSync(resolve(publicUiDir, 'esc-return.png'))).toBe(true)
expect(existsSync(resolve(publicUiDir, 'pentspin.png'))).toBe(true)
})
it('computes exact 1.13c Escape menu geometry for 800x600 and 1024x768', () => {
const layout800 = computeEscapeMenuLayout(800, 600, 2, 0)
// topY = (600 - 80) / 2 - (3 * 50) / 2 = 260 - 75 = 185
expect(layout800.centerX).toBe(400)
expect(layout800.topY).toBe(185)
expect(layout800.rowStepY).toBe(50)
expect(layout800.totalHeight).toBe(150)
expect(layout800.hitMinX).toBe(140)
expect(layout800.hitMaxX).toBe(660)
expect(layout800.hitMinY).toBe(185)
expect(layout800.hitMaxY).toBe(335)
expect(layout800.rows).toHaveLength(3)
// Row 0: Options (160x36)
expect(layout800.rows[0]).toEqual({
index: 0,
action: 'options',
rowY: 185,
baselineY: 224,
bannerX: 400 - 80 - 1, // 319
bannerY: 224 - 36, // 188
bannerWidth: 160,
bannerHeight: 36,
})
// Row 1: Save and Exit Game (434x36)
expect(layout800.rows[1]).toEqual({
index: 1,
action: 'saveAndExit',
rowY: 235,
baselineY: 274,
bannerX: 400 - 217 - 1, // 182
bannerY: 274 - 36, // 238
bannerWidth: 434,
bannerHeight: 36,
})
// Row 2: Return to Game (356x36)
expect(layout800.rows[2]).toEqual({
index: 2,
action: 'returnToGame',
rowY: 285,
baselineY: 324,
bannerX: 400 - 178 - 1, // 221
bannerY: 324 - 36, // 288
bannerWidth: 356,
bannerHeight: 36,
})
// Pentagrams flank selected row 2 at anchorX=99 and anchorX=649, baselineY=185+100+51=336
expect(layout800.leftPent.anchorX).toBe(400 - ESCAPE_MENU_PENTSPIN_MAX_WIDTH - 249) // 99
expect(layout800.rightPent.anchorX).toBe(400 + 249) // 649
expect(layout800.leftPent.baselineY).toBe(336)
expect(layout800.rightPent.baselineY).toBe(336)
// 1024x768 layout
const layout1024 = computeEscapeMenuLayout(1024, 768, 1, 0)
// topY = (768 - 80) / 2 - 75 = 344 - 75 = 269
expect(layout1024.centerX).toBe(512)
expect(layout1024.topY).toBe(269)
expect(layout1024.hitMinX).toBe(252)
expect(layout1024.hitMaxX).toBe(772)
expect(layout1024.rows[0].rowY).toBe(269)
expect(layout1024.rows[1].rowY).toBe(319)
expect(layout1024.rows[2].rowY).toBe(369)
expect(layout1024.leftPent.anchorX).toBe(512 - 52 - 249) // 211
expect(layout1024.rightPent.anchorX).toBe(512 + 249) // 761
expect(layout1024.leftPent.baselineY).toBe(269 + 50 + 51) // 370
})
it('hit-tests rows within [centerX-260, centerX+260] and [topY, topY+150) per 0x6fb15560 & 0x6fb14080', () => {
// Inside row 0 (185..234)
expect(hitTestEscapeMenu(400, 185, 800, 600)).toBe(0)
expect(hitTestEscapeMenu(140, 234, 800, 600)).toBe(0)
// Inside row 1 (235..284)
expect(hitTestEscapeMenu(400, 235, 800, 600)).toBe(1)
expect(hitTestEscapeMenu(660, 284, 800, 600)).toBe(1)
// Inside row 2 (285..334)
expect(hitTestEscapeMenu(400, 285, 800, 600)).toBe(2)
expect(hitTestEscapeMenu(400, 334, 800, 600)).toBe(2)
// Outside X bounds (140..660)
expect(hitTestEscapeMenu(139, 210, 800, 600)).toBeNull()
expect(hitTestEscapeMenu(661, 210, 800, 600)).toBeNull()
// Outside Y bounds (185..334)
expect(hitTestEscapeMenu(400, 184, 800, 600)).toBeNull()
expect(hitTestEscapeMenu(400, 335, 800, 600)).toBeNull()
})
it('draws centered DC6 banners and counter-rotating pentspin frames without dimming box', () => {
const worldPanels = new WorldPanelsHud()
worldPanels.escapeMenuOpen = true
worldPanels.escapeMenuSelectedIndex = 1 // Save and Exit
const drawImageCalls: Array<{
img: unknown
args: number[]
}> = []
const fillRectCalls: number[][] = []
const strokeRectCalls: number[][] = []
const mockCtx = {
drawImage: (img: unknown, ...args: number[]) => {
drawImageCalls.push({ img, args })
},
fillRect: (...args: number[]) => {
fillRectCalls.push(args)
},
strokeRect: (...args: number[]) => {
strokeRectCalls.push(args)
},
} as unknown as CanvasRenderingContext2D
const fakeImg = (name: string, w: number, h: number) =>
({ name, naturalWidth: w, width: w, height: h }) as unknown as HTMLImageElement
const escOptionsImg = fakeImg('escOptions', ESCAPE_MENU_BANNER_SIZES[0].width, 36)
const escExitImg = fakeImg('escExit', ESCAPE_MENU_BANNER_SIZES[1].width, 36)
const escReturnImg = fakeImg('escReturn', ESCAPE_MENU_BANNER_SIZES[2].width, 36)
const pentSpinImg = fakeImg('pentSpin', 295, 53)
// At nowMs = 100ms -> spinFrame = floor(100 / 50) & 7 = 2, leftFrameIndex = (8 - 2) & 7 = 6, rightFrameIndex = 2
worldPanels.drawEscapeMenu(mockCtx, 800, 600, 100, {
escOptionsImg,
escExitImg,
escReturnImg,
pentSpinImg,
})
// Authentic 1.13c draws no dim rect or brown border box
expect(fillRectCalls).toHaveLength(0)
expect(strokeRectCalls).toHaveLength(0)
// 3 banner draws + 2 pentagram draws = 5 drawImage calls
expect(drawImageCalls).toHaveLength(5)
expect(drawImageCalls[0]).toEqual({
img: escOptionsImg,
args: [319, 188, 160, 36],
})
expect(drawImageCalls[1]).toEqual({
img: escExitImg,
args: [182, 238, 434, 36],
})
expect(drawImageCalls[2]).toEqual({
img: escReturnImg,
args: [221, 288, 356, 36],
})
const leftFrame = ESCAPE_MENU_PENTSPIN_FRAMES[6]
const rightFrame = ESCAPE_MENU_PENTSPIN_FRAMES[2]
// Selected row 1 pentagram baselineY = 185 + 50 + 51 = 286
expect(drawImageCalls[3]).toEqual({
img: pentSpinImg,
args: [
leftFrame.x,
leftFrame.y,
leftFrame.w,
leftFrame.h,
99 + leftFrame.offsetX,
286 - leftFrame.h + leftFrame.offsetY,
leftFrame.w,
leftFrame.h,
],
})
expect(drawImageCalls[4]).toEqual({
img: pentSpinImg,
args: [
rightFrame.x,
rightFrame.y,
rightFrame.w,
rightFrame.h,
649 + rightFrame.offsetX,
286 - rightFrame.h + rightFrame.offsetY,
rightFrame.w,
rightFrame.h,
],
})
})
it('navigates Escape menu with ArrowUp/ArrowDown/Enter, plays sound ID 1 (button.wav), and blocks world input', () => {
const world = new ClientWorld()
const actions: string[] = []
const sounds: number[] = []
const commands: ClientCommand[] = []
const hudManager = new HudManager(undefined, {
onEscapeMenuAction: (act) => actions.push(act),
})
const hudModel = new HudModel({
world,
hudManager,
playUiSound: (id) => sounds.push(id),
onCommand: (cmd) => commands.push(cmd),
})
const mapper = new CommandMapper({
world,
hudManager,
hudModel,
sendCommand: (cmd) => commands.push(cmd),
onEscapeMenu: () => {
hudModel.togglePanel('escapeMenu')
},
})
// 1. Open inventory first; Escape closes inventory before opening Escape menu
hudModel.togglePanel('inventory')
expect(hudModel.getSnapshot().panels.inventory).toBe(true)
expect(hudModel.getSnapshot().panels.escapeMenu).toBe(false)
mapper.handleKeyDown({ key: 'Escape' })
expect(hudModel.getSnapshot().panels.inventory).toBe(false)
expect(hudModel.getSnapshot().panels.escapeMenu).toBe(false)
// 2. Press Escape again -> opens Escape menu with default selection index 2 (Return to Game)
mapper.handleKeyDown({ key: 'Escape' })
expect(hudModel.getSnapshot().panels.escapeMenu).toBe(true)
expect(hudManager.escapeMenuOpen).toBe(true)
expect(hudManager.escapeMenuSelectedIndex).toBe(2)
// 3. While Escape menu is open, gameplay hotkeys (I, C, W, 1) and world clicks are blocked
mapper.handleKeyDown({ key: 'i' })
mapper.handleKeyDown({ key: 'w' })
expect(hudModel.getSnapshot().panels.inventory).toBe(false)
expect(commands).toHaveLength(0)
expect(hudManager.isPointInterceptedByHud(50, 50)).toBe(true)
expect(mapper.handlePointerDown(50, 50, 0)).toBeNull()
expect(commands).toHaveLength(0)
// 4. ArrowUp moves selection 2 -> 1 (Save and Exit) -> 0 (Options) -> wraps to 2, playing sound 1 each time
mapper.handleKeyDown({ key: 'ArrowUp' })
expect(hudManager.escapeMenuSelectedIndex).toBe(1)
mapper.handleKeyDown({ key: 'ArrowUp' })
expect(hudManager.escapeMenuSelectedIndex).toBe(0)
mapper.handleKeyDown({ key: 'ArrowUp' })
expect(hudManager.escapeMenuSelectedIndex).toBe(2)
expect(sounds).toEqual([1, 1, 1])
// 5. ArrowDown moves selection 2 -> wraps to 0 (Options); Enter activates Options (stays open, plays sound 1)
mapper.handleKeyDown({ key: 'ArrowDown' })
expect(hudManager.escapeMenuSelectedIndex).toBe(0)
mapper.handleKeyDown({ key: 'Enter' })
expect(sounds).toEqual([1, 1, 1, 1, 1])
expect(actions).toEqual(['options'])
expect(hudManager.escapeMenuOpen).toBe(true)
// 6. Click on Row 2 (Return to Game at y=300) -> plays sound 1, closes Escape menu
mapper.handlePointerDown(400, 300, 0)
expect(sounds).toEqual([1, 1, 1, 1, 1, 1])
expect(actions).toEqual(['options', 'returnToGame'])
expect(hudManager.escapeMenuOpen).toBe(false)
expect(hudModel.getSnapshot().panels.escapeMenu).toBe(false)
// 7. Reopen Escape menu and click Row 1 (Save and Exit at y=250) -> plays sound 1, closes menu, emits saveAndExit
mapper.handleKeyDown({ key: 'Escape' })
expect(hudManager.escapeMenuOpen).toBe(true)
expect(hudManager.escapeMenuSelectedIndex).toBe(2) // resets to Return to Game on reopen
mapper.handlePointerDown(400, 250, 0)
expect(sounds).toEqual([1, 1, 1, 1, 1, 1, 1])
expect(actions).toEqual(['options', 'returnToGame', 'saveAndExit'])
expect(hudManager.escapeMenuOpen).toBe(false)
})
})