diff --git a/public/ui/esc-exit.png b/public/ui/esc-exit.png new file mode 100644 index 0000000..f405967 Binary files /dev/null and b/public/ui/esc-exit.png differ diff --git a/public/ui/esc-options.png b/public/ui/esc-options.png new file mode 100644 index 0000000..bde796e Binary files /dev/null and b/public/ui/esc-options.png differ diff --git a/public/ui/esc-return.png b/public/ui/esc-return.png new file mode 100644 index 0000000..2f802d9 Binary files /dev/null and b/public/ui/esc-return.png differ diff --git a/public/ui/pentspin.png b/public/ui/pentspin.png new file mode 100644 index 0000000..c3d571c Binary files /dev/null and b/public/ui/pentspin.png differ diff --git a/src/baker/packers/pack-ui-assets.ts b/src/baker/packers/pack-ui-assets.ts index 0f6c746..2971af8 100644 --- a/src/baker/packers/pack-ui-assets.ts +++ b/src/baker/packers/pack-ui-assets.ts @@ -471,6 +471,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) + // 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) + }) +})