From e31e1b561a731b7fc8e99b611d4032f6fedafde1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:50:11 +0000 Subject: [PATCH] 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 --- src/client/input/command-mapper.ts | 4 +- src/client/ui/hud-manager.ts | 408 ++++++++---- src/client/ui/skill-tree-panel.ts | 10 +- src/client/ui/world-panels.ts | 163 ++++- .../client/viewport-formula-profiles.test.ts | 626 +++++++++++++++++- 5 files changed, 1060 insertions(+), 151 deletions(-) diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 4a32e04..5db4709 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -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 } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 592a77c..7904295 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -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, +): { 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, +): { 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, +): { 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, +): { 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, +): { 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: `, 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', diff --git a/src/client/ui/skill-tree-panel.ts b/src/client/ui/skill-tree-panel.ts index 1ee8bdf..01837d6 100644 --- a/src/client/ui/skill-tree-panel.ts +++ b/src/client/ui/skill-tree-panel.ts @@ -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) diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index e2801a5..94ddaab 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -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 | 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 | null): NpcDialogLayout | null { + getNpcMenuLayout( + font?: Pick | 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 | 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 | 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 | 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: ` / `进入:`). */ - 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', diff --git a/tests/client/viewport-formula-profiles.test.ts b/tests/client/viewport-formula-profiles.test.ts index c97a9e7..57c1b00 100644 --- a/tests/client/viewport-formula-profiles.test.ts +++ b/tests/client/viewport-formula-profiles.test.ts @@ -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)) + }, + ) +}) + +