fix(ui): restore Horadric Cube Transmute and Close buttons per D2Client.dll 1.13c parity

This commit is contained in:
troytt 2026-09-27 08:31:26 +00:00
parent 8c5ae5c623
commit 3bf9a8c220
9 changed files with 377 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{
"version": "1.13c",
"decodedDc6Count": 683,
"decodedDc6Count": 685,
"dc6DecodeFailures": 0,
"atlasWidth": 1024,
"atlasHeight": 3573,
@ -13143,6 +13143,7 @@
"runBtn": "/ui/runbutton.png",
"menuBtn": "/ui/menubutton.png",
"buySellBtn": "/ui/buysellbtn.png",
"miniConvert": "/ui/miniconvert.png",
"levelBtn": "/ui/level-btn.png",
"levelSocket": "/ui/level-socket.png",
"skillPoints": "/ui/skillpoints.png",

BIN
public/ui/miniconvert.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -328,6 +328,14 @@ async function main(): Promise<void> {
const gcbStrip = stitchHorizontalStrip(gcbFrames)
savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.pixels)
const miniConvertDc6 = await decodeUiDc6('data\\global\\ui\\Panel\\miniconvert.dc6')
const mcFrames = miniConvertDc6.groups[0]?.frames
if (!mcFrames || mcFrames.length !== 2 || mcFrames.some(f => f.width !== 32 || f.height !== 32)) {
throw new Error('Invalid miniconvert.dc6 frame geometry: expected 2 frames of 32x32')
}
const mcStrip = stitchHorizontalStrip(mcFrames)
savePng('miniconvert.png', mcStrip.width, mcStrip.height, mcStrip.pixels)
const buySellTabs = await decodeUiDc6('data\\global\\ui\\Panel\\buyselltabs.dc6')
const bstFrames = buySellTabs.groups[0]?.frames
if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) {

View File

@ -371,6 +371,15 @@ async function main(): Promise<void> {
const gcbStrip = stitchHorizontalStrip(gcbFrames)
savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.pixels)
// Horadric Cube Transmute button (`Panel/miniconvert.dc6` per D2Client.dll 0x6fb3ce4b & 0x6fb49657, 2 frames of 32x32)
const miniConvertDc6 = await decodeUiDc6('data/global/ui/Panel/miniconvert.dc6')
const mcFrames = miniConvertDc6.groups[0]?.frames
if (!mcFrames || mcFrames.length !== 2 || mcFrames.some(f => f.width !== 32 || f.height !== 32)) {
throw new Error('Invalid miniconvert.dc6 frame geometry: expected 2 frames of 32x32')
}
const mcStrip = stitchHorizontalStrip(mcFrames)
savePng('miniconvert.png', mcStrip.width, mcStrip.height, mcStrip.pixels)
const buySellTabs = await decodeUiDc6('data/global/ui/Panel/buyselltabs.dc6')
const bstFrames = buySellTabs.groups[0]?.frames
if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) {
@ -1051,6 +1060,7 @@ async function main(): Promise<void> {
runBtn: '/ui/runbutton.png',
menuBtn: '/ui/menubutton.png',
buySellBtn: '/ui/buysellbtn.png',
miniConvert: '/ui/miniconvert.png',
levelBtn: '/ui/level-btn.png',
levelSocket: '/ui/level-socket.png',
skillPoints: '/ui/skillpoints.png',

View File

@ -83,7 +83,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly<Record<number, readonly QuestAssetInf
export const BAKED_UI_MANIFEST: BakedUiManifest = {
version: '1.13c',
decodedDc6Count: 683,
decodedDc6Count: 685,
dc6DecodeFailures: 0,
atlasWidth: 1024,
atlasHeight: 3573,
@ -13226,6 +13226,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"runBtn": "/ui/runbutton.png",
"menuBtn": "/ui/menubutton.png",
"buySellBtn": "/ui/buysellbtn.png",
"miniConvert": "/ui/miniconvert.png",
"levelBtn": "/ui/level-btn.png",
"levelSocket": "/ui/level-socket.png",
"skillPoints": "/ui/skillpoints.png",

View File

@ -157,7 +157,16 @@ export function computeHudLayout(cssW: number, cssH: number, dpr: number = 1): H
export { CHAR_PANEL_ORIGIN } from './character-sheet.ts'
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.ts'
export { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN, VENDOR_PANEL_ORIGIN, VENDOR_GRID_ORIGIN } from './world-panels.ts'
export {
STASH_PANEL_ORIGIN,
STASH_GRID_ORIGIN,
CUBE_PANEL_ORIGIN,
CUBE_GRID_ORIGIN,
CUBE_TRANSMUTE_BTN_BOUNDS,
CUBE_CLOSE_BTN_BOUNDS,
VENDOR_PANEL_ORIGIN,
VENDOR_GRID_ORIGIN,
} from './world-panels.ts'
/**
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
@ -452,6 +461,7 @@ export class HudManager {
menuBtn: `${baseUrl}/menubutton.png`,
buySellBtn: `${baseUrl}/buysellbtn.png`,
goldCoinBtn: `${baseUrl}/goldcoinbtn.png`,
miniConvert: `${baseUrl}/miniconvert.png`,
levelBtn: `${baseUrl}/level-btn.png`,
levelSocket: `${baseUrl}/level-socket.png`,
skillPoints: `${baseUrl}/skillpoints.png`,
@ -1249,6 +1259,7 @@ export class HudManager {
vendorBgImg: this.images.get('vendorBg') ?? null,
vendorTabsImg: this.images.get('vendorTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
miniConvertImg: this.images.get('miniConvert') ?? null,
questsAtlasImg: this.images.get('questsAtlas') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
waypointTabsImg: this.images.get('waypointTabs') ?? null,

View File

@ -90,6 +90,60 @@ export const STASH_HEADER_BOTTOM_RECESS = {
export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const
/**
* Horadric Cube Transmute button (`Panel\miniconvert.dc6`, 2 frames of 32x32)
* per `D2Client.dll` `0x6fb3ce4b` (asset load), `0x6fb49657..0x6fb49698` (draw),
* `0x6fb3b600` (hit-test), and `0x6fb4977a..0x6fb497c8` (hover tooltip):
* - Draw: `x = ox + 0x90 = ox + 144` (`224`), `nYpos = oy + 480 - 0xbc = oy + 292` -> top-left `y = oy + 260` (`320`), `32x32`
* - Frame `0` (unpressed) or Frame `1` (pressed, `ds:0x6fbcbe94`)
* - Hit-test (`0x6fb3b600`): `relX ∈ (144, 184)`, `relY ∈ (257, 297)` (`x ∈ (224, 264)`, `y ∈ (317, 357)`)
* - Hover tooltip (`0x6fb4977a`): `strIdx = 0xd0d = 3341` (`改變` / `Transmute`), `font16`, centered at `ox + 158` (`238`), anchored above `oy + 257` (`317`)
*/
export const CUBE_TRANSMUTE_BTN_BOUNDS = {
x: 80 + 144,
y: 60 + 260,
relX: 144,
relY: 260,
w: 32,
h: 32,
hitMinX: 80 + 144,
hitMaxX: 80 + 184,
hitMinY: 60 + 257,
hitMaxY: 60 + 297,
tooltipCenterX: 80 + 158,
tooltipAnchorY: 60 + 257,
strIdx: 3341,
labelZh: '改變',
labelEn: 'Transmute',
} as const
/**
* Horadric Cube Close button (`Panel\buysellbtn.dc6`, frames 10/11 of 32x32)
* per `D2Client.dll` `0x6fb49615..0x6fb49656` (draw), `0x6fb3b650` (hit-test),
* and `0x6fb49732..0x6fb49775` (hover tooltip):
* - Draw: `x = ox + 0x113 = ox + 275` (`355`), `nYpos = oy + 480 - 0x41 = oy + 415` -> top-left `y = oy + 383` (`443`), `32x32`
* - Frame `10` (unpressed) or Frame `11` (pressed, `ds:0x6fbcbe8c`)
* - Hit-test (`0x6fb3b650`): `relX ∈ (275, 315)`, `relY ∈ (380, 420)` (`x ∈ (355, 395)`, `y ∈ (440, 480)`)
* - Hover tooltip (`0x6fb49732`): `strIdx = 0x1030 = 4144` (`關閉` / `Close`), `font16`, centered at `ox + 289` (`369`), anchored above `oy + 380` (`440`)
*/
export const CUBE_CLOSE_BTN_BOUNDS = {
x: 80 + 275,
y: 60 + 383,
relX: 275,
relY: 383,
w: 32,
h: 32,
hitMinX: 80 + 275,
hitMaxX: 80 + 315,
hitMinY: 60 + 380,
hitMaxY: 60 + 420,
tooltipCenterX: 80 + 289,
tooltipAnchorY: 60 + 380,
strIdx: 4144,
labelZh: '關閉',
labelEn: 'Close',
} as const
export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
/** `Inventory.txt` row `Monster2`: gridLeft=96 (`80 + 16`), gridTop=123 (`60 + 63`), 10x10 cells of 29x29px */
export const VENDOR_GRID_ORIGIN = { x: 80 + 16, y: 60 + 63, cols: 10, rows: 10, cellPx: 29 } as const
@ -565,6 +619,8 @@ export class WorldPanelsHud {
vendorTradeState: VendorTradeState = 'idle'
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
pressedMomentarySlot: 0 | 1 | 2 | 3 | null = null
hoveredCubeButton: 'transmute' | 'close' | null = null
pressedCubeButton: 'transmute' | 'close' | null = null
activePlayerInventory: InventoryPanel | null = null
vendorStatusMsg: { text: string; color: D2ColorCode; untilMs: number } | null = null
hoveredVendorItem: { item: UiInventoryItem; x: number; y: number; col: number; row: number } | null = null
@ -586,6 +642,32 @@ export class WorldPanelsHud {
releaseMomentaryVendorButton(): void {
this.pressedMomentarySlot = null
this.pressedCubeButton = null
}
/**
* D2Client.dll `0x6fb3b600` (Transmute button) & `0x6fb3b650` (Cube Close button):
* - Transmute: `relX ∈ [144, 184]`, `relY ∈ [257, 297]` (`x ∈ [224, 264]`, `y ∈ [317, 357]`)
* - Close: `relX ∈ [275, 315]`, `relY ∈ [380, 420]` (`x ∈ [355, 395]`, `y ∈ [440, 480]`)
*/
hitTestCubeButton(logicalX: number, logicalY: number): 'transmute' | 'close' | null {
if (
logicalX >= CUBE_TRANSMUTE_BTN_BOUNDS.hitMinX &&
logicalX <= CUBE_TRANSMUTE_BTN_BOUNDS.hitMaxX &&
logicalY >= CUBE_TRANSMUTE_BTN_BOUNDS.hitMinY &&
logicalY <= CUBE_TRANSMUTE_BTN_BOUNDS.hitMaxY
) {
return 'transmute'
}
if (
logicalX >= CUBE_CLOSE_BTN_BOUNDS.hitMinX &&
logicalX <= CUBE_CLOSE_BTN_BOUNDS.hitMaxX &&
logicalY >= CUBE_CLOSE_BTN_BOUNDS.hitMinY &&
logicalY <= CUBE_CLOSE_BTN_BOUNDS.hitMaxY
) {
return 'close'
}
return null
}
isVendorTabPresent(tabId: VendorTabId): boolean {
@ -1122,6 +1204,7 @@ export class WorldPanelsHud {
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null
private cachedBoxPiecesImg: HTMLImageElement | null = null
private cachedMiniConvertImg: HTMLImageElement | null = null
getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' {
return this.questStatuses.get(quest.id) ?? quest.status
@ -1204,6 +1287,17 @@ export class WorldPanelsHud {
return null
}
private getMiniConvertImage(): HTMLImageElement | null {
if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.miniConvert ?? '/ui/miniconvert.png'
this.cachedMiniConvertImg = img
return img
}
return null
}
/** Area entry banner state (`Entering: <Level Name>`). */
areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null
@ -1552,6 +1646,10 @@ export class WorldPanelsHud {
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
? this.hitTestVendorButtonSlot(logicalX, logicalY)
: null
this.hoveredCubeButton = this.hitTestCubeButton(logicalX, logicalY)
if (this.pressedCubeButton !== null && this.hoveredCubeButton !== this.pressedCubeButton) {
this.pressedCubeButton = null
}
this.hoveredWaypointIdx = null
const ox = 80
@ -1763,6 +1861,7 @@ export class WorldPanelsHud {
) {
this.vendorTradeState = 'idle'
this.pressedMomentarySlot = null
this.pressedCubeButton = null
callbacks.onClose()
return true
}
@ -1808,9 +1907,10 @@ export class WorldPanelsHud {
}
}
} else if (kind === 'cube') {
// 1. Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`)
if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) {
// 1. Transmute button (`D2Client.dll` 0x6fb3b600: `relX ∈ [144, 184], relY ∈ [257, 297]`, drawn at `ox + 144, oy + 260, 32x32`)
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
if (!callbacks.isRightClick) {
this.pressedCubeButton = 'transmute'
this.cubeTransmuteCount += 1
}
return true
@ -2058,6 +2158,7 @@ export class WorldPanelsHud {
vendorBgImg: HTMLImageElement | null
vendorTabsImg?: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
miniConvertImg?: HTMLImageElement | null
questsAtlasImg?: HTMLImageElement | null
itemsAtlasImg?: HTMLImageElement | null
waypointTabsImg?: HTMLImageElement | null
@ -2341,6 +2442,60 @@ export class WorldPanelsHud {
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
}
}
// D2Client.dll `0x6fb49615..0x6fb49656`: Cube Close button (`buysellbtn.dc6` frame 10 or 11 at `ox + 275, oy + 383`)
if (assets.buySellBtnImg) {
const closeFrame = this.pressedCubeButton === 'close' ? 11 : 10
ctx.drawImage(
assets.buySellBtnImg,
closeFrame * 32,
0,
32,
32,
ox + CUBE_CLOSE_BTN_BOUNDS.relX,
oy + CUBE_CLOSE_BTN_BOUNDS.relY,
CUBE_CLOSE_BTN_BOUNDS.w,
CUBE_CLOSE_BTN_BOUNDS.h,
)
}
// D2Client.dll `0x6fb49657..0x6fb49698`: Cube Transmute button (`miniconvert.dc6` frame 0 or 1 at `ox + 144, oy + 260`)
const miniConvertImg = assets.miniConvertImg ?? this.getMiniConvertImage()
if (miniConvertImg) {
const transmuteFrame = this.pressedCubeButton === 'transmute' ? 1 : 0
ctx.drawImage(
miniConvertImg,
transmuteFrame * 32,
0,
32,
32,
ox + CUBE_TRANSMUTE_BTN_BOUNDS.relX,
oy + CUBE_TRANSMUTE_BTN_BOUNDS.relY,
CUBE_TRANSMUTE_BTN_BOUNDS.w,
CUBE_TRANSMUTE_BTN_BOUNDS.h,
)
}
// D2Client.dll `0x6fb49720..0x6fb497c8`: Centered framed hover tooltip (`改變` / `關閉` in Font16)
if (this.hoveredCubeButton !== null) {
const spec =
this.hoveredCubeButton === 'transmute' ? CUBE_TRANSMUTE_BTN_BOUNDS : CUBE_CLOSE_BTN_BOUNDS
const hoverText = spec.labelZh
const padX = 6
const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2)
const boxH = 22
const centerX = ox + (spec.tooltipCenterX - CUBE_PANEL_ORIGIN.x)
const anchorY = oy + (spec.tooltipAnchorY - CUBE_PANEL_ORIGIN.y)
const boxX = Math.round(centerX - boxW / 2)
const boxY = anchorY - boxH
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(boxX, boxY, boxW, boxH)
font.drawText(ctx, hoverText, centerX, boxY + 17, {
font: 'font16',
color: 'white',
align: 'center',
})
}
} else if (kind === 'vendor') {
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
throw new Error('Cannot draw vendor panel without an active vendor descriptor')
@ -2442,7 +2597,7 @@ export class WorldPanelsHud {
}
}
if (kind !== 'vendor' && assets.buySellBtnImg) {
if (kind !== 'vendor' && kind !== 'cube' && assets.buySellBtnImg) {
ctx.drawImage(assets.buySellBtnImg, 10 * 32, 0, 32, 32, ox + 272, oy + 388, 32, 32)
}
}

View File

@ -1080,7 +1080,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
inv.gold = 850_000
const drawImageCalls: Array<{ img: unknown; args: number[] }> = []
const drawTextCalls: Array<{ text: string; x: number; y: number; opts?: Record<string, unknown> }> = []
const drawTextCalls: Array<{ text: string; x: number; y: number; opts?: Record<string, unknown> | undefined }> = []
const mockGoldBtnImg = { tag: 'goldCoinBtn' } as unknown as HTMLImageElement
const mockCtx = {

View File

@ -7,8 +7,20 @@
import { describe, expect, it, vi } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { WorldPanelsHud, ACT_WAYPOINTS } from '../src/ui/world-panels.ts'
import {
WorldPanelsHud,
ACT_WAYPOINTS,
CUBE_TRANSMUTE_BTN_BOUNDS,
CUBE_CLOSE_BTN_BOUNDS,
} from '../src/ui/world-panels.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
import { encodeIndexedPng } from '../scripts/png.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
function getPngDimensions(filePath: string): { width: number; height: number } {
const buf = readFileSync(filePath)
@ -267,3 +279,174 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
expect(onClose).toHaveBeenCalled()
})
})
describe('Diablo II 1.13c Horadric Cube Transmute & Close Button Parity', () => {
it('verifies miniconvert.png dimensions, manifest entry, and byte-exact parity against MPQ miniconvert.dc6', async () => {
expect(BAKED_UI_MANIFEST.images.miniConvert).toBe('/ui/miniconvert.png')
const miniConvertPath = join(process.cwd(), 'public/ui/miniconvert.png')
expect(existsSync(miniConvertPath)).toBe(true)
expect(getPngDimensions(miniConvertPath)).toEqual({ width: 64, height: 32 })
expect(CUBE_TRANSMUTE_BTN_BOUNDS).toEqual({
x: 224,
y: 320,
relX: 144,
relY: 260,
w: 32,
h: 32,
hitMinX: 224,
hitMaxX: 264,
hitMinY: 317,
hitMaxY: 357,
tooltipCenterX: 238,
tooltipAnchorY: 317,
strIdx: 3341,
labelZh: '改變',
labelEn: 'Transmute',
})
expect(CUBE_CLOSE_BTN_BOUNDS).toEqual({
x: 355,
y: 443,
relX: 275,
relY: 383,
w: 32,
h: 32,
hitMinX: 355,
hitMaxX: 395,
hitMinY: 440,
hitMaxY: 480,
tooltipCenterX: 369,
tooltipAnchorY: 440,
strIdx: 4144,
labelZh: '關閉',
labelEn: 'Close',
})
const d2dataPath = join(process.cwd(), 'samples/d2/d2data.mpq')
const d2expPath = join(process.cwd(), 'samples/d2/d2exp.mpq')
if (existsSync(d2dataPath) && existsSync(d2expPath)) {
const archives = new MountedArchives()
archives.add('d2data.mpq', await MpqArchive.open(await fileSource(d2dataPath)))
archives.add('d2exp.mpq', await MpqArchive.open(await fileSource(d2expPath)))
const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2'))
const sheet = decodeDc6(await archives.read('data/global/ui/Panel/miniconvert.dc6'))
const frames = sheet.groups[0]!.frames
expect(frames).toHaveLength(2)
expect(frames[0]!.width).toBe(32)
expect(frames[0]!.height).toBe(32)
expect(frames[1]!.width).toBe(32)
expect(frames[1]!.height).toBe(32)
const pixels = new Uint8Array(64 * 32)
for (let i = 0; i < 2; i++) {
const f = frames[i]!
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const idx = y * 32 + x
if (f.mask[idx] !== 0 && f.indices[idx] !== 0) {
pixels[y * 64 + i * 32 + x] = f.indices[idx]!
}
}
}
}
const expectedPng = encodeIndexedPng({
width: 64,
height: 32,
pixels,
palette: pl2.rgb,
transparentIndex: 0,
})
const actualBytes = readFileSync(miniConvertPath)
expect(Buffer.compare(actualBytes, Buffer.from(expectedPng))).toBe(0)
}
})
it('draws Horadric Cube Transmute button (miniconvert.png) at (ox+144, oy+260) and Close button at (ox+275, oy+383), switching frames on press and rendering hover tooltips', () => {
const hud = new WorldPanelsHud()
const drawImageCalls: any[] = []
const textCalls: Array<{ text: string; x: number; y: number; opts?: any }> = []
const ctx = {
drawImage: vi.fn((...args: any[]) => drawImageCalls.push(args)),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillStyle: '',
strokeStyle: '',
} as unknown as CanvasRenderingContext2D
const font = {
measureText: vi.fn((text: string) => text.length * 16),
drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => {
textCalls.push({ text, x, y, opts })
}),
} as unknown as D2FontRenderer
const mockCubeBg = { tag: 'cubeBg' } as unknown as HTMLImageElement
const mockBuySellBtn = { tag: 'buySellBtn' } as unknown as HTMLImageElement
const mockMiniConvert = { tag: 'miniConvert' } as unknown as HTMLImageElement
const assets = {
borderLeftImg: null,
questBgImg: null,
waypointBgImg: null,
stashBgImg: null,
cubeBgImg: mockCubeBg,
vendorBgImg: null,
buySellBtnImg: mockBuySellBtn,
miniConvertImg: mockMiniConvert,
}
// 1. Unpressed render
hud.drawLeftDockPanel(ctx, 'cube', assets, font)
const transmuteDraws = drawImageCalls.filter(c => c[0] === mockMiniConvert)
const closeDraws = drawImageCalls.filter(c => c[0] === mockBuySellBtn)
expect(transmuteDraws).toHaveLength(1)
expect(transmuteDraws[0].slice(1)).toEqual([0, 0, 32, 32, 80 + 144, 60 + 260, 32, 32])
expect(closeDraws).toHaveLength(1)
expect(closeDraws[0].slice(1)).toEqual([10 * 32, 0, 32, 32, 80 + 275, 60 + 383, 32, 32])
// 2. Click Transmute button (`ox + 160, oy + 276`) -> increments count, sets pressedCubeButton = 'transmute' (frame 1 -> sx = 32)
const onClose = vi.fn()
hud.handleLeftDockClick('cube', 80 + 160, 60 + 276, {
onClose,
onWaypointTeleport: () => {},
})
expect(hud.cubeTransmuteCount).toBe(1)
expect(hud.pressedCubeButton).toBe('transmute')
// Hover over Transmute button -> hoveredCubeButton = 'transmute'
hud.handleMouseMove(80 + 160, 60 + 276)
expect(hud.hoveredCubeButton).toBe('transmute')
expect(hud.pressedCubeButton).toBe('transmute')
drawImageCalls.length = 0
textCalls.length = 0
hud.drawLeftDockPanel(ctx, 'cube', assets, font)
const pressedTransmuteDraws = drawImageCalls.filter(c => c[0] === mockMiniConvert)
expect(pressedTransmuteDraws).toHaveLength(1)
expect(pressedTransmuteDraws[0].slice(1)).toEqual([32, 0, 32, 32, 80 + 144, 60 + 260, 32, 32])
const transmuteTooltip = textCalls.find(c => c.text === '改變')
expect(transmuteTooltip).toBeDefined()
expect(transmuteTooltip!.x).toBe(80 + 158)
expect(transmuteTooltip!.opts?.font).toBe('font16')
// 3. Moving cursor outside Transmute button clears pressedCubeButton per D2Client.dll 0x6fb49783
hud.handleMouseMove(80 + 290, 60 + 395)
expect(hud.hoveredCubeButton).toBe('close')
expect(hud.pressedCubeButton).toBeNull()
textCalls.length = 0
hud.drawLeftDockPanel(ctx, 'cube', assets, font)
const closeTooltip = textCalls.find(c => c.text === '關閉')
expect(closeTooltip).toBeDefined()
expect(closeTooltip!.x).toBe(80 + 289)
expect(closeTooltip!.opts?.font).toBe('font16')
// 4. Clicking Close button closes panel
hud.handleLeftDockClick('cube', 80 + 290, 60 + 395, {
onClose,
onWaypointTeleport: () => {},
})
expect(onClose).toHaveBeenCalledTimes(1)
})
})