fix(drop): unify monster and inventory ground drops on 16x8 sub-tile lattice with bilingual metadata parity
1. Sub-Tile Scatter Spacing (D2Common.dll COLLISION_GetFreeCoordinatesImpl 0x6FD9D140):
- Unify GameEngine.triggerMonsterDrop, GameEngine.dropItem, GameEngine.dropGold,
and net-scene monster drops on findIsometricDropPosition (16x8 sub-tile diamond
lattice, 16px orthogonal / 8px vertical spacing) instead of 80x40px macro cells.
- Derive GroundItemEntity (cellX, cellY) from final scattered (finalX, finalY)
via worldToCell rather than overwriting with stale origin cell coordinates.
2. Monster vs. Inventory Drop Metadata & Bilingual Name Parity:
- Add formatBilingualGroundItemName in src/game/ground-items.ts using
translateItemName / translateMagicName / translateRareName so raw monster-dropped
Item objects receive identical '中文名 (English Name)' nameZh as UiInventoryItem.
- Detect runes via isRuneCode in resolveItemQualityString so monster-dropped runes
render with #ff9c18 orange labels matching inventory-dropped runes.
- Preserve flippyFile, dropSound, and dropSfxFrame in itemToUiInventoryItem, fix
socket count resolution so unsocketed items do not inherit base.gemsockets, and
add embedded drop table fallback in resolveGroundItemMetadata.
3. Anti-Stacking for Sequential Player Inventory/Body Drops:
- Simplify act-scene onDropItemToGround / onDropGoldToGround to delegate directly
to engine.dropItem / engine.dropGold at (player.x, player.y) so every dropped
item checks engine.groundItems.all and scatters to a unique sub-tile slot.
Fixes #479
This commit is contained in:
parent
9022579306
commit
c09e237ff8
|
|
@ -15,7 +15,7 @@ import { getMonsterTreasureClass } from './monsters.ts'
|
|||
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
|
||||
import { MonsterStreamingManager } from './monster-streaming.ts'
|
||||
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
||||
import { GroundItemManager, triggerFlippyBounce, findSafeDropPosition, getPickupEdgeDistance, isWithinPickupBounds } from './ground-items.ts'
|
||||
import { GroundItemManager, triggerFlippyBounce, findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance, isWithinPickupBounds } from './ground-items.ts'
|
||||
import type { GroundItemEntity, GroundItemSource } from './ground-items.ts'
|
||||
import { ORTHO_CELL_WIDTH, ORTHO_CELL_HEIGHT } from './d2map.ts'
|
||||
import { getInventoryGoldLimit } from '../ui/inventory.ts'
|
||||
|
|
@ -1448,26 +1448,25 @@ export class GameEngine {
|
|||
})
|
||||
|
||||
this.metrics.dropsRolled += 1
|
||||
const burstOccupied: { cellX?: number; cellY?: number; x?: number; y?: number }[] = [...this.groundItems.all]
|
||||
const burstOccupied: { x: number; y: number }[] = this.groundItems.all.map(g => ({ x: g.x, y: g.y }))
|
||||
const currentSimTime = this.world.tick * 40
|
||||
const originCell = worldToCell(this.terrain, pending.x, pending.y)
|
||||
const spawned: GroundItemEntity[] = []
|
||||
|
||||
for (const drop of droppedItems) {
|
||||
const dropPos = findSafeDropPosition(
|
||||
this.terrain,
|
||||
originCell.cellX,
|
||||
originCell.cellY,
|
||||
5,
|
||||
const dropPos = findIsometricDropPosition(
|
||||
pending.x,
|
||||
pending.y,
|
||||
burstOccupied,
|
||||
this.terrain,
|
||||
)
|
||||
burstOccupied.push(dropPos)
|
||||
const dropCell = worldToCell(this.terrain, dropPos.x, dropPos.y)
|
||||
|
||||
const isGold = drop.base?.id === 'gold' || drop.code?.trim() === 'gld' || (drop as any).id === 'gold'
|
||||
const goldAmount = isGold ? (drop.stack ?? drop.value ?? 1) : undefined
|
||||
|
||||
this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
||||
const entity = this.groundItems.add(drop, dropPos.cellX, dropPos.cellY, dropPos.x, dropPos.y, {
|
||||
const entity = this.groundItems.add(drop, dropCell.cellX, dropCell.cellY, dropPos.x, dropPos.y, {
|
||||
...(goldAmount !== undefined ? { amount: goldAmount } : {}),
|
||||
bounce: true,
|
||||
now: currentSimTime,
|
||||
|
|
@ -1487,26 +1486,20 @@ export class GameEngine {
|
|||
dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity {
|
||||
const rawX = x ?? this.world.player.x
|
||||
const rawY = y ?? this.world.player.y
|
||||
const originCell = (cellX !== undefined && cellY !== undefined)
|
||||
? { cellX, cellY }
|
||||
: worldToCell(this.terrain, rawX, rawY)
|
||||
const dropPos = findSafeDropPosition(
|
||||
this.terrain,
|
||||
originCell.cellX,
|
||||
originCell.cellY,
|
||||
5,
|
||||
const dropPos = findIsometricDropPosition(
|
||||
rawX,
|
||||
rawY,
|
||||
this.groundItems.all,
|
||||
this.terrain,
|
||||
)
|
||||
const cx = cellX ?? dropPos.cellX
|
||||
const cy = cellY ?? dropPos.cellY
|
||||
const finalX = (x !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY)
|
||||
? rawX
|
||||
: dropPos.x
|
||||
const finalY = (y !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY)
|
||||
? rawY
|
||||
: dropPos.y
|
||||
const finalX = dropPos.x
|
||||
const finalY = dropPos.y
|
||||
const moved = finalX !== rawX || finalY !== rawY
|
||||
const dropCell = (!moved && cellX !== undefined && cellY !== undefined)
|
||||
? { cellX, cellY }
|
||||
: worldToCell(this.terrain, finalX, finalY)
|
||||
const now = this.world.tick * 40
|
||||
const entity = this.groundItems.add(item, cx, cy, finalX, finalY, { bounce: true, now })
|
||||
const entity = this.groundItems.add(item, dropCell.cellX, dropCell.cellY, finalX, finalY, { bounce: true, now })
|
||||
this.ground.push({ x: finalX, y: finalY, item: item as any })
|
||||
return entity
|
||||
}
|
||||
|
|
@ -1525,30 +1518,24 @@ export class GameEngine {
|
|||
if (amount <= 0) return null
|
||||
const rawX = x ?? this.world.player.x
|
||||
const rawY = y ?? this.world.player.y
|
||||
const originCell = (cellX !== undefined && cellY !== undefined)
|
||||
? { cellX, cellY }
|
||||
: worldToCell(this.terrain, rawX, rawY)
|
||||
const dropPos = findSafeDropPosition(
|
||||
this.terrain,
|
||||
originCell.cellX,
|
||||
originCell.cellY,
|
||||
5,
|
||||
const dropPos = findIsometricDropPosition(
|
||||
rawX,
|
||||
rawY,
|
||||
this.groundItems.all,
|
||||
this.terrain,
|
||||
)
|
||||
const cx = cellX ?? dropPos.cellX
|
||||
const cy = cellY ?? dropPos.cellY
|
||||
const finalX = (x !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY)
|
||||
? rawX
|
||||
: dropPos.x
|
||||
const finalY = (y !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY)
|
||||
? rawY
|
||||
: dropPos.y
|
||||
const finalX = dropPos.x
|
||||
const finalY = dropPos.y
|
||||
const moved = finalX !== rawX || finalY !== rawY
|
||||
const dropCell = (!moved && cellX !== undefined && cellY !== undefined)
|
||||
? { cellX, cellY }
|
||||
: worldToCell(this.terrain, finalX, finalY)
|
||||
const now = this.world.tick * 40
|
||||
const goldPayload = goldItem(amount)
|
||||
const entity = this.groundItems.add(
|
||||
goldPayload,
|
||||
cx,
|
||||
cy,
|
||||
dropCell.cellX,
|
||||
dropCell.cellY,
|
||||
finalX,
|
||||
finalY,
|
||||
{ amount, bounce: true, now },
|
||||
|
|
|
|||
|
|
@ -25,9 +25,13 @@ import {
|
|||
ORTHO_SUB_TILE_WIDTH,
|
||||
ORTHO_SUB_TILE_HEIGHT,
|
||||
getCollisionMaskAt,
|
||||
isBlockedAt,
|
||||
rayTraceScene,
|
||||
} from './d2map.ts'
|
||||
import type { CollisionGrid } from './d2map.ts'
|
||||
import { translateItemName, translateMagicName, translateRareName } from './tooltip-i18n.ts'
|
||||
import { isRuneCode } from './gems-data.ts'
|
||||
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
|
||||
|
||||
export interface GroundItemBounceState {
|
||||
startTime: number
|
||||
|
|
@ -83,8 +87,18 @@ export function isGoldItem(item: any): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
/** Resolve quality name string safely. */
|
||||
export function resolveItemQualityString(quality: any): string {
|
||||
/** Resolve quality name string safely, including rune detection per D2 1.13c ground label rules. */
|
||||
export function resolveItemQualityString(quality: any, code?: string, item?: any): string {
|
||||
const normCode = typeof code === 'string' ? code.trim().toLowerCase() : ''
|
||||
const rawRarity = typeof item?.rarity === 'string' ? item.rarity.toLowerCase() : ''
|
||||
if (
|
||||
(normCode && isRuneCode(normCode)) ||
|
||||
item?.isRune === true ||
|
||||
item?.base?.isRune === true ||
|
||||
rawRarity === 'rune'
|
||||
) {
|
||||
return 'rune'
|
||||
}
|
||||
if (typeof quality === 'string') return quality.toLowerCase()
|
||||
if (typeof quality === 'number') {
|
||||
switch (quality) {
|
||||
|
|
@ -99,9 +113,59 @@ export function resolveItemQualityString(quality: any): string {
|
|||
default: return 'normal'
|
||||
}
|
||||
}
|
||||
if (item?.uniqueItemDef || rawRarity === 'unique') return 'unique'
|
||||
if (item?.setItemDef || rawRarity === 'set') return 'set'
|
||||
if (item?.rolledRareAffixes || rawRarity === 'rare') return 'rare'
|
||||
if (item?.rolledMagicAffixes || rawRarity === 'magic') return 'magic'
|
||||
if (rawRarity === 'crafted' || rawRarity === 'craft') return 'craft'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an item's display name into the canonical bilingual Chinese+English format
|
||||
* (`"中文名 (English Name)"`) matching `itemToUiInventoryItem` (`src/ui/item-bridge.ts`),
|
||||
* while preserving any pre-localized `item.nameZh` that already differs from `name`.
|
||||
*/
|
||||
export function formatBilingualGroundItemName(item: any, name: string, quality: string): string {
|
||||
if (typeof item?.nameZh === 'string' && item.nameZh.trim().length > 0 && item.nameZh !== name) {
|
||||
return item.nameZh
|
||||
}
|
||||
const baseEn = typeof item?.base?.name === 'string' && item.base.name.length > 0 ? item.base.name : undefined
|
||||
if (quality === 'unique' && item?.uniqueItemDef) {
|
||||
const uEn = item.uniqueItemDef.index || name
|
||||
const uZh = translateItemName(uEn)
|
||||
return uZh && uZh !== uEn ? `${uZh} (${uEn})` : uEn
|
||||
}
|
||||
if (quality === 'set' && item?.setItemDef) {
|
||||
const sEn = item.setItemDef.index || name
|
||||
const sZh = translateItemName(sEn)
|
||||
return sZh && sZh !== sEn ? `${sZh} (${sEn})` : sEn
|
||||
}
|
||||
if ((quality === 'rare' || quality === 'craft') && item?.rolledRareAffixes) {
|
||||
const rEn = item.rolledRareAffixes.name || name
|
||||
const rZh = translateRareName(rEn)
|
||||
return rZh && rZh !== rEn ? `${rZh} (${rEn})` : rEn
|
||||
}
|
||||
if (quality === 'magic' && item?.rolledMagicAffixes) {
|
||||
const mEn = item.rolledMagicAffixes.name || name
|
||||
const mZh = translateMagicName(mEn, baseEn ?? name)
|
||||
return mZh && mZh !== mEn ? `${mZh} (${mEn})` : mEn
|
||||
}
|
||||
const directZh = translateItemName(name)
|
||||
if (directZh && directZh !== name) {
|
||||
return `${directZh} (${name})`
|
||||
}
|
||||
if (quality === 'rare' || quality === 'craft') {
|
||||
const rZh = translateRareName(name)
|
||||
if (rZh && rZh !== name) return `${rZh} (${name})`
|
||||
}
|
||||
if (quality === 'magic') {
|
||||
const mZh = translateMagicName(name, baseEn)
|
||||
if (mZh && mZh !== name) return `${mZh} (${name})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Metadata extracted from an item for ground display. */
|
||||
export interface GroundItemMetadata {
|
||||
readonly name: string
|
||||
|
|
@ -145,13 +209,38 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
|||
}
|
||||
|
||||
const name = typeof item?.name === 'string' ? item.name : 'Unknown Item'
|
||||
const nameZh = typeof item?.nameZh === 'string' ? item.nameZh : name
|
||||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality)
|
||||
const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? 1)
|
||||
const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? 1)
|
||||
const rawCode = typeof item?.code === 'string'
|
||||
? item.code.trim().toLowerCase()
|
||||
: typeof item?.base?.id === 'string'
|
||||
? item.base.id.trim().toLowerCase()
|
||||
: ''
|
||||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
||||
const 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
|
||||
}
|
||||
}
|
||||
|
||||
let tableBase: any
|
||||
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
|
||||
try {
|
||||
const tables = getEmbeddedDropTables() as any
|
||||
tableBase = typeof tables.getBase === 'function'
|
||||
? tables.getBase(rawCode)
|
||||
: (tables.weapons?.get(rawCode) ?? tables.armor?.get(rawCode) ?? tables.misc?.get(rawCode))
|
||||
} catch {
|
||||
// fallback if embedded tables unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? tableBase?.invWidth ?? 1)
|
||||
const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1)
|
||||
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
||||
const dropSound = item?.base?.dropsound ?? item?.dropSound ?? item?.dropsound ?? 'item_drop'
|
||||
const dropSfxFrame = item?.base?.dropsfxframe ?? item?.dropSfxFrame ?? item?.dropsfxframe ?? 12
|
||||
const dropSound = item?.base?.dropsound ?? item?.dropSound ?? item?.dropsound ?? tableBase?.dropsound ?? 'item_drop'
|
||||
const dropSfxFrame = item?.base?.dropsfxframe ?? item?.dropSfxFrame ?? item?.dropsfxframe ?? tableBase?.dropsfxframe ?? 12
|
||||
const ethereal = Boolean(item?.ethereal === true || item?.isEthereal === true || item?.item?.ethereal === true)
|
||||
const rawSockets = item?.sockets ?? item?.totalSockets ?? item?.socketedCount ?? item?.item?.sockets ?? item?.item?.totalSockets ?? 0
|
||||
const sockets = typeof rawSockets === 'number' && rawSockets > 0 ? rawSockets : undefined
|
||||
|
|
@ -465,6 +554,9 @@ export function isDropPositionBlocked(
|
|||
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
|
||||
|
|
@ -572,6 +664,7 @@ export function findIsometricDropPosition(
|
|||
minSpacing = 16,
|
||||
): { x: number; y: number } {
|
||||
const minSpacingSq = minSpacing * minSpacing
|
||||
let firstWalkableCandidate: { x: number; y: number } | null = null
|
||||
|
||||
for (let ring = 0; ring <= maxRings; ring += 1) {
|
||||
const ringCandidates = getIsometricRingCandidates(ring)
|
||||
|
|
@ -588,6 +681,10 @@ export function findIsometricDropPosition(
|
|||
continue
|
||||
}
|
||||
|
||||
if (firstWalkableCandidate === null) {
|
||||
firstWalkableCandidate = { x: cx, y: cy }
|
||||
}
|
||||
|
||||
// Check conflict with existing items
|
||||
let hasConflict = false
|
||||
for (const item of existingItems) {
|
||||
|
|
@ -606,7 +703,7 @@ export function findIsometricDropPosition(
|
|||
}
|
||||
}
|
||||
|
||||
return { x: originX, y: originY }
|
||||
return firstWalkableCandidate ?? { x: originX, y: originY }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6615,16 +6615,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
},
|
||||
onDropItemToGround: (item) => {
|
||||
const player = engine.world.player
|
||||
const playerCell = cellOf(runtime.grid, player.x, player.y)
|
||||
const dropPos = findSafeDropPosition(runtime.grid, playerCell.x, playerCell.y, 2)
|
||||
const groundItem = engine.dropItem(item, dropPos.x, dropPos.y, dropPos.cellX, dropPos.cellY)
|
||||
const groundItem = engine.dropItem(item, player.x, player.y)
|
||||
status.textContent = `已将「${groundItem.nameZh}」丢弃在地面`
|
||||
},
|
||||
onDropGoldToGround: (amount) => {
|
||||
const player = engine.world.player
|
||||
const playerCell = cellOf(runtime.grid, player.x, player.y)
|
||||
const dropPos = findSafeDropPosition(runtime.grid, playerCell.x, playerCell.y, 2)
|
||||
const groundGold = engine.dropGold(amount, dropPos.x, dropPos.y, dropPos.cellX, dropPos.cellY)
|
||||
const groundGold = engine.dropGold(amount, player.x, player.y)
|
||||
if (groundGold) {
|
||||
status.textContent = `已将 ${amount.toLocaleString()} 金币丢弃在地面`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts'
|
|||
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
||||
import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts'
|
||||
import { D2Rng } from '../game/d2-rng.ts'
|
||||
import { findSafeDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
|
||||
import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
|
||||
import { worldToCell } from '../game/engine.ts'
|
||||
|
||||
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts'
|
||||
|
|
@ -739,15 +739,13 @@ export function createNetSimulation(
|
|||
monsterRng: new D2Rng(monsterSeed),
|
||||
})
|
||||
|
||||
const originCell = worldToCell(mapTerrain, event.x, event.y)
|
||||
const burstOccupied: { x?: number; y?: number; cellX?: number; cellY?: number }[] = [...ground]
|
||||
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
|
||||
for (const drop of droppedItems) {
|
||||
const dropPos = findSafeDropPosition(
|
||||
mapTerrain,
|
||||
originCell.cellX,
|
||||
originCell.cellY,
|
||||
5,
|
||||
const dropPos = findIsometricDropPosition(
|
||||
event.x,
|
||||
event.y,
|
||||
burstOccupied,
|
||||
mapTerrain,
|
||||
)
|
||||
burstOccupied.push(dropPos)
|
||||
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ export interface UiInventoryItem {
|
|||
readonly ethereal?: boolean | undefined
|
||||
readonly setPieces?: readonly string[] | undefined
|
||||
readonly setBonuses?: readonly { text: string; color?: D2ColorCode }[] | undefined
|
||||
readonly flippyFile?: string | undefined
|
||||
readonly dropSound?: string | undefined
|
||||
readonly dropSfxFrame?: number | undefined
|
||||
}
|
||||
|
||||
export interface GridPlacement {
|
||||
|
|
|
|||
|
|
@ -434,13 +434,24 @@ export function itemToUiInventoryItem(
|
|||
? { current: rawItem.durability, max: rawItem.maxDurability }
|
||||
: undefined
|
||||
|
||||
const sockets = tt.sockets ?? (rawItem as any).sockets ?? rawItem.totalSockets ?? rawItem.socketedCount
|
||||
const ethereal = Boolean((tt as any).ethereal || (rawItem as any).ethereal)
|
||||
const rolledSockProp = Array.isArray(rawItem.rolledProps)
|
||||
? rawItem.rolledProps.reduce((sum, p) => ((p.code || '').trim().toLowerCase() === 'sock' ? sum + p.value : sum), 0)
|
||||
: 0
|
||||
const sockets =
|
||||
(rawItem as any).sockets ??
|
||||
rawItem.totalSockets ??
|
||||
rawItem.socketedCount ??
|
||||
(rolledSockProp > 0 ? rolledSockProp : undefined)
|
||||
const ethereal = Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
|
||||
|
||||
const id = rawItem.uniqueId
|
||||
? `item-${code}-${rawItem.uniqueId}`
|
||||
: (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile
|
||||
const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound
|
||||
const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe
|
||||
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
|
|
@ -466,5 +477,8 @@ export function itemToUiInventoryItem(
|
|||
...((rawItem as any).runewordRunes || tt.runewordRunes ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}),
|
||||
...(tt.setPieces ? { setPieces: tt.setPieces } : {}),
|
||||
...(tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
|
||||
...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}),
|
||||
...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}),
|
||||
...(typeof dropSfxFrame === 'number' && Number.isFinite(dropSfxFrame) ? { dropSfxFrame } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* Regression Test Suite for Issue #479:
|
||||
* 1. 打怪掉落的物品图标间隔太大,和原版不符合 (16x8 sub-tile diamond lattice vs 80x40 macro cell)
|
||||
* 2. 打怪掉落物品和从身上扔下来的物品逻辑不一致(比如名字一个是纯英文一个是中英、符文品质颜色、掉落音效等)
|
||||
* 3. 身上扔下来的不同物品图标不应该叠在一起(比如血瓶连续丢弃不重叠)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
|
||||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
|
||||
import { D2Rng } from '../src/game/d2-rng.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS } from '../src/game/demo-data.ts'
|
||||
import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts'
|
||||
import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts'
|
||||
import {
|
||||
formatGroundItemLabelText,
|
||||
getGroundItemQualityColor,
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
} from '../src/ui/ground-labels.ts'
|
||||
import { ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_CELL_WIDTH } from '../src/game/d2map.ts'
|
||||
import type { Monster } from '../src/game/combat.ts'
|
||||
|
||||
describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacking', () => {
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
const terrain = { widthPx: 2000, heightPx: 2000, originX: 0, originY: 0, overlap: () => 0 }
|
||||
|
||||
function createEngine(overrides?: Partial<GameEngineOptions>): GameEngine {
|
||||
const engine = new GameEngine(terrain, {
|
||||
spawn: { x: 512, y: 384 },
|
||||
stats: [],
|
||||
xpTable: DEMO_EXPERIENCE,
|
||||
skills: DEMO_SKILLS,
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: {
|
||||
playerSpeed: 4,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 0,
|
||||
playerDamage: 100,
|
||||
playerManaPerAttack: 0,
|
||||
respawnTicks: 1000,
|
||||
},
|
||||
talkRadius: 48,
|
||||
pickupRadius: 0, // keep items on ground for inspection
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
lootSeed: 1337,
|
||||
...overrides,
|
||||
})
|
||||
engine.setDropTables(dropTables)
|
||||
return engine
|
||||
}
|
||||
|
||||
describe('1. Monster Drop Sub-Tile Spacing (16x8 Sub-Tile Diamond Lattice vs 80x40 Macro Cell)', () => {
|
||||
it('places monster drops on the 16x8 sub-tile diamond lattice around death position, never 80x40 macro cells apart', () => {
|
||||
const engine = createEngine()
|
||||
// Use a non-macro-cell-aligned sub-tile death coordinate (e.g. 533, 417)
|
||||
// Countesses / Act Bosses drop multiple items in a single burst
|
||||
const deathX = 533
|
||||
const deathY = 417
|
||||
const boss: Monster = {
|
||||
index: 0,
|
||||
stats: {
|
||||
id: 'andariel',
|
||||
name: 'Andariel',
|
||||
hp: 10,
|
||||
damage: 1,
|
||||
cooldownTicks: 10,
|
||||
reach: 20,
|
||||
aggroRadius: 100,
|
||||
speed: 10,
|
||||
xp: 500,
|
||||
level: 12,
|
||||
rank: 'normal',
|
||||
},
|
||||
x: deathX,
|
||||
y: deathY,
|
||||
hp: 0,
|
||||
cooldown: 0,
|
||||
state: 'dead',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
dropRolled: false,
|
||||
pendingDrop: {
|
||||
tcName: 'Andariel',
|
||||
nLevel: 12,
|
||||
monsterType: 'boss',
|
||||
isBoss: true,
|
||||
isNoRatio: false,
|
||||
seed: 424242,
|
||||
x: deathX,
|
||||
y: deathY,
|
||||
killerContext: {
|
||||
gamePlayers: 8,
|
||||
partyPlayers: 8,
|
||||
playerMf: 200,
|
||||
playerGf: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
engine.world.monsters.push(boss)
|
||||
|
||||
const spawned = engine.triggerMonsterDrop(boss)
|
||||
expect(spawned.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// 1st drop lands at the exact monster death epicenter (Ring 0)
|
||||
expect(spawned[0]!.x).toBe(deathX)
|
||||
expect(spawned[0]!.y).toBe(deathY)
|
||||
|
||||
// Every subsequent drop in the burst must be on the 16x8 sub-tile lattice around (deathX, deathY)
|
||||
for (let i = 1; i < spawned.length; i++) {
|
||||
const item = spawned[i]!
|
||||
const dx = item.x - deathX
|
||||
const dy = item.y - deathY
|
||||
expect(Math.abs(dx) % ORTHO_SUB_TILE_WIDTH).toBe(0)
|
||||
expect(Math.abs(dy) % ORTHO_SUB_TILE_HEIGHT).toBe(0)
|
||||
|
||||
// Ring 1 & Ring 2 sub-tile offsets are compact (< ORTHO_CELL_WIDTH = 80px), NOT 80..400px macro cell steps!
|
||||
const distFromEpicenter = Math.hypot(dx, dy)
|
||||
expect(distFromEpicenter).toBeGreaterThanOrEqual(16)
|
||||
expect(distFromEpicenter).toBeLessThan(ORTHO_CELL_WIDTH)
|
||||
}
|
||||
|
||||
// Pairwise anti-stacking: no two monster drops overlap
|
||||
for (let i = 0; i < spawned.length; i++) {
|
||||
for (let j = i + 1; j < spawned.length; j++) {
|
||||
const d = Math.hypot(spawned[i]!.x - spawned[j]!.x, spawned[i]!.y - spawned[j]!.y)
|
||||
expect(d).toBeGreaterThanOrEqual(16)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Monster Drop vs. Inventory/Body Drop Metadata & Bilingual Name Parity', () => {
|
||||
it('produces identical bilingual nameZh, quality, label color, sound, and sprite rect for monster-dropped vs inventory-dropped items', () => {
|
||||
const testCases = [
|
||||
{ code: 'hp2', level: 5, mf: 0 }, // Light Healing Potion
|
||||
{ code: 'mp4', level: 20, mf: 0 }, // Greater Mana Potion
|
||||
{ code: 'rvl', level: 30, mf: 0 }, // Full Rejuvenation Potion
|
||||
{ code: 'hax', level: 5, mf: 0 }, // Hand Axe
|
||||
{ code: 'cap', level: 5, mf: 0 }, // Cap
|
||||
{ code: 'r01', level: 15, mf: 0 }, // El Rune
|
||||
{ code: 'r31', level: 85, mf: 0 }, // Jah Rune
|
||||
]
|
||||
|
||||
for (const tc of testCases) {
|
||||
const engine = createEngine()
|
||||
const rng = new D2Rng(999)
|
||||
const base = dropTables.getBase(tc.code)
|
||||
expect(base).toBeDefined()
|
||||
const rawDroppedItem = createDroppedItem(base!, 'normal', {
|
||||
ilvl: tc.level,
|
||||
dwInitSeed: 999,
|
||||
itemRng: rng,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
expect(rawDroppedItem).not.toBeNull()
|
||||
|
||||
// Path A: Dropped directly by a monster onto the ground (raw Item)
|
||||
const monsterGroundEntity = engine.groundItems.add(rawDroppedItem, 5, 5, 400, 300, { bounce: true, now: 1000 })
|
||||
|
||||
// Path B: Picked up into player UI inventory (converted via itemToUiInventoryItem) and dropped from body/inventory
|
||||
const uiInventoryItem = itemToUiInventoryItem(rawDroppedItem, dropTables)
|
||||
const playerGroundEntity = engine.dropItem(uiInventoryItem, 500, 300)
|
||||
|
||||
// 1. Bilingual nameZh parity (both must be "中文名 (English Name)", NEVER pure English on monster drop)
|
||||
expect(monsterGroundEntity.name).toBe(playerGroundEntity.name)
|
||||
expect(monsterGroundEntity.nameZh).toBe(playerGroundEntity.nameZh)
|
||||
expect(monsterGroundEntity.nameZh).toContain('(')
|
||||
expect(monsterGroundEntity.nameZh).not.toBe(monsterGroundEntity.name)
|
||||
expect(formatGroundItemLabelText(monsterGroundEntity)).toBe(formatGroundItemLabelText(playerGroundEntity))
|
||||
|
||||
// 2. Quality & ground label color parity (especially Runes -> 'rune' -> #ff9c18 orange)
|
||||
expect(monsterGroundEntity.quality).toBe(playerGroundEntity.quality)
|
||||
expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(getGroundItemQualityColor(playerGroundEntity))
|
||||
if (tc.code.startsWith('r0') || tc.code.startsWith('r3')) {
|
||||
expect(monsterGroundEntity.quality).toBe('rune')
|
||||
expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.rune)
|
||||
}
|
||||
|
||||
// 3. Audio & dimensions parity
|
||||
expect(monsterGroundEntity.invWidth).toBe(playerGroundEntity.invWidth)
|
||||
expect(monsterGroundEntity.invHeight).toBe(playerGroundEntity.invHeight)
|
||||
expect(monsterGroundEntity.dropSound).toBe(playerGroundEntity.dropSound)
|
||||
expect(monsterGroundEntity.dropSfxFrame).toBe(playerGroundEntity.dropSfxFrame)
|
||||
|
||||
// 4. Ground flippy sprite rect parity
|
||||
const monsterRect = resolveGroundItemSpriteRect(monsterGroundEntity.item)
|
||||
const playerRect = resolveGroundItemSpriteRect(playerGroundEntity.item)
|
||||
expect(monsterRect).not.toBeNull()
|
||||
expect(monsterRect).toEqual(playerRect)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Player Body/Inventory Dropped Items Anti-Stacking (Potions & Equipment)', () => {
|
||||
it('scatters 8 sequentially dropped potions/items from player inventory onto distinct sub-tile slots with zero stacking', () => {
|
||||
const engine = createEngine()
|
||||
const playerX = engine.world.player.x
|
||||
const playerY = engine.world.player.y
|
||||
|
||||
const potionsToDrop = [
|
||||
{ id: 'pot-1', code: 'hp1', invFile: 'invhp1', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂 (Minor Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-2', code: 'hp2', invFile: 'invhp2', name: 'Light Healing Potion', nameZh: '轻度治疗药剂 (Light Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-3', code: 'hp3', invFile: 'invhp3', name: 'Healing Potion', nameZh: '治疗药剂 (Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-4', code: 'hp4', invFile: 'invhp4', name: 'Greater Healing Potion', nameZh: '强效治疗药剂 (Greater Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-5', code: 'hp5', invFile: 'invhp5', name: 'Super Healing Potion', nameZh: '超级治疗药剂 (Super Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-6', code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂 (Greater Mana Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-7', code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂 (Rejuvenation Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
{ id: 'pot-8', code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂 (Full Rejuvenation Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
|
||||
]
|
||||
|
||||
const droppedEntities = potionsToDrop.map(p => engine.dropItem(p, playerX, playerY))
|
||||
expect(droppedEntities.length).toBe(8)
|
||||
|
||||
// Check that all 8 dropped items have unique (x, y) coordinates
|
||||
const coordSet = new Set(droppedEntities.map(e => `${e.x},${e.y}`))
|
||||
expect(coordSet.size).toBe(8)
|
||||
|
||||
// Check pairwise distance >= 16px (minimum 1 sub-tile spacing) and compact Ring 0..1 radius
|
||||
for (let i = 0; i < droppedEntities.length; i++) {
|
||||
const a = droppedEntities[i]!
|
||||
expect(Math.hypot(a.x - playerX, a.y - playerY)).toBeLessThanOrEqual(32)
|
||||
for (let j = i + 1; j < droppedEntities.length; j++) {
|
||||
const b = droppedEntities[j]!
|
||||
const dist = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
expect(dist).toBeGreaterThanOrEqual(16)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('scatters multiple gold piles dropped from player inventory onto distinct sub-tile slots without stacking', () => {
|
||||
const engine = createEngine()
|
||||
const playerX = engine.world.player.x
|
||||
const playerY = engine.world.player.y
|
||||
|
||||
const g1 = engine.dropGold(100, playerX, playerY)!
|
||||
const g2 = engine.dropGold(250, playerX, playerY)!
|
||||
const g3 = engine.dropGold(500, playerX, playerY)!
|
||||
const g4 = engine.dropGold(1000, playerX, playerY)!
|
||||
|
||||
const piles = [g1, g2, g3, g4]
|
||||
const coordSet = new Set(piles.map(g => `${g.x},${g.y}`))
|
||||
expect(coordSet.size).toBe(4)
|
||||
|
||||
for (let i = 0; i < piles.length; i++) {
|
||||
for (let j = i + 1; j < piles.length; j++) {
|
||||
expect(Math.hypot(piles[i]!.x - piles[j]!.x, piles[i]!.y - piles[j]!.y)).toBeGreaterThanOrEqual(16)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue