fix(ui): 补齐物品栏金币按钮 (goldcoinbtn.dc6) 并对齐金币栏数值

This commit is contained in:
troytt 2026-09-27 08:16:53 +00:00
parent 855d610168
commit 8c5ae5c623
7 changed files with 191 additions and 7 deletions

BIN
public/ui/goldcoinbtn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -320,6 +320,14 @@ async function main(): Promise<void> {
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)) {

View File

@ -363,6 +363,14 @@ async function main(): Promise<void> {
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)) {

View File

@ -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

View File

@ -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,
},

View File

@ -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<string, { x: number; y: number; w: number; h: number }>
},
@ -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',

View File

@ -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<string, unknown> }> = []
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<string, unknown>) => {
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')
})
})