diablo2-web/src/client/scene/overhead-labels.ts

463 lines
18 KiB
TypeScript

import { type AudioManager, sharedAudioManager } from '../audio/manager.ts'
import type { Monster, SceneEngineLike as GameEngine } from '../../common/units/unit-types.ts'
import type { GroundItemEntity } from '../../common/items/ground-items.ts'
import { getLevelNames, levelDisplayName } from '../../common/world/level-names-zh.ts'
import { getTownNpcDescriptorByName, npcDisplayName } from '../../common/units/npc-table.ts'
import type { TownPortalSlot } from '../../common/world/portal.ts'
import { getTblLang } from '../i18n/lang.ts'
import type { ViewportCamera } from '../sim/camera.ts'
import {
computeInitialGroundLabelLayouts,
resolveLadderCollisions,
type GroundLabelOverlay,
} from '../ui/ground-labels.ts'
import type { HudManager } from '../ui/hud-manager.ts'
import {
getEntranceLabelWorldPos,
getWarpLabelWorldPos,
screenToWorld,
subTileToScene,
type SceneEntrance,
type SceneWarp,
} from '../../common/world/scene-links-nav.ts'
import { MARKER_HEIGHT, type MapRuntime } from './pack-loader.ts'
import {
monsterArtStatusText,
monsterPlanStatusText,
} from './scene-state.ts'
export { monsterArtStatusText, monsterPlanStatusText }
export interface NpcLabelProjectionParams {
npc: {
x: number
y: number
frame?: {
readonly width?: number
readonly height?: number
readonly offsetX?: number
readonly offsetY?: number
} | null | undefined
}
camX: number
camY: number
cameraZoom: number
canvas: { width: number; height: number }
rect: { left: number; top: number; width: number; height: number }
markerHeight?: number
}
export interface NpcLabelProjectionResult {
domX: number
domY: number
fontSize: number
visible: boolean
}
export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProjectionResult {
const markerHeight = params.markerHeight ?? MARKER_HEIGHT
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
const frame = params.npc.frame
const topX = frame != null
? params.npc.x + (frame.offsetX ?? 0) + (frame.width ?? 0) / 2
: params.npc.x
const topY = frame != null
? params.npc.y + (frame.offsetY ?? 0)
: params.npc.y - markerHeight
const domX = params.rect.left + params.rect.width * 0.5 + (topX - params.camX) * params.cameraZoom * scaleX
const domY = params.rect.top + params.rect.height * 0.5 + (topY - params.camY) * params.cameraZoom * scaleY
const visible = domX > -100 && domX < params.rect.width + 100 && domY > -100 && domY < params.rect.height + 100
const safeZoom = Math.max(0.001, Number.isFinite(params.cameraZoom) ? params.cameraZoom : 1)
const fontSize = Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(safeZoom))))
return { domX, domY: domY - 2, fontSize, visible }
}
/**
* Play synthesized Diablo II-style audio rejection feedback when inventory is full.
* Low descending refusal tone paired with delayed secondary touch landing thud.
*/
export function playInventoryFullFeedback(audioManager: AudioManager = sharedAudioManager): void {
if (typeof audioManager?.playSfx === 'function') {
audioManager.playSfx('inventory_full')
return
}
audioManager?.playTone?.({
type: 'sawtooth',
from: 175,
to: 90,
gain: 0.3,
durationS: 0.3,
delayS: 0,
})
audioManager?.playTone?.({
type: 'triangle',
from: 110,
to: 45,
gain: 0.2,
durationS: 0.1,
delayS: 0.35,
})
}
/**
* Display prominent Diablo II style on-screen notification banner with auto fade-out.
*/
export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void {
if (status) {
status.textContent = message
}
if (typeof document !== 'undefined') {
let banner = document.getElementById('d2-center-notification')
if (!banner && document.body) {
banner = document.createElement('div')
banner.id = 'd2-center-notification'
banner.style.position = 'fixed'
banner.style.top = '18%'
banner.style.left = '50%'
banner.style.transform = 'translateX(-50%)'
banner.style.color = '#e03030'
banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)'
banner.style.fontSize = '20px'
banner.style.fontWeight = 'bold'
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
banner.style.pointerEvents = 'none'
banner.style.zIndex = '99999'
banner.style.transition = 'opacity 0.4s ease-out'
document.body.appendChild(banner)
}
if (banner) {
banner.textContent = message
banner.style.opacity = '1'
clearTimeout((banner as any)._timeout)
;(banner as any)._timeout = setTimeout(() => {
if (banner) banner.style.opacity = '0'
}, durationMs)
}
}
}
;(globalThis as unknown as {
__d2PlayInventoryFullFeedback?: () => void
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
}).__d2PlayInventoryFullFeedback = playInventoryFullFeedback
;(globalThis as unknown as {
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
}).__d2ShowNotification = (message: string, durationMs = 2000, status?: unknown) => {
showNotification(message, durationMs, status as HTMLElement | undefined)
}
export function allocateLabelElement(
container: HTMLElement,
labelPool: HTMLDivElement[],
): HTMLDivElement {
let label: HTMLDivElement
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
label = document.createElement('div')
} else {
label = {
tagName: 'DIV',
textContent: '',
hidden: false,
className: '',
dataset: {},
style: {},
} as unknown as HTMLDivElement
}
label.style.position = 'absolute'
label.style.transform = 'translate(-50%, -100%)'
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
label.style.whiteSpace = 'nowrap'
label.style.pointerEvents = 'none'
labelPool.push(label)
if (typeof container.appendChild === 'function') {
container.appendChild(label)
} else if (Array.isArray((container as any).children)) {
;(container as any).children.push(label)
}
return label
}
export interface OverheadLabelsControllerHost {
readonly labelsContainer?: HTMLElement | null | undefined
readonly canvas: HTMLCanvasElement
readonly camera: ViewportCamera
readonly engine: GameEngine
readonly hudManager: HudManager | null
readonly portalSlot?: TownPortalSlot | undefined
readonly labelPool: HTMLDivElement[]
lastClientPos: { x: number; y: number } | null
hoveredMonster: Monster | null
hoveredGroundItem: GroundItemEntity | null
hoveredWarp: SceneWarp | null
hoveredEntrance: SceneEntrance | null
getRuntime(): MapRuntime
findMonsterAtWorld(worldX: number, worldY: number): Monster | null
syncLinkHoverAtWorld(worldX: number, worldY: number): void
}
/**
* Render recycled overhead labels for on-screen NPCs, Town Portals,
* `runtime.warps`, and `runtime.entrances` into `labelsContainer`.
*/
export function updateOverheadLabels(
host: OverheadLabelsControllerHost,
camX = host.engine.world.player.x,
camY = host.engine.world.player.y - 16,
activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null,
activePortalPosOverride?: { readonly x: number; readonly y: number } | null,
): void {
const container = host.labelsContainer
if (!container) return
const runtime = host.getRuntime()
let labelCount = 0
const rect = host.canvas.getBoundingClientRect()
const scaleX = host.canvas.width > 0 ? rect.width / host.canvas.width : 1
const scaleY = host.canvas.height > 0 ? rect.height / host.canvas.height : 1
const zoom = host.camera?.zoom ?? 1
const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px`
if (host.lastClientPos !== null) {
let hudIntercepted = false
if (host.hudManager !== null) {
const logical = host.hudManager.clientToLogical(host.lastClientPos.x, host.lastClientPos.y)
hudIntercepted = host.hudManager.isPointInterceptedByHud(logical.x, logical.y)
}
if (hudIntercepted) {
host.hoveredMonster = null
host.hoveredGroundItem = null
host.hoveredWarp = null
host.hoveredEntrance = null
if (host.canvas?.style) {
host.canvas.style.cursor = ''
}
} else {
const worldAtPointer = screenToWorld({
clientX: host.lastClientPos.x,
clientY: host.lastClientPos.y,
rect,
canvas: host.canvas,
camX,
camY,
cameraZoom: zoom,
})
host.hoveredMonster = host.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
host.hoveredGroundItem = host.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
host.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
}
}
const activeNpcMenuName = host.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase()
const currentLang = getTblLang()
const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1)
for (const npc of host.engine.npcEntities ?? []) {
if (npc.def.name.toLowerCase() === 'dummy') continue
const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc)
const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name
if (
activeNpcMenuName &&
(npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName)
) {
continue
}
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100) continue
if (domY <= -100 || domY >= rect.height + 100) continue
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
if (label.textContent !== npcLabelText) label.textContent = npcLabelText
label.className = 'd2-overhead-label d2-npc-label'
if (label.dataset) {
label.dataset.kind = 'npc'
delete label.dataset.toLevelId
delete label.dataset.hovered
}
label.style.color = '#c8a15a'
label.style.background = 'transparent'
label.style.border = 'none'
label.style.padding = '0'
label.style.borderRadius = '0'
label.style.fontWeight = 'normal'
label.style.boxShadow = 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
const portalMouth = activePortalMouthOverride !== undefined
? activePortalMouthOverride
: (host.portalSlot && runtime ? host.portalSlot.mouthInLevel(runtime.levelId) : null)
const portalPos = activePortalPosOverride !== undefined
? activePortalPosOverride
: (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null)
if (portalMouth !== null && portalPos !== null) {
const topX = portalPos.x
const topY = portalPos.y - 94
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) {
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
const portalLabelText = currentLang === 'ENG'
? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}`
: `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}`
if (label.textContent !== portalLabelText) label.textContent = portalLabelText
label.className = 'd2-overhead-label d2-portal-label'
if (label.dataset) {
label.dataset.kind = 'portal'
label.dataset.toLevelId = String(portalMouth.toLevelId)
delete label.dataset.hovered
}
label.style.color = '#6cb4ff'
label.style.background = 'transparent'
label.style.border = 'none'
label.style.padding = '0'
label.style.borderRadius = '0'
label.style.fontWeight = 'normal'
label.style.boxShadow = 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
}
if (runtime?.grid) {
for (const warp of runtime.warps ?? []) {
if (!(warp.toLevelId > 0)) continue
const pos = getWarpLabelWorldPos(warp, runtime.grid)
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
const badgeText = currentLang === 'ENG'
? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
: `通往 ${getLevelNames(warp.toLevelId).nameZh}`
if (label.textContent !== badgeText) label.textContent = badgeText
const isHovered = host.hoveredWarp === warp
label.className = isHovered
? 'd2-overhead-label d2-exit-badge is-hovered'
: 'd2-overhead-label d2-exit-badge'
if (label.dataset) {
label.dataset.kind = 'warp'
label.dataset.toLevelId = String(warp.toLevelId)
label.dataset.hovered = isHovered ? 'true' : 'false'
}
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
label.style.padding = '2px 7px'
label.style.borderRadius = '3px'
label.style.fontWeight = isHovered ? 'bold' : 'normal'
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
for (const entrance of runtime.entrances ?? []) {
if (!(entrance.toLevelId > 0)) continue
const pos = getEntranceLabelWorldPos(entrance, runtime.grid)
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
const badgeText = currentLang === 'ENG'
? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
: `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
if (label.textContent !== badgeText) label.textContent = badgeText
const isHovered = host.hoveredEntrance === entrance
label.className = isHovered
? 'd2-overhead-label d2-exit-badge is-hovered'
: 'd2-overhead-label d2-exit-badge'
if (label.dataset) {
label.dataset.kind = 'entrance'
label.dataset.toLevelId = String(entrance.toLevelId)
label.dataset.hovered = isHovered ? 'true' : 'false'
}
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
label.style.padding = '2px 7px'
label.style.borderRadius = '3px'
label.style.fontWeight = isHovered ? 'bold' : 'normal'
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
label.style.left = `${domX}px`
label.style.top = `${domY - 2}px`
label.style.fontSize = fontSizePx
label.hidden = false
labelCount += 1
}
}
for (let index = labelCount; index < host.labelPool.length; index += 1) {
const unused = host.labelPool[index]!
unused.hidden = true
if (unused.dataset && unused.dataset.hovered === 'true') {
unused.dataset.hovered = 'false'
}
if (unused.className.includes('is-hovered')) {
unused.className = unused.className.replace(/\s*is-hovered\b/g, '')
}
}
}
/**
* Project and update floating ground item labels (Alt mode or single hovered item).
*/
export function updateGroundItemLabelsOverlay(
groundLabelOverlay: GroundLabelOverlay | null,
hudManager: HudManager | null,
mouseController: { hoveredGroundItem: GroundItemEntity | null } | null,
engine: GameEngine,
canvas: HTMLCanvasElement,
camera: ViewportCamera,
camX: number,
camY: number,
): void {
if (groundLabelOverlay === null) return
const showLabels = Boolean(hudManager?.showGroundLabels)
let hoveredItem = mouseController?.hoveredGroundItem ?? null
if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) {
if (mouseController) mouseController.hoveredGroundItem = null
hoveredItem = null
}
if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) {
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 }
}
if (showLabels) {
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
groundLabelOverlay.update(true, resolvedLayouts)
} else if (hoveredItem !== null) {
const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen)
groundLabelOverlay.update(true, initialLayouts)
}
} else {
groundLabelOverlay.update(false, [])
}
}