fix(ui): restore Horadric Cube Transmute and Close buttons per D2Client.dll 1.13c parity
This commit is contained in:
parent
8c5ae5c623
commit
3bf9a8c220
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue