feat(ui): restore authentic 1.13c in-game Escape menu with DC6 banners and pentspin

This commit is contained in:
troytt 2026-10-01 14:14:08 +00:00
parent 40a3a12e67
commit 7df567b2e4
13 changed files with 806 additions and 70 deletions

BIN
public/ui/esc-exit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
public/ui/esc-options.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/ui/esc-return.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/ui/pentspin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -471,6 +471,37 @@ async function main(): Promise<void> {
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<string, FontAtlasMeta> = {}

View File

@ -624,6 +624,37 @@ async function main(): Promise<void> {
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())

View File

@ -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

View File

@ -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<PlayAppController> {
window.__d2SkipAutoBoot = true
@ -255,6 +216,11 @@ async function bootPlayApp(): Promise<PlayAppController> {
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<PlayAppController> {
}
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<PlayAppController> {
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<PlayAppController> {
nowMs,
playerMoving: session.predictor.getSnapshot().moving,
})
if (hudCtx && session.hudModel.getSnapshot().panels.escapeMenu) {
drawEscapeMenuOverlay(hudCtx, profile.width, profile.height)
}
}
const loop = (now: number): void => {

View File

@ -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,

View File

@ -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
}

View File

@ -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: <Level Name>`, 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)

View File

@ -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
}
/**

View File

@ -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)
})
})