472 lines
15 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|