diablo2-web/src/game/ground-items.ts

374 lines
11 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Ground Item & Drop Entities
*
* Implements ground item entities, safe drop radial search algorithms,
* gold & equipment drop representations, and lifecycle management.
*
* Ground Truth:
* - D2Common.dll (0x6FD40000) ITEM_Drop radial search algorithm
* - D2Game.dll (0x6FC45670) D2GAME_DropItem / D2Game!6FC319D0 gold amounts
* - Safe void & collision protection: items must never drop onto COLLIDE_WALL or COLLIDE_MASK_INVALID
*/
import type { UiInventoryItem, UiItemQuality } from '../ui/inventory.ts'
import type { Item } from './items.ts'
import {
COLLIDE_WALL,
COLLIDE_BLANK,
COLLIDE_MASK_INVALID,
COLLIDE_NONE,
ORTHO_CELL_WIDTH,
ORTHO_CELL_HEIGHT,
} from './d2map.ts'
export interface GroundItemBounceState {
startTime: number
durationMs: number
peakHeightPx: number
phase?: 'primary' | 'secondary'
}
export type GroundItemSource = UiInventoryItem | Item | any
export interface GroundItemEntity {
readonly id: string
readonly item: GroundItemSource
readonly name: string
readonly nameZh: string
readonly quality: string
readonly isGold: boolean
readonly amount: number
readonly invWidth: number
readonly invHeight: number
readonly dropTime: number
x: number // World pixel coordinate X
y: number // World pixel coordinate Y
cellX: number // Map cell coordinate X
cellY: number // Map cell coordinate Y
/** Sparkle animation phase (0..2*Math.PI) */
sparklePhase: number
/** Optional bounce animation state (for inventory-full or dropped items) */
bounceState?: GroundItemBounceState | null
}
/** Check whether an item source represents gold. */
export function isGoldItem(item: any): boolean {
if (!item) return false
if (item.isGold === true) return true
if (item.code === 'gld' || item.code?.trim() === 'gld') return true
if (item.id === 'gold') return true
if (item.base?.id === 'gold' || item.base?.name?.toLowerCase() === 'gold') return true
if (typeof item.name === 'string' && /gold|\u91d1\u5e01/i.test(item.name)) return true
return false
}
/** Resolve quality name string safely. */
export function resolveItemQualityString(quality: any): string {
if (typeof quality === 'string') return quality.toLowerCase()
if (typeof quality === 'number') {
switch (quality) {
case 1: return 'low'
case 2: return 'normal'
case 3: return 'superior'
case 4: return 'magic'
case 5: return 'set'
case 6: return 'rare'
case 7: return 'unique'
case 8: return 'craft'
default: return 'normal'
}
}
return 'normal'
}
/** Metadata extracted from an item for ground display. */
export interface GroundItemMetadata {
readonly name: string
readonly nameZh: string
readonly quality: string
readonly isGold: boolean
readonly amount: number
readonly invWidth: number
readonly invHeight: number
}
/** Extract ground item metadata from any item source. */
export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata {
const isGold = isGoldItem(item)
if (isGold) {
const amount = specifiedAmount ?? (
typeof item?.amount === 'number'
? item.amount
: typeof item?.stack === 'number'
? item.stack
: typeof item?.value === 'number'
? item.value
: 1
)
return {
name: `${amount} Gold`,
nameZh: `${amount} 金币`,
quality: 'normal',
isGold: true,
amount,
invWidth: 1,
invHeight: 1,
}
}
const name = typeof item?.name === 'string' ? item.name : 'Unknown Item'
const nameZh = typeof item?.nameZh === 'string' ? item.nameZh : name
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality)
const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? 1)
const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? 1)
const amount = typeof item?.stack === 'number' ? item.stack : 1
return {
name,
nameZh,
quality,
isGold: false,
amount,
invWidth,
invHeight,
}
}
/** Calculate parabolic bounce height h(t) at timestamp now. */
export function calculateBounceHeight(bounceState: GroundItemBounceState, now: number): number {
const elapsed = now - bounceState.startTime
if (elapsed <= 0) return 0
if (elapsed >= bounceState.durationMs) return 0
const p = elapsed / bounceState.durationMs
// Parabolic equation: h(p) = 4 * H * p * (1 - p)
return 4 * bounceState.peakHeightPx * p * (1 - p)
}
/**
* Radial spiral neighbor search around origin (D2Common.dll ITEM_Drop parity).
* Searches radius 0, 1, 2, ... up to maxRadius for the first walkable cell
* not blocked by COLLIDE_WALL or COLLIDE_MASK_INVALID.
*/
export function findSafeDropPosition(
grid: any,
originCellX: number,
originCellY: number,
maxRadius = 3,
): { cellX: number; cellY: number; x: number; y: number } {
const isBlocked = (cx: number, cy: number): boolean => {
if (!grid) return false
// Function check (e.g. overlap callback in tests)
if (typeof grid === 'function') {
return grid(cx, cy) !== 0
}
if (typeof grid.overlap === 'function') {
return grid.overlap(cx, cy) !== 0
}
if (typeof grid.isBlocked === 'function') {
return grid.isBlocked(cx, cy)
}
// Grid array / TypedArray check
if (grid.cells || grid.collisionMasks) {
if (cx < 0 || cy < 0) return true
if (typeof grid.width === 'number' && cx >= grid.width) return true
if (typeof grid.height === 'number' && cy >= grid.height) return true
const width = grid.width ?? 0
const idx = cy * width + cx
const mask = (grid.collisionMasks ? grid.collisionMasks[idx] : grid.cells[idx]) ?? COLLIDE_NONE
return (mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0
}
return false
}
// Pre-generate candidate ring offsets sorted by distance squared
const candidates: { dx: number; dy: number; distSq: number }[] = []
for (let dy = -maxRadius; dy <= maxRadius; dy += 1) {
for (let dx = -maxRadius; dx <= maxRadius; dx += 1) {
candidates.push({ dx, dy, distSq: dx * dx + dy * dy })
}
}
candidates.sort((a, b) => a.distSq - b.distSq)
let chosenCellX = originCellX
let chosenCellY = originCellY
for (const { dx, dy } of candidates) {
const cx = originCellX + dx
const cy = originCellY + dy
if (!isBlocked(cx, cy)) {
chosenCellX = cx
chosenCellY = cy
break
}
}
// Calculate world coordinate (iso projection)
const originX = typeof grid?.originX === 'number' ? grid.originX : 0
const originY = typeof grid?.originY === 'number' ? grid.originY : 0
const worldX = originX + (chosenCellX - chosenCellY) * ORTHO_CELL_WIDTH
const worldY = originY + (chosenCellX + chosenCellY) * ORTHO_CELL_HEIGHT
return {
cellX: chosenCellX,
cellY: chosenCellY,
x: worldX,
y: worldY,
}
}
/**
* GroundItemManager maintains all dropped items and gold currently lying on the ground.
*/
export class GroundItemManager {
private readonly items = new Map<string, GroundItemEntity>()
private idCounter = 1
/** All active ground items. */
get all(): readonly GroundItemEntity[] {
return Array.from(this.items.values())
}
/** Count of active ground items. */
get count(): number {
return this.items.size
}
/** Add an item or gold to the ground. */
add(
item: GroundItemSource,
cellX: number,
cellY: number,
worldX: number,
worldY: number,
options?: {
amount?: number
bounce?: boolean
peakHeight?: number
durationMs?: number
now?: number
},
): GroundItemEntity {
const id = `ground_item_${Date.now()}_${this.idCounter++}`
const meta = resolveGroundItemMetadata(item, options?.amount)
const now = options?.now ?? (typeof performance !== 'undefined' ? performance.now() : Date.now())
const entity: GroundItemEntity = {
id,
item,
name: meta.name,
nameZh: meta.nameZh,
quality: meta.quality,
isGold: meta.isGold,
amount: meta.amount,
invWidth: meta.invWidth,
invHeight: meta.invHeight,
dropTime: now,
x: worldX,
y: worldY,
cellX,
cellY,
sparklePhase: Math.random() * Math.PI * 2,
bounceState: options?.bounce
? {
startTime: now,
durationMs: options?.durationMs ?? 400,
peakHeightPx: options?.peakHeight ?? 24,
phase: 'primary',
}
: null,
}
this.items.set(id, entity)
return entity
}
/** Remove a ground item by id (e.g. when picked up). */
remove(id: string): GroundItemEntity | undefined {
const entity = this.items.get(id)
if (entity) {
this.items.delete(id)
}
return entity
}
/** Retrieve an entity by id. */
get(id: string): GroundItemEntity | undefined {
return this.items.get(id)
}
/** Clear all ground items. */
clear(): void {
this.items.clear()
}
/** Find the nearest ground item within hit radius. */
findAtPoint(x: number, y: number, hitRadiusPx = 25): GroundItemEntity | undefined {
let closest: GroundItemEntity | undefined
let minDistSq = hitRadiusPx * hitRadiusPx
for (const item of this.items.values()) {
const dx = item.x - x
const dy = item.y - y
const distSq = dx * dx + dy * dy
if (distSq <= minDistSq) {
minDistSq = distSq
closest = item
}
}
return closest
}
/** Find all ground items within radius. */
findNearby(x: number, y: number, radiusPx: number): GroundItemEntity[] {
const radiusSq = radiusPx * radiusPx
const result: GroundItemEntity[] = []
for (const item of this.items.values()) {
const dx = item.x - x
const dy = item.y - y
if (dx * dx + dy * dy <= radiusSq) {
result.push(item)
}
}
return result
}
/** Advance sparkle phase and bounce animations. */
update(now: number, deltaMs: number): void {
for (const item of this.items.values()) {
item.sparklePhase = (item.sparklePhase + deltaMs * 0.005) % (Math.PI * 2)
if (item.bounceState) {
const elapsed = now - item.bounceState.startTime
if (elapsed >= item.bounceState.durationMs) {
if (item.bounceState.phase === 'primary') {
// Secondary bounce: 25% peak height, half duration
item.bounceState = {
startTime: item.bounceState.startTime + item.bounceState.durationMs,
durationMs: Math.round(item.bounceState.durationMs * 0.5),
peakHeightPx: Math.max(4, Math.round(item.bounceState.peakHeightPx * 0.25)),
phase: 'secondary',
}
} else {
item.bounceState = null
}
}
}
}
}
/** Get visual render offset including bounce height. */
getRenderOffset(item: GroundItemEntity, now: number): { x: number; y: number } {
if (!item.bounceState) return { x: 0, y: 0 }
const h = calculateBounceHeight(item.bounceState, now)
return { x: 0, y: -h }
}
}