fix(client): ground items cannot be picked up and holding Alt does not display ground item labels (#555) #560
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function decode0x11ReportKill(pkt: Uint8Array): ServerEvent {
|
|||
type: 'UnitMode',
|
||||
unitType,
|
||||
id,
|
||||
mode: 0x09,
|
||||
mode: unitType === 0 ? 17 : resolveMonsterCmdMode(0x09),
|
||||
lifePct: 0,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue