463 lines
18 KiB
TypeScript
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, [])
|
|
}
|
|
}
|