582 lines
18 KiB
TypeScript
582 lines
18 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,
|
|
COLLIDE_ITEM,
|
|
COLLIDE_MASK_SPAWN,
|
|
COLLIDE_MASK_SPAWN_LOS,
|
|
ORTHO_CELL_WIDTH,
|
|
ORTHO_CELL_HEIGHT,
|
|
ORTHO_SUB_TILE_WIDTH,
|
|
ORTHO_SUB_TILE_HEIGHT,
|
|
getCollisionMaskAt,
|
|
rayTraceScene,
|
|
} from './d2map.ts'
|
|
import type { CollisionGrid } 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 || elapsed >= bounceState.durationMs) return 0
|
|
|
|
const p = elapsed / bounceState.durationMs
|
|
// Parabolic equation: h(p) = 4 * H * p * (1 - p)
|
|
return Math.max(0, 4 * bounceState.peakHeightPx * p * (1 - p))
|
|
}
|
|
|
|
/**
|
|
* Trigger authentic Diablo II v1.13c parabolic flippy bounce animation on a ground item.
|
|
* - Primary phase: duration ~350ms, peak height ~28px.
|
|
* - Secondary phase: duration ~175ms, peak height ~7px.
|
|
*/
|
|
export function triggerFlippyBounce(
|
|
entity: GroundItemEntity,
|
|
now: number = typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
options?: Partial<GroundItemBounceState>,
|
|
): void {
|
|
entity.bounceState = {
|
|
startTime: now,
|
|
durationMs: options?.durationMs ?? 350,
|
|
peakHeightPx: options?.peakHeightPx ?? 28,
|
|
phase: options?.phase ?? 'primary',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to check whether a candidate world position is blocked by collision or Line-of-Sight.
|
|
*/
|
|
export function isDropPositionBlocked(
|
|
candX: number,
|
|
candY: number,
|
|
originX: number,
|
|
originY: number,
|
|
isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any,
|
|
): boolean {
|
|
if (!isBlocked) return false
|
|
|
|
// 1. If it's a CollisionGrid or has a collision grid
|
|
const grid: CollisionGrid | undefined =
|
|
isBlocked.collisionMasks !== undefined || isBlocked.gridWidth !== undefined
|
|
? isBlocked
|
|
: isBlocked.grid?.collisionMasks !== undefined || isBlocked.grid?.gridWidth !== undefined
|
|
? isBlocked.grid
|
|
: undefined
|
|
|
|
if (grid !== undefined) {
|
|
const mask = getCollisionMaskAt(grid, candX, candY)
|
|
if ((mask & COLLIDE_MASK_SPAWN) !== 0) {
|
|
return true
|
|
}
|
|
const los = rayTraceScene(grid, originX, originY, candX, candY, COLLIDE_MASK_SPAWN_LOS)
|
|
if (los.hit) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 2. CountingTerrain overlap check
|
|
if (typeof isBlocked.overlap === 'function') {
|
|
if (isBlocked.overlap(candX, candY) !== 0) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 3. CountingTerrain LoS check
|
|
if (typeof isBlocked.hasLineOfSight === 'function') {
|
|
if (!isBlocked.hasLineOfSight(originX, originY, candX, candY)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 4. Function callback (e.g. (x, y) => boolean)
|
|
if (typeof isBlocked === 'function') {
|
|
if (isBlocked(candX, candY)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 5. General isBlocked method
|
|
if (typeof isBlocked.isBlocked === 'function') {
|
|
if (isBlocked.isBlocked(candX, candY)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Generate candidate offsets on concentric ring R around origin in integer sub-tile space,
|
|
* minimizing Manhattan distance L1 = |dx| + |dy| with authentic Blizzard tie-breaking.
|
|
* Matches D2Common.dll COLLISION_GetFreeCoordinatesImpl (0x6FD9D140).
|
|
*/
|
|
export function getIsometricRingCandidates(R: number): { dx: number; dy: number; l1: number }[] {
|
|
if (R === 0) return [{ dx: 0, dy: 0, l1: 0 }]
|
|
const cands: { dx: number; dy: number; l1: number; order: number }[] = []
|
|
let order = 0
|
|
|
|
// Phase 1: Left and right vertical columns (scan dy with smaller |dy| first for L1 minimization)
|
|
const dyOrder: number[] = [0]
|
|
for (let d = 1; d <= R; d += 1) {
|
|
dyOrder.push(-d, d)
|
|
}
|
|
|
|
for (const dy of dyOrder) {
|
|
cands.push({ dx: -R, dy, l1: R + Math.abs(dy), order: order++ })
|
|
cands.push({ dx: R, dy, l1: R + Math.abs(dy), order: order++ })
|
|
}
|
|
|
|
// Phase 2: Top and bottom horizontal rows (excluding corners already checked)
|
|
const dxOrder: number[] = [0]
|
|
for (let d = 1; d < R; d += 1) {
|
|
dxOrder.push(d, -d)
|
|
}
|
|
for (const dx of dxOrder) {
|
|
cands.push({ dx, dy: -R, l1: Math.abs(dx) + R, order: order++ })
|
|
cands.push({ dx, dy: R, l1: Math.abs(dx) + R, order: order++ })
|
|
}
|
|
|
|
// Sort candidates by L1 Manhattan distance ascending, preserving canonical traversal order for ties
|
|
cands.sort((a, b) => {
|
|
if (a.l1 !== b.l1) return a.l1 - b.l1
|
|
return a.order - b.order
|
|
})
|
|
|
|
return cands
|
|
}
|
|
|
|
/**
|
|
* Find an unoccupied isometric grid slot around an origin coordinate.
|
|
*
|
|
* Implements Blizzard Diablo II v1.13c 2:1 isometric diamond grid scatter algorithm:
|
|
* - Sub-tile steps: stepX = 16px, stepY = 8px (2:1 aspect ratio).
|
|
* - Concentric square rings R = 0, 1, 2, ... up to maxRings.
|
|
* - L1 Manhattan distance minimization (|dx| + |dy|).
|
|
* - Collision checks against COLLIDE_MASK_SPAWN (0x3E01) and LoS raycast against COLLIDE_MASK_SPAWN_LOS (0x0801).
|
|
* - Prevents item stacking by enforcing minimum spacing (default 16px).
|
|
*
|
|
* @param originX - epicenter X in world coordinates.
|
|
* @param originY - epicenter Y in world coordinates.
|
|
* @param existingItems - array of existing ground items to avoid overlapping with.
|
|
* @param isBlocked - optional collision callback or terrain / collision grid.
|
|
* @param stepX - isometric horizontal lattice step (default ORTHO_SUB_TILE_WIDTH = 16).
|
|
* @param stepY - isometric vertical lattice step (default ORTHO_SUB_TILE_HEIGHT = 8).
|
|
* @param maxRings - max search rings (default 10).
|
|
* @param minSpacing - minimum distance in pixels between ground items (default 16).
|
|
*/
|
|
export function findIsometricDropPosition(
|
|
originX: number,
|
|
originY: number,
|
|
existingItems: readonly { x: number; y: number }[] = [],
|
|
isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any,
|
|
stepX = ORTHO_SUB_TILE_WIDTH,
|
|
stepY = ORTHO_SUB_TILE_HEIGHT,
|
|
maxRings = 10,
|
|
minSpacing = 16,
|
|
): { x: number; y: number } {
|
|
const minSpacingSq = minSpacing * minSpacing
|
|
|
|
for (let ring = 0; ring <= maxRings; ring += 1) {
|
|
const ringCandidates = getIsometricRingCandidates(ring)
|
|
|
|
for (const cand of ringCandidates) {
|
|
// 2:1 Isometric projection screen offset:
|
|
// cx = originX + (dx - dy) * stepX
|
|
// cy = originY + (dx + dy) * stepY
|
|
const cx = originX + (cand.dx - cand.dy) * stepX
|
|
const cy = originY + (cand.dx + cand.dy) * stepY
|
|
|
|
// Check collision and line of sight
|
|
if (isDropPositionBlocked(cx, cy, originX, originY, isBlocked)) {
|
|
continue
|
|
}
|
|
|
|
// Check conflict with existing items
|
|
let hasConflict = false
|
|
for (const item of existingItems) {
|
|
const dx = item.x - cx
|
|
const dy = item.y - cy
|
|
const dSq = dx * dx + dy * dy
|
|
if (dSq < minSpacingSq) {
|
|
hasConflict = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!hasConflict) {
|
|
return { x: cx, y: cy }
|
|
}
|
|
}
|
|
}
|
|
|
|
return { x: originX, y: originY }
|
|
}
|
|
|
|
/**
|
|
* 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 | undefined
|
|
bounce?: boolean | undefined
|
|
peakHeight?: number | undefined
|
|
durationMs?: number | undefined
|
|
now?: number | undefined
|
|
},
|
|
): 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
|
|
}
|
|
|
|
/** Add an existing ground item entity. */
|
|
addEntity(entity: GroundItemEntity): GroundItemEntity {
|
|
this.items.set(entity.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 }
|
|
}
|
|
|
|
/** Trigger flippy bounce by ground item ID. */
|
|
triggerBounce(id: string, now?: number, options?: Partial<GroundItemBounceState>): boolean {
|
|
const item = this.items.get(id)
|
|
if (!item) return false
|
|
triggerFlippyBounce(item, now, options)
|
|
return true
|
|
}
|
|
}
|