From 7df567b2e479039a82c413fde1c206a79fca0825 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 14:14:08 +0000 Subject: [PATCH] feat(ui): restore authentic 1.13c in-game Escape menu with DC6 banners and pentspin --- public/ui/esc-exit.png | Bin 0 -> 4668 bytes public/ui/esc-options.png | Bin 0 -> 2634 bytes public/ui/esc-return.png | Bin 0 -> 4209 bytes public/ui/pentspin.png | Bin 0 -> 6797 bytes src/baker/packers/pack-ui-assets.ts | 31 +++ src/baker/packers/pack-ui.ts | 31 +++ src/client/input/command-mapper.ts | 26 ++- src/client/main.ts | 69 +------ src/client/session/online-session.ts | 8 + src/client/ui-model/hud-model.ts | 25 ++- src/client/ui/hud-manager.ts | 149 +++++++++++++- src/client/ui/world-panels.ts | 252 +++++++++++++++++++++++ tests/client/escape-menu-113c.test.ts | 285 ++++++++++++++++++++++++++ 13 files changed, 806 insertions(+), 70 deletions(-) create mode 100644 public/ui/esc-exit.png create mode 100644 public/ui/esc-options.png create mode 100644 public/ui/esc-return.png create mode 100644 public/ui/pentspin.png create mode 100644 tests/client/escape-menu-113c.test.ts diff --git a/public/ui/esc-exit.png b/public/ui/esc-exit.png new file mode 100644 index 0000000000000000000000000000000000000000..f4059676962eeb8cc697a1c620bbe4e1917ef8c7 GIT binary patch literal 4668 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 Hu0mjfF|m43 literal 0 HcmV?d00001 diff --git a/public/ui/pentspin.png b/public/ui/pentspin.png new file mode 100644 index 0000000000000000000000000000000000000000..c3d571c6975dcf9c541a3375ebcd0a2d3bfb798b GIT binary patch literal 6797 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 { const ohand = await decodeUiDc6('data\\global\\ui\\CURSOR\\ohand.dc6') savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices) + // 6b. In-Game Escape Menu Banners (`data\local\ui\eng\{Options,Exit,ReturnToGame}.dc6`) & 3D Pentagram Spinner (`CURSOR\Pentspin.dc6`) + // Per D2Client.dll gmenu.cpp (0x6fb13e90, 0x6fb145a0, 0x6fb15230) and D2Win #10138 (0x6f8f17a0) + const escOptionsDc6 = await decodeUiDc6('data\\local\\ui\\eng\\Options.dc6') + const escOptionsStrip = stitchHorizontalStrip(escOptionsDc6.groups[0]!.frames) + if (escOptionsStrip.width !== 160 || escOptionsStrip.height !== 36) { + throw new Error(`Invalid Options.dc6 geometry: expected 160x36, got ${escOptionsStrip.width}x${escOptionsStrip.height}`) + } + savePng('esc-options.png', escOptionsStrip.width, escOptionsStrip.height, escOptionsStrip.pixels) + + const escExitDc6 = await decodeUiDc6('data\\local\\ui\\eng\\Exit.dc6') + const escExitStrip = stitchHorizontalStrip(escExitDc6.groups[0]!.frames) + if (escExitStrip.width !== 434 || escExitStrip.height !== 36) { + throw new Error(`Invalid Exit.dc6 geometry: expected 434x36, got ${escExitStrip.width}x${escExitStrip.height}`) + } + savePng('esc-exit.png', escExitStrip.width, escExitStrip.height, escExitStrip.pixels) + + const escReturnDc6 = await decodeUiDc6('data\\local\\ui\\eng\\ReturnToGame.dc6') + const escReturnStrip = stitchHorizontalStrip(escReturnDc6.groups[0]!.frames) + if (escReturnStrip.width !== 356 || escReturnStrip.height !== 36) { + throw new Error(`Invalid ReturnToGame.dc6 geometry: expected 356x36, got ${escReturnStrip.width}x${escReturnStrip.height}`) + } + savePng('esc-return.png', escReturnStrip.width, escReturnStrip.height, escReturnStrip.pixels) + + const pentSpinDc6 = await decodeUiDc6('data\\global\\ui\\CURSOR\\Pentspin.dc6') + const psFrames = pentSpinDc6.groups[0]?.frames + if (!psFrames || psFrames.length !== 8) { + throw new Error(`Invalid Pentspin.dc6 frame count: expected 8 frames, got ${psFrames?.length ?? 0}`) + } + const psStrip = stitchHorizontalStrip(psFrames) + savePng('pentspin.png', psStrip.width, psStrip.height, psStrip.pixels) + // 7. Extract Bitmap Fonts (`data\local\font\latin\*.dc6 + *.tbl`) const fontNames = ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const const fontMetas: Record = {} diff --git a/src/baker/packers/pack-ui.ts b/src/baker/packers/pack-ui.ts index 4dc8889..cd2f09a 100644 --- a/src/baker/packers/pack-ui.ts +++ b/src/baker/packers/pack-ui.ts @@ -624,6 +624,37 @@ async function main(): Promise { const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6') savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices) + // 6b. In-Game Escape Menu Banners (`data/local/ui/eng/{Options,Exit,ReturnToGame}.dc6`) & 3D Pentagram Spinner (`CURSOR/Pentspin.dc6`) + // Per D2Client.dll gmenu.cpp (0x6fb13e90, 0x6fb145a0, 0x6fb15230) and D2Win #10138 (0x6f8f17a0) + const escOptionsDc6 = await decodeUiDc6('data/local/ui/eng/Options.dc6') + const escOptionsStrip = stitchHorizontalStrip(escOptionsDc6.groups[0]!.frames) + if (escOptionsStrip.width !== 160 || escOptionsStrip.height !== 36) { + throw new Error(`Invalid Options.dc6 geometry: expected 160x36, got ${escOptionsStrip.width}x${escOptionsStrip.height}`) + } + savePng('esc-options.png', escOptionsStrip.width, escOptionsStrip.height, escOptionsStrip.pixels) + + const escExitDc6 = await decodeUiDc6('data/local/ui/eng/Exit.dc6') + const escExitStrip = stitchHorizontalStrip(escExitDc6.groups[0]!.frames) + if (escExitStrip.width !== 434 || escExitStrip.height !== 36) { + throw new Error(`Invalid Exit.dc6 geometry: expected 434x36, got ${escExitStrip.width}x${escExitStrip.height}`) + } + savePng('esc-exit.png', escExitStrip.width, escExitStrip.height, escExitStrip.pixels) + + const escReturnDc6 = await decodeUiDc6('data/local/ui/eng/ReturnToGame.dc6') + const escReturnStrip = stitchHorizontalStrip(escReturnDc6.groups[0]!.frames) + if (escReturnStrip.width !== 356 || escReturnStrip.height !== 36) { + throw new Error(`Invalid ReturnToGame.dc6 geometry: expected 356x36, got ${escReturnStrip.width}x${escReturnStrip.height}`) + } + savePng('esc-return.png', escReturnStrip.width, escReturnStrip.height, escReturnStrip.pixels) + + const pentSpinDc6 = await decodeUiDc6('data/global/ui/CURSOR/Pentspin.dc6') + const psFrames = pentSpinDc6.groups[0]?.frames + if (!psFrames || psFrames.length !== 8) { + throw new Error(`Invalid Pentspin.dc6 frame count: expected 8 frames, got ${psFrames?.length ?? 0}`) + } + const psStrip = stitchHorizontalStrip(psFrames) + savePng('pentspin.png', psStrip.width, psStrip.height, psStrip.pixels) + // Extract authentic dynamic cursor atlas (Issue #148: protate, ohand, orotate) console.log('Extracting authentic dynamic cursor atlas (Issue #148)...') await packCursorAssets(process.cwd()) diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 2572867..ba51c6d 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -587,7 +587,7 @@ export class CommandMapper { const keyUpper = event.key.toUpperCase() - // Escape: close open HUD panels first, otherwise invoke escape menu callback + // Escape: close open HUD panels first, otherwise invoke escape menu callback / toggle if (event.key === 'Escape') { if (this.hudManager.leftPanel !== 'none' || this.hudManager.rightPanel !== 'none') { this.hudManager.closeAllPanels() @@ -604,7 +604,29 @@ export class CommandMapper { event.preventDefault?.() return true } - return false + this.hudManager.toggleEscapeMenu() + event.preventDefault?.() + return true + } + + // While Escape menu is open, route Up/Down/Enter to the Escape menu and block gameplay hotkeys + if (this.hudManager.escapeMenuOpen || this.hudModel?.getSnapshot().panels.escapeMenu) { + if (event.key === 'ArrowUp') { + event.preventDefault?.() + this.hudManager.moveEscapeMenuSelection(-1) + return true + } + if (event.key === 'ArrowDown') { + event.preventDefault?.() + this.hudManager.moveEscapeMenuSelection(1) + return true + } + if (event.key === 'Enter') { + event.preventDefault?.() + this.hudManager.activateEscapeMenuIndex() + return true + } + return true } // Enter: toggle chat input diff --git a/src/client/main.ts b/src/client/main.ts index 9fbc152..dd1cbc6 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -118,45 +118,6 @@ declare global { } } -function drawEscapeMenuOverlay( - ctx: CanvasRenderingContext2D, - width: number, - height: number, -): void { - ctx.save() - const canvasW = ctx.canvas?.width ?? width - const canvasH = ctx.canvas?.height ?? height - const scaleX = width > 0 && canvasW > 0 ? canvasW / width : 1 - const scaleY = height > 0 && canvasH > 0 ? canvasH / height : 1 - if (scaleX !== 1 || scaleY !== 1) { - ctx.scale(scaleX, scaleY) - } - - ctx.fillStyle = 'rgba(0, 0, 0, 0.52)' - ctx.fillRect(0, 0, width, height) - - const boxW = 300 - const boxH = 170 - const boxX = Math.round((width - boxW) / 2) - const boxY = Math.round((height - boxH) / 2) - 24 - - ctx.fillStyle = 'rgba(14, 11, 8, 0.94)' - ctx.strokeStyle = '#c8a858' - ctx.lineWidth = 2 - ctx.fillRect(boxX, boxY, boxW, boxH) - ctx.strokeRect(boxX, boxY, boxW, boxH) - - ctx.textAlign = 'center' - ctx.font = 'bold 20px "D2Exocet", serif' - ctx.fillStyle = '#d8b868' - ctx.fillText('OPTIONS', width / 2, boxY + 46) - ctx.fillStyle = '#f2e6c8' - ctx.fillText('SAVE AND EXIT GAME', width / 2, boxY + 92) - ctx.fillStyle = '#d8b868' - ctx.fillText('RETURN TO GAME', width / 2, boxY + 138) - ctx.restore() -} - async function bootPlayApp(): Promise { window.__d2SkipAutoBoot = true @@ -255,6 +216,11 @@ async function bootPlayApp(): Promise { minimap.resetPan() }, onPanAutomap: (dx, dy) => minimap.pan(dx, dy), + onEscapeMenuAction: (action) => { + if (action === 'saveAndExit') { + void sessionRef?.disconnect('Saved and exited the game; Battle.net session closed') + } + }, }) await hudManager.load('/ui') bootProgress.setStageFloor(25, '游戏界面资源') @@ -798,25 +764,8 @@ async function bootPlayApp(): Promise { } return } + if (e.target === hudCanvas) return const pt = toStageCoords(e) - if (session.hudModel.getSnapshot().panels.escapeMenu && e.button === 0) { - const profile = getViewportProfile(settingsStore.get().viewport) - const boxW = 300 - const boxH = 170 - const boxX = Math.round((profile.width - boxW) / 2) - const boxY = Math.round((profile.height - boxH) / 2) - 24 - if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) { - if (pt.y >= boxY + 70 && pt.y <= boxY + 108) { - session.hudModel.togglePanel('escapeMenu') - void session.disconnect('Saved and exited the game; Battle.net session closed') - return - } - if (pt.y >= boxY + 115 && pt.y <= boxY + 155) { - session.hudModel.togglePanel('escapeMenu') - return - } - } - } session.commandMapper.handlePointerDown(pt.x, pt.y, e.button, performance.now()) }) @@ -872,8 +821,6 @@ async function bootPlayApp(): Promise { true, ) - const hudCtx = hudCanvas.getContext('2d') - let lastFrameMs = performance.now() const renderFrame = (nowMs = performance.now()): void => { const dtMs = Math.max(0, Math.min(100, nowMs - lastFrameMs)) @@ -916,10 +863,6 @@ async function bootPlayApp(): Promise { nowMs, playerMoving: session.predictor.getSnapshot().moving, }) - - if (hudCtx && session.hudModel.getSnapshot().panels.escapeMenu) { - drawEscapeMenuOverlay(hudCtx, profile.width, profile.height) - } } const loop = (now: number): void => { diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index b0572f7..1c54254 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -334,6 +334,14 @@ export class OnlineSession { }, }) + if (!this.hudManager.onEscapeMenuAction) { + this.hudManager.onEscapeMenuAction = (action) => { + if (action === 'saveAndExit') { + void this.disconnect('Saved and exited the game; Battle.net session closed') + } + } + } + this.commandMapper = new CommandMapper({ world: this.world, camera: this.camera, diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 0d18b26..91388e4 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -297,7 +297,12 @@ export class HudModel implements HudCommandSink { this.uiSound = options.playUiSound ?? null this.now = options.now ?? (() => performance.now()) this.handledNpcTransactionSeq = this.world.lastNpcTransaction?.seq ?? 0 - if (this.hudManager) this.hudManager.commandSink = this + if (this.hudManager) { + this.hudManager.commandSink = this + this.hudManager.onPlayUiSound = (soundId) => { + this.uiSound?.(soundId) + } + } this.unsubWorld = this.world.onChange(() => { this.syncFromWorld() @@ -308,7 +313,10 @@ export class HudModel implements HudCommandSink { * Detach the world listener when disposing the session. */ dispose(): void { - if (this.hudManager?.commandSink === this) this.hudManager.commandSink = null + if (this.hudManager?.commandSink === this) { + this.hudManager.commandSink = null + this.hudManager.onPlayUiSound = undefined + } if (this.unsubWorld) { this.unsubWorld() this.unsubWorld = null @@ -321,10 +329,15 @@ export class HudModel implements HudCommandSink { attachHudManager(hudManager: HudManager | null): void { if (this.hudManager && this.hudManager !== hudManager && this.hudManager.commandSink === this) { this.hudManager.commandSink = null + this.hudManager.onPlayUiSound = undefined } this.hudManager = hudManager if (hudManager) { hudManager.commandSink = this + hudManager.onPlayUiSound = (soundId) => { + this.uiSound?.(soundId) + } + hudManager.escapeMenuOpen = this.escapeMenuOpen this.syncFromWorld() } } @@ -345,6 +358,8 @@ export class HudModel implements HudCommandSink { const cursorItem = this.world.containers.cursor const leftPanel = this.hudManager?.leftPanel ?? 'none' const rightPanel = this.hudManager?.rightPanel ?? 'none' + const escapeMenuOpen = this.hudManager ? this.hudManager.escapeMenuOpen : this.escapeMenuOpen + this.escapeMenuOpen = escapeMenuOpen return { charName: self.name, @@ -392,7 +407,7 @@ export class HudModel implements HudCommandSink { cube: leftPanel === 'cube', vendor: leftPanel === 'vendor', automap: this.automap ? this.automap.getMode() !== 'off' : false, - escapeMenu: this.escapeMenuOpen, + escapeMenu: escapeMenuOpen, }, } } @@ -422,6 +437,10 @@ export class HudModel implements HudCommandSink { */ togglePanel(panel: string): boolean { if (panel === 'escapeMenu') { + if (this.hudManager) { + this.escapeMenuOpen = this.hudManager.toggleEscapeMenu() + return this.escapeMenuOpen + } this.escapeMenuOpen = !this.escapeMenuOpen return this.escapeMenuOpen } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index f877834..f562afc 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -33,7 +33,12 @@ import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts' import { SkillTreePanel } from './skill-tree-panel.ts' import { + ESCAPE_MENU_ACTIONS, WorldPanelsHud, + computeEscapeMenuLayout, + hitTestEscapeMenu, + type EscapeMenuAction, + type EscapeMenuRowIndex, type LeftDockPanelKind, type MonsterTargetHudInfo, type VendorTradeState, @@ -81,6 +86,7 @@ export interface PublishedHudState { font: boolean itemsAtlas: boolean questsAtlas: boolean + escapeMenu?: boolean } leftPanel: LeftDockPanelKind rightPanel: 'none' | 'inv' | 'skill' @@ -112,6 +118,8 @@ export interface PublishedHudState { vendorTab?: string vendorItemsCount?: number npcMenuOpen?: boolean + escapeMenuOpen?: boolean + escapeMenuSelectedIndex?: EscapeMenuRowIndex } interface UiManifest { @@ -186,6 +194,11 @@ export { CUBE_CLOSE_BTN_BOUNDS, VENDOR_PANEL_ORIGIN, VENDOR_GRID_ORIGIN, + ESCAPE_MENU_ACTIONS, + computeEscapeMenuLayout, + hitTestEscapeMenu, + type EscapeMenuAction, + type EscapeMenuRowIndex, } from './world-panels.ts' /** @@ -482,6 +495,11 @@ export class HudManager { this.globes.lang = tbl } + /** Optional callback invoked when an Escape menu row is activated ('options' | 'saveAndExit' | 'returnToGame'). */ + onEscapeMenuAction?: ((action: EscapeMenuAction) => void) | undefined + /** Optional UI sound callback (e.g. soundId 1 = cursor/button.wav). */ + onPlayUiSound?: ((soundId: number) => void) | undefined + private readonly hudCanvas: HTMLCanvasElement private readonly callbacks: { onToggleAutomap: () => void @@ -500,6 +518,8 @@ export class HudManager { onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void + onEscapeMenuAction?: (action: EscapeMenuAction) => void + onPlayUiSound?: (soundId: number) => void } constructor( hudCanvas?: HTMLCanvasElement | null, @@ -520,6 +540,8 @@ export class HudManager { onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void + onEscapeMenuAction?: (action: EscapeMenuAction) => void + onPlayUiSound?: (soundId: number) => void }, ) { this.hudCanvas = @@ -549,6 +571,8 @@ export class HudManager { onWaypointTeleport: () => {}, ...callbacks, } + this.onEscapeMenuAction = callbacks?.onEscapeMenuAction + this.onPlayUiSound = callbacks?.onPlayUiSound this.setLang(detectInitialLang()) this.worldPanels.activePlayerInventory = this.inventory this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId) @@ -699,6 +723,10 @@ export class HudManager { cursorAtlas: `${baseUrl}/cursor.png`, boxPieces: `${baseUrl}/boxpieces.png`, focus16: `${baseUrl}/focus16.png`, + escOptions: `${baseUrl}/esc-options.png`, + escExit: `${baseUrl}/esc-exit.png`, + escReturn: `${baseUrl}/esc-return.png`, + pentSpin: `${baseUrl}/pentspin.png`, itemsAtlas: `${baseUrl}/items-atlas.png`, questsAtlas: `${baseUrl}/quests-atlas.png`, } @@ -934,6 +962,82 @@ export class HudManager { this.syncPublishedState() } + get escapeMenuOpen(): boolean { + return this.worldPanels.escapeMenuOpen + } + + set escapeMenuOpen(val: boolean) { + if (val && !this.worldPanels.escapeMenuOpen) { + this.worldPanels.escapeMenuSelectedIndex = 2 + } + this.worldPanels.escapeMenuOpen = val + this.syncPublishedState() + } + + get escapeMenuSelectedIndex(): EscapeMenuRowIndex { + return this.worldPanels.escapeMenuSelectedIndex + } + + set escapeMenuSelectedIndex(idx: EscapeMenuRowIndex) { + this.worldPanels.escapeMenuSelectedIndex = idx + this.syncPublishedState() + } + + /** + * Toggle the authentic 1.13c In-Game Escape Menu (`D2Client.dll` `0x6fb15760` & `0x6fb15bc0`). + * Opening the Escape menu closes any open panels and resets the default selection to row 2 (`Return to Game`). + */ + toggleEscapeMenu(): boolean { + if (this.worldPanels.escapeMenuOpen) { + this.worldPanels.escapeMenuOpen = false + this.syncPublishedState() + return false + } + this.closeAllPanels() + this.worldPanels.escapeMenuOpen = true + this.worldPanels.escapeMenuSelectedIndex = 2 + this.syncPublishedState() + return true + } + + /** + * Move Escape menu selection up (`delta = -1`) or down (`delta = 1`) with wrap-around (`0x6fb14ee0` / `0x6fb14f50`). + */ + moveEscapeMenuSelection(delta: -1 | 1): EscapeMenuRowIndex { + const next = ((this.worldPanels.escapeMenuSelectedIndex + delta + 3) % 3) as EscapeMenuRowIndex + this.worldPanels.escapeMenuSelectedIndex = next + this.onPlayUiSound?.(1) + this.syncPublishedState() + return next + } + + /** + * Activate the Escape menu item at `index` (defaults to current `escapeMenuSelectedIndex`): + * - `0` (`options`): placeholder — selects row 0, plays click sound, keeps Escape menu open. + * - `1` (`saveAndExit`): closes Escape menu, plays click sound, invokes `onEscapeMenuAction('saveAndExit')`. + * - `2` (`returnToGame`): closes Escape menu, plays click sound, invokes `onEscapeMenuAction('returnToGame')`. + */ + activateEscapeMenuIndex(index: EscapeMenuRowIndex = this.worldPanels.escapeMenuSelectedIndex): EscapeMenuAction { + this.worldPanels.escapeMenuSelectedIndex = index + this.onPlayUiSound?.(1) + const action = ESCAPE_MENU_ACTIONS[index] + if (action === 'returnToGame' || action === 'saveAndExit') { + this.worldPanels.escapeMenuOpen = false + } + this.syncPublishedState() + this.onEscapeMenuAction?.(action) + return action + } + + /** + * Hit-test and activate an Escape menu item at logical coordinates `(logicalX, logicalY)`. + */ + handleEscapeMenuClick(logicalX: number, logicalY: number): EscapeMenuAction | null { + const hit = hitTestEscapeMenu(logicalX, logicalY, this.viewportProfile.width, this.viewportProfile.height) + if (hit === null) return null + return this.activateEscapeMenuIndex(hit) + } + closeAllPanels(): boolean { const anyOpen = this.leftPanel !== 'none' || @@ -941,6 +1045,7 @@ export class HudManager { this.belt.lockedOpen || this.hotkeys.openPopup !== null || this.worldPanels.npcMenu !== null || + this.worldPanels.escapeMenuOpen || this.goldDialog !== null || this.inventory.isIdentifyMode() const prevLeft = this.leftPanel @@ -956,6 +1061,7 @@ export class HudManager { this.belt.lockedOpen = false this.hotkeys.openPopup = null this.worldPanels.npcMenu = null + this.worldPanels.escapeMenuOpen = false this.worldPanels.vendorTradeState = 'idle' this.worldPanels.releaseMomentaryVendorButton() this.syncPublishedState() @@ -1018,7 +1124,7 @@ export class HudManager { * Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false. */ isPointInterceptedByHud(logicalX: number, logicalY: number): boolean { - if (this.goldDialog) return true + if (this.goldDialog || this.worldPanels.escapeMenuOpen) return true const profile = this.viewportProfile const docking = this.getDockingLayout() const { marginW } = docking @@ -1076,6 +1182,22 @@ export class HudManager { this.syncPublishedState() return } + if (this.worldPanels.escapeMenuOpen) { + if (e.key === 'Escape') { + e.preventDefault() + this.toggleEscapeMenu() + } else if (e.key === 'ArrowUp') { + e.preventDefault() + this.moveEscapeMenuSelection(-1) + } else if (e.key === 'ArrowDown') { + e.preventDefault() + this.moveEscapeMenuSelection(1) + } else if (e.key === 'Enter') { + e.preventDefault() + this.activateEscapeMenuIndex() + } + return + } const key = e.key.toUpperCase() if (key === 'I' || key === 'B') { @@ -1251,6 +1373,12 @@ export class HudManager { if (e.button === 0) this.handleGoldDialogClick(pt.x, pt.y) return } + if (this.worldPanels.escapeMenuOpen) { + e.preventDefault() + e.stopPropagation() + if (e.button === 0) this.handleEscapeMenuClick(pt.x, pt.y) + return + } if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) { this.dismissNpcMenu() } @@ -1667,6 +1795,11 @@ export class HudManager { font: this.font.loaded, itemsAtlas: this.images.has('itemsAtlas'), questsAtlas: this.images.has('questsAtlas'), + escapeMenu: + this.images.has('escOptions') && + this.images.has('escExit') && + this.images.has('escReturn') && + this.images.has('pentSpin'), } this.state.leftPanel = this.leftPanel this.state.rightPanel = this.rightPanel @@ -1702,6 +1835,8 @@ export class HudManager { this.state.vendorTab = this.worldPanels.activeVendorTab this.state.vendorItemsCount = this.worldPanels.getActiveVendorPlacements().length this.state.npcMenuOpen = this.worldPanels.npcMenu !== null + this.state.escapeMenuOpen = this.worldPanels.escapeMenuOpen + this.state.escapeMenuSelectedIndex = this.worldPanels.escapeMenuSelectedIndex } /** @@ -1922,7 +2057,7 @@ export class HudManager { } // 9. Area Entry Gothic Banner (`Entering: `, only when no split panels are open) - if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu) { + if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) { this.worldPanels.drawAreaBanner(ctx, nowMs, this.font) } @@ -1941,6 +2076,16 @@ export class HudManager { // 10b. Gold dialog (drop / deposit / withdraw) this.drawGoldDialog(ctx) + // 10c. Authentic In-Game Escape Menu (`D2Client.dll` `gmenu.cpp` `0x6fb15230`) + if (this.worldPanels.escapeMenuOpen) { + this.worldPanels.drawEscapeMenu(ctx, profile.width, profile.height, nowMs, { + escOptionsImg: this.images.get('escOptions') ?? null, + escExitImg: this.images.get('escExit') ?? null, + escReturnImg: this.images.get('escReturn') ?? null, + pentSpinImg: this.images.get('pentSpin') ?? null, + }) + } + // 11. Authentic Hand / Trade Cursor & Held Item at Hotspot this.renderCursor(ctx, nowMs) diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index 4f479a2..69362ea 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -657,6 +657,7 @@ export class WorldPanelsHud { private _starterStashCleared = false cubeTransmuteCount = 0 escapeMenuOpen = false + escapeMenuSelectedIndex: EscapeMenuRowIndex = 2 lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null npcMenu: { /** Server unit id of the NPC (from `NpcInit`); `null` for menus opened without a server unit. */ @@ -3105,6 +3106,257 @@ export class WorldPanelsHud { }) ctx.restore() } + + /** + * Draw the authentic Diablo II v1.13c In-Game Escape Menu (`D2Client.dll` `gmenu.cpp` `0x6fb15230..0x6fb15555` + * & `D2Win.dll` `#10138` `0x6f8f17a0`): + * - No background box or screen dimming: draws the 3 stitched DC6 banners (`Options.dc6`, `Exit.dc6`, + * `ReturnToGame.dc6`) directly over the game viewport. + * - Draws counter-rotating 8-frame 3D pentagrams (`CURSOR\Pentspin.dc6`, 50ms/frame) flanking the selected row. + */ + drawEscapeMenu( + ctx: CanvasRenderingContext2D, + hudWidth: number, + hudHeight: number, + nowMs: number, + assets: { + escOptionsImg: HTMLImageElement | null + escExitImg: HTMLImageElement | null + escReturnImg: HTMLImageElement | null + pentSpinImg: HTMLImageElement | null + }, + ): void { + if (!this.escapeMenuOpen) return + const layout = computeEscapeMenuLayout(hudWidth, hudHeight, this.escapeMenuSelectedIndex, nowMs) + const bannerImgs: readonly (HTMLImageElement | null)[] = [ + assets.escOptionsImg, + assets.escExitImg, + assets.escReturnImg, + ] + for (const row of layout.rows) { + const img = bannerImgs[row.index] + if (img) { + ctx.drawImage(img, row.bannerX, row.bannerY, row.bannerWidth, row.bannerHeight) + } + } + if (assets.pentSpinImg) { + const lp = layout.leftPent + ctx.drawImage( + assets.pentSpinImg, + lp.frame.x, + lp.frame.y, + lp.frame.w, + lp.frame.h, + lp.x, + lp.y, + lp.frame.w, + lp.frame.h, + ) + const rp = layout.rightPent + ctx.drawImage( + assets.pentSpinImg, + rp.frame.x, + rp.frame.y, + rp.frame.w, + rp.frame.h, + rp.x, + rp.y, + rp.frame.w, + rp.frame.h, + ) + } + } +} + +export type EscapeMenuRowIndex = 0 | 1 | 2 +export type EscapeMenuAction = 'options' | 'saveAndExit' | 'returnToGame' + +export const ESCAPE_MENU_ACTIONS: readonly [EscapeMenuAction, EscapeMenuAction, EscapeMenuAction] = [ + 'options', + 'saveAndExit', + 'returnToGame', +] as const + +export const ESCAPE_MENU_BANNER_SIZES: readonly [ + { readonly width: 160; readonly height: 36 }, + { readonly width: 434; readonly height: 36 }, + { readonly width: 356; readonly height: 36 }, +] = [ + { width: 160, height: 36 }, + { width: 434, height: 36 }, + { width: 356, height: 36 }, +] as const + +export const ESCAPE_MENU_PENTSPIN_MAX_WIDTH = 52 +export const ESCAPE_MENU_PENTSPIN_INTERVAL_MS = 50 + +export interface PentSpinFrameMeta { + readonly x: number + readonly y: number + readonly w: number + readonly h: number + readonly offsetX: number + readonly offsetY: number +} + +/** + * Authentic 8-frame `data/global/ui/CURSOR/Pentspin.dc6` strip geometry (`public/ui/pentspin.png`, 295x53). + */ +export const ESCAPE_MENU_PENTSPIN_FRAMES: readonly [ + PentSpinFrameMeta, + PentSpinFrameMeta, + PentSpinFrameMeta, + PentSpinFrameMeta, + PentSpinFrameMeta, + PentSpinFrameMeta, + PentSpinFrameMeta, + PentSpinFrameMeta, +] = [ + { x: 0, y: 0, w: 51, h: 52, offsetX: -1, offsetY: 0 }, + { x: 51, y: 0, w: 43, h: 53, offsetX: 3, offsetY: 0 }, + { x: 94, y: 0, w: 27, h: 53, offsetX: 11, offsetY: 0 }, + { x: 121, y: 0, w: 9, h: 53, offsetX: 21, offsetY: 0 }, + { x: 130, y: 0, w: 23, h: 53, offsetX: 15, offsetY: 0 }, + { x: 153, y: 0, w: 40, h: 53, offsetX: 7, offsetY: 0 }, + { x: 193, y: 0, w: 50, h: 52, offsetX: 1, offsetY: 0 }, + { x: 243, y: 0, w: 52, h: 51, offsetX: -1, offsetY: 0 }, +] as const + +export interface EscapeMenuRowLayout { + readonly index: EscapeMenuRowIndex + readonly action: EscapeMenuAction + readonly rowY: number + readonly baselineY: number + readonly bannerX: number + readonly bannerY: number + readonly bannerWidth: number + readonly bannerHeight: number +} + +export interface EscapeMenuPentPlacement { + readonly frameIndex: number + readonly frame: PentSpinFrameMeta + readonly anchorX: number + readonly baselineY: number + readonly x: number + readonly y: number +} + +export interface EscapeMenuLayout { + readonly centerX: number + readonly topY: number + readonly totalHeight: number + readonly rowStepY: number + readonly hitMinX: number + readonly hitMaxX: number + readonly hitMinY: number + readonly hitMaxY: number + readonly selectedIndex: EscapeMenuRowIndex + readonly spinFrame: number + readonly rows: readonly [EscapeMenuRowLayout, EscapeMenuRowLayout, EscapeMenuRowLayout] + readonly leftPent: EscapeMenuPentPlacement + readonly rightPent: EscapeMenuPentPlacement +} + +/** + * Compute authentic Diablo II v1.13c In-Game Escape Menu layout (`D2Client.dll` `0x6fb92ac0`, `0x6fb15230..0x6fb15555`). + */ +export function computeEscapeMenuLayout( + hudWidth = 800, + hudHeight = 600, + selectedIndex: EscapeMenuRowIndex = 2, + nowMs = 0, +): EscapeMenuLayout { + const centerX = Math.trunc(hudWidth / 2) + const rowStepY = 50 + const totalHeight = 3 * rowStepY + const topY = Math.trunc((hudHeight - 80) / 2) - Math.trunc(totalHeight / 2) + + const buildRow = (index: EscapeMenuRowIndex): EscapeMenuRowLayout => { + const size = ESCAPE_MENU_BANNER_SIZES[index] + const rowY = topY + index * rowStepY + const baselineY = rowY + 39 + const bannerX = centerX - Math.trunc(size.width / 2) - 1 + const bannerY = baselineY - size.height + return { + index, + action: ESCAPE_MENU_ACTIONS[index], + rowY, + baselineY, + bannerX, + bannerY, + bannerWidth: size.width, + bannerHeight: size.height, + } + } + + const rows: readonly [EscapeMenuRowLayout, EscapeMenuRowLayout, EscapeMenuRowLayout] = [ + buildRow(0), + buildRow(1), + buildRow(2), + ] + + const spinFrame = Math.floor(Math.max(0, nowMs) / ESCAPE_MENU_PENTSPIN_INTERVAL_MS) & 7 + const leftFrameIndex = (8 - spinFrame) & 7 + const rightFrameIndex = spinFrame + const leftFrame = ESCAPE_MENU_PENTSPIN_FRAMES[leftFrameIndex]! + const rightFrame = ESCAPE_MENU_PENTSPIN_FRAMES[rightFrameIndex]! + + const pentBaselineY = topY + selectedIndex * rowStepY + 51 + const leftAnchorX = centerX - ESCAPE_MENU_PENTSPIN_MAX_WIDTH - 249 + const rightAnchorX = centerX + 249 + + return { + centerX, + topY, + totalHeight, + rowStepY, + hitMinX: centerX - 260, + hitMaxX: centerX + 260, + hitMinY: topY, + hitMaxY: topY + totalHeight, + selectedIndex, + spinFrame, + rows, + leftPent: { + frameIndex: leftFrameIndex, + frame: leftFrame, + anchorX: leftAnchorX, + baselineY: pentBaselineY, + x: leftAnchorX + leftFrame.offsetX, + y: pentBaselineY - leftFrame.h + leftFrame.offsetY, + }, + rightPent: { + frameIndex: rightFrameIndex, + frame: rightFrame, + anchorX: rightAnchorX, + baselineY: pentBaselineY, + x: rightAnchorX + rightFrame.offsetX, + y: pentBaselineY - rightFrame.h + rightFrame.offsetY, + }, + } +} + +/** + * Hit-test the In-Game Escape Menu (`D2Client.dll` `0x6fb15560` horizontal guard + `0x6fb14080` row check). + * Returns row index `0` (`options`), `1` (`saveAndExit`), `2` (`returnToGame`), or `null` if outside bounds. + */ +export function hitTestEscapeMenu( + logicalX: number, + logicalY: number, + hudWidth = 800, + hudHeight = 600, +): EscapeMenuRowIndex | null { + const centerX = Math.trunc(hudWidth / 2) + if (logicalX < centerX - 260 || logicalX > centerX + 260) { + return null + } + const topY = Math.trunc((hudHeight - 80) / 2) - 75 + if (logicalY < topY || logicalY >= topY + 150) { + return null + } + const idx = Math.floor((logicalY - topY) / 50) + return idx === 0 || idx === 1 || idx === 2 ? idx : null } /** diff --git a/tests/client/escape-menu-113c.test.ts b/tests/client/escape-menu-113c.test.ts new file mode 100644 index 0000000..c9bfb0a --- /dev/null +++ b/tests/client/escape-menu-113c.test.ts @@ -0,0 +1,285 @@ +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) + }) +})