fix(drop): prevent items and gold from dropping into player-unreachable locations (Fixes #492)
This commit is contained in:
parent
d2358ede37
commit
9c2e3ca81c
|
|
@ -1452,12 +1452,19 @@ export class GameEngine {
|
|||
const currentSimTime = this.world.tick * 40
|
||||
const spawned: GroundItemEntity[] = []
|
||||
|
||||
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
|
||||
|
||||
for (const drop of droppedItems) {
|
||||
const dropPos = findIsometricDropPosition(
|
||||
pending.x,
|
||||
pending.y,
|
||||
burstOccupied,
|
||||
this.terrain,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
reachableFrom,
|
||||
)
|
||||
burstOccupied.push(dropPos)
|
||||
const dropCell = worldToCell(this.terrain, dropPos.x, dropPos.y)
|
||||
|
|
@ -1486,11 +1493,17 @@ export class GameEngine {
|
|||
dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity {
|
||||
const rawX = x ?? this.world.player.x
|
||||
const rawY = y ?? this.world.player.y
|
||||
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
|
||||
const dropPos = findIsometricDropPosition(
|
||||
rawX,
|
||||
rawY,
|
||||
this.groundItems.all,
|
||||
this.terrain,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
reachableFrom,
|
||||
)
|
||||
const finalX = dropPos.x
|
||||
const finalY = dropPos.y
|
||||
|
|
@ -1518,11 +1531,17 @@ export class GameEngine {
|
|||
if (amount <= 0) return null
|
||||
const rawX = x ?? this.world.player.x
|
||||
const rawY = y ?? this.world.player.y
|
||||
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
|
||||
const dropPos = findIsometricDropPosition(
|
||||
rawX,
|
||||
rawY,
|
||||
this.groundItems.all,
|
||||
this.terrain,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
reachableFrom,
|
||||
)
|
||||
const finalX = dropPos.x
|
||||
const finalY = dropPos.y
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ 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,
|
||||
|
|
@ -24,9 +27,11 @@ import {
|
|||
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 {
|
||||
|
|
@ -38,6 +43,21 @@ import {
|
|||
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
|
||||
|
|
@ -383,7 +403,7 @@ export function triggerFlippyBounce(
|
|||
/**
|
||||
* 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_WALL, COLLIDE_BLANK, or COLLIDE_MASK_INVALID, and not occupied by existing items.
|
||||
* 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,
|
||||
|
|
@ -391,22 +411,23 @@ export function findSafeDropPosition(
|
|||
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) return false
|
||||
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') {
|
||||
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 (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
|
||||
}
|
||||
|
|
@ -418,18 +439,35 @@ export function findSafeDropPosition(
|
|||
|
||||
if (targetGrid.collisionMasks) {
|
||||
const mask = targetGrid.collisionMasks[subIdx] ?? COLLIDE_NONE
|
||||
if ((mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0) {
|
||||
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 (ArrayBuffer.isView(targetGrid)) {
|
||||
if (targetGrid && ArrayBuffer.isView(targetGrid)) {
|
||||
if (cx < 0 || cy < 0) return true
|
||||
const width = typeof (targetGrid as any).width === 'number'
|
||||
? (targetGrid as any).width
|
||||
|
|
@ -445,11 +483,11 @@ export function findSafeDropPosition(
|
|||
if (height > 0 && cy >= height) return true
|
||||
const idx = cy * width + cx
|
||||
const val = (targetGrid as any)[idx] ?? COLLIDE_NONE
|
||||
return (val & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0 || val === 1
|
||||
return (val & COLLIDE_MASK_DROP_BLOCK) !== 0 || val === 1
|
||||
}
|
||||
|
||||
// 4. Test grid object with cells or collisionMasks array
|
||||
if (targetGrid.cells || targetGrid.collisionMasks) {
|
||||
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
|
||||
|
|
@ -458,19 +496,20 @@ export function findSafeDropPosition(
|
|||
|
||||
const idx = cy * width + cx
|
||||
const mask = (targetGrid.collisionMasks ? targetGrid.collisionMasks[idx] : targetGrid.cells[idx]) ?? COLLIDE_NONE
|
||||
return (mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0
|
||||
return (mask & COLLIDE_MASK_DROP_BLOCK) !== 0
|
||||
}
|
||||
|
||||
// 5. CountingTerrain / WorldMapProvider overlap callback
|
||||
if (typeof targetGrid.overlap === 'function') {
|
||||
if (targetGrid.widthPx !== undefined && targetGrid.heightPx !== undefined) {
|
||||
const originX = typeof targetGrid.originX === 'number' ? targetGrid.originX : 0
|
||||
const originY = typeof targetGrid.originY === 'number' ? targetGrid.originY : 0
|
||||
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 targetGrid.overlap(wx, wy) > 0
|
||||
return overlapTarget.overlap(wx, wy) > 0
|
||||
}
|
||||
return targetGrid.overlap(cx, cy) !== 0
|
||||
return overlapTarget.overlap(cx, cy) !== 0
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
@ -504,19 +543,137 @@ export function findSafeDropPosition(
|
|||
return occupiedSet.has(`${cx},${cy}`)
|
||||
}
|
||||
|
||||
let chosenCellX = originCellX
|
||||
let chosenCellY = originCellY
|
||||
// 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 origin cell
|
||||
if (!isBlocked(originCellX, originCellY)) {
|
||||
if (!isCellOccupied(originCellX, originCellY)) {
|
||||
chosenCellX = originCellX
|
||||
chosenCellY = originCellY
|
||||
// Ring 0: check base cell
|
||||
if (baseWalkable && isConnectedToBase(baseCellX, baseCellY)) {
|
||||
if (!isCellOccupied(baseCellX, baseCellY)) {
|
||||
chosenCellX = baseCellX
|
||||
chosenCellY = baseCellY
|
||||
found = true
|
||||
} else {
|
||||
firstWalkableOccupied = { cx: originCellX, cy: originCellY }
|
||||
firstWalkableOccupied = { cx: baseCellX, cy: baseCellY }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -525,10 +682,10 @@ export function findSafeDropPosition(
|
|||
for (let r = 1; r <= maxRadius; r += 1) {
|
||||
const ringCandidates = getIsometricRingCandidates(r)
|
||||
for (const cand of ringCandidates) {
|
||||
const cx = originCellX + cand.dx
|
||||
const cy = originCellY + cand.dy
|
||||
const cx = baseCellX + cand.dx
|
||||
const cy = baseCellY + cand.dy
|
||||
|
||||
if (isBlocked(cx, cy)) {
|
||||
if (isBlocked(cx, cy) || !isConnectedToBase(cx, cy)) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -548,6 +705,32 @@ export function findSafeDropPosition(
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -580,8 +763,142 @@ export function findSafeDropPosition(
|
|||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check whether a candidate world position is blocked by collision or Line-of-Sight.
|
||||
* 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,
|
||||
|
|
@ -592,52 +909,38 @@ export function isDropPositionBlocked(
|
|||
): 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 (isTerrainPointBlocked(candX, candY, isBlocked)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (candX === originX && candY === originY) {
|
||||
return false
|
||||
}
|
||||
|
||||
const grid = extractCollisionGrid(isBlocked)
|
||||
if (grid !== undefined) {
|
||||
const mask = getCollisionMaskAt(grid, candX, candY)
|
||||
if ((mask & COLLIDE_MASK_SPAWN) !== 0) {
|
||||
return true
|
||||
}
|
||||
if (grid.blocked && isBlockedAt(grid, candX, candY)) {
|
||||
return true
|
||||
}
|
||||
const los = rayTraceScene(grid, originX, originY, candX, candY, COLLIDE_MASK_SPAWN_LOS)
|
||||
if (los.hit) {
|
||||
if (isSubTileRayBlocked(grid, originX, originY, candX, candY)) {
|
||||
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)) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -686,13 +989,15 @@ export function getIsometricRingCandidates(R: number): { dx: number; dy: number;
|
|||
}
|
||||
|
||||
/**
|
||||
* Find an unoccupied isometric grid slot around an origin coordinate.
|
||||
* 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_SPAWN (0x3E01) and LoS raycast against COLLIDE_MASK_SPAWN_LOS (0x0801).
|
||||
* - 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.
|
||||
|
|
@ -703,6 +1008,7 @@ export function getIsometricRingCandidates(R: number): { dx: number; dy: number;
|
|||
* @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,
|
||||
|
|
@ -713,25 +1019,300 @@ export function findIsometricDropPosition(
|
|||
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) {
|
||||
// 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
|
||||
const cx = anchorX + (cand.dx - cand.dy) * stepX
|
||||
const cy = anchorY + (cand.dx + cand.dy) * stepY
|
||||
|
||||
// Check collision and line of sight
|
||||
if (isDropPositionBlocked(cx, cy, originX, originY, isBlocked)) {
|
||||
// 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 }
|
||||
}
|
||||
|
|
@ -754,7 +1335,7 @@ export function findIsometricDropPosition(
|
|||
}
|
||||
}
|
||||
|
||||
return firstWalkableCandidate ?? { x: originX, y: originY }
|
||||
return firstWalkableCandidate ?? { x: anchorX, y: anchorY }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -740,12 +740,33 @@ export function createNetSimulation(
|
|||
})
|
||||
|
||||
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
|
||||
const alivePlayers = world.players.filter(p => p.alive)
|
||||
const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players
|
||||
let reachableFrom: { x: number; y: number } | undefined
|
||||
if (refPlayers.length > 0) {
|
||||
let bestPlayer = refPlayers[0]!
|
||||
let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y)
|
||||
for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) {
|
||||
const p = refPlayers[pIdx]!
|
||||
const d = Math.hypot(p.x - event.x, p.y - event.y)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
bestPlayer = p
|
||||
}
|
||||
}
|
||||
reachableFrom = { x: bestPlayer.x, y: bestPlayer.y }
|
||||
}
|
||||
for (const drop of droppedItems) {
|
||||
const dropPos = findIsometricDropPosition(
|
||||
event.x,
|
||||
event.y,
|
||||
burstOccupied,
|
||||
mapTerrain,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
reachableFrom,
|
||||
)
|
||||
burstOccupied.push(dropPos)
|
||||
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
COLLIDE_MASK_DROP_BLOCK,
|
||||
findIsometricDropPosition,
|
||||
findSafeDropPosition,
|
||||
isDropPositionBlocked,
|
||||
isTerrainPointBlocked,
|
||||
isWithinPickupBounds,
|
||||
} from '../src/game/ground-items.ts'
|
||||
import {
|
||||
COLLIDE_BLANK,
|
||||
COLLIDE_DOOR,
|
||||
COLLIDE_MASK_INVALID,
|
||||
COLLIDE_NO_PATH,
|
||||
COLLIDE_NONE,
|
||||
COLLIDE_NOPLAYER,
|
||||
COLLIDE_OBJECT,
|
||||
COLLIDE_WALL,
|
||||
COLLIDE_WATER,
|
||||
ORTHO_CELL_HEIGHT,
|
||||
ORTHO_CELL_WIDTH,
|
||||
ORTHO_SUB_TILE_HEIGHT,
|
||||
ORTHO_SUB_TILE_WIDTH,
|
||||
getCollisionMaskAt,
|
||||
isBlockedAt,
|
||||
subTileAt,
|
||||
subTileCentre,
|
||||
type CollisionGrid,
|
||||
} from '../src/game/d2map.ts'
|
||||
import { createIsoTerrain } from '../src/game/iso-terrain.ts'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
|
||||
function createSubTileGrid(cellsX = 12, cellsY = 12): CollisionGrid {
|
||||
const gridWidth = cellsX * 5
|
||||
const gridHeight = cellsY * 5
|
||||
return {
|
||||
cellsX,
|
||||
cellsY,
|
||||
gridWidth,
|
||||
originX: cellsY * ORTHO_CELL_WIDTH,
|
||||
originY: 0,
|
||||
blocked: new Uint8Array(gridWidth * gridHeight),
|
||||
collisionMasks: new Uint16Array(gridWidth * gridHeight),
|
||||
}
|
||||
}
|
||||
|
||||
function setSubTileMask(grid: CollisionGrid, subX: number, subY: number, mask: number): void {
|
||||
const idx = subY * grid.gridWidth + subX
|
||||
if (grid.collisionMasks) {
|
||||
grid.collisionMasks[idx] = mask
|
||||
}
|
||||
grid.blocked[idx] = (mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_NOPLAYER | COLLIDE_WATER | COLLIDE_DOOR | COLLIDE_OBJECT)) !== 0 ? 1 : 0
|
||||
}
|
||||
|
||||
describe('Issue #492: Prevent Ground Items & Gold from Dropping into Unreachable Locations', () => {
|
||||
describe('1. Blocked Origin Escape (Monster Dying Inside Thick Walls / Void / Water)', () => {
|
||||
it('escapes a 4x4 solid wall block instead of poisoning rayTraceScene and falling back to blocked origin', () => {
|
||||
const grid = createSubTileGrid(12, 12)
|
||||
// Block a 5x5 sub-tile block around sub-tile (25, 25)
|
||||
for (let sy = 23; sy <= 27; sy += 1) {
|
||||
for (let sx = 23; sx <= 27; sx += 1) {
|
||||
setSubTileMask(grid, sx, sy, COLLIDE_WALL)
|
||||
}
|
||||
}
|
||||
|
||||
const origin = subTileCentre(grid, 25, 25)
|
||||
expect(isTerrainPointBlocked(origin.x, origin.y, grid)).toBe(true)
|
||||
|
||||
const dropPos = findIsometricDropPosition(origin.x, origin.y, [], grid)
|
||||
expect(isTerrainPointBlocked(dropPos.x, dropPos.y, grid)).toBe(false)
|
||||
expect(getCollisionMaskAt(grid, dropPos.x, dropPos.y) & COLLIDE_MASK_DROP_BLOCK).toBe(0)
|
||||
expect(isBlockedAt(grid, dropPos.x, dropPos.y)).toBe(false)
|
||||
})
|
||||
|
||||
it('escapes deeply buried origin (> 10 rings inside void/water) to nearest walkable floor', () => {
|
||||
const grid = createSubTileGrid(16, 16)
|
||||
// Block sub-tiles [15..45, 15..45] (radius 15 > default maxRings=10 around (30, 30))
|
||||
for (let sy = 15; sy <= 45; sy += 1) {
|
||||
for (let sx = 15; sx <= 45; sx += 1) {
|
||||
setSubTileMask(grid, sx, sy, COLLIDE_WATER)
|
||||
}
|
||||
}
|
||||
|
||||
const buriedOrigin = subTileCentre(grid, 30, 30)
|
||||
expect(isTerrainPointBlocked(buriedOrigin.x, buriedOrigin.y, grid)).toBe(true)
|
||||
|
||||
const dropPos = findIsometricDropPosition(buriedOrigin.x, buriedOrigin.y, [], grid)
|
||||
expect(isTerrainPointBlocked(dropPos.x, dropPos.y, grid)).toBe(false)
|
||||
expect(getCollisionMaskAt(grid, dropPos.x, dropPos.y) & COLLIDE_MASK_DROP_BLOCK).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Player Path-Connectivity (`reachableFrom`) Across Rivers, Moats & Sealed Rooms', () => {
|
||||
it('snaps drops from a monster killed across an impassable river onto the player side of the river', () => {
|
||||
const grid = createSubTileGrid(12, 12)
|
||||
// Create a full north-south river at subX = 24..27 across the entire map height
|
||||
for (let sy = 0; sy < 60; sy += 1) {
|
||||
for (let sx = 24; sx <= 27; sx += 1) {
|
||||
setSubTileMask(grid, sx, sy, COLLIDE_WATER | COLLIDE_WALL)
|
||||
}
|
||||
}
|
||||
|
||||
// Player is on the West bank (subX = 20, subY = 30)
|
||||
const playerPos = subTileCentre(grid, 20, 30)
|
||||
// Flying/ranged monster dies on the East bank (subX = 32, subY = 30) across the river
|
||||
const monsterDeathPos = subTileCentre(grid, 32, 30)
|
||||
|
||||
expect(isTerrainPointBlocked(playerPos.x, playerPos.y, grid)).toBe(false)
|
||||
expect(isTerrainPointBlocked(monsterDeathPos.x, monsterDeathPos.y, grid)).toBe(false)
|
||||
|
||||
const existing: { x: number; y: number }[] = []
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const dropPos = findIsometricDropPosition(
|
||||
monsterDeathPos.x,
|
||||
monsterDeathPos.y,
|
||||
existing,
|
||||
grid,
|
||||
ORTHO_SUB_TILE_WIDTH,
|
||||
ORTHO_SUB_TILE_HEIGHT,
|
||||
10,
|
||||
16,
|
||||
playerPos,
|
||||
)
|
||||
existing.push(dropPos)
|
||||
|
||||
const dropSub = subTileAt(grid, dropPos.x, dropPos.y)
|
||||
// Every dropped item must land on the West bank (subX < 24) where the player can reach it
|
||||
expect(dropSub.subX).toBeLessThan(24)
|
||||
expect(isTerrainPointBlocked(dropPos.x, dropPos.y, grid)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('never leaks items through a thin 1-sub-tile wall into an adjacent sealed room during a 15-item burst drop', () => {
|
||||
const grid = createSubTileGrid(12, 12)
|
||||
// Seal a 3x3 sub-tile room at subX = 20..22, subY = 20..22 with a 1-sub-tile wall ring at 19..23
|
||||
for (let sy = 19; sy <= 23; sy += 1) {
|
||||
for (let sx = 19; sx <= 23; sx += 1) {
|
||||
const isInterior = sx >= 20 && sx <= 22 && sy >= 20 && sy <= 22
|
||||
if (!isInterior) {
|
||||
setSubTileMask(grid, sx, sy, COLLIDE_WALL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exterior floor (subX < 19 or > 23) is completely open, but separated by the wall ring
|
||||
const roomCenter = subTileCentre(grid, 21, 21)
|
||||
const existing: { x: number; y: number }[] = []
|
||||
|
||||
for (let i = 0; i < 15; i += 1) {
|
||||
const pos = findIsometricDropPosition(
|
||||
roomCenter.x,
|
||||
roomCenter.y,
|
||||
existing,
|
||||
grid,
|
||||
ORTHO_SUB_TILE_WIDTH,
|
||||
ORTHO_SUB_TILE_HEIGHT,
|
||||
10,
|
||||
16,
|
||||
roomCenter,
|
||||
)
|
||||
existing.push(pos)
|
||||
const sub = subTileAt(grid, pos.x, pos.y)
|
||||
expect(sub.subX).toBeGreaterThanOrEqual(20)
|
||||
expect(sub.subX).toBeLessThanOrEqual(22)
|
||||
expect(sub.subY).toBeGreaterThanOrEqual(20)
|
||||
expect(sub.subY).toBeLessThanOrEqual(22)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Diagonal Wall-Corner Pinch Prevention', () => {
|
||||
it('rejects diagonal step when both shared orthogonal neighbors are wall sub-tiles', () => {
|
||||
const grid = createSubTileGrid(10, 10)
|
||||
// Origin at (25, 25). Block (26, 25) and (25, 26) so diagonal (26, 26) is pinched by orthogonal walls.
|
||||
setSubTileMask(grid, 26, 25, COLLIDE_WALL)
|
||||
setSubTileMask(grid, 25, 26, COLLIDE_WALL)
|
||||
|
||||
const origin = subTileCentre(grid, 25, 25)
|
||||
const diagTarget = subTileCentre(grid, 26, 26)
|
||||
|
||||
expect(isTerrainPointBlocked(diagTarget.x, diagTarget.y, grid)).toBe(false)
|
||||
expect(isDropPositionBlocked(diagTarget.x, diagTarget.y, origin.x, origin.y, grid)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects diagonal cell in findSafeDropPosition when sealed by orthogonal wall cells', () => {
|
||||
const width = 5
|
||||
const height = 5
|
||||
const collisionMasks = new Uint16Array(width * height).fill(COLLIDE_WALL)
|
||||
// Open (2, 2) and (3, 3), but keep (3, 2) and (2, 3) as COLLIDE_WALL so (3, 3) is diagonally pinched
|
||||
collisionMasks[2 * width + 2] = COLLIDE_NONE
|
||||
collisionMasks[3 * width + 3] = COLLIDE_NONE
|
||||
const grid = { width, height, collisionMasks }
|
||||
|
||||
// Occupy (2, 2) so findSafeDropPosition searches Ring 1
|
||||
const pos = findSafeDropPosition(grid, 2, 2, 2, [{ cellX: 2, cellY: 2 }])
|
||||
// Must NOT jump diagonally into the sealed (3, 3) pocket; instead stacks safely on (2, 2)
|
||||
expect(pos.cellX).toBe(2)
|
||||
expect(pos.cellY).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Full Collision Mask & CountingTerrain Player Footprint Clearance', () => {
|
||||
it('rejects COLLIDE_WATER, COLLIDE_NOPLAYER, COLLIDE_BLANK, COLLIDE_NO_PATH, and COLLIDE_MASK_INVALID', () => {
|
||||
const forbiddenMasks = [
|
||||
COLLIDE_WATER,
|
||||
COLLIDE_NOPLAYER,
|
||||
COLLIDE_BLANK,
|
||||
COLLIDE_NO_PATH,
|
||||
COLLIDE_MASK_INVALID,
|
||||
COLLIDE_DOOR,
|
||||
COLLIDE_OBJECT,
|
||||
]
|
||||
|
||||
for (const mask of forbiddenMasks) {
|
||||
const grid = createSubTileGrid(8, 8)
|
||||
setSubTileMask(grid, 20, 20, mask)
|
||||
const pt = subTileCentre(grid, 20, 20)
|
||||
expect(isTerrainPointBlocked(pt.x, pt.y, grid)).toBe(true)
|
||||
|
||||
const drop = findIsometricDropPosition(pt.x, pt.y, [], grid)
|
||||
expect(drop).not.toEqual(pt)
|
||||
expect(isTerrainPointBlocked(drop.x, drop.y, grid)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces CountingTerrain.overlap(x, y) === 0 in both findIsometricDropPosition and findSafeDropPosition', () => {
|
||||
const grid = createSubTileGrid(10, 10)
|
||||
const terrain = createIsoTerrain(grid, 800, 800, { width: 20, height: 10 })
|
||||
|
||||
// Sub-tile (20, 20) itself is COLLIDE_NONE, but adjacent sub-tile (20, 21) is blocked so player 20x10 box overlaps
|
||||
const center = subTileCentre(grid, 20, 20)
|
||||
const neighbor = subTileAt(grid, center.x + 8, center.y + 4)
|
||||
setSubTileMask(grid, neighbor.subX, neighbor.subY, COLLIDE_WALL)
|
||||
|
||||
expect(terrain.overlap(center.x, center.y)).toBeGreaterThan(0)
|
||||
expect(isTerrainPointBlocked(center.x, center.y, terrain)).toBe(true)
|
||||
const drop = findIsometricDropPosition(center.x, center.y, [], terrain)
|
||||
expect(terrain.overlap(drop.x, drop.y)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('5. End-to-End GameEngine Walk & Pickup Reachability Verification', () => {
|
||||
it('guarantees player can physically walk to and pick up items/gold dropped when monster dies inside a wall', () => {
|
||||
const grid = createSubTileGrid(12, 12)
|
||||
// Create a solid wall on the right half of the map (subX >= 25)
|
||||
for (let sy = 0; sy < 60; sy += 1) {
|
||||
for (let sx = 25; sx < 60; sx += 1) {
|
||||
setSubTileMask(grid, sx, sy, COLLIDE_WALL)
|
||||
}
|
||||
}
|
||||
|
||||
const terrain = createIsoTerrain(grid, 1600, 1600, { width: 20, height: 10 })
|
||||
const playerSpawn = subTileCentre(grid, 20, 25)
|
||||
expect(terrain.overlap(playerSpawn.x, playerSpawn.y)).toBe(0)
|
||||
|
||||
const engine = new GameEngine(terrain, {
|
||||
spawn: playerSpawn,
|
||||
stats: [],
|
||||
xpTable: [0, 500, 1500],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: {
|
||||
playerSpeed: 4,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 10,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 1,
|
||||
respawnTicks: 100,
|
||||
},
|
||||
talkRadius: 50,
|
||||
pickupRadius: 30,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
})
|
||||
|
||||
// Drop item and gold at a point deep inside the wall (subX = 30, subY = 25)
|
||||
const insideWallPos = subTileCentre(grid, 30, 25)
|
||||
expect(terrain.overlap(insideWallPos.x, insideWallPos.y)).toBeGreaterThan(0)
|
||||
|
||||
const itemEntity = engine.dropItem(
|
||||
{ code: 'ssd', name: 'Short Sword', invWidth: 1, invHeight: 3 } as any,
|
||||
insideWallPos.x,
|
||||
insideWallPos.y,
|
||||
)
|
||||
const goldEntity = engine.dropGold(250, insideWallPos.x, insideWallPos.y)!
|
||||
|
||||
expect(terrain.overlap(itemEntity.x, itemEntity.y)).toBe(0)
|
||||
expect(terrain.overlap(goldEntity.x, goldEntity.y)).toBe(0)
|
||||
|
||||
// Physically walk the player toward the dropped item using axis-separated collision checks
|
||||
for (let step = 0; step < 120; step += 1) {
|
||||
const dx = itemEntity.x - engine.world.player.x
|
||||
const dy = itemEntity.y - engine.world.player.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist <= 4) break
|
||||
const vx = (dx / dist) * 4
|
||||
const vy = (dy / dist) * 4
|
||||
const cur = terrain.overlap(engine.world.player.x, engine.world.player.y)
|
||||
if (terrain.overlap(engine.world.player.x + vx, engine.world.player.y) <= cur) {
|
||||
engine.world.player.x += vx
|
||||
}
|
||||
if (terrain.overlap(engine.world.player.x, engine.world.player.y + vy) <= cur) {
|
||||
engine.world.player.y += vy
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
isWithinPickupBounds(
|
||||
engine.world.player.x,
|
||||
engine.world.player.y,
|
||||
itemEntity.x,
|
||||
itemEntity.y,
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const itemPickup = engine.pickupItem(itemEntity.id)
|
||||
expect(itemPickup.success).toBe(true)
|
||||
const goldPickup = engine.pickupGold(goldEntity.id)
|
||||
expect(goldPickup.success).toBe(true)
|
||||
expect(goldPickup.amount).toBe(250)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue