fix(ui): fix skill tooltips, HUD docking offsets, and UI clamping at >800x600 viewports (#557)

- Translate HUD mouse-move, click, drag, and drop coordinates across all 4 viewport profiles (800x600, 1024x768, 1068x600, 1280x720) using toControlBarLocal, toLeftDockLocal, fromLeftDockLocal, toRightDockLocal, and fromRightDockLocal
- Make HudManager.leftPanel and HudManager.rightPanel getters/setters that keep charSheet.visible, inventory.visible, and skillTree.visible synchronized on direct assignment (e.g., HudModel.syncFromWorld)
- Draw SkillTreePanel tooltips on the top-most HUD tooltip layer with minLocalX = 8 - deltaRight so they are never occluded by the bottom control bar/globes/belt and never clamp over column-1 skill nodes on widescreen viewports
- Swap belt.draw before hotkeys.draw so right-hand speedbar popups and skill tooltips render above the belt
- Route both left-click and right-click on HUD regions in CommandMapper.handlePointerDown to HudManager.handleClick
- Reverse-translate Mercenary, Trade, and Quest Reward (Imbue/Add Sockets/Personalize) left-dock item hover tooltips and clamp NPC dialog menus, Area Entry Banner, and Death Overlay against active viewport dimensions

Closes #557
Closes #542
This commit is contained in:
troytt 2026-10-02 10:50:11 +00:00
parent 9e749a21cf
commit e31e1b561a
5 changed files with 1060 additions and 151 deletions

View File

@ -407,8 +407,8 @@ export class CommandMapper {
// 2. Route HUD clicks to HudManager (status bar, orbs, skill popup, belt, open panels)
if (this.hudManager.isPointInterceptedByHud(screenX, screenY)) {
if (button === 0) {
this.hudManager.handleClick(screenX, screenY)
if (button === 0 || button === 2) {
this.hudManager.handleClick(screenX, screenY, button)
}
return null
}

View File

@ -319,6 +319,84 @@ export function logicalToClient(
}
}
/**
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
* of the bottom control bar region (`controlBar`, `globes`, `belt`, `hotkeys`).
*/
export function toControlBarLocal(
x: number,
y: number,
docking: Pick<DockingLayout, 'barOffsetX' | 'barOffsetY'>,
): { x: number; y: number } {
return {
x: x - (docking.barOffsetX ?? 0),
y: y - (docking.barOffsetY ?? 0),
}
}
/**
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
* of the left dock panel region (`charSheet`, `worldPanels`).
*/
export function toLeftDockLocal(
x: number,
y: number,
docking: Pick<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
return {
x: x - docking.leftDockX,
y: y - (docking.panelOffsetY ?? 0),
}
}
/**
* Reverse-translates a left-dock-local coordinate `(localX, localY)` back into
* logical viewport coordinates (used for hovered item tooltip anchoring).
*/
export function fromLeftDockLocal(
localX: number,
localY: number,
docking: Pick<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
return {
x: localX + docking.leftDockX,
y: localY + (docking.panelOffsetY ?? 0),
}
}
/**
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
* of the right dock panel region (`inventory`, `skillTree`), where the right panel is
* authored at `x = 400..800, y = 0..553`.
*/
export function toRightDockLocal(
x: number,
y: number,
docking: Pick<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
const deltaRight = docking.rightDockX - 400
return {
x: x - deltaRight,
y: y - (docking.panelOffsetY ?? 0),
}
}
/**
* Reverse-translates a right-dock-local coordinate `(localX, localY)` back into
* logical viewport coordinates (used for hovered item tooltip anchoring).
*/
export function fromRightDockLocal(
localX: number,
localY: number,
docking: Pick<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
const deltaRight = docking.rightDockX - 400
return {
x: localX + deltaRight,
y: localY + (docking.panelOffsetY ?? 0),
}
}
/**
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`;
@ -449,8 +527,27 @@ export class HudManager {
readonly worldPanels = new WorldPanelsHud()
readonly cursor = new CursorController()
leftPanel: LeftPanelType = 'none'
rightPanel: 'none' | 'inv' | 'skill' = 'none'
private _leftPanel: LeftPanelType = 'none'
get leftPanel(): LeftPanelType {
return this._leftPanel
}
set leftPanel(val: LeftPanelType) {
this._leftPanel = val
this.charSheet.visible = val === 'char'
}
private _rightPanel: 'none' | 'inv' | 'skill' = 'none'
get rightPanel(): 'none' | 'inv' | 'skill' {
return this._rightPanel
}
set rightPanel(val: 'none' | 'inv' | 'skill') {
this._rightPanel = val
this.inventory.visible = val === 'inv'
this.skillTree.visible = val === 'skill'
if (val !== 'skill') {
this.skillTree.hoveredSkill = null
}
}
isRunning = true
showGroundLabels = false
/** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */
@ -744,28 +841,108 @@ export class HudManager {
this.mouseX = logicalX
this.mouseY = logicalY
this.cursor.handleMouseMove(logicalX, logicalY)
this.globes.handleMouseMove(logicalX, logicalY)
this.belt.handleMouseMove(logicalX, logicalY)
this.hotkeys.handleMouseMove(logicalX, logicalY)
this.controlBar.handleMouseMove(logicalX, logicalY)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(logicalX, logicalY, this.font)
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(logicalX - deltaRight, logicalY)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
const barPt = toControlBarLocal(logicalX, logicalY, docking)
this.globes.handleMouseMove(barPt.x, barPt.y)
this.belt.handleMouseMove(barPt.x, barPt.y)
this.hotkeys.handleMouseMove(barPt.x, barPt.y)
this.controlBar.handleMouseMove(barPt.x, barPt.y)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(
logicalX,
logicalY,
this.font,
this.viewportProfile.width,
this.viewportProfile.height,
)
}
this.skillTree.handleMouseMove(logicalX - deltaRight, logicalY)
if (
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.handleMouseMove(logicalX - deltaLeft, logicalY)
const rightPt = toRightDockLocal(logicalX, logicalY, docking)
this.inventory.handleMouseMove(rightPt.x, rightPt.y)
if (this.inventory.hoveredItem) {
const tipPt = fromRightDockLocal(
this.inventory.hoveredItem.x,
this.inventory.hoveredItem.y,
docking,
)
this.inventory.hoveredItem.x = tipPt.x
this.inventory.hoveredItem.y = tipPt.y
}
this.skillTree.handleMouseMove(rightPt.x, rightPt.y)
if (this.leftPanel !== 'none' && this.leftPanel !== 'char') {
const leftPt = toLeftDockLocal(logicalX, logicalY, docking)
this.worldPanels.handleMouseMove(leftPt.x, leftPt.y)
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredStashItem.x,
this.worldPanels.hoveredStashItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredStashItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredCubeItem.x,
this.worldPanels.hoveredCubeItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredCubeItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredVendorItem.x,
this.worldPanels.hoveredVendorItem.y,
docking,
)
this.inventory.hoveredItem = {
item: this.worldPanels.hoveredVendorItem.item,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'merc' && this.worldPanels.hoveredMercItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredMercItem.x,
this.worldPanels.hoveredMercItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredMercItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'trade' && this.worldPanels.hoveredTradeItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredTradeItem.x,
this.worldPanels.hoveredTradeItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredTradeItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (
(this.leftPanel === 'imbue' ||
this.leftPanel === 'add_sockets' ||
this.leftPanel === 'personalize') &&
this.worldPanels.hoveredQuestRewardItem
) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredQuestRewardItem.x,
this.worldPanels.hoveredQuestRewardItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredQuestRewardItem,
x: tipPt.x,
y: tipPt.y,
}
}
}
}
@ -1296,7 +1473,7 @@ export class HudManager {
if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) {
return false
}
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY, profile.width, profile.height)) return true
const barX = logicalX - profile.barOffsetX
const barY = logicalY - profile.barOffsetY
@ -1318,7 +1495,7 @@ export class HudManager {
}
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
@ -1497,52 +1674,7 @@ export class HudManager {
window.addEventListener('mousemove', (e) => {
const pt = this.clientToLogical(e.clientX, e.clientY)
this.mouseX = pt.x
this.mouseY = pt.y
this.cursor.handleMouseMove(pt.x, pt.y)
this.globes.handleMouseMove(pt.x, pt.y)
this.belt.handleMouseMove(pt.x, pt.y)
this.hotkeys.handleMouseMove(pt.x, pt.y)
this.controlBar.handleMouseMove(pt.x, pt.y)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(pt.x, pt.y, this.font)
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(pt.x - deltaRight, pt.y)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
}
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
if (
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
this.inventory.hoveredItem = {
...this.worldPanels.hoveredStashItem,
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
}
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
this.inventory.hoveredItem = {
...this.worldPanels.hoveredCubeItem,
x: this.worldPanels.hoveredCubeItem.x + deltaLeft,
}
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
this.inventory.hoveredItem = {
item: this.worldPanels.hoveredVendorItem.item,
x: this.worldPanels.hoveredVendorItem.x + deltaLeft,
y: this.worldPanels.hoveredVendorItem.y,
}
}
}
this.handleMouseMove(pt.x, pt.y)
const intercept =
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
@ -1663,13 +1795,18 @@ export class HudManager {
},
},
this.font,
this.viewportProfile.width,
this.viewportProfile.height,
)
this.syncPublishedState()
return
}
const docking = this.getDockingLayout()
const barPt = toControlBarLocal(pt.x, pt.y, docking)
// Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
const beltHit = this.belt.slotAtPoint(barPt.x, barPt.y)
if (beltHit) {
if (e.button === 2) {
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
@ -1707,19 +1844,19 @@ export class HudManager {
return
}
if (this.globes.handleClick(pt.x, pt.y)) {
if (this.globes.handleClick(barPt.x, barPt.y)) {
this.syncPublishedState()
return
}
if (this.hotkeys.handleClick(pt.x, pt.y)) {
if (this.hotkeys.handleClick(barPt.x, barPt.y)) {
this.syncPublishedState()
return
}
const ctrlState = this.buildControlBarState()
if (
this.controlBar.handleClick(pt.x, pt.y, ctrlState, {
this.controlBar.handleClick(barPt.x, barPt.y, ctrlState, {
onToggleRun: () => {
this.isRunning = !this.isRunning
},
@ -1737,15 +1874,11 @@ export class HudManager {
return
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// Left-Half Dock Click
if (pt.x <= 400 + deltaLeft) {
const leftX = pt.x - deltaLeft
const leftPt = toLeftDockLocal(pt.x, pt.y, docking)
if (leftPt.x >= 0 && leftPt.x <= 400 && leftPt.y >= 0 && leftPt.y <= 553) {
if (this.leftPanel === 'char') {
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
this.charSheet.handleClick(leftPt.x, leftPt.y, (key: BaseStatKey) => {
this._commandSink?.allocateStat(key)
})
if (!this.charSheet.visible) this.leftPanel = 'none'
@ -1756,8 +1889,8 @@ export class HudManager {
if (typeof (this.worldPanels as any).handleActiveLeftPanelClick === 'function') {
const handled = (this.worldPanels as any).handleActiveLeftPanelClick(
dockKind,
leftX,
pt.y,
leftPt.x,
leftPt.y,
e.button === 2,
e.shiftKey,
{
@ -1834,7 +1967,7 @@ export class HudManager {
return
}
}
this.worldPanels.handleLeftDockClick(dockKind as any, leftX, pt.y, {
this.worldPanels.handleLeftDockClick(dockKind as any, leftPt.x, leftPt.y, {
onClose: () => {
this.leftPanel = 'none'
this.leaveLeftPanel(dockKind)
@ -1874,20 +2007,20 @@ export class HudManager {
}
// Right-Half Dock Click
if (pt.x >= 400 + deltaRight) {
const rightX = pt.x - deltaRight
const rightPt = toRightDockLocal(pt.x, pt.y, docking)
if (rightPt.x >= 400 && rightPt.x <= 800 && rightPt.y >= 0 && rightPt.y <= 553) {
if (this.rightPanel === 'inv') {
if (!this.inventory.isIdentifyMode() && !this._commandSink) {
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
if (
rightX >= INV_GRID_ORIGIN.x &&
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
pt.y >= INV_GRID_ORIGIN.y &&
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
rightPt.x >= INV_GRID_ORIGIN.x &&
rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
rightPt.y >= INV_GRID_ORIGIN.y &&
rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
@ -1902,13 +2035,13 @@ export class HudManager {
// Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) {
if (
rightX >= INV_GRID_ORIGIN.x &&
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
pt.y >= INV_GRID_ORIGIN.y &&
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
rightPt.x >= INV_GRID_ORIGIN.x &&
rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
rightPt.y >= INV_GRID_ORIGIN.y &&
rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
@ -1925,21 +2058,21 @@ export class HudManager {
this.leftPanel === 'vendor' &&
!this.inventory.isIdentifyMode() &&
this.pendingIdentifyScrollId === null &&
this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)
this.handleVendorInventoryClick(rightPt.x, rightPt.y, e.button === 2)
) {
this.syncPublishedState()
return
}
if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
if (this._commandSink && this.forwardInventoryPanelClick(rightPt.x, rightPt.y, e.button === 2)) {
this.syncPublishedState()
return
}
this.syncPlayerContext()
this.inventory.handleClick(
rightX,
pt.y,
rightPt.x,
rightPt.y,
{
onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
@ -1955,7 +2088,7 @@ export class HudManager {
this.syncPublishedState()
return
} else if (this.rightPanel === 'skill') {
this.skillTree.handleClick(rightX, pt.y, (skillId) => this.handleSkillAllocated(skillId))
this.skillTree.handleClick(rightPt.x, rightPt.y, (skillId) => this.handleSkillAllocated(skillId))
if (!this.skillTree.visible) this.rightPanel = 'none'
this.syncPublishedState()
return
@ -2628,11 +2761,12 @@ export class HudManager {
buySellBtnImg: this.images.get('buySellBtn') ?? null,
},
this.font,
true,
)
ctx.restore()
}
// 3..6. Bottom 800px Control Panel, Globes, Skill Hotkeys, and Belt (translated by barOffsetX, barOffsetY)
// 3..6. Bottom 800px Control Panel, Globes, Belt, and Skill Hotkeys (translated by barOffsetX, barOffsetY)
ctx.save()
ctx.translate(barOffsetX, barOffsetY)
this.controlBar.draw(
@ -2669,15 +2803,7 @@ export class HudManager {
this.font,
)
// 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`)
this.hotkeys.draw(
ctx,
this.images.get('genericSkills') ?? null,
this.font,
Boolean(this.inventory.equipped.weapon1),
)
// 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
// 5. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
this.belt.draw(
ctx,
{
@ -2687,6 +2813,14 @@ export class HudManager {
},
this.font,
)
// 6. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`) — drawn above belt so speedbar popup & tooltips are never occluded
this.hotkeys.draw(
ctx,
this.images.get('genericSkills') ?? null,
this.font,
Boolean(this.inventory.equipped.weapon1),
)
ctx.restore()
// 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`)
@ -2702,27 +2836,51 @@ export class HudManager {
this.leftPanel !== 'hire' &&
(this.worldPanels as any).activePanel !== 'hire'
) {
this.worldPanels.drawNpcMenu(ctx, this.font, {
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
})
this.worldPanels.drawNpcMenu(
ctx,
this.font,
{
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
},
profile.width,
profile.height,
)
}
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) {
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font, profile.width)
}
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor)
// 10. Hover Tooltips (Inventory / Equipment / Stash / Cube / Vendor / Merc / Trade / Quest Reward / Skill Tree)
if (
(this.rightPanel === 'inv' ||
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'vendor') &&
this.leftPanel === 'vendor' ||
this.leftPanel === 'merc' ||
this.leftPanel === 'trade' ||
this.leftPanel === 'imbue' ||
this.leftPanel === 'add_sockets' ||
this.leftPanel === 'personalize') &&
this.inventory.hoveredItem &&
!this.inventory.cursorItem
) {
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor')
this.worldPanels.drawItemTooltip(
ctx,
this.inventory.hoveredItem,
this.font,
this.leftPanel === 'vendor',
profile.width,
profile.height,
)
}
if (this.rightPanel === 'skill' && this.skillTree.hoveredSkill) {
ctx.save()
ctx.translate(deltaRight, panelOffsetY)
this.skillTree.drawSkillTooltip(ctx, this.font, 8 - deltaRight)
ctx.restore()
}
// 10b. Gold dialog (drop / deposit / withdraw)
@ -2740,15 +2898,17 @@ export class HudManager {
// 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4)
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
const centerX = Math.round(profile.width / 2)
const centerY = Math.round(profile.height / 2)
ctx.save()
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
ctx.fillRect(0, 0, 800, 600)
ctx.fillRect(0, 0, profile.width, profile.height)
ctx.fillStyle = '#d4b468'
ctx.font = 'bold 22px serif'
ctx.textAlign = 'center'
const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续'
ctx.fillText(deathMsg, 400, 300)
this.font.drawText(ctx, deathMsg, 400, 300, {
ctx.fillText(deathMsg, centerX, centerY)
this.font.drawText(ctx, deathMsg, centerX, centerY, {
font: 'font16',
color: 'gold',
align: 'center',

View File

@ -423,6 +423,7 @@ export class SkillTreePanel {
skillTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
skipTooltip = false,
): void {
if (!this.visible) return
if (this.skillIcons.size === 0 && this.characterClass !== null) {
@ -571,7 +572,7 @@ export class SkillTreePanel {
}
// 6. Hover Tooltip for Skill Node
if (this.hoveredSkill) {
if (this.hoveredSkill && !skipTooltip) {
this.drawSkillTooltip(ctx, font)
}
}
@ -801,6 +802,7 @@ export class SkillTreePanel {
pos: { x: number; y: number; w: number; h: number },
lines: readonly FormattedTooltipLine[],
font: D2FontRenderer,
minLocalX = 8,
): { x: number; y: number; w: number; h: number } {
const maxLineWidth = lines.reduce((max, l) => {
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
@ -809,13 +811,13 @@ export class SkillTreePanel {
const boxW = Math.max(240, maxLineWidth + 28)
const boxH = lines.length * 17 + 24
const rawX = pos.x - boxW - 8
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
const clampedX = Math.max(minLocalX, Math.min(800 - boxW - 8, rawX))
const rawY = Math.round(pos.y + 24 - boxH / 2)
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
}
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer, minLocalX = 8): void {
if (!this.hoveredSkill) return
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
@ -824,7 +826,7 @@ export class SkillTreePanel {
const lines = SkillTreePanel.formatSkillTooltipLines(vm, this.lang)
if (lines.length === 0) return
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font, minLocalX)
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)

View File

@ -528,7 +528,7 @@ export interface NpcDialogLayout {
* - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true`
* - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`)
* - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21`
* - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`)
* - Clamped to viewport (`left ∈ [10, viewportWidth - width]`, `top ∈ [10, viewportHeight - height - 48]`)
*/
export function computeNpcDialogLayout(
npcName: string,
@ -537,6 +537,8 @@ export function computeNpcDialogLayout(
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
lang: TblLang = 'CHI',
viewportWidth = 800,
viewportHeight = 600,
): NpcDialogLayout {
const measure = (text: string): number =>
font ? font.measureText(text, 'font16') : text.length * 10
@ -557,11 +559,11 @@ export function computeNpcDialogLayout(
let left = Math.trunc(anchorX) - Math.trunc(width / 2)
let top = Math.trunc(anchorY) - headerStepY
if (left + width > 800 - 10) {
left = 800 - width
if (left + width > viewportWidth - 10) {
left = viewportWidth - width
}
if (top + height > 600 - 58) {
top = 600 - height - 48
if (top + height > viewportHeight - 58) {
top = viewportHeight - height - 48
}
if (left < 10) left = 10
if (top < 10) top = 10
@ -1677,6 +1679,9 @@ export class WorldPanelsHud {
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
cubeItems: GridPlacement[] = []
hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredMercItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredTradeItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredQuestRewardItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredWaypointIdx: number | null = null
/**
@ -2058,6 +2063,9 @@ export class WorldPanelsHud {
this.hoveredStashItem = null
this.hoveredCubeItem = null
this.hoveredVendorItem = null
this.hoveredMercItem = null
this.hoveredTradeItem = null
this.hoveredQuestRewardItem = null
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
? this.hitTestVendorButtonSlot(logicalX, logicalY)
: null
@ -2143,9 +2151,82 @@ export class WorldPanelsHud {
}
}
}
// Mercenary equipment slots (`merc` left panel)
const m = this.merc
if (m) {
const eq = m.equipped ?? m.equipment
const mercSlots: ReadonlyArray<{ x: number; y: number; w: number; h: number; item: UiInventoryItem | null }> = [
{ x: ox + 132, y: oy + 44, w: 56, h: 56, item: m.headItem ?? eq?.helm ?? eq?.head ?? null },
{ x: ox + 132, y: oy + 112, w: 56, h: 84, item: m.bodyItem ?? eq?.armor ?? eq?.torso ?? null },
{ x: ox + 36, y: oy + 98, w: 56, h: 112, item: m.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null },
{ x: ox + 228, y: oy + 98, w: 56, h: 112, item: m.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null },
]
for (const s of mercSlots) {
if (s.item && logicalX >= s.x && logicalX <= s.x + s.w && logicalY >= s.y && logicalY <= s.y + s.h) {
this.hoveredMercItem = {
item: s.item,
x: s.x + s.w / 2,
y: s.y,
}
break
}
}
}
// Player-to-player trade grids (`trade` left panel)
if (this.trade) {
const checkTradeGrid = (gridY: number, items: readonly any[] | undefined) => {
if (!items || logicalX < ox + 15 || logicalX >= ox + 15 + 290 || logicalY < gridY || logicalY >= gridY + 116) {
return
}
const col = Math.floor((logicalX - (ox + 15)) / 29)
const row = Math.floor((logicalY - gridY) / 29)
const hit = items.find((it: any) => {
const gx = Number(it.gridX ?? it.col ?? 0)
const gy = Number(it.gridY ?? it.row ?? 0)
const gw = Math.max(1, Number(it.invWidth ?? it.w ?? 1))
const gh = Math.max(1, Number(it.invHeight ?? it.h ?? 1))
return col >= gx && col < gx + gw && row >= gy && row < gy + gh
})
if (hit) {
const gx = Number(hit.gridX ?? hit.col ?? 0)
const gy = Number(hit.gridY ?? hit.row ?? 0)
const gw = Math.max(1, Number(hit.invWidth ?? hit.w ?? 1))
this.hoveredTradeItem = {
item: hit as UiInventoryItem,
x: ox + 15 + (gx + gw / 2) * 29,
y: gridY + gy * 29,
}
}
}
checkTradeGrid(oy + 60, this.trade.partnerItems)
if (!this.hoveredTradeItem) {
checkTradeGrid(oy + 220, this.trade.selfItems ?? this.trade.myItems)
}
}
// Quest reward slot (`imbue` / `add_sockets` / `personalize` left panel)
if (
this.questRewardItem &&
logicalX >= ox + 116 &&
logicalX <= ox + 116 + 88 &&
logicalY >= oy + 120 &&
logicalY <= oy + 120 + 116
) {
this.hoveredQuestRewardItem = {
item: this.questRewardItem,
x: ox + 116 + 44,
y: oy + 120,
}
}
}
getNpcMenuLayout(font?: Pick<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
getNpcMenuLayout(
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): NpcDialogLayout | null {
if (!this.npcMenu) return null
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
@ -2154,23 +2235,34 @@ export class WorldPanelsHud {
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
return computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
return computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
}
isPointInNpcMenu(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
}
const layout = this.getNpcMenuLayout(font)
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
if (!layout) return false
return (
logicalX >= layout.left &&
@ -2184,9 +2276,11 @@ export class WorldPanelsHud {
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!this.npcMenu || this.npcMenu.talking) return
const layout = this.getNpcMenuLayout(font)
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
if (!layout) return
// D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line;
// when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection.
@ -2206,6 +2300,8 @@ export class WorldPanelsHud {
onCancel: () => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
@ -2221,7 +2317,16 @@ export class WorldPanelsHud {
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
const layout = computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
if (
logicalX < layout.left ||
@ -3055,6 +3160,8 @@ export class WorldPanelsHud {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
},
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!this.npcMenu) return
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
@ -3064,8 +3171,8 @@ export class WorldPanelsHud {
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
@ -3092,7 +3199,16 @@ export class WorldPanelsHud {
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
const layout = computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
// 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border
drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg)
@ -3802,6 +3918,8 @@ export class WorldPanelsHud {
hover: { item: UiInventoryItem; x: number; y: number } | null,
font: D2FontRenderer,
vendorOpen = false,
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!hover) return
const { item } = hover
@ -4066,10 +4184,10 @@ export class WorldPanelsHud {
const boxH = lines.length * lineH + padY * 2
const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8
const bx = Math.max(8, Math.min(800 - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, 600 - boxH - 8)
const bx = Math.max(8, Math.min(viewportWidth - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, viewportHeight - boxH - 8)
const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy))
const by = Math.max(topMargin, Math.min(viewportHeight - boxH - 8, rawBy))
this.lastTooltipRect = { bx, by, boxW, boxH }
// Rendering
@ -4090,7 +4208,12 @@ export class WorldPanelsHud {
/**
* Draw Area Entry Gothic Banner (`Entering: <Level Name>` / `进入:<Level Name>`).
*/
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
drawAreaBanner(
ctx: CanvasRenderingContext2D,
nowMs: number,
font: D2FontRenderer,
viewportWidth = 800,
): void {
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
const remain = this.areaBanner.untilMs - nowMs
const alpha = remain < 800 ? remain / 800 : 1
@ -4100,7 +4223,7 @@ export class WorldPanelsHud {
this.lang === 'ENG'
? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}`
: `进入:${this.areaBanner.titleZh}`
font.drawText(ctx, bannerText, 400, 142, {
font.drawText(ctx, bannerText, Math.round(viewportWidth / 2), 142, {
font: 'font30',
color: 'gold',
align: 'center',

View File

@ -8,7 +8,23 @@ import {
} from '../../src/client/settings/client-settings.ts'
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
import { computeDockingLayout, HudManager } from '../../src/client/ui/hud-manager.ts'
import {
computeDockingLayout,
CUBE_GRID_ORIGIN,
fromLeftDockLocal,
fromRightDockLocal,
HudManager,
INV_GRID_ORIGIN,
STASH_GRID_ORIGIN,
toControlBarLocal,
toLeftDockLocal,
toRightDockLocal,
VENDOR_GRID_ORIGIN,
type HudCommandSink,
} from '../../src/client/ui/hud-manager.ts'
import type { UiInventoryItem } from '../../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts'
import {
computeViewportAnchors,
computeViewportFitSize,
@ -98,6 +114,37 @@ const EXPECTED: readonly ExpectedProfile[] = [
function createHudAt(profile: ViewportProfile): HudManager {
const { width, height } = profile
const mockCtx = {
canvas: { width, height },
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
font: '',
textAlign: 'left',
textBaseline: 'alphabetic',
imageSmoothingEnabled: false,
save: () => {},
restore: () => {},
translate: () => {},
scale: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
beginPath: () => {},
closePath: () => {},
moveTo: () => {},
lineTo: () => {},
arc: () => {},
rect: () => {},
clip: () => {},
fill: () => {},
stroke: () => {},
fillText: () => {},
drawImage: () => {},
measureText: (t: string) => ({ width: t.length * 8 }),
createLinearGradient: () => ({ addColorStop: () => {} }),
} as unknown as CanvasRenderingContext2D
const canvas = {
width,
height,
@ -106,6 +153,7 @@ function createHudAt(profile: ViewportProfile): HudManager {
style: { cursor: '', pointerEvents: '' },
addEventListener: () => {},
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
getContext: () => mockCtx,
} as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
@ -337,3 +385,579 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () =>
expect(fit1068).toMatchObject({ cssWidth: 1920, cssHeight: 1079, letterboxLeft: 0, letterboxTop: 0 })
})
})
function makeTestUiItem(id: string, code: string, name: string, w = 1, h = 1): UiInventoryItem {
return {
id,
code,
invFile: code,
name,
nameZh: name,
baseNameZh: name,
quality: 'normal',
invWidth: w,
invHeight: h,
allowedSlots: ['helm'],
stats: [],
}
}
describe('HUD input and hover coordinate translation across all 4 viewport profiles (Issue #542)', () => {
it('converts logical <-> region-local coordinates using active docking offsets across all 4 profiles', () => {
for (const { profile } of EXPECTED) {
const docking = computeDockingLayout(profile.width, profile.height, 1, profile)
const deltaRight = docking.rightDockX - 400
expect(toControlBarLocal(263 + profile.barOffsetX, 582 + profile.barOffsetY, docking)).toEqual({
x: 263,
y: 582,
})
expect(toLeftDockLocal(217 + docking.leftDockX, 153 + profile.panelOffsetY, docking)).toEqual({
x: 217,
y: 153,
})
expect(fromLeftDockLocal(217, 153, docking)).toEqual({
x: 217 + docking.leftDockX,
y: 153 + profile.panelOffsetY,
})
expect(toRightDockLocal(432.5 + deltaRight, 330.5 + profile.panelOffsetY, docking)).toEqual({
x: 432.5,
y: 330.5,
})
expect(fromRightDockLocal(432.5, 330.5, docking)).toEqual({
x: 432.5 + deltaRight,
y: 330.5 + profile.panelOffsetY,
})
}
})
it.each(EXPECTED)(
'translates bottom control bar clicks and hovers ($id: barOffset=($barOffsetX,$barOffsetY))',
({ profile, barOffsetX, barOffsetY }) => {
const hud = createHudAt(profile)
const useBeltSlot = vi.fn(() => true)
hud.commandSink = { useBeltSlot } as unknown as HudCommandSink
// 1. Run/Walk toggle button (canonical 255..271, 572..592 -> center 263, 582)
const runX = 263 + barOffsetX
const runY = 582 + barOffsetY
hud.handleMouseMove(runX, runY)
expect(hud.controlBar.hoveredElement).toBe('run')
const prevRun = hud.isRunning
hud.handleClick(runX, runY, 0)
expect(hud.isRunning).toBe(!prevRun)
// 2. Mini-panel buttons (canonical 312..488, 522..548 -> char at 325, 535; inv at 352, 535)
const charBtnX = 325 + barOffsetX
const charBtnY = 535 + barOffsetY
hud.handleMouseMove(charBtnX, charBtnY)
expect(hud.controlBar.hoveredElement).toBe('char')
hud.handleClick(charBtnX, charBtnY, 0)
expect(hud.leftPanel).toBe('char')
const invBtnX = 352 + barOffsetX
const invBtnY = 535 + barOffsetY
hud.handleMouseMove(invBtnX, invBtnY)
expect(hud.controlBar.hoveredElement).toBe('inv')
hud.handleClick(invBtnX, invBtnY, 0)
expect(hud.rightPanel).toBe('inv')
// 3. Belt slot col 0, row 0 (canonical 423..451, 561..589 -> center 435, 575)
const beltX = 435 + barOffsetX
const beltY = 575 + barOffsetY
hud.handleMouseMove(beltX, beltY)
expect(hud.belt.hoveredSlot).toEqual({ row: 0, col: 0 })
hud.handleClick(beltX, beltY, 2)
expect(useBeltSlot).toHaveBeenCalledWith(0, false)
// 4. Life & Mana globes (canonical centers 68, 555 and 732, 555)
const lifeX = 68 + barOffsetX
const lifeY = 555 + barOffsetY
hud.handleMouseMove(lifeX, lifeY)
expect(hud.globes.lifeHovered).toBe(true)
const prevLifeLock = hud.globes.lifeTextLocked
hud.handleClick(lifeX, lifeY, 0)
expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock)
const manaX = 732 + barOffsetX
const manaY = 555 + barOffsetY
hud.handleMouseMove(manaX, manaY)
expect(hud.globes.manaHovered).toBe(true)
const prevManaLock = hud.globes.manaTextLocked
hud.handleClick(manaX, manaY, 0)
expect(hud.globes.manaTextLocked).toBe(!prevManaLock)
// 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575)
const leftSkillX = 141 + barOffsetX
const leftSkillY = 575 + barOffsetY
hud.handleClick(leftSkillX, leftSkillY, 0)
expect(hud.hotkeys.openPopup).toBe('left')
hud.handleClick(leftSkillX, leftSkillY, 0)
expect(hud.hotkeys.openPopup).toBeNull()
},
)
it.each(EXPECTED)(
'translates left dock clicks and reverse-translates stash/cube/vendor hover tooltips ($id: panelOffsetY=$panelOffsetY)',
({ profile, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const leftDockX = docking.leftDockX
const allocateStat = vi.fn(() => true)
hud.commandSink = { allocateStat } as unknown as HudCommandSink
// 1. Character sheet stat '+' button (STR canonical 202..232, 138..168 -> center 217, 153)
hud.toggleLeftPanel('char')
hud.charSheet.attrs.statPoints = 5
hud.handleClick(217 + leftDockX, 153 + panelOffsetY, 0)
expect(allocateStat).toHaveBeenCalledWith('str')
// Character sheet close button (canonical 208..240, 448..480 -> center 224, 464)
hud.handleClick(224 + leftDockX, 464 + panelOffsetY, 0)
expect(hud.leftPanel).toBe('none')
// Clear commandSink to test offline stash pickup as well
hud.commandSink = null
// 2. Stash grid cell (col 0, row 0) click & hover tooltip writeback
hud.leftPanel = 'stash'
const stashItem = makeTestUiItem('stash-1', 'rin', 'Stone of Jordan')
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
const stashHoverX = STASH_GRID_ORIGIN.x + 14.5 + leftDockX
const stashHoverY = STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(stashHoverX, stashHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: stashItem,
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
y: STASH_GRID_ORIGIN.y + panelOffsetY,
})
// Clicking stash cell (0, 0) picks up the item onto cursor
hud.handleClick(stashHoverX, stashHoverY, 0)
expect(hud.inventory.cursorItem?.id).toBe('stash-1')
hud.inventory.cursorItem = null
// 3. Cube grid cell (col 0, row 0) hover tooltip writeback
hud.leftPanel = 'cube'
const cubeItem = makeTestUiItem('cube-1', 'rvl', 'Full Rejuvenation Potion')
hud.worldPanels.cubeItems = [{ item: cubeItem, col: 0, row: 0 }]
const cubeHoverX = CUBE_GRID_ORIGIN.x + 14.5 + leftDockX
const cubeHoverY = CUBE_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(cubeHoverX, cubeHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: cubeItem,
x: CUBE_GRID_ORIGIN.x + 14.5 + leftDockX,
y: CUBE_GRID_ORIGIN.y + panelOffsetY,
})
// 4. Vendor grid cell (col 0, row 0) hover tooltip writeback
const akara = getTownNpcDescriptorByName('Akara')!
const vendorItem = makeTestUiItem('vendor-1', 'cap', 'Cap', 2, 2)
hud.worldPanels.setVendorShop({
npcId: 1,
descriptor: akara,
mode: 'trade',
items: [{ storePage: 1, placement: { item: vendorItem, col: 0, row: 0 } }],
})
hud.leftPanel = 'vendor'
const vendorHoverX = VENDOR_GRID_ORIGIN.x + 14.5 + leftDockX
const vendorHoverY = VENDOR_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(vendorHoverX, vendorHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: vendorItem,
x: VENDOR_GRID_ORIGIN.x + 29 + leftDockX,
y: VENDOR_GRID_ORIGIN.y + panelOffsetY,
})
},
)
it.each(EXPECTED)(
'translates right dock inventory/equipment/skill-tree clicks and hover tooltips ($id: panelOffsetY=$panelOffsetY)',
({ profile, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const deltaRight = docking.rightDockX - 400
// 1. Inventory grid cell (col 0, row 0) hover & click at docked right panel coordinates
hud.toggleRightPanel('inv')
const bagItem = makeTestUiItem('bag-1', 'rin', 'Bul-Kathos Ring')
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
const invHoverX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const invHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(invHoverX, invHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: bagItem,
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
y: INV_GRID_ORIGIN.y + panelOffsetY,
})
// Clicking at the docked right-panel coordinate picks up the item
hud.handleClick(invHoverX, invHoverY, 0)
expect(hud.inventory.cursorItem?.id).toBe('bag-1')
hud.inventory.cursorItem = null
// 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64)
const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2)
hud.inventory.equipped.helm = helmItem
hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY)
expect(hud.inventory.hoveredItem).toEqual({
item: helmItem,
x: 561 + deltaRight,
y: 64 + panelOffsetY,
})
// 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600)
hud.inventory.gridItems = [{ item: bagItem, col: 9, row: 0 }]
const rightColHoverX = INV_GRID_ORIGIN.x + 9 * 29 + 14.5 + deltaRight
const rightColHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(rightColHoverX, rightColHoverY)
const mockCtx = {
fillStyle: '',
strokeStyle: '',
save: () => {},
restore: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
measureText: () => ({ width: 50 }),
} as unknown as CanvasRenderingContext2D
hud.worldPanels.drawItemTooltip(
mockCtx,
hud.inventory.hoveredItem,
hud.font,
false,
profile.width,
profile.height,
)
expect(hud.worldPanels.lastTooltipRect).not.toBeNull()
const tipRect = hud.worldPanels.lastTooltipRect!
expect(tipRect.bx + tipRect.boxW).toBeLessThanOrEqual(profile.width - 8)
if (profile.width > 800) {
expect(tipRect.bx + tipRect.boxW).toBeGreaterThan(800)
}
// 3. Skill tree node hover & click at docked right panel coordinates
hud.toggleRightPanel('skill')
hud.skillTree.setClass('sor')
hud.skillTree.characterLevel = 30
hud.skillTree.unspentSkillPoints = 5
const allocateSkill = vi.fn(() => true)
hud.commandSink = { allocateSkill } as unknown as HudCommandSink
const firstTabNode = hud.skillTree
.getSkillTree()
.find((n) => n.tab === hud.skillTree.activeTab && n.prereqs.length === 0)!
const nodeRect = SkillTreePanel.nodeScreenPos(firstTabNode)
const nodeClickX = nodeRect.x + nodeRect.w / 2 + deltaRight
const nodeClickY = nodeRect.y + nodeRect.h / 2 + panelOffsetY
hud.handleMouseMove(nodeClickX, nodeClickY)
expect(hud.skillTree.hoveredSkill?.skillId).toBe(firstTabNode.skillId)
hud.handleClick(nodeClickX, nodeClickY, 0)
expect(allocateSkill).toHaveBeenCalledWith(firstTabNode.skillId)
},
)
it('does not trigger right-dock or bottom-bar actions when clicking old un-offset 800x600 coordinates in 1024x768 and 1280x720', () => {
for (const profile of [VIEWPORT_1024x768, VIEWPORT_1280x720]) {
const hud = createHudAt(profile)
hud.toggleRightPanel('inv')
const bagItem = makeTestUiItem('bag-corridor', 'rin', 'Ring')
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
// Un-offset (432.5, 330.5) falls inside the open center corridor (400..624 in 1024x768, 400..880 in 1280x720)
expect(hud.isPointInterceptedByHud(432.5, 330.5)).toBe(false)
hud.handleClick(432.5, 330.5, 0)
expect(hud.inventory.cursorItem).toBeNull()
// Un-offset Run button (263, 582) is above the bottom bar in 1024x768 (bar y=708..768) and 1280x720 (bar y=660..720)
const prevRun = hud.isRunning
hud.handleClick(263, 582, 0)
expect(hud.isRunning).toBe(prevRun)
}
})
it.each(EXPECTED)(
'translates window mousemove listener hover coordinates and pointer-events across $id',
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
const prevWindow = (globalThis as { window?: unknown }).window
const mockWindow = Object.assign(new EventTarget(), {
innerWidth: profile.width,
innerHeight: profile.height,
devicePixelRatio: 1,
})
;(globalThis as { window?: unknown }).window = mockWindow
try {
const hud = createHudAt(profile)
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
const docking = hud.getDockingLayout()
const deltaRight = docking.rightDockX - 400
const leftDockX = docking.leftDockX
hud.toggleRightPanel('inv')
hud.leftPanel = 'stash'
const bagItem = makeTestUiItem('win-bag-1', 'rin', 'Raven Frost')
const stashItem = makeTestUiItem('win-stash-1', 'amu', 'Highlord Wrath')
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
// Dispatch window mousemove over right-dock inventory item
const invClient = hud.logicalToClient(
INV_GRID_ORIGIN.x + 14.5 + deltaRight,
INV_GRID_ORIGIN.y + 14.5 + panelOffsetY,
)
const invEv = Object.assign(new Event('mousemove'), {
clientX: invClient.x,
clientY: invClient.y,
})
mockWindow.dispatchEvent(invEv)
expect(hud.inventory.hoveredItem).toEqual({
item: bagItem,
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
y: INV_GRID_ORIGIN.y + panelOffsetY,
})
expect(hudCanvas.style.pointerEvents).toBe('auto')
// Dispatch window mousemove over left-dock stash item
const stashClient = hud.logicalToClient(
STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY,
)
const stashEv = Object.assign(new Event('mousemove'), {
clientX: stashClient.x,
clientY: stashClient.y,
})
mockWindow.dispatchEvent(stashEv)
expect(hud.inventory.hoveredItem).toEqual({
item: stashItem,
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
y: STASH_GRID_ORIGIN.y + panelOffsetY,
})
expect(hudCanvas.style.pointerEvents).toBe('auto')
// Dispatch window mousemove over bottom bar Run/Walk toggle button
const runClient = hud.logicalToClient(263 + barOffsetX, 582 + barOffsetY)
const runEv = Object.assign(new Event('mousemove'), {
clientX: runClient.x,
clientY: runClient.y,
})
mockWindow.dispatchEvent(runEv)
expect(hud.controlBar.hoveredElement).toBe('run')
expect(hudCanvas.style.pointerEvents).toBe('auto')
} finally {
if (prevWindow === undefined) {
delete (globalThis as { window?: unknown }).window
} else {
;(globalThis as { window?: unknown }).window = prevWindow
}
}
},
)
})
describe('Skill tooltips and full HUD UI audit across >800x600 viewports (Issue #557)', () => {
it.each(EXPECTED)(
'synchronizes skillTree.visible on direct rightPanel assignment and renders SkillTreePanel & Hotkeys skill tooltips on top layer ($id)',
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const deltaRight = docking.rightDockX - 400
// 1. Direct assignment (as done by HudModel.syncFromWorld) must keep skillTree.visible in sync
hud.rightPanel = 'skill'
expect(hud.skillTree.visible).toBe(true)
hud.skillTree.setClass('sor')
hud.skillTree.characterLevel = 30
hud.skillTree.hardPoints.set(36, 5) // Fire Bolt
const col1Node = hud.skillTree
.getSkillTree()
.find((n) => n.tab === hud.skillTree.activeTab && n.col === 1)!
const nodeRect = SkillTreePanel.nodeScreenPos(col1Node)
hud.handleMouseMove(
nodeRect.x + nodeRect.w / 2 + deltaRight,
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
)
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
// Verify tooltip placement with minLocalX = 8 - deltaRight does not clamp over col-1 node on >800x600
const placement = SkillTreePanel.computeTooltipPlacement(
nodeRect,
[{ text: 'Fire Bolt Level 5 (Mana Cost: 2.5)', color: 'gold', font: 'font8' }],
hud.font,
8 - deltaRight,
)
if (deltaRight > 0) {
expect(placement.x + placement.w).toBeLessThanOrEqual(nodeRect.x)
}
// 2. Quickbar active skill slot hover & Speedbar popup skill hover at translated barOffset
hud.hotkeys.setAvailableSkills(
[
{
skillId: 0,
name: 'Attack',
nameZh: '攻击',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
},
{
skillId: 36,
name: 'Fire Bolt',
nameZh: '火弹',
level: 5,
manaCost: 3,
leftUsable: true,
rightUsable: true,
},
{
skillId: 38,
name: 'Charged Bolt',
nameZh: '充能弹',
level: 3,
manaCost: 4,
leftUsable: true,
rightUsable: true,
},
],
36,
38,
)
const leftSlotX = 141 + barOffsetX
const leftSlotY = 575 + barOffsetY
hud.handleMouseMove(leftSlotX, leftSlotY)
expect(hud.hotkeys.hoveredSlot).toBe('left')
expect(hud.hotkeys.hoverSkillId).toBe(36)
// Open left speedbar popup and hover its first skill cell
hud.handleClick(leftSlotX, leftSlotY, 0)
expect(hud.hotkeys.openPopup).toBe('left')
const popupCells = hud.hotkeys.getPopupCells('left')
expect(popupCells.length).toBeGreaterThan(0)
const firstCell = popupCells[0]!
hud.handleMouseMove(firstCell.x + 24 + barOffsetX, firstCell.y + 24 + barOffsetY)
expect(hud.hotkeys.hoveredPopupSkill).toEqual({
side: 'left',
skillId: firstCell.skill.skillId,
})
expect(hud.hotkeys.hoverSpeedbarSkillId).toBe(firstCell.skill.skillId)
// 3. Verify HudManager.render invokes hotkeys.draw AFTER belt.draw, and draws skillTree tooltip on top layer
const callOrder: string[] = []
const origBeltDraw = hud.belt.draw.bind(hud.belt)
const origHotkeysDraw = hud.hotkeys.draw.bind(hud.hotkeys)
const origSkillTooltipDraw = hud.skillTree.drawSkillTooltip.bind(hud.skillTree)
vi.spyOn(hud.belt, 'draw').mockImplementation((...args) => {
callOrder.push('belt.draw')
return origBeltDraw(...args)
})
vi.spyOn(hud.hotkeys, 'draw').mockImplementation((...args) => {
callOrder.push('hotkeys.draw')
return origHotkeysDraw(...args)
})
vi.spyOn(hud.skillTree, 'drawSkillTooltip').mockImplementation((...args) => {
callOrder.push('skillTree.drawSkillTooltip')
return origSkillTooltipDraw(...args)
})
// Hover the skill tree node again before rendering so skillTree.hoveredSkill is active during render()
hud.handleMouseMove(
nodeRect.x + nodeRect.w / 2 + deltaRight,
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
)
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
hud.render(1000)
expect(callOrder.indexOf('belt.draw')).toBeGreaterThanOrEqual(0)
expect(callOrder.indexOf('hotkeys.draw')).toBeGreaterThan(callOrder.indexOf('belt.draw'))
expect(callOrder.indexOf('skillTree.drawSkillTooltip')).toBeGreaterThan(
callOrder.indexOf('hotkeys.draw'),
)
},
)
it.each(EXPECTED)(
'translates Mercenary, Trade, and Quest Reward left-dock item hover tooltips and clamps NPC menu / Area Banner / Death Overlay ($id)',
({ profile, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const leftDockX = docking.leftDockX
// 1. Mercenary equipment hover tooltip (helm slot: x=212..268, y=104..160 -> center 240, 132)
hud.leftPanel = 'merc'
const mercHelm = makeTestUiItem('merc-helm', 'usk', 'Andariel Visage', 2, 2)
hud.worldPanels.merc = { headItem: mercHelm }
hud.handleMouseMove(240 + leftDockX, 132 + panelOffsetY)
expect(hud.inventory.hoveredItem).toEqual({
item: mercHelm,
x: 240 + leftDockX,
y: 104 + panelOffsetY,
})
// 2. Quest Reward (imbue) slot hover tooltip (x=196..284, y=180..296 -> center 240, 238)
hud.leftPanel = 'imbue'
const imbueItem = makeTestUiItem('imbue-boots', 'xtb', 'War Boots', 2, 2)
hud.worldPanels.questRewardItem = imbueItem
hud.handleMouseMove(240 + leftDockX, 238 + panelOffsetY)
expect(hud.inventory.hoveredItem).toEqual({
item: imbueItem,
x: 240 + leftDockX,
y: 180 + panelOffsetY,
})
// 3. NPC interaction menu clamps against active viewport width/height (not hardcoded 800x600)
const akara = getTownNpcDescriptorByName('Akara')!
hud.worldPanels.openNpcMenu(akara, profile.width - 60, 220, 1)
expect(
hud.worldPanels.isPointInNpcMenu(
profile.width - 60,
240,
hud.font,
profile.width,
profile.height,
),
).toBe(true)
expect(hud.isPointInterceptedByHud(profile.width - 60, 240)).toBe(true)
hud.worldPanels.npcMenu = null
// 4. Area Banner & Death Overlay center across profile.width / profile.height
hud.leftPanel = 'none'
hud.rightPanel = 'none'
hud.worldPanels.areaBanner = {
titleZh: '鲜血荒地',
titleEn: 'Blood Moor',
untilMs: 5000,
}
hud.isDead = true
const fillRectCalls: Array<[number, number, number, number]> = []
const drawTextXs: number[] = []
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
const hudCtx = hudCanvas.getContext('2d')!
const origFillRect = hudCtx.fillRect.bind(hudCtx)
vi.spyOn(hudCtx, 'fillRect').mockImplementation((x, y, w, h) => {
fillRectCalls.push([x, y, w, h])
return origFillRect(x, y, w, h)
})
vi.spyOn(hud.font, 'drawText').mockImplementation((_ctx, _text, x) => {
drawTextXs.push(x)
})
hud.render(1500)
// Death overlay must cover (0, 0, profile.width, profile.height)
expect(fillRectCalls).toContainEqual([0, 0, profile.width, profile.height])
// Area banner and Death overlay text must center at Math.round(profile.width / 2)
expect(drawTextXs).toContain(Math.round(profile.width / 2))
},
)
})