diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts index 8c37506..413a472 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -736,11 +736,11 @@ async function main(): Promise { 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' diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 83d5cb7..fb42857 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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, []) } diff --git a/src/ui/baked-ui-meta.ts b/src/ui/baked-ui-meta.ts index 1b2ee7d..d4148e7 100644 --- a/src/ui/baked-ui-meta.ts +++ b/src/ui/baked-ui-meta.ts @@ -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", diff --git a/src/ui/ground-labels.ts b/src/ui/ground-labels.ts index 2f2a3cd..ccdd837 100644 --- a/src/ui/ground-labels.ts +++ b/src/ui/ground-labels.ts @@ -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() diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 3613148..3c16147 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -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) diff --git a/tests/charm-ground-flippy-parity.test.ts b/tests/charm-ground-flippy-parity.test.ts new file mode 100644 index 0000000..1f84731 --- /dev/null +++ b/tests/charm-ground-flippy-parity.test.ts @@ -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) + }) +}) diff --git a/tests/ground-items-alt-labels.test.ts b/tests/ground-items-alt-labels.test.ts index 63925a5..b7f245d 100644 --- a/tests/ground-items-alt-labels.test.ts +++ b/tests/ground-items-alt-labels.test.ts @@ -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) + }) }) })