Merge branch 'issue-390' into main (Fixes #390)
This commit is contained in:
commit
7d7925ab2b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue