fix: align ControlBar XP bar coordinates and 10-segment ticks (Fixes #146)

This commit is contained in:
troytt 2026-09-22 02:06:58 +00:00
commit 6e4d0382ec
2 changed files with 67 additions and 5 deletions

View File

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

View File

@ -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', () => {