fix(ui): 补齐物品栏金币按钮 (goldcoinbtn.dc6) 并对齐金币栏数值
This commit is contained in:
parent
855d610168
commit
8c5ae5c623
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue