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",
|
"version": "1.13c",
|
||||||
"decodedDc6Count": 683,
|
"decodedDc6Count": 685,
|
||||||
"dc6DecodeFailures": 0,
|
"dc6DecodeFailures": 0,
|
||||||
"atlasWidth": 1024,
|
"atlasWidth": 1024,
|
||||||
"atlasHeight": 3573,
|
"atlasHeight": 3573,
|
||||||
|
|
@ -13143,6 +13143,7 @@
|
||||||
"runBtn": "/ui/runbutton.png",
|
"runBtn": "/ui/runbutton.png",
|
||||||
"menuBtn": "/ui/menubutton.png",
|
"menuBtn": "/ui/menubutton.png",
|
||||||
"buySellBtn": "/ui/buysellbtn.png",
|
"buySellBtn": "/ui/buysellbtn.png",
|
||||||
|
"miniConvert": "/ui/miniconvert.png",
|
||||||
"levelBtn": "/ui/level-btn.png",
|
"levelBtn": "/ui/level-btn.png",
|
||||||
"levelSocket": "/ui/level-socket.png",
|
"levelSocket": "/ui/level-socket.png",
|
||||||
"skillPoints": "/ui/skillpoints.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)
|
const gcbStrip = stitchHorizontalStrip(gcbFrames)
|
||||||
savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.pixels)
|
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 buySellTabs = await decodeUiDc6('data\\global\\ui\\Panel\\buyselltabs.dc6')
|
||||||
const bstFrames = buySellTabs.groups[0]?.frames
|
const bstFrames = buySellTabs.groups[0]?.frames
|
||||||
if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) {
|
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)
|
const gcbStrip = stitchHorizontalStrip(gcbFrames)
|
||||||
savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.pixels)
|
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 buySellTabs = await decodeUiDc6('data/global/ui/Panel/buyselltabs.dc6')
|
||||||
const bstFrames = buySellTabs.groups[0]?.frames
|
const bstFrames = buySellTabs.groups[0]?.frames
|
||||||
if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) {
|
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',
|
runBtn: '/ui/runbutton.png',
|
||||||
menuBtn: '/ui/menubutton.png',
|
menuBtn: '/ui/menubutton.png',
|
||||||
buySellBtn: '/ui/buysellbtn.png',
|
buySellBtn: '/ui/buysellbtn.png',
|
||||||
|
miniConvert: '/ui/miniconvert.png',
|
||||||
levelBtn: '/ui/level-btn.png',
|
levelBtn: '/ui/level-btn.png',
|
||||||
levelSocket: '/ui/level-socket.png',
|
levelSocket: '/ui/level-socket.png',
|
||||||
skillPoints: '/ui/skillpoints.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 = {
|
export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||||
version: '1.13c',
|
version: '1.13c',
|
||||||
decodedDc6Count: 683,
|
decodedDc6Count: 685,
|
||||||
dc6DecodeFailures: 0,
|
dc6DecodeFailures: 0,
|
||||||
atlasWidth: 1024,
|
atlasWidth: 1024,
|
||||||
atlasHeight: 3573,
|
atlasHeight: 3573,
|
||||||
|
|
@ -13226,6 +13226,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||||
"runBtn": "/ui/runbutton.png",
|
"runBtn": "/ui/runbutton.png",
|
||||||
"menuBtn": "/ui/menubutton.png",
|
"menuBtn": "/ui/menubutton.png",
|
||||||
"buySellBtn": "/ui/buysellbtn.png",
|
"buySellBtn": "/ui/buysellbtn.png",
|
||||||
|
"miniConvert": "/ui/miniconvert.png",
|
||||||
"levelBtn": "/ui/level-btn.png",
|
"levelBtn": "/ui/level-btn.png",
|
||||||
"levelSocket": "/ui/level-socket.png",
|
"levelSocket": "/ui/level-socket.png",
|
||||||
"skillPoints": "/ui/skillpoints.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 { CHAR_PANEL_ORIGIN } from './character-sheet.ts'
|
||||||
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
|
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
|
||||||
export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.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.
|
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
|
||||||
|
|
@ -452,6 +461,7 @@ export class HudManager {
|
||||||
menuBtn: `${baseUrl}/menubutton.png`,
|
menuBtn: `${baseUrl}/menubutton.png`,
|
||||||
buySellBtn: `${baseUrl}/buysellbtn.png`,
|
buySellBtn: `${baseUrl}/buysellbtn.png`,
|
||||||
goldCoinBtn: `${baseUrl}/goldcoinbtn.png`,
|
goldCoinBtn: `${baseUrl}/goldcoinbtn.png`,
|
||||||
|
miniConvert: `${baseUrl}/miniconvert.png`,
|
||||||
levelBtn: `${baseUrl}/level-btn.png`,
|
levelBtn: `${baseUrl}/level-btn.png`,
|
||||||
levelSocket: `${baseUrl}/level-socket.png`,
|
levelSocket: `${baseUrl}/level-socket.png`,
|
||||||
skillPoints: `${baseUrl}/skillpoints.png`,
|
skillPoints: `${baseUrl}/skillpoints.png`,
|
||||||
|
|
@ -1249,6 +1259,7 @@ export class HudManager {
|
||||||
vendorBgImg: this.images.get('vendorBg') ?? null,
|
vendorBgImg: this.images.get('vendorBg') ?? null,
|
||||||
vendorTabsImg: this.images.get('vendorTabs') ?? null,
|
vendorTabsImg: this.images.get('vendorTabs') ?? null,
|
||||||
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
||||||
|
miniConvertImg: this.images.get('miniConvert') ?? null,
|
||||||
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
||||||
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
|
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
|
||||||
waypointTabsImg: this.images.get('waypointTabs') ?? 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_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
|
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
|
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 */
|
/** `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
|
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'
|
vendorTradeState: VendorTradeState = 'idle'
|
||||||
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
|
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
|
||||||
pressedMomentarySlot: 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
|
activePlayerInventory: InventoryPanel | null = null
|
||||||
vendorStatusMsg: { text: string; color: D2ColorCode; untilMs: number } | null = null
|
vendorStatusMsg: { text: string; color: D2ColorCode; untilMs: number } | null = null
|
||||||
hoveredVendorItem: { item: UiInventoryItem; x: number; y: number; col: number; row: 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 {
|
releaseMomentaryVendorButton(): void {
|
||||||
this.pressedMomentarySlot = null
|
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 {
|
isVendorTabPresent(tabId: VendorTabId): boolean {
|
||||||
|
|
@ -1122,6 +1204,7 @@ export class WorldPanelsHud {
|
||||||
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
|
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
|
||||||
private cachedQuestsAtlasImg: HTMLImageElement | null = null
|
private cachedQuestsAtlasImg: HTMLImageElement | null = null
|
||||||
private cachedBoxPiecesImg: HTMLImageElement | null = null
|
private cachedBoxPiecesImg: HTMLImageElement | null = null
|
||||||
|
private cachedMiniConvertImg: HTMLImageElement | null = null
|
||||||
|
|
||||||
getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' {
|
getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' {
|
||||||
return this.questStatuses.get(quest.id) ?? quest.status
|
return this.questStatuses.get(quest.id) ?? quest.status
|
||||||
|
|
@ -1204,6 +1287,17 @@ export class WorldPanelsHud {
|
||||||
return null
|
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>`). */
|
/** Area entry banner state (`Entering: <Level Name>`). */
|
||||||
areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null
|
areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null
|
||||||
|
|
||||||
|
|
@ -1552,6 +1646,10 @@ export class WorldPanelsHud {
|
||||||
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
|
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
|
||||||
? this.hitTestVendorButtonSlot(logicalX, logicalY)
|
? this.hitTestVendorButtonSlot(logicalX, logicalY)
|
||||||
: null
|
: null
|
||||||
|
this.hoveredCubeButton = this.hitTestCubeButton(logicalX, logicalY)
|
||||||
|
if (this.pressedCubeButton !== null && this.hoveredCubeButton !== this.pressedCubeButton) {
|
||||||
|
this.pressedCubeButton = null
|
||||||
|
}
|
||||||
this.hoveredWaypointIdx = null
|
this.hoveredWaypointIdx = null
|
||||||
|
|
||||||
const ox = 80
|
const ox = 80
|
||||||
|
|
@ -1763,6 +1861,7 @@ export class WorldPanelsHud {
|
||||||
) {
|
) {
|
||||||
this.vendorTradeState = 'idle'
|
this.vendorTradeState = 'idle'
|
||||||
this.pressedMomentarySlot = null
|
this.pressedMomentarySlot = null
|
||||||
|
this.pressedCubeButton = null
|
||||||
callbacks.onClose()
|
callbacks.onClose()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -1808,9 +1907,10 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (kind === 'cube') {
|
} else if (kind === 'cube') {
|
||||||
// 1. Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`)
|
// 1. Transmute button (`D2Client.dll` 0x6fb3b600: `relX ∈ [144, 184], relY ∈ [257, 297]`, drawn at `ox + 144, oy + 260, 32x32`)
|
||||||
if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) {
|
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
|
||||||
if (!callbacks.isRightClick) {
|
if (!callbacks.isRightClick) {
|
||||||
|
this.pressedCubeButton = 'transmute'
|
||||||
this.cubeTransmuteCount += 1
|
this.cubeTransmuteCount += 1
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
@ -2058,6 +2158,7 @@ export class WorldPanelsHud {
|
||||||
vendorBgImg: HTMLImageElement | null
|
vendorBgImg: HTMLImageElement | null
|
||||||
vendorTabsImg?: HTMLImageElement | null
|
vendorTabsImg?: HTMLImageElement | null
|
||||||
buySellBtnImg: HTMLImageElement | null
|
buySellBtnImg: HTMLImageElement | null
|
||||||
|
miniConvertImg?: HTMLImageElement | null
|
||||||
questsAtlasImg?: HTMLImageElement | null
|
questsAtlasImg?: HTMLImageElement | null
|
||||||
itemsAtlasImg?: HTMLImageElement | null
|
itemsAtlasImg?: HTMLImageElement | null
|
||||||
waypointTabsImg?: HTMLImageElement | null
|
waypointTabsImg?: HTMLImageElement | null
|
||||||
|
|
@ -2341,6 +2442,60 @@ export class WorldPanelsHud {
|
||||||
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
|
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') {
|
} else if (kind === 'vendor') {
|
||||||
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
||||||
throw new Error('Cannot draw vendor panel without an active vendor descriptor')
|
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)
|
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
|
inv.gold = 850_000
|
||||||
|
|
||||||
const drawImageCalls: Array<{ img: unknown; args: number[] }> = []
|
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 mockGoldBtnImg = { tag: 'goldCoinBtn' } as unknown as HTMLImageElement
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,20 @@
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
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 { 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 } {
|
function getPngDimensions(filePath: string): { width: number; height: number } {
|
||||||
const buf = readFileSync(filePath)
|
const buf = readFileSync(filePath)
|
||||||
|
|
@ -267,3 +279,174 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
||||||
expect(onClose).toHaveBeenCalled()
|
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