From 263286c0c2e98afa18dfe4cf654a3ee8b5bd2b46 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 06:23:21 +0000 Subject: [PATCH] fix(render): isometric grid drop dispersal and authentic ground slanted sprite perspective --- src/game/engine.ts | 30 ++++++--- src/game/ground-items.ts | 81 +++++++++++++++++++++++ src/render/renderer.ts | 104 ++++++++++++++++++++++++++++-- src/scene/act-scene.ts | 52 ++++++++++++--- tests/ground-items-drop.test.ts | 81 +++++++++++++++++++++++ tests/ground-items-render.test.ts | 47 ++++++++++++++ 6 files changed, 371 insertions(+), 24 deletions(-) diff --git a/src/game/engine.ts b/src/game/engine.ts index 235125b..1f343d6 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -14,7 +14,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 } from './ground-items.ts' +import { GroundItemManager, triggerFlippyBounce, findIsometricDropPosition } from './ground-items.ts' import type { GroundItemEntity, GroundItemSource } from './ground-items.ts' export interface EngineInput { @@ -551,8 +551,10 @@ export class GameEngine { if (drop.base.id === 'gold' || drop.code?.trim() === 'gld') { this.bag.add(drop) } else { - this.ground.push({ x: event.x, y: event.y, item: drop }) - this.groundItems.add(drop, 0, 0, event.x, event.y, { bounce: true }) + const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) + const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) + this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) + this.groundItems.add(drop, 0, 0, dropPos.x, dropPos.y, { bounce: true }) this.metrics.lastDrops.push(drop.name) if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } @@ -568,8 +570,10 @@ export class GameEngine { if (drop.kind === 'gold') { this.bag.add(goldItem(drop.amount)) } else if (drop.kind === 'item') { - this.ground.push({ x: event.x, y: event.y, item: drop.item }) - this.groundItems.add(drop.item, 0, 0, event.x, event.y, { bounce: true }) + const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) + const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) + this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop.item }) + this.groundItems.add(drop.item, 0, 0, dropPos.x, dropPos.y, { bounce: true }) this.metrics.lastDrops.push(drop.item.name) if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } @@ -680,8 +684,12 @@ export class GameEngine { * Drop an item onto the ground at target coordinates. */ dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity { - const worldX = x ?? this.world.player.x - const worldY = y ?? this.world.player.y + const rawX = x ?? this.world.player.x + const rawY = y ?? this.world.player.y + const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) + const dropPos = findIsometricDropPosition(rawX, rawY, this.groundItems.all, isBlocked) + const worldX = dropPos.x + const worldY = dropPos.y const cx = cellX ?? 0 const cy = cellY ?? 0 const entity = this.groundItems.add(item, cx, cy, worldX, worldY, { bounce: true }) @@ -694,8 +702,12 @@ export class GameEngine { */ dropGold(amount: number, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity | null { if (amount <= 0) return null - const worldX = x ?? this.world.player.x - const worldY = y ?? this.world.player.y + const rawX = x ?? this.world.player.x + const rawY = y ?? this.world.player.y + const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) + const dropPos = findIsometricDropPosition(rawX, rawY, this.groundItems.all, isBlocked) + const worldX = dropPos.x + const worldY = dropPos.y const cx = cellX ?? 0 const cy = cellY ?? 0 const entity = this.groundItems.add( diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index a3fb43c..7847749 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -243,6 +243,87 @@ export function findSafeDropPosition( } } +/** + * Find an unoccupied isometric grid slot around an origin coordinate. + * + * In Diablo II v1.13c (D2Common.dll ITEM_Drop 0x6FD40000), items dropped + * on the ground never stack at the exact same pixel coordinates. + * Instead, they disperse across the 2:1 isometric diamond grid lattice. + * + * @param originX - epicenter X in world coordinates. + * @param originY - epicenter Y in world coordinates. + * @param existingItems - array of existing ground items to avoid overlapping with. + * @param isBlocked - optional collision callback (returns true if world coordinates are blocked). + * @param stepX - isometric horizontal lattice step (default ORTHO_CELL_WIDTH = 24). + * @param stepY - isometric vertical lattice step (default ORTHO_CELL_HEIGHT = 12). + * @param maxRings - max search rings (default 5). + * @param minSpacing - minimum distance in pixels between ground items (default 20). + */ +export function findIsometricDropPosition( + originX: number, + originY: number, + existingItems: readonly { x: number; y: number }[] = [], + isBlocked?: (x: number, y: number) => boolean, + stepX = ORTHO_CELL_WIDTH, + stepY = ORTHO_CELL_HEIGHT, + maxRings = 5, + minSpacing = 20, +): { x: number; y: number } { + const candidates: { x: number; y: number; distSq: number }[] = [] + for (let di = -maxRings; di <= maxRings; di += 1) { + for (let dj = -maxRings; dj <= maxRings; dj += 1) { + const cx = originX + (di - dj) * stepX + const cy = originY + (di + dj) * stepY + const distSq = (cx - originX) * (cx - originX) + (cy - originY) * (cy - originY) + candidates.push({ x: cx, y: cy, distSq }) + } + } + + // Sort by Euclidean distance from origin ascending + candidates.sort((a, b) => a.distSq - b.distSq) + + const minSpacingSq = minSpacing * minSpacing + let bestCandidate: { x: number; y: number } | null = null + let maxMinDistSq = -1 + + for (const cand of candidates) { + if (isBlocked && isBlocked(cand.x, cand.y)) { + continue + } + + let nearestItemDistSq = Infinity + let hasConflict = false + + for (const item of existingItems) { + const dx = item.x - cand.x + const dy = item.y - cand.y + const dSq = dx * dx + dy * dy + if (dSq < nearestItemDistSq) { + nearestItemDistSq = dSq + } + if (dSq < minSpacingSq) { + hasConflict = true + break + } + } + + if (!hasConflict) { + return { x: cand.x, y: cand.y } + } + + if (nearestItemDistSq > maxMinDistSq) { + maxMinDistSq = nearestItemDistSq + bestCandidate = cand + } + } + + if (bestCandidate !== null) { + return { x: bestCandidate.x, y: bestCandidate.y } + } + + return { x: originX, y: originY } +} + /** * GroundItemManager maintains all dropped items and gold currently lying on the ground. */ diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 70fcf80..73ace3e 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -823,7 +823,57 @@ export class SpriteRenderer { const drawW = options.width ?? frame.width const drawH = options.height ?? frame.height this.quad( - x, y, x + drawW, y + drawH, + x, y, + x + drawW, y, + x, y + drawH, + x + drawW, y + drawH, + flipped ? u1 : u0, v0, + flipped ? u0 : u1, v1, + tint === undefined ? 1 : tint[0], + tint === undefined ? 1 : tint[1], + tint === undefined ? 1 : tint[2], + tint === undefined ? 1 : tint[3], + unit, + paletteRow, + ) + } + + /** + * Queue one arbitrarily projected atlas quad with four explicit corners. + * + * Corner order matches index winding: + * - (x0, y0): Top-Left + * - (x1, y1): Top-Right + * - (x2, y2): Bottom-Left + * - (x3, y3): Bottom-Right + * + * Allows authentic isometric perspective ground projection, sheared decals, + * and affine sprite tilting. + */ + drawQuad( + frame: AtlasFrame, + x0: number, y0: number, + x1: number, y1: number, + x2: number, y2: number, + x3: number, y3: number, + options: DrawOptions = {}, + ): void { + if (this.disposed) return + const blendMode = options.blendMode ?? 'normal' + if (blendMode !== this.currentBlendMode) { + this.setBlendMode(blendMode) + } + const page = options.atlas ?? this.defaultAtlas + const unit = this.unitFor(page) + const u0 = frame.x / page.width + const v0 = frame.y / page.height + const u1 = (frame.x + frame.width) / page.width + const v1 = (frame.y + frame.height) / page.height + const tint = options.tint + const flipped = options.flipX === true + const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 + this.quad( + x0, y0, x1, y1, x2, y2, x3, y3, flipped ? u1 : u0, v0, flipped ? u0 : u1, v1, tint === undefined ? 1 : tint[0], @@ -859,7 +909,40 @@ export class SpriteRenderer { this.setBlendMode(blendMode) } const unit = this.unitFor(this.whiteAtlas) - this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color[0], color[1], color[2], color[3], unit, -1) + this.quad( + x, y, + x + width, y, + x, y + height, + x + width, y + height, + 0.5, 0.5, 0.5, 0.5, + color[0], color[1], color[2], color[3], + unit, -1, + ) + } + + /** + * Queue one arbitrarily projected solid color quad with four explicit corners. + */ + drawSolidQuad( + x0: number, y0: number, + x1: number, y1: number, + x2: number, y2: number, + x3: number, y3: number, + color: readonly [number, number, number, number], + options: { blendMode?: 'normal' | 'additive' } = {}, + ): void { + if (this.disposed) return + const blendMode = options.blendMode ?? 'normal' + if (blendMode !== this.currentBlendMode) { + this.setBlendMode(blendMode) + } + const unit = this.unitFor(this.whiteAtlas) + this.quad( + x0, y0, x1, y1, x2, y2, x3, y3, + 0.5, 0.5, 0.5, 0.5, + color[0], color[1], color[2], color[3], + unit, -1, + ) } /** @@ -887,9 +970,18 @@ export class SpriteRenderer { /** * Append one quad's four vertices. + * + * Corners: + * - 0: Top-Left (x0, y0, u0, v0) + * - 1: Top-Right (x1, y1, u1, v0) + * - 2: Bottom-Left (x2, y2, u0, v1) + * - 3: Bottom-Right (x3, y3, u1, v1) */ private quad( - x0: number, y0: number, x1: number, y1: number, + x0: number, y0: number, + x1: number, y1: number, + x2: number, y2: number, + x3: number, y3: number, u0: number, v0: number, u1: number, v1: number, r: number, g: number, b: number, a: number, unit: number, @@ -904,15 +996,15 @@ export class SpriteRenderer { v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow at += FLOATS_PER_VERTEX // Corner 1: top-right. - v[at] = x1; v[at + 1] = y0; v[at + 2] = u1; v[at + 3] = v0 + v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v0 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow at += FLOATS_PER_VERTEX // Corner 2: bottom-left. - v[at] = x0; v[at + 1] = y1; v[at + 2] = u0; v[at + 3] = v1 + v[at] = x2; v[at + 1] = y2; v[at + 2] = u0; v[at + 3] = v1 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow at += FLOATS_PER_VERTEX // Corner 3: bottom-right. - v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v1 + v[at] = x3; v[at + 1] = y3; v[at + 2] = u1; v[at + 3] = v1 v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow this.quadCount += 1 this.frameQuads += 1 diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 9aefc6a..8ed3b86 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -3925,7 +3925,7 @@ export function drawGroundItem( 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).draw === 'function') + 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 @@ -3937,31 +3937,65 @@ export function drawGroundItem( const baseW = Math.max(12, Math.round(sr.w * scale)) const baseH = Math.max(12, Math.round(sr.h * scale)) + // 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) - renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, 4, [0, 0, 0, shadowAlpha]) + 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 width modulation during vertical bounce + // 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 spinScale = Math.abs(Math.cos(elapsed * 0.018)) + 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, } - renderer.draw(frame, item.x - renderW / 2, baseY - baseH, { - atlas: itemsAtlas, - width: renderW, - height: baseH, - }) + + 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 diff --git a/tests/ground-items-drop.test.ts b/tests/ground-items-drop.test.ts index 9f10fc7..2a1b06b 100644 --- a/tests/ground-items-drop.test.ts +++ b/tests/ground-items-drop.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest' import { findSafeDropPosition, + findIsometricDropPosition, isGoldItem, resolveGroundItemMetadata, calculateBounceHeight, @@ -363,4 +364,84 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => { expect(engine.groundItems.count).toBe(1) }) }) + + describe('6. findIsometricDropPosition & Ground Diamond Dispersal (D2 1.13c Ground Truth)', () => { + it('returns exact origin when ground has no existing items', () => { + const pos = findIsometricDropPosition(500, 300, []) + expect(pos.x).toBe(500) + expect(pos.y).toBe(300) + }) + + it('disperses multiple items dropped at identical epicenter onto unoccupied isometric grid slots', () => { + const items: { x: number; y: number }[] = [] + for (let i = 0; i < 5; i++) { + const pos = findIsometricDropPosition(400, 400, items) + items.push(pos) + } + + expect(items.length).toBe(5) + expect(items[0]).toEqual({ x: 400, y: 400 }) + + // Verify no two items share identical coordinates or are closer than minSpacing + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const dist = Math.hypot(items[i]!.x - items[j]!.x, items[i]!.y - items[j]!.y) + expect(dist).toBeGreaterThanOrEqual(20) + } + } + }) + + it('avoids colliding with obstacles when isBlocked is provided', () => { + const blockedCells = new Set([ + // Block (400, 424) which is the nearest (1, 1) candidate + '400,424', + ]) + const isBlocked = (x: number, y: number) => blockedCells.has(`${x},${y}`) + + const existing = [{ x: 400, y: 400 }] + const pos = findIsometricDropPosition(400, 400, existing, isBlocked) + expect(pos.x === 400 && pos.y === 424).toBe(false) + expect(isBlocked(pos.x, pos.y)).toBe(false) + expect(Math.hypot(pos.x - 400, pos.y - 400)).toBeGreaterThanOrEqual(20) + }) + + it('GameEngine.dropItem repeatedly spreads items out across isometric lattice without stacking', () => { + const engine = new GameEngine( + { widthPx: 1000, heightPx: 1000, overlap: () => 0 }, + { + spawn: { x: 100, y: 100 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: DEMO_QUESTS, + combatOptions: { + playerSpeed: 4, + playerReach: 50, + playerCooldownTicks: 10, + playerDamage: 5, + playerManaPerAttack: 1, + respawnTicks: 100, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + }, + ) + + const d1 = engine.dropItem({ name: 'Item 1' } as any, 200, 200) + const d2 = engine.dropItem({ name: 'Item 2' } as any, 200, 200) + const d3 = engine.dropItem({ name: 'Item 3' } as any, 200, 200) + + expect(d1.x).toBe(200) + expect(d1.y).toBe(200) + // d2 and d3 must be dispersed and not stacked at (200, 200) + expect(d2.x !== 200 || d2.y !== 200).toBe(true) + expect(d3.x !== 200 || d3.y !== 200).toBe(true) + expect(Math.hypot(d2.x - d1.x, d2.y - d1.y)).toBeGreaterThanOrEqual(20) + expect(Math.hypot(d3.x - d1.x, d3.y - d1.y)).toBeGreaterThanOrEqual(20) + expect(Math.hypot(d3.x - d2.x, d3.y - d2.y)).toBeGreaterThanOrEqual(20) + }) + }) }) diff --git a/tests/ground-items-render.test.ts b/tests/ground-items-render.test.ts index 615d9e0..f3bb71c 100644 --- a/tests/ground-items-render.test.ts +++ b/tests/ground-items-render.test.ts @@ -318,5 +318,52 @@ describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', () expect(blueBody).toBeUndefined() }) }) + + describe('6. Authentic Ground Slanted Quad Projection (D2 1.13c Ground Truth)', () => { + it('calls renderer.drawQuad with 2:1 isometric ground foreshortened height and shear angle', () => { + const drawnQuads: any[] = [] + const renderer = { + drawSolid() {}, + drawQuad(frame: any, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, options: any) { + drawnQuads.push({ frame, x0, y0, x1, y1, x2, y2, x3, y3, options }) + }, + } as unknown as SpriteRenderer + + const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any + const groundSword: GroundItemEntity = { + id: 'ground_sword', + item: { name: 'Broad Sword', code: 'bsd' }, + name: 'Broad Sword', + nameZh: '阔剑', + quality: 'normal', + 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) + + expect(drawnQuads.length).toBe(1) + const q = drawnQuads[0]! + // Bottom edge rests at baseY (y = 250) + expect(q.y2).toBe(250) + expect(q.y3).toBe(250) + // Top edge is above ground (y0 < 250) + expect(q.y0).toBeLessThan(250) + expect(q.y1).toBeLessThan(250) + // Top edge is sheared horizontally along 2:1 isometric diamond (x0 < x2) + expect(q.x0).toBeLessThan(q.x2) + expect(q.x1).toBeLessThan(q.x3) + // Slanted parallelogram: width of top edge equals width of bottom edge + expect(q.x1 - q.x0).toBe(q.x3 - q.x2) + }) + }) })