Compare commits
8 Commits
ce00574f83
...
d998a0551b
| Author | SHA1 | Date |
|---|---|---|
|
|
d998a0551b | |
|
|
9c2e3ca81c | |
|
|
d2358ede37 | |
|
|
4d023e815f | |
|
|
f3c500978d | |
|
|
e9c3b41768 | |
|
|
7cc56ed967 | |
|
|
5c19d92334 |
|
|
@ -566,6 +566,21 @@ export function rollItemEtherealAndSockets(
|
||||||
return { ethereal, sockets }
|
return { ethereal, sockets }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per 1.13c D2Game.dll (ITEMS_Create / 0x6FC2FC40):
|
||||||
|
* - Low Quality (1), Normal (2), Superior (3), and Quest items (quest > 0) spawn identified.
|
||||||
|
* - Magic (4), Set (5), Rare (6), Unique (7), and Crafted (8) non-quest items spawn unidentified on drop.
|
||||||
|
*/
|
||||||
|
export function shouldSpawnIdentifiedByDefault(
|
||||||
|
base: ItemBase,
|
||||||
|
qualityTier: ItemQualityTier,
|
||||||
|
): boolean {
|
||||||
|
if ((base.quest ?? 0) > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return qualityTier === 'low' || qualityTier === 'normal' || qualityTier === 'superior'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a concrete Item instance for a dropped leaf item.
|
* Creates a concrete Item instance for a dropped leaf item.
|
||||||
*/
|
*/
|
||||||
|
|
@ -587,10 +602,12 @@ export function createDroppedItem(
|
||||||
readonly dwInitSeed: number
|
readonly dwInitSeed: number
|
||||||
readonly durabilityMultiplier?: number | undefined
|
readonly durabilityMultiplier?: number | undefined
|
||||||
readonly itemRng?: D2Rng | undefined
|
readonly itemRng?: D2Rng | undefined
|
||||||
|
readonly identified?: boolean | undefined
|
||||||
},
|
},
|
||||||
): Item {
|
): Item {
|
||||||
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
|
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
|
||||||
let rawFlags = ItemFlag.IDENTIFIED
|
const isIdentified = options.identified ?? shouldSpawnIdentifiedByDefault(base, qualityTier)
|
||||||
|
let rawFlags = isIdentified ? ItemFlag.IDENTIFIED : 0
|
||||||
|
|
||||||
let name = base.name
|
let name = base.name
|
||||||
let prefix = null
|
let prefix = null
|
||||||
|
|
@ -894,6 +911,7 @@ export function createDroppedItem(
|
||||||
value: base.value,
|
value: base.value,
|
||||||
rawFlags,
|
rawFlags,
|
||||||
flags: decodeItemFlags(rawFlags),
|
flags: decodeItemFlags(rawFlags),
|
||||||
|
identified: isIdentified,
|
||||||
version: 0x65,
|
version: 0x65,
|
||||||
code: formatItemCode(base.id),
|
code: formatItemCode(base.id),
|
||||||
quality,
|
quality,
|
||||||
|
|
|
||||||
|
|
@ -1452,12 +1452,19 @@ export class GameEngine {
|
||||||
const currentSimTime = this.world.tick * 40
|
const currentSimTime = this.world.tick * 40
|
||||||
const spawned: GroundItemEntity[] = []
|
const spawned: GroundItemEntity[] = []
|
||||||
|
|
||||||
|
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
|
||||||
|
|
||||||
for (const drop of droppedItems) {
|
for (const drop of droppedItems) {
|
||||||
const dropPos = findIsometricDropPosition(
|
const dropPos = findIsometricDropPosition(
|
||||||
pending.x,
|
pending.x,
|
||||||
pending.y,
|
pending.y,
|
||||||
burstOccupied,
|
burstOccupied,
|
||||||
this.terrain,
|
this.terrain,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
reachableFrom,
|
||||||
)
|
)
|
||||||
burstOccupied.push(dropPos)
|
burstOccupied.push(dropPos)
|
||||||
const dropCell = worldToCell(this.terrain, dropPos.x, dropPos.y)
|
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 {
|
dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity {
|
||||||
const rawX = x ?? this.world.player.x
|
const rawX = x ?? this.world.player.x
|
||||||
const rawY = y ?? this.world.player.y
|
const rawY = y ?? this.world.player.y
|
||||||
|
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
|
||||||
const dropPos = findIsometricDropPosition(
|
const dropPos = findIsometricDropPosition(
|
||||||
rawX,
|
rawX,
|
||||||
rawY,
|
rawY,
|
||||||
this.groundItems.all,
|
this.groundItems.all,
|
||||||
this.terrain,
|
this.terrain,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
reachableFrom,
|
||||||
)
|
)
|
||||||
const finalX = dropPos.x
|
const finalX = dropPos.x
|
||||||
const finalY = dropPos.y
|
const finalY = dropPos.y
|
||||||
|
|
@ -1518,11 +1531,17 @@ export class GameEngine {
|
||||||
if (amount <= 0) return null
|
if (amount <= 0) return null
|
||||||
const rawX = x ?? this.world.player.x
|
const rawX = x ?? this.world.player.x
|
||||||
const rawY = y ?? this.world.player.y
|
const rawY = y ?? this.world.player.y
|
||||||
|
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
|
||||||
const dropPos = findIsometricDropPosition(
|
const dropPos = findIsometricDropPosition(
|
||||||
rawX,
|
rawX,
|
||||||
rawY,
|
rawY,
|
||||||
this.groundItems.all,
|
this.groundItems.all,
|
||||||
this.terrain,
|
this.terrain,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
reachableFrom,
|
||||||
)
|
)
|
||||||
const finalX = dropPos.x
|
const finalX = dropPos.x
|
||||||
const finalY = dropPos.y
|
const finalY = dropPos.y
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ import type { Item } from './items.ts'
|
||||||
import {
|
import {
|
||||||
COLLIDE_WALL,
|
COLLIDE_WALL,
|
||||||
COLLIDE_BLANK,
|
COLLIDE_BLANK,
|
||||||
|
COLLIDE_WATER,
|
||||||
|
COLLIDE_NO_PATH,
|
||||||
COLLIDE_MASK_INVALID,
|
COLLIDE_MASK_INVALID,
|
||||||
|
COLLIDE_MASK_PLAYER_PATH,
|
||||||
COLLIDE_NONE,
|
COLLIDE_NONE,
|
||||||
COLLIDE_ITEM,
|
COLLIDE_ITEM,
|
||||||
COLLIDE_MASK_SPAWN,
|
COLLIDE_MASK_SPAWN,
|
||||||
|
|
@ -24,15 +27,37 @@ import {
|
||||||
ORTHO_CELL_HEIGHT,
|
ORTHO_CELL_HEIGHT,
|
||||||
ORTHO_SUB_TILE_WIDTH,
|
ORTHO_SUB_TILE_WIDTH,
|
||||||
ORTHO_SUB_TILE_HEIGHT,
|
ORTHO_SUB_TILE_HEIGHT,
|
||||||
|
getCollisionMask,
|
||||||
getCollisionMaskAt,
|
getCollisionMaskAt,
|
||||||
isBlockedAt,
|
isBlockedAt,
|
||||||
rayTraceScene,
|
rayTraceScene,
|
||||||
|
subTileAt,
|
||||||
} from './d2map.ts'
|
} from './d2map.ts'
|
||||||
import type { CollisionGrid } from './d2map.ts'
|
import type { CollisionGrid } from './d2map.ts'
|
||||||
import { translateItemName, translateMagicName, translateRareName } from './tooltip-i18n.ts'
|
import {
|
||||||
|
resolveBaseItemNameEn,
|
||||||
|
translateItemName,
|
||||||
|
translateMagicName,
|
||||||
|
translateRareName,
|
||||||
|
} from './tooltip-i18n.ts'
|
||||||
import { isRuneCode } from './gems-data.ts'
|
import { isRuneCode } from './gems-data.ts'
|
||||||
import { getEmbeddedDropTables } from './embedded-drop-tables.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 {
|
export interface GroundItemBounceState {
|
||||||
startTime: number
|
startTime: number
|
||||||
durationMs: number
|
durationMs: number
|
||||||
|
|
@ -181,6 +206,25 @@ export interface GroundItemMetadata {
|
||||||
readonly sockets?: number | 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. */
|
/** Extract ground item metadata from any item source. */
|
||||||
export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata {
|
export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata {
|
||||||
const isGold = isGoldItem(item)
|
const isGold = isGoldItem(item)
|
||||||
|
|
@ -208,21 +252,15 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = typeof item?.name === 'string' ? item.name : 'Unknown Item'
|
|
||||||
const rawCode = typeof item?.code === 'string'
|
const rawCode = typeof item?.code === 'string'
|
||||||
? item.code.trim().toLowerCase()
|
? item.code.trim().toLowerCase()
|
||||||
: typeof item?.base?.id === 'string'
|
: typeof item?.base?.id === 'string'
|
||||||
? item.base.id.trim().toLowerCase()
|
? item.base.id.trim().toLowerCase()
|
||||||
: ''
|
: typeof item?.rawItem?.code === 'string'
|
||||||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
? item.rawItem.code.trim().toLowerCase()
|
||||||
const nameZh = formatBilingualGroundItemName(item, name, quality)
|
: typeof item?.rawItem?.base?.id === 'string'
|
||||||
if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) {
|
? item.rawItem.base.id.trim().toLowerCase()
|
||||||
try {
|
: ''
|
||||||
item.nameZh = nameZh
|
|
||||||
} catch {
|
|
||||||
// ignore if frozen object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tableBase: any
|
let tableBase: any
|
||||||
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
|
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
|
||||||
|
|
@ -236,6 +274,39 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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})`
|
||||||
|
: 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 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 invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1)
|
||||||
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
||||||
|
|
@ -332,7 +403,7 @@ export function triggerFlippyBounce(
|
||||||
/**
|
/**
|
||||||
* Concentric quadrant spiral neighbor search around origin (D2Common.dll ITEM_Drop / COLLISION_GetFreeCoordinatesImpl parity).
|
* 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
|
* 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(
|
export function findSafeDropPosition(
|
||||||
grid: any,
|
grid: any,
|
||||||
|
|
@ -340,22 +411,23 @@ export function findSafeDropPosition(
|
||||||
originCellY: number,
|
originCellY: number,
|
||||||
maxRadius = 3,
|
maxRadius = 3,
|
||||||
occupied?: readonly { cellX?: number; cellY?: number; x?: number; y?: number }[] | Set<string> | ((cx: number, cy: number) => boolean),
|
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 } {
|
): { cellX: number; cellY: number; x: number; y: number } {
|
||||||
const targetGrid = grid?.grid ?? grid
|
const targetGrid = grid?.grid ?? grid
|
||||||
|
|
||||||
const isBlocked = (cx: number, cy: number): boolean => {
|
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)
|
// 1. Function check (e.g. overlap callback or mock in tests)
|
||||||
if (typeof targetGrid === 'function') {
|
if (typeof targetGrid === 'function') {
|
||||||
return targetGrid(cx, cy) !== 0
|
return targetGrid(cx, cy) !== 0
|
||||||
}
|
}
|
||||||
if (typeof targetGrid.isBlocked === 'function') {
|
if (typeof targetGrid?.isBlocked === 'function') {
|
||||||
return targetGrid.isBlocked(cx, cy)
|
return targetGrid.isBlocked(cx, cy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Real CollisionGrid (cellsX, cellsY, gridWidth, blocked, optional collisionMasks)
|
// 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) {
|
if (cx < 0 || cy < 0 || cx >= targetGrid.cellsX || cy >= targetGrid.cellsY) {
|
||||||
return true // Out of map bounds -> COLLIDE_MASK_INVALID
|
return true // Out of map bounds -> COLLIDE_MASK_INVALID
|
||||||
}
|
}
|
||||||
|
|
@ -367,18 +439,35 @@ export function findSafeDropPosition(
|
||||||
|
|
||||||
if (targetGrid.collisionMasks) {
|
if (targetGrid.collisionMasks) {
|
||||||
const mask = targetGrid.collisionMasks[subIdx] ?? COLLIDE_NONE
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetGrid.blocked && targetGrid.blocked[subIdx] === 1) {
|
if (targetGrid.blocked && targetGrid.blocked[subIdx] === 1) {
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. TypedArray directly (e.g. new Uint8Array(400) or new Uint16Array(width * height))
|
// 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
|
if (cx < 0 || cy < 0) return true
|
||||||
const width = typeof (targetGrid as any).width === 'number'
|
const width = typeof (targetGrid as any).width === 'number'
|
||||||
? (targetGrid as any).width
|
? (targetGrid as any).width
|
||||||
|
|
@ -394,11 +483,11 @@ export function findSafeDropPosition(
|
||||||
if (height > 0 && cy >= height) return true
|
if (height > 0 && cy >= height) return true
|
||||||
const idx = cy * width + cx
|
const idx = cy * width + cx
|
||||||
const val = (targetGrid as any)[idx] ?? COLLIDE_NONE
|
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
|
// 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
|
if (cx < 0 || cy < 0) return true
|
||||||
const width = targetGrid.width ?? targetGrid.cellsX ?? 0
|
const width = targetGrid.width ?? targetGrid.cellsX ?? 0
|
||||||
const height = targetGrid.height ?? targetGrid.cellsY ?? 0
|
const height = targetGrid.height ?? targetGrid.cellsY ?? 0
|
||||||
|
|
@ -407,19 +496,20 @@ export function findSafeDropPosition(
|
||||||
|
|
||||||
const idx = cy * width + cx
|
const idx = cy * width + cx
|
||||||
const mask = (targetGrid.collisionMasks ? targetGrid.collisionMasks[idx] : targetGrid.cells[idx]) ?? COLLIDE_NONE
|
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
|
// 5. CountingTerrain / WorldMapProvider overlap callback
|
||||||
if (typeof targetGrid.overlap === 'function') {
|
const overlapTarget = typeof grid?.overlap === 'function' ? grid : targetGrid
|
||||||
if (targetGrid.widthPx !== undefined && targetGrid.heightPx !== undefined) {
|
if (typeof overlapTarget?.overlap === 'function') {
|
||||||
const originX = typeof targetGrid.originX === 'number' ? targetGrid.originX : 0
|
if (overlapTarget.widthPx !== undefined && overlapTarget.heightPx !== undefined) {
|
||||||
const originY = typeof targetGrid.originY === 'number' ? targetGrid.originY : 0
|
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 wx = originX + (cx - cy) * ORTHO_CELL_WIDTH
|
||||||
const wy = originY + (cx + cy) * ORTHO_CELL_HEIGHT
|
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
|
return false
|
||||||
|
|
@ -453,19 +543,137 @@ export function findSafeDropPosition(
|
||||||
return occupiedSet.has(`${cx},${cy}`)
|
return occupiedSet.has(`${cx},${cy}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let chosenCellX = originCellX
|
// Optional 4-connected reachability from reachableFromCell
|
||||||
let chosenCellY = originCellY
|
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 found = false
|
||||||
let firstWalkableOccupied: { cx: number; cy: number } | null = null
|
let firstWalkableOccupied: { cx: number; cy: number } | null = null
|
||||||
|
|
||||||
// Ring 0: check origin cell
|
// Ring 0: check base cell
|
||||||
if (!isBlocked(originCellX, originCellY)) {
|
if (baseWalkable && isConnectedToBase(baseCellX, baseCellY)) {
|
||||||
if (!isCellOccupied(originCellX, originCellY)) {
|
if (!isCellOccupied(baseCellX, baseCellY)) {
|
||||||
chosenCellX = originCellX
|
chosenCellX = baseCellX
|
||||||
chosenCellY = originCellY
|
chosenCellY = baseCellY
|
||||||
found = true
|
found = true
|
||||||
} else {
|
} else {
|
||||||
firstWalkableOccupied = { cx: originCellX, cy: originCellY }
|
firstWalkableOccupied = { cx: baseCellX, cy: baseCellY }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,10 +682,10 @@ export function findSafeDropPosition(
|
||||||
for (let r = 1; r <= maxRadius; r += 1) {
|
for (let r = 1; r <= maxRadius; r += 1) {
|
||||||
const ringCandidates = getIsometricRingCandidates(r)
|
const ringCandidates = getIsometricRingCandidates(r)
|
||||||
for (const cand of ringCandidates) {
|
for (const cand of ringCandidates) {
|
||||||
const cx = originCellX + cand.dx
|
const cx = baseCellX + cand.dx
|
||||||
const cy = originCellY + cand.dy
|
const cy = baseCellY + cand.dy
|
||||||
|
|
||||||
if (isBlocked(cx, cy)) {
|
if (isBlocked(cx, cy) || !isConnectedToBase(cx, cy)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,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:
|
// Authentic Fallback:
|
||||||
// If all candidate cells within maxRadius are occupied, stack on first walkable floor cell
|
// If all candidate cells within maxRadius are occupied, stack on first walkable floor cell
|
||||||
// rather than escaping into void or walls.
|
// rather than escaping into void or walls.
|
||||||
|
|
@ -529,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(
|
export function isDropPositionBlocked(
|
||||||
candX: number,
|
candX: number,
|
||||||
|
|
@ -541,52 +909,38 @@ export function isDropPositionBlocked(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isBlocked) return false
|
if (!isBlocked) return false
|
||||||
|
|
||||||
// 1. If it's a CollisionGrid or has a collision grid
|
if (isTerrainPointBlocked(candX, candY, isBlocked)) {
|
||||||
const grid: CollisionGrid | undefined =
|
return true
|
||||||
isBlocked.collisionMasks !== undefined || isBlocked.gridWidth !== undefined
|
}
|
||||||
? isBlocked
|
|
||||||
: isBlocked.grid?.collisionMasks !== undefined || isBlocked.grid?.gridWidth !== undefined
|
|
||||||
? isBlocked.grid
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
|
if (candX === originX && candY === originY) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = extractCollisionGrid(isBlocked)
|
||||||
if (grid !== undefined) {
|
if (grid !== undefined) {
|
||||||
const mask = getCollisionMaskAt(grid, candX, candY)
|
if (isSubTileRayBlocked(grid, originX, originY, 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) {
|
|
||||||
return true
|
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 (typeof isBlocked.hasLineOfSight === 'function') {
|
||||||
if (!isBlocked.hasLineOfSight(originX, originY, candX, candY)) {
|
if (!isBlocked.hasLineOfSight(originX, originY, candX, candY)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Function callback (e.g. (x, y) => boolean)
|
// Prevent diagonal corner-clipping on non-grid callbacks for 1-step diagonal sub-tile offsets
|
||||||
if (typeof isBlocked === 'function') {
|
const relX = candX - originX
|
||||||
if (isBlocked(candX, candY)) {
|
const relY = candY - originY
|
||||||
return true
|
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
|
||||||
// 5. General isBlocked method
|
const c1y = originY + dx * ORTHO_SUB_TILE_HEIGHT
|
||||||
if (typeof isBlocked.isBlocked === 'function') {
|
const c2x = originX - dy * ORTHO_SUB_TILE_WIDTH
|
||||||
if (isBlocked.isBlocked(candX, candY)) {
|
const c2y = originY + dy * ORTHO_SUB_TILE_HEIGHT
|
||||||
|
if (isTerrainPointBlocked(c1x, c1y, isBlocked) && isTerrainPointBlocked(c2x, c2y, isBlocked)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -635,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:
|
* Implements Blizzard Diablo II v1.13c 2:1 isometric diamond grid scatter algorithm:
|
||||||
* - Sub-tile steps: stepX = 16px, stepY = 8px (2:1 aspect ratio).
|
* - Sub-tile steps: stepX = 16px, stepY = 8px (2:1 aspect ratio).
|
||||||
* - Concentric square rings R = 0, 1, 2, ... up to maxRings.
|
* - Concentric square rings R = 0, 1, 2, ... up to maxRings.
|
||||||
* - L1 Manhattan distance minimization (|dx| + |dy|).
|
* - 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).
|
* - Prevents item stacking by enforcing minimum spacing (default 16px).
|
||||||
*
|
*
|
||||||
* @param originX - epicenter X in world coordinates.
|
* @param originX - epicenter X in world coordinates.
|
||||||
|
|
@ -652,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 stepY - isometric vertical lattice step (default ORTHO_SUB_TILE_HEIGHT = 8).
|
||||||
* @param maxRings - max search rings (default 10).
|
* @param maxRings - max search rings (default 10).
|
||||||
* @param minSpacing - minimum distance in pixels between ground items (default 16).
|
* @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(
|
export function findIsometricDropPosition(
|
||||||
originX: number,
|
originX: number,
|
||||||
|
|
@ -662,25 +1019,300 @@ export function findIsometricDropPosition(
|
||||||
stepY = ORTHO_SUB_TILE_HEIGHT,
|
stepY = ORTHO_SUB_TILE_HEIGHT,
|
||||||
maxRings = 10,
|
maxRings = 10,
|
||||||
minSpacing = 16,
|
minSpacing = 16,
|
||||||
|
reachableFrom?: { x: number; y: number },
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
const minSpacingSq = minSpacing * minSpacing
|
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
|
let firstWalkableCandidate: { x: number; y: number } | null = null
|
||||||
|
|
||||||
for (let ring = 0; ring <= maxRings; ring += 1) {
|
for (let ring = 0; ring <= maxRings; ring += 1) {
|
||||||
const ringCandidates = getIsometricRingCandidates(ring)
|
const ringCandidates = getIsometricRingCandidates(ring)
|
||||||
|
|
||||||
for (const cand of ringCandidates) {
|
for (const cand of ringCandidates) {
|
||||||
// 2:1 Isometric projection screen offset:
|
const cx = anchorX + (cand.dx - cand.dy) * stepX
|
||||||
// cx = originX + (dx - dy) * stepX
|
const cy = anchorY + (cand.dx + cand.dy) * stepY
|
||||||
// cy = originY + (dx + dy) * stepY
|
|
||||||
const cx = originX + (cand.dx - cand.dy) * stepX
|
|
||||||
const cy = originY + (cand.dx + cand.dy) * stepY
|
|
||||||
|
|
||||||
// Check collision and line of sight
|
// Check collision and line of sight from the walkable anchor
|
||||||
if (isDropPositionBlocked(cx, cy, originX, originY, isBlocked)) {
|
if (isDropPositionBlocked(cx, cy, anchorX, anchorY, isBlocked)) {
|
||||||
continue
|
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) {
|
if (firstWalkableCandidate === null) {
|
||||||
firstWalkableCandidate = { x: cx, y: cy }
|
firstWalkableCandidate = { x: cx, y: cy }
|
||||||
}
|
}
|
||||||
|
|
@ -703,7 +1335,7 @@ export function findIsometricDropPosition(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstWalkableCandidate ?? { x: originX, y: originY }
|
return firstWalkableCandidate ?? { x: anchorX, y: anchorY }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -510,7 +510,8 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
|
||||||
nRepCost = afterSkill.repCost
|
nRepCost = afterSkill.repCost
|
||||||
}
|
}
|
||||||
|
|
||||||
const isIdentified = item.flags?.identified !== false && !item.isGambleMystery
|
const isIdentified =
|
||||||
|
item.identified !== false && item.flags?.identified !== false && !item.isGambleMystery
|
||||||
|
|
||||||
if (isIdentified) {
|
if (isIdentified) {
|
||||||
let nSell = 0
|
let nSell = 0
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@ export function generateVendorStoreItem(options: {
|
||||||
ilvl: nItemLevel,
|
ilvl: nItemLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
identified: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense)
|
// Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense)
|
||||||
|
|
@ -779,6 +780,7 @@ export function resolveGambledItem(options: {
|
||||||
ilvl,
|
ilvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
identified: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -6853,10 +6853,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
|
|
||||||
const here = playerSubTile()
|
const here = playerSubTile()
|
||||||
const candidateOffsets = [
|
const candidateOffsets = [
|
||||||
|
{ dx: -2, dy: 2 },
|
||||||
|
{ dx: -2, dy: 1 },
|
||||||
|
{ dx: -1, dy: 2 },
|
||||||
|
{ dx: 0, dy: -2 },
|
||||||
{ dx: 2, dy: -1 },
|
{ dx: 2, dy: -1 },
|
||||||
{ dx: 2, dy: 0 },
|
{ dx: 2, dy: 0 },
|
||||||
{ dx: 0, dy: -2 },
|
|
||||||
{ dx: -2, dy: 1 },
|
|
||||||
{ dx: 1, dy: -1 },
|
{ dx: 1, dy: -1 },
|
||||||
{ dx: 0, dy: 0 },
|
{ dx: 0, dy: 0 },
|
||||||
]
|
]
|
||||||
|
|
@ -7218,12 +7220,34 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
await next.loadPages(renderer, next.priorityPages, () => {
|
await next.loadPages(renderer, next.priorityPages, () => {
|
||||||
state.pagesLoaded = countLoadedPages(next.pages)
|
state.pagesLoaded = countLoadedPages(next.pages)
|
||||||
})
|
})
|
||||||
|
let effectiveOverride = override
|
||||||
|
if (isTownLevel(next.levelId) && next.variant) {
|
||||||
|
townVariantByAct.set(next.act, next.variant)
|
||||||
|
const openPortal = portalSlot.current()
|
||||||
|
if (openPortal !== null && openPortal.townLevelId === next.levelId) {
|
||||||
|
const exactTownSub = resolveTownPortalSubTile(next.levelId, next.variant)
|
||||||
|
if (openPortal.townX !== exactTownSub.x || openPortal.townY !== exactTownSub.y) {
|
||||||
|
const wasHeadingToPortal =
|
||||||
|
effectiveOverride !== undefined &&
|
||||||
|
effectiveOverride.x === openPortal.townX &&
|
||||||
|
effectiveOverride.y === openPortal.townY
|
||||||
|
portalSlot.cast({
|
||||||
|
...openPortal,
|
||||||
|
townX: exactTownSub.x,
|
||||||
|
townY: exactTownSub.y,
|
||||||
|
})
|
||||||
|
if (wasHeadingToPortal) {
|
||||||
|
effectiveOverride = exactTownSub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// The destination's own opening back to where we came from is the landing
|
// The destination's own opening back to where we came from is the landing
|
||||||
// spot; an override wins, because a waypoint or a portal lands somewhere
|
// spot; an override wins, because a waypoint or a portal lands somewhere
|
||||||
// that no edge describes.
|
// that no edge describes.
|
||||||
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
||||||
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
||||||
const rawLanding = override
|
const rawLanding = effectiveOverride
|
||||||
?? (back === undefined
|
?? (back === undefined
|
||||||
? {
|
? {
|
||||||
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
||||||
|
|
|
||||||
|
|
@ -740,12 +740,33 @@ export function createNetSimulation(
|
||||||
})
|
})
|
||||||
|
|
||||||
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
|
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) {
|
for (const drop of droppedItems) {
|
||||||
const dropPos = findIsometricDropPosition(
|
const dropPos = findIsometricDropPosition(
|
||||||
event.x,
|
event.x,
|
||||||
event.y,
|
event.y,
|
||||||
burstOccupied,
|
burstOccupied,
|
||||||
mapTerrain,
|
mapTerrain,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
reachableFrom,
|
||||||
)
|
)
|
||||||
burstOccupied.push(dropPos)
|
burstOccupied.push(dropPos)
|
||||||
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@
|
||||||
* - Clickable labels to trigger pathfinding and item pickup.
|
* - Clickable labels to trigger pathfinding and item pickup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GroundItemEntity } from '../game/ground-items.ts'
|
import { isGroundSourceUnidentified, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts'
|
import {
|
||||||
|
resolveBaseItemNameEn,
|
||||||
|
translateItemName,
|
||||||
|
translateRareName,
|
||||||
|
translateMagicName,
|
||||||
|
} from '../game/tooltip-i18n.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
||||||
|
|
@ -112,6 +117,31 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string {
|
||||||
if (item.isGold) {
|
if (item.isGold) {
|
||||||
return `${item.amount} 金币`
|
return `${item.amount} 金币`
|
||||||
}
|
}
|
||||||
|
const src = item.item as any
|
||||||
|
if (isGroundSourceUnidentified(src)) {
|
||||||
|
const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base
|
||||||
|
const rawCode =
|
||||||
|
typeof src?.code === 'string'
|
||||||
|
? src.code.trim().toLowerCase()
|
||||||
|
: typeof baseObj?.id === 'string'
|
||||||
|
? baseObj.id.trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
const rawBaseName =
|
||||||
|
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
|
||||||
|
? baseObj.name
|
||||||
|
: item.name
|
||||||
|
if (rawBaseName) {
|
||||||
|
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
|
||||||
|
if (item.nameZh && item.name === baseNameEn) {
|
||||||
|
return item.nameZh
|
||||||
|
}
|
||||||
|
const translatedBase =
|
||||||
|
translateItemName(rawBaseName) !== rawBaseName
|
||||||
|
? translateItemName(rawBaseName)
|
||||||
|
: translateItemName(baseNameEn)
|
||||||
|
return translatedBase || baseNameEn
|
||||||
|
}
|
||||||
|
}
|
||||||
if (item.nameZh) {
|
if (item.nameZh) {
|
||||||
return item.nameZh
|
return item.nameZh
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -748,10 +748,17 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intercept =
|
||||||
|
this.isPointInterceptedByHud(this.mouseX, this.mouseY) || this.inventory.cursorItem !== null
|
||||||
|
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mousedown', () => {
|
window.addEventListener('mousedown', (e) => {
|
||||||
this.cursor.handleMouseDown()
|
this.cursor.handleMouseDown()
|
||||||
|
if (e.target !== this.hudCanvas && e.target instanceof HTMLCanvasElement) {
|
||||||
|
handleHudMouseDown(e)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
window.addEventListener('mouseup', () => {
|
||||||
|
|
@ -763,7 +770,7 @@ export class HudManager {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.hudCanvas.addEventListener('mousedown', (e) => {
|
const handleHudMouseDown = (e: MouseEvent): void => {
|
||||||
if (e.button !== 0 && e.button !== 2) return
|
if (e.button !== 0 && e.button !== 2) return
|
||||||
const pt = this.clientToLogical(e.clientX, e.clientY)
|
const pt = this.clientToLogical(e.clientX, e.clientY)
|
||||||
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
|
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
|
||||||
|
|
@ -1008,7 +1015,9 @@ export class HudManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
this.hudCanvas.addEventListener('mousedown', handleHudMouseDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSkillAllocated(skillId: number): void {
|
private handleSkillAllocated(skillId: number): void {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export interface UiInventoryItem {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly code: string
|
readonly code: string
|
||||||
readonly invFile: string
|
readonly invFile: string
|
||||||
|
readonly baseInvFile?: string | undefined
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly nameZh: string
|
readonly nameZh: string
|
||||||
readonly baseNameZh: string
|
readonly baseNameZh: string
|
||||||
|
|
@ -146,6 +147,7 @@ export interface ResolvableItem {
|
||||||
readonly nameZh?: string | undefined
|
readonly nameZh?: string | undefined
|
||||||
readonly baseNameZh?: string | undefined
|
readonly baseNameZh?: string | undefined
|
||||||
readonly invFile?: string | undefined
|
readonly invFile?: string | undefined
|
||||||
|
readonly baseInvFile?: string | undefined
|
||||||
readonly flippyFile?: string | undefined
|
readonly flippyFile?: string | undefined
|
||||||
readonly isGold?: boolean | undefined
|
readonly isGold?: boolean | undefined
|
||||||
readonly amount?: number | undefined
|
readonly amount?: number | undefined
|
||||||
|
|
@ -158,6 +160,9 @@ export interface ResolvableItem {
|
||||||
readonly invHeight?: number | undefined
|
readonly invHeight?: number | undefined
|
||||||
readonly width?: number | undefined
|
readonly width?: number | undefined
|
||||||
readonly height?: number | undefined
|
readonly height?: number | undefined
|
||||||
|
readonly identified?: boolean | undefined
|
||||||
|
readonly isGambleMystery?: boolean | undefined
|
||||||
|
readonly rawItem?: Item | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -179,7 +184,28 @@ export function resolveItemSpriteRect(
|
||||||
itemRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.itemRects,
|
itemRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.itemRects,
|
||||||
): SpriteRect | null {
|
): SpriteRect | null {
|
||||||
const normalizedCode = normalizeItemCode(rawItem)
|
const normalizedCode = normalizeItemCode(rawItem)
|
||||||
const item: ResolvableItem = normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode }
|
const normalizedItem: ResolvableItem =
|
||||||
|
normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode }
|
||||||
|
const isUnid = Boolean(
|
||||||
|
normalizedItem.identified === false ||
|
||||||
|
normalizedItem.isGambleMystery === true ||
|
||||||
|
normalizedItem.rawItem?.identified === false ||
|
||||||
|
normalizedItem.rawItem?.isGambleMystery === true ||
|
||||||
|
(normalizedItem.rawItem?.flags !== undefined && normalizedItem.rawItem.flags.identified === false),
|
||||||
|
)
|
||||||
|
// Per D2Client.dll, unidentified items render their base item graphic without unique/set invfile or dye transform
|
||||||
|
const item: ResolvableItem = isUnid
|
||||||
|
? {
|
||||||
|
...normalizedItem,
|
||||||
|
invFile:
|
||||||
|
normalizedItem.baseInvFile ??
|
||||||
|
(normalizedItem.code && BAKED_UI_MANIFEST.codeToInvFile[normalizedItem.code]
|
||||||
|
? BAKED_UI_MANIFEST.codeToInvFile[normalizedItem.code]
|
||||||
|
: normalizedItem.invFile),
|
||||||
|
invtransform: undefined,
|
||||||
|
name: normalizedItem.rawItem?.base?.name ?? undefined,
|
||||||
|
}
|
||||||
|
: normalizedItem
|
||||||
// Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}`
|
// Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}`
|
||||||
const targetInv =
|
const targetInv =
|
||||||
item.invFile ||
|
item.invFile ||
|
||||||
|
|
@ -1056,6 +1082,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
||||||
}
|
}
|
||||||
const rebuilt = itemToUiInventoryItem(item.rawItem)
|
const rebuilt = itemToUiInventoryItem(item.rawItem)
|
||||||
Object.assign(item, {
|
Object.assign(item, {
|
||||||
|
invFile: rebuilt.invFile,
|
||||||
|
...(rebuilt.baseInvFile !== undefined ? { baseInvFile: rebuilt.baseInvFile } : {}),
|
||||||
name: rebuilt.name,
|
name: rebuilt.name,
|
||||||
nameZh: rebuilt.nameZh,
|
nameZh: rebuilt.nameZh,
|
||||||
baseNameZh: rebuilt.baseNameZh,
|
baseNameZh: rebuilt.baseNameZh,
|
||||||
|
|
@ -1645,8 +1673,9 @@ export class InventoryPanel {
|
||||||
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
||||||
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
||||||
|
|
||||||
ctx.fillStyle =
|
ctx.fillStyle = isUiItemUnidentified(placed.item)
|
||||||
placed.item.quality === 'unique'
|
? 'rgba(72, 18, 18, 0.50)'
|
||||||
|
: placed.item.quality === 'unique'
|
||||||
? 'rgba(58, 42, 16, 0.45)'
|
? 'rgba(58, 42, 16, 0.45)'
|
||||||
: placed.item.quality === 'set'
|
: placed.item.quality === 'set'
|
||||||
? 'rgba(16, 52, 22, 0.45)'
|
? 'rgba(16, 52, 22, 0.45)'
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,12 @@ export function itemToUiInventoryItem(
|
||||||
|
|
||||||
// 3. invFile & Dye Resolution
|
// 3. invFile & Dye Resolution
|
||||||
const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables)
|
const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables)
|
||||||
|
const { invFile: baseInvFile } = resolveItemInvFile(
|
||||||
|
{ ...rawItem, name: baseEn, uniqueItemDef: undefined, setItemDef: undefined } as Item,
|
||||||
|
'normal',
|
||||||
|
code,
|
||||||
|
dropTables,
|
||||||
|
)
|
||||||
|
|
||||||
// 4. Allowed Equipment Slots
|
// 4. Allowed Equipment Slots
|
||||||
const allowedSlots = resolveAllowedSlots(
|
const allowedSlots = resolveAllowedSlots(
|
||||||
|
|
@ -494,6 +500,7 @@ export function itemToUiInventoryItem(
|
||||||
id,
|
id,
|
||||||
code,
|
code,
|
||||||
invFile,
|
invFile,
|
||||||
|
baseInvFile,
|
||||||
name: rawItem.name || baseEn,
|
name: rawItem.name || baseEn,
|
||||||
nameZh,
|
nameZh,
|
||||||
baseNameZh,
|
baseNameZh,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,22 @@ export interface SkillNodeDef {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
|
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
|
||||||
export const SKILL_CLOSE_BTN_BOUNDS = { x: 400 + 272, y: 60 + 388, w: 32, h: 32 } as const
|
|
||||||
|
/**
|
||||||
|
* Authentic D2 v1.13c (`skltree_*_back.DC6` frames 6, 10, 14) 32x32 Close Button socket X offsets
|
||||||
|
* per class and tab `[tab0, tab1, tab2]` relative to `SKILL_PANEL_ORIGIN.x` (Y offset is always +385).
|
||||||
|
*/
|
||||||
|
export const SKILL_CLOSE_BTN_OFFSET_X: Record<CharacterClassCode, readonly [number, number, number]> = {
|
||||||
|
ama: [171, 101, 16],
|
||||||
|
sor: [16, 16, 171],
|
||||||
|
nec: [16, 171, 16],
|
||||||
|
pal: [16, 101, 16],
|
||||||
|
bar: [171, 16, 171],
|
||||||
|
dru: [171, 171, 171],
|
||||||
|
ass: [101, 171, 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SKILL_CLOSE_BTN_BOUNDS = { x: 400 + 171, y: 60 + 385, w: 32, h: 32 } as const
|
||||||
|
|
||||||
/** Authentic D2Client.dll 1.13c ground truth grid offsets inside panel (ox=400, oy=60). */
|
/** Authentic D2Client.dll 1.13c ground truth grid offsets inside panel (ox=400, oy=60). */
|
||||||
export const SKILL_TREE_COL_OFFSETS_X = [15, 84, 153] as const
|
export const SKILL_TREE_COL_OFFSETS_X = [15, 84, 153] as const
|
||||||
|
|
@ -287,6 +302,17 @@ export class SkillTreePanel {
|
||||||
return { x, y, w: 48, h: 48 }
|
return { x, y, w: 48, h: 48 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCloseButtonBounds(): { x: number; y: number; w: number; h: number } {
|
||||||
|
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass] ?? SKILL_CLOSE_BTN_OFFSET_X.sor
|
||||||
|
const offsetX = offsets[this.activeTab] ?? 171
|
||||||
|
return {
|
||||||
|
x: SKILL_PANEL_ORIGIN.x + offsetX,
|
||||||
|
y: SKILL_PANEL_ORIGIN.y + 385,
|
||||||
|
w: 32,
|
||||||
|
h: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
this.hoveredSkill = null
|
this.hoveredSkill = null
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
|
|
@ -304,19 +330,20 @@ export class SkillTreePanel {
|
||||||
if (!this.visible) return false
|
if (!this.visible) return false
|
||||||
if (logicalX < 400) return false
|
if (logicalX < 400) return false
|
||||||
|
|
||||||
// Close button
|
// Close button (authentic per-class/per-tab 32x32 stone socket at y = oy + 385)
|
||||||
|
const closeBounds = this.getCloseButtonBounds()
|
||||||
if (
|
if (
|
||||||
logicalX >= SKILL_CLOSE_BTN_BOUNDS.x &&
|
logicalX >= closeBounds.x &&
|
||||||
logicalX <= SKILL_CLOSE_BTN_BOUNDS.x + SKILL_CLOSE_BTN_BOUNDS.w &&
|
logicalX <= closeBounds.x + closeBounds.w &&
|
||||||
logicalY >= SKILL_CLOSE_BTN_BOUNDS.y &&
|
logicalY >= closeBounds.y &&
|
||||||
logicalY <= SKILL_CLOSE_BTN_BOUNDS.y + SKILL_CLOSE_BTN_BOUNDS.h
|
logicalY <= closeBounds.y + closeBounds.h
|
||||||
) {
|
) {
|
||||||
this.visible = false
|
this.visible = false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right vertical tabs (`x = 656..720`)
|
// Right vertical tabs (`x = 630..720`, full 90px tab column `ox + 230..320`)
|
||||||
if (logicalX >= 656 && logicalX <= 720) {
|
if (logicalX >= SKILL_PANEL_ORIGIN.x + 230 && logicalX <= SKILL_PANEL_ORIGIN.x + 320) {
|
||||||
const vTabs = this.getVerticalTabs()
|
const vTabs = this.getVerticalTabs()
|
||||||
if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) {
|
if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) {
|
||||||
this.activeTab = vTabs[0].tab
|
this.activeTab = vTabs[0].tab
|
||||||
|
|
@ -326,7 +353,7 @@ export class SkillTreePanel {
|
||||||
this.activeTab = vTabs[1].tab
|
this.activeTab = vTabs[1].tab
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (logicalY >= 60 + 295 && logicalY < 60 + 385 && vTabs[2]) {
|
if (logicalY >= 60 + 295 && logicalY <= 60 + 432 && vTabs[2]) {
|
||||||
this.activeTab = vTabs[2].tab
|
this.activeTab = vTabs[2].tab
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +396,8 @@ export class SkillTreePanel {
|
||||||
|
|
||||||
// 2. Draw 256x432 Skill Tree Tab Background (`bg_sor_<tab>.png`)
|
// 2. Draw 256x432 Skill Tree Tab Background (`bg_sor_<tab>.png`)
|
||||||
const bg = this.tabBgs.get(this.activeTab)
|
const bg = this.tabBgs.get(this.activeTab)
|
||||||
if (bg && bg.complete && bg.naturalWidth > 0) {
|
const hasBg = Boolean(bg && bg.complete && bg.naturalWidth > 0)
|
||||||
|
if (hasBg && bg) {
|
||||||
ctx.drawImage(bg, ox, oy, 256, 432)
|
ctx.drawImage(bg, ox, oy, 256, 432)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#181410'
|
ctx.fillStyle = '#181410'
|
||||||
|
|
@ -381,7 +409,10 @@ export class SkillTreePanel {
|
||||||
const tabColIdx = verticalTabs.findIndex(vt => vt.tab === this.activeTab)
|
const tabColIdx = verticalTabs.findIndex(vt => vt.tab === this.activeTab)
|
||||||
const safeColIdx = tabColIdx >= 0 ? tabColIdx : 0
|
const safeColIdx = tabColIdx >= 0 ? tabColIdx : 0
|
||||||
|
|
||||||
if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) {
|
const hasTabsImg = Boolean(
|
||||||
|
assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0,
|
||||||
|
)
|
||||||
|
if (hasTabsImg && assets.skillTabsImg) {
|
||||||
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
|
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#14110d'
|
ctx.fillStyle = '#14110d'
|
||||||
|
|
@ -390,38 +421,28 @@ export class SkillTreePanel {
|
||||||
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
|
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remaining Skill Points Header Box
|
// Remaining Skill Points Header Plaque (built-in DC6 recessed stone box at x=255..297, y=61..83)
|
||||||
font.drawText(ctx, '剩余技能点', ox + 288, oy + 26, {
|
font.drawText(ctx, '剩余技能点', ox + 276, oy + 38, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
ctx.fillStyle = '#080605'
|
if (!hasTabsImg) {
|
||||||
ctx.fillRect(ox + 270, oy + 34, 36, 24)
|
ctx.fillStyle = '#080605'
|
||||||
ctx.strokeStyle = '#8c7244'
|
ctx.fillRect(ox + 255, oy + 61, 43, 23)
|
||||||
ctx.strokeRect(ox + 270.5, oy + 34.5, 35, 23)
|
ctx.strokeStyle = '#8c7244'
|
||||||
font.drawText(ctx, String(this.unspentSkillPoints), ox + 288, oy + 52, {
|
ctx.strokeRect(ox + 255.5, oy + 61.5, 42, 22)
|
||||||
|
}
|
||||||
|
font.drawText(ctx, String(this.unspentSkillPoints), ox + 276, oy + 78, {
|
||||||
font: 'font16',
|
font: 'font16',
|
||||||
color: this.unspentSkillPoints > 0 ? 'red' : 'white',
|
color: this.unspentSkillPoints > 0 ? 'red' : 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remaining Skill Points Banner (Authentic D2 skillpoints.dc6 banner at bottom of tree)
|
// 3 Vertical Tabs for Active Class (90px column x=230..319, center ox + 275)
|
||||||
if (this.unspentSkillPoints > 0 && assets.skillPointsImg && assets.skillPointsImg.complete && assets.skillPointsImg.naturalWidth > 0) {
|
|
||||||
const spX = ox + Math.round((256 - 135) / 2)
|
|
||||||
const spY = oy + 395
|
|
||||||
ctx.drawImage(assets.skillPointsImg, spX, spY, 135, 23)
|
|
||||||
font.drawText(ctx, String(this.unspentSkillPoints), spX + 112, spY + 16, {
|
|
||||||
font: 'font16',
|
|
||||||
color: 'red',
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3 Vertical Tabs for Active Class
|
|
||||||
for (const vt of verticalTabs) {
|
for (const vt of verticalTabs) {
|
||||||
const active = this.activeTab === vt.tab
|
const active = this.activeTab === vt.tab
|
||||||
if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) {
|
if (!hasTabsImg) {
|
||||||
// Authentic stone-beveled fallback when texture atlas is unavailable
|
// Authentic stone-beveled fallback when texture atlas is unavailable
|
||||||
ctx.fillStyle = active ? '#382e22' : '#1b1610'
|
ctx.fillStyle = active ? '#382e22' : '#1b1610'
|
||||||
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
|
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
|
||||||
|
|
@ -429,12 +450,12 @@ export class SkillTreePanel {
|
||||||
ctx.lineWidth = active ? 2 : 1
|
ctx.lineWidth = active ? 2 : 1
|
||||||
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
|
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
|
||||||
}
|
}
|
||||||
font.drawText(ctx, vt.titleZh, ox + 288, oy + vt.y + 42, {
|
font.drawText(ctx, vt.titleZh, ox + 275, oy + vt.y + 52, {
|
||||||
font: 'fontexocet10',
|
font: 'fontexocet10',
|
||||||
color: active ? 'gold' : 'tan',
|
color: active ? 'gold' : 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, vt.subZh, ox + 288, oy + vt.y + 62, {
|
font.drawText(ctx, vt.subZh, ox + 275, oy + vt.y + 72, {
|
||||||
font: 'fontexocet10',
|
font: 'fontexocet10',
|
||||||
color: active ? 'gold' : 'tan',
|
color: active ? 'gold' : 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -465,28 +486,31 @@ export class SkillTreePanel {
|
||||||
ctx.strokeRect(pos.x + 0.5, pos.y + 0.5, 47, 47)
|
ctx.strokeRect(pos.x + 0.5, pos.y + 0.5, 47, 47)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level Badge in bottom-right corner of the 48x48 skill icon
|
// Level socket: built-in DC6 stone socket has dark interior at pos.x + 42..60, pos.y + 50..60
|
||||||
ctx.fillStyle = '#090705'
|
if (!hasBg) {
|
||||||
ctx.fillRect(pos.x + 34, pos.y + 34, 20, 16)
|
ctx.fillStyle = '#090705'
|
||||||
ctx.strokeStyle = '#6c5838'
|
ctx.fillRect(pos.x + 42, pos.y + 50, 19, 11)
|
||||||
ctx.strokeRect(pos.x + 34.5, pos.y + 34.5, 19, 15)
|
ctx.strokeStyle = '#6c5838'
|
||||||
font.drawText(ctx, String(eff), pos.x + 44, pos.y + 46, {
|
ctx.strokeRect(pos.x + 41.5, pos.y + 49.5, 20, 12)
|
||||||
|
}
|
||||||
|
font.drawText(ctx, String(eff), pos.x + 51, pos.y + 59, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: eff > hard ? 'blue' : hard > 0 ? 'white' : 'gray',
|
color: eff > hard ? 'blue' : hard > 0 ? 'white' : 'gray',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Close Button (`buysellbtn.dc6` frame 10)
|
// 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket)
|
||||||
if (assets.buySellBtnImg) {
|
if (assets.buySellBtnImg) {
|
||||||
|
const closeBounds = this.getCloseButtonBounds()
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
assets.buySellBtnImg,
|
assets.buySellBtnImg,
|
||||||
10 * 32,
|
10 * 32,
|
||||||
0,
|
0,
|
||||||
32,
|
32,
|
||||||
32,
|
32,
|
||||||
SKILL_CLOSE_BTN_BOUNDS.x,
|
closeBounds.x,
|
||||||
SKILL_CLOSE_BTN_BOUNDS.y,
|
closeBounds.y,
|
||||||
32,
|
32,
|
||||||
32,
|
32,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,45 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 }
|
||||||
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
|
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
|
||||||
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
|
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Panel\TradeStash.dc6` (`public/ui/stash-bg.png`, 320x432 anchored at `ox = 80, oy = 60`)
|
||||||
|
* header recessed dark boxes (Issue #493):
|
||||||
|
* - Top box (`relY = 24..38`, height 15px) is divided by a vertical bar at `relX = 94..96` into:
|
||||||
|
* - Left icon socket: `relX = 74..93, relY = 24..38` (20x15px)
|
||||||
|
* - Right title text recess: `relX = 97..247, relY = 24..38` (151x15px, center `relX = 172, relY = 31`, alphabetic baseline `oy + 35`)
|
||||||
|
* - Bottom gold text recess: `relX = 75..246, relY = 49..65` (172x17px, center `relX = 160, relY = 57`, alphabetic baseline `oy + 61`)
|
||||||
|
*/
|
||||||
|
export const STASH_HEADER_ICON_SOCKET = {
|
||||||
|
x: 80 + 74,
|
||||||
|
y: 60 + 24,
|
||||||
|
relX: 74,
|
||||||
|
relY: 24,
|
||||||
|
w: 20,
|
||||||
|
h: 15,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const STASH_HEADER_TOP_RECESS = {
|
||||||
|
x: 80 + 97,
|
||||||
|
y: 60 + 24,
|
||||||
|
relX: 97,
|
||||||
|
relY: 24,
|
||||||
|
w: 151,
|
||||||
|
h: 15,
|
||||||
|
textX: 80 + 172,
|
||||||
|
textY: 60 + 35,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const STASH_HEADER_BOTTOM_RECESS = {
|
||||||
|
x: 80 + 75,
|
||||||
|
y: 60 + 49,
|
||||||
|
relX: 75,
|
||||||
|
relY: 49,
|
||||||
|
w: 172,
|
||||||
|
h: 17,
|
||||||
|
textX: 80 + 160,
|
||||||
|
textY: 60 + 61,
|
||||||
|
} as const
|
||||||
|
|
||||||
/** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */
|
/** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */
|
||||||
export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
||||||
export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const
|
export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const
|
||||||
|
|
@ -2126,12 +2165,12 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
} else if (kind === 'stash') {
|
} else if (kind === 'stash') {
|
||||||
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
|
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
|
||||||
font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 160, oy + 26, {
|
font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 172, oy + 35, {
|
||||||
font: 'fontexocet10',
|
font: 'font8',
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 48, {
|
font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 61, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -2612,12 +2651,19 @@ export class WorldPanelsHud {
|
||||||
: 'white'
|
: 'white'
|
||||||
|
|
||||||
// Header: Title, Runeword Runes line, Base item name
|
// Header: Title, Runeword Runes line, Base item name
|
||||||
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
|
const unid = isUiItemUnidentified(item)
|
||||||
if (item.runewordRunes) {
|
if (unid) {
|
||||||
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
|
const maskedBaseHeader =
|
||||||
}
|
(item.baseNameZh ? item.baseNameZh.split(' · ')[0] : undefined) || item.nameZh
|
||||||
if (item.baseNameZh) {
|
lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' })
|
||||||
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
|
} else {
|
||||||
|
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
|
||||||
|
if (item.runewordRunes) {
|
||||||
|
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
|
||||||
|
}
|
||||||
|
if (item.baseNameZh) {
|
||||||
|
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0)
|
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0)
|
||||||
|
|
@ -2659,33 +2705,52 @@ export class WorldPanelsHud {
|
||||||
if (item.reqStr !== undefined) {
|
if (item.reqStr !== undefined) {
|
||||||
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
|
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
|
||||||
}
|
}
|
||||||
if (item.reqLevel !== undefined) {
|
const displayReqLevel = unid
|
||||||
lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' })
|
? item.rawItem?.base?.level && item.rawItem.base.level > 1
|
||||||
|
? item.rawItem.base.level
|
||||||
|
: undefined
|
||||||
|
: item.reqLevel
|
||||||
|
if (displayReqLevel !== undefined) {
|
||||||
|
lines.push({ text: '需要等级: ' + displayReqLevel, color: 'white', font: 'font8' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Magic Affixes
|
if (unid) {
|
||||||
for (const st of item.stats) {
|
if (item.isGambleMystery) {
|
||||||
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
|
lines.push({ text: '未鉴定 (Unidentified Gamble Item)', color: 'red', font: 'font8' })
|
||||||
}
|
} else {
|
||||||
|
lines.push({ text: '未辨识 (Unidentified)', color: 'red', font: 'font8' })
|
||||||
// Sockets Block
|
if (item.allowedSlots.length > 0) {
|
||||||
if (
|
lines.push({ text: '无法使用 (Cannot be Equipped)', color: 'red', font: 'font8' })
|
||||||
item.sockets !== undefined &&
|
}
|
||||||
item.sockets > 0 &&
|
if (item.ethereal) {
|
||||||
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
|
lines.push({ text: '无形的 (无法修复) (Ethereal)', color: 'blue', font: 'font8' })
|
||||||
) {
|
}
|
||||||
lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Pieces & Set Bonuses Block
|
|
||||||
if (item.setPieces) {
|
|
||||||
for (const piece of item.setPieces) {
|
|
||||||
lines.push({ text: piece, color: 'gray', font: 'font8' })
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
if (item.setBonuses) {
|
// Magic Affixes
|
||||||
for (const bonus of item.setBonuses) {
|
for (const st of item.stats) {
|
||||||
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
|
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sockets Block
|
||||||
|
if (
|
||||||
|
item.sockets !== undefined &&
|
||||||
|
item.sockets > 0 &&
|
||||||
|
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
|
||||||
|
) {
|
||||||
|
lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Pieces & Set Bonuses Block
|
||||||
|
if (item.setPieces) {
|
||||||
|
for (const piece of item.setPieces) {
|
||||||
|
lines.push({ text: piece, color: 'gray', font: 'font8' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.setBonuses) {
|
||||||
|
for (const bonus of item.setBonuses) {
|
||||||
|
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -35,6 +35,8 @@ import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts'
|
||||||
import {
|
import {
|
||||||
InventoryPanel,
|
InventoryPanel,
|
||||||
resolveItemSpriteRect,
|
resolveItemSpriteRect,
|
||||||
|
identifyUiItem,
|
||||||
|
isUiItemUnidentified,
|
||||||
PLAYER_GOLD_CAP,
|
PLAYER_GOLD_CAP,
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
type EquipSlotId,
|
type EquipSlotId,
|
||||||
|
|
@ -575,6 +577,14 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S
|
||||||
expect(invPanel.equipped[invalidSlot]).toBeUndefined()
|
expect(invPanel.equipped[invalidSlot]).toBeUndefined()
|
||||||
expect(invPanel.cursorItem).toBe(placed!.item)
|
expect(invPanel.cursorItem).toBe(placed!.item)
|
||||||
|
|
||||||
|
// Unidentified magic/rare/set/unique drops cannot be equipped until identified (D2 1.13c)
|
||||||
|
if (isUiItemUnidentified(placed!.item)) {
|
||||||
|
const unidEquip = invPanel.clickEquipSlot(equippableSlot!)
|
||||||
|
expect(unidEquip).toBe(false)
|
||||||
|
expect(invPanel.equipped[equippableSlot!]).toBeUndefined()
|
||||||
|
identifyUiItem(placed!.item)
|
||||||
|
}
|
||||||
|
|
||||||
// Equip into valid matching slot
|
// Equip into valid matching slot
|
||||||
const validEquip = invPanel.clickEquipSlot(equippableSlot!)
|
const validEquip = invPanel.clickEquipSlot(equippableSlot!)
|
||||||
expect(validEquip).toBe(true)
|
expect(validEquip).toBe(true)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
|
import {
|
||||||
|
SkillTreePanel,
|
||||||
|
SKILL_PANEL_ORIGIN,
|
||||||
|
SKILL_CLOSE_BTN_BOUNDS,
|
||||||
|
SKILL_CLOSE_BTN_OFFSET_X,
|
||||||
|
} from '../src/ui/skill-tree-panel.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||||
import type { D2FontRenderer } from '../src/ui/font.ts'
|
import type { D2FontRenderer } from '../src/ui/font.ts'
|
||||||
|
|
||||||
describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner', () => {
|
describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
|
||||||
it('manifest contains skillTabs and skillPoints image references', () => {
|
it('manifest contains skillTabs and skillPoints image references', () => {
|
||||||
expect(BAKED_UI_MANIFEST.images.skillTabs).toBe('/ui/skill-tree-tabs.png')
|
expect(BAKED_UI_MANIFEST.images.skillTabs).toBe('/ui/skill-tree-tabs.png')
|
||||||
expect(BAKED_UI_MANIFEST.images.skillPoints).toBe('/ui/skillpoints.png')
|
expect(BAKED_UI_MANIFEST.images.skillPoints).toBe('/ui/skillpoints.png')
|
||||||
|
|
@ -27,15 +32,6 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner'
|
||||||
expect(height).toBe(432)
|
expect(height).toBe(432)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skillpoints.png has authentic 135x23 dimensions', () => {
|
|
||||||
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
|
|
||||||
const buf = readFileSync(spPath)
|
|
||||||
const width = buf.readUInt32BE(16)
|
|
||||||
const height = buf.readUInt32BE(20)
|
|
||||||
expect(width).toBe(135)
|
|
||||||
expect(height).toBe(23)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('draws authentic 64x432 tab column slice according to activeTab', () => {
|
it('draws authentic 64x432 tab column slice according to activeTab', () => {
|
||||||
const panel = new SkillTreePanel()
|
const panel = new SkillTreePanel()
|
||||||
panel.visible = true
|
panel.visible = true
|
||||||
|
|
@ -135,10 +131,10 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('draws authentic skillpoints.png banner when unspentSkillPoints > 0', () => {
|
it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => {
|
||||||
const panel = new SkillTreePanel()
|
const panel = new SkillTreePanel()
|
||||||
panel.visible = true
|
panel.visible = true
|
||||||
panel.unspentSkillPoints = 5
|
panel.unspentSkillPoints = 12
|
||||||
|
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
save: vi.fn(),
|
save: vi.fn(),
|
||||||
|
|
@ -157,6 +153,12 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner'
|
||||||
measureText: vi.fn(() => 50),
|
measureText: vi.fn(() => 50),
|
||||||
} as unknown as D2FontRenderer
|
} as unknown as D2FontRenderer
|
||||||
|
|
||||||
|
const mockTabsImg = {
|
||||||
|
complete: true,
|
||||||
|
naturalWidth: 192,
|
||||||
|
naturalHeight: 432,
|
||||||
|
} as unknown as HTMLImageElement
|
||||||
|
|
||||||
const mockSpImg = {
|
const mockSpImg = {
|
||||||
complete: true,
|
complete: true,
|
||||||
naturalWidth: 135,
|
naturalWidth: 135,
|
||||||
|
|
@ -167,26 +169,26 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner'
|
||||||
borderRightImg: null,
|
borderRightImg: null,
|
||||||
skillPointsImg: mockSpImg,
|
skillPointsImg: mockSpImg,
|
||||||
buySellBtnImg: null,
|
buySellBtnImg: null,
|
||||||
|
skillTabsImg: mockTabsImg,
|
||||||
}, mockFont)
|
}, mockFont)
|
||||||
|
|
||||||
// Verify skillpoints banner drawn at (ox + 60, oy + 395)
|
// Top-right remaining skill points label and number aligned to recessed stone box center (ox + 276, oy + 78)
|
||||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
expect(mockFont.drawText).toHaveBeenCalledWith(
|
||||||
mockSpImg,
|
mockCtx,
|
||||||
SKILL_PANEL_ORIGIN.x + Math.round((256 - 135) / 2),
|
'剩余技能点',
|
||||||
SKILL_PANEL_ORIGIN.y + 395,
|
SKILL_PANEL_ORIGIN.x + 276,
|
||||||
135,
|
SKILL_PANEL_ORIGIN.y + 38,
|
||||||
23,
|
expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }),
|
||||||
|
)
|
||||||
|
expect(mockFont.drawText).toHaveBeenCalledWith(
|
||||||
|
mockCtx,
|
||||||
|
'12',
|
||||||
|
SKILL_PANEL_ORIGIN.x + 276,
|
||||||
|
SKILL_PANEL_ORIGIN.y + 78,
|
||||||
|
expect.objectContaining({ font: 'font16', color: 'red', align: 'center' }),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verify when unspentSkillPoints = 0, banner is NOT drawn
|
// Redundant bottom skillpoints.png banner is NOT drawn
|
||||||
vi.clearAllMocks()
|
|
||||||
panel.unspentSkillPoints = 0
|
|
||||||
panel.draw(mockCtx, {
|
|
||||||
borderRightImg: null,
|
|
||||||
skillPointsImg: mockSpImg,
|
|
||||||
buySellBtnImg: null,
|
|
||||||
}, mockFont)
|
|
||||||
|
|
||||||
expect(mockCtx.drawImage).not.toHaveBeenCalledWith(
|
expect(mockCtx.drawImage).not.toHaveBeenCalledWith(
|
||||||
mockSpImg,
|
mockSpImg,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
|
@ -195,4 +197,81 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner'
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => {
|
||||||
|
const panel = new SkillTreePanel()
|
||||||
|
panel.visible = true
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
save: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
drawImage: vi.fn(),
|
||||||
|
fillRect: vi.fn(),
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
fillStyle: '',
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 1,
|
||||||
|
globalAlpha: 1,
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
|
const mockFont = {
|
||||||
|
drawText: vi.fn(),
|
||||||
|
measureText: vi.fn(() => 50),
|
||||||
|
} as unknown as D2FontRenderer
|
||||||
|
|
||||||
|
const mockBuySellImg = {
|
||||||
|
complete: true,
|
||||||
|
naturalWidth: 512,
|
||||||
|
naturalHeight: 32,
|
||||||
|
} as unknown as HTMLImageElement
|
||||||
|
|
||||||
|
// Default Sorceress Cold tab (tab 2): socket at ox + 171, oy + 385
|
||||||
|
expect(SKILL_CLOSE_BTN_BOUNDS).toEqual({
|
||||||
|
x: SKILL_PANEL_ORIGIN.x + 171,
|
||||||
|
y: SKILL_PANEL_ORIGIN.y + 385,
|
||||||
|
w: 32,
|
||||||
|
h: 32,
|
||||||
|
})
|
||||||
|
expect(panel.getCloseButtonBounds()).toEqual(SKILL_CLOSE_BTN_BOUNDS)
|
||||||
|
|
||||||
|
panel.draw(mockCtx, {
|
||||||
|
borderRightImg: null,
|
||||||
|
skillPointsImg: null,
|
||||||
|
buySellBtnImg: mockBuySellImg,
|
||||||
|
}, mockFont)
|
||||||
|
|
||||||
|
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
||||||
|
mockBuySellImg,
|
||||||
|
10 * 32,
|
||||||
|
0,
|
||||||
|
32,
|
||||||
|
32,
|
||||||
|
SKILL_PANEL_ORIGIN.x + 171,
|
||||||
|
SKILL_PANEL_ORIGIN.y + 385,
|
||||||
|
32,
|
||||||
|
32,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch to Sorceress Fire tab (tab 0): socket at ox + 16, oy + 385
|
||||||
|
panel.activeTab = 0
|
||||||
|
expect(panel.getCloseButtonBounds()).toEqual({
|
||||||
|
x: SKILL_PANEL_ORIGIN.x + 16,
|
||||||
|
y: SKILL_PANEL_ORIGIN.y + 385,
|
||||||
|
w: 32,
|
||||||
|
h: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clicking inside the active tab's close button socket closes the panel
|
||||||
|
expect(panel.handleClick(SKILL_PANEL_ORIGIN.x + 16 + 16, SKILL_PANEL_ORIGIN.y + 385 + 16)).toBe(true)
|
||||||
|
expect(panel.visible).toBe(false)
|
||||||
|
|
||||||
|
// Verify all 7 classes x 3 tabs have valid authentic socket X offsets (16, 101, or 171)
|
||||||
|
for (const offsets of Object.values(SKILL_CLOSE_BTN_OFFSET_X)) {
|
||||||
|
expect(offsets).toHaveLength(3)
|
||||||
|
for (const ox of offsets) {
|
||||||
|
expect([16, 101, 171]).toContain(ox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,12 @@ import {
|
||||||
PLAYER_GOLD_CAP,
|
PLAYER_GOLD_CAP,
|
||||||
STASH_DEPOSIT_BTN_BOUNDS,
|
STASH_DEPOSIT_BTN_BOUNDS,
|
||||||
STASH_WITHDRAW_BTN_BOUNDS,
|
STASH_WITHDRAW_BTN_BOUNDS,
|
||||||
|
STASH_HEADER_ICON_SOCKET,
|
||||||
|
STASH_HEADER_TOP_RECESS,
|
||||||
|
STASH_HEADER_BOTTOM_RECESS,
|
||||||
WorldPanelsHud,
|
WorldPanelsHud,
|
||||||
} from '../src/ui/world-panels.ts'
|
} from '../src/ui/world-panels.ts'
|
||||||
|
import type { D2FontRenderer } from '../src/ui/font.ts'
|
||||||
import {
|
import {
|
||||||
InventoryPanel,
|
InventoryPanel,
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
|
|
@ -604,4 +608,123 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x
|
||||||
expect(hud.inventory.visible).toBe(false)
|
expect(hud.inventory.visible).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('8. Stash Header Recessed Boxes & Text Alignment Parity (Issue #493)', () => {
|
||||||
|
it('defines exact TradeStash.dc6 recessed box bounds and centered baselines', () => {
|
||||||
|
// Left square icon socket in top row: relX = 74..93, relY = 24..38 (20x15)
|
||||||
|
expect(STASH_HEADER_ICON_SOCKET).toEqual({
|
||||||
|
x: 80 + 74,
|
||||||
|
y: 60 + 24,
|
||||||
|
relX: 74,
|
||||||
|
relY: 24,
|
||||||
|
w: 20,
|
||||||
|
h: 15,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Right title recess in top row (after relX = 94..96 divider): relX = 97..247, relY = 24..38 (151x15)
|
||||||
|
expect(STASH_HEADER_TOP_RECESS).toEqual({
|
||||||
|
x: 80 + 97,
|
||||||
|
y: 60 + 24,
|
||||||
|
relX: 97,
|
||||||
|
relY: 24,
|
||||||
|
w: 151,
|
||||||
|
h: 15,
|
||||||
|
textX: 80 + 172,
|
||||||
|
textY: 60 + 35,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Full-width gold readout recess in bottom row: relX = 75..246, relY = 49..65 (172x17)
|
||||||
|
expect(STASH_HEADER_BOTTOM_RECESS).toEqual({
|
||||||
|
x: 80 + 75,
|
||||||
|
y: 60 + 49,
|
||||||
|
relX: 75,
|
||||||
|
relY: 49,
|
||||||
|
w: 172,
|
||||||
|
h: 17,
|
||||||
|
textX: 80 + 160,
|
||||||
|
textY: 60 + 61,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Private Stash title and gold readout centered inside their respective recessed boxes without crossing dividers', () => {
|
||||||
|
const panels = new WorldPanelsHud()
|
||||||
|
panels.stashGold = 2_500_000
|
||||||
|
|
||||||
|
const drawTextCalls: Array<{
|
||||||
|
text: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
opts?: { font?: string; color?: string; align?: string }
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
const mockFont = {
|
||||||
|
drawText: (_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => {
|
||||||
|
drawTextCalls.push({ text, x, y, opts })
|
||||||
|
},
|
||||||
|
measureText: (text: string) => text.length * 6.5,
|
||||||
|
} as unknown as D2FontRenderer
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
drawImage: () => {},
|
||||||
|
fillRect: () => {},
|
||||||
|
strokeRect: () => {},
|
||||||
|
fillStyle: '',
|
||||||
|
strokeStyle: '',
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
|
panels.drawLeftDockPanel(
|
||||||
|
mockCtx,
|
||||||
|
'stash',
|
||||||
|
{
|
||||||
|
questBgImg: null,
|
||||||
|
waypointBgImg: null,
|
||||||
|
borderLeftImg: null,
|
||||||
|
stashBgImg: null,
|
||||||
|
cubeBgImg: null,
|
||||||
|
vendorBgImg: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
},
|
||||||
|
mockFont,
|
||||||
|
)
|
||||||
|
|
||||||
|
const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱'))
|
||||||
|
const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币'))
|
||||||
|
|
||||||
|
expect(titleCall).toBeDefined()
|
||||||
|
expect(goldCall).toBeDefined()
|
||||||
|
|
||||||
|
// 1. Top title must be centered in the right compartment (relX = 97..247 -> center ox + 172 = 252)
|
||||||
|
// and vertically centered in relY = 24..38 (alphabetic baseline oy + 35 = 95)
|
||||||
|
expect(titleCall!.x).toBe(STASH_HEADER_TOP_RECESS.textX)
|
||||||
|
expect(titleCall!.y).toBe(STASH_HEADER_TOP_RECESS.textY)
|
||||||
|
expect(titleCall!.opts?.font).toBe('font8')
|
||||||
|
expect(titleCall!.opts?.color).toBe('gold')
|
||||||
|
expect(titleCall!.opts?.align).toBe('center')
|
||||||
|
|
||||||
|
// Verify title glyph vertical span [y - 9, y + 1] stays strictly inside top recess [oy + 24, oy + 38]
|
||||||
|
const topRecessTop = STASH_HEADER_TOP_RECESS.y
|
||||||
|
const topRecessBottom = STASH_HEADER_TOP_RECESS.y + STASH_HEADER_TOP_RECESS.h - 1
|
||||||
|
expect(titleCall!.y - 9).toBeGreaterThanOrEqual(topRecessTop)
|
||||||
|
expect(titleCall!.y + 1).toBeLessThanOrEqual(topRecessBottom)
|
||||||
|
|
||||||
|
// Verify title horizontal span (~130px in font8) stays strictly inside [ox + 97, ox + 247] (never crossing relX = 94..96 divider)
|
||||||
|
const approxTitleWidth = 130
|
||||||
|
expect(titleCall!.x - approxTitleWidth / 2).toBeGreaterThan(STASH_HEADER_TOP_RECESS.x)
|
||||||
|
expect(titleCall!.x + approxTitleWidth / 2).toBeLessThan(STASH_HEADER_TOP_RECESS.x + STASH_HEADER_TOP_RECESS.w)
|
||||||
|
|
||||||
|
// 2. Bottom gold text must be centered in the bottom recess (relX = 75..246 -> center ox + 160 = 240)
|
||||||
|
// and vertically centered in relY = 49..65 (alphabetic baseline oy + 61 = 121)
|
||||||
|
expect(goldCall!.x).toBe(STASH_HEADER_BOTTOM_RECESS.textX)
|
||||||
|
expect(goldCall!.y).toBe(STASH_HEADER_BOTTOM_RECESS.textY)
|
||||||
|
expect(goldCall!.opts?.font).toBe('font8')
|
||||||
|
expect(goldCall!.opts?.color).toBe('white')
|
||||||
|
expect(goldCall!.opts?.align).toBe('center')
|
||||||
|
|
||||||
|
// Verify gold glyph vertical span [y - 9, y + 1] stays strictly inside bottom recess [oy + 49, oy + 65]
|
||||||
|
const bottomRecessTop = STASH_HEADER_BOTTOM_RECESS.y
|
||||||
|
const bottomRecessBottom = STASH_HEADER_BOTTOM_RECESS.y + STASH_HEADER_BOTTOM_RECESS.h - 1
|
||||||
|
expect(goldCall!.y - 9).toBeGreaterThanOrEqual(bottomRecessTop)
|
||||||
|
expect(goldCall!.y + 1).toBeLessThanOrEqual(bottomRecessBottom)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,449 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
||||||
|
import {
|
||||||
|
createDroppedItem,
|
||||||
|
shouldSpawnIdentifiedByDefault,
|
||||||
|
} from '../src/game/drop-pipeline.ts'
|
||||||
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||||
|
import {
|
||||||
|
GroundItemManager,
|
||||||
|
isGroundSourceUnidentified,
|
||||||
|
resolveGroundItemMetadata,
|
||||||
|
} from '../src/game/ground-items.ts'
|
||||||
|
import {
|
||||||
|
calculateTransactionCost,
|
||||||
|
TransactionType,
|
||||||
|
} from '../src/game/item-cost.ts'
|
||||||
|
import { ItemFlag, ItemQuality, type Item } from '../src/game/items.ts'
|
||||||
|
import { VendorNpcId } from '../src/game/npc-table.ts'
|
||||||
|
import {
|
||||||
|
generateGambleInventory,
|
||||||
|
generateVendorStoreItem,
|
||||||
|
resolveGambledItem,
|
||||||
|
} from '../src/game/vendor-generator.ts'
|
||||||
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||||
|
import type { D2ColorCode, D2FontName, D2FontRenderer } from '../src/ui/font.ts'
|
||||||
|
import {
|
||||||
|
formatGroundItemLabelText,
|
||||||
|
getGroundItemQualityColor,
|
||||||
|
GROUND_LABEL_QUALITY_COLORS,
|
||||||
|
} from '../src/ui/ground-labels.ts'
|
||||||
|
import {
|
||||||
|
EQUIP_SLOTS_LAYOUT,
|
||||||
|
identifyUiItem,
|
||||||
|
INV_GRID_ORIGIN,
|
||||||
|
InventoryPanel,
|
||||||
|
isUiItemUnidentified,
|
||||||
|
itemToUiInventoryItem,
|
||||||
|
resolveItemSpriteRect,
|
||||||
|
type UiInventoryItem,
|
||||||
|
} from '../src/ui/inventory.ts'
|
||||||
|
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
|
||||||
|
|
||||||
|
function createMockTooltipCanvas() {
|
||||||
|
const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = []
|
||||||
|
const fillStyles: string[] = []
|
||||||
|
const ctx = {
|
||||||
|
save: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
drawImage: vi.fn(),
|
||||||
|
fillRect: vi.fn(function (this: { fillStyle: string }) {
|
||||||
|
fillStyles.push(this.fillStyle)
|
||||||
|
}),
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
moveTo: vi.fn(),
|
||||||
|
lineTo: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
fillText: vi.fn(),
|
||||||
|
fillStyle: '',
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 1,
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
|
const font = {
|
||||||
|
loaded: true,
|
||||||
|
measureText: vi.fn((text: string) => text.length * 8),
|
||||||
|
drawText: vi.fn(
|
||||||
|
(
|
||||||
|
_ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
_x: number,
|
||||||
|
_y: number,
|
||||||
|
opts?: { font?: D2FontName; color?: D2ColorCode },
|
||||||
|
) => {
|
||||||
|
fontCalls.push({ text, color: opts?.color, font: opts?.font })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
} as unknown as D2FontRenderer
|
||||||
|
|
||||||
|
return { ctx, font, fontCalls, fillStyles }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System', () => {
|
||||||
|
const dropTables = getEmbeddedDropTables()
|
||||||
|
const shakoBase = dropTables.getBase('uap')!
|
||||||
|
const harlequinDef = dropTables.uniques.getEnabledByCode('uap')[0]!
|
||||||
|
|
||||||
|
function makeDroppedShako(qualityTier: 'normal' | 'magic' | 'rare' | 'unique', seed = 101): Item {
|
||||||
|
const rng = new D2Rng(seed)
|
||||||
|
return createDroppedItem(shakoBase, qualityTier, {
|
||||||
|
uniqueItem: qualityTier === 'unique' ? harlequinDef : undefined,
|
||||||
|
itemTypes: dropTables.itemTypes,
|
||||||
|
ilvl: 85,
|
||||||
|
dwInitSeed: seed,
|
||||||
|
itemRng: rng,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('1. Drop Pipeline Unidentified Spawn Rules (D2Game.dll / Items.cpp)', () => {
|
||||||
|
it('spawns low, normal, superior, and quest items as identified, and magic/rare/set/unique/crafted items as unidentified', () => {
|
||||||
|
expect(shakoBase).toBeDefined()
|
||||||
|
expect(harlequinDef).toBeDefined()
|
||||||
|
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'low')).toBe(true)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'normal')).toBe(true)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'superior')).toBe(true)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'magic')).toBe(false)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'rare')).toBe(false)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'set')).toBe(false)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'unique')).toBe(false)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'crafted' as any)).toBe(false)
|
||||||
|
|
||||||
|
// Quest items (base.quest > 0) always spawn identified regardless of quality tier
|
||||||
|
const cubeBase = dropTables.getBase('box')!
|
||||||
|
expect(cubeBase.quest).toBeGreaterThan(0)
|
||||||
|
expect(shouldSpawnIdentifiedByDefault(cubeBase, 'unique')).toBe(true)
|
||||||
|
|
||||||
|
// Verify concrete createDroppedItem outputs
|
||||||
|
const normalItem = makeDroppedShako('normal', 1)
|
||||||
|
expect(normalItem.identified).toBe(true)
|
||||||
|
expect(normalItem.flags?.identified).toBe(true)
|
||||||
|
expect((normalItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true)
|
||||||
|
|
||||||
|
const magicItem = makeDroppedShako('magic', 2)
|
||||||
|
expect(magicItem.identified).toBe(false)
|
||||||
|
expect(magicItem.flags?.identified).toBe(false)
|
||||||
|
expect((magicItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false)
|
||||||
|
|
||||||
|
const rareItem = makeDroppedShako('rare', 3)
|
||||||
|
expect(rareItem.identified).toBe(false)
|
||||||
|
expect(rareItem.flags?.identified).toBe(false)
|
||||||
|
expect((rareItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false)
|
||||||
|
|
||||||
|
const uniqueShako = makeDroppedShako('unique', 4)
|
||||||
|
expect(uniqueShako.quality).toBe(ItemQuality.UNIQUE)
|
||||||
|
expect(uniqueShako.name).toBe('Harlequin Crest')
|
||||||
|
expect(uniqueShako.identified).toBe(false)
|
||||||
|
expect(uniqueShako.flags?.identified).toBe(false)
|
||||||
|
expect((uniqueShako.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('2. Vendor Store, Gambling Reveal & Unidentified Transaction Cost', () => {
|
||||||
|
it('spawns vendor trade items and resolved gamble items as pre-identified', () => {
|
||||||
|
const rng = new D2Rng(42)
|
||||||
|
const capBase = dropTables.getBase('cap')!
|
||||||
|
const vendorMagic = generateVendorStoreItem({
|
||||||
|
base: capBase,
|
||||||
|
vendorId: VendorNpcId.CHARSI,
|
||||||
|
qualityTier: 'magic',
|
||||||
|
cLvl: 25,
|
||||||
|
nItemLevel: 30,
|
||||||
|
difficulty: 0,
|
||||||
|
permStore: false,
|
||||||
|
tables: dropTables,
|
||||||
|
rng,
|
||||||
|
})
|
||||||
|
expect(vendorMagic).not.toBeNull()
|
||||||
|
expect(vendorMagic!.identified).toBe(true)
|
||||||
|
expect(vendorMagic!.flags?.identified).toBe(true)
|
||||||
|
expect((vendorMagic!.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true)
|
||||||
|
|
||||||
|
const gambleGrid = generateGambleInventory({
|
||||||
|
vendorId: VendorNpcId.GHEED,
|
||||||
|
cLvl: 30,
|
||||||
|
tables: dropTables,
|
||||||
|
rng,
|
||||||
|
})
|
||||||
|
const mystery = gambleGrid.list()[0]!.item
|
||||||
|
expect(mystery.isGambleMystery).toBe(true)
|
||||||
|
expect(mystery.identified).toBe(false)
|
||||||
|
|
||||||
|
const gambled = resolveGambledItem({
|
||||||
|
mysteryItem: mystery,
|
||||||
|
cLvl: 30,
|
||||||
|
tables: dropTables,
|
||||||
|
rng,
|
||||||
|
})
|
||||||
|
expect(gambled.identified).toBe(true)
|
||||||
|
expect(gambled.flags?.identified).toBe(true)
|
||||||
|
expect(gambled.isGambleMystery).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discounts sell price of unidentified magic/unique items until identified (0x6FD9B1C0)', () => {
|
||||||
|
const uniqueShako = makeDroppedShako('unique', 7)
|
||||||
|
expect(uniqueShako.identified).toBe(false)
|
||||||
|
|
||||||
|
const unidSellCost = calculateTransactionCost({
|
||||||
|
item: uniqueShako,
|
||||||
|
transactionType: TransactionType.SELL,
|
||||||
|
vendorId: VendorNpcId.CHARSI,
|
||||||
|
difficulty: 2,
|
||||||
|
tables: dropTables,
|
||||||
|
})
|
||||||
|
const idShako: Item = {
|
||||||
|
...uniqueShako,
|
||||||
|
identified: true,
|
||||||
|
flags: { ...uniqueShako.flags!, identified: true },
|
||||||
|
rawFlags: uniqueShako.rawFlags! | ItemFlag.IDENTIFIED,
|
||||||
|
}
|
||||||
|
const idSellCost = calculateTransactionCost({
|
||||||
|
item: idShako,
|
||||||
|
transactionType: TransactionType.SELL,
|
||||||
|
vendorId: VendorNpcId.CHARSI,
|
||||||
|
difficulty: 2,
|
||||||
|
tables: dropTables,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(unidSellCost).toBeGreaterThan(0)
|
||||||
|
expect(idSellCost).toBeGreaterThan(unidSellCost)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('3. Ground Item Metadata & Alt Label Masking', () => {
|
||||||
|
it('masks unidentified ground item labels to base item name while preserving quality color and underlying secret name', () => {
|
||||||
|
const uniqueShako = makeDroppedShako('unique', 11)
|
||||||
|
expect(isGroundSourceUnidentified(uniqueShako)).toBe(true)
|
||||||
|
|
||||||
|
const meta = resolveGroundItemMetadata(uniqueShako)
|
||||||
|
expect(meta.quality).toBe('unique')
|
||||||
|
expect(meta.name).toBe('Shako')
|
||||||
|
expect(meta.nameZh).toContain('军帽')
|
||||||
|
expect(meta.nameZh).not.toContain('Harlequin Crest')
|
||||||
|
expect(meta.nameZh).not.toContain('谐角之冠')
|
||||||
|
|
||||||
|
const manager = new GroundItemManager()
|
||||||
|
const groundEntity = manager.add(uniqueShako, 10, 10, 320, 240)
|
||||||
|
expect(getGroundItemQualityColor(groundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||||
|
|
||||||
|
const labelText = formatGroundItemLabelText(groundEntity)
|
||||||
|
expect(labelText).toContain('军帽')
|
||||||
|
expect(labelText).not.toContain('Harlequin Crest')
|
||||||
|
expect(labelText).not.toContain('谐角之冠')
|
||||||
|
|
||||||
|
// Underlying item's secret identity is untouched so picking it up and identifying it works
|
||||||
|
expect(uniqueShako.name).toBe('Harlequin Crest')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('4. UI Item Bridge, Sprite/Dye Masking, Tooltip Masking & Equipping Gate', () => {
|
||||||
|
it('masks unique dye transform and custom unique invFile while unidentified and restores them upon identification', () => {
|
||||||
|
const uniqueShako = makeDroppedShako('unique', 19)
|
||||||
|
const uiShako = itemToUiInventoryItem(uniqueShako, dropTables)
|
||||||
|
|
||||||
|
expect(isUiItemUnidentified(uiShako)).toBe(true)
|
||||||
|
expect(uiShako.invtransform).toBeTruthy()
|
||||||
|
expect(uiShako.baseInvFile).toBe('invcap')
|
||||||
|
|
||||||
|
const unidSprite = resolveItemSpriteRect(uiShako)
|
||||||
|
const baseCapSprite = BAKED_UI_MANIFEST.itemRects['invcap']
|
||||||
|
const dyedCapSprite = BAKED_UI_MANIFEST.itemRects[`invcap_${uiShako.invtransform}`]
|
||||||
|
expect(unidSprite).toEqual(baseCapSprite)
|
||||||
|
if (dyedCapSprite) {
|
||||||
|
expect(unidSprite).not.toEqual(dyedCapSprite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify and verify dyed sprite is now resolved
|
||||||
|
identifyUiItem(uiShako)
|
||||||
|
expect(isUiItemUnidentified(uiShako)).toBe(false)
|
||||||
|
if (dyedCapSprite) {
|
||||||
|
expect(resolveItemSpriteRect(uiShako)).toEqual(dyedCapSprite)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders red cell backdrop in InventoryPanel.draw and hides secret name/affixes in drawItemTooltip while unidentified', () => {
|
||||||
|
const uniqueShako = makeDroppedShako('unique', 23)
|
||||||
|
const uiShako = itemToUiInventoryItem(uniqueShako, dropTables)
|
||||||
|
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
inv.gridItems = [{ item: uiShako, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
const { ctx, font, fontCalls, fillStyles } = createMockTooltipCanvas()
|
||||||
|
inv.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: null,
|
||||||
|
invTab0Img: null,
|
||||||
|
invTab1Img: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: null,
|
||||||
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
expect(fillStyles).toContain('rgba(72, 18, 18, 0.50)')
|
||||||
|
|
||||||
|
// Hover tooltip while unidentified
|
||||||
|
const wp = new WorldPanelsHud()
|
||||||
|
fontCalls.length = 0
|
||||||
|
wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false)
|
||||||
|
|
||||||
|
const texts = fontCalls.map(c => c.text)
|
||||||
|
expect(texts[0]).toContain('军帽')
|
||||||
|
expect(texts.some(t => t.includes('Harlequin Crest') || t.includes('谐角之冠'))).toBe(false)
|
||||||
|
expect(texts).toContain('未辨识 (Unidentified)')
|
||||||
|
expect(texts).toContain('无法使用 (Cannot be Equipped)')
|
||||||
|
|
||||||
|
// Hover tooltip after identification
|
||||||
|
identifyUiItem(uiShako)
|
||||||
|
fontCalls.length = 0
|
||||||
|
wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false)
|
||||||
|
const identifiedTexts = fontCalls.map(c => c.text)
|
||||||
|
expect(identifiedTexts[0]).toContain('Harlequin Crest')
|
||||||
|
expect(identifiedTexts).not.toContain('未辨识 (Unidentified)')
|
||||||
|
expect(identifiedTexts).not.toContain('无法使用 (Cannot be Equipped)')
|
||||||
|
expect(identifiedTexts.length).toBeGreaterThan(texts.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => {
|
||||||
|
it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => {
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
delete inv.equipped.helm
|
||||||
|
|
||||||
|
const uniqueShako = itemToUiInventoryItem(makeDroppedShako('unique', 31), dropTables)
|
||||||
|
const iscBase = dropTables.getBase('isc')!
|
||||||
|
const scrollItem = itemToUiInventoryItem(
|
||||||
|
createDroppedItem(iscBase, 'normal', {
|
||||||
|
itemTypes: dropTables.itemTypes,
|
||||||
|
ilvl: 1,
|
||||||
|
dwInitSeed: 32,
|
||||||
|
itemRng: new D2Rng(32),
|
||||||
|
}),
|
||||||
|
dropTables,
|
||||||
|
)
|
||||||
|
|
||||||
|
inv.gridItems = [
|
||||||
|
{ item: uniqueShako, col: 0, row: 0 },
|
||||||
|
{ item: scrollItem, col: 2, row: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Right-click Scroll of Identify at (2, 0) via handleClick(button=2) to enter Identify Cursor Mode
|
||||||
|
const scrollX = INV_GRID_ORIGIN.x + 2 * INV_GRID_ORIGIN.cellPx + 5
|
||||||
|
const scrollY = INV_GRID_ORIGIN.y + 5
|
||||||
|
expect(inv.handleClick(scrollX, scrollY, undefined, 2)).toBe(true)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(true)
|
||||||
|
|
||||||
|
// Left-click the unidentified Shako at (0, 0) via handleClick(button=0) to identify it
|
||||||
|
const shakoX = INV_GRID_ORIGIN.x + 5
|
||||||
|
const shakoY = INV_GRID_ORIGIN.y + 5
|
||||||
|
expect(inv.handleClick(shakoX, shakoY, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(false)
|
||||||
|
expect(isUiItemUnidentified(uniqueShako)).toBe(false)
|
||||||
|
expect((uniqueShako.rawItem!.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true)
|
||||||
|
|
||||||
|
// Scroll was consumed and removed from inventory
|
||||||
|
expect(inv.gridItems.some(p => p.item.id === scrollItem.id)).toBe(false)
|
||||||
|
|
||||||
|
// Now pick up the identified Shako and equip it into the helm slot
|
||||||
|
expect(inv.clickGridCell(0, 0)).toBe(true)
|
||||||
|
expect(inv.cursorItem?.id).toBe(uniqueShako.id)
|
||||||
|
expect(inv.clickEquipSlot('helm')).toBe(true)
|
||||||
|
expect((inv.equipped.helm as UiInventoryItem | undefined)?.id).toBe(uniqueShako.id)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies an item using a Tome of Identify (ibk) and decrements tome quantity', () => {
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
const rinBase = dropTables.getBase('rin')!
|
||||||
|
const ibkBase = dropTables.getBase('ibk')!
|
||||||
|
|
||||||
|
const rareRing = itemToUiInventoryItem(
|
||||||
|
createDroppedItem(rinBase, 'rare', {
|
||||||
|
itemTypes: dropTables.itemTypes,
|
||||||
|
ilvl: 60,
|
||||||
|
dwInitSeed: 41,
|
||||||
|
itemRng: new D2Rng(41),
|
||||||
|
}),
|
||||||
|
dropTables,
|
||||||
|
)
|
||||||
|
const tomeItem = itemToUiInventoryItem(
|
||||||
|
{
|
||||||
|
...createDroppedItem(ibkBase, 'normal', {
|
||||||
|
itemTypes: dropTables.itemTypes,
|
||||||
|
ilvl: 1,
|
||||||
|
dwInitSeed: 42,
|
||||||
|
itemRng: new D2Rng(42),
|
||||||
|
}),
|
||||||
|
quantity: 5,
|
||||||
|
stack: 5,
|
||||||
|
},
|
||||||
|
dropTables,
|
||||||
|
)
|
||||||
|
|
||||||
|
inv.gridItems = [
|
||||||
|
{ item: rareRing, col: 0, row: 0 },
|
||||||
|
{ item: tomeItem, col: 1, row: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tomeX = INV_GRID_ORIGIN.x + 1 * INV_GRID_ORIGIN.cellPx + 5
|
||||||
|
const tomeY = INV_GRID_ORIGIN.y + 5
|
||||||
|
expect(inv.handleClick(tomeX, tomeY, undefined, 2)).toBe(true)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(true)
|
||||||
|
|
||||||
|
const ringX = INV_GRID_ORIGIN.x + 5
|
||||||
|
const ringY = INV_GRID_ORIGIN.y + 5
|
||||||
|
expect(inv.handleClick(ringX, ringY, undefined, 0)).toBe(true)
|
||||||
|
expect(isUiItemUnidentified(rareRing)).toBe(false)
|
||||||
|
expect(tomeItem.quantity).toBe(4)
|
||||||
|
expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies all unidentified items across Inventory, Stash, and Horadric Cube via Deckard Cain', () => {
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
const wp = new WorldPanelsHud()
|
||||||
|
const rinBase = dropTables.getBase('rin')!
|
||||||
|
const amuBase = dropTables.getBase('amu')!
|
||||||
|
|
||||||
|
const bagUnid = itemToUiInventoryItem(makeDroppedShako('unique', 51), dropTables)
|
||||||
|
const stashUnid = itemToUiInventoryItem(
|
||||||
|
createDroppedItem(rinBase, 'rare', {
|
||||||
|
itemTypes: dropTables.itemTypes,
|
||||||
|
ilvl: 60,
|
||||||
|
dwInitSeed: 52,
|
||||||
|
itemRng: new D2Rng(52),
|
||||||
|
}),
|
||||||
|
dropTables,
|
||||||
|
)
|
||||||
|
const cubeUnid = itemToUiInventoryItem(
|
||||||
|
createDroppedItem(amuBase, 'magic', {
|
||||||
|
itemTypes: dropTables.itemTypes,
|
||||||
|
ilvl: 50,
|
||||||
|
dwInitSeed: 53,
|
||||||
|
itemRng: new D2Rng(53),
|
||||||
|
}),
|
||||||
|
dropTables,
|
||||||
|
)
|
||||||
|
|
||||||
|
inv.gridItems = [{ item: bagUnid, col: 0, row: 0 }]
|
||||||
|
wp.stashItems = [{ item: stashUnid, col: 0, row: 0 }]
|
||||||
|
wp.cubeItems = [{ item: cubeUnid, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
expect(isUiItemUnidentified(bagUnid)).toBe(true)
|
||||||
|
expect(isUiItemUnidentified(stashUnid)).toBe(true)
|
||||||
|
expect(isUiItemUnidentified(cubeUnid)).toBe(true)
|
||||||
|
|
||||||
|
const count = wp.identifyAllUiItems(inv)
|
||||||
|
expect(count).toBe(3)
|
||||||
|
expect(isUiItemUnidentified(bagUnid)).toBe(false)
|
||||||
|
expect(isUiItemUnidentified(stashUnid)).toBe(false)
|
||||||
|
expect(isUiItemUnidentified(cubeUnid)).toBe(false)
|
||||||
|
expect(wp.identifyAllUiItems(inv)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -10,7 +10,11 @@ import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||||
|
<<<<<<< HEAD
|
||||||
|
import type { D2ColorCode, D2FontName as FontSizeName, D2FontRenderer } from '../src/ui/font.ts'
|
||||||
|
=======
|
||||||
import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts'
|
import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts'
|
||||||
|
>>>>>>> ce00574 (fix(ui): restore authentic D2 v1.13c NPC dialog popup menu (Fixes #489))
|
||||||
import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||||
import {
|
import {
|
||||||
BLACKSMITH_CLASS_IDS,
|
BLACKSMITH_CLASS_IDS,
|
||||||
|
|
@ -454,7 +458,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
||||||
expect(wp.activeVendorTab).toBe('armor')
|
expect(wp.activeVendorTab).toBe('armor')
|
||||||
|
|
||||||
// Leave only 1 non-permStore item on the armor tab and buy it
|
// Leave only 1 non-permStore item on the armor tab and buy it
|
||||||
const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.rawItem?.permStore)
|
const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore)
|
||||||
expect(nonPerm).toBeDefined()
|
expect(nonPerm).toBeDefined()
|
||||||
wp.vendorTabPlacements.armor = [nonPerm!]
|
wp.vendorTabPlacements.armor = [nonPerm!]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue