Merge branch 'issue-390' into main (Fixes #390)

This commit is contained in:
troytt 2026-09-23 02:36:16 +00:00
commit 7d7925ab2b
4 changed files with 865 additions and 0 deletions

View File

@ -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'
@ -3348,6 +3353,17 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
// render loop only has to move them.
const labelsContainer = document.querySelector<HTMLElement>('#labels')
const labelPool: HTMLDivElement[] = []
// Ground item Alt floating labels container and overlay manager
const groundLabelsContainer = document.querySelector<HTMLElement>('#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
@ -4406,6 +4422,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

345
src/ui/ground-labels.ts Normal file
View File

@ -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<string, string> = {
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
}
}
}

View File

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

View File

@ -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> = {}): 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<string, ((e: any) => 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)
})
})
})