fix(ground): hover ground item reveals label & fix grand charm flippy sprite (Fixes #447, Fixes #452)
- Hover ground item icon reveals authentic name label without needing Alt key (Fixes #447) - Connect SceneMouseController pointermove/pointerleave with GroundLabelOverlay hover callback and ladder layout rendering - Correct Grand Charm (cm3) and Gheed's Fortune ground flippy sprite mapping to flpchm3 (Fixes #452) - Correct Small Charm (cm1) and Large Charm (cm2) default flippy mappings to flpchm1 and flpchm2 - Add 1.13c charm ground flippy resolution tier and Chinese semantic fallbacks in resolveGroundItemSpriteRect - Add comprehensive vitest suites in tests/charm-ground-flippy-parity.test.ts and tests/ground-items-alt-labels.test.ts
This commit is contained in:
parent
af2671c14a
commit
e3a7918a16
|
|
@ -736,11 +736,11 @@ async function main(): Promise<void> {
|
|||
codeToFlippyFile['gold'] = 'flpgld'
|
||||
codeToFlippyFile['box'] = 'flpbox'
|
||||
codeToFlippyFile['Horadric Cube'] = 'flpbox'
|
||||
codeToFlippyFile['cm1'] = 'flpmss'
|
||||
codeToFlippyFile['cm2'] = 'flptrch'
|
||||
codeToFlippyFile['cm3'] = 'flpmss'
|
||||
codeToFlippyFile['gheeds'] = 'flpmss'
|
||||
codeToFlippyFile["Gheed's Fortune"] = 'flpmss'
|
||||
codeToFlippyFile['cm1'] = 'flpchm1'
|
||||
codeToFlippyFile['cm2'] = 'flpchm2'
|
||||
codeToFlippyFile['cm3'] = 'flpchm3'
|
||||
codeToFlippyFile['gheeds'] = 'flpchm3'
|
||||
codeToFlippyFile["Gheed's Fortune"] = 'flpchm3'
|
||||
codeToFlippyFile['anni'] = 'flpmss'
|
||||
codeToFlippyFile['Annihilus'] = 'flpmss'
|
||||
codeToFlippyFile['torch'] = 'flptrch'
|
||||
|
|
|
|||
|
|
@ -2896,6 +2896,7 @@ export class SceneMouseController {
|
|||
isLeftMouseDown = false
|
||||
isRightMouseDown = false
|
||||
lastClientPos: { x: number; y: number } | null = null
|
||||
hoveredGroundItem: GroundItemEntity | null = null
|
||||
|
||||
private stuckTicks = 0
|
||||
private lastDist = -1
|
||||
|
|
@ -2948,6 +2949,9 @@ export class SceneMouseController {
|
|||
if (dist <= 48) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
if (this.hoveredGroundItem?.id === item.id) {
|
||||
this.hoveredGroundItem = null
|
||||
}
|
||||
player.facing = facingOf(item.x - player.x, item.y - player.y)
|
||||
|
||||
if (item.isGold) {
|
||||
|
|
@ -3159,22 +3163,32 @@ export class SceneMouseController {
|
|||
if (this.hudManager !== null) {
|
||||
const logical = this.hudManager.clientToLogical(e.clientX, e.clientY)
|
||||
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
|
||||
this.hoveredGroundItem = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
||||
this.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null
|
||||
|
||||
if (this.isLeftMouseDown && !this.input.shiftHeld && this.activeClickType === 'ground') {
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
} else if (this.isRightMouseDown) {
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
if (isPassiveSkill(rightSkillId) || isAuraSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) {
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly onPointerLeave = (e: PointerEvent): void => {
|
||||
const target = e.relatedTarget as HTMLElement | null
|
||||
if (target && target.closest?.('.d2-ground-label')) {
|
||||
return
|
||||
}
|
||||
this.hoveredGroundItem = null
|
||||
}
|
||||
|
||||
readonly onPointerUp = (e: PointerEvent): void => {
|
||||
if (e.button === 0) {
|
||||
this.isLeftMouseDown = false
|
||||
|
|
@ -3437,6 +3451,7 @@ export class SceneMouseController {
|
|||
this.isLeftMouseDown = false
|
||||
this.isRightMouseDown = false
|
||||
this.lastClientPos = null
|
||||
this.hoveredGroundItem = null
|
||||
this.stuckTicks = 0
|
||||
this.lastDist = -1
|
||||
;(this.engine as any).dialog = null
|
||||
|
|
@ -3625,6 +3640,7 @@ export class SceneMouseController {
|
|||
this.canvas.addEventListener('contextmenu', this.onContextMenu)
|
||||
this.canvas.addEventListener('pointerdown', this.onPointerDown)
|
||||
this.canvas.addEventListener('pointermove', this.onPointerMove)
|
||||
this.canvas.addEventListener('pointerleave', this.onPointerLeave)
|
||||
this.canvas.addEventListener('pointerup', this.onPointerUp)
|
||||
this.canvas.addEventListener('pointercancel', this.onPointerUp)
|
||||
|
||||
|
|
@ -3638,6 +3654,7 @@ export class SceneMouseController {
|
|||
this.canvas.removeEventListener('contextmenu', this.onContextMenu)
|
||||
this.canvas.removeEventListener('pointerdown', this.onPointerDown)
|
||||
this.canvas.removeEventListener('pointermove', this.onPointerMove)
|
||||
this.canvas.removeEventListener('pointerleave', this.onPointerLeave)
|
||||
this.canvas.removeEventListener('pointerup', this.onPointerUp)
|
||||
this.canvas.removeEventListener('pointercancel', this.onPointerUp)
|
||||
|
||||
|
|
@ -5855,6 +5872,9 @@ if (typeof window !== 'undefined') {
|
|||
groundLabelOverlay.setOnPickupCallback((item) => {
|
||||
mouseController.pickupGroundItem(item)
|
||||
})
|
||||
groundLabelOverlay.setOnHoverCallback((item) => {
|
||||
mouseController.hoveredGroundItem = item
|
||||
})
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__d2webMouseController = mouseController
|
||||
|
|
@ -6984,7 +7004,13 @@ if (typeof window !== 'undefined') {
|
|||
|
||||
if (groundLabelOverlay !== null) {
|
||||
const showLabels = Boolean(hudManager?.showGroundLabels)
|
||||
if (showLabels && engine.groundItems.count > 0) {
|
||||
let hoveredItem = mouseController?.hoveredGroundItem ?? null
|
||||
if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) {
|
||||
if (mouseController) mouseController.hoveredGroundItem = null
|
||||
hoveredItem = null
|
||||
}
|
||||
|
||||
if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
|
||||
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
|
||||
|
|
@ -6996,9 +7022,14 @@ if (typeof window !== 'undefined') {
|
|||
}
|
||||
return { x: domX, y: domY }
|
||||
}
|
||||
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
|
||||
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
|
||||
groundLabelOverlay.update(true, resolvedLayouts)
|
||||
if (showLabels) {
|
||||
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
|
||||
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
|
||||
groundLabelOverlay.update(true, resolvedLayouts)
|
||||
} else if (hoveredItem !== null) {
|
||||
const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen)
|
||||
groundLabelOverlay.update(true, initialLayouts)
|
||||
}
|
||||
} else {
|
||||
groundLabelOverlay.update(false, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2586,9 +2586,9 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
"skl": "flpskl",
|
||||
"skz": "flpskl",
|
||||
"hrb": "flphrb",
|
||||
"cm1": "flpmss",
|
||||
"cm2": "flptrch",
|
||||
"cm3": "flpmss",
|
||||
"cm1": "flpchm1",
|
||||
"cm2": "flpchm2",
|
||||
"cm3": "flpchm3",
|
||||
"rps": "flprps",
|
||||
"rpl": "flprpl",
|
||||
"bps": "flpbps",
|
||||
|
|
@ -3294,7 +3294,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
"flpci1": "flpci1",
|
||||
"Ormus' Robes": "flpqlt_blac",
|
||||
"flpqlt_blac": "flpqlt_blac",
|
||||
"Gheed's Fortune": "flpmss",
|
||||
"Gheed's Fortune": "flpchm3",
|
||||
"Stormlash": "flpfla_dgry",
|
||||
"flpfla_dgry": "flpfla_dgry",
|
||||
"Halaberd's Reign": "flpba5",
|
||||
|
|
@ -3565,7 +3565,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
"Sander's Superstition": "flpbwn",
|
||||
"gold": "flpgld",
|
||||
"Horadric Cube": "flpbox",
|
||||
"gheeds": "flpmss",
|
||||
"gheeds": "flpchm3",
|
||||
"anni": "flpmss",
|
||||
"torch": "flptrch",
|
||||
"flphax": "flphax",
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export class GroundLabelOverlay {
|
|||
private readonly container: HTMLElement
|
||||
private readonly labelPool: HTMLElement[] = []
|
||||
private onPickupCallback: ((item: GroundItemEntity) => void) | null = null
|
||||
private onHoverCallback: ((item: GroundItemEntity | null) => void) | null = null
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container
|
||||
|
|
@ -259,6 +260,13 @@ export class GroundLabelOverlay {
|
|||
this.onPickupCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback when a ground item label is hovered or unhovered.
|
||||
*/
|
||||
setOnHoverCallback(callback: (item: GroundItemEntity | null) => void): void {
|
||||
this.onHoverCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update and render ground item labels based on current visible items and Alt key state.
|
||||
*/
|
||||
|
|
@ -295,10 +303,17 @@ export class GroundLabelOverlay {
|
|||
el.addEventListener('mouseenter', () => {
|
||||
el.style.backgroundColor = 'rgba(28, 28, 48, 0.96)'
|
||||
el.style.borderColor = '#ffffff'
|
||||
const currentItem = (el as any).__groundItem as GroundItemEntity | undefined
|
||||
if (currentItem && this.onHoverCallback) {
|
||||
this.onHoverCallback(currentItem)
|
||||
}
|
||||
})
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.style.backgroundColor = 'rgba(0, 0, 0, 0.88)'
|
||||
el.style.borderColor = el.dataset.qualityColor || '#404040'
|
||||
if (this.onHoverCallback) {
|
||||
this.onHoverCallback(null)
|
||||
}
|
||||
})
|
||||
el.addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export const INV_CLOSE_BTN_BOUNDS = { x: 400 + 18, y: 60 + 385, w: 32, h: 32 } a
|
|||
export interface ResolvableItem {
|
||||
readonly code?: string | undefined
|
||||
readonly name?: string | undefined
|
||||
readonly nameZh?: string | undefined
|
||||
readonly baseNameZh?: string | undefined
|
||||
readonly invFile?: string | undefined
|
||||
readonly flippyFile?: string | undefined
|
||||
readonly isGold?: boolean | undefined
|
||||
|
|
@ -397,6 +399,20 @@ export function resolveGroundItemSpriteRect(
|
|||
return flippyRects[`flpgld_${pileIndex}`] ?? flippyRects['flpgld'] ?? null
|
||||
}
|
||||
|
||||
// 1.5 Authentic Charm Ground Flippy Resolution (cm1 -> flpchm1, cm2 -> flpchm2, cm3 -> flpchm3)
|
||||
const normCode = normalizeItemCode(item)
|
||||
const isAnni = normCode === 'anni' || (typeof item.name === 'string' && item.name.includes('Annihilus'))
|
||||
const isTorch = normCode === 'torch' || (typeof item.name === 'string' && item.name.includes('Torch'))
|
||||
if (normCode === 'cm3' || normCode === 'gheeds' || (typeof item.name === 'string' && item.name.includes('Gheed'))) {
|
||||
return flippyRects['flpchm3'] ?? null
|
||||
}
|
||||
if (normCode === 'cm2' || isTorch) {
|
||||
return isTorch ? (flippyRects['flptrch'] ?? flippyRects['flpchm2'] ?? null) : (flippyRects['flpchm2'] ?? null)
|
||||
}
|
||||
if (normCode === 'cm1' || isAnni) {
|
||||
return isAnni ? (flippyRects['flpmss'] ?? flippyRects['flpchm1'] ?? null) : (flippyRects['flpchm1'] ?? null)
|
||||
}
|
||||
|
||||
// 2. Direct flippyFile property
|
||||
if ((item as any).flippyFile) {
|
||||
const flpFile = String((item as any).flippyFile).toLowerCase()
|
||||
|
|
@ -472,6 +488,10 @@ export function resolveGroundItemSpriteRect(
|
|||
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return flippyRects['flplbl'] ?? flippyRects['flphbl'] ?? null
|
||||
if (nameStr.includes('rune') || nameStr.includes('符文')) return flippyRects['flprun'] ?? null
|
||||
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return flippyRects['flptsc'] ?? flippyRects['flprsc'] ?? null
|
||||
if (nameStr.includes('grand charm') || nameStr.includes('超大护身符') || nameStr.includes('超大型护身符')) return flippyRects['flpchm3'] ?? null
|
||||
if (nameStr.includes('large charm') || nameStr.includes('大型护身符')) return flippyRects['flpchm2'] ?? null
|
||||
if (nameStr.includes('small charm') || nameStr.includes('小型护身符') || nameStr.includes('小护身符')) return flippyRects['flpchm1'] ?? null
|
||||
if (nameStr.includes('charm') || nameStr.includes('护身符')) return flippyRects['flpchm1'] ?? null
|
||||
|
||||
// 7. Fallback to 2D inventory item sprite rect if no flippy found
|
||||
return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* tests/charm-ground-flippy-parity.test.ts
|
||||
*
|
||||
* Regression test for Issue #452:
|
||||
* Grand Charm (cm3 / 超大护身符) ground sprite must resolve to flpchm3 (21x6 px),
|
||||
* NOT flpmss (Annihilus / Soulstone, 9x4 px).
|
||||
*
|
||||
* Authentic 1.13c Charm Ground Flippy Parity:
|
||||
* - cm1 (Small Charm): flpchm1 (13x5 px)
|
||||
* - Annihilus (anni / Annihilus): flpmss (9x4 px)
|
||||
* - cm2 (Large Charm): flpchm2 (13x6 px)
|
||||
* - Hellfire Torch (torch / Hellfire Torch): flptrch
|
||||
* - cm3 (Grand Charm): flpchm3 (21x6 px)
|
||||
* - Gheed's Fortune (gheeds / Gheed's Fortune): flpchm3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
|
||||
describe('Charm Ground Flippy Sprite Resolution Parity (Issue #452)', () => {
|
||||
const flippyRects = BAKED_UI_MANIFEST.flippyRects
|
||||
const flpchm1 = flippyRects['flpchm1']
|
||||
const flpchm2 = flippyRects['flpchm2']
|
||||
const flpchm3 = flippyRects['flpchm3']
|
||||
const flpmss = flippyRects['flpmss']
|
||||
const flptrch = flippyRects['flptrch']
|
||||
|
||||
it('verifies flippy sprite assets exist in manifest', () => {
|
||||
expect(flpchm1).toBeDefined()
|
||||
expect(flpchm2).toBeDefined()
|
||||
expect(flpchm3).toBeDefined()
|
||||
expect(flpmss).toBeDefined()
|
||||
expect(flpchm3?.w).toBe(21)
|
||||
expect(flpchm3?.h).toBe(6)
|
||||
})
|
||||
|
||||
it('resolves normal Grand Charm (cm3) to flpchm3 and NOT flpmss', () => {
|
||||
const item = { code: 'cm3', name: 'Grand Charm', quality: 'magic' }
|
||||
const rect = resolveGroundItemSpriteRect(item)
|
||||
expect(rect).toEqual(flpchm3)
|
||||
expect(rect).not.toEqual(flpmss)
|
||||
})
|
||||
|
||||
it("resolves Gheed's Fortune unique Grand Charm to flpchm3 and NOT flpmss", () => {
|
||||
const item = {
|
||||
code: 'cm3',
|
||||
name: "Gheed's Fortune",
|
||||
nameZh: "基德的运气 (Gheed's Fortune)",
|
||||
baseNameZh: '超大型护身符 (Grand Charm)',
|
||||
quality: 'unique',
|
||||
}
|
||||
const rect = resolveGroundItemSpriteRect(item)
|
||||
expect(rect).toEqual(flpchm3)
|
||||
expect(rect).not.toEqual(flpmss)
|
||||
})
|
||||
|
||||
it('resolves normal Small Charm (cm1) to flpchm1 and NOT flpmss', () => {
|
||||
const item = { code: 'cm1', name: 'Small Charm', quality: 'magic' }
|
||||
const rect = resolveGroundItemSpriteRect(item)
|
||||
expect(rect).toEqual(flpchm1)
|
||||
expect(rect).not.toEqual(flpmss)
|
||||
})
|
||||
|
||||
it('resolves Annihilus unique Small Charm to flpmss', () => {
|
||||
const item = {
|
||||
code: 'cm1',
|
||||
name: 'Annihilus',
|
||||
nameZh: '毁灭 (Annihilus)',
|
||||
quality: 'unique',
|
||||
}
|
||||
const rect = resolveGroundItemSpriteRect(item)
|
||||
expect(rect).toEqual(flpmss)
|
||||
})
|
||||
|
||||
it('resolves normal Large Charm (cm2) to flpchm2', () => {
|
||||
const item = { code: 'cm2', name: 'Large Charm', quality: 'magic' }
|
||||
const rect = resolveGroundItemSpriteRect(item)
|
||||
expect(rect).toEqual(flpchm2)
|
||||
})
|
||||
|
||||
it('resolves Hellfire Torch unique Large Charm to flptrch', () => {
|
||||
const item = {
|
||||
code: 'cm2',
|
||||
name: 'Hellfire Torch',
|
||||
nameZh: '地狱火炬 (Hellfire Torch)',
|
||||
quality: 'unique',
|
||||
}
|
||||
const rect = resolveGroundItemSpriteRect(item)
|
||||
expect(rect).toEqual(flptrch)
|
||||
})
|
||||
|
||||
it('resolves semantic Chinese fallback names for charms correctly', () => {
|
||||
expect(resolveGroundItemSpriteRect({ name: '超大护身符' })).toEqual(flpchm3)
|
||||
expect(resolveGroundItemSpriteRect({ nameZh: '超大型护身符' })).toEqual(flpchm3)
|
||||
expect(resolveGroundItemSpriteRect({ name: '大型护身符' })).toEqual(flpchm2)
|
||||
expect(resolveGroundItemSpriteRect({ name: '小型护身符' })).toEqual(flpchm1)
|
||||
})
|
||||
})
|
||||
|
|
@ -469,5 +469,45 @@ describe('Ground Items Alt Labels (Issue #390)', () => {
|
|||
el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() })
|
||||
expect(onPickup).toHaveBeenCalledWith(uniqueItem)
|
||||
})
|
||||
|
||||
it('triggers onHoverCallback on mouseenter and mouseleave for hovered labels (Issue #447)', () => {
|
||||
const container = createMockDomElement('div')
|
||||
const overlay = new GroundLabelOverlay(container)
|
||||
const onHover = vi.fn()
|
||||
overlay.setOnHoverCallback(onHover)
|
||||
|
||||
const magicItem = createMockItem({
|
||||
id: 'ring-1',
|
||||
name: 'Ring of the Apprentice',
|
||||
nameZh: '学徒之戒',
|
||||
quality: 'magic',
|
||||
})
|
||||
|
||||
const layout: GroundLabelLayout = {
|
||||
id: 'ring-1',
|
||||
itemId: 'ring-1',
|
||||
item: magicItem,
|
||||
text: '学徒之戒',
|
||||
color: GROUND_LABEL_QUALITY_COLORS.magic!,
|
||||
borderColor: GROUND_LABEL_QUALITY_COLORS.magic!,
|
||||
width: 70,
|
||||
height: 18,
|
||||
screenX: 200,
|
||||
screenY: 100,
|
||||
naturalY: 100,
|
||||
}
|
||||
|
||||
overlay.update(true, [layout])
|
||||
const el = container.children[0]
|
||||
|
||||
// Mouseenter
|
||||
el.dispatchEvent({ type: 'mouseenter' })
|
||||
expect(onHover).toHaveBeenCalledWith(magicItem)
|
||||
expect(el.style.borderColor).toBe('#ffffff')
|
||||
|
||||
// Mouseleave
|
||||
el.dispatchEvent({ type: 'mouseleave' })
|
||||
expect(onHover).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue