diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 6fd2a09..cc0fe76 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -48,6 +48,11 @@ import type { NpcDef } from '../game/quests.ts' import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getMissileTxtData } from '../game/skills.ts' import type { Projectile } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, type GroundItemEntity } from '../game/ground-items.ts' +import { + GroundLabelOverlay, + computeInitialGroundLabelLayouts, + resolveLadderCollisions, +} from '../ui/ground-labels.ts' import type { AtlasFrame } from '../render/atlas.ts' import { SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle } from '../render/renderer.ts' @@ -3241,6 +3246,17 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st // render loop only has to move them. const labelsContainer = document.querySelector('#labels') const labelPool: HTMLDivElement[] = [] + + // Ground item Alt floating labels container and overlay manager + const groundLabelsContainer = document.querySelector('#ground-labels') ?? (() => { + if (typeof document === 'undefined') return null + const el = document.createElement('div') + el.id = 'ground-labels' + document.body.appendChild(el) + return el + })() + const groundLabelOverlay = groundLabelsContainer ? new GroundLabelOverlay(groundLabelsContainer) : null + /** `performance.now()` at which the HUD text may be rewritten again. */ let hudNextMs = 0 @@ -4297,6 +4313,28 @@ if (typeof window !== 'undefined') { for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true } + if (groundLabelOverlay !== null) { + const showLabels = Boolean(hudManager?.showGroundLabels) + if (showLabels && engine.groundItems.count > 0) { + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1 + const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1 + const projectToScreen = (worldX: number, worldY: number) => { + const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX + const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY + if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) { + return null + } + return { x: domX, y: domY } + } + const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen) + const resolvedLayouts = resolveLadderCollisions(initialLayouts) + groundLabelOverlay.update(true, resolvedLayouts) + } else { + groundLabelOverlay.update(false, []) + } + } + state.drawCalls = renderer.drawCalls state.quadsDrawn = renderer.quadsSubmitted state.culledDraws = culledDraws diff --git a/src/ui/ground-labels.ts b/src/ui/ground-labels.ts new file mode 100644 index 0000000..2f2a3cd --- /dev/null +++ b/src/ui/ground-labels.ts @@ -0,0 +1,345 @@ +/** + * Diablo II v1.13c Authentic Ground Item Alt Labels (`src/ui/ground-labels.ts`). + * + * Implements: + * - Holding the `Alt` key displays floating text labels for all ground items on screen. + * - Text color and border styling strictly following Diablo II 1.13c item quality colors: + * Normal (white), Low (gray), Superior (white), Magic (blue), Rare (yellow), + * Set (green), Unique (gold/tan), Craft/Rune (orange), Gold (yellow/gold). + * - Vertical ladder collision avoidance (anti-overlap): + * When multiple items drop in close proximity (e.g. boss drops, packed loot), + * labels are neatly stacked vertically into a non-overlapping ladder. + * - Clickable labels to trigger pathfinding and item pickup. + */ + +import type { GroundItemEntity } from '../game/ground-items.ts' + +/** + * Authentic 1.13c Diablo II quality color codes for ground item labels. + * Matching D2 palette tables & D2WinFont values. + */ +export const GROUND_LABEL_QUALITY_COLORS: Record = { + normal: '#d8d8d8', + low: '#808080', + superior: '#ffffff', + magic: '#6868ff', + rare: '#ffff64', + set: '#00fc00', + unique: '#c8a15a', + craft: '#ff9c18', + rune: '#ff9c18', + gold: '#d8b420', +} + +/** + * Resolves the quality color string for a ground item. + */ +export function getGroundItemQualityColor(item: GroundItemEntity): string { + if (item.isGold) { + return GROUND_LABEL_QUALITY_COLORS.gold! + } + const q = item.quality?.toLowerCase() ?? 'normal' + return GROUND_LABEL_QUALITY_COLORS[q] ?? GROUND_LABEL_QUALITY_COLORS.normal! +} + +/** + * Format the user-facing display text for a ground item label. + */ +export function formatGroundItemLabelText(item: GroundItemEntity): string { + if (item.isGold) { + return `${item.amount} 金币` + } + return item.nameZh || item.name || '物品' +} + +/** + * Estimates the pixel width of a ground item label based on CJK and Latin character counts. + */ +export function estimateLabelWidth(text: string, fontSizePx = 13): number { + let charWidths = 0 + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i) + // CJK ideographs / punctuation roughly full-width + if (code > 0x2e80) { + charWidths += fontSizePx * 1.05 + } else { + // Latin characters / numbers / spaces roughly half-width + charWidths += fontSizePx * 0.58 + } + } + // Base padding: 12px (6px left + 6px right) + 2px border + return Math.ceil(charWidths + 14) +} + +export interface GroundLabelLayout { + readonly id: string + readonly itemId: string + readonly item: GroundItemEntity + readonly text: string + readonly color: string + readonly borderColor: string + readonly width: number + readonly height: number + readonly screenX: number + screenY: number + readonly naturalY: number +} + +/** + * Computes initial screen layouts for a list of ground items. + */ +export function computeInitialGroundLabelLayouts( + items: readonly GroundItemEntity[], + projectToScreen: (worldX: number, worldY: number) => { x: number; y: number } | null, + fontSizePx = 13, + labelHeightPx = 18, +): GroundLabelLayout[] { + const layouts: GroundLabelLayout[] = [] + + for (const item of items) { + const pt = projectToScreen(item.x, item.y) + if (pt === null) continue + + const text = formatGroundItemLabelText(item) + const color = getGroundItemQualityColor(item) + const width = estimateLabelWidth(text, fontSizePx) + const naturalY = pt.y - 12 // Positioned 12px above ground contact point + + layouts.push({ + id: `label-${item.id}`, + itemId: item.id, + item, + text, + color, + borderColor: color, + width, + height: labelHeightPx, + screenX: pt.x, + screenY: naturalY, + naturalY, + }) + } + + return layouts +} + +/** + * Checks if two rectangular labels overlap within specified padding tolerances. + */ +export function doLabelsOverlap( + a: GroundLabelLayout, + b: GroundLabelLayout, + padX = 4, + padY = 2, +): boolean { + const aLeft = a.screenX - a.width / 2 + const aRight = a.screenX + a.width / 2 + const aTop = a.screenY - a.height / 2 + const aBottom = a.screenY + a.height / 2 + + const bLeft = b.screenX - b.width / 2 + const bRight = b.screenX + b.width / 2 + const bTop = b.screenY - b.height / 2 + const bBottom = b.screenY + b.height / 2 + + const xOverlap = aLeft - padX < bRight && aRight + padX > bLeft + const yOverlap = aTop - padY < bBottom && aBottom + padY > bTop + + return xOverlap && yOverlap +} + +/** + * Authentic Diablo II 1.13c Vertical Ladder Collision Avoidance Algorithm. + * + * Stacks overlapping ground item labels vertically so they remain readable and clickable. + * - Groups labels into overlapping horizontal clusters. + * - Stacks each cluster from bottom (closest to the ground item) upwards. + * - Preserves natural anchor proximity while eliminating 100% of bounding box overlaps. + */ +export function resolveLadderCollisions( + labels: GroundLabelLayout[], + padX = 4, + padY = 2, +): GroundLabelLayout[] { + if (labels.length <= 1) return labels + + // Work on a copy with mutable screenY + const result: GroundLabelLayout[] = labels.map(l => ({ ...l })) + + // Partition into connected overlapping components (clusters) + const n = result.length + const adj: number[][] = Array.from({ length: n }, () => []) + + for (let i = 0; i < n; i += 1) { + for (let j = i + 1; j < n; j += 1) { + // Horizontal overlap check: do their X spans overlap? + const a = result[i]! + const b = result[j]! + const aLeft = a.screenX - a.width / 2 + const aRight = a.screenX + a.width / 2 + const bLeft = b.screenX - b.width / 2 + const bRight = b.screenX + b.width / 2 + + if (aLeft - padX < bRight && aRight + padX > bLeft) { + adj[i]!.push(j) + adj[j]!.push(i) + } + } + } + + // Find connected clusters via BFS + const visited = new Uint8Array(n) + for (let i = 0; i < n; i += 1) { + if (visited[i]) continue + const cluster: number[] = [] + const queue = [i] + visited[i] = 1 + + while (queue.length > 0) { + const curr = queue.shift()! + cluster.push(curr) + for (const neighbor of adj[curr]!) { + if (!visited[neighbor]) { + visited[neighbor] = 1 + queue.push(neighbor) + } + } + } + + if (cluster.length <= 1) continue + + // Sort cluster items by their natural Y ascending + cluster.sort((idxA, idxB) => { + const a = result[idxA]! + const b = result[idxB]! + if (a.naturalY !== b.naturalY) { + return a.naturalY - b.naturalY + } + return a.screenX - b.screenX + }) + + // Ladder layout: Bottom-most label is placed closest to the ground item, + // and subsequent labels stack upward neatly. + // Base Y is the natural Y of the lowest (highest Y coordinate) item in the cluster. + const bottomItem = result[cluster[cluster.length - 1]!]! + let currentBottomY = bottomItem.naturalY + + // Stack upward from bottom to top + for (let k = cluster.length - 1; k >= 0; k -= 1) { + const itemIdx = cluster[k]! + const item = result[itemIdx]! + item.screenY = currentBottomY + currentBottomY -= item.height + padY + } + } + + return result +} + +/** + * DOM Manager for ground item Alt labels overlay. + */ +export class GroundLabelOverlay { + private readonly container: HTMLElement + private readonly labelPool: HTMLElement[] = [] + private onPickupCallback: ((item: GroundItemEntity) => void) | null = null + + constructor(container: HTMLElement) { + this.container = container + this.container.style.position = 'fixed' + this.container.style.inset = '0' + this.container.style.pointerEvents = 'none' + this.container.style.zIndex = '5' + } + + /** + * Register a callback when a ground item label is clicked. + */ + setOnPickupCallback(callback: (item: GroundItemEntity) => void): void { + this.onPickupCallback = callback + } + + /** + * Update and render ground item labels based on current visible items and Alt key state. + */ + update( + visible: boolean, + layouts: readonly GroundLabelLayout[], + ): void { + if (!visible || layouts.length === 0) { + this.hideAll() + return + } + + let count = 0 + for (const layout of layouts) { + let el = this.labelPool[count] + if (el === undefined) { + el = document.createElement('div') + el.className = 'd2-ground-label' + el.style.position = 'absolute' + el.style.transform = 'translate(-50%, -50%)' + el.style.background = 'rgba(0, 0, 0, 0.88)' + el.style.padding = '2px 6px' + el.style.fontFamily = "'Exocet', 'Formal', 'SimSun', sans-serif" + el.style.fontSize = '13px' + el.style.lineHeight = '14px' + el.style.whiteSpace = 'nowrap' + el.style.pointerEvents = 'auto' + el.style.cursor = 'pointer' + el.style.userSelect = 'none' + el.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.8)' + el.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' + el.style.transition = 'border-color 0.1s, background-color 0.1s' + + el.addEventListener('mouseenter', () => { + el.style.backgroundColor = 'rgba(28, 28, 48, 0.96)' + el.style.borderColor = '#ffffff' + }) + el.addEventListener('mouseleave', () => { + el.style.backgroundColor = 'rgba(0, 0, 0, 0.88)' + el.style.borderColor = el.dataset.qualityColor || '#404040' + }) + el.addEventListener('pointerdown', (e) => { + e.stopPropagation() + e.preventDefault() + const currentItem = (el as any).__groundItem as GroundItemEntity | undefined + if (currentItem && this.onPickupCallback) { + this.onPickupCallback(currentItem) + } + }) + + this.labelPool.push(el) + this.container.appendChild(el) + } + + ;(el as any).__groundItem = layout.item + el.dataset.itemId = layout.itemId + el.dataset.qualityColor = layout.borderColor + if (el.textContent !== layout.text) el.textContent = layout.text + el.style.color = layout.color + el.style.border = `1px solid ${layout.borderColor}` + el.style.left = `${Math.round(layout.screenX)}px` + el.style.top = `${Math.round(layout.screenY)}px` + el.style.display = 'block' + el.hidden = false + count += 1 + } + + // Hide surplus pooled elements + for (let i = count; i < this.labelPool.length; i += 1) { + this.labelPool[i]!.style.display = 'none' + this.labelPool[i]!.hidden = true + } + } + + /** + * Hide all active labels. + */ + hideAll(): void { + for (const el of this.labelPool) { + el.style.display = 'none' + el.hidden = true + } + } +} diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index c87f46b..56a84bb 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -611,6 +611,7 @@ export class HudManager { this.hotkeys.triggerFunctionKey(key) this.syncPublishedState() } else if (e.key === 'Alt') { + e.preventDefault() this.showGroundLabels = true this.syncPublishedState() } else if (e.key === 'Escape' || e.code === 'Space' || e.key === ' ') { @@ -622,6 +623,14 @@ export class HudManager { window.addEventListener('keyup', (e) => { if (e.key === 'Alt') { + e.preventDefault() + this.showGroundLabels = false + this.syncPublishedState() + } + }) + + window.addEventListener('blur', () => { + if (this.showGroundLabels) { this.showGroundLabels = false this.syncPublishedState() } diff --git a/tests/ground-items-alt-labels.test.ts b/tests/ground-items-alt-labels.test.ts new file mode 100644 index 0000000..63925a5 --- /dev/null +++ b/tests/ground-items-alt-labels.test.ts @@ -0,0 +1,473 @@ +/** + * Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390). + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' +import type { GroundItemEntity } from '../src/game/ground-items.ts' +import { + GROUND_LABEL_QUALITY_COLORS, + getGroundItemQualityColor, + formatGroundItemLabelText, + estimateLabelWidth, + doLabelsOverlap, + resolveLadderCollisions, + computeInitialGroundLabelLayouts, + GroundLabelOverlay, + type GroundLabelLayout, +} from '../src/ui/ground-labels.ts' + +function createMockItem(partial: Partial = {}): GroundItemEntity { + return { + id: partial.id ?? 'item-1', + item: partial.item ?? {}, + name: partial.name ?? 'Short Sword', + nameZh: partial.nameZh ?? '短剑', + quality: partial.quality ?? 'normal', + isGold: partial.isGold ?? false, + amount: partial.amount ?? 1, + invWidth: partial.invWidth ?? 1, + invHeight: partial.invHeight ?? 2, + dropTime: 1000, + x: partial.x ?? 400, + y: partial.y ?? 300, + cellX: 20, + cellY: 15, + sparklePhase: 0, + } +} + +describe('Ground Items Alt Labels (Issue #390)', () => { + describe('Quality Colors & Text Formatting', () => { + it('formats gold amounts accurately as authentic Chinese localized text', () => { + const gold1 = createMockItem({ isGold: true, amount: 150 }) + expect(formatGroundItemLabelText(gold1)).toBe('150 金币') + + const gold2 = createMockItem({ isGold: true, amount: 25000 }) + expect(formatGroundItemLabelText(gold2)).toBe('25000 金币') + }) + + it('formats item display names preferring nameZh with fallback to name', () => { + const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' }) + expect(formatGroundItemLabelText(item1)).toBe('短剑') + + const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' }) + expect(formatGroundItemLabelText(item2)).toBe('Grand Charm') + }) + + it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => { + expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.normal, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.low, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.superior, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.magic, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.rare, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.set, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.unique, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.craft, + ) + expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe( + GROUND_LABEL_QUALITY_COLORS.rune, + ) + expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe( + GROUND_LABEL_QUALITY_COLORS.gold, + ) + }) + }) + + describe('Label Width Estimation', () => { + it('correctly accounts for both ASCII and CJK wide characters', () => { + const asciiW = estimateLabelWidth('OK', 13) + const cjkW = estimateLabelWidth('戒指', 13) + // CJK characters have greater pixel width than Latin characters for same character count + expect(cjkW).toBeGreaterThan(asciiW) + expect(asciiW).toBeGreaterThan(20) + }) + }) + + describe('Overlap Detection & Layout Calculation', () => { + it('projects ground items to initial layouts with natural Y offset', () => { + const items = [ + createMockItem({ id: 'i1', x: 100, y: 200 }), + createMockItem({ id: 'i2', x: 300, y: 400 }), + ] + const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 }) + const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18) + + expect(layouts.length).toBe(2) + expect(layouts[0]!.screenX).toBe(200) + expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12 + expect(layouts[0]!.screenY).toBe(388) + expect(layouts[1]!.screenX).toBe(600) + expect(layouts[1]!.naturalY).toBe(788) + }) + + it('filters out items outside viewport when project returns null', () => { + const items = [createMockItem({ id: 'i1' })] + const layouts = computeInitialGroundLabelLayouts(items, () => null) + expect(layouts.length).toBe(0) + }) + + it('accurately identifies overlapping and non-overlapping label rectangles', () => { + const l1: GroundLabelLayout = { + id: '1', + itemId: '1', + item: createMockItem(), + text: 'A', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 100, + screenY: 100, + naturalY: 100, + } + const l2Overlapping: GroundLabelLayout = { + id: '2', + itemId: '2', + item: createMockItem(), + text: 'B', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 110, + screenY: 105, + naturalY: 105, + } + const l3Distant: GroundLabelLayout = { + id: '3', + itemId: '3', + item: createMockItem(), + text: 'C', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 300, + screenY: 100, + naturalY: 100, + } + + expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true) + expect(doLabelsOverlap(l1, l3Distant)).toBe(false) + }) + }) + + describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => { + it('returns empty array or single label unchanged', () => { + expect(resolveLadderCollisions([])).toEqual([]) + const single: GroundLabelLayout[] = [ + { + id: '1', + itemId: '1', + item: createMockItem(), + text: 'A', + color: '#fff', + borderColor: '#fff', + width: 50, + height: 18, + screenX: 100, + screenY: 100, + naturalY: 100, + }, + ] + expect(resolveLadderCollisions(single)).toEqual(single) + }) + + it('retains natural positions when labels do not overlap horizontally', () => { + const nonOverlapping: GroundLabelLayout[] = [ + { + id: '1', + itemId: '1', + item: createMockItem(), + text: 'Left Item', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 100, + screenY: 200, + naturalY: 200, + }, + { + id: '2', + itemId: '2', + item: createMockItem(), + text: 'Right Item', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 300, + screenY: 200, + naturalY: 200, + }, + ] + const resolved = resolveLadderCollisions(nonOverlapping) + expect(resolved[0]!.screenY).toBe(200) + expect(resolved[1]!.screenY).toBe(200) + }) + + it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => { + const overlapping: GroundLabelLayout[] = [ + { + id: '1', + itemId: '1', + item: createMockItem(), + text: 'Item A', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 100, + screenY: 200, + naturalY: 200, + }, + { + id: '2', + itemId: '2', + item: createMockItem(), + text: 'Item B', + color: '#fff', + borderColor: '#fff', + width: 60, + height: 18, + screenX: 105, + screenY: 202, + naturalY: 202, + }, + ] + + const resolved = resolveLadderCollisions(overlapping, 4, 2) + expect(resolved.length).toBe(2) + + // Zero overlap verification + expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false) + + // Vertical distance between centers must be at least height + padY = 20px + const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY) + expect(distY).toBeGreaterThanOrEqual(20) + }) + + it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => { + const cluster: GroundLabelLayout[] = [] + for (let i = 0; i < 5; i += 1) { + cluster.push({ + id: `item-${i}`, + itemId: `item-${i}`, + item: createMockItem({ id: `item-${i}` }), + text: `Drop ${i}`, + color: '#fff', + borderColor: '#fff', + width: 70, + height: 18, + screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally + screenY: 300, + naturalY: 300, + }) + } + + const resolved = resolveLadderCollisions(cluster, 4, 2) + expect(resolved.length).toBe(5) + + // Assert pairwise zero-overlap across all 5 labels + for (let i = 0; i < resolved.length; i += 1) { + for (let j = i + 1; j < resolved.length; j += 1) { + expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false) + } + } + + // Check ladder sorting: positions strictly decreasing (stacking upward) + const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY) + for (let k = 0; k < sortedByY.length - 1; k += 1) { + expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20) + } + }) + + it('handles multiple independent clusters simultaneously', () => { + const items: GroundLabelLayout[] = [ + // Cluster 1 around X=100 + { + id: 'c1-1', + itemId: 'c1-1', + item: createMockItem(), + text: 'Loot A', + color: '#fff', + borderColor: '#fff', + width: 50, + height: 18, + screenX: 100, + screenY: 200, + naturalY: 200, + }, + { + id: 'c1-2', + itemId: 'c1-2', + item: createMockItem(), + text: 'Loot B', + color: '#fff', + borderColor: '#fff', + width: 50, + height: 18, + screenX: 102, + screenY: 200, + naturalY: 200, + }, + // Cluster 2 around X=500 + { + id: 'c2-1', + itemId: 'c2-1', + item: createMockItem(), + text: 'Loot C', + color: '#fff', + borderColor: '#fff', + width: 50, + height: 18, + screenX: 500, + screenY: 200, + naturalY: 200, + }, + { + id: 'c2-2', + itemId: 'c2-2', + item: createMockItem(), + text: 'Loot D', + color: '#fff', + borderColor: '#fff', + width: 50, + height: 18, + screenX: 503, + screenY: 200, + naturalY: 200, + }, + ] + + const resolved = resolveLadderCollisions(items) + // Both clusters have their own pairwise overlap resolved + expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false) + expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false) + // Cluster 1 and Cluster 2 remain at their distinct X + expect(resolved[0]!.screenX).toBeLessThan(200) + expect(resolved[2]!.screenX).toBeGreaterThan(400) + }) + }) + + describe('GroundLabelOverlay DOM Integration', () => { + function createMockDomElement(tag = 'div'): any { + const listeners: Record void)[]> = {} + const children: any[] = [] + const el: any = { + tagName: tag.toUpperCase(), + className: '', + textContent: '', + style: {}, + dataset: {}, + hidden: false, + children, + appendChild(child: any) { + children.push(child) + return child + }, + addEventListener(type: string, fn: any) { + listeners[type] = listeners[type] ?? [] + listeners[type].push(fn) + }, + dispatchEvent(event: any) { + for (const fn of listeners[event.type] ?? []) { + fn(event) + } + }, + } + return el + } + + const origDocument = (globalThis as any).document + beforeAll(() => { + ;(globalThis as any).document = { + createElement: (tag: string) => createMockDomElement(tag), + } + }) + afterAll(() => { + ;(globalThis as any).document = origDocument + }) + + it('hides all pooled label elements when visible is false or layouts empty', () => { + const container = createMockDomElement('div') + const overlay = new GroundLabelOverlay(container) + + const layout: GroundLabelLayout = { + id: '1', + itemId: '1', + item: createMockItem(), + text: 'Short Sword', + color: '#d8d8d8', + borderColor: '#d8d8d8', + width: 60, + height: 18, + screenX: 100, + screenY: 100, + naturalY: 100, + } + + overlay.update(true, [layout]) + expect(container.children.length).toBe(1) + expect(container.children[0].style.display).toBe('block') + + overlay.update(false, []) + expect(container.children[0].style.display).toBe('none') + }) + + it('renders labels with authentic quality colors and triggers pickup callback on click', () => { + const container = createMockDomElement('div') + const overlay = new GroundLabelOverlay(container) + const onPickup = vi.fn() + overlay.setOnPickupCallback(onPickup) + + const uniqueItem = createMockItem({ + id: 'soj', + name: 'The Stone of Jordan', + nameZh: '乔丹之石', + quality: 'unique', + }) + + const layout: GroundLabelLayout = { + id: 'soj', + itemId: 'soj', + item: uniqueItem, + text: '乔丹之石', + color: GROUND_LABEL_QUALITY_COLORS.unique!, + borderColor: GROUND_LABEL_QUALITY_COLORS.unique!, + width: 80, + height: 18, + screenX: 250, + screenY: 150, + naturalY: 150, + } + + overlay.update(true, [layout]) + const el = container.children[0] + expect(el.textContent).toBe('乔丹之石') + expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique) + expect(el.style.left).toBe('250px') + expect(el.style.top).toBe('150px') + + // Simulate pointerdown + el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() }) + expect(onPickup).toHaveBeenCalledWith(uniqueItem) + }) + }) +})