From 53a9ef3f9be2de4b21b98b6e4734bf9f28d1766c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 03:12:06 +0000 Subject: [PATCH] feat(interaction): implement authentic ground item & gold pickup with pathfinding (Fixes #391) --- src/game/engine.ts | 51 +++++++ src/game/items.ts | 26 ++-- src/scene/act-scene.ts | 75 +++++++++- tests/ground-items-pickup.test.ts | 234 ++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 tests/ground-items-pickup.test.ts diff --git a/src/game/engine.ts b/src/game/engine.ts index 1b37287..5dcfb03 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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 { diff --git a/src/game/items.ts b/src/game/items.ts index 0861d17..eab968b 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -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) } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index dd172f7..ed43368 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 } diff --git a/tests/ground-items-pickup.test.ts b/tests/ground-items-pickup.test.ts new file mode 100644 index 0000000..bc734be --- /dev/null +++ b/tests/ground-items-pickup.test.ts @@ -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('拾起物品:戒指') + }) + }) +})