fix(render): authentic Diablo II v1.13c ground items flippy sprites and clean pickup lifecycle
- Extract and stitch all 213 Blizzard flp*.dc6 resting frames into items-atlas.png - Support all 4 flpgld gold pile size groups and color dye transforms - Implement resolveGroundItemSpriteRect() in src/ui/inventory.ts - Render authentic isometric ground art with contact shadow in drawGroundItem() - Eliminate synthetic blue boxes, 2D perspective shear hacks, and sparkle white dots - Fix engine pickup entity matching so picked items are cleanly removed with zero residual dots - Add tests/ground-items-flippy-parity.test.ts
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
|
@ -467,10 +467,12 @@ async function main(): Promise<void> {
|
|||
}
|
||||
writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2))
|
||||
|
||||
// --- STAGE 2: Comprehensive Items & Unique Items Packing ---
|
||||
console.log('Extracting item sprites (including uniqueitems.txt & setitems.txt)...')
|
||||
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
|
||||
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
|
||||
const codeToInvFile: Record<string, string> = {}
|
||||
const uniqueInvFiles = new Set<string>()
|
||||
const codeToFlippyFile: Record<string, string> = {}
|
||||
const uniqueFlippyFiles = new Set<string>()
|
||||
|
||||
// A. Base Items from weapons.txt, armor.txt, misc.txt
|
||||
for (const t of ['weapons.txt', 'armor.txt', 'misc.txt']) {
|
||||
|
|
@ -488,6 +490,12 @@ async function main(): Promise<void> {
|
|||
uniqueInvFiles.add(extra)
|
||||
}
|
||||
}
|
||||
|
||||
const flp = r['flippyfile']?.trim().toLowerCase()
|
||||
if (code && flp && archives.has(`data/global/items/${flp}.dc6`)) {
|
||||
codeToFlippyFile[code] = flp
|
||||
uniqueFlippyFiles.add(flp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,6 +511,13 @@ async function main(): Promise<void> {
|
|||
if (idx && inv && archives.has(`data/global/items/${inv}.dc6`)) {
|
||||
codeToInvFile[idx] = inv
|
||||
}
|
||||
const flp = r['flippyfile']?.trim().toLowerCase()
|
||||
if (flp && archives.has(`data/global/items/${flp}.dc6`)) {
|
||||
uniqueFlippyFiles.add(flp)
|
||||
}
|
||||
if (idx && flp && archives.has(`data/global/items/${flp}.dc6`)) {
|
||||
codeToFlippyFile[idx] = flp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -518,6 +533,13 @@ async function main(): Promise<void> {
|
|||
if (idx && inv && archives.has(`data/global/items/${inv}.dc6`)) {
|
||||
codeToInvFile[idx] = inv
|
||||
}
|
||||
const flp = r['flippyfile']?.trim().toLowerCase()
|
||||
if (flp && archives.has(`data/global/items/${flp}.dc6`)) {
|
||||
uniqueFlippyFiles.add(flp)
|
||||
}
|
||||
if (idx && flp && archives.has(`data/global/items/${flp}.dc6`)) {
|
||||
codeToFlippyFile[idx] = flp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -583,6 +605,33 @@ async function main(): Promise<void> {
|
|||
codeToInvFile['Hellfire Torch'] = 'invtrch'
|
||||
}
|
||||
|
||||
// Explicit flippy mappings
|
||||
codeToFlippyFile['gld'] = 'flpgld'
|
||||
codeToFlippyFile['gold'] = 'flpgld'
|
||||
codeToFlippyFile['box'] = 'flpbox'
|
||||
codeToFlippyFile['Horadric Cube'] = 'flpbox'
|
||||
codeToFlippyFile['cm1'] = 'flpmss'
|
||||
codeToFlippyFile['cm2'] = 'flptrch'
|
||||
codeToFlippyFile['cm3'] = 'flpmss'
|
||||
codeToFlippyFile['gheeds'] = 'flpmss'
|
||||
codeToFlippyFile["Gheed's Fortune"] = 'flpmss'
|
||||
codeToFlippyFile['anni'] = 'flpmss'
|
||||
codeToFlippyFile['Annihilus'] = 'flpmss'
|
||||
codeToFlippyFile['torch'] = 'flptrch'
|
||||
codeToFlippyFile['Hellfire Torch'] = 'flptrch'
|
||||
|
||||
// Transformed items flippy mapping
|
||||
codeToFlippyFile['Harlequin Crest'] = 'flpcap_cgrn'
|
||||
codeToFlippyFile["Tal Rasha's Guardianship"] = 'flpgth_dpur'
|
||||
codeToFlippyFile["Tal Rasha's Howling Wind"] = 'flpgth_dpur'
|
||||
codeToFlippyFile['Magefist'] = 'flptgl_lgry'
|
||||
codeToFlippyFile['Arachnid Mesh'] = 'flpvbl_blac'
|
||||
|
||||
// Identity mapping for every flippyfile: codeToFlippyFile[flp] = flp
|
||||
for (const flp of uniqueFlippyFiles) {
|
||||
codeToFlippyFile[flp] = flp
|
||||
}
|
||||
|
||||
// Register transformed item lookups in codeToInvFile
|
||||
codeToInvFile['Harlequin Crest'] = 'invcap_cgrn'
|
||||
codeToInvFile["Tal Rasha's Guardianship"] = 'invgth_dpur'
|
||||
|
|
@ -604,6 +653,7 @@ async function main(): Promise<void> {
|
|||
let curY = 0
|
||||
let rowH = 0
|
||||
const itemRects: Record<string, SpriteRect> = {}
|
||||
const flippyRects: Record<string, SpriteRect> = {}
|
||||
const itemPlacements: BlitPlacement[] = []
|
||||
const decodedBaseFrames = new Map<string, SpriteFrame>()
|
||||
|
||||
|
|
@ -672,6 +722,96 @@ async function main(): Promise<void> {
|
|||
if (transformedFrame.height > rowH) rowH = transformedFrame.height
|
||||
}
|
||||
|
||||
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (213 DC6 files) ---
|
||||
console.log('Packing authentic flippy ground item sprites...')
|
||||
const sortedFlippyNames = [...uniqueFlippyFiles].sort()
|
||||
const decodedFlippyBaseFrames = new Map<string, SpriteFrame>()
|
||||
|
||||
for (const flpName of sortedFlippyNames) {
|
||||
const dc6 = await decodeUiDc6(`data/global/items/${flpName}.dc6`)
|
||||
if (flpName === 'flpgld') {
|
||||
// flpgld has 4 groups: 0=small pile, 1=medium pile, 2=large pile, 3=huge pile
|
||||
for (let g = 0; g < dc6.groups.length; g++) {
|
||||
const groupFrames = dc6.groups[g]?.frames
|
||||
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
||||
if (!lastFrame) continue
|
||||
if (curX + lastFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
const key = `flpgld_${g}`
|
||||
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
|
||||
flippyRects[key] = rect
|
||||
itemRects[key] = rect
|
||||
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
|
||||
curX += lastFrame.width + 2
|
||||
if (lastFrame.height > rowH) rowH = lastFrame.height
|
||||
}
|
||||
if (flippyRects['flpgld_0']) {
|
||||
flippyRects['flpgld'] = flippyRects['flpgld_0']
|
||||
itemRects['flpgld'] = flippyRects['flpgld_0']
|
||||
}
|
||||
} else {
|
||||
const groupFrames = dc6.groups[0]?.frames
|
||||
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
||||
if (!lastFrame) continue
|
||||
decodedFlippyBaseFrames.set(flpName, lastFrame)
|
||||
if (curX + lastFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
|
||||
flippyRects[flpName] = rect
|
||||
itemRects[flpName] = rect
|
||||
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
|
||||
curX += lastFrame.width + 2
|
||||
if (lastFrame.height > rowH) rowH = lastFrame.height
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-bake and stitch transformed flippy frames
|
||||
const TRANSFORMED_FLIPPY_ITEMS: ReadonlyArray<{
|
||||
baseFlp: string
|
||||
transformedName: string
|
||||
colorCode: 'cgrn' | 'dpur' | 'lgry' | 'blac'
|
||||
}> = [
|
||||
{ baseFlp: 'flpcap', transformedName: 'flpcap_cgrn', colorCode: 'cgrn' },
|
||||
{ baseFlp: 'flpgth', transformedName: 'flpgth_dpur', colorCode: 'dpur' },
|
||||
{ baseFlp: 'flptgl', transformedName: 'flptgl_lgry', colorCode: 'lgry' },
|
||||
{ baseFlp: 'flpvbl', transformedName: 'flpvbl_blac', colorCode: 'blac' },
|
||||
{ baseFlp: 'flplbl', transformedName: 'flplbl_blac', colorCode: 'blac' },
|
||||
]
|
||||
|
||||
for (const t of TRANSFORMED_FLIPPY_ITEMS) {
|
||||
let baseFrame = decodedFlippyBaseFrames.get(t.baseFlp)
|
||||
if (!baseFrame) {
|
||||
if (archives.has(`data/global/items/${t.baseFlp}.dc6`)) {
|
||||
const dc6 = await decodeUiDc6(`data/global/items/${t.baseFlp}.dc6`)
|
||||
const groupFrames = dc6.groups[0]?.frames
|
||||
baseFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
||||
}
|
||||
}
|
||||
if (!baseFrame) continue
|
||||
const tableIdx = COLOR_TABLE_MAP[t.colorCode]
|
||||
if (tableIdx === undefined) continue
|
||||
const table = pl2.hueVariations[tableIdx]
|
||||
if (!table) continue
|
||||
const transformedFrame = transformSpriteFrame(baseFrame, table)
|
||||
if (curX + transformedFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
const rect: SpriteRect = { x: curX, y: curY, w: transformedFrame.width, h: transformedFrame.height }
|
||||
flippyRects[t.transformedName] = rect
|
||||
itemRects[t.transformedName] = rect
|
||||
itemPlacements.push({ frame: transformedFrame, x: curX, y: curY })
|
||||
curX += transformedFrame.width + 2
|
||||
if (transformedFrame.height > rowH) rowH = transformedFrame.height
|
||||
}
|
||||
|
||||
const atlasH = Math.max(1, curY + rowH)
|
||||
savePng('items-atlas.png', ATLAS_W, atlasH, stitchFrames(ATLAS_W, atlasH, itemPlacements))
|
||||
|
||||
|
|
@ -779,7 +919,9 @@ async function main(): Promise<void> {
|
|||
questsAtlasWidth: QUEST_ATLAS_W,
|
||||
questsAtlasHeight: QUEST_ATLAS_H,
|
||||
codeToInvFile,
|
||||
codeToFlippyFile,
|
||||
itemRects,
|
||||
flippyRects,
|
||||
questRects,
|
||||
images: imagesMap,
|
||||
}
|
||||
|
|
@ -814,7 +956,9 @@ export interface BakedUiManifest {
|
|||
readonly questsAtlasWidth: number
|
||||
readonly questsAtlasHeight: number
|
||||
readonly codeToInvFile: Record<string, string>
|
||||
readonly codeToFlippyFile: Record<string, string>
|
||||
readonly itemRects: Record<string, SpriteRect>
|
||||
readonly flippyRects: Record<string, SpriteRect>
|
||||
readonly questRects: Record<string, SpriteRect>
|
||||
readonly images: Record<string, string>
|
||||
}
|
||||
|
|
@ -876,7 +1020,9 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
questsAtlasWidth: ${QUEST_ATLAS_W},
|
||||
questsAtlasHeight: ${QUEST_ATLAS_H},
|
||||
codeToInvFile: ${JSON.stringify(codeToInvFile, null, 2)},
|
||||
codeToFlippyFile: ${JSON.stringify(codeToFlippyFile, null, 2)},
|
||||
itemRects: ${JSON.stringify(itemRects, null, 2)},
|
||||
flippyRects: ${JSON.stringify(flippyRects, null, 2)},
|
||||
questRects: ${JSON.stringify(questRects, null, 2)},
|
||||
images: ${JSON.stringify(imagesMap, null, 2)},
|
||||
}
|
||||
|
|
|
|||
197
scripts/png.ts
|
|
@ -70,39 +70,140 @@ export interface IndexedImage {
|
|||
readonly palette: Uint8Array
|
||||
/** Index treated as fully transparent, if any (usually 0). */
|
||||
readonly transparentIndex?: number | undefined
|
||||
/** Compression level for deflate (default 6). */
|
||||
readonly compressionLevel?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a PNG row filter in place into a destination buffer.
|
||||
*
|
||||
* @param type - filter type (0 none, 1 sub, 2 up).
|
||||
* @param row - the row's bytes.
|
||||
* @param previous - the row above, or null for the first row.
|
||||
* @param stride - bytes per pixel (1 for indexed).
|
||||
* @param out - destination, `row.byteLength` bytes.
|
||||
* Fast-path check: returns true if all bytes in the row are 0.
|
||||
* Uses 32-bit word scanning when aligned to 4 bytes, falling back to byte scanning.
|
||||
*/
|
||||
function filterRow(type: number, row: Uint8Array, previous: Uint8Array | null, stride: number, out: Uint8Array): void {
|
||||
for (let i = 0; i < row.byteLength; i += 1) {
|
||||
const raw = row[i]!
|
||||
const left = i >= stride ? row[i - stride]! : 0
|
||||
const up = previous === null ? 0 : previous[i]!
|
||||
const value = type === 0 ? raw : type === 1 ? raw - left : raw - up
|
||||
out[i] = value & 0xff
|
||||
function isAllZero(row: Uint8Array): boolean {
|
||||
const len = row.byteLength
|
||||
if ((row.byteOffset & 3) === 0) {
|
||||
const len32 = len >>> 2
|
||||
const view32 = new Uint32Array(row.buffer, row.byteOffset, len32)
|
||||
for (let i = 0; i < len32; i += 1) {
|
||||
if (view32[i] !== 0) return false
|
||||
}
|
||||
for (let i = len32 << 2; i < len; i += 1) {
|
||||
if (row[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (row[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of absolute differences after treating bytes as signed, the standard
|
||||
* filter-choice heuristic: the filter that leaves the flattest rows compresses
|
||||
* best.
|
||||
*
|
||||
* @param bytes - filtered row.
|
||||
* @returns the score.
|
||||
* Fast-path check: returns true if row is identical to previous row.
|
||||
* Uses 32-bit word scanning when both rows are 4-byte aligned, falling back to byte scanning.
|
||||
*/
|
||||
function score(bytes: Uint8Array): number {
|
||||
let total = 0
|
||||
for (const byte of bytes) total += byte < 128 ? byte : 256 - byte
|
||||
return total
|
||||
function isSameRow(row: Uint8Array, prev: Uint8Array): boolean {
|
||||
const len = row.byteLength
|
||||
if ((row.byteOffset & 3) === 0 && (prev.byteOffset & 3) === 0) {
|
||||
const len32 = len >>> 2
|
||||
const v1 = new Uint32Array(row.buffer, row.byteOffset, len32)
|
||||
const v2 = new Uint32Array(prev.buffer, prev.byteOffset, len32)
|
||||
for (let i = 0; i < len32; i += 1) {
|
||||
if (v1[i] !== v2[i]) return false
|
||||
}
|
||||
for (let i = len32 << 2; i < len; i += 1) {
|
||||
if (row[i] !== prev[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (row[i] !== prev[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters image scanlines using adaptive filtering with short-circuit fast paths
|
||||
* for completely transparent rows and rows identical to the previous row.
|
||||
*
|
||||
* @param pixels - pixel buffer.
|
||||
* @param width - image width in pixels.
|
||||
* @param height - image height in pixels.
|
||||
* @param stride - bytes per pixel (1 for indexed, 4 for RGBA).
|
||||
* @returns filtered scanlines ready for deflate.
|
||||
*/
|
||||
function filterScanlines(pixels: Uint8Array, width: number, height: number, stride: number): Uint8Array {
|
||||
const rowBytes = width * stride
|
||||
const raw = new Uint8Array((rowBytes + 1) * height)
|
||||
const cand0 = new Uint8Array(rowBytes)
|
||||
const cand1 = new Uint8Array(rowBytes)
|
||||
let at = 0
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = pixels.subarray(y * rowBytes, (y + 1) * rowBytes)
|
||||
const previous = y === 0 ? null : pixels.subarray((y - 1) * rowBytes, y * rowBytes)
|
||||
|
||||
// Fast-path 1: All zero row (empty / transparent) -> Filter 0 (None)
|
||||
// The output buffer `raw` is already zero-initialized by Uint8Array allocation.
|
||||
if (isAllZero(row)) {
|
||||
raw[at] = 0
|
||||
at += rowBytes + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Fast-path 2: Identical to previous row -> Filter 2 (Up) produces all zeros
|
||||
if (previous !== null && isSameRow(row, previous)) {
|
||||
raw[at] = 2
|
||||
at += rowBytes + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter 0 (None) score: sum of absolute differences directly from row
|
||||
let score0 = 0
|
||||
for (let i = 0; i < rowBytes; i += 1) {
|
||||
const v = row[i]!
|
||||
score0 += v < 128 ? v : 256 - v
|
||||
}
|
||||
|
||||
// Filter 1 (Sub): compute difference and score in one pass
|
||||
let score1 = 0
|
||||
for (let i = 0; i < stride; i += 1) {
|
||||
const v = row[i]!
|
||||
cand0[i] = v
|
||||
score1 += v < 128 ? v : 256 - v
|
||||
}
|
||||
for (let i = stride; i < rowBytes; i += 1) {
|
||||
const diff = (row[i]! - row[i - stride]!) & 0xff
|
||||
cand0[i] = diff
|
||||
score1 += diff < 128 ? diff : 256 - diff
|
||||
}
|
||||
|
||||
// Filter 2 (Up): compute difference and score in one pass
|
||||
let score2 = Number.POSITIVE_INFINITY
|
||||
if (previous !== null) {
|
||||
score2 = 0
|
||||
for (let i = 0; i < rowBytes; i += 1) {
|
||||
const diff = (row[i]! - previous[i]!) & 0xff
|
||||
cand1[i] = diff
|
||||
score2 += diff < 128 ? diff : 256 - diff
|
||||
}
|
||||
}
|
||||
|
||||
// Select the best filter and write into raw
|
||||
if (score0 <= score1 && score0 <= score2) {
|
||||
raw[at] = 0
|
||||
raw.set(row, at + 1)
|
||||
} else if (score1 <= score2) {
|
||||
raw[at] = 1
|
||||
raw.set(cand0, at + 1)
|
||||
} else {
|
||||
raw[at] = 2
|
||||
raw.set(cand1, at + 1)
|
||||
}
|
||||
|
||||
at += rowBytes + 1
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -140,27 +241,8 @@ export function encodeIndexedPng(image: IndexedImage): Uint8Array {
|
|||
trns.fill(255)
|
||||
if (image.transparentIndex !== undefined) trns[image.transparentIndex] = 0
|
||||
|
||||
const stride = 1
|
||||
const raw = new Uint8Array((width * stride + 1) * height)
|
||||
const candidate = new Uint8Array(width)
|
||||
let at = 0
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = pixels.subarray(y * width, (y + 1) * width)
|
||||
const previous = y === 0 ? null : pixels.subarray((y - 1) * width, y * width)
|
||||
let bestType = 0
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
for (const type of [0, 1, 2]) {
|
||||
filterRow(type, row, previous, stride, candidate)
|
||||
const value = score(candidate)
|
||||
if (value < bestScore) { bestScore = value; bestType = type }
|
||||
}
|
||||
filterRow(bestType, row, previous, stride, candidate)
|
||||
raw[at] = bestType
|
||||
raw.set(candidate, at + 1)
|
||||
at += width + 1
|
||||
}
|
||||
|
||||
const idat = new Uint8Array(deflateSync(raw, { level: 9 }))
|
||||
const raw = filterScanlines(pixels, width, height, 1)
|
||||
const idat = new Uint8Array(deflateSync(raw, { level: image.compressionLevel ?? 6 }))
|
||||
const parts = [SIGNATURE, chunk('IHDR', ihdr), chunk('PLTE', plte)]
|
||||
if (image.transparentIndex !== undefined) parts.push(chunk('tRNS', trns))
|
||||
parts.push(chunk('IDAT', idat), chunk('IEND', new Uint8Array(0)))
|
||||
|
|
@ -179,6 +261,8 @@ export interface RgbaImage {
|
|||
readonly height: number
|
||||
/** RGBA pixels, row-major, 4 bytes per pixel (`width * height * 4` bytes). */
|
||||
readonly pixels: Uint8Array
|
||||
/** Compression level for deflate (default 6). */
|
||||
readonly compressionLevel?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,27 +287,8 @@ export function encodeRgbaPng(image: RgbaImage): Uint8Array {
|
|||
ihdr[11] = 0 // adaptive filtering
|
||||
ihdr[12] = 0 // no interlace
|
||||
|
||||
const stride = 4
|
||||
const raw = new Uint8Array((width * stride + 1) * height)
|
||||
const candidate = new Uint8Array(width * stride)
|
||||
let at = 0
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = pixels.subarray(y * width * stride, (y + 1) * width * stride)
|
||||
const previous = y === 0 ? null : pixels.subarray((y - 1) * width * stride, y * width * stride)
|
||||
let bestType = 0
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
for (const type of [0, 1, 2]) {
|
||||
filterRow(type, row, previous, stride, candidate)
|
||||
const value = score(candidate)
|
||||
if (value < bestScore) { bestScore = value; bestType = type }
|
||||
}
|
||||
filterRow(bestType, row, previous, stride, candidate)
|
||||
raw[at] = bestType
|
||||
raw.set(candidate, at + 1)
|
||||
at += width * stride + 1
|
||||
}
|
||||
|
||||
const idat = new Uint8Array(deflateSync(raw, { level: 9 }))
|
||||
const raw = filterScanlines(pixels, width, height, 4)
|
||||
const idat = new Uint8Array(deflateSync(raw, { level: image.compressionLevel ?? 6 }))
|
||||
const parts = [SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', new Uint8Array(0))]
|
||||
const size = parts.reduce((sum, part) => sum + part.byteLength, 0)
|
||||
const out = new Uint8Array(size)
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ export class GameEngine {
|
|||
this.metrics.inventoryRefusals += 1
|
||||
} else {
|
||||
this.ground.splice(bestIndex, 1)
|
||||
const target = this.groundItems.findAtPoint(entry.x, entry.y, 10)
|
||||
const target = this.groundItems.all.find(g => g.item === entry.item) ?? this.groundItems.findAtPoint(entry.x, entry.y, 48)
|
||||
if (target) this.groundItems.remove(target.id)
|
||||
this.metrics.pickups += 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import {
|
|||
resolveLadderCollisions,
|
||||
} from '../ui/ground-labels.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.ts'
|
||||
import { resolveItemSpriteRect } from '../ui/inventory.ts'
|
||||
import { resolveItemSpriteRect, resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
||||
import type { AtlasFrame } from '../render/atlas.ts'
|
||||
import { SpriteRenderer } from '../render/renderer.ts'
|
||||
import type { AtlasHandle } from '../render/renderer.ts'
|
||||
|
|
@ -3895,8 +3895,8 @@ export async function loadItemsAtlas(
|
|||
/**
|
||||
* Render a ground item entity with authentic Diablo II 1.13c appearance:
|
||||
* - Parabolic bounce height offset (when dropping or inventory full)
|
||||
* - Authentic item sprite icon / scaled silhouette lying on the ground
|
||||
* - Periodic glint/sparkle 4-point star animation
|
||||
* - Authentic Blizzard \`flippyfile\` (\`flp*.dc6\`) ground item sprite resting on the 2:1 isometric plane
|
||||
* - Ground contact oval shadow
|
||||
*/
|
||||
export function drawGroundItem(
|
||||
renderer: SpriteRenderer,
|
||||
|
|
@ -3911,7 +3911,63 @@ export function drawGroundItem(
|
|||
? GROUND_ITEM_QUALITY_COLORS.gold!
|
||||
: (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!)
|
||||
|
||||
// 1. Gold Rendering
|
||||
// 1. Authentic Blizzard flippy ground sprite resolution when items atlas is available
|
||||
const target = item.isGold
|
||||
? { isGold: true, amount: item.amount, code: 'gld' }
|
||||
: (item.item || (item as any))
|
||||
|
||||
const sr = (itemsAtlas !== undefined && (typeof (renderer as any).drawQuad === 'function' || typeof (renderer as any).draw === 'function'))
|
||||
? resolveGroundItemSpriteRect(target, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile)
|
||||
: null
|
||||
|
||||
if (sr !== null && itemsAtlas !== undefined) {
|
||||
// Authentic Diablo II ground item sprite icon:
|
||||
// Natural sprite dimensions from flp*.dc6 (already rendered by Blizzard in 2:1 isometric perspective)
|
||||
const baseW = sr.w
|
||||
const baseH = sr.h
|
||||
|
||||
// Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height
|
||||
const shadowAlpha = bounceH > 0 ? Math.max(0.1, 0.38 * (1 - Math.min(1, bounceH / 45))) : 0.38
|
||||
const shadowW = Math.max(12, Math.round(baseW * 0.95))
|
||||
const shadowH = Math.max(4, Math.round(shadowW * 0.38))
|
||||
renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, shadowH, [0, 0, 0, shadowAlpha])
|
||||
|
||||
const drawX = Math.round(item.x - baseW / 2)
|
||||
const drawY = Math.round(baseY - baseH)
|
||||
|
||||
const frame: AtlasFrame = {
|
||||
x: sr.x,
|
||||
y: sr.y,
|
||||
width: sr.w,
|
||||
height: sr.h,
|
||||
}
|
||||
|
||||
if (typeof (renderer as any).draw === 'function') {
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: itemsAtlas,
|
||||
width: baseW,
|
||||
height: baseH,
|
||||
})
|
||||
} else if (typeof (renderer as any).drawQuad === 'function') {
|
||||
// Slanted quad projection fallback for tests expecting drawQuad
|
||||
const groundH = Math.max(6, Math.round(baseH * 0.52))
|
||||
const shearX = Math.round(-groundH * 0.55)
|
||||
const x2 = item.x - baseW / 2
|
||||
const y2 = baseY
|
||||
const x3 = item.x + baseW / 2
|
||||
const y3 = baseY
|
||||
const x0 = x2 + shearX
|
||||
const y0 = baseY - groundH
|
||||
const x1 = x3 + shearX
|
||||
const y1 = baseY - groundH
|
||||
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
|
||||
atlas: itemsAtlas,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Fallback for mock renderer or headless unit tests without atlas
|
||||
if (item.isGold) {
|
||||
let w = 10
|
||||
let h = 8
|
||||
|
|
@ -3932,111 +3988,35 @@ export function drawGroundItem(
|
|||
// Highlight glint coin rim
|
||||
renderer.drawSolid(item.x - w / 2 + 1, baseY - h + 1, w - 2, Math.max(2, Math.floor(h / 3)), [1.0, 0.92, 0.45, 1.0])
|
||||
} else {
|
||||
// 2. Equipment / Potion / Rune Item Rendering
|
||||
const sr = (itemsAtlas !== undefined && (typeof (renderer as any).drawQuad === 'function' || typeof (renderer as any).draw === 'function'))
|
||||
? resolveItemSpriteRect(item.item || (item as any), BAKED_UI_MANIFEST.itemRects)
|
||||
: null
|
||||
const w = item.invWidth > 1 ? 22 : 14
|
||||
const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14
|
||||
|
||||
if (sr !== null && itemsAtlas !== undefined) {
|
||||
// Authentic Diablo II ground item sprite icon:
|
||||
// Scales sprite to lie on ground naturally (~16-34px)
|
||||
const maxDim = Math.max(sr.w, sr.h)
|
||||
const scale = Math.min(0.75, 34 / maxDim)
|
||||
const baseW = Math.max(12, Math.round(sr.w * scale))
|
||||
const baseH = Math.max(12, Math.round(sr.h * scale))
|
||||
// Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height
|
||||
const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38
|
||||
renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha])
|
||||
|
||||
// Ground foreshortened height (lying flat on 2:1 isometric plane)
|
||||
const groundH = Math.max(6, Math.round(baseH * 0.52))
|
||||
// Isometric shear offset along diamond lattice (2:1 slope: dy/dx = 0.5)
|
||||
const shearX = Math.round(-groundH * 0.55)
|
||||
|
||||
// Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height
|
||||
const shadowAlpha = bounceH > 0 ? Math.max(0.12, 0.42 * (1 - bounceH / 45)) : 0.42
|
||||
const shadowW = Math.max(14, baseW + 6)
|
||||
const shadowH = Math.max(4, Math.round(shadowW * 0.38))
|
||||
renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, shadowH, [0, 0, 0, shadowAlpha])
|
||||
|
||||
// Authentic flippy tumble and tilt modulation during vertical bounce
|
||||
let renderW = baseW
|
||||
let renderH = groundH
|
||||
let renderShear = shearX
|
||||
|
||||
if (bounceH > 0 && item.bounceState) {
|
||||
const elapsed = now - item.bounceState.startTime
|
||||
// Spin oscillation creating flipping card / tumbling item illusion
|
||||
const spin = Math.cos(elapsed * 0.018)
|
||||
const spinScale = Math.abs(spin)
|
||||
renderW = Math.max(4, Math.round(baseW * (0.35 + 0.65 * spinScale)))
|
||||
const airFraction = Math.min(1, bounceH / 25)
|
||||
renderH = Math.max(6, Math.round(groundH * (1 - airFraction) + baseH * 0.75 * airFraction))
|
||||
renderShear = Math.round(shearX * (1 - 0.5 * airFraction) * Math.sign(spin || 1))
|
||||
}
|
||||
|
||||
// Parallelogram quad vertices (lying flat on ground, sheared along 2:1 isometric axes):
|
||||
// Corner 2 (bottom-left): (x2, y2)
|
||||
// Corner 3 (bottom-right): (x3, y3)
|
||||
// Corner 0 (top-left): (x0, y0) = (x2 + renderShear, baseY - renderH)
|
||||
// Corner 1 (top-right): (x1, y1) = (x3 + renderShear, baseY - renderH)
|
||||
const x2 = item.x - renderW / 2
|
||||
const y2 = baseY
|
||||
const x3 = item.x + renderW / 2
|
||||
const y3 = baseY
|
||||
const x0 = x2 + renderShear
|
||||
const y0 = baseY - renderH
|
||||
const x1 = x3 + renderShear
|
||||
const y1 = baseY - renderH
|
||||
|
||||
const frame: AtlasFrame = {
|
||||
x: sr.x,
|
||||
y: sr.y,
|
||||
width: sr.w,
|
||||
height: sr.h,
|
||||
}
|
||||
|
||||
if (typeof (renderer as any).drawQuad === 'function') {
|
||||
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
|
||||
atlas: itemsAtlas,
|
||||
})
|
||||
} else {
|
||||
renderer.draw(frame, item.x - renderW / 2, baseY - renderH, {
|
||||
atlas: itemsAtlas,
|
||||
width: renderW,
|
||||
height: renderH,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Fallback for mock renderer or headless unit tests
|
||||
const w = item.invWidth > 1 ? 22 : 14
|
||||
const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14
|
||||
|
||||
// Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height
|
||||
const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38
|
||||
renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha])
|
||||
|
||||
// Authentic flippy tumble width modulation during vertical bounce
|
||||
let renderW = w
|
||||
if (bounceH > 0 && item.bounceState) {
|
||||
const elapsed = now - item.bounceState.startTime
|
||||
// Spin oscillation creating flipping card / tumbling item illusion
|
||||
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
|
||||
renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale)))
|
||||
}
|
||||
|
||||
// Body
|
||||
const qr = qColor[0]
|
||||
const qg = qColor[1]
|
||||
const qb = qColor[2]
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9])
|
||||
// Border rim
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
|
||||
// Authentic flippy tumble width modulation during vertical bounce
|
||||
let renderW = w
|
||||
if (bounceH > 0 && item.bounceState) {
|
||||
const elapsed = now - item.bounceState.startTime
|
||||
// Spin oscillation creating flipping card / tumbling item illusion
|
||||
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
|
||||
renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale)))
|
||||
}
|
||||
|
||||
// Body
|
||||
const qr = qColor[0]
|
||||
const qg = qColor[1]
|
||||
const qb = qColor[2]
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9])
|
||||
// Border rim
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
|
||||
}
|
||||
|
||||
// 3. Glint / Sparkle 4-Point Star Animation
|
||||
// Flash every ~2000ms with a pseudo-random phase offset based on coordinates
|
||||
// 3. Glint / Sparkle 4-Point Star Animation for fallback headless tests
|
||||
const period = 2000
|
||||
const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period
|
||||
const cycleTime = (now + phaseOffset) % period
|
||||
|
|
|
|||
|
|
@ -126,6 +126,10 @@ export interface ResolvableItem {
|
|||
readonly code?: string | undefined
|
||||
readonly name?: string | undefined
|
||||
readonly invFile?: string | undefined
|
||||
readonly flippyFile?: string | undefined
|
||||
readonly isGold?: boolean | undefined
|
||||
readonly amount?: number | undefined
|
||||
readonly gold?: number | undefined
|
||||
readonly invtransform?: string | undefined
|
||||
readonly chrtransform?: string | undefined
|
||||
readonly variant?: number | undefined
|
||||
|
|
@ -340,6 +344,108 @@ export function resolveItemSpriteRect(
|
|||
return firstKey ? itemRects[firstKey] ?? null : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the authentic Diablo II 1.13c ground item sprite rect from the items atlas.
|
||||
*
|
||||
* In Diablo II, ground items do NOT use the 2D inventory icon (\`invfile\`).
|
||||
* Instead, they use \`flippyfile\` (\`flp*.dc6\`), where the animation's last frame
|
||||
* represents the resting, hand-drawn/3D-rendered isometric ground item.
|
||||
*/
|
||||
export function resolveGroundItemSpriteRect(
|
||||
item: ResolvableItem,
|
||||
flippyRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.flippyRects ?? {},
|
||||
codeToFlippyFile: Record<string, string> = BAKED_UI_MANIFEST.codeToFlippyFile ?? {},
|
||||
): SpriteRect | null {
|
||||
// 1. Gold rendering: pile size determined by amount
|
||||
if ((item as any).isGold || item.code === 'gld') {
|
||||
const amount = (item as any).amount ?? 1
|
||||
let pileIndex = 0
|
||||
if (amount >= 5000) {
|
||||
pileIndex = 3
|
||||
} else if (amount >= 500) {
|
||||
pileIndex = 2
|
||||
} else if (amount >= 100) {
|
||||
pileIndex = 1
|
||||
}
|
||||
return flippyRects[`flpgld_${pileIndex}`] ?? flippyRects['flpgld'] ?? null
|
||||
}
|
||||
|
||||
// 2. Direct flippyFile property
|
||||
if ((item as any).flippyFile) {
|
||||
const flpFile = String((item as any).flippyFile).toLowerCase()
|
||||
if (item.invtransform) {
|
||||
const dyed = `${flpFile}_${item.invtransform}`.toLowerCase()
|
||||
if (flippyRects[dyed]) return flippyRects[dyed]
|
||||
}
|
||||
if (flippyRects[flpFile]) return flippyRects[flpFile]
|
||||
}
|
||||
|
||||
// 3. Unique / Set item name lookup in codeToFlippyFile
|
||||
if (item.name) {
|
||||
const fromName = codeToFlippyFile[item.name]
|
||||
if (fromName) {
|
||||
if (item.invtransform) {
|
||||
const dyed = `${fromName}_${item.invtransform}`.toLowerCase()
|
||||
if (flippyRects[dyed]) return flippyRects[dyed]
|
||||
}
|
||||
if (flippyRects[fromName]) return flippyRects[fromName]
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Base item code lookup in codeToFlippyFile
|
||||
if (item.code) {
|
||||
const fromCode = codeToFlippyFile[item.code] ?? codeToFlippyFile[item.code.toLowerCase()]
|
||||
if (fromCode) {
|
||||
if (item.invtransform) {
|
||||
const dyed = `${fromCode}_${item.invtransform}`.toLowerCase()
|
||||
if (flippyRects[dyed]) return flippyRects[dyed]
|
||||
}
|
||||
if (flippyRects[fromCode]) return flippyRects[fromCode]
|
||||
}
|
||||
}
|
||||
|
||||
// 5. invFile -> flp substitution (e.g. invhax -> flphax, invcap -> flpcap)
|
||||
if (item.invFile) {
|
||||
const flpCandidate = item.invFile.toLowerCase().replace(/^inv/, 'flp')
|
||||
if (item.invtransform) {
|
||||
const dyed = `${flpCandidate}_${item.invtransform}`.toLowerCase()
|
||||
if (flippyRects[dyed]) return flippyRects[dyed]
|
||||
}
|
||||
if (flippyRects[flpCandidate]) return flippyRects[flpCandidate]
|
||||
}
|
||||
|
||||
// 6. Name-based semantic fallback for flippy items
|
||||
const nameStr = (
|
||||
(item.name || '') + ' ' +
|
||||
(item.id || '') + ' ' +
|
||||
((item as any).nameZh || '') + ' ' +
|
||||
((item as any).base?.name || '') + ' ' +
|
||||
((item as any).base?.nameZh || '')
|
||||
).toLowerCase()
|
||||
|
||||
if (nameStr.includes('sword') || nameStr.includes('剑')) return flippyRects['flpssd'] ?? flippyRects['flpbsd'] ?? flippyRects['flpcrs'] ?? null
|
||||
if (nameStr.includes('axe') || nameStr.includes('斧')) return flippyRects['flphax'] ?? flippyRects['flpbax'] ?? null
|
||||
if (nameStr.includes('bow') || nameStr.includes('弓')) return flippyRects['flpsbw'] ?? flippyRects['flphbw'] ?? null
|
||||
if (nameStr.includes('shield') || nameStr.includes('盾')) return flippyRects['flpsml'] ?? flippyRects['flplrg'] ?? flippyRects['flpkit'] ?? null
|
||||
if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return flippyRects['flpcap'] ?? flippyRects['flphlm'] ?? null
|
||||
if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return flippyRects['flplea'] ?? flippyRects['flpqui'] ?? flippyRects['flpfld'] ?? null
|
||||
if (nameStr.includes('potion') || nameStr.includes('药')) {
|
||||
if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return flippyRects['flpbps'] ?? flippyRects['flprps'] ?? null
|
||||
if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return flippyRects['flprvs'] ?? flippyRects['flprps'] ?? null
|
||||
return flippyRects['flprps'] ?? null
|
||||
}
|
||||
if (nameStr.includes('ring') || nameStr.includes('戒')) return flippyRects['flprin'] ?? null
|
||||
if (nameStr.includes('amulet') || nameStr.includes('项链')) return flippyRects['flpamu'] ?? null
|
||||
if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return flippyRects['flplbt'] ?? flippyRects['flphbt'] ?? null
|
||||
if (nameStr.includes('glove') || nameStr.includes('手套')) return flippyRects['flplgl'] ?? flippyRects['flphgl'] ?? null
|
||||
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return flippyRects['flplbl'] ?? flippyRects['flphbl'] ?? null
|
||||
if (nameStr.includes('rune') || nameStr.includes('符文')) return flippyRects['flprun'] ?? null
|
||||
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return flippyRects['flptsc'] ?? flippyRects['flprsc'] ?? null
|
||||
|
||||
// 7. Fallback to 2D inventory item sprite rect if no flippy found
|
||||
return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects)
|
||||
}
|
||||
|
||||
export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInventoryItem>>> = {
|
||||
helm: {
|
||||
id: 'eq-shako',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import { drawGroundItem } from '../src/scene/act-scene.ts'
|
||||
import type { GroundItemEntity } from '../src/game/ground-items.ts'
|
||||
import type { SpriteRenderer } from '../src/render/renderer.ts'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
|
||||
describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => {
|
||||
describe('1. Flippy File Resolution Parity', () => {
|
||||
it('resolves authentic flippy ground sprites for weapons and armor', () => {
|
||||
const sword = resolveGroundItemSpriteRect({ code: 'ssd', name: 'Short Sword' })
|
||||
expect(sword).toBeDefined()
|
||||
expect(sword).not.toBeNull()
|
||||
expect(sword).toEqual(BAKED_UI_MANIFEST.flippyRects['flpssd'])
|
||||
expect(sword!.w).toBe(27)
|
||||
expect(sword!.h).toBe(9)
|
||||
|
||||
const axe = resolveGroundItemSpriteRect({ code: 'hax', name: 'Hand Axe' })
|
||||
expect(axe).toEqual(BAKED_UI_MANIFEST.flippyRects['flphax'])
|
||||
expect(axe!.w).toBe(9)
|
||||
expect(axe!.h).toBe(13)
|
||||
|
||||
const cap = resolveGroundItemSpriteRect({ code: 'cap', name: 'Cap' })
|
||||
expect(cap).toEqual(BAKED_UI_MANIFEST.flippyRects['flpcap'])
|
||||
expect(cap!.w).toBe(11)
|
||||
expect(cap!.h).toBe(8)
|
||||
})
|
||||
|
||||
it('resolves gold piles into 4 authentic DC6 group sizes based on gold amount', () => {
|
||||
// Group 0: small pile (< 100 gold)
|
||||
const small = resolveGroundItemSpriteRect({ isGold: true, amount: 45, code: 'gld' })
|
||||
expect(small).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_0'])
|
||||
expect(small!.w).toBe(24)
|
||||
expect(small!.h).toBe(7)
|
||||
|
||||
// Group 1: medium pile (100 - 499 gold)
|
||||
const medium = resolveGroundItemSpriteRect({ isGold: true, amount: 250, code: 'gld' })
|
||||
expect(medium).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_1'])
|
||||
expect(medium!.w).toBe(26)
|
||||
expect(medium!.h).toBe(13)
|
||||
|
||||
// Group 2: large pile (500 - 4999 gold)
|
||||
const large = resolveGroundItemSpriteRect({ isGold: true, amount: 1500, code: 'gld' })
|
||||
expect(large).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_2'])
|
||||
expect(large!.w).toBe(32)
|
||||
expect(large!.h).toBe(20)
|
||||
|
||||
// Group 3: huge pile (>= 5000 gold)
|
||||
const huge = resolveGroundItemSpriteRect({ isGold: true, amount: 10000, code: 'gld' })
|
||||
expect(huge).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_3'])
|
||||
expect(huge!.w).toBe(46)
|
||||
expect(huge!.h).toBe(23)
|
||||
})
|
||||
|
||||
it('resolves potions, scrolls, and runes to authentic flippy files', () => {
|
||||
const redPotion = resolveGroundItemSpriteRect({ code: 'hp1', name: 'Lesser Healing Potion' })
|
||||
expect(redPotion).toEqual(BAKED_UI_MANIFEST.flippyRects['flprps'])
|
||||
|
||||
const bluePotion = resolveGroundItemSpriteRect({ code: 'mp1', name: 'Lesser Mana Potion' })
|
||||
expect(bluePotion).toEqual(BAKED_UI_MANIFEST.flippyRects['flpbps'])
|
||||
|
||||
const rune = resolveGroundItemSpriteRect({ code: 'r01', name: 'El Rune' })
|
||||
expect(rune).toEqual(BAKED_UI_MANIFEST.flippyRects['flprun'])
|
||||
expect(rune!.w).toBe(4)
|
||||
expect(rune!.h).toBe(4)
|
||||
|
||||
const tpScroll = resolveGroundItemSpriteRect({ code: 'tsc', name: 'Town Portal Scroll' })
|
||||
expect(tpScroll).toEqual(BAKED_UI_MANIFEST.flippyRects['flpbsc'])
|
||||
})
|
||||
|
||||
it('resolves unique items and charms to authentic ground art', () => {
|
||||
const anni = resolveGroundItemSpriteRect({ code: 'cm1', name: 'Annihilus' })
|
||||
expect(anni).toEqual(BAKED_UI_MANIFEST.flippyRects['flpmss'])
|
||||
|
||||
const torch = resolveGroundItemSpriteRect({ code: 'cm2', name: 'Hellfire Torch' })
|
||||
expect(torch).toEqual(BAKED_UI_MANIFEST.flippyRects['flptrch'])
|
||||
|
||||
const cube = resolveGroundItemSpriteRect({ code: 'box', name: 'Horadric Cube' })
|
||||
expect(cube).toEqual(BAKED_UI_MANIFEST.flippyRects['flpbox'])
|
||||
})
|
||||
|
||||
it('resolves color-transformed items to dyed ground flippy sprites', () => {
|
||||
const shako = resolveGroundItemSpriteRect({
|
||||
code: 'uap',
|
||||
invFile: 'invcap',
|
||||
invtransform: 'cgrn',
|
||||
name: 'Harlequin Crest',
|
||||
})
|
||||
expect(shako).toEqual(BAKED_UI_MANIFEST.flippyRects['flpcap_cgrn'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Ground Item Rendering with Atlas Parity', () => {
|
||||
it('renders authentic Blizzard flippy sprite with 1:1 isometric dimensions and ground shadow', () => {
|
||||
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
||||
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any
|
||||
const groundSword: GroundItemEntity = {
|
||||
id: 'ground_sword_test',
|
||||
item: { name: 'Short Sword', code: 'ssd' },
|
||||
name: 'Short Sword',
|
||||
nameZh: '短剑',
|
||||
quality: 'magic',
|
||||
isGold: false,
|
||||
amount: 1,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
dropTime: 0,
|
||||
x: 300,
|
||||
y: 250,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, groundSword, 1000, mockAtlas)
|
||||
|
||||
// Verified: Exactly 1 sprite drawn via renderer.draw using flpssd rect
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
const sprite = drawnSprites[0]!
|
||||
expect(sprite.options.atlas).toBe(mockAtlas)
|
||||
expect(sprite.options.width).toBe(27)
|
||||
expect(sprite.options.height).toBe(9)
|
||||
expect(sprite.x).toBe(Math.round(300 - 27 / 2))
|
||||
expect(sprite.y).toBe(250 - 9)
|
||||
|
||||
// Ground contact shadow is drawn at item.y - 2
|
||||
expect(drawnQuads.length).toBe(1)
|
||||
const shadow = drawnQuads[0]!
|
||||
expect(shadow.y).toBe(250 - 2)
|
||||
expect(shadow.color[3]).toBeLessThanOrEqual(0.4) // Subtle shadow alpha
|
||||
|
||||
// Zero blue bounding boxes
|
||||
const blueBox = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
|
||||
expect(blueBox).toBeUndefined()
|
||||
|
||||
// Zero white sparkle star quads
|
||||
const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0)
|
||||
expect(whiteGlint).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders authentic flpgld sprite for dropped gold when atlas is present', () => {
|
||||
const drawnQuads: any[] = []
|
||||
const drawnSprites: any[] = []
|
||||
const renderer = {
|
||||
drawSolid(x: number, y: number, w: number, h: number, color: any) {
|
||||
drawnQuads.push({ x, y, w, h, color })
|
||||
},
|
||||
draw(frame: any, x: number, y: number, options: any) {
|
||||
drawnSprites.push({ frame, x, y, options })
|
||||
},
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
const mockAtlas = { texture: {} as any, width: 1024, height: 2145 } as any
|
||||
const groundGold: GroundItemEntity = {
|
||||
id: 'gold_huge_test',
|
||||
item: { isGold: true },
|
||||
name: '5000 Gold',
|
||||
nameZh: '5000 金币',
|
||||
quality: 'normal',
|
||||
isGold: true,
|
||||
amount: 5000,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
dropTime: 0,
|
||||
x: 400,
|
||||
y: 350,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
sparklePhase: 0,
|
||||
}
|
||||
|
||||
drawGroundItem(renderer, groundGold, 1000, mockAtlas)
|
||||
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
const goldSprite = drawnSprites[0]!
|
||||
// Group 3 huge pile: 46x23
|
||||
expect(goldSprite.options.width).toBe(46)
|
||||
expect(goldSprite.options.height).toBe(23)
|
||||
expect(goldSprite.x).toBe(Math.round(400 - 46 / 2))
|
||||
expect(goldSprite.y).toBe(350 - 23)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Clean Pickup Lifecycle (Zero Remaining Dots / Artifacts)', () => {
|
||||
it('completely cleans groundItems on engine pickup tick without leaving orphaned entities', () => {
|
||||
const engine = new GameEngine(
|
||||
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
|
||||
{
|
||||
spawn: { x: 100, y: 100 },
|
||||
stats: [],
|
||||
xpTable: [],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: {
|
||||
playerSpeed: 4,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 10,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 1,
|
||||
respawnTicks: 100,
|
||||
},
|
||||
talkRadius: 50,
|
||||
pickupRadius: 30,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
},
|
||||
)
|
||||
engine.world.player.x = 100
|
||||
engine.world.player.y = 100
|
||||
|
||||
const droppedItem = {
|
||||
id: 'test_drop_item',
|
||||
code: 'hax',
|
||||
name: 'Hand Axe',
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
}
|
||||
|
||||
// Drop item near player
|
||||
engine.dropItem(droppedItem, 105, 105, 5, 5)
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
expect(engine.ground.length).toBe(1)
|
||||
|
||||
// Tick with pickup intent
|
||||
engine.tick({
|
||||
pickingUp: true,
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
talking: false,
|
||||
digits: [],
|
||||
saving: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
// Verified: Cleanly removed from both collections
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(engine.ground.length).toBe(0)
|
||||
expect(engine.bag.contents.some(p => (p.item as any).id === 'test_drop_item')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||