diablo2-web/src/ui/ground-labels.ts

472 lines
15 KiB
TypeScript

/**
* 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 { isGroundSourceUnidentified, type GroundItemEntity } from '../game/ground-items.ts'
import {
resolveBaseItemNameEn,
translateItemName,
translateRareName,
translateMagicName,
} from '../game/tooltip-i18n.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',
}
/**
* Checks whether a ground item entity or its underlying item source is ethereal.
*/
export function isGroundItemEthereal(item: GroundItemEntity): boolean {
const src = item.item
return Boolean(
item.ethereal === true ||
src?.ethereal === true ||
src?.isEthereal === true ||
src?.item?.ethereal === true ||
src?.item?.isEthereal === true
)
}
/**
* Checks whether a ground item entity or its underlying item source has sockets (> 0).
*/
export function isGroundItemSocketed(item: GroundItemEntity): boolean {
const src = item.item
const sockets =
item.sockets ??
src?.sockets ??
src?.totalSockets ??
src?.socketedCount ??
src?.item?.sockets ??
src?.item?.totalSockets ??
src?.item?.socketedCount ??
0
return typeof sockets === 'number' && sockets > 0
}
/**
* Checks whether a ground item entity is ethereal or socketed.
*/
export function isGroundItemEtherealOrSocketed(item: GroundItemEntity): boolean {
return isGroundItemEthereal(item) || isGroundItemSocketed(item)
}
/**
* Resolves the quality color string for a ground item.
*
* Authentic Diablo II v1.13c rule (`D2Client.dll` ground item label color lookup):
* - Gold -> `#d8b420` (Gold)
* - Low Quality (`low` / `inferior` / `crude` / `damaged` / `cracked`) -> `#808080` (Gray)
* - Normal or Superior (`normal` / `superior` / `high` / `hi-quality`) items that are
* Ethereal OR have Sockets (`sockets > 0`, excluding completed runewords) -> `#808080` (Gray)
* - Magic, Rare, Set, Unique, Craft, Rune -> retain their respective quality colors even when ethereal/socketed.
*/
export function getGroundItemQualityColor(item: GroundItemEntity): string {
if (item.isGold) {
return GROUND_LABEL_QUALITY_COLORS.gold!
}
const q = item.quality?.toLowerCase() ?? 'normal'
if (q === 'low' || q === 'inferior' || q === 'crude' || q === 'damaged' || q === 'cracked') {
return GROUND_LABEL_QUALITY_COLORS.low!
}
const isNormalOrSuperior = q === 'normal' || q === 'superior' || q === 'high' || q === 'hi-quality'
const isRuneword = Boolean(
(item as any).isRuneword ||
item.item?.isRuneword ||
item.item?.runewordRunes ||
item.item?.item?.isRuneword ||
item.item?.item?.runewordRunes,
)
if (isRuneword) {
return GROUND_LABEL_QUALITY_COLORS.unique!
}
if (isNormalOrSuperior && isGroundItemEtherealOrSocketed(item)) {
return GROUND_LABEL_QUALITY_COLORS.low!
}
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} 金币`
}
const src = item.item as any
if (isGroundSourceUnidentified(src)) {
const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base
const rawCode =
typeof src?.code === 'string'
? src.code.trim().toLowerCase()
: typeof baseObj?.id === 'string'
? baseObj.id.trim().toLowerCase()
: ''
const rawBaseName =
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
? baseObj.name
: item.name
if (rawBaseName) {
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
if (item.nameZh && item.name === baseNameEn) {
return item.nameZh
}
const translatedBase =
translateItemName(rawBaseName) !== rawBaseName
? translateItemName(rawBaseName)
: translateItemName(baseNameEn)
return translatedBase || baseNameEn
}
}
if (item.nameZh) {
return item.nameZh
}
const srcNameZh = (item.item as any)?.nameZh ?? (item.item as any)?.item?.nameZh
if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') {
return srcNameZh
}
if (item.name) {
const q = (item.quality || 'normal').toLowerCase()
if (q === 'rare') {
return translateRareName(item.name)
}
if (q === 'magic') {
const baseName = (item.item as any)?.base?.name ?? (item.item as any)?.item?.base?.name
return translateMagicName(item.name, baseName)
}
return translateItemName(item.name)
}
return '物品'
}
/**
* 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
private onHoverCallback: ((item: GroundItemEntity | null) => 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
}
/**
* 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.
*/
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'
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()
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
}
}
}