fix(render): isometric grid drop dispersal and authentic ground slanted sprite perspective
This commit is contained in:
parent
513ad145e8
commit
263286c0c2
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue