From b839301aae406309d8e57986ad11e062af363edf Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:26:12 +0000 Subject: [PATCH] fix(client): ground items cannot be picked up and holding Alt does not display ground item labels (#555) Closes #555 --- src/client/input/command-mapper.ts | 133 ++++++- src/client/main.ts | 8 +- src/client/scene/world-renderer.ts | 61 ++- src/client/session/online-session.ts | 18 + src/client/ui/hud-manager.ts | 55 ++- src/client/view/unit-drawables.ts | 6 + src/client/world/client-unit.ts | 2 + src/client/world/client-world.ts | 13 +- src/netproto/d2gs/s2c/units.ts | 2 +- ...und-item-pickup-and-alt-labels-555.test.ts | 373 ++++++++++++++++++ 10 files changed, 656 insertions(+), 15 deletions(-) create mode 100644 tests/client/ground-item-pickup-and-alt-labels-555.test.ts diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index b021064..4a32e04 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -161,7 +161,11 @@ export class CommandMapper { private rightMouseDown = false private lastPointerX = 0 private lastPointerY = 0 + private pointerDownX = 0 + private pointerDownY = 0 private hoveredUnit: ClientUnit | null = null + private lockedPointerUnit: ClientUnit | null = null + private pendingItemPickupId: number | null = null private lastPath: readonly SubTilePoint[] = [] /** Default F1..F8 skill hotkey bindings (`key.toUpperCase()` -> binding). */ @@ -232,7 +236,39 @@ export class CommandMapper { this.camera.update(this.world.self.x, this.world.self.y) } const subtile = this.camera.screenToSubtile(screenX, screenY) - const unit = this.pickUnitAtSubtile(subtile.x, subtile.y, hitRadiusSubtiles) + + // 1. Hit-test visible ground item label plaques (`Alt` or hovered item label boxes) + const labelBoxes = this.hudManager.groundItemLabelBoxes + if (labelBoxes && labelBoxes.length > 0) { + for (let i = labelBoxes.length - 1; i >= 0; i--) { + const box = labelBoxes[i]! + if ( + screenX >= box.x && + screenX <= box.x + box.width && + screenY >= box.y && + screenY <= box.y + box.height + ) { + const itemUnit = this.world.getUnit('item', box.id) + if (itemUnit) { + return { + subtileX: itemUnit.x, + subtileY: itemUnit.y, + hoveredUnit: itemUnit, + } + } + } + } + } + + // 2. Hit-test world units, compensating for `world-renderer.ts` `camY = playerWorld.y - 16` + const renderAlignedSubtile = this.camera.screenToSubtile(screenX, screenY - 16) + const unit = this.pickUnitAtSubtile( + subtile.x, + subtile.y, + hitRadiusSubtiles, + renderAlignedSubtile.x, + renderAlignedSubtile.y, + ) return { subtileX: subtile.x, subtileY: subtile.y, @@ -244,17 +280,27 @@ export class CommandMapper { * Pick the closest interactive unit within `radiusSubtiles` of `(subtileX, subtileY)`. * Excludes the local player unit and dead/corpse monsters unless interactable. */ - pickUnitAtSubtile(subtileX: number, subtileY: number, radiusSubtiles = 2.5): ClientUnit | null { + pickUnitAtSubtile( + subtileX: number, + subtileY: number, + radiusSubtiles = 2.5, + altSubtileX?: number, + altSubtileY?: number, + ): ClientUnit | null { const localPlayerId = this.world.selfId const radiusSq = radiusSubtiles * radiusSubtiles let bestUnit: ClientUnit | null = null let bestDistSq = radiusSq + const hasAlt = altSubtileX !== undefined && altSubtileY !== undefined // 1. Check ground items first (unitType 4 = 'item') for (const unit of this.world.getUnitsByType(4)) { const dx = unit.x - subtileX const dy = unit.y - subtileY - const d2 = dx * dx + dy * dy + const d2Primary = dx * dx + dy * dy + const d2 = hasAlt + ? Math.min(d2Primary, (unit.x - altSubtileX!) ** 2 + (unit.y - altSubtileY!) ** 2) + : d2Primary if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) { bestDistSq = d2 bestUnit = unit @@ -269,13 +315,16 @@ export class CommandMapper { if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) { continue } - // Skip dead monsters (mode 12 = Dead, or lifePct <= 0) - if (unitType === 1 && (unit.mode === 12 || unit.lifePct <= 0)) { + // Skip dead monsters (mode 0 = Death, mode 12 = Dead, or lifePct <= 0) + if (unitType === 1 && (unit.mode === 0 || unit.mode === 12 || unit.lifePct <= 0)) { continue } const dx = unit.x - subtileX const dy = unit.y - subtileY - const d2 = dx * dx + dy * dy + const d2Primary = dx * dx + dy * dy + const d2 = hasAlt + ? Math.min(d2Primary, (unit.x - altSubtileX!) ** 2 + (unit.y - altSubtileY!) ** 2) + : d2Primary if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) { bestDistSq = d2 bestUnit = unit @@ -323,6 +372,12 @@ export class CommandMapper { // If holding left mouse button in world space, continuously steer toward cursor if (this.leftMouseDown) { + if (this.lockedPointerUnit?.unitType === 'item') { + if (Math.hypot(screenX - this.pointerDownX, screenY - this.pointerDownY) <= 12) { + return + } + this.lockedPointerUnit = null + } this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } else if (this.rightMouseDown) { this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) @@ -341,6 +396,8 @@ export class CommandMapper { ): DispatchedCommandRecord | null { this.lastPointerX = screenX this.lastPointerY = screenY + this.pointerDownX = screenX + this.pointerDownY = screenY this.hudManager.handleMouseMove(screenX, screenY) // 1. Check external UI interception first @@ -362,11 +419,14 @@ export class CommandMapper { if (button === 0) { this.leftMouseDown = true + this.lockedPointerUnit = + target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } if (button === 2) { this.rightMouseDown = true + this.lockedPointerUnit = null return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } @@ -379,6 +439,7 @@ export class CommandMapper { handlePointerUp(button: number): void { if (button === 0) { this.leftMouseDown = false + this.lockedPointerUnit = null } else if (button === 2) { this.rightMouseDown = false } @@ -400,6 +461,7 @@ export class CommandMapper { // Stand-still modifier (Shift): cast left skill on location/target instead of moving if (this.shiftHeld) { + this.pendingItemPickupId = null if (hoveredUnit && hoveredUnit.unitType === 'monster') { return this.dispatchRateLimited( { @@ -434,11 +496,14 @@ export class CommandMapper { const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning) const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY) if (dist > 4) { + this.pendingItemPickupId = hoveredUnit.id this.sendCommand({ type: 'MoveToUnit', run: isRunning, target: unitToRef(hoveredUnit), }) + } else { + this.pendingItemPickupId = null } return this.dispatchRateLimited( { @@ -451,6 +516,8 @@ export class CommandMapper { ) } + this.pendingItemPickupId = null + if (hoveredUnit.unitType === 'tile' || (hoveredUnit as any).kind === 'warp') { const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning) const moveRec = this.dispatchRateLimited( @@ -567,6 +634,7 @@ export class CommandMapper { } // Ground walk / run + this.pendingItemPickupId = null const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning) const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY } return this.dispatchRateLimited( @@ -589,6 +657,7 @@ export class CommandMapper { hoveredUnit: ClientUnit | null = null, nowMs = performance.now(), ): DispatchedCommandRecord | null { + this.pendingItemPickupId = null if (hoveredUnit) { return this.dispatchRateLimited( { @@ -635,6 +704,8 @@ export class CommandMapper { } if (event.key === 'Alt' || event.altKey) { this.altHeld = true + this.hudManager.showGroundLabels = true + this.hudManager.syncPublishedState() event.preventDefault?.() if (event.key === 'Alt') return true } @@ -843,6 +914,23 @@ export class CommandMapper { } if (event.key === 'Alt' || event.altKey === false) { this.altHeld = false + this.hudManager.showGroundLabels = false + this.hudManager.syncPublishedState() + } + } + + /** + * Reset transient modifier and mouse down states (e.g. on window blur). + */ + resetModifiers(): void { + this.shiftHeld = false + this.altHeld = false + this.leftMouseDown = false + this.rightMouseDown = false + this.lockedPointerUnit = null + if (this.hudManager.showGroundLabels) { + this.hudManager.showGroundLabels = false + this.hudManager.syncPublishedState() } } @@ -851,6 +939,7 @@ export class CommandMapper { /** * Advance periodic input state: * - Flushes any coalesced movement command when its 120ms cooldown expires. + * - Completes deferred arrival pickup when player walks within <= 4 subtiles of a distant clicked item. * - Repeats held mouse actions at rate-limited intervals. */ tick(nowMs = performance.now()): ClientCommand | null { @@ -860,7 +949,39 @@ export class CommandMapper { return flushed } + // Check deferred ground item pickup upon arrival within reach (<= 4 subtiles) + if (this.pendingItemPickupId !== null) { + const pendingItem = this.world.getUnit('item', this.pendingItemPickupId) + if (!pendingItem) { + this.pendingItemPickupId = null + } else { + const predSnap = this.predictor?.getSnapshot() + const selfX = + predSnap && (predSnap.x !== 0 || predSnap.y !== 0) ? predSnap.x : this.world.self.x + const selfY = + predSnap && (predSnap.x !== 0 || predSnap.y !== 0) ? predSnap.y : this.world.self.y + const dist = Math.hypot(pendingItem.x - selfX, pendingItem.y - selfY) + if (dist <= 4) { + const record = this.dispatchRateLimited( + { + type: 'ItemPickup', + itemId: pendingItem.id, + toCursor: false, + }, + nowMs, + ) + if (record.rateLimit.allowed) { + this.pendingItemPickupId = null + return record.command + } + } + } + } + if (this.leftMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) { + if (this.lockedPointerUnit?.unitType === 'item') { + return null + } const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY) const record = this.triggerPrimaryWorldAction( target.subtileX, diff --git a/src/client/main.ts b/src/client/main.ts index 66238f5..bfe3d9f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -843,7 +843,7 @@ async function bootPlayApp(): Promise { } return } - if (e.target === hudCanvas) return + if (e.defaultPrevented) return const pt = toStageCoords(e) session.commandMapper.handlePointerDown(pt.x, pt.y, e.button, performance.now()) }) @@ -912,6 +912,12 @@ async function bootPlayApp(): Promise { true, ) + window.addEventListener('blur', () => { + if (inGameView) { + session.commandMapper.resetModifiers() + } + }) + let lastFrameMs = performance.now() const renderFrame = (nowMs = performance.now()): void => { const dtMs = Math.max(0, Math.min(100, nowMs - lastFrameMs)) diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 3709d94..c948c5d 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -43,7 +43,11 @@ import type { import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts' import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts' import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.ts' -import { layoutGroundItemLabels } from '../../common/items/ground-items.ts' +import { + layoutGroundItemLabels, + resolveGroundItemMetadata, + type GroundItemLabelInput, +} from '../../common/items/ground-items.ts' import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' import { applyLightingInputs, @@ -1040,16 +1044,18 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.flush() } if (ctx.hudManager) { + ctx.hudManager.groundItemLabelBoxes = [] ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot } + const camX = snapshot.playerWorld.x + const camY = snapshot.playerWorld.y - 16 + if (renderer !== null) { ctx.tileGpuCache?.ensureActPalette(snapshot.actId) - const camX = snapshot.playerWorld.x - const camY = snapshot.playerWorld.y - 16 renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03]) if (ctx.lightingTargets) { @@ -1233,6 +1239,55 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram } if (ctx.hudManager) { + const vpW = snapshot.viewportProfile.width + const vpH = snapshot.viewportProfile.height + const showAll = ctx.hudManager.showGroundLabels + const isEn = getTblLang() === 'ENG' + const labelInputs: GroundItemLabelInput[] = [] + for (const u of snapshot.entities.unitDrawables) { + if (u.unitKind !== 'item') continue + if (!showAll && !u.isHovered) continue + const screenX = Math.round(u.worldX - camX + vpW / 2) + const screenY = Math.round(u.worldY - 14 - camY + vpH / 2) + if (screenX < -60 || screenX > vpW + 60 || screenY < -10 || screenY > vpH + 20) { + continue + } + const rawCode = + u.code?.trim().toLowerCase() || + u.itemCode?.trim().toLowerCase() || + (u.token && !u.token.startsWith('ITM_') ? u.token.trim().toLowerCase() : '') || + BAKED_UI_MANIFEST.itemClassIdToCode[u.classId] || + '' + const meta = + !u.itemColor || (!u.name && !u.nameZh) + ? resolveGroundItemMetadata( + { + code: rawCode, + ...(u.itemQuality !== undefined ? { quality: u.itemQuality } : {}), + ...(u.goldAmount !== undefined ? { goldAmount: u.goldAmount } : {}), + ...(u.name !== undefined ? { name: u.name } : {}), + }, + u.goldAmount, + ) + : undefined + const labelText = isEn + ? (u.name || meta?.labelEn || rawCode || `Item #${u.unitId}`) + : (u.nameZh || meta?.labelZh || u.name || meta?.labelEn || rawCode || `Item #${u.unitId}`) + const color = u.itemColor || meta?.color || '#ffffff' + const measuredW = Math.max(40, ctx.hudManager.font.measureText(labelText, 'font16') + 14) + labelInputs.push({ + id: u.unitId, + text: labelText, + label: labelText, + color, + screenX, + screenY, + width: measuredW, + height: 18, + }) + } + ctx.hudManager.groundItemLabelBoxes = + labelInputs.length > 0 ? layoutGroundItemLabels(labelInputs, vpW, vpH) : [] ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 3af1e8c..e60f2d1 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1644,6 +1644,8 @@ export class OnlineSession { this.mapService?.getActiveLevelView() ?? this.automapView.getLevelView() + const hoveredUnit = this.commandMapper.currentHoveredUnit + for (const u of this.world.units.values()) { if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) { continue @@ -1672,6 +1674,9 @@ export class OnlineSession { }) continue } + const isHovered = Boolean( + hoveredUnit && hoveredUnit.unitType === u.unitType && hoveredUnit.id === u.id, + ) const interp = this.interpolator.getEntityState( u.unitType === 'player' ? 0 @@ -1730,6 +1735,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, radiusX: 28, radiusY: 20, + isHovered, }) continue } @@ -1809,6 +1815,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), isCorpse: false, + isHovered, states: Array.from(u.states.list()), ...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}), ...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}), @@ -1839,11 +1846,20 @@ export class OnlineSession { : u.name && u.name.trim() !== '' ? u.name : meta?.name + const itemNameZh = + meta?.isGold && goldAmount !== undefined + ? `${goldAmount} 金币` + : u.nameZh && u.nameZh.trim() !== '' + ? u.nameZh + : meta?.nameZh + const itemColor = u.itemColor ?? meta?.color out.push({ id: u.id, kind: 'item', classId: u.classId, ...(itemName !== undefined ? { name: itemName } : {}), + ...(itemNameZh !== undefined ? { nameZh: itemNameZh } : {}), + ...(itemColor !== undefined ? { itemColor } : {}), ...(itemCode !== undefined ? { code: itemCode, itemCode } : {}), x: ux, y: uy, @@ -1851,6 +1867,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, hpRatio: 1, isCorpse: false, + isHovered, ...(goldAmount !== undefined ? { goldAmount } : {}), ...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}), }) @@ -1880,6 +1897,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), isCorpse, + isHovered, isChampion: u.isChampion, isUnique: u.isUnique, isSuperUnique: u.isSuperUnique, diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index f1b3271..8fe8355 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -13,7 +13,8 @@ * and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite. */ -import { D2FontRenderer } from './font.ts' +import { D2FontRenderer, type D2ColorCode } from './font.ts' +import type { GroundItemLabelBox } from '../../common/items/ground-items.ts' import { GlobesHud } from './globes.ts' import { BeltHud } from './belt.ts' import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts' @@ -406,6 +407,31 @@ export function serverItemIdOf(item: UiInventoryItem): number { return id } +function groundLabelHexToD2Color(hex: string | undefined): D2ColorCode { + switch ((hex ?? '').toLowerCase()) { + case '#c4a060': + case '#d8b864': + case '#c8a15a': + return 'gold' + case '#00fc00': + return 'green' + case '#ffff64': + return 'yellow' + case '#ff8000': + case '#ffa500': + case '#ff9c18': + return 'orange' + case '#4880ff': + case '#6868ff': + return 'blue' + case '#a0a0a0': + case '#696969': + return 'gray' + default: + return 'white' + } +} + export class HudManager { readonly font = new D2FontRenderer() readonly globes = new GlobesHud() @@ -422,6 +448,8 @@ export class HudManager { rightPanel: 'none' | 'inv' | 'skill' = 'none' isRunning = true showGroundLabels = false + /** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */ + groundItemLabelBoxes: readonly GroundItemLabelBox[] = [] /** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */ stamina = 0 maxStamina = 0 @@ -2355,6 +2383,31 @@ export class HudManager { } } + // 0c. Ground Item Labels (`D2Client.dll` Alt / hover item label plaques) + if (this.groundItemLabelBoxes.length > 0) { + ctx.save() + for (const box of this.groundItemLabelBoxes) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.78)' + ctx.fillRect(box.x, box.y, box.width, box.height) + ctx.strokeStyle = 'rgba(96, 84, 56, 0.75)' + ctx.strokeRect( + box.x + 0.5, + box.y + 0.5, + Math.max(0, box.width - 1), + Math.max(0, box.height - 1), + ) + const d2Color = groundLabelHexToD2Color(box.color) + const textCenterX = Math.round(box.x + box.width / 2) + const textBaselineY = Math.round(box.y + box.height - 4) + this.font.drawText(ctx, box.text, textCenterX, textBaselineY, { + font: 'font16', + color: d2Color, + align: 'center', + }) + } + ctx.restore() + } + // 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`) if (this.leftPanel === 'char') { ctx.save() diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index fa4709d..3f2b664 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -60,8 +60,10 @@ export interface RenderableUnitInput { readonly altitude?: number | undefined readonly lifePct?: number | undefined readonly name?: string | undefined + readonly nameZh?: string | undefined readonly code?: string | undefined readonly itemQuality?: number | undefined + readonly itemColor?: string | undefined readonly goldAmount?: number | undefined readonly token?: string | undefined readonly compositeArmor?: Readonly> | undefined @@ -101,6 +103,7 @@ export interface SceneUnitDrawable { readonly token: string readonly code?: string | undefined readonly itemQuality?: number | undefined + readonly itemColor?: string | undefined readonly goldAmount?: number | undefined readonly mode: number /** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */ @@ -114,6 +117,7 @@ export interface SceneUnitDrawable { readonly blendMode: DrawableBlendMode readonly lifePct: number readonly name: string | undefined + readonly nameZh?: string | undefined readonly isHovered: boolean readonly isCorpse: boolean readonly tintColor: readonly [number, number, number] | null @@ -662,6 +666,8 @@ export function buildSceneDrawables( blendMode: 'normal', lifePct: unit.lifePct ?? 100, name: resolvedName, + ...(unit.nameZh !== undefined ? { nameZh: unit.nameZh } : {}), + ...(unit.itemColor !== undefined ? { itemColor: unit.itemColor } : {}), isHovered: Boolean(unit.isHovered), isCorpse, tintColor, diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 0f95e80..66c0370 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -59,8 +59,10 @@ export class ClientUnit { readonly id: number classId: number name?: string | undefined + nameZh?: string | undefined itemCode?: string | undefined itemQuality?: number | undefined + itemColor?: string | undefined goldAmount?: number | undefined x: number y: number diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index ef343f8..ab4ac53 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -2128,8 +2128,8 @@ export class ClientWorld { this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId) if (effectiveContainer === 'ground') { const itemUnit = this.ensureUnit('item', ev.item.id) - itemUnit.x = ev.item.worldX - itemUnit.y = ev.item.worldY + itemUnit.x = ev.item.worldX || ev.item.x || 0 + itemUnit.y = ev.item.worldY || ev.item.y || 0 const trimmedCode = ev.item.code.trim() const goldStatVal = ev.item.stats?.find((s: { statId: number; value: number }) => s.statId === 14)?.value const goldAmt = @@ -2141,7 +2141,14 @@ export class ClientWorld { itemUnit.itemData = ev.item itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3 const uiItem = onlineItemDataToUiInventoryItem(ev.item) - itemUnit.name = uiItem.name || ev.item.code + itemUnit.name = + trimmedCode === 'gld' && goldAmt !== undefined + ? `${goldAmt} Gold` + : (uiItem.name || ev.item.code) + itemUnit.nameZh = + trimmedCode === 'gld' && goldAmt !== undefined + ? `${goldAmt} 金币` + : (uiItem.nameZh || uiItem.baseNameZh || itemUnit.name) } else { this._units.delete(makeUnitKey('item', ev.item.id)) } diff --git a/src/netproto/d2gs/s2c/units.ts b/src/netproto/d2gs/s2c/units.ts index 2ed0d15..ef24e83 100644 --- a/src/netproto/d2gs/s2c/units.ts +++ b/src/netproto/d2gs/s2c/units.ts @@ -94,7 +94,7 @@ export function decode0x11ReportKill(pkt: Uint8Array): ServerEvent { type: 'UnitMode', unitType, id, - mode: 0x09, + mode: unitType === 0 ? 17 : resolveMonsterCmdMode(0x09), lifePct: 0, } } diff --git a/tests/client/ground-item-pickup-and-alt-labels-555.test.ts b/tests/client/ground-item-pickup-and-alt-labels-555.test.ts new file mode 100644 index 0000000..36cb1fa --- /dev/null +++ b/tests/client/ground-item-pickup-and-alt-labels-555.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi } from 'vitest' +import { decode0x11ReportKill } from '../../src/netproto/d2gs/s2c/units.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { CommandMapper } from '../../src/client/input/command-mapper.ts' +import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' +import type { SceneSource, SceneFrameSnapshot } from '../../src/client/view/scene-source.ts' +import { VIEWPORT_800x600 } from '../../src/client/view/viewport-profile.ts' +import { buildSceneDrawables } from '../../src/client/view/unit-drawables.ts' +import { setTblLang } from '../../src/client/i18n/lang.ts' + +describe('Issue #555: Ground items pickup, arrival trigger, and Alt labels', () => { + it('0x11 ReportKill maps monster kills to mode 12 (MONMODE_DEAD) and player kills to mode 17 (PLRMODE_DEAD)', () => { + // Monster kill: unitType 1, id 100, killer 0x59 + const monsterKillBytes = new Uint8Array([ + 0x11, + 0x01, + 0x64, 0x00, 0x00, 0x00, // unitId 100 + 0x09, // raw mode in packet + 0x59, 0x00, 0x00, 0x00, // killerId + ]) + const monsterEv = decode0x11ReportKill(monsterKillBytes) + expect(monsterEv.type).toBe('UnitMode') + if (monsterEv.type === 'UnitMode') { + expect(monsterEv.unitType).toBe(1) + expect(monsterEv.id).toBe(100) + expect(monsterEv.mode).toBe(12) // MONMODE_DEAD + } + + // Player kill: unitType 0, id 200, killer 0x10 + const playerKillBytes = new Uint8Array([ + 0x11, + 0x00, + 0xc8, 0x00, 0x00, 0x00, // unitId 200 + 0x09, // raw mode in packet + 0x10, 0x00, 0x00, 0x00, // killerId + ]) + const playerEv = decode0x11ReportKill(playerKillBytes) + expect(playerEv.type).toBe('UnitMode') + if (playerEv.type === 'UnitMode') { + expect(playerEv.unitType).toBe(0) + expect(playerEv.id).toBe(200) + expect(playerEv.mode).toBe(17) // PLRMODE_DEAD + } + }) + + it('Alt keydown/keyup synchronizes HudManager.showGroundLabels and published state', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const sentCommands: any[] = [] + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: (cmd) => sentCommands.push(cmd), + }) + + expect(hudManager.showGroundLabels).toBe(false) + expect(commandMapper.isAltHeld).toBe(false) + + // Press Alt + const handledDown = commandMapper.handleKeyDown({ key: 'Alt', preventDefault: vi.fn() }) + expect(handledDown).toBe(true) + expect(commandMapper.isAltHeld).toBe(true) + expect(hudManager.showGroundLabels).toBe(true) + expect(hudManager.state.showGroundLabels).toBe(true) + + // Release Alt + commandMapper.handleKeyUp({ key: 'Alt' }) + expect(commandMapper.isAltHeld).toBe(false) + expect(hudManager.showGroundLabels).toBe(false) + expect(hudManager.state.showGroundLabels).toBe(false) + + // Press Alt again and blur window + commandMapper.handleKeyDown({ key: 'Alt' }) + expect(commandMapper.isAltHeld).toBe(true) + expect(hudManager.showGroundLabels).toBe(true) + + commandMapper.resetModifiers() + expect(commandMapper.isAltHeld).toBe(false) + expect(hudManager.showGroundLabels).toBe(false) + expect(hudManager.state.showGroundLabels).toBe(false) + }) + + it('renderSceneSourceFrame computes groundItemLabelBoxes with localized text and 1.13c colors when Alt is held or item is hovered', () => { + const hudManager = new HudManager() + setTblLang('ENG') + const mockCtx = { + save: vi.fn(), + restore: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + fillText: vi.fn(), + scale: vi.fn(), + translate: vi.fn(), + measureText: (text: string) => ({ width: text.length * 8 }), + } as unknown as CanvasRenderingContext2D + hudManager.ctx = mockCtx + + // Create scene units: gold pile, a unique short sword, and a normal potion + const entities = buildSceneDrawables([ + { + id: 101, + kind: 'item', + classId: 0, + x: 100, + y: 100, + code: 'gld', + goldAmount: 250, + name: '250 Gold', + nameZh: '250 金币', + itemColor: '#d8b864', + mode: 3, + }, + { + id: 102, + kind: 'item', + classId: 1, + x: 102, + y: 101, + code: 'ssd', + name: 'Rixot\'s Keen', + nameZh: '瑞克希特的开胃甜点', + itemQuality: 7, + itemColor: '#c4a060', + mode: 3, + isHovered: true, // Hovered item + }, + { + id: 103, + kind: 'item', + classId: 2, + x: 104, + y: 102, + code: 'hp1', + name: 'Minor Healing Potion', + nameZh: '微型治疗药剂', + itemQuality: 2, + itemColor: '#ffffff', + mode: 3, + }, + ]) + + const mockSnapshot: SceneFrameSnapshot = { + tick: 1, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 100, y: 100 }, + playerLocalSubTile: { x: 100, y: 100 }, + playerWorld: { x: 0, y: 1600 }, + cameraTopLeft: { x: -400, y: 1300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities, + } + + const mockSource: SceneSource = { + getViewportProfile: () => VIEWPORT_800x600, + setViewportProfile: () => VIEWPORT_800x600, + getActId: () => 1, + getLevelId: () => 1, + getTick: () => 1, + getPlayerSubTile: () => ({ x: 100, y: 100 }), + isWorldReady: () => true, + getPlayerLocalSubTile: () => ({ x: 100, y: 100 }), + getPlayerWorldOrtho: () => ({ x: 0, y: 1600 }), + getCameraTopLeft: () => ({ x: -400, y: 1300 }), + getLevelView: () => null, + isSubTileWalkable: () => true, + buildFrameSnapshot: () => mockSnapshot, + buildLightingInputs: () => ({} as any), + pickUnitAtScreen: () => null, + } + + // 1. Without Alt: only hovered item (id 102) gets a label box + hudManager.showGroundLabels = false + renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' }) + + expect(hudManager.groundItemLabelBoxes.length).toBe(1) + expect(hudManager.groundItemLabelBoxes[0]!.id).toBe(102) + expect(hudManager.groundItemLabelBoxes[0]!.text).toBe('Rixot\'s Keen') + expect(hudManager.groundItemLabelBoxes[0]!.color).toBe('#c4a060') + + // 2. With Alt: all 3 ground items get label boxes + hudManager.showGroundLabels = true + renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' }) + + expect(hudManager.groundItemLabelBoxes.length).toBe(3) + const boxIds = hudManager.groundItemLabelBoxes.map((b) => b.id) + expect(boxIds).toContain(101) + expect(boxIds).toContain(102) + expect(boxIds).toContain(103) + + const goldBox = hudManager.groundItemLabelBoxes.find((b) => b.id === 101)! + expect(goldBox.text).toBe('250 Gold') + expect(goldBox.color).toBe('#d8b864') + + // 3. In Chinese locale + setTblLang('CHI') + renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' }) + const swordBoxZh = hudManager.groundItemLabelBoxes.find((b) => b.id === 102)! + expect(swordBoxZh.text).toBe('瑞克希特的开胃甜点') + }) + + it('CommandMapper.resolvePointerTarget resolves ground item when clicking directly on its label box', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: () => {}, + }) + + // Simulate an item on the ground via ItemEvent + world.applyServerEvent({ + type: 'ItemEvent', + action: 0x03, + container: 'ground', + item: { + id: 777, + action: 0x03, + code: 'rin', + worldX: 105, + worldY: 105, + x: 105, + y: 105, + } as any, + }) + + // Position label box on screen at x: 380..460, y: 220..238 + hudManager.groundItemLabelBoxes = [ + { + id: 777, + text: 'Bronze Ring', + label: 'Bronze Ring', + color: '#4880ff', + x: 380, + y: 220, + width: 80, + height: 18, + }, + ] + + // Click inside the label box at (400, 228) + const target = commandMapper.resolvePointerTarget(400, 228) + expect(target.hoveredUnit).not.toBeNull() + expect(target.hoveredUnit!.id).toBe(777) + expect(target.hoveredUnit!.unitType).toBe('item') + expect(target.subtileX).toBe(105) + expect(target.subtileY).toBe(105) + }) + + it('CommandMapper handles distant item pickup (dist > 4) and dispatches ItemPickup upon arrival via tick', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const sentCommands: any[] = [] + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: (cmd) => sentCommands.push(cmd), + }) + + // Player starts at (100, 100) + world.self.x = 100 + world.self.y = 100 + commandMapper.getCamera().snapTo(100, 100) + + // Distant item at (108, 100) -> distance = 8 (> 4) + world.applyServerEvent({ + type: 'ItemEvent', + action: 0x03, + container: 'ground', + item: { + id: 888, + action: 0x03, + code: 'bsd', + worldX: 108, + worldY: 100, + x: 108, + y: 100, + } as any, + }) + const itemUnit = world.getUnit('item', 888)! + + // Click on distant item + const rec = commandMapper.triggerPrimaryWorldAction(108, 100, itemUnit, 1000) + expect(rec).not.toBeNull() + // Initial click sends MoveToUnit and attempts ItemPickup + expect(sentCommands.some((c) => c.type === 'MoveToUnit' && c.target.id === 888)).toBe(true) + expect(sentCommands.some((c) => c.type === 'ItemPickup' && c.itemId === 888)).toBe(true) + + // Player moves toward the item: at (105, 100), distance is 3 (<= 4) + world.self.x = 105 + world.self.y = 100 + + // Clear sent commands to observe arrival pickup + sentCommands.length = 0 + + // Advance tick at 1150ms (> 80ms interaction cooldown) + const tickResult = commandMapper.tick(1150) + expect(tickResult).not.toBeNull() + expect(tickResult!.type).toBe('ItemPickup') + expect((tickResult as any).itemId).toBe(888) + expect(sentCommands.some((c) => c.type === 'ItemPickup' && c.itemId === 888)).toBe(true) + + // Subsequent tick does not re-emit once picked up + sentCommands.length = 0 + const nextTick = commandMapper.tick(1200) + expect(nextTick).toBeNull() + expect(sentCommands.length).toBe(0) + }) + + it('pickUnitAtSubtile skips dead monsters with mode 0 (Death) or mode 12 (Dead)', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: () => {}, + }) + + // Dead monster at (100, 100) with mode 12 + world.applyServerEvent({ + type: 'UnitAssign', + unit: { + unitType: 'monster', + id: 301, + classId: 1, + x: 100, + y: 100, + mode: 12, + lifePct: 0, + }, + }) + + // Dying monster at (100, 100) with mode 0 + world.applyServerEvent({ + type: 'UnitAssign', + unit: { + unitType: 'monster', + id: 302, + classId: 1, + x: 100, + y: 100, + mode: 0, + lifePct: 0, + }, + }) + + // Alive monster at (101, 100) with mode 1 + world.applyServerEvent({ + type: 'UnitAssign', + unit: { + unitType: 'monster', + id: 303, + classId: 1, + x: 101, + y: 100, + mode: 1, + lifePct: 100, + }, + }) + + const picked = commandMapper.pickUnitAtSubtile(100, 100, 2.5) + expect(picked).not.toBeNull() + expect(picked!.id).toBe(303) // Picked the alive monster, ignored 301 and 302 + }) +})