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

380 lines
13 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 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
return { renderer, drawnQuads, drawnSprites }
}
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).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 medium gold pile (50 gold) using authentic flpgld_1 sprite and ground shadow', () => {
const { renderer, drawnQuads, drawnSprites } = 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)
// Exactly 1 ground contact shadow quad and 1 authentic flippy sprite
expect(drawnQuads.length).toBe(1)
expect(drawnSprites.length).toBe(1)
const shadow = drawnQuads[0]!
expect(shadow.y).toBe(150 - 2)
expect(shadow.color[3]).toBeLessThan(0.5)
const sprite = drawnSprites[0]!
expect(sprite.options.width).toBe(26)
expect(sprite.options.height).toBe(13)
})
it('scales gold pile sprite dimensions for large piles (>= 500 gold -> flpgld_2 32x20)', () => {
const { renderer, drawnSprites } = 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)
expect(drawnSprites.length).toBe(1)
const sprite = drawnSprites[0]!
expect(sprite.options.width).toBe(32)
expect(sprite.options.height).toBe(20)
})
})
describe('3. Equipment Item Rendering with Authentic Flippy Sprite & Bounce Offset', () => {
it('draws equipment item with ground shadow and authentic flippy sprite (zero procedural border quads)', () => {
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
const uniqueSword: GroundItemEntity = {
id: 'unique_sword',
item: { name: 'The Patriarch', code: 'gis' },
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)
// Only 1 shadow quad (outside sparkle window) + 1 flippy sprite
expect(drawnQuads.length).toBe(1)
expect(drawnSprites.length).toBe(1)
expect(drawnQuads[0]!.y).toBe(350 - 2)
expect(drawnSprites[0]!.options.width).toBeGreaterThan(0)
expect(drawnSprites[0]!.options.height).toBeGreaterThan(0)
})
it('translates vertical sprite rendering position upwards during parabolic bounce', () => {
const { renderer: rGround, drawnQuads: quadsGround, drawnSprites: spritesGround } = createMockRenderer()
const { renderer: rAir, drawnQuads: quadsAir, drawnSprites: spritesAir } = createMockRenderer()
const restingItem: GroundItemEntity = {
id: 'item_rest',
item: { code: 'hlm', name: 'Helm' },
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 groundSprite = spritesGround[0]!
const airSprite = spritesAir[0]!
// Shadow stays anchored on ground plane (item.y - 2 = 198)
expect(quadsGround[0]!.y).toBe(198)
expect(quadsAir[0]!.y).toBe(198)
// Air sprite y should be exactly ground sprite y - 30
expect(airSprite.y).toBe(groundSprite.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, drawnSprites } = createMockRenderer()
const gem: GroundItemEntity = {
id: 'gem_ruby',
item: { code: 'gcr', name: 'Perfect Ruby' },
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: shadow (1) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 5
expect(drawnSprites.length).toBe(1)
expect(drawnQuads.length).toBe(5)
const horizontalRay = drawnQuads[1]!
const verticalRay = drawnQuads[2]!
const core = drawnQuads[3]!
const aura = drawnQuads[4]!
// 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)
// Aura matches rare quality color
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.rare![0], 2)
// 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, drawnSprites } = createMockRenderer()
const item: GroundItemEntity = {
id: 'item_dormant',
item: { code: 'rin', name: 'Ring' },
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 ground shadow quad (1), no sparkle quads
expect(drawnSprites.length).toBe(1)
expect(drawnQuads.length).toBe(1)
})
})
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)
})
})
})