feat(ui): restore authentic 1.13c in-game Escape menu with DC6 banners and pentspin
This commit is contained in:
parent
40a3a12e67
commit
7df567b2e4
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
|
|
@ -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> = {}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue