fix(client): ground items cannot be picked up and holding Alt does not display ground item labels (Closes #555)

This commit is contained in:
troytt 2026-10-02 10:07:31 +00:00
parent 7bd10903f6
commit ad3533a7d2
10 changed files with 656 additions and 15 deletions

View File

@ -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,

View File

@ -843,7 +843,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
}
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<PlayAppController> {
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))

View File

@ -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)

View File

@ -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,

View File

@ -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()

View File

@ -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<Record<string, string>> | 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,

View File

@ -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

View File

@ -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))
}

View File

@ -94,7 +94,7 @@ export function decode0x11ReportKill(pkt: Uint8Array): ServerEvent {
type: 'UnitMode',
unitType,
id,
mode: 0x09,
mode: unitType === 0 ? 17 : resolveMonsterCmdMode(0x09),
lifePct: 0,
}
}

View File

@ -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
})
})