diff --git a/public/ui/goldcoinbtn.png b/public/ui/goldcoinbtn.png new file mode 100644 index 0000000..f1499ba Binary files /dev/null and b/public/ui/goldcoinbtn.png differ diff --git a/scripts/pack-ui-assets.ts b/scripts/pack-ui-assets.ts index bf44c6c..a073340 100644 --- a/scripts/pack-ui-assets.ts +++ b/scripts/pack-ui-assets.ts @@ -320,6 +320,14 @@ async function main(): Promise { const bsStrip = stitchHorizontalStrip(buySellBtn.groups[0]!.frames) savePng('buysellbtn.png', bsStrip.width, bsStrip.height, bsStrip.pixels) + const goldCoinBtn = await decodeUiDc6('data\\global\\ui\\Panel\\goldcoinbtn.dc6') + const gcbFrames = goldCoinBtn.groups[0]?.frames + if (!gcbFrames || gcbFrames.length !== 2 || gcbFrames.some(f => f.width !== 20 || f.height !== 18)) { + throw new Error('Invalid goldcoinbtn.dc6 frame geometry: expected 2 frames of 20x18') + } + const gcbStrip = stitchHorizontalStrip(gcbFrames) + savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.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 dd5ad0c..581f27d 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -363,6 +363,14 @@ async function main(): Promise { const bsStrip = stitchHorizontalStrip(buySellBtn.groups[0]!.frames) savePng('buysellbtn.png', bsStrip.width, bsStrip.height, bsStrip.pixels) + const goldCoinBtn = await decodeUiDc6('data/global/ui/Panel/goldcoinbtn.dc6') + const gcbFrames = goldCoinBtn.groups[0]?.frames + if (!gcbFrames || gcbFrames.length !== 2 || gcbFrames.some(f => f.width !== 20 || f.height !== 18)) { + throw new Error('Invalid goldcoinbtn.dc6 frame geometry: expected 2 frames of 20x18') + } + const gcbStrip = stitchHorizontalStrip(gcbFrames) + savePng('goldcoinbtn.png', gcbStrip.width, gcbStrip.height, gcbStrip.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/src/ui/font.ts b/src/ui/font.ts index 680f9d0..e76ec7c 100644 --- a/src/ui/font.ts +++ b/src/ui/font.ts @@ -258,7 +258,7 @@ export class D2FontRenderer { ctx.save() const isTitleFont = font === 'font16' || font === 'font30' || font === 'fontexocet10' - ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px "Cinzel", "Georgia", "Songti SC", "Noto Serif SC", serif` + ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif` ctx.textBaseline = 'alphabetic' let measuredTotal = 0 diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 89aba2a..03b46a1 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -451,6 +451,7 @@ export class HudManager { runBtn: `${baseUrl}/runbutton.png`, menuBtn: `${baseUrl}/menubutton.png`, buySellBtn: `${baseUrl}/buysellbtn.png`, + goldCoinBtn: `${baseUrl}/goldcoinbtn.png`, levelBtn: `${baseUrl}/level-btn.png`, levelSocket: `${baseUrl}/level-socket.png`, skillPoints: `${baseUrl}/skillpoints.png`, @@ -1272,6 +1273,7 @@ export class HudManager { invTab0Img: this.images.get('invTab0') ?? null, invTab1Img: this.images.get('invTab1') ?? null, buySellBtnImg: this.images.get('buySellBtn') ?? null, + goldCoinBtnImg: this.images.get('goldCoinBtn') ?? null, itemsAtlasImg: this.images.get('itemsAtlas') ?? null, itemRects: this.itemRects, }, diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 47a72e4..cb34c0a 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -131,6 +131,34 @@ export const WEAPON_SWAP_TABS_BOUNDS = { export const INV_CLOSE_BTN_BOUNDS = { x: 400 + 18, y: 60 + 385, w: 32, h: 32 } as const +/** + * D2Client.dll `0x6fb3cfb0` (`0x6fb3d118` & `0x6fb3d2a2`) + `Panel\invchar6.dc6` frames 4..7: + * - Left gold coin button socket (`Panel\goldcoinbtn.dc6`, 2 frames of 20x18, row 0 transparent): + * drawn at `x = ox + 84 = 484`, `y = oy + 391 = 451` (`w = 20, h = 18`), filling the 20x16 dark socket (`relX = 84..103, relY = 392..407`). + * - Vertical divider bar: `relX = 104..106` (3px wide). + * - Right gold amount text recess: `relX = 107..196` (`w = 90`), `relY = 392..407` (`h = 16`), + * horizontally centered at `textX = ox + 152 = 552` and vertically centered at alphabetic baseline `textY = oy + 403 = 463`. + */ +export const INV_GOLD_BTN_BOUNDS = { + x: 400 + 84, + y: 60 + 391, + relX: 84, + relY: 391, + w: 20, + h: 18, +} as const + +export const INV_GOLD_BOX_RECESS = { + x: 400 + 107, + y: 60 + 392, + relX: 107, + relY: 392, + w: 90, + h: 16, + textX: 400 + 152, + textY: 60 + 403, +} as const + /** * Resolves the authentic DC6 sprite rectangle from the items atlas for a given item. * @@ -1600,8 +1628,8 @@ export class InventoryPanel { return true } - // 5. Gold Button / Readout Area (x: 500..625, y: 450..485) - if (logicalX >= 500 && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { + // 5. Gold Coin Button (`goldcoinbtn.dc6` at x=484..504) & Readout Area (x: 484..625, y: 450..485) + if (logicalX >= INV_GOLD_BTN_BOUNDS.x && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { if (this.gold > 0) { const dropAmt = Math.min(10_000, this.gold) callbacks?.onDropGold?.(dropAmt) @@ -1620,6 +1648,7 @@ export class InventoryPanel { invTab0Img: HTMLImageElement | null invTab1Img: HTMLImageElement | null buySellBtnImg: HTMLImageElement | null + goldCoinBtnImg?: HTMLImageElement | null itemsAtlasImg: HTMLImageElement | null itemRects: Record }, @@ -1684,8 +1713,21 @@ export class InventoryPanel { this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets) } - // 6. Gold Amount Readout - font.drawText(ctx, this.gold.toLocaleString(), 400 + 162, 60 + 406, { + // 6. Gold Coin Button (`Panel\goldcoinbtn.dc6` frame 0 at x=484, y=451, 20x18) & Gold Amount Readout + if (assets.goldCoinBtnImg) { + ctx.drawImage( + assets.goldCoinBtnImg, + 0, + 0, + INV_GOLD_BTN_BOUNDS.w, + INV_GOLD_BTN_BOUNDS.h, + INV_GOLD_BTN_BOUNDS.x, + INV_GOLD_BTN_BOUNDS.y, + INV_GOLD_BTN_BOUNDS.w, + INV_GOLD_BTN_BOUNDS.h, + ) + } + font.drawText(ctx, this.gold.toLocaleString(), INV_GOLD_BOX_RECESS.textX, INV_GOLD_BOX_RECESS.textY, { font: 'font8', color: 'white', align: 'center', diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index d7d91ab..a01032b 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -3,9 +3,9 @@ */ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' +import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' -import { parseColoredText, parseFontTblMetrics, D2_COLOR_HEX } from '../src/ui/font.ts' +import { D2FontRenderer, parseColoredText, parseFontTblMetrics, D2_COLOR_HEX } from '../src/ui/font.ts' import { GlobesHud, LIFE_GLOBE_BOUNDS, MANA_GLOBE_BOUNDS } from '../src/ui/globes.ts' import { BeltHud, BELT_COLS, BELT_ROWS, BELT_BASE_X } from '../src/ui/belt.ts' import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/ui/hotkeys.ts' @@ -19,6 +19,8 @@ import { InventoryPanel, INV_PANEL_ORIGIN, INV_GRID_ORIGIN, + INV_GOLD_BTN_BOUNDS, + INV_GOLD_BOX_RECESS, EQUIP_SLOTS_LAYOUT, resolveItemSpriteRect, STARTER_BAG_ITEMS, @@ -1041,6 +1043,128 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(STASH_GRID_ORIGIN.cols).toBe(6) expect(STASH_GRID_ORIGIN.rows).toBe(8) }) + + it('extracts goldcoinbtn.dc6 (40x18) and renders Inventory gold button and aligned gold readout per D2Client.dll 0x6fb3cfb0', () => { + // 1. Verify public/ui/goldcoinbtn.png exists and is a 40x18 PNG strip (2 frames of 20x18) + const goldBtnPath = join(process.cwd(), 'public', 'ui', 'goldcoinbtn.png') + expect(existsSync(goldBtnPath)).toBe(true) + const pngBytes = readFileSync(goldBtnPath) + // PNG IHDR width @ offset 16 (uint32 BE), height @ offset 20 (uint32 BE) + const view = new DataView(pngBytes.buffer, pngBytes.byteOffset, pngBytes.byteLength) + expect(view.getUint32(16, false)).toBe(40) + expect(view.getUint32(20, false)).toBe(18) + + // 2. Verify D2Client.dll 0x6fb3cfb0 / invchar6.dc6 socket & recess bounds + expect(INV_GOLD_BTN_BOUNDS).toEqual({ + x: 400 + 84, + y: 60 + 391, + relX: 84, + relY: 391, + w: 20, + h: 18, + }) + expect(INV_GOLD_BOX_RECESS).toEqual({ + x: 400 + 107, + y: 60 + 392, + relX: 107, + relY: 392, + w: 90, + h: 16, + textX: 400 + 152, + textY: 60 + 403, + }) + + // 3. Verify InventoryPanel.draw blits goldCoinBtnImg at (484, 451) and centers gold readout at (552, 463) + const inv = new InventoryPanel() + inv.visible = true + inv.gold = 850_000 + + const drawImageCalls: Array<{ img: unknown; args: number[] }> = [] + const drawTextCalls: Array<{ text: string; x: number; y: number; opts?: Record }> = [] + + const mockGoldBtnImg = { tag: 'goldCoinBtn' } as unknown as HTMLImageElement + const mockCtx = { + drawImage: (img: unknown, ...args: number[]) => { + drawImageCalls.push({ img, args }) + }, + fillRect: () => {}, + strokeRect: () => {}, + fillStyle: '', + strokeStyle: '', + } as unknown as CanvasRenderingContext2D + + const mockFont = { + drawText: (_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: Record) => { + drawTextCalls.push({ text, x, y, opts }) + }, + measureText: (text: string) => text.length * 6, + } as unknown as D2FontRenderer + + inv.draw( + mockCtx, + { + borderRightImg: null, + invSheetImg: null, + invTab0Img: null, + invTab1Img: null, + buySellBtnImg: null, + goldCoinBtnImg: mockGoldBtnImg, + itemsAtlasImg: null, + itemRects: {}, + }, + mockFont, + ) + + const goldBtnCall = drawImageCalls.find(c => c.img === mockGoldBtnImg) + expect(goldBtnCall).toBeDefined() + expect(goldBtnCall!.args).toEqual([0, 0, 20, 18, 484, 451, 20, 18]) + + const goldTextCall = drawTextCalls.find(c => c.text === (850_000).toLocaleString()) + expect(goldTextCall).toBeDefined() + expect(goldTextCall!.x).toBe(INV_GOLD_BOX_RECESS.textX) + expect(goldTextCall!.y).toBe(INV_GOLD_BOX_RECESS.textY) + expect(goldTextCall!.opts).toMatchObject({ + font: 'font8', + color: 'white', + align: 'center', + }) + + // Verify 8px cap-height digits [textY - 7, textY] + 1px shadow stay strictly inside dark recess [452, 467] + expect(goldTextCall!.y - 7).toBeGreaterThanOrEqual(INV_GOLD_BOX_RECESS.y) + expect(goldTextCall!.y + 1).toBeLessThanOrEqual(INV_GOLD_BOX_RECESS.y + INV_GOLD_BOX_RECESS.h - 1) + + // 4. Verify clicking directly on the Gold Coin Button (490, 460) triggers onDropGold + let droppedGold = 0 + expect( + inv.handleClick(490, 460, { + onDropGold: amt => { + droppedGold = amt + }, + }), + ).toBe(true) + expect(droppedGold).toBe(10_000) + + // 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia) + const fontRenderer = new D2FontRenderer() + let capturedFont = '' + const fontCtx = { + save: () => {}, + restore: () => {}, + measureText: (t: string) => ({ width: t.length * 6 }), + fillText: () => {}, + set font(v: string) { + capturedFont = v + }, + get font() { + return capturedFont + }, + textBaseline: 'alphabetic', + fillStyle: '', + } as unknown as CanvasRenderingContext2D + fontRenderer.drawText(fontCtx, '850,000', 552, 463, { font: 'font8', color: 'white', align: 'center' }) + expect(capturedFont).not.toContain('Georgia') + expect(capturedFont).toContain('Times New Roman') + }) })