diff --git a/public/ui/manifest.json b/public/ui/manifest.json index 6030431..0300340 100644 --- a/public/ui/manifest.json +++ b/public/ui/manifest.json @@ -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", diff --git a/public/ui/miniconvert.png b/public/ui/miniconvert.png new file mode 100644 index 0000000..5c191a5 Binary files /dev/null and b/public/ui/miniconvert.png differ diff --git a/scripts/pack-ui-assets.ts b/scripts/pack-ui-assets.ts index a073340..407de3b 100644 --- a/scripts/pack-ui-assets.ts +++ b/scripts/pack-ui-assets.ts @@ -328,6 +328,14 @@ async function main(): Promise { 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)) { diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts index 581f27d..ce26ba7 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -371,6 +371,15 @@ async function main(): Promise { 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 { 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', diff --git a/src/ui/baked-ui-meta.ts b/src/ui/baked-ui-meta.ts index 0ba7c42..011cc6e 100644 --- a/src/ui/baked-ui-meta.ts +++ b/src/ui/baked-ui-meta.ts @@ -83,7 +83,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly 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() 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: `). */ 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) } } diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index a01032b..bf7ec94 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -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 }> = [] + const drawTextCalls: Array<{ text: string; x: number; y: number; opts?: Record | undefined }> = [] const mockGoldBtnImg = { tag: 'goldCoinBtn' } as unknown as HTMLImageElement const mockCtx = { diff --git a/tests/world-panels.test.ts b/tests/world-panels.test.ts index 89b5ab2..f88b397 100644 --- a/tests/world-panels.test.ts +++ b/tests/world-panels.test.ts @@ -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) + }) +})