diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 8b42ecd..63e5b57 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -47,6 +47,45 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const +/** + * `Panel\TradeStash.dc6` (`public/ui/stash-bg.png`, 320x432 anchored at `ox = 80, oy = 60`) + * header recessed dark boxes (Issue #493): + * - Top box (`relY = 24..38`, height 15px) is divided by a vertical bar at `relX = 94..96` into: + * - Left icon socket: `relX = 74..93, relY = 24..38` (20x15px) + * - Right title text recess: `relX = 97..247, relY = 24..38` (151x15px, center `relX = 172, relY = 31`, alphabetic baseline `oy + 35`) + * - Bottom gold text recess: `relX = 75..246, relY = 49..65` (172x17px, center `relX = 160, relY = 57`, alphabetic baseline `oy + 61`) + */ +export const STASH_HEADER_ICON_SOCKET = { + x: 80 + 74, + y: 60 + 24, + relX: 74, + relY: 24, + w: 20, + h: 15, +} as const + +export const STASH_HEADER_TOP_RECESS = { + x: 80 + 97, + y: 60 + 24, + relX: 97, + relY: 24, + w: 151, + h: 15, + textX: 80 + 172, + textY: 60 + 35, +} as const + +export const STASH_HEADER_BOTTOM_RECESS = { + x: 80 + 75, + y: 60 + 49, + relX: 75, + relY: 49, + w: 172, + h: 17, + textX: 80 + 160, + textY: 60 + 61, +} as const + /** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */ 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 @@ -1916,12 +1955,12 @@ export class WorldPanelsHud { } } else if (kind === 'stash') { if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) - font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 160, oy + 26, { - font: 'fontexocet10', + font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 172, oy + 35, { + font: 'font8', color: 'gold', align: 'center', }) - font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 48, { + font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 61, { font: 'font8', color: 'white', align: 'center', diff --git a/tests/stash.test.ts b/tests/stash.test.ts index 23ad260..56bd7af 100644 --- a/tests/stash.test.ts +++ b/tests/stash.test.ts @@ -15,8 +15,12 @@ import { PLAYER_GOLD_CAP, STASH_DEPOSIT_BTN_BOUNDS, STASH_WITHDRAW_BTN_BOUNDS, + STASH_HEADER_ICON_SOCKET, + STASH_HEADER_TOP_RECESS, + STASH_HEADER_BOTTOM_RECESS, WorldPanelsHud, } from '../src/ui/world-panels.ts' +import type { D2FontRenderer } from '../src/ui/font.ts' import { InventoryPanel, type UiInventoryItem, @@ -604,4 +608,123 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x expect(hud.inventory.visible).toBe(false) }) }) + + describe('8. Stash Header Recessed Boxes & Text Alignment Parity (Issue #493)', () => { + it('defines exact TradeStash.dc6 recessed box bounds and centered baselines', () => { + // Left square icon socket in top row: relX = 74..93, relY = 24..38 (20x15) + expect(STASH_HEADER_ICON_SOCKET).toEqual({ + x: 80 + 74, + y: 60 + 24, + relX: 74, + relY: 24, + w: 20, + h: 15, + }) + + // Right title recess in top row (after relX = 94..96 divider): relX = 97..247, relY = 24..38 (151x15) + expect(STASH_HEADER_TOP_RECESS).toEqual({ + x: 80 + 97, + y: 60 + 24, + relX: 97, + relY: 24, + w: 151, + h: 15, + textX: 80 + 172, + textY: 60 + 35, + }) + + // Full-width gold readout recess in bottom row: relX = 75..246, relY = 49..65 (172x17) + expect(STASH_HEADER_BOTTOM_RECESS).toEqual({ + x: 80 + 75, + y: 60 + 49, + relX: 75, + relY: 49, + w: 172, + h: 17, + textX: 80 + 160, + textY: 60 + 61, + }) + }) + + it('renders Private Stash title and gold readout centered inside their respective recessed boxes without crossing dividers', () => { + const panels = new WorldPanelsHud() + panels.stashGold = 2_500_000 + + const drawTextCalls: Array<{ + text: string + x: number + y: number + opts?: { font?: string; color?: string; align?: string } + }> = [] + + const mockFont = { + drawText: (_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => { + drawTextCalls.push({ text, x, y, opts }) + }, + measureText: (text: string) => text.length * 6.5, + } as unknown as D2FontRenderer + + const mockCtx = { + drawImage: () => {}, + fillRect: () => {}, + strokeRect: () => {}, + fillStyle: '', + strokeStyle: '', + } as unknown as CanvasRenderingContext2D + + panels.drawLeftDockPanel( + mockCtx, + 'stash', + { + questBgImg: null, + waypointBgImg: null, + borderLeftImg: null, + stashBgImg: null, + cubeBgImg: null, + vendorBgImg: null, + buySellBtnImg: null, + }, + mockFont, + ) + + const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱')) + const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币')) + + expect(titleCall).toBeDefined() + expect(goldCall).toBeDefined() + + // 1. Top title must be centered in the right compartment (relX = 97..247 -> center ox + 172 = 252) + // and vertically centered in relY = 24..38 (alphabetic baseline oy + 35 = 95) + expect(titleCall!.x).toBe(STASH_HEADER_TOP_RECESS.textX) + expect(titleCall!.y).toBe(STASH_HEADER_TOP_RECESS.textY) + expect(titleCall!.opts?.font).toBe('font8') + expect(titleCall!.opts?.color).toBe('gold') + expect(titleCall!.opts?.align).toBe('center') + + // Verify title glyph vertical span [y - 9, y + 1] stays strictly inside top recess [oy + 24, oy + 38] + const topRecessTop = STASH_HEADER_TOP_RECESS.y + const topRecessBottom = STASH_HEADER_TOP_RECESS.y + STASH_HEADER_TOP_RECESS.h - 1 + expect(titleCall!.y - 9).toBeGreaterThanOrEqual(topRecessTop) + expect(titleCall!.y + 1).toBeLessThanOrEqual(topRecessBottom) + + // Verify title horizontal span (~130px in font8) stays strictly inside [ox + 97, ox + 247] (never crossing relX = 94..96 divider) + const approxTitleWidth = 130 + expect(titleCall!.x - approxTitleWidth / 2).toBeGreaterThan(STASH_HEADER_TOP_RECESS.x) + expect(titleCall!.x + approxTitleWidth / 2).toBeLessThan(STASH_HEADER_TOP_RECESS.x + STASH_HEADER_TOP_RECESS.w) + + // 2. Bottom gold text must be centered in the bottom recess (relX = 75..246 -> center ox + 160 = 240) + // and vertically centered in relY = 49..65 (alphabetic baseline oy + 61 = 121) + expect(goldCall!.x).toBe(STASH_HEADER_BOTTOM_RECESS.textX) + expect(goldCall!.y).toBe(STASH_HEADER_BOTTOM_RECESS.textY) + expect(goldCall!.opts?.font).toBe('font8') + expect(goldCall!.opts?.color).toBe('white') + expect(goldCall!.opts?.align).toBe('center') + + // Verify gold glyph vertical span [y - 9, y + 1] stays strictly inside bottom recess [oy + 49, oy + 65] + const bottomRecessTop = STASH_HEADER_BOTTOM_RECESS.y + const bottomRecessBottom = STASH_HEADER_BOTTOM_RECESS.y + STASH_HEADER_BOTTOM_RECESS.h - 1 + expect(goldCall!.y - 9).toBeGreaterThanOrEqual(bottomRecessTop) + expect(goldCall!.y + 1).toBeLessThanOrEqual(bottomRecessBottom) + }) + }) })