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:
parent
9e749a21cf
commit
e31e1b561a
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue