374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
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
|
|
})
|
|
})
|