feat(interaction): implement authentic ground item & gold pickup with pathfinding (Fixes #391)

This commit is contained in:
troytt 2026-09-23 03:12:06 +00:00
parent 7d7925ab2b
commit 53a9ef3f9b
4 changed files with 375 additions and 11 deletions

View File

@ -594,6 +594,57 @@ export class GameEngine {
})
return entity
}
/**
* Pick up gold from the ground by its ground item id.
*/
pickupGold(id: string): { readonly success: boolean; readonly amount: number } {
const entity = this.groundItems.get(id)
if (!entity || !entity.isGold) {
return { success: false, amount: 0 }
}
const amount = entity.amount || 1
this.groundItems.remove(id)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 16)
if (gIdx !== -1) {
this.ground.splice(gIdx, 1)
}
this.metrics.pickups += 1
return { success: true, amount }
}
/**
* Pick up an item from the ground by ground item id into the inventory.
*/
pickupItem(id: string): {
readonly success: boolean
readonly reason?: 'full' | 'not_found'
readonly item?: GroundItemEntity
} {
const entity = this.groundItems.get(id)
if (!entity) {
return { success: false, reason: 'not_found' }
}
if (entity.isGold) {
const res = this.pickupGold(id)
return { success: res.success, item: entity }
}
// Attempt to place in bag
const placed = this.bag.add(entity.item as any)
if (placed === null) {
this.metrics.inventoryRefusals += 1
return { success: false, reason: 'full', item: entity }
}
this.groundItems.remove(id)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 16)
if (gIdx !== -1) {
this.ground.splice(gIdx, 1)
}
this.metrics.pickups += 1
return { success: true, item: entity }
}
}
export interface NpcSpriteFrame {

View File

@ -726,12 +726,16 @@ export class Inventory {
*/
add(item: Item): PlacedItem | null {
// Stack first: a second potion belongs on the first one, not beside it.
if (item.base.maxStack > 1) {
const maxStack = item.base?.maxStack ?? 1
if (maxStack > 1) {
for (const placed of this.items) {
if (placed.item.base.id !== item.base.id) continue
const room = placed.item.base.maxStack - placed.item.stack
const placedBaseId = placed.item.base?.id
const itemBaseId = item.base?.id
if (!itemBaseId || placedBaseId !== itemBaseId) continue
const placedMaxStack = placed.item.base?.maxStack ?? 1
const room = placedMaxStack - placed.item.stack
if (room <= 0) continue
const moved = Math.min(room, item.stack)
const moved = Math.min(room, item.stack ?? 1)
const nextStack = placed.item.stack + moved
const merged: Item = {
...placed.item,
@ -739,7 +743,7 @@ export class Inventory {
...(placed.item.quantity !== undefined ? { quantity: nextStack } : {}),
}
this.items[this.items.indexOf(placed)] = { ...placed, item: merged }
const leftover = item.stack - moved
const leftover = (item.stack ?? 1) - moved
if (leftover <= 0) return { ...placed, item: merged }
return this.add({
...item,
@ -748,13 +752,15 @@ export class Inventory {
})
}
}
const invWidth = typeof item.invWidth === 'number' ? item.invWidth : (item.base?.invWidth ?? 1)
const invHeight = typeof item.invHeight === 'number' ? item.invHeight : (item.base?.invHeight ?? 1)
for (let y = 0; y < this.height; y += 1) {
for (let x = 0; x < this.width; x += 1) {
if (!this.canPlace(item.invWidth, item.invHeight, x, y)) continue
if (!this.canPlace(invWidth, invHeight, x, y)) continue
const id = this.nextId
this.nextId += 1
for (let row = y; row < y + item.invHeight; row += 1) {
for (let column = x; column < x + item.invWidth; column += 1) {
for (let row = y; row < y + invHeight; row += 1) {
for (let column = x; column < x + invWidth; column += 1) {
this.cells[row * this.width + column] = id
}
}
@ -818,8 +824,8 @@ export class Inventory {
/** How much gold is held, summed over gold stacks. */
get gold(): number {
return this.items
.filter(placed => placed.item.base.id === 'gold')
.reduce((total, placed) => total + placed.item.stack, 0)
.filter(placed => placed.item.base?.id === 'gold')
.reduce((total, placed) => total + (placed.item.stack ?? 0), 0)
}
}

View File

@ -1258,6 +1258,7 @@ export type PendingInteraction =
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
| { readonly kind: 'ground-item'; readonly item: GroundItemEntity }
declare global {
interface HTMLCanvasElement {
@ -1340,6 +1341,56 @@ export class SceneMouseController {
}
}
/**
* Interact with or move towards a ground item to pick it up.
*/
pickupGroundItem(item: GroundItemEntity): void {
const player = this.engine.world.player
const dist = Math.hypot(item.x - player.x, item.y - player.y)
if (dist <= 48) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(item.x - player.x, item.y - player.y)
if (item.isGold) {
const res = this.engine.pickupGold(item.id)
if (res.success) {
if (this.hudManager) {
this.hudManager.inventory.gold += res.amount
this.hudManager.syncPublishedState()
}
this.status.textContent = `拾起金币:${res.amount}`
}
} else {
if (this.hudManager) {
const placed = this.hudManager.inventory.autoPlaceInGrid(item.item)
if (!placed) {
this.engine.metrics.inventoryRefusals += 1
this.status.textContent = '包裹已满。'
return
}
this.engine.groundItems.remove(item.id)
this.engine.metrics.pickups += 1
this.hudManager.syncPublishedState()
this.status.textContent = `拾起物品:${item.nameZh || item.name}`
} else {
const res = this.engine.pickupItem(item.id)
if (!res.success && res.reason === 'full') {
this.status.textContent = '包裹已满。'
} else if (res.success) {
this.status.textContent = `拾起物品:${item.nameZh || item.name}`
}
}
}
} else {
this.activeClickType = 'ground'
this.pendingInteraction = { kind: 'ground-item', item }
this.navTarget = { x: item.x, y: item.y }
this.lastDist = dist
this.stuckTicks = 0
}
}
playStashOpenSound(): void {
try {
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
@ -1625,7 +1676,14 @@ export class SceneMouseController {
return
}
// 4. Ground clicked:
// 5. Check Ground Item: within ~32 px
const clickedItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32)
if (clickedItem) {
this.pickupGroundItem(clickedItem)
return
}
// 6. Pure ground click to move:
this.activeClickType = 'ground'
;(this.engine as any).dialog = null
this.pendingInteraction = null
@ -1795,6 +1853,16 @@ export class SceneMouseController {
this.navTarget = { x: monster.x, y: monster.y }
}
}
} else if (this.pendingInteraction.kind === 'ground-item') {
const item = this.pendingInteraction.item
const dist = Math.hypot(item.x - player.x, item.y - player.y)
if (dist <= 48) {
this.navTarget = null
this.pendingInteraction = null
this.pickupGroundItem(item)
} else {
this.navTarget = { x: item.x, y: item.y }
}
}
}
@ -3538,6 +3606,11 @@ if (typeof window !== 'undefined') {
getCharacter: () => character,
})
mouseController.attach()
if (groundLabelOverlay !== null) {
groundLabelOverlay.setOnPickupCallback((item) => {
mouseController.pickupGroundItem(item)
})
}
if (typeof window !== 'undefined') {
(window as any).__d2webMouseController = mouseController
}

View File

@ -0,0 +1,234 @@
/**
* Diablo II v1.13c Ground Items Pathfinding & Pickup Tests (Issue #391).
*/
import { describe, it, expect, vi } from 'vitest'
import { GameEngine } from '../src/game/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import { SceneMouseController } from '../src/scene/act-scene.ts'
import type { GroundItemEntity } from '../src/game/ground-items.ts'
function createTestEngine() {
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
const engine = new GameEngine(terrain, {
spawn: { x: 500, y: 500 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 48,
pickupRadius: 48,
inventoryCols: 4,
inventoryRows: 2, // Compact 4x2 grid for testing full capacity
lootSeed: 42,
npcDefs: [],
})
return engine
}
describe('Ground Items Pickup & Pathfinding (Issue #391)', () => {
describe('GameEngine Pickup Methods', () => {
it('successfully picks up gold, increments metrics, and removes from groundItems', () => {
const engine = createTestEngine()
const gold = engine.dropGold(500, 510, 510)
expect(gold).not.toBeNull()
expect(engine.groundItems.count).toBe(1)
const res = engine.pickupGold(gold!.id)
expect(res.success).toBe(true)
expect(res.amount).toBe(500)
expect(engine.groundItems.count).toBe(0)
expect(engine.metrics.pickups).toBe(1)
})
it('returns false when picking up non-existent gold id', () => {
const engine = createTestEngine()
const res = engine.pickupGold('non-existent-gold')
expect(res.success).toBe(false)
expect(res.amount).toBe(0)
})
it('picks up an equipment item into the bag when space is available', () => {
const engine = createTestEngine()
const item = {
id: 'test-sword',
name: 'Short Sword',
nameZh: '短剑',
invWidth: 1,
invHeight: 2,
}
const entity = engine.dropItem(item, 520, 520)
expect(engine.groundItems.count).toBe(1)
const res = engine.pickupItem(entity.id)
expect(res.success).toBe(true)
expect(res.item).toBe(entity)
expect(engine.groundItems.count).toBe(0)
expect(engine.metrics.pickups).toBe(1)
expect(engine.bag.contents.length).toBe(1)
})
it('refuses pickup when the inventory bag is completely full', () => {
const engine = createTestEngine() // 4x2 = 8 slots total
// Fill the 4x2 bag with four 1x2 items
for (let i = 0; i < 4; i++) {
const added = engine.bag.add({
id: `filler-${i}`,
name: `Filler ${i}`,
invWidth: 1,
invHeight: 2,
} as any)
expect(added).not.toBeNull()
}
expect(engine.bag.contents.length).toBe(4)
// Try dropping and picking up another 1x2 item
const extraItem = {
id: 'extra-axe',
name: 'Hand Axe',
nameZh: '手斧',
invWidth: 1,
invHeight: 2,
}
const entity = engine.dropItem(extraItem, 530, 530)
expect(engine.groundItems.count).toBe(1)
const res = engine.pickupItem(entity.id)
expect(res.success).toBe(false)
expect(res.reason).toBe('full')
expect(engine.groundItems.count).toBe(1) // Still on ground
expect(engine.metrics.inventoryRefusals).toBe(1)
})
})
describe('SceneMouseController Click & Pathfinding Integration', () => {
function createMockController(engine: GameEngine) {
const mockCanvas: any = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}
const mockStatus: any = { textContent: '' }
const mockInput: any = {
takeLeftClick: () => null,
takeRightClick: () => null,
shiftHeld: false,
movement: () => ({ x: 0, y: 0 }),
}
const mockRuntime: any = {
grid: { cellsX: 50, cellsY: 50 },
waypoints: [],
stashes: [],
}
const controller = new SceneMouseController({
canvas: mockCanvas,
engine,
camera: { zoom: 1 } as any,
input: mockInput,
getRuntime: () => mockRuntime,
hudManager: null,
waypointNetwork: null as any,
status: mockStatus,
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
getCharacter: () => null,
})
return { controller, status: mockStatus }
}
it('immediately picks up ground item within authentic 48px range', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
const entity = engine.dropItem(
{ id: 'near-potion', name: 'Potion', nameZh: '治疗药剂', invWidth: 1, invHeight: 1 },
520, // distance = 20px <= 48px
500,
)
const { controller, status } = createMockController(engine)
controller.pickupGroundItem(entity)
expect(engine.groundItems.count).toBe(0)
expect(engine.bag.contents.length).toBe(1)
expect(status.textContent).toBe('拾起物品:治疗药剂')
expect(controller.navTarget).toBeNull()
expect(controller.pendingInteraction).toBeNull()
})
it('sets pathfinding navTarget when ground item is farther than 48px', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
const entity = engine.dropItem(
{ id: 'far-sword', name: 'Broad Sword', nameZh: '阔剑', invWidth: 1, invHeight: 3 },
650, // distance = 150px > 48px
500,
)
const { controller } = createMockController(engine)
controller.pickupGroundItem(entity)
// Item should still be on ground, player begins navigating
expect(engine.groundItems.count).toBe(1)
expect(controller.navTarget).toEqual({ x: 650, y: 500 })
expect(controller.pendingInteraction).toEqual({ kind: 'ground-item', item: entity })
})
it('automatically executes pickup upon reaching ground item on tick', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
const entity = engine.dropItem(
{ id: 'distant-gold', isGold: true, amount: 1200 },
600,
500,
)
const { controller, status } = createMockController(engine)
controller.pickupGroundItem(entity)
// Move player close to item
engine.world.player.x = 580
engine.world.player.y = 500 // distance = 20px <= 48px
// Tick controller
controller.tick()
expect(engine.groundItems.count).toBe(0)
expect(status.textContent).toBe('拾起金币:1200')
expect(controller.pendingInteraction).toBeNull()
expect(controller.navTarget).toBeNull()
})
it('detects ground item click in handleLeftClickWorld within 32px', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
const entity = engine.dropItem(
{ id: 'ring', name: 'Ring', nameZh: '戒指', invWidth: 1, invHeight: 1 },
510,
510,
)
const { controller, status } = createMockController(engine)
// Player clicks near item coordinates (515, 512), within 32px of (510, 510)
controller.handleLeftClickWorld(515, 512)
expect(engine.groundItems.count).toBe(0)
expect(status.textContent).toBe('拾起物品:戒指')
})
})
})