diff --git a/src/ui/control-bar.ts b/src/ui/control-bar.ts index 300b8ba..341fa80 100644 --- a/src/ui/control-bar.ts +++ b/src/ui/control-bar.ts @@ -2,7 +2,7 @@ * Diablo II v1.13c 800x600 Bottom Control Panel (`Panel\800CtrlPnl7.dc6`). * * Houses: - * - Experience Bar (x=256, y=552, w=120, h=4) with tick notches & hover readout + * - Experience Bar (x=256, y=560, w=120, h=4) with tick notches & hover readout * - Stamina Bar (x=273, y=573, w=102, h=18) with run drain & walk/stand recovery * - Walk / Run Toggle Button (`Panel\runbutton.dc6` at x=255, y=572, w=16, h=20, hotkey `R`) * - Central Mini-Panel Toggle (`Panel\menubutton.dc6` at x=393, y=562, w=15, h=24) & @@ -27,7 +27,8 @@ export interface ControlBarState { readonly unspentSkillPoints: number } -export const EXP_BAR_BOUNDS = { x: 256, y: 552, width: 120, height: 5 } as const +export const EXP_BAR_BOUNDS = { x: 256, y: 560, width: 120, height: 4 } as const +export const EXP_BAR_HOVER_BOUNDS = { x: 256, y: 558, width: 120, height: 8 } as const export const STAMINA_BAR_BOUNDS = { x: 273, y: 573, width: 102, height: 18 } as const export const RUN_BTN_BOUNDS = { x: 255, y: 572, width: 16, height: 20 } as const export const MENU_TOGGLE_BOUNDS = { x: 393, y: 562, width: 15, height: 24 } as const @@ -59,7 +60,7 @@ export class ControlBarHud { this.hoveredElement = MINIPANEL_ITEMS[idx]!.action return } - if (this.inRect(logicalX, logicalY, EXP_BAR_BOUNDS)) { + if (this.inRect(logicalX, logicalY, EXP_BAR_HOVER_BOUNDS)) { this.hoveredElement = 'exp' } else if (this.inRect(logicalX, logicalY, STAMINA_BAR_BOUNDS)) { this.hoveredElement = 'stamina' @@ -136,13 +137,19 @@ export class ControlBarHud { ctx.fillRect(0, 545, 800, 55) } - // 2. Experience Bar (`x=256, y=552, w=120, h=4`) + // 2. Experience Bar (`x=256, y=560, w=120, h=4`) const xpSpan = Math.max(1, state.nextLevelXp - state.currentLevelXp) const xpRatio = Math.max(0, Math.min(1, (state.xp - state.currentLevelXp) / xpSpan)) ctx.fillStyle = '#070605' ctx.fillRect(EXP_BAR_BOUNDS.x, EXP_BAR_BOUNDS.y, EXP_BAR_BOUNDS.width, EXP_BAR_BOUNDS.height) ctx.fillStyle = '#d8d0a8' - ctx.fillRect(EXP_BAR_BOUNDS.x, EXP_BAR_BOUNDS.y, Math.round(EXP_BAR_BOUNDS.width * xpRatio), EXP_BAR_BOUNDS.height - 1) + ctx.fillRect(EXP_BAR_BOUNDS.x, EXP_BAR_BOUNDS.y, Math.round(EXP_BAR_BOUNDS.width * xpRatio), EXP_BAR_BOUNDS.height) + + // Tick notches (10 segments, 12px each per D2 800x600 specification) + ctx.fillStyle = '#1e1812' + for (let i = 1; i < 10; i++) { + ctx.fillRect(EXP_BAR_BOUNDS.x + i * 12, EXP_BAR_BOUNDS.y, 1, EXP_BAR_BOUNDS.height) + } // 3. Stamina Bar (`x=273, y=573, w=102, h=18`) const stamRatio = Math.max(0, Math.min(1, state.stamina / Math.max(1, state.maxStamina))) diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 9908424..e1de876 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -9,6 +9,12 @@ import { parseColoredText, parseFontTblMetrics, D2_COLOR_HEX } from '../src/ui/f 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' +import { + ControlBarHud, + EXP_BAR_BOUNDS, + EXP_BAR_HOVER_BOUNDS, + STAMINA_BAR_BOUNDS, +} from '../src/ui/control-bar.ts' import { InventoryPanel, INV_PANEL_ORIGIN, @@ -76,6 +82,55 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(LIFE_GLOBE_BOUNDS).toEqual({ x: 28, y: 515, width: 80, height: 80 }) expect(MANA_GLOBE_BOUNDS).toEqual({ x: 692, y: 515, width: 80, height: 80 }) expect(BELT_BASE_X).toBe(423) + + // Experience Bar (Issue #146: groove at x=256, y=560, w=120, h=4 in 800CtrlPnl7) + expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 }) + expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 }) + expect(STAMINA_BAR_BOUNDS).toEqual({ x: 273, y: 573, width: 102, height: 18 }) + }) + + it('aligns Experience Bar to authentic 1.13c groove and handles hover hit-testing (Issue #146)', () => { + expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 }) + expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 }) + + // 10 segments of 12px width = 120px total width + expect(EXP_BAR_BOUNDS.width).toBe(120) + const segmentWidth = EXP_BAR_BOUNDS.width / 10 + expect(segmentWidth).toBe(12) + + // 9 divider ticks at x = 256 + i * 12 (i = 1..9) + const tickXs = Array.from({ length: 9 }, (_, i) => EXP_BAR_BOUNDS.x + (i + 1) * 12) + expect(tickXs).toEqual([268, 280, 292, 304, 316, 328, 340, 352, 364]) + + const bar = new ControlBarHud() + + // Test start of groove (256, 560) + bar.handleMouseMove(256, 560) + expect(bar.hoveredElement).toBe('exp') + + // Test throughout the groove + bar.handleMouseMove(256 + 60, 562) + expect(bar.hoveredElement).toBe('exp') + + // Test end of groove (376, 563) + bar.handleMouseMove(256 + 120, 563) + expect(bar.hoveredElement).toBe('exp') + + // Test padded hover bounds: y = 558 and y = 566 + bar.handleMouseMove(256, 558) + expect(bar.hoveredElement).toBe('exp') + bar.handleMouseMove(376, 566) + expect(bar.hoveredElement).toBe('exp') + + // Outside bounds + bar.handleMouseMove(255, 560) + expect(bar.hoveredElement).toBeNull() + bar.handleMouseMove(377, 560) + expect(bar.hoveredElement).toBeNull() + bar.handleMouseMove(300, 557) + expect(bar.hoveredElement).toBeNull() + bar.handleMouseMove(300, 567) + expect(bar.hoveredElement).toBeNull() }) it('computes vertical bottom-up liquid clipping for Life & Mana globes', () => {