feat(interaction): implement authentic ground item & gold pickup with pathfinding (Fixes #391)
This commit is contained in:
parent
7d7925ab2b
commit
53a9ef3f9b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('拾起物品:戒指')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue