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:
troytt 2026-09-24 13:06:15 +00:00
parent af2671c14a
commit e3a7918a16
7 changed files with 221 additions and 16 deletions

View File

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

View File

@ -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, [])
}

View File

@ -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",

View File

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

View File

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

View File

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

View File

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