diablo2-web/tests/ground-items-render.test.ts

370 lines
12 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Ground Item Rendering & Glint Sparkle Test Suite
*
* Requirements (Issue #389):
* 1. Isometric depth sorting & entity queue integration.
* 2. Adaptive item & gold icon silhouettes with bottom-center anchor and shadows.
* 3. Authentic quality colors: normal, magic, rare, set, unique, craft, rune, gold.
* 4. Periodic 4-point star glint & sparkle animation with sine easing.
* 5. Parabolic bounce vertical translation.
*/
import { describe, expect, it } from 'vitest'
import {
drawGroundItem,
GROUND_ITEM_QUALITY_COLORS,
} from '../src/scene/act-scene.ts'
import type { GroundItemEntity } from '../src/game/ground-items.ts'
import type { SpriteRenderer } from '../src/render/renderer.ts'
describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', () => {
const createMockRenderer = () => {
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const renderer = {
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
drawnQuads.push({ x, y, w, h, color })
},
} as unknown as SpriteRenderer
return { renderer, drawnQuads }
}
describe('1. GROUND_ITEM_QUALITY_COLORS Palette Parity', () => {
it('provides distinct, authentic RGBA color tuples for all D2 quality tiers', () => {
const requiredQualities = ['normal', 'low', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune', 'gold']
for (const q of requiredQualities) {
const col = GROUND_ITEM_QUALITY_COLORS[q]
expect(col).toBeDefined()
expect(col!.length).toBe(4)
for (const c of col!) {
expect(c).toBeGreaterThanOrEqual(0)
expect(c).toBeLessThanOrEqual(1)
}
}
// Magic is blue
expect(GROUND_ITEM_QUALITY_COLORS.magic![2]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.magic![0])
// Rare is yellow (red & green high, blue low)
expect(GROUND_ITEM_QUALITY_COLORS.rare![0]).toBeGreaterThan(0.8)
expect(GROUND_ITEM_QUALITY_COLORS.rare![1]).toBeGreaterThan(0.8)
// Set is green
expect(GROUND_ITEM_QUALITY_COLORS.set![1]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.set![0])
// Unique is gold/tan
expect(GROUND_ITEM_QUALITY_COLORS.unique![0]).toBeGreaterThan(0.7)
})
})
describe('2. Gold Pile Rendering & Scaling', () => {
it('draws small gold pile (< 100 gold) with shadow, body, and coin rim', () => {
const { renderer, drawnQuads } = createMockRenderer()
const goldItem: GroundItemEntity = {
id: 'gold_small',
item: { isGold: true },
name: '50 Gold',
nameZh: '50 金币',
quality: 'normal',
isGold: true,
amount: 50,
invWidth: 1,
invHeight: 1,
dropTime: 0,
x: 200,
y: 150,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
// Use a timestamp outside the sparkle window (e.g. 1000ms in a 2000ms cycle)
drawGroundItem(renderer, goldItem, 1000)
// At least 3 quads: shadow, body, highlight rim
expect(drawnQuads.length).toBeGreaterThanOrEqual(3)
const shadow = drawnQuads[0]!
expect(shadow.w).toBe(12) // w + 2 = 10 + 2 = 12
expect(shadow.color[3]).toBeLessThan(0.5) // shadow alpha
const body = drawnQuads[1]!
expect(body.w).toBe(10)
expect(body.h).toBe(8)
// Gold color
expect(body.color[0]).toBeGreaterThan(0.8)
expect(body.color[1]).toBeGreaterThan(0.6)
})
it('scales gold pile dimensions for large piles (>= 5000 gold)', () => {
const { renderer, drawnQuads } = createMockRenderer()
const largeGold: GroundItemEntity = {
id: 'gold_large',
item: { isGold: true },
name: '10000 Gold',
nameZh: '10000 金币',
quality: 'normal',
isGold: true,
amount: 10000,
invWidth: 1,
invHeight: 1,
dropTime: 0,
x: 300,
y: 250,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, largeGold, 1000)
const body = drawnQuads[1]!
expect(body.w).toBe(18)
expect(body.h).toBe(13)
})
})
describe('3. Equipment Item Rendering with Quality Framing & Bounce Offset', () => {
it('draws equipment item with shadow, quality body, and 4 border edges', () => {
const { renderer, drawnQuads } = createMockRenderer()
const uniqueSword: GroundItemEntity = {
id: 'unique_sword',
item: { name: 'The Patriarch' },
name: 'The Patriarch',
nameZh: '族长',
quality: 'unique',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 3,
dropTime: 0,
x: 400,
y: 350,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, uniqueSword, 1000)
// Shadow + body + 4 border edges = 6 quads
expect(drawnQuads.length).toBeGreaterThanOrEqual(6)
const body = drawnQuads[1]!
const topBorder = drawnQuads[2]!
// Unique color tint
const uniqueColor = GROUND_ITEM_QUALITY_COLORS.unique!
expect(topBorder.color[0]).toBeCloseTo(uniqueColor[0], 2)
expect(topBorder.color[1]).toBeCloseTo(uniqueColor[1], 2)
})
it('translates vertical rendering position upwards during parabolic bounce', () => {
const { renderer: rGround, drawnQuads: quadsGround } = createMockRenderer()
const { renderer: rAir, drawnQuads: quadsAir } = createMockRenderer()
const restingItem: GroundItemEntity = {
id: 'item_rest',
item: {},
name: 'Helm',
nameZh: '头盔',
quality: 'magic',
isGold: false,
amount: 1,
invWidth: 2,
invHeight: 2,
dropTime: 0,
x: 100,
y: 200,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
const bouncingItem: GroundItemEntity = {
...restingItem,
id: 'item_bounce',
bounceState: {
startTime: 1000,
durationMs: 400,
peakHeightPx: 30,
},
}
// Draw resting item at t = 1200
drawGroundItem(rGround, restingItem, 1200)
// Draw bouncing item at apex (t = 1200, apex = 30px)
drawGroundItem(rAir, bouncingItem, 1200)
const groundBody = quadsGround[1]!
const airBody = quadsAir[1]!
// air body y should be exactly ground body y - 30
expect(airBody.y).toBe(groundBody.y - 30)
})
})
describe('4. Glint & Sparkle 4-Point Star Animation', () => {
it('spawns 4-point star rays, core, and quality aura during the active flash window', () => {
const { renderer, drawnQuads } = createMockRenderer()
const gem: GroundItemEntity = {
id: 'gem_ruby',
item: {},
name: 'Perfect Ruby',
nameZh: '完美红宝石',
quality: 'rare',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 1,
dropTime: 0,
x: 500,
y: 400,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
// At t = 160ms (middle of flash window 0..320ms, p = 0.5, intensity = 1.0)
drawGroundItem(renderer, gem, 160)
// Total quads: base item (6) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 10
expect(drawnQuads.length).toBe(10)
const horizontalRay = drawnQuads[6]!
const verticalRay = drawnQuads[7]!
const core = drawnQuads[8]!
const aura = drawnQuads[9]!
// Brilliant core color (near-white)
expect(core.color[0]).toBeGreaterThan(0.9)
expect(core.color[1]).toBeGreaterThan(0.9)
expect(core.color[3]).toBeCloseTo(1.0, 1)
// Rays are centered on sparkle point
expect(horizontalRay.h).toBe(2)
expect(verticalRay.w).toBe(2)
expect(horizontalRay.w).toBeGreaterThan(10)
expect(verticalRay.h).toBeGreaterThan(10)
})
it('suppresses sparkle rendering during the dormant period of the cycle', () => {
const { renderer, drawnQuads } = createMockRenderer()
const item: GroundItemEntity = {
id: 'item_dormant',
item: {},
name: 'Ring',
nameZh: '戒指',
quality: 'unique',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 1,
dropTime: 0,
x: 200,
y: 200,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
// At t = 1000ms (far outside 0..320ms window)
drawGroundItem(renderer, item, 1000)
// Only base item quads (6), no sparkle quads
expect(drawnQuads.length).toBe(6)
})
})
describe('5. Authentic Ground Item Sprite Parity (Anti-Blue-Box & Clean Pickup)', () => {
it('renders authentic item sprite using itemsAtlas instead of solid color box', () => {
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 WebGLTexture, width: 1024, height: 1024 } as any
const magicSword: GroundItemEntity = {
id: 'magic_sword',
item: { name: 'Short Sword', code: 'ssd' },
name: 'Short Sword',
nameZh: '短剑',
quality: 'magic',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 3,
dropTime: 0,
x: 100,
y: 200,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, magicSword, 1000, mockAtlas)
// Verified: Sprite is drawn using itemsAtlas
expect(drawnSprites.length).toBe(1)
const sprite = drawnSprites[0]!
expect(sprite.options.atlas).toBe(mockAtlas)
expect(sprite.options.width).toBeGreaterThan(0)
expect(sprite.options.height).toBeGreaterThan(0)
// Drop shadow is drawn on ground
expect(drawnQuads.some(q => q.color[3] < 0.5 && q.y >= 198)).toBe(true)
// Crucial: No opaque blue body box drawn in quads
const blueBody = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
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)
})
})
})