fix(client): ground items cannot be picked up and holding Alt does not display ground item labels (Closes #555)
This commit is contained in:
parent
7bd10903f6
commit
ad3533a7d2
|
|
@ -161,7 +161,11 @@ export class CommandMapper {
|
||||||
private rightMouseDown = false
|
private rightMouseDown = false
|
||||||
private lastPointerX = 0
|
private lastPointerX = 0
|
||||||
private lastPointerY = 0
|
private lastPointerY = 0
|
||||||
|
private pointerDownX = 0
|
||||||
|
private pointerDownY = 0
|
||||||
private hoveredUnit: ClientUnit | null = null
|
private hoveredUnit: ClientUnit | null = null
|
||||||
|
private lockedPointerUnit: ClientUnit | null = null
|
||||||
|
private pendingItemPickupId: number | null = null
|
||||||
private lastPath: readonly SubTilePoint[] = []
|
private lastPath: readonly SubTilePoint[] = []
|
||||||
|
|
||||||
/** Default F1..F8 skill hotkey bindings (`key.toUpperCase()` -> binding). */
|
/** 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)
|
this.camera.update(this.world.self.x, this.world.self.y)
|
||||||
}
|
}
|
||||||
const subtile = this.camera.screenToSubtile(screenX, screenY)
|
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 {
|
return {
|
||||||
subtileX: subtile.x,
|
subtileX: subtile.x,
|
||||||
subtileY: subtile.y,
|
subtileY: subtile.y,
|
||||||
|
|
@ -244,17 +280,27 @@ export class CommandMapper {
|
||||||
* Pick the closest interactive unit within `radiusSubtiles` of `(subtileX, subtileY)`.
|
* Pick the closest interactive unit within `radiusSubtiles` of `(subtileX, subtileY)`.
|
||||||
* Excludes the local player unit and dead/corpse monsters unless interactable.
|
* 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 localPlayerId = this.world.selfId
|
||||||
const radiusSq = radiusSubtiles * radiusSubtiles
|
const radiusSq = radiusSubtiles * radiusSubtiles
|
||||||
let bestUnit: ClientUnit | null = null
|
let bestUnit: ClientUnit | null = null
|
||||||
let bestDistSq = radiusSq
|
let bestDistSq = radiusSq
|
||||||
|
const hasAlt = altSubtileX !== undefined && altSubtileY !== undefined
|
||||||
|
|
||||||
// 1. Check ground items first (unitType 4 = 'item')
|
// 1. Check ground items first (unitType 4 = 'item')
|
||||||
for (const unit of this.world.getUnitsByType(4)) {
|
for (const unit of this.world.getUnitsByType(4)) {
|
||||||
const dx = unit.x - subtileX
|
const dx = unit.x - subtileX
|
||||||
const dy = unit.y - subtileY
|
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)) {
|
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
|
||||||
bestDistSq = d2
|
bestDistSq = d2
|
||||||
bestUnit = unit
|
bestUnit = unit
|
||||||
|
|
@ -269,13 +315,16 @@ export class CommandMapper {
|
||||||
if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) {
|
if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip dead monsters (mode 12 = Dead, or lifePct <= 0)
|
// Skip dead monsters (mode 0 = Death, mode 12 = Dead, or lifePct <= 0)
|
||||||
if (unitType === 1 && (unit.mode === 12 || unit.lifePct <= 0)) {
|
if (unitType === 1 && (unit.mode === 0 || unit.mode === 12 || unit.lifePct <= 0)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const dx = unit.x - subtileX
|
const dx = unit.x - subtileX
|
||||||
const dy = unit.y - subtileY
|
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)) {
|
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
|
||||||
bestDistSq = d2
|
bestDistSq = d2
|
||||||
bestUnit = unit
|
bestUnit = unit
|
||||||
|
|
@ -323,6 +372,12 @@ export class CommandMapper {
|
||||||
|
|
||||||
// If holding left mouse button in world space, continuously steer toward cursor
|
// If holding left mouse button in world space, continuously steer toward cursor
|
||||||
if (this.leftMouseDown) {
|
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)
|
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
} else if (this.rightMouseDown) {
|
} else if (this.rightMouseDown) {
|
||||||
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
|
|
@ -341,6 +396,8 @@ export class CommandMapper {
|
||||||
): DispatchedCommandRecord | null {
|
): DispatchedCommandRecord | null {
|
||||||
this.lastPointerX = screenX
|
this.lastPointerX = screenX
|
||||||
this.lastPointerY = screenY
|
this.lastPointerY = screenY
|
||||||
|
this.pointerDownX = screenX
|
||||||
|
this.pointerDownY = screenY
|
||||||
this.hudManager.handleMouseMove(screenX, screenY)
|
this.hudManager.handleMouseMove(screenX, screenY)
|
||||||
|
|
||||||
// 1. Check external UI interception first
|
// 1. Check external UI interception first
|
||||||
|
|
@ -362,11 +419,14 @@ export class CommandMapper {
|
||||||
|
|
||||||
if (button === 0) {
|
if (button === 0) {
|
||||||
this.leftMouseDown = true
|
this.leftMouseDown = true
|
||||||
|
this.lockedPointerUnit =
|
||||||
|
target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null
|
||||||
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (button === 2) {
|
if (button === 2) {
|
||||||
this.rightMouseDown = true
|
this.rightMouseDown = true
|
||||||
|
this.lockedPointerUnit = null
|
||||||
return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +439,7 @@ export class CommandMapper {
|
||||||
handlePointerUp(button: number): void {
|
handlePointerUp(button: number): void {
|
||||||
if (button === 0) {
|
if (button === 0) {
|
||||||
this.leftMouseDown = false
|
this.leftMouseDown = false
|
||||||
|
this.lockedPointerUnit = null
|
||||||
} else if (button === 2) {
|
} else if (button === 2) {
|
||||||
this.rightMouseDown = false
|
this.rightMouseDown = false
|
||||||
}
|
}
|
||||||
|
|
@ -400,6 +461,7 @@ export class CommandMapper {
|
||||||
|
|
||||||
// Stand-still modifier (Shift): cast left skill on location/target instead of moving
|
// Stand-still modifier (Shift): cast left skill on location/target instead of moving
|
||||||
if (this.shiftHeld) {
|
if (this.shiftHeld) {
|
||||||
|
this.pendingItemPickupId = null
|
||||||
if (hoveredUnit && hoveredUnit.unitType === 'monster') {
|
if (hoveredUnit && hoveredUnit.unitType === 'monster') {
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
{
|
{
|
||||||
|
|
@ -434,11 +496,14 @@ export class CommandMapper {
|
||||||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
||||||
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
|
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
|
||||||
if (dist > 4) {
|
if (dist > 4) {
|
||||||
|
this.pendingItemPickupId = hoveredUnit.id
|
||||||
this.sendCommand({
|
this.sendCommand({
|
||||||
type: 'MoveToUnit',
|
type: 'MoveToUnit',
|
||||||
run: isRunning,
|
run: isRunning,
|
||||||
target: unitToRef(hoveredUnit),
|
target: unitToRef(hoveredUnit),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
this.pendingItemPickupId = null
|
||||||
}
|
}
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
{
|
{
|
||||||
|
|
@ -451,6 +516,8 @@ export class CommandMapper {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pendingItemPickupId = null
|
||||||
|
|
||||||
if (hoveredUnit.unitType === 'tile' || (hoveredUnit as any).kind === 'warp') {
|
if (hoveredUnit.unitType === 'tile' || (hoveredUnit as any).kind === 'warp') {
|
||||||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
||||||
const moveRec = this.dispatchRateLimited(
|
const moveRec = this.dispatchRateLimited(
|
||||||
|
|
@ -567,6 +634,7 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ground walk / run
|
// Ground walk / run
|
||||||
|
this.pendingItemPickupId = null
|
||||||
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
|
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
|
||||||
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
|
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
|
|
@ -589,6 +657,7 @@ export class CommandMapper {
|
||||||
hoveredUnit: ClientUnit | null = null,
|
hoveredUnit: ClientUnit | null = null,
|
||||||
nowMs = performance.now(),
|
nowMs = performance.now(),
|
||||||
): DispatchedCommandRecord | null {
|
): DispatchedCommandRecord | null {
|
||||||
|
this.pendingItemPickupId = null
|
||||||
if (hoveredUnit) {
|
if (hoveredUnit) {
|
||||||
return this.dispatchRateLimited(
|
return this.dispatchRateLimited(
|
||||||
{
|
{
|
||||||
|
|
@ -635,6 +704,8 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
if (event.key === 'Alt' || event.altKey) {
|
if (event.key === 'Alt' || event.altKey) {
|
||||||
this.altHeld = true
|
this.altHeld = true
|
||||||
|
this.hudManager.showGroundLabels = true
|
||||||
|
this.hudManager.syncPublishedState()
|
||||||
event.preventDefault?.()
|
event.preventDefault?.()
|
||||||
if (event.key === 'Alt') return true
|
if (event.key === 'Alt') return true
|
||||||
}
|
}
|
||||||
|
|
@ -843,6 +914,23 @@ export class CommandMapper {
|
||||||
}
|
}
|
||||||
if (event.key === 'Alt' || event.altKey === false) {
|
if (event.key === 'Alt' || event.altKey === false) {
|
||||||
this.altHeld = 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:
|
* Advance periodic input state:
|
||||||
* - Flushes any coalesced movement command when its 120ms cooldown expires.
|
* - 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.
|
* - Repeats held mouse actions at rate-limited intervals.
|
||||||
*/
|
*/
|
||||||
tick(nowMs = performance.now()): ClientCommand | null {
|
tick(nowMs = performance.now()): ClientCommand | null {
|
||||||
|
|
@ -860,7 +949,39 @@ export class CommandMapper {
|
||||||
return flushed
|
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.leftMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
|
||||||
|
if (this.lockedPointerUnit?.unitType === 'item') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
|
||||||
const record = this.triggerPrimaryWorldAction(
|
const record = this.triggerPrimaryWorldAction(
|
||||||
target.subtileX,
|
target.subtileX,
|
||||||
|
|
|
||||||
|
|
@ -843,7 +843,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.target === hudCanvas) return
|
if (e.defaultPrevented) return
|
||||||
const pt = toStageCoords(e)
|
const pt = toStageCoords(e)
|
||||||
session.commandMapper.handlePointerDown(pt.x, pt.y, e.button, performance.now())
|
session.commandMapper.handlePointerDown(pt.x, pt.y, e.button, performance.now())
|
||||||
})
|
})
|
||||||
|
|
@ -912,6 +912,12 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
window.addEventListener('blur', () => {
|
||||||
|
if (inGameView) {
|
||||||
|
session.commandMapper.resetModifiers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let lastFrameMs = performance.now()
|
let lastFrameMs = performance.now()
|
||||||
const renderFrame = (nowMs = performance.now()): void => {
|
const renderFrame = (nowMs = performance.now()): void => {
|
||||||
const dtMs = Math.max(0, Math.min(100, nowMs - lastFrameMs))
|
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 { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
|
||||||
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
||||||
import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.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 { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
||||||
import {
|
import {
|
||||||
applyLightingInputs,
|
applyLightingInputs,
|
||||||
|
|
@ -1040,16 +1044,18 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
renderer.flush()
|
renderer.flush()
|
||||||
}
|
}
|
||||||
if (ctx.hudManager) {
|
if (ctx.hudManager) {
|
||||||
|
ctx.hudManager.groundItemLabelBoxes = []
|
||||||
ctx.hudManager.render(nowMs)
|
ctx.hudManager.render(nowMs)
|
||||||
}
|
}
|
||||||
ctx.onFrameSnapshot?.(snapshot)
|
ctx.onFrameSnapshot?.(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const camX = snapshot.playerWorld.x
|
||||||
|
const camY = snapshot.playerWorld.y - 16
|
||||||
|
|
||||||
if (renderer !== null) {
|
if (renderer !== null) {
|
||||||
ctx.tileGpuCache?.ensureActPalette(snapshot.actId)
|
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])
|
renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03])
|
||||||
|
|
||||||
if (ctx.lightingTargets) {
|
if (ctx.lightingTargets) {
|
||||||
|
|
@ -1233,6 +1239,55 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.hudManager) {
|
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.hudManager.render(nowMs)
|
||||||
}
|
}
|
||||||
ctx.onFrameSnapshot?.(snapshot)
|
ctx.onFrameSnapshot?.(snapshot)
|
||||||
|
|
|
||||||
|
|
@ -1644,6 +1644,8 @@ export class OnlineSession {
|
||||||
this.mapService?.getActiveLevelView() ??
|
this.mapService?.getActiveLevelView() ??
|
||||||
this.automapView.getLevelView()
|
this.automapView.getLevelView()
|
||||||
|
|
||||||
|
const hoveredUnit = this.commandMapper.currentHoveredUnit
|
||||||
|
|
||||||
for (const u of this.world.units.values()) {
|
for (const u of this.world.units.values()) {
|
||||||
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -1672,6 +1674,9 @@ export class OnlineSession {
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const isHovered = Boolean(
|
||||||
|
hoveredUnit && hoveredUnit.unitType === u.unitType && hoveredUnit.id === u.id,
|
||||||
|
)
|
||||||
const interp = this.interpolator.getEntityState(
|
const interp = this.interpolator.getEntityState(
|
||||||
u.unitType === 'player'
|
u.unitType === 'player'
|
||||||
? 0
|
? 0
|
||||||
|
|
@ -1730,6 +1735,7 @@ export class OnlineSession {
|
||||||
direction: interp ? interp.dir64 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
radiusX: 28,
|
radiusX: 28,
|
||||||
radiusY: 20,
|
radiusY: 20,
|
||||||
|
isHovered,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1809,6 +1815,7 @@ export class OnlineSession {
|
||||||
direction: interp ? interp.dir64 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||||||
isCorpse: false,
|
isCorpse: false,
|
||||||
|
isHovered,
|
||||||
states: Array.from(u.states.list()),
|
states: Array.from(u.states.list()),
|
||||||
...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}),
|
...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}),
|
||||||
...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}),
|
...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}),
|
||||||
|
|
@ -1839,11 +1846,20 @@ export class OnlineSession {
|
||||||
: u.name && u.name.trim() !== ''
|
: u.name && u.name.trim() !== ''
|
||||||
? u.name
|
? u.name
|
||||||
: meta?.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({
|
out.push({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
kind: 'item',
|
kind: 'item',
|
||||||
classId: u.classId,
|
classId: u.classId,
|
||||||
...(itemName !== undefined ? { name: itemName } : {}),
|
...(itemName !== undefined ? { name: itemName } : {}),
|
||||||
|
...(itemNameZh !== undefined ? { nameZh: itemNameZh } : {}),
|
||||||
|
...(itemColor !== undefined ? { itemColor } : {}),
|
||||||
...(itemCode !== undefined ? { code: itemCode, itemCode } : {}),
|
...(itemCode !== undefined ? { code: itemCode, itemCode } : {}),
|
||||||
x: ux,
|
x: ux,
|
||||||
y: uy,
|
y: uy,
|
||||||
|
|
@ -1851,6 +1867,7 @@ export class OnlineSession {
|
||||||
direction: interp ? interp.dir64 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
hpRatio: 1,
|
hpRatio: 1,
|
||||||
isCorpse: false,
|
isCorpse: false,
|
||||||
|
isHovered,
|
||||||
...(goldAmount !== undefined ? { goldAmount } : {}),
|
...(goldAmount !== undefined ? { goldAmount } : {}),
|
||||||
...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}),
|
...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}),
|
||||||
})
|
})
|
||||||
|
|
@ -1880,6 +1897,7 @@ export class OnlineSession {
|
||||||
direction: interp ? interp.dir64 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||||||
isCorpse,
|
isCorpse,
|
||||||
|
isHovered,
|
||||||
isChampion: u.isChampion,
|
isChampion: u.isChampion,
|
||||||
isUnique: u.isUnique,
|
isUnique: u.isUnique,
|
||||||
isSuperUnique: u.isSuperUnique,
|
isSuperUnique: u.isSuperUnique,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
* and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite.
|
* 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 { GlobesHud } from './globes.ts'
|
||||||
import { BeltHud } from './belt.ts'
|
import { BeltHud } from './belt.ts'
|
||||||
import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts'
|
import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts'
|
||||||
|
|
@ -406,6 +407,31 @@ export function serverItemIdOf(item: UiInventoryItem): number {
|
||||||
return id
|
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 {
|
export class HudManager {
|
||||||
readonly font = new D2FontRenderer()
|
readonly font = new D2FontRenderer()
|
||||||
readonly globes = new GlobesHud()
|
readonly globes = new GlobesHud()
|
||||||
|
|
@ -422,6 +448,8 @@ export class HudManager {
|
||||||
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
||||||
isRunning = true
|
isRunning = true
|
||||||
showGroundLabels = false
|
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. */
|
/** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
|
||||||
stamina = 0
|
stamina = 0
|
||||||
maxStamina = 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`)
|
// 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`)
|
||||||
if (this.leftPanel === 'char') {
|
if (this.leftPanel === 'char') {
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,10 @@ export interface RenderableUnitInput {
|
||||||
readonly altitude?: number | undefined
|
readonly altitude?: number | undefined
|
||||||
readonly lifePct?: number | undefined
|
readonly lifePct?: number | undefined
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
|
readonly nameZh?: string | undefined
|
||||||
readonly code?: string | undefined
|
readonly code?: string | undefined
|
||||||
readonly itemQuality?: number | undefined
|
readonly itemQuality?: number | undefined
|
||||||
|
readonly itemColor?: string | undefined
|
||||||
readonly goldAmount?: number | undefined
|
readonly goldAmount?: number | undefined
|
||||||
readonly token?: string | undefined
|
readonly token?: string | undefined
|
||||||
readonly compositeArmor?: Readonly<Record<string, string>> | undefined
|
readonly compositeArmor?: Readonly<Record<string, string>> | undefined
|
||||||
|
|
@ -101,6 +103,7 @@ export interface SceneUnitDrawable {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly code?: string | undefined
|
readonly code?: string | undefined
|
||||||
readonly itemQuality?: number | undefined
|
readonly itemQuality?: number | undefined
|
||||||
|
readonly itemColor?: string | undefined
|
||||||
readonly goldAmount?: number | undefined
|
readonly goldAmount?: number | undefined
|
||||||
readonly mode: number
|
readonly mode: number
|
||||||
/** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */
|
/** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */
|
||||||
|
|
@ -114,6 +117,7 @@ export interface SceneUnitDrawable {
|
||||||
readonly blendMode: DrawableBlendMode
|
readonly blendMode: DrawableBlendMode
|
||||||
readonly lifePct: number
|
readonly lifePct: number
|
||||||
readonly name: string | undefined
|
readonly name: string | undefined
|
||||||
|
readonly nameZh?: string | undefined
|
||||||
readonly isHovered: boolean
|
readonly isHovered: boolean
|
||||||
readonly isCorpse: boolean
|
readonly isCorpse: boolean
|
||||||
readonly tintColor: readonly [number, number, number] | null
|
readonly tintColor: readonly [number, number, number] | null
|
||||||
|
|
@ -662,6 +666,8 @@ export function buildSceneDrawables(
|
||||||
blendMode: 'normal',
|
blendMode: 'normal',
|
||||||
lifePct: unit.lifePct ?? 100,
|
lifePct: unit.lifePct ?? 100,
|
||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
|
...(unit.nameZh !== undefined ? { nameZh: unit.nameZh } : {}),
|
||||||
|
...(unit.itemColor !== undefined ? { itemColor: unit.itemColor } : {}),
|
||||||
isHovered: Boolean(unit.isHovered),
|
isHovered: Boolean(unit.isHovered),
|
||||||
isCorpse,
|
isCorpse,
|
||||||
tintColor,
|
tintColor,
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@ export class ClientUnit {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
classId: number
|
classId: number
|
||||||
name?: string | undefined
|
name?: string | undefined
|
||||||
|
nameZh?: string | undefined
|
||||||
itemCode?: string | undefined
|
itemCode?: string | undefined
|
||||||
itemQuality?: number | undefined
|
itemQuality?: number | undefined
|
||||||
|
itemColor?: string | undefined
|
||||||
goldAmount?: number | undefined
|
goldAmount?: number | undefined
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|
|
||||||
|
|
@ -2128,8 +2128,8 @@ export class ClientWorld {
|
||||||
this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId)
|
this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId)
|
||||||
if (effectiveContainer === 'ground') {
|
if (effectiveContainer === 'ground') {
|
||||||
const itemUnit = this.ensureUnit('item', ev.item.id)
|
const itemUnit = this.ensureUnit('item', ev.item.id)
|
||||||
itemUnit.x = ev.item.worldX
|
itemUnit.x = ev.item.worldX || ev.item.x || 0
|
||||||
itemUnit.y = ev.item.worldY
|
itemUnit.y = ev.item.worldY || ev.item.y || 0
|
||||||
const trimmedCode = ev.item.code.trim()
|
const trimmedCode = ev.item.code.trim()
|
||||||
const goldStatVal = ev.item.stats?.find((s: { statId: number; value: number }) => s.statId === 14)?.value
|
const goldStatVal = ev.item.stats?.find((s: { statId: number; value: number }) => s.statId === 14)?.value
|
||||||
const goldAmt =
|
const goldAmt =
|
||||||
|
|
@ -2141,7 +2141,14 @@ export class ClientWorld {
|
||||||
itemUnit.itemData = ev.item
|
itemUnit.itemData = ev.item
|
||||||
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
|
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
|
||||||
const uiItem = onlineItemDataToUiInventoryItem(ev.item)
|
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 {
|
} else {
|
||||||
this._units.delete(makeUnitKey('item', ev.item.id))
|
this._units.delete(makeUnitKey('item', ev.item.id))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export function decode0x11ReportKill(pkt: Uint8Array): ServerEvent {
|
||||||
type: 'UnitMode',
|
type: 'UnitMode',
|
||||||
unitType,
|
unitType,
|
||||||
id,
|
id,
|
||||||
mode: 0x09,
|
mode: unitType === 0 ? 17 : resolveMonsterCmdMode(0x09),
|
||||||
lifePct: 0,
|
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