1538 lines
52 KiB
TypeScript
1538 lines
52 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_WATER,
|
||
COLLIDE_NO_PATH,
|
||
COLLIDE_MASK_INVALID,
|
||
COLLIDE_MASK_PLAYER_PATH,
|
||
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,
|
||
getCollisionMask,
|
||
getCollisionMaskAt,
|
||
isBlockedAt,
|
||
rayTraceScene,
|
||
subTileAt,
|
||
} from './d2map.ts'
|
||
import type { CollisionGrid } from './d2map.ts'
|
||
import {
|
||
resolveBaseItemNameEn,
|
||
translateItemName,
|
||
translateMagicName,
|
||
translateRareName,
|
||
} from './tooltip-i18n.ts'
|
||
import { isRuneCode } from './gems-data.ts'
|
||
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
|
||
|
||
/**
|
||
* Composite collision mask for ground item & gold drops.
|
||
* Combines D2Common.dll `COLLISION_GetFreeCoordinatesImpl` (`COLLIDE_MASK_SPAWN = 0x3E01`)
|
||
* with player walkability masks (`COLLIDE_MASK_PLAYER_PATH`, `COLLIDE_BLANK`, `COLLIDE_WATER`,
|
||
* `COLLIDE_MASK_INVALID`, `COLLIDE_NO_PATH`) so items never land on tiles a player cannot walk onto.
|
||
*/
|
||
export const COLLIDE_MASK_DROP_BLOCK =
|
||
COLLIDE_MASK_SPAWN |
|
||
COLLIDE_MASK_PLAYER_PATH |
|
||
COLLIDE_BLANK |
|
||
COLLIDE_WATER |
|
||
COLLIDE_MASK_INVALID |
|
||
COLLIDE_NO_PATH
|
||
|
||
|
||
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
|
||
name: string
|
||
nameZh: string
|
||
readonly quality: string
|
||
readonly isGold: boolean
|
||
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
|
||
|
||
/** Authentic drop sound effect identifier from MPQ dropsound column */
|
||
readonly dropSound?: string | undefined
|
||
/** Authentic animation frame index at which drop SFX plays (default: 12) */
|
||
readonly dropSfxFrame?: number | undefined
|
||
/** Whether drop SFX has already played for this bounce sequence */
|
||
sfxPlayed?: boolean | undefined
|
||
/** Whether the underlying item is ethereal (D2 1.13c gray ground label rule) */
|
||
readonly ethereal?: boolean | undefined
|
||
/** Total number of sockets on the underlying item (D2 1.13c gray ground label rule) */
|
||
readonly sockets?: number | undefined
|
||
}
|
||
|
||
/** 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, including rune detection per D2 1.13c ground label rules. */
|
||
export function resolveItemQualityString(quality: any, code?: string, item?: any): string {
|
||
const normCode = typeof code === 'string' ? code.trim().toLowerCase() : ''
|
||
const rawRarity = typeof item?.rarity === 'string' ? item.rarity.toLowerCase() : ''
|
||
if (
|
||
(normCode && isRuneCode(normCode)) ||
|
||
item?.isRune === true ||
|
||
item?.base?.isRune === true ||
|
||
rawRarity === 'rune'
|
||
) {
|
||
return 'rune'
|
||
}
|
||
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'
|
||
}
|
||
}
|
||
if (item?.uniqueItemDef || rawRarity === 'unique') return 'unique'
|
||
if (item?.setItemDef || rawRarity === 'set') return 'set'
|
||
if (item?.rolledRareAffixes || rawRarity === 'rare') return 'rare'
|
||
if (item?.rolledMagicAffixes || rawRarity === 'magic') return 'magic'
|
||
if (rawRarity === 'crafted' || rawRarity === 'craft') return 'craft'
|
||
return 'normal'
|
||
}
|
||
|
||
/**
|
||
* Formats an item's Chinese display name into pure Chinese matching `itemToUiInventoryItem` (`src/ui/item-bridge.ts`),
|
||
* without appending `(${name})`.
|
||
*/
|
||
export function formatBilingualGroundItemName(item: any, name: string, quality: string): string {
|
||
if (typeof item?.nameZh === 'string' && item.nameZh.trim().length > 0 && item.nameZh !== name) {
|
||
return item.nameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
|
||
}
|
||
const baseEn = typeof item?.base?.name === 'string' && item.base.name.length > 0 ? item.base.name : undefined
|
||
if (quality === 'unique' && item?.uniqueItemDef) {
|
||
const uEn = item.uniqueItemDef.index || name
|
||
const uZh = translateItemName(uEn)
|
||
return uZh && uZh !== uEn ? uZh : uEn
|
||
}
|
||
if (quality === 'set' && item?.setItemDef) {
|
||
const sEn = item.setItemDef.index || name
|
||
const sZh = translateItemName(sEn)
|
||
return sZh && sZh !== sEn ? sZh : sEn
|
||
}
|
||
if ((quality === 'rare' || quality === 'craft') && item?.rolledRareAffixes) {
|
||
const rEn = item.rolledRareAffixes.name || name
|
||
const rZh = translateRareName(rEn)
|
||
return rZh && rZh !== rEn ? rZh : rEn
|
||
}
|
||
if (quality === 'magic' && item?.rolledMagicAffixes) {
|
||
const mEn = item.rolledMagicAffixes.name || name
|
||
const mZh = translateMagicName(mEn, baseEn ?? name)
|
||
return mZh && mZh !== mEn ? mZh : mEn
|
||
}
|
||
const directZh = translateItemName(name)
|
||
if (directZh && directZh !== name) {
|
||
return directZh
|
||
}
|
||
if (quality === 'rare' || quality === 'craft') {
|
||
const rZh = translateRareName(name)
|
||
if (rZh && rZh !== name) return rZh
|
||
}
|
||
if (quality === 'magic') {
|
||
const mZh = translateMagicName(name, baseEn)
|
||
if (mZh && mZh !== name) return mZh
|
||
}
|
||
return name
|
||
}
|
||
|
||
/** 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
|
||
readonly dropSound?: string | undefined
|
||
readonly dropSfxFrame?: number | undefined
|
||
readonly ethereal?: boolean | undefined
|
||
readonly sockets?: number | undefined
|
||
}
|
||
|
||
/**
|
||
* Checks whether an item or GroundItemSource is currently unidentified.
|
||
*/
|
||
export function isGroundSourceUnidentified(item: any): boolean {
|
||
if (!item || isGoldItem(item)) return false
|
||
if (item.identified === false) return true
|
||
if (item.identified === true) return false
|
||
if (item.flags && typeof item.flags.identified === 'boolean') {
|
||
return item.flags.identified === false
|
||
}
|
||
if (item.rawItem && item.rawItem !== item) {
|
||
return isGroundSourceUnidentified(item.rawItem)
|
||
}
|
||
if (item.item && item.item !== item) {
|
||
return isGroundSourceUnidentified(item.item)
|
||
}
|
||
return false
|
||
}
|
||
|
||
/** 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,
|
||
dropSound: 'item_gold',
|
||
dropSfxFrame: 12,
|
||
}
|
||
}
|
||
|
||
const rawCode = typeof item?.code === 'string'
|
||
? item.code.trim().toLowerCase()
|
||
: typeof item?.base?.id === 'string'
|
||
? item.base.id.trim().toLowerCase()
|
||
: typeof item?.rawItem?.code === 'string'
|
||
? item.rawItem.code.trim().toLowerCase()
|
||
: typeof item?.rawItem?.base?.id === 'string'
|
||
? item.rawItem.base.id.trim().toLowerCase()
|
||
: ''
|
||
|
||
let tableBase: any
|
||
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
|
||
try {
|
||
const tables = getEmbeddedDropTables() as any
|
||
tableBase = typeof tables.getBase === 'function'
|
||
? tables.getBase(rawCode)
|
||
: (tables.weapons?.get(rawCode) ?? tables.armor?.get(rawCode) ?? tables.misc?.get(rawCode))
|
||
} catch {
|
||
// fallback if embedded tables unavailable
|
||
}
|
||
}
|
||
|
||
const rawName = typeof item?.name === 'string' ? item.name : 'Unknown Item'
|
||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
||
const unidentified = isGroundSourceUnidentified(item)
|
||
|
||
let name = rawName
|
||
let nameZh: string
|
||
if (unidentified) {
|
||
const baseObj = item?.base ?? item?.rawItem?.base ?? tableBase
|
||
const rawBaseName =
|
||
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
|
||
? baseObj.name
|
||
: rawName
|
||
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
|
||
const translatedBase =
|
||
translateItemName(rawBaseName) !== rawBaseName
|
||
? translateItemName(rawBaseName)
|
||
: translateItemName(baseNameEn)
|
||
name = baseNameEn
|
||
nameZh =
|
||
translatedBase && translatedBase !== baseNameEn
|
||
? translatedBase
|
||
: baseNameEn
|
||
} else {
|
||
nameZh = formatBilingualGroundItemName(item, name, quality)
|
||
if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) {
|
||
try {
|
||
item.nameZh = nameZh
|
||
} catch {
|
||
// ignore if frozen object
|
||
}
|
||
}
|
||
}
|
||
|
||
const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? tableBase?.invWidth ?? 1)
|
||
const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1)
|
||
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
||
const dropSound = item?.base?.dropsound ?? item?.dropSound ?? item?.dropsound ?? tableBase?.dropsound ?? 'item_drop'
|
||
const dropSfxFrame = item?.base?.dropsfxframe ?? item?.dropSfxFrame ?? item?.dropsfxframe ?? tableBase?.dropsfxframe ?? 12
|
||
const ethereal = Boolean(item?.ethereal === true || item?.isEthereal === true || item?.item?.ethereal === true)
|
||
const rawSockets = item?.sockets ?? item?.totalSockets ?? item?.socketedCount ?? item?.item?.sockets ?? item?.item?.totalSockets ?? 0
|
||
const sockets = typeof rawSockets === 'number' && rawSockets > 0 ? rawSockets : undefined
|
||
|
||
return {
|
||
name,
|
||
nameZh,
|
||
quality,
|
||
isGold: false,
|
||
amount,
|
||
invWidth,
|
||
invHeight,
|
||
dropSound,
|
||
dropSfxFrame,
|
||
...(ethereal ? { ethereal: true } : {}),
|
||
...(sockets !== undefined ? { sockets } : {}),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Authentic Diablo II v1.13c Unit Collision Extents & Pickup Reach (`D2Game.dll` `SUNITDMG_IsWithinReach` / `UNITS_GetDistanceToOtherUnit`).
|
||
*
|
||
* - Player collision footprint: `SizeX = 2, SizeY = 2` sub-tiles -> half-extent = 1 sub-tile (`16px`).
|
||
* - Ground item collision footprint: `1 × 1` sub-tile -> half-extent = 1 sub-tile (`16px`).
|
||
* - Sub-tile pickup reach: `<= 4` sub-tiles (`4 * 16px = 64px`) edge-to-edge (`~96px` center-to-center).
|
||
*/
|
||
export const PLAYER_COLLISION_EXTENT_PX = ORTHO_SUB_TILE_WIDTH // 16px
|
||
export const ITEM_COLLISION_EXTENT_PX = ORTHO_SUB_TILE_WIDTH // 16px
|
||
export const D2_PICKUP_SUBTILE_REACH_PX = 4 * ORTHO_SUB_TILE_WIDTH // 64px
|
||
|
||
/**
|
||
* Computes the edge-to-edge distance between a player's collision box and a ground item's collision box.
|
||
*/
|
||
export function getPickupEdgeDistance(
|
||
playerX: number,
|
||
playerY: number,
|
||
itemX: number,
|
||
itemY: number,
|
||
playerExtentPx = PLAYER_COLLISION_EXTENT_PX,
|
||
itemExtentPx = ITEM_COLLISION_EXTENT_PX,
|
||
): number {
|
||
const centerDist = Math.hypot(itemX - playerX, itemY - playerY)
|
||
return Math.max(0, centerDist - (playerExtentPx + itemExtentPx))
|
||
}
|
||
|
||
/**
|
||
* Checks whether a ground item is within authentic Diablo II v1.13c pickup reach (`<= 4` sub-tiles / `64px` edge-to-edge)
|
||
* of the player's collision box.
|
||
*/
|
||
export function isWithinPickupBounds(
|
||
playerX: number,
|
||
playerY: number,
|
||
itemX: number,
|
||
itemY: number,
|
||
maxEdgeReachPx = D2_PICKUP_SUBTILE_REACH_PX,
|
||
): boolean {
|
||
return getPickupEdgeDistance(playerX, playerY, itemX, itemY) <= maxEdgeReachPx
|
||
}
|
||
|
||
/** 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',
|
||
}
|
||
entity.sfxPlayed = false
|
||
}
|
||
|
||
/**
|
||
* Concentric quadrant spiral neighbor search around origin (D2Common.dll ITEM_Drop / COLLISION_GetFreeCoordinatesImpl parity).
|
||
* Searches radius 0, 1, 2, ... up to maxRadius in authentic 1.13c quadrant order for the first walkable cell
|
||
* not blocked by COLLIDE_MASK_DROP_BLOCK or player overlap, 4-connected to the drop/player room, and not occupied by existing items.
|
||
*/
|
||
export function findSafeDropPosition(
|
||
grid: any,
|
||
originCellX: number,
|
||
originCellY: number,
|
||
maxRadius = 3,
|
||
occupied?: readonly { cellX?: number; cellY?: number; x?: number; y?: number }[] | Set<string> | ((cx: number, cy: number) => boolean),
|
||
reachableFromCell?: { cellX: number; cellY: number },
|
||
): { cellX: number; cellY: number; x: number; y: number } {
|
||
const targetGrid = grid?.grid ?? grid
|
||
|
||
const isBlocked = (cx: number, cy: number): boolean => {
|
||
if (!targetGrid && typeof grid?.overlap !== 'function') return false
|
||
|
||
// 1. Function check (e.g. overlap callback or mock in tests)
|
||
if (typeof targetGrid === 'function') {
|
||
return targetGrid(cx, cy) !== 0
|
||
}
|
||
if (typeof targetGrid?.isBlocked === 'function') {
|
||
return targetGrid.isBlocked(cx, cy)
|
||
}
|
||
|
||
// 2. Real CollisionGrid (cellsX, cellsY, gridWidth, blocked, optional collisionMasks)
|
||
if (typeof targetGrid?.cellsX === 'number' && typeof targetGrid?.cellsY === 'number') {
|
||
if (cx < 0 || cy < 0 || cx >= targetGrid.cellsX || cy >= targetGrid.cellsY) {
|
||
return true // Out of map bounds -> COLLIDE_MASK_INVALID
|
||
}
|
||
const gridWidth = targetGrid.gridWidth ?? (targetGrid.cellsX * 5)
|
||
// Check center sub-tile (cx * 5 + 2, cy * 5 + 2)
|
||
const subX = cx * 5 + 2
|
||
const subY = cy * 5 + 2
|
||
const subIdx = subY * gridWidth + subX
|
||
|
||
if (targetGrid.collisionMasks) {
|
||
const mask = targetGrid.collisionMasks[subIdx] ?? COLLIDE_NONE
|
||
if ((mask & COLLIDE_MASK_DROP_BLOCK) !== 0) {
|
||
return true
|
||
}
|
||
}
|
||
if (targetGrid.blocked && targetGrid.blocked[subIdx] === 1) {
|
||
return true
|
||
}
|
||
if (typeof grid?.overlap === 'function' && grid.widthPx !== undefined && grid.heightPx !== undefined) {
|
||
const oX = typeof grid.originX === 'number'
|
||
? grid.originX
|
||
: typeof targetGrid.originX === 'number'
|
||
? targetGrid.originX
|
||
: 0
|
||
const oY = typeof grid.originY === 'number'
|
||
? grid.originY
|
||
: typeof targetGrid.originY === 'number'
|
||
? targetGrid.originY
|
||
: 0
|
||
const wx = oX + (cx - cy) * ORTHO_CELL_WIDTH
|
||
const wy = oY + (cx + cy) * ORTHO_CELL_HEIGHT
|
||
if (grid.overlap(wx, wy) > 0) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 3. TypedArray directly (e.g. new Uint8Array(400) or new Uint16Array(width * height))
|
||
if (targetGrid && ArrayBuffer.isView(targetGrid)) {
|
||
if (cx < 0 || cy < 0) return true
|
||
const width = typeof (targetGrid as any).width === 'number'
|
||
? (targetGrid as any).width
|
||
: typeof (targetGrid as any).cellsX === 'number'
|
||
? (targetGrid as any).cellsX
|
||
: Math.round(Math.sqrt((targetGrid as any).length))
|
||
const height = typeof (targetGrid as any).height === 'number'
|
||
? (targetGrid as any).height
|
||
: typeof (targetGrid as any).cellsY === 'number'
|
||
? (targetGrid as any).cellsY
|
||
: width
|
||
if (width > 0 && cx >= width) return true
|
||
if (height > 0 && cy >= height) return true
|
||
const idx = cy * width + cx
|
||
const val = (targetGrid as any)[idx] ?? COLLIDE_NONE
|
||
return (val & COLLIDE_MASK_DROP_BLOCK) !== 0 || val === 1
|
||
}
|
||
|
||
// 4. Test grid object with cells or collisionMasks array
|
||
if (targetGrid?.cells || targetGrid?.collisionMasks) {
|
||
if (cx < 0 || cy < 0) return true
|
||
const width = targetGrid.width ?? targetGrid.cellsX ?? 0
|
||
const height = targetGrid.height ?? targetGrid.cellsY ?? 0
|
||
if (typeof width === 'number' && width > 0 && cx >= width) return true
|
||
if (typeof height === 'number' && height > 0 && cy >= height) return true
|
||
|
||
const idx = cy * width + cx
|
||
const mask = (targetGrid.collisionMasks ? targetGrid.collisionMasks[idx] : targetGrid.cells[idx]) ?? COLLIDE_NONE
|
||
return (mask & COLLIDE_MASK_DROP_BLOCK) !== 0
|
||
}
|
||
|
||
// 5. CountingTerrain / WorldMapProvider overlap callback
|
||
const overlapTarget = typeof grid?.overlap === 'function' ? grid : targetGrid
|
||
if (typeof overlapTarget?.overlap === 'function') {
|
||
if (overlapTarget.widthPx !== undefined && overlapTarget.heightPx !== undefined) {
|
||
const originX = typeof overlapTarget.originX === 'number' ? overlapTarget.originX : 0
|
||
const originY = typeof overlapTarget.originY === 'number' ? overlapTarget.originY : 0
|
||
const wx = originX + (cx - cy) * ORTHO_CELL_WIDTH
|
||
const wy = originY + (cx + cy) * ORTHO_CELL_HEIGHT
|
||
return overlapTarget.overlap(wx, wy) > 0
|
||
}
|
||
return overlapTarget.overlap(cx, cy) !== 0
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// Build fast occupied set
|
||
const occupiedSet = new Set<string>()
|
||
if (occupied) {
|
||
if (occupied instanceof Set) {
|
||
for (const k of occupied) occupiedSet.add(k)
|
||
} else if (typeof occupied !== 'function' && Array.isArray(occupied)) {
|
||
for (const item of occupied) {
|
||
if (typeof item.cellX === 'number' && typeof item.cellY === 'number') {
|
||
occupiedSet.add(`${item.cellX},${item.cellY}`)
|
||
} else if (typeof item.x === 'number' && typeof item.y === 'number') {
|
||
const originX = typeof grid?.originX === 'number' ? grid.originX : (grid?.grid?.originX ?? 0)
|
||
const originY = typeof grid?.originY === 'number' ? grid.originY : (grid?.grid?.originY ?? 0)
|
||
const px = item.x - originX
|
||
const py = item.y - originY
|
||
const cellX = Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2)
|
||
const cellY = Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2)
|
||
occupiedSet.add(`${cellX},${cellY}`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const isCellOccupied = (cx: number, cy: number): boolean => {
|
||
if (!occupied) return false
|
||
if (typeof occupied === 'function') return occupied(cx, cy)
|
||
return occupiedSet.has(`${cx},${cy}`)
|
||
}
|
||
|
||
// Optional 4-connected reachability from reachableFromCell
|
||
let baseCellX = originCellX
|
||
let baseCellY = originCellY
|
||
let playerReachableCells: Set<string> | null = null
|
||
|
||
if (
|
||
reachableFromCell &&
|
||
Number.isFinite(reachableFromCell.cellX) &&
|
||
Number.isFinite(reachableFromCell.cellY) &&
|
||
!isBlocked(reachableFromCell.cellX, reachableFromCell.cellY)
|
||
) {
|
||
const startX = reachableFromCell.cellX
|
||
const startY = reachableFromCell.cellY
|
||
const margin = Math.max(maxRadius + 4, 12)
|
||
const minX = Math.min(startX, originCellX) - margin
|
||
const maxX = Math.max(startX, originCellX) + margin
|
||
const minY = Math.min(startY, originCellY) - margin
|
||
const maxY = Math.max(startY, originCellY) + margin
|
||
|
||
playerReachableCells = new Set<string>([`${startX},${startY}`])
|
||
const queue: [number, number][] = [[startX, startY]]
|
||
let head = 0
|
||
while (head < queue.length && queue.length < 4096) {
|
||
const [cx, cy] = queue[head++]!
|
||
const neighbors: [number, number][] = [
|
||
[cx - 1, cy],
|
||
[cx + 1, cy],
|
||
[cx, cy - 1],
|
||
[cx, cy + 1],
|
||
]
|
||
for (const [nx, ny] of neighbors) {
|
||
if (nx < minX || nx > maxX || ny < minY || ny > maxY) continue
|
||
const key = `${nx},${ny}`
|
||
if (playerReachableCells.has(key)) continue
|
||
if (isBlocked(nx, ny)) continue
|
||
playerReachableCells.add(key)
|
||
queue.push([nx, ny])
|
||
}
|
||
}
|
||
|
||
if (!playerReachableCells.has(`${originCellX},${originCellY}`)) {
|
||
// Snap baseCell to the reachable cell closest to originCell
|
||
let bestX = startX
|
||
let bestY = startY
|
||
let bestL1 = Math.abs(startX - originCellX) + Math.abs(startY - originCellY)
|
||
let bestDistSq = (startX - originCellX) ** 2 + (startY - originCellY) ** 2
|
||
for (const [rx, ry] of queue) {
|
||
const l1 = Math.abs(rx - originCellX) + Math.abs(ry - originCellY)
|
||
const dSq = (rx - originCellX) ** 2 + (ry - originCellY) ** 2
|
||
if (l1 < bestL1 || (l1 === bestL1 && dSq < bestDistSq)) {
|
||
bestL1 = l1
|
||
bestDistSq = dSq
|
||
bestX = rx
|
||
bestY = ry
|
||
}
|
||
}
|
||
baseCellX = bestX
|
||
baseCellY = bestY
|
||
}
|
||
}
|
||
|
||
const baseWalkable = !isBlocked(baseCellX, baseCellY)
|
||
let anchorConnectedCells: Set<string> | null = null
|
||
const isConnectedToBase = (cx: number, cy: number): boolean => {
|
||
if (playerReachableCells !== null) {
|
||
return playerReachableCells.has(`${cx},${cy}`)
|
||
}
|
||
if (!baseWalkable) return true
|
||
if (cx === baseCellX && cy === baseCellY) return true
|
||
|
||
// Fast greedy 4-way walk check from (baseCellX, baseCellY) to (cx, cy)
|
||
let curX = baseCellX
|
||
let curY = baseCellY
|
||
let greedyOk = true
|
||
while (curX !== cx || curY !== cy) {
|
||
const remX = Math.abs(cx - curX)
|
||
const remY = Math.abs(cy - curY)
|
||
const stepX = curX < cx ? 1 : -1
|
||
const stepY = curY < cy ? 1 : -1
|
||
if (remX >= remY && remX > 0 && !isBlocked(curX + stepX, curY)) {
|
||
curX += stepX
|
||
} else if (remY > 0 && !isBlocked(curX, curY + stepY)) {
|
||
curY += stepY
|
||
} else if (remX > 0 && !isBlocked(curX + stepX, curY)) {
|
||
curX += stepX
|
||
} else {
|
||
greedyOk = false
|
||
break
|
||
}
|
||
}
|
||
if (greedyOk) return true
|
||
|
||
if (anchorConnectedCells === null) {
|
||
const bound = Math.max(maxRadius + 2, 6)
|
||
anchorConnectedCells = new Set<string>([`${baseCellX},${baseCellY}`])
|
||
const q: [number, number][] = [[baseCellX, baseCellY]]
|
||
let h = 0
|
||
while (h < q.length && q.length < 1024) {
|
||
const [qx, qy] = q[h++]!
|
||
const nbs: [number, number][] = [
|
||
[qx - 1, qy],
|
||
[qx + 1, qy],
|
||
[qx, qy - 1],
|
||
[qx, qy + 1],
|
||
]
|
||
for (const [nx, ny] of nbs) {
|
||
if (Math.abs(nx - baseCellX) > bound || Math.abs(ny - baseCellY) > bound) continue
|
||
const k = `${nx},${ny}`
|
||
if (anchorConnectedCells.has(k)) continue
|
||
if (isBlocked(nx, ny)) continue
|
||
anchorConnectedCells.add(k)
|
||
q.push([nx, ny])
|
||
}
|
||
}
|
||
}
|
||
return anchorConnectedCells.has(`${cx},${cy}`)
|
||
}
|
||
|
||
let chosenCellX = baseCellX
|
||
let chosenCellY = baseCellY
|
||
let found = false
|
||
let firstWalkableOccupied: { cx: number; cy: number } | null = null
|
||
|
||
// Ring 0: check base cell
|
||
if (baseWalkable && isConnectedToBase(baseCellX, baseCellY)) {
|
||
if (!isCellOccupied(baseCellX, baseCellY)) {
|
||
chosenCellX = baseCellX
|
||
chosenCellY = baseCellY
|
||
found = true
|
||
} else {
|
||
firstWalkableOccupied = { cx: baseCellX, cy: baseCellY }
|
||
}
|
||
}
|
||
|
||
// Concentric quadrant rings R = 1, 2, ... maxRadius
|
||
if (!found) {
|
||
for (let r = 1; r <= maxRadius; r += 1) {
|
||
const ringCandidates = getIsometricRingCandidates(r)
|
||
for (const cand of ringCandidates) {
|
||
const cx = baseCellX + cand.dx
|
||
const cy = baseCellY + cand.dy
|
||
|
||
if (isBlocked(cx, cy) || !isConnectedToBase(cx, cy)) {
|
||
continue
|
||
}
|
||
|
||
if (isCellOccupied(cx, cy)) {
|
||
if (firstWalkableOccupied === null) {
|
||
firstWalkableOccupied = { cx, cy }
|
||
}
|
||
continue
|
||
}
|
||
|
||
chosenCellX = cx
|
||
chosenCellY = cy
|
||
found = true
|
||
break
|
||
}
|
||
if (found) break
|
||
}
|
||
}
|
||
|
||
// Extended escape search when origin itself is blocked and maxRadius > 0
|
||
if (!found && firstWalkableOccupied === null && !baseWalkable && maxRadius > 0) {
|
||
const extendedMax = Math.max(maxRadius, 15)
|
||
for (let r = maxRadius + 1; r <= extendedMax; r += 1) {
|
||
const ringCandidates = getIsometricRingCandidates(r)
|
||
for (const cand of ringCandidates) {
|
||
const cx = baseCellX + cand.dx
|
||
const cy = baseCellY + cand.dy
|
||
if (isBlocked(cx, cy) || !isConnectedToBase(cx, cy)) {
|
||
continue
|
||
}
|
||
if (isCellOccupied(cx, cy)) {
|
||
if (firstWalkableOccupied === null) {
|
||
firstWalkableOccupied = { cx, cy }
|
||
}
|
||
continue
|
||
}
|
||
chosenCellX = cx
|
||
chosenCellY = cy
|
||
found = true
|
||
break
|
||
}
|
||
if (found) break
|
||
}
|
||
}
|
||
|
||
// Authentic Fallback:
|
||
// If all candidate cells within maxRadius are occupied, stack on first walkable floor cell
|
||
// rather than escaping into void or walls.
|
||
// If even no walkable cell was found (e.g. solid stone block), fallback to origin.
|
||
if (!found && firstWalkableOccupied !== null) {
|
||
chosenCellX = firstWalkableOccupied.cx
|
||
chosenCellY = firstWalkableOccupied.cy
|
||
}
|
||
|
||
// Calculate world coordinates (2:1 isometric projection)
|
||
const originX = typeof grid?.originX === 'number'
|
||
? grid.originX
|
||
: typeof grid?.grid?.originX === 'number'
|
||
? grid.grid.originX
|
||
: 0
|
||
const originY = typeof grid?.originY === 'number'
|
||
? grid.originY
|
||
: typeof grid?.grid?.originY === 'number'
|
||
? grid.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,
|
||
}
|
||
}
|
||
|
||
/** Extract a CollisionGrid from either a direct CollisionGrid or a CountingTerrain wrapper. */
|
||
function extractCollisionGrid(isBlocked: any): CollisionGrid | undefined {
|
||
if (!isBlocked || typeof isBlocked !== 'object') return undefined
|
||
if (isBlocked.collisionMasks !== undefined || isBlocked.gridWidth !== undefined) {
|
||
return isBlocked as CollisionGrid
|
||
}
|
||
if (isBlocked.grid && (isBlocked.grid.collisionMasks !== undefined || isBlocked.grid.gridWidth !== undefined)) {
|
||
return isBlocked.grid as CollisionGrid
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/**
|
||
* Checks whether a single world coordinate `(x, y)` is blocked by terrain collision masks,
|
||
* solid sub-tiles, or the player's physical bounding box (`overlap`).
|
||
*/
|
||
export function isTerrainPointBlocked(
|
||
x: number,
|
||
y: number,
|
||
isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any,
|
||
): boolean {
|
||
if (!isBlocked) return false
|
||
|
||
const grid = extractCollisionGrid(isBlocked)
|
||
if (grid !== undefined) {
|
||
const mask = getCollisionMaskAt(grid, x, y)
|
||
if ((mask & COLLIDE_MASK_DROP_BLOCK) !== 0) {
|
||
return true
|
||
}
|
||
if (grid.blocked && isBlockedAt(grid, x, y)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if (typeof isBlocked.overlap === 'function') {
|
||
if (isBlocked.overlap(x, y) !== 0) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if (typeof isBlocked === 'function') {
|
||
if (isBlocked(x, y)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if (typeof isBlocked.isBlocked === 'function') {
|
||
if (isBlocked.isBlocked(x, y)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Checks whether a sub-tile ray from `(fromX, fromY)` to `(toX, toY)` is blocked by
|
||
* `COLLIDE_MASK_SPAWN_LOS`, solid `blocked` sub-tiles, or diagonal wall-corner clipping
|
||
* where two orthogonal wall sub-tiles meet at a diagonal corner.
|
||
*/
|
||
function isSubTileRayBlocked(
|
||
grid: CollisionGrid,
|
||
fromX: number,
|
||
fromY: number,
|
||
toX: number,
|
||
toY: number,
|
||
): boolean {
|
||
const los = rayTraceScene(grid, fromX, fromY, toX, toY, COLLIDE_MASK_SPAWN_LOS)
|
||
if (los.hit) {
|
||
return true
|
||
}
|
||
|
||
const start = subTileAt(grid, fromX, fromY)
|
||
const end = subTileAt(grid, toX, toY)
|
||
const gridHeight = grid.cellsY * 5
|
||
|
||
const isSubTileSolid = (subX: number, subY: number): boolean => {
|
||
if (subX < 0 || subY < 0 || subX >= grid.gridWidth || subY >= gridHeight) {
|
||
return true
|
||
}
|
||
const mask = getCollisionMask(grid, subX, subY)
|
||
if ((mask & (COLLIDE_MASK_SPAWN_LOS | COLLIDE_MASK_DROP_BLOCK)) !== 0) {
|
||
return true
|
||
}
|
||
if (grid.blocked && grid.blocked[subY * grid.gridWidth + subX] === 1) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
let x0 = start.subX
|
||
let y0 = start.subY
|
||
const x1 = end.subX
|
||
const y1 = end.subY
|
||
const dx = Math.abs(x1 - x0)
|
||
const dy = Math.abs(y1 - y0)
|
||
const sx = x0 < x1 ? 1 : -1
|
||
const sy = y0 < y1 ? 1 : -1
|
||
let err = dx - dy
|
||
let steps = 0
|
||
|
||
while (true) {
|
||
if (steps > 0 && isSubTileSolid(x0, y0)) {
|
||
return true
|
||
}
|
||
if (x0 === x1 && y0 === y1) {
|
||
break
|
||
}
|
||
const e2 = 2 * err
|
||
const stepX = e2 > -dy
|
||
const stepY = e2 < dx
|
||
|
||
if (stepX && stepY) {
|
||
// Diagonal step across sub-tile corner: block if both shared cardinal neighbors are solid
|
||
if (isSubTileSolid(x0 + sx, y0) && isSubTileSolid(x0, y0 + sy)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if (stepX) {
|
||
err -= dy
|
||
x0 += sx
|
||
}
|
||
if (stepY) {
|
||
err += dx
|
||
y0 += sy
|
||
}
|
||
steps += 1
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Helper to check whether a candidate world position is blocked by collision, Line-of-Sight,
|
||
* or diagonal wall-corner pinching.
|
||
*/
|
||
export function isDropPositionBlocked(
|
||
candX: number,
|
||
candY: number,
|
||
originX: number,
|
||
originY: number,
|
||
isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any,
|
||
): boolean {
|
||
if (!isBlocked) return false
|
||
|
||
if (isTerrainPointBlocked(candX, candY, isBlocked)) {
|
||
return true
|
||
}
|
||
|
||
if (candX === originX && candY === originY) {
|
||
return false
|
||
}
|
||
|
||
const grid = extractCollisionGrid(isBlocked)
|
||
if (grid !== undefined) {
|
||
if (isSubTileRayBlocked(grid, originX, originY, candX, candY)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if (typeof isBlocked.hasLineOfSight === 'function') {
|
||
if (!isBlocked.hasLineOfSight(originX, originY, candX, candY)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// Prevent diagonal corner-clipping on non-grid callbacks for 1-step diagonal sub-tile offsets
|
||
const relX = candX - originX
|
||
const relY = candY - originY
|
||
const dx = Math.round((relX / ORTHO_SUB_TILE_WIDTH + relY / ORTHO_SUB_TILE_HEIGHT) / 2)
|
||
const dy = Math.round((relY / ORTHO_SUB_TILE_HEIGHT - relX / ORTHO_SUB_TILE_WIDTH) / 2)
|
||
if (Math.abs(dx) === 1 && Math.abs(dy) === 1) {
|
||
const c1x = originX + dx * ORTHO_SUB_TILE_WIDTH
|
||
const c1y = originY + dx * ORTHO_SUB_TILE_HEIGHT
|
||
const c2x = originX - dy * ORTHO_SUB_TILE_WIDTH
|
||
const c2y = originY + dy * ORTHO_SUB_TILE_HEIGHT
|
||
if (isTerrainPointBlocked(c1x, c1y, isBlocked) && isTerrainPointBlocked(c2x, c2y, isBlocked)) {
|
||
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, player-reachable 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_DROP_BLOCK, player footprint overlap, and LoS raycast.
|
||
* - Verifies 4-connected walkable path reachability from the drop anchor (and from `reachableFrom`
|
||
* when provided), snapping blocked or isolated drop origins to the nearest reachable floor sub-tile.
|
||
* - 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).
|
||
* @param reachableFrom - optional player world coordinates to guarantee the drop is path-reachable by the player.
|
||
*/
|
||
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,
|
||
reachableFrom?: { x: number; y: number },
|
||
): { x: number; y: number } {
|
||
const minSpacingSq = minSpacing * minSpacing
|
||
const grid = extractCollisionGrid(isBlocked)
|
||
|
||
const coordAt = (baseX: number, baseY: number, dx: number, dy: number): { x: number; y: number } => ({
|
||
x: baseX + (dx - dy) * stepX,
|
||
y: baseY + (dx + dy) * stepY,
|
||
})
|
||
|
||
const isPointWalkable = (x: number, y: number): boolean => !isTerrainPointBlocked(x, y, isBlocked)
|
||
|
||
const canStepCardinal = (fromX: number, fromY: number, toX: number, toY: number): boolean => {
|
||
if (!isPointWalkable(toX, toY)) return false
|
||
if (grid !== undefined && isSubTileRayBlocked(grid, fromX, fromY, toX, toY)) return false
|
||
if (typeof isBlocked?.hasLineOfSight === 'function' && !isBlocked.hasLineOfSight(fromX, fromY, toX, toY)) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
const canGreedyWalkOnLattice = (
|
||
baseX: number,
|
||
baseY: number,
|
||
fromDx: number,
|
||
fromDy: number,
|
||
toDx: number,
|
||
toDy: number,
|
||
): boolean => {
|
||
let curDx = fromDx
|
||
let curDy = fromDy
|
||
while (curDx !== toDx || curDy !== toDy) {
|
||
const curPos = coordAt(baseX, baseY, curDx, curDy)
|
||
const remX = Math.abs(toDx - curDx)
|
||
const remY = Math.abs(toDy - curDy)
|
||
const sX = curDx < toDx ? 1 : -1
|
||
const sY = curDy < toDy ? 1 : -1
|
||
|
||
const nextPosX = coordAt(baseX, baseY, curDx + sX, curDy)
|
||
const nextPosY = coordAt(baseX, baseY, curDx, curDy + sY)
|
||
|
||
if (remX >= remY && remX > 0 && canStepCardinal(curPos.x, curPos.y, nextPosX.x, nextPosX.y)) {
|
||
curDx += sX
|
||
} else if (remY > 0 && canStepCardinal(curPos.x, curPos.y, nextPosY.x, nextPosY.y)) {
|
||
curDy += sY
|
||
} else if (remX > 0 && canStepCardinal(curPos.x, curPos.y, nextPosX.x, nextPosX.y)) {
|
||
curDx += sX
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Step 1: Resolve effective reachable anchor (anchorX, anchorY)
|
||
let anchorX = originX
|
||
let anchorY = originY
|
||
const originWalkable = isPointWalkable(originX, originY)
|
||
let playerReachableOffsetsFromOrigin: Set<string> | null = null
|
||
|
||
if (
|
||
isBlocked &&
|
||
reachableFrom &&
|
||
Number.isFinite(reachableFrom.x) &&
|
||
Number.isFinite(reachableFrom.y)
|
||
) {
|
||
const relX = reachableFrom.x - originX
|
||
const relY = reachableFrom.y - originY
|
||
const rawStartDx = Math.round((relX / stepX + relY / stepY) / 2)
|
||
const rawStartDy = Math.round((relY / stepY - relX / stepX) / 2)
|
||
|
||
let startNode: { dx: number; dy: number } | null = null
|
||
const rawStartPos = coordAt(originX, originY, rawStartDx, rawStartDy)
|
||
if (isPointWalkable(rawStartPos.x, rawStartPos.y)) {
|
||
startNode = { dx: rawStartDx, dy: rawStartDy }
|
||
} else if (isPointWalkable(reachableFrom.x, reachableFrom.y)) {
|
||
for (const c of getIsometricRingCandidates(1)) {
|
||
const p = coordAt(originX, originY, rawStartDx + c.dx, rawStartDy + c.dy)
|
||
if (isPointWalkable(p.x, p.y)) {
|
||
startNode = { dx: rawStartDx + c.dx, dy: rawStartDy + c.dy }
|
||
break
|
||
}
|
||
}
|
||
} else {
|
||
for (let r = 1; r <= 2 && startNode === null; r += 1) {
|
||
for (const c of getIsometricRingCandidates(r)) {
|
||
const p = coordAt(originX, originY, rawStartDx + c.dx, rawStartDy + c.dy)
|
||
if (isPointWalkable(p.x, p.y)) {
|
||
startNode = { dx: rawStartDx + c.dx, dy: rawStartDy + c.dy }
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (startNode !== null) {
|
||
const distL1 = Math.abs(startNode.dx) + Math.abs(startNode.dy)
|
||
const directReachable =
|
||
originWalkable &&
|
||
distL1 <= 80 &&
|
||
canGreedyWalkOnLattice(originX, originY, startNode.dx, startNode.dy, 0, 0)
|
||
|
||
if (!directReachable && distL1 <= 120) {
|
||
const margin = Math.max(maxRings + 6, 16)
|
||
const minDx = Math.min(startNode.dx, 0) - margin
|
||
const maxDx = Math.max(startNode.dx, 0) + margin
|
||
const minDy = Math.min(startNode.dy, 0) - margin
|
||
const maxDy = Math.max(startNode.dy, 0) + margin
|
||
|
||
const visited = new Set<string>([`${startNode.dx},${startNode.dy}`])
|
||
const queue: [number, number][] = [[startNode.dx, startNode.dy]]
|
||
let head = 0
|
||
while (head < queue.length && queue.length < 4096) {
|
||
const [cx, cy] = queue[head++]!
|
||
const curWorld = coordAt(originX, originY, cx, cy)
|
||
const neighbors: [number, number][] = [
|
||
[cx - 1, cy],
|
||
[cx + 1, cy],
|
||
[cx, cy - 1],
|
||
[cx, cy + 1],
|
||
]
|
||
for (const [nx, ny] of neighbors) {
|
||
if (nx < minDx || nx > maxDx || ny < minDy || ny > maxDy) continue
|
||
const key = `${nx},${ny}`
|
||
if (visited.has(key)) continue
|
||
const nextWorld = coordAt(originX, originY, nx, ny)
|
||
if (!canStepCardinal(curWorld.x, curWorld.y, nextWorld.x, nextWorld.y)) continue
|
||
visited.add(key)
|
||
queue.push([nx, ny])
|
||
}
|
||
}
|
||
|
||
playerReachableOffsetsFromOrigin = visited
|
||
if (!visited.has('0,0')) {
|
||
// Snap anchor to the player-reachable coordinate closest to origin (0, 0)
|
||
let snapped = false
|
||
const scanMax = Math.max(maxRings, 20)
|
||
for (let r = 1; r <= scanMax && !snapped; r += 1) {
|
||
for (const cand of getIsometricRingCandidates(r)) {
|
||
if (visited.has(`${cand.dx},${cand.dy}`)) {
|
||
const pos = coordAt(originX, originY, cand.dx, cand.dy)
|
||
anchorX = pos.x
|
||
anchorY = pos.y
|
||
snapped = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if (!snapped) {
|
||
let bestDx = startNode.dx
|
||
let bestDy = startNode.dy
|
||
let bestL1 = Math.abs(bestDx) + Math.abs(bestDy)
|
||
let bestSq = bestDx * bestDx + bestDy * bestDy
|
||
for (const [vx, vy] of queue) {
|
||
const l1 = Math.abs(vx) + Math.abs(vy)
|
||
const sq = vx * vx + vy * vy
|
||
if (l1 < bestL1 || (l1 === bestL1 && sq < bestSq)) {
|
||
bestL1 = l1
|
||
bestSq = sq
|
||
bestDx = vx
|
||
bestDy = vy
|
||
}
|
||
}
|
||
const pos = coordAt(originX, originY, bestDx, bestDy)
|
||
anchorX = pos.x
|
||
anchorY = pos.y
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If anchor is still on a blocked coordinate (e.g. origin inside thick wall/water/void without reachableFrom),
|
||
// snap anchor to the nearest walkable floor coordinate with the largest local connected component.
|
||
if (isBlocked && !isPointWalkable(anchorX, anchorY)) {
|
||
const measureComponentSize = (startX: number, startY: number, cap = 12): number => {
|
||
const seen = new Set<string>(['0,0'])
|
||
const q: [number, number][] = [[0, 0]]
|
||
let h = 0
|
||
while (h < q.length && seen.size < cap) {
|
||
const [qx, qy] = q[h++]!
|
||
const curW = coordAt(startX, startY, qx, qy)
|
||
const nbs: [number, number][] = [
|
||
[qx - 1, qy],
|
||
[qx + 1, qy],
|
||
[qx, qy - 1],
|
||
[qx, qy + 1],
|
||
]
|
||
for (const [nx, ny] of nbs) {
|
||
const k = `${nx},${ny}`
|
||
if (seen.has(k)) continue
|
||
const nextW = coordAt(startX, startY, nx, ny)
|
||
if (!canStepCardinal(curW.x, curW.y, nextW.x, nextW.y)) continue
|
||
seen.add(k)
|
||
q.push([nx, ny])
|
||
if (seen.size >= cap) break
|
||
}
|
||
}
|
||
return seen.size
|
||
}
|
||
|
||
const escapeRings = Math.max(maxRings, 30)
|
||
let fallbackWalkable: { x: number; y: number; size: number } | null = null
|
||
let snapped = false
|
||
for (let r = 1; r <= escapeRings && !snapped; r += 1) {
|
||
for (const cand of getIsometricRingCandidates(r)) {
|
||
const pos = coordAt(originX, originY, cand.dx, cand.dy)
|
||
if (!isPointWalkable(pos.x, pos.y)) continue
|
||
const compSize = measureComponentSize(pos.x, pos.y, 8)
|
||
if (compSize >= 6) {
|
||
anchorX = pos.x
|
||
anchorY = pos.y
|
||
snapped = true
|
||
break
|
||
}
|
||
if (fallbackWalkable === null || compSize > fallbackWalkable.size) {
|
||
fallbackWalkable = { x: pos.x, y: pos.y, size: compSize }
|
||
}
|
||
}
|
||
}
|
||
if (!snapped && fallbackWalkable !== null) {
|
||
anchorX = fallbackWalkable.x
|
||
anchorY = fallbackWalkable.y
|
||
}
|
||
}
|
||
|
||
// Step 2: Lazy 4-connected walkable verification around (anchorX, anchorY)
|
||
const anchorWalkable = isPointWalkable(anchorX, anchorY)
|
||
let anchorConnectedOffsets: Set<string> | null = null
|
||
|
||
const isCandidateConnectedToAnchor = (dx: number, dy: number): boolean => {
|
||
if (!isBlocked || !anchorWalkable) return true
|
||
if (dx === 0 && dy === 0) return true
|
||
|
||
if (canGreedyWalkOnLattice(anchorX, anchorY, 0, 0, dx, dy)) {
|
||
return true
|
||
}
|
||
|
||
if (anchorConnectedOffsets === null) {
|
||
const bound = Math.max(maxRings + 4, 14)
|
||
anchorConnectedOffsets = new Set<string>(['0,0'])
|
||
const q: [number, number][] = [[0, 0]]
|
||
let h = 0
|
||
while (h < q.length && q.length < 2048) {
|
||
const [qx, qy] = q[h++]!
|
||
const curW = coordAt(anchorX, anchorY, qx, qy)
|
||
const nbs: [number, number][] = [
|
||
[qx - 1, qy],
|
||
[qx + 1, qy],
|
||
[qx, qy - 1],
|
||
[qx, qy + 1],
|
||
]
|
||
for (const [nx, ny] of nbs) {
|
||
if (Math.abs(nx) > bound || Math.abs(ny) > bound) continue
|
||
const k = `${nx},${ny}`
|
||
if (anchorConnectedOffsets.has(k)) continue
|
||
const nextW = coordAt(anchorX, anchorY, nx, ny)
|
||
if (!canStepCardinal(curW.x, curW.y, nextW.x, nextW.y)) continue
|
||
anchorConnectedOffsets.add(k)
|
||
q.push([nx, ny])
|
||
}
|
||
}
|
||
}
|
||
|
||
return anchorConnectedOffsets.has(`${dx},${dy}`)
|
||
}
|
||
|
||
// Step 3: Concentric diamond ring search around (anchorX, anchorY)
|
||
let firstWalkableCandidate: { x: number; y: number } | null = null
|
||
|
||
for (let ring = 0; ring <= maxRings; ring += 1) {
|
||
const ringCandidates = getIsometricRingCandidates(ring)
|
||
|
||
for (const cand of ringCandidates) {
|
||
const cx = anchorX + (cand.dx - cand.dy) * stepX
|
||
const cy = anchorY + (cand.dx + cand.dy) * stepY
|
||
|
||
// Check collision and line of sight from the walkable anchor
|
||
if (isDropPositionBlocked(cx, cy, anchorX, anchorY, isBlocked)) {
|
||
continue
|
||
}
|
||
|
||
// Check 4-way walkable connectivity to anchor (prevents clipping across walls/corners)
|
||
if (!isCandidateConnectedToAnchor(cand.dx, cand.dy)) {
|
||
continue
|
||
}
|
||
|
||
// If player reachability set was computed on origin lattice, verify candidate is also in it
|
||
if (playerReachableOffsetsFromOrigin !== null && anchorX === originX && anchorY === originY) {
|
||
if (!playerReachableOffsetsFromOrigin.has(`${cand.dx},${cand.dy}`)) {
|
||
continue
|
||
}
|
||
}
|
||
|
||
if (firstWalkableCandidate === null) {
|
||
firstWalkableCandidate = { x: cx, y: cy }
|
||
}
|
||
|
||
// 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 firstWalkableCandidate ?? { x: anchorX, y: anchorY }
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
dropSound: meta.dropSound,
|
||
dropSfxFrame: meta.dropSfxFrame,
|
||
sfxPlayed: false,
|
||
...(meta.ethereal !== undefined ? { ethereal: meta.ethereal } : {}),
|
||
...(meta.sockets !== undefined ? { sockets: meta.sockets } : {}),
|
||
}
|
||
|
||
this.items.set(id, entity)
|
||
return entity
|
||
}
|
||
|
||
/**
|
||
* Advance ground item bounce states and trigger authentic drop SFX
|
||
* when animation reaches dropsfxframe (or on bounce landing).
|
||
*/
|
||
updateBounces(
|
||
now: number,
|
||
audioManager?: { playSfx: (name: string) => void },
|
||
): readonly { entityId: string; sound: string; frame: number }[] {
|
||
const triggered: { entityId: string; sound: string; frame: number }[] = []
|
||
for (const entity of this.items.values()) {
|
||
if (!entity.bounceState || entity.sfxPlayed) continue
|
||
const elapsed = now - entity.bounceState.startTime
|
||
// 25 fps simulation = 40ms per frame
|
||
const currentFrame = Math.floor(elapsed / 40)
|
||
const targetFrame = entity.dropSfxFrame ?? 12
|
||
const landed = elapsed >= entity.bounceState.durationMs
|
||
|
||
if (currentFrame >= targetFrame || landed) {
|
||
const sound = entity.dropSound || 'item_drop'
|
||
if (audioManager && typeof audioManager.playSfx === 'function') {
|
||
try {
|
||
audioManager.playSfx(sound)
|
||
} catch {
|
||
// Gracefully handled
|
||
}
|
||
}
|
||
entity.sfxPlayed = true
|
||
triggered.push({ entityId: entity.id, sound, frame: currentFrame })
|
||
}
|
||
}
|
||
return triggered
|
||
}
|
||
|
||
/** 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
|
||
}
|
||
}
|